diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs
index fdbec59bfc..3397ada7a5 100644
--- a/apps/vectorcraft/src/main.rs
+++ b/apps/vectorcraft/src/main.rs
@@ -373,11 +373,14 @@ fn main() -> std::process::ExitCode {
// Display Server › X11 opens the window through XWayland instead, where file drops work (#1151),
// and the other choices do so when a pen is attached, where the pen draws (#2028, #4967).
#[cfg(target_os = "linux")]
+ let mut forced_to_x11 = false;
+ #[cfg(target_os = "linux")]
let options = {
let mut options = options;
let pref = saved.as_ref().and_then(|ui| ui.engine_prefs.get("linuxDisplayServer")).and_then(serde_json::Value::as_str);
if xwayland::wanted(pref, |name| std::env::var_os(name), xwayland::pen_attached) {
if xwayland::x11_libraries_found() {
+ forced_to_x11 = true;
if pref != Some("x11") {
log::info!("pen found: opening the window through XWayland, where the pen works (VECTORCRAFT_NATIVE_WAYLAND=1 keeps Wayland)");
} else {
@@ -393,6 +396,11 @@ fn main() -> std::process::ExitCode {
}
options
};
+ #[cfg(not(target_os = "linux"))]
+ let forced_to_x11 = false;
+ // Preferences › User Interface notes under System Title Bar when the desktop draws the frame
+ // with it on or off.
+ let desktop_keeps_frame = titlebar::desktop_keeps_frame(forced_to_x11);
// With Windows Ink, winit reports a pen as a touch and drops its barrel button and which end
// is down; the message hook notes each pointer contact and whether it starts with the barrel
// button held, and writes the pen's end into the pen feed (`windows_stylus`). It also notes the
@@ -595,6 +603,7 @@ fn main() -> std::process::ExitCode {
app.custom_titlebar = custom_titlebar;
app.caption_layout = caption_layout;
app.desktop_frame = !custom_titlebar;
+ app.desktop_keeps_frame = desktop_keeps_frame;
if let Some(port) = control_port {
let rx = control_server::start(port, cc.egui_ctx.clone());
app = app.with_control(rx).with_automation_roots(roots.clone());
diff --git a/apps/vectorcraft/src/tests.rs b/apps/vectorcraft/src/tests.rs
index 99ca880bc9..1994569c0c 100644
--- a/apps/vectorcraft/src/tests.rs
+++ b/apps/vectorcraft/src/tests.rs
@@ -346,6 +346,34 @@ fn the_titlebar_variable_decides_the_frame_before_the_window_opens() {
}
}
+/// Preferences says under System Title Bar that the desktop draws the frame with it on or off
+/// exactly where the start frame without VectorCraft Title Bar on Every Desktop is the desktop's
+/// with System Title Bar off as well as on and `VECTORCRAFT_TITLEBAR` is unset: Linux and the BSDs
+/// only.
+#[test]
+fn the_desktop_keeps_the_frame_where_system_title_bar_does_not_change_it() {
+ use crate::titlebar::desktop_keeps_frame_from;
+ use craft_window::{Frame, Layout};
+ let linux = cfg!(not(any(target_os = "macos", target_os = "windows")));
+ for probed in [Frame::Desktop, Frame::App(Layout::default())] {
+ for forced_to_x11 in [false, true] {
+ let frame = super::start_frame(probed.clone(), None, forced_to_x11, false);
+ let either_way = !super::custom_titlebar_from(&frame, false) && !super::custom_titlebar_from(&frame, true);
+ assert_eq!(desktop_keeps_frame_from(&probed, None, forced_to_x11), linux && either_way, "{probed:?} {forced_to_x11}");
+ }
+ }
+ // KDE, Sway and X11, and GNOME through XWayland, keep the desktop's frame; GNOME on Wayland does not.
+ assert_eq!(desktop_keeps_frame_from(&Frame::Desktop, None, false), linux);
+ assert_eq!(desktop_keeps_frame_from(&Frame::App(Layout::default()), None, true), linux);
+ assert!(!desktop_keeps_frame_from(&Frame::App(Layout::default()), None, false));
+ // While `VECTORCRAFT_TITLEBAR` chooses the frame, System Title Bar does not apply and there is no note.
+ for (frame, overridden) in [(Frame::App(Layout::default()), Some(true)), (Frame::Desktop, Some(false))] {
+ for forced_to_x11 in [false, true] {
+ assert!(!desktop_keeps_frame_from(&frame, overridden, forced_to_x11), "{overridden:?} {forced_to_x11}");
+ }
+ }
+}
+
/// The file extensions the macOS bundle declares: its document types and its own exported type.
fn plist_extensions(plist: &str) -> Vec<&str> {
["CFBundleTypeExtensions ", "public.filename-extension "]
diff --git a/apps/vectorcraft/src/titlebar.rs b/apps/vectorcraft/src/titlebar.rs
index 88dce26897..cb98fa2648 100644
--- a/apps/vectorcraft/src/titlebar.rs
+++ b/apps/vectorcraft/src/titlebar.rs
@@ -191,6 +191,25 @@ pub(crate) fn custom_titlebar(saved: Option<&vectorcraft_ui_egui::UiState>) -> (
(custom_titlebar_from(&frame, opted_into_system), caption_layout_from(&frame))
}
+/// Whether the desktop would draw this window's frame with System Title Bar on or off while
+/// VectorCraft Title Bar on Every Desktop is off: `VECTORCRAFT_TITLEBAR` is not set to `app`,
+/// `system` or `desktop` (`overridden` is `None`), and the probe chose
+/// [`craft_window::Frame::Desktop`] or the window opens through XWayland (`forced_to_x11`)
+/// ([`app_draws_frame`]). The saved VectorCraft Title Bar on Every Desktop is left out, so the value
+/// stays `true` on such a desktop while that preference gives the app the frame. Always `false` on
+/// macOS and Windows, where System Title Bar decides the frame by itself.
+pub(crate) fn desktop_keeps_frame_from(probed: &craft_window::Frame, overridden: Option, forced_to_x11: bool) -> bool {
+ let probed_app = matches!(probed, craft_window::Frame::App(_));
+ cfg!(not(any(target_os = "macos", target_os = "windows"))) && overridden.is_none() && !app_draws_frame(probed_app, None, forced_to_x11, false)
+}
+
+/// [`desktop_keeps_frame_from`] for this start's probe, given whether the launch path opens the
+/// window through XWayland.
+pub(crate) fn desktop_keeps_frame(forced_to_x11: bool) -> bool {
+ let choice = probed_choice();
+ desktop_keeps_frame_from(choice.frame(), choice.overridden(), forced_to_x11)
+}
+
pub(crate) fn native_options(custom_titlebar: bool) -> eframe::NativeOptions {
eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
diff --git a/crates/brush/src/colorize.rs b/crates/brush/src/colorize.rs
index 2cf9f43c97..00edeb3113 100644
--- a/crates/brush/src/colorize.rs
+++ b/crates/brush/src/colorize.rs
@@ -114,51 +114,9 @@ fn strip_brushes(n: &mut Node) {
}
}
-/// Mix every solid paint under `n` towards `color` by `amount` (0..1) — the Symbol Stainer.
-pub fn tint_node(n: &mut Node, color: &Color, amount: f32) {
- let amount = amount.clamp(0.0, 1.0);
- for it in &mut n.appearance.items {
- let p = match it {
- AppearanceItem::Fill(f) => &mut f.paint,
- AppearanceItem::Stroke(s) => &mut s.paint,
- };
- if let Paint::Solid { color: c, .. } = p {
- *p = Paint::solid(c.lerp(color, amount));
- }
- }
- if let vectorcraft_doc::NodeKind::Text(t) = &mut n.kind {
- for r in &mut t.runs {
- for p in [&mut r.style.fill, &mut r.style.stroke] {
- if let Paint::Solid { color: c, .. } = p {
- *p = Paint::solid(c.lerp(color, amount));
- }
- }
- }
- }
- if let Some(ch) = n.children_mut() {
- for c in ch.iter_mut() {
- tint_node(Arc::make_mut(c), color, amount);
- }
- }
-}
-
-/// The stain instance `inst` puts on its symbol's art: its visible fill colour and that fill's
-/// opacity (how far the art mixes towards it). `None` for an unstained instance, including one
-/// whose fill has an opacity of 0, so its art keeps its paints and their swatch links.
-pub fn stain(inst: &Node) -> Option<(Color, f32)> {
- let f = inst.appearance.fill().filter(|f| f.visible && f.opacity > 0.0)?;
- Some((f.paint.color()?, f.opacity))
-}
-
-/// A symbol's art for instance `inst`: with the instance's own appearance changes to it (a dynamic
-/// symbol's, [`vectorcraft_doc::symbol_overrides`]), stained by the instance's fill ([`stain`]).
-pub fn instance_art(art: &Node, inst: &Node) -> Node {
- let mut a = vectorcraft_doc::symbol_overrides::dynamic_art(art, inst);
- if let Some((c, amount)) = stain(inst) {
- tint_node(&mut a, &c, amount);
- }
- a
-}
+/// The Symbol Stainer's mix, an instance's stain and its stained art, from
+/// [`vectorcraft_doc::symbol_overrides`], which the effects that reshape an instance also read.
+pub use vectorcraft_doc::symbol_overrides::{instance_art, stain, tint_node};
/// The solid colours of the fills and strokes under `nodes`, most used first (then in the order
/// they first appear): the colours Brush Options offers as Hue Shift's key colour, the first being
diff --git a/crates/cad/src/scene.rs b/crates/cad/src/scene.rs
index 4860e4989a..ea3c962053 100644
--- a/crates/cad/src/scene.rs
+++ b/crates/cad/src/scene.rs
@@ -683,7 +683,7 @@ impl<'a> Scene<'a> {
{
// Symbols holding instances of symbols are written in full for each instance,
// empty or not: every instance's art counts against one budget for the export.
- let art = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n);
+ let art = vectorcraft_doc::symbol_overrides::instance_art(&sym.art, n);
let count = art.count_with_masks() as u64;
// Counted as `place_symbol_art_unclipped` places it: no clip groups.
let most = doc.symbol_slice_map(symbol, *xf).map_or(count, |m| m.most_placed_unclipped(&art, doc));
diff --git a/crates/cad/src/tests.rs b/crates/cad/src/tests.rs
index 96c037a4a7..a14af8d929 100644
--- a/crates/cad/src/tests.rs
+++ b/crates/cad/src/tests.rs
@@ -1395,6 +1395,21 @@ fn crop_keeps_an_instance_whose_art_reaches_the_region() {
assert!(!kinds(&export(&d, &o).unwrap()).is_empty());
}
+/// An instance stained blue by its fill writes its symbol's red art in blue, as the canvas draws it.
+#[test]
+fn a_stained_instance_is_written_in_its_stained_color() {
+ let mut d = doc_with(|d| {
+ let mut n = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Mark".into(), xf: Affine::translate((30.0, 30.0)) });
+ n.appearance = Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), Paint::None, 0.0).into();
+ vec![n]
+ });
+ let art = path(&mut d, shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 40.0)), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
+ d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Mark".into(), art: Arc::new(art) });
+ let out = export(&d, &opts(&d)).unwrap();
+ let colors: Vec> = entities(&out).iter().map(|e| get(e, 420).map(str::to_string)).collect();
+ assert!(!colors.is_empty() && colors.iter().all(|c| c.as_deref() == Some("255")), "{colors:?}");
+}
+
#[test]
fn symbols_fanning_out_into_empty_symbols_stop_at_the_symbol_budget() {
// Five levels of 100 instances of the level below, ending in an empty symbol: 10^10 empty
diff --git a/crates/doc/src/orient.rs b/crates/doc/src/orient.rs
index e524d6ca37..a2ff76d83f 100644
--- a/crates/doc/src/orient.rs
+++ b/crates/doc/src/orient.rs
@@ -3,6 +3,8 @@
//! square to it: scaling by a side handle stretches it along its own width or height. Path
//! geometry is still baked; Object ▸ Transform ▸ Reset Bounding Box sets the angle back to 0.
+use std::sync::Arc;
+
use vectorcraft_geom::{Affine, Point, Rect, Vec2, normalize_deg, union_opt};
use crate::{Document, Node, NodeId, NodeKind, SymbolBoxes};
@@ -84,7 +86,35 @@ pub fn transformed_angle(angle: f64, a: Affine) -> f64 {
}
}
+/// The turn (counter-clockwise degrees) from the boxes of art placed by a symbol instance's
+/// transform `xf` to the instance's own box at `angle`: 0 while the box turns with the transform,
+/// otherwise the change Reset Bounding Box made to it. Under a mirroring `xf` the instance's box can
+/// stand 180° from the one `xf` gives in one step ([`transformed_angle`] keeps the angle nearer the
+/// one before at each step); the turn is then 180°, so the art's boxes match the instance's.
+pub fn placed_turn(angle: f64, xf: Affine) -> f64 {
+ let turn = normalize_deg(angle - transformed_angle(0.0, xf));
+ if turn.is_finite() && turn.abs() > EPS_DEG { turn } else { 0.0 }
+}
+
impl Node {
+ /// Turns the box of this object, and of every object that turns with it ([`Node::transform`]:
+ /// its children and the art of a linked opacity mask), by `by` degrees counter-clockwise.
+ /// Layers have no box.
+ pub fn turn_boxes(&mut self, by: f64) {
+ if !self.is_layer() {
+ let a = normalize_deg(self.bbox_angle + by);
+ self.bbox_angle = if a.abs() <= EPS_DEG { 0.0 } else { a };
+ }
+ for c in self.children_mut().into_iter().flatten() {
+ Arc::make_mut(c).turn_boxes(by);
+ }
+ if let Some(m) = self.extras_if_any().and_then(|e| e.mask.as_mut())
+ && m.linked
+ {
+ Arc::make_mut(&mut m.art).turn_boxes(by);
+ }
+ }
+
/// Bounds of this object as `a` would place it (geometric, or `visual` with its strokes),
/// without changing it, symbol instances measured by their art from `symbols`
/// ([`Node::geometric_bounds_with`]).
@@ -185,6 +215,44 @@ mod tests {
assert_eq!(transformed_angle(30.0, Affine::scale(0.0)), 30.0);
}
+ #[test]
+ fn placed_turn_is_what_reset_bounding_box_took_off_the_instance() {
+ let xf = Affine::translate((40.0, 10.0)) * rot(20.0) * Affine::scale_non_uniform(2.0, 0.5);
+ // The box turns with the transform: no turn.
+ assert_eq!(placed_turn(transformed_angle(0.0, xf), xf), 0.0);
+ assert_eq!(placed_turn(0.0, Affine::scale(3.0)), 0.0);
+ // Reset to 0: the placed art turns back by the 20°.
+ assert!(close(placed_turn(0.0, xf), -20.0));
+ // Mirrored: turned 170° and then flipped, the instance's box stands 180° from the box the
+ // whole transform gives in one step, and the art turns the half turn to match it.
+ let flip = Affine::scale_non_uniform(1.0, -1.0);
+ let flipped = flip * rot(170.0);
+ let stepwise = transformed_angle(transformed_angle(0.0, rot(170.0)), flip);
+ assert!(close(stepwise, -170.0) && close(transformed_angle(0.0, flipped), 10.0));
+ assert!(close(placed_turn(stepwise, flipped).abs(), 180.0));
+ assert!(close(normalize_deg(placed_turn(0.0, flipped) + transformed_angle(0.0, flipped)), 0.0));
+ // Degenerate and non-finite transforms turn nothing.
+ assert_eq!(placed_turn(0.0, Affine::scale(0.0)), 0.0);
+ assert_eq!(placed_turn(f64::NAN, xf), 0.0);
+ }
+
+ #[test]
+ fn turn_boxes_turns_children_and_linked_mask_art_but_not_layers() {
+ let mut d = Document::new(500.0, 500.0);
+ let a = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());
+ let mut g = Node::group(d.alloc_id(), vec![Arc::new(a.clone())]);
+ g.bbox_angle = 170.0;
+ g.extras_mut().mask = Some(Box::new(crate::OpacityMask::new(a.clone(), true)));
+ let mut layer = (*d.layers[0]).clone();
+ layer.children_mut().unwrap().push(Arc::new(g));
+ layer.turn_boxes(20.0);
+ assert_eq!(layer.bbox_angle, 0.0);
+ let g = &layer.children().unwrap()[0];
+ assert!(close(g.bbox_angle, -170.0), "wraps to (-180, 180]");
+ assert!(close(g.children().unwrap()[0].bbox_angle, 20.0));
+ assert!(close(g.extras().mask.as_ref().unwrap().art.bbox_angle, 20.0));
+ }
+
#[test]
fn oriented_bounds_hug_a_rotated_rectangle() {
let mut d = Document::new(500.0, 500.0);
diff --git a/crates/doc/src/symbol_nine_slice.rs b/crates/doc/src/symbol_nine_slice.rs
index 53f84e869c..3c8c1b324d 100644
--- a/crates/doc/src/symbol_nine_slice.rs
+++ b/crates/doc/src/symbol_nine_slice.rs
@@ -614,6 +614,8 @@ impl NineSliceMap {
/// `n`, whose box at the natural size is `b`, drawn in each cell it reaches: a clip group of
/// the cell around a copy placed by the cell's map, all in a group with `n`'s id. `n` placed
/// by `center` when it draws symbol art ([`draws_symbol_art`]), and in [`Self::place_unclipped`].
+ /// The clip groups and clip paths take the angle of the cell map that places them. The group
+ /// and each copy keep `n`'s box angle, turned by the map that places them.
///
/// The drop shadows, outer glows and blurs ([`PAINTED_EFFECTS`]) at the end of `n`'s own
/// effects, after its last other visible effect, go on that group with its opacity, blending
@@ -647,11 +649,15 @@ impl NineSliceMap {
area.transform(local);
let mut clip = Node::new(NodeId(0), NodeKind::Path { path: area, rule: FillRule::NonZero, live: None, clipping: true, guide: false });
clip.appearance = Appearance::default().into();
+ clip.bbox_angle = crate::orient::transformed_angle(0.0, local);
let copy = transformed(n, local * self.natural);
- cells.push(Arc::new(Node::new(NodeId(0), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(copy)], clip: true })));
+ let mut cell = Node::new(NodeId(0), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(copy)], clip: true });
+ cell.bbox_angle = crate::orient::transformed_angle(0.0, local);
+ cells.push(Arc::new(cell));
}
}
let mut out = Node::group(n.id, cells);
+ out.bbox_angle = crate::orient::transformed_angle(n.bbox_angle, center);
out.visible = n.visible;
out.locked = n.locked;
out.name = n.name.clone();
diff --git a/crates/doc/src/symbol_overrides.rs b/crates/doc/src/symbol_overrides.rs
index 33f0fd7695..241c61fba8 100644
--- a/crates/doc/src/symbol_overrides.rs
+++ b/crates/doc/src/symbol_overrides.rs
@@ -2,14 +2,15 @@
//! symbol keeps appearance changes of its own to objects in the symbol's art, while the art stays
//! the symbol's. Editing the symbol reshapes every instance and each keeps its changes; Reset drops
//! them. The changes are stored on the instance ([`crate::node::NodeExtras::symbol_overrides`]) and
-//! applied wherever an instance is drawn or exported ([`dynamic_art`]).
+//! applied wherever an instance is drawn or exported ([`dynamic_art`]), and an instance's fill
+//! stains the art after them ([`instance_art`]).
use std::sync::Arc;
use serde::{Deserialize, Serialize};
-use vectorcraft_color::{BlendMode, Paint};
+use vectorcraft_color::{BlendMode, Color, Paint};
-use crate::{Node, NodeKind};
+use crate::{AppearanceItem, Node, NodeKind};
/// Deepest object an override may name: groups nested this deep in the symbol's art.
pub const MAX_PATH_DEPTH: usize = 64;
@@ -150,6 +151,52 @@ pub fn dynamic_art(art: &Node, inst: &Node) -> Node {
a
}
+/// Mix every solid paint under `n` towards `color` by `amount` (0..1): the Symbol Stainer.
+pub fn tint_node(n: &mut Node, color: &Color, amount: f32) {
+ let amount = amount.clamp(0.0, 1.0);
+ for it in &mut n.appearance.items {
+ let p = match it {
+ AppearanceItem::Fill(f) => &mut f.paint,
+ AppearanceItem::Stroke(s) => &mut s.paint,
+ };
+ if let Paint::Solid { color: c, .. } = p {
+ *p = Paint::solid(c.lerp(color, amount));
+ }
+ }
+ if let NodeKind::Text(t) = &mut n.kind {
+ for r in &mut t.runs {
+ for p in [&mut r.style.fill, &mut r.style.stroke] {
+ if let Paint::Solid { color: c, .. } = p {
+ *p = Paint::solid(c.lerp(color, amount));
+ }
+ }
+ }
+ }
+ if let Some(ch) = n.children_mut() {
+ for c in ch.iter_mut() {
+ tint_node(Arc::make_mut(c), color, amount);
+ }
+ }
+}
+
+/// The stain instance `inst` puts on its symbol's art: its visible fill color and that fill's
+/// opacity (how far the art mixes towards it). `None` for an unstained instance, including one
+/// whose fill has an opacity of 0, so its art keeps its paints and their swatch links.
+pub fn stain(inst: &Node) -> Option<(Color, f32)> {
+ let f = inst.appearance.fill().filter(|f| f.visible && f.opacity > 0.0)?;
+ Some((f.paint.color()?, f.opacity))
+}
+
+/// A symbol's art for instance `inst`, as the canvas draws it: with the instance's own appearance
+/// changes to it ([`dynamic_art`]), stained by the instance's fill ([`stain`]).
+pub fn instance_art(art: &Node, inst: &Node) -> Node {
+ let mut a = dynamic_art(art, inst);
+ if let Some((c, amount)) = stain(inst) {
+ tint_node(&mut a, &c, amount);
+ }
+ a
+}
+
/// Keep the overrides that name an object of `art` (a symbol's new art), dropping the rest.
pub fn retain_parts(overrides: &mut Vec, art: &Node) {
overrides.retain(|o| part(art, &o.path).is_some());
@@ -159,7 +206,6 @@ pub fn retain_parts(overrides: &mut Vec, art: &Node) {
mod tests {
use super::*;
use crate::{Appearance, NodeId};
- use vectorcraft_color::Color;
use vectorcraft_geom::{Rect, shapes};
fn square(id: u64) -> Arc {
diff --git a/crates/effects/src/adjust.rs b/crates/effects/src/adjust.rs
index 4193b085ef..1b9ac80452 100644
--- a/crates/effects/src/adjust.rs
+++ b/crates/effects/src/adjust.rs
@@ -523,13 +523,14 @@ fn color_mask_keys<'a>(n: &'a Node, out: &mut Vec<&'a str>) {
/// [`adjust`] for export and Expand Appearance: recoloured embedded images become new images in
/// `d` (named by their content), and a symbol instance stands for its symbol's art: `symbol_art`
-/// when given, otherwise the symbol's art with the instance's own appearance changes.
+/// when given, otherwise the symbol's art with the instance's own appearance changes, stained by
+/// the instance's fill ([`vectorcraft_doc::symbol_overrides::instance_art`]).
pub fn adjust_in_document(d: &mut Document, n: &Node, symbol_art: Option) -> Option {
let art;
let src = match &n.kind {
NodeKind::SymbolInstance { symbol, .. } if has_adjustment(n) => {
let symbol = symbol_art
- .or_else(|| d.symbols.iter().find(|s| s.name == *symbol).map(|s| vectorcraft_doc::symbol_overrides::dynamic_art(&s.art, n)));
+ .or_else(|| d.symbols.iter().find(|s| s.name == *symbol).map(|s| vectorcraft_doc::symbol_overrides::instance_art(&s.art, n)));
art = crate::reshape::as_art(Some(d), n, symbol.as_ref())?;
&art
}
diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs
index 7e42147443..90a0d85ec8 100644
--- a/crates/effects/src/bake.rs
+++ b/crates/effects/src/bake.rs
@@ -148,9 +148,11 @@ fn bake_node(d: &mut Document, n: &Node) -> Option {
if let Some(m) = moved_type_group(n) {
return Some(bake_pieces(d, m));
}
+ // A symbol instance's art as the canvas draws it: with its own appearance changes, stained
+ // by its fill.
let symbol = match &n.kind {
NodeKind::SymbolInstance { symbol, .. } => {
- d.symbols.iter().find(|s| s.name == *symbol).map(|s| vectorcraft_doc::symbol_overrides::dynamic_art(&s.art, n))
+ d.symbols.iter().find(|s| s.name == *symbol).map(|s| vectorcraft_doc::symbol_overrides::instance_art(&s.art, n))
}
_ => None,
};
diff --git a/crates/effects/src/bake/tests.rs b/crates/effects/src/bake/tests.rs
index 2bcc026340..f3855f57ca 100644
--- a/crates/effects/src/bake/tests.rs
+++ b/crates/effects/src/bake/tests.rs
@@ -59,6 +59,46 @@ fn object_effects_become_geometry_and_raster_effects_stay() {
assert_eq!(m.appearance.effects[0].id, "stylize.dropShadow");
}
+/// The fill colors of the paths under the layers of `d`, in paint order.
+fn path_fills(d: &Document) -> Vec {
+ let mut out = vec![];
+ d.walk(|n| {
+ if matches!(n.kind, NodeKind::Path { .. })
+ && let Some(c) = n.appearance.fill_paint().color()
+ {
+ out.push(c);
+ }
+ });
+ out
+}
+
+/// A symbol instance half stained red is baked in its stained colors, as the canvas draws it:
+/// moved by a Transform effect, and recolored by a color adjustment (Invert Colors) after the
+/// stain.
+#[test]
+fn a_stained_instance_is_baked_in_its_stained_colors() {
+ let blue = Color::rgb8(30, 90, 220);
+ let art = Node::path(NodeId(2), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(Paint::solid(blue), Paint::None, 0.0));
+ let mut inst = Node::new(NodeId(50), NodeKind::SymbolInstance { symbol: "Mark".into(), xf: vectorcraft_geom::Affine::translate((20.0, 20.0)) });
+ inst.appearance = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0).into();
+ inst.appearance.items.retain(|i| i.is_fill());
+ if let vectorcraft_doc::AppearanceItem::Fill(f) = &mut inst.appearance.items[0] {
+ f.opacity = 0.5;
+ }
+ let stained = vectorcraft_doc::symbol_overrides::instance_art(&art, &inst).appearance.fill_paint().color().unwrap();
+ assert_ne!(stained.to_hex(), blue.to_hex());
+ let [r, g, b] = stained.to_rgb();
+ let inverted = Color::rgb(1.0 - r, 1.0 - g, 1.0 - b);
+ for (id, params, want) in [("distort.transform", json!({"moveH": 30.0}), stained), (crate::addons::color::INVERT, json!({}), inverted)] {
+ let mut n = inst.clone();
+ n.appearance.effects.push(fx(id, params));
+ let mut d = doc_with(n);
+ d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Mark".into(), art: Arc::new(art.clone()) });
+ let fills = path_fills(&bake_document(&d).unwrap());
+ assert_eq!(fills.iter().map(Color::to_hex).collect::>(), [want.to_hex()], "{id}");
+ }
+}
+
#[test]
fn a_baked_transform_moves_a_placed_gradient_with_the_path() {
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
diff --git a/crates/effects/src/live.rs b/crates/effects/src/live.rs
index 2c02f886c0..5a878d42d3 100644
--- a/crates/effects/src/live.rs
+++ b/crates/effects/src/live.rs
@@ -23,15 +23,22 @@ pub fn text_outliner() -> Outliner<'static> {
/// Type outlined, or a symbol instance as its symbol's art (from `doc`) placed by the instance,
/// through the symbol's 9-slice guides when it has them ([`Document::place_symbol_art`], `None`
/// past its object limit). As an [`OutlineHook`](crate::OutlineHook), it gives compound shapes and
-/// Pathfinder operations the symbol instances among their members.
+/// Pathfinder operations the symbol instances among their members. The group around the art takes
+/// the instance's box angle, and the art's boxes turn with it, as Break Link turns them.
pub fn outline_object(doc: Option<&Document>, n: &Node) -> Option {
match &n.kind {
NodeKind::SymbolInstance { symbol, xf } => {
let doc = doc?;
let sym = doc.symbols.iter().find(|s| s.name == *symbol)?;
let art = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n);
- let art = doc.place_symbol_art(symbol, &art, *xf)?;
- Some(Node::group(n.id, vec![Arc::new(art)]))
+ let mut art = doc.place_symbol_art(symbol, &art, *xf)?;
+ let turn = vectorcraft_doc::orient::placed_turn(n.bbox_angle, *xf);
+ if turn != 0.0 {
+ art.turn_boxes(turn);
+ }
+ let mut out = Node::group(n.id, vec![Arc::new(art)]);
+ out.bbox_angle = n.bbox_angle;
+ Some(out)
}
_ => outline_text(n),
}
diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs
index 627cc1be03..0023fc5b16 100644
--- a/crates/effects/src/tests.rs
+++ b/crates/effects/src/tests.rs
@@ -1570,6 +1570,27 @@ fn outline_object_is_a_geometry_effect_that_keeps_the_path() {
assert_eq!(apply_geometry(&[fx("path.outlineObject", json!({})), offset.clone()], &square(), b), apply_geometry(&[offset], &square(), b));
}
+/// A symbol instance given to Pathfinder and compound shapes as its art: the group takes the
+/// instance's box angle and the art turns with it, turned with the instance or reset to 0.
+#[test]
+fn outline_object_turns_an_instances_art_with_its_box() {
+ use std::sync::Arc;
+
+ use vectorcraft_doc::orient::transformed_angle;
+ use vectorcraft_doc::{Appearance, Document, NodeId, Symbol};
+ let mut d = Document::new(500.0, 500.0);
+ d.symbols.push(Symbol { dynamic: false, name: "s".into(), art: Arc::new(Node::path(NodeId(0), square(), Appearance::default())) });
+ let xf = Affine::translate((200.0, 100.0)) * Affine::rotate(0.35);
+ for box_angle in [transformed_angle(0.0, xf), 0.0] {
+ let mut inst = Node::new(NodeId(3), NodeKind::SymbolInstance { symbol: "s".into(), xf });
+ inst.bbox_angle = box_angle;
+ let out = outline_object(Some(&d), &inst).unwrap();
+ let mut angles = vec![];
+ out.walk(&mut |c| angles.push(c.bbox_angle));
+ assert!(angles.len() == 2 && angles.iter().all(|a| close(*a, box_angle, 1e-9)), "{box_angle}: {angles:?}");
+ }
+}
+
#[test]
fn convert_to_shape_sizes() {
let r = run("convertToShape.rectangle", json!({"extraW": 20, "extraH": 10}), &square()).bounds().unwrap();
diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs
index 94d8add68b..d4fb2c39cd 100644
--- a/crates/engine/src/cmd.rs
+++ b/crates/engine/src/cmd.rs
@@ -440,6 +440,15 @@ pub(crate) fn f64_req(p: &Value, key: &str, cmd: &str) -> Result {
Some(v) => v.as_f64().ok_or_else(|| bad(cmd, format!("`{key}` must be a number, not {v}"))),
}
}
+/// The whole number of 0 or more that `p` gives for `key`. A missing key or null is "missing integer
+/// `{key}`"; any other value, such as `"3"`, `2.5`, `-1` or `true`, is "`{key}` must be a whole
+/// number", as [`object_param`] words it.
+pub(crate) fn u64_req(p: &Value, key: &str, cmd: &str) -> Result {
+ match p.get(key) {
+ None | Some(Value::Null) => Err(bad(cmd, format!("missing integer `{key}`"))),
+ Some(v) => v.as_u64().ok_or_else(|| bad(cmd, format!("`{key}` must be a whole number"))),
+ }
+}
/// The number `p` gives for `key`, or `default` when `p` leaves it out or gives null. Any other
/// value, such as `"40"`, is an error: "`{key}` must be a number, not {v}".
pub(crate) fn number_or(p: &Value, key: &str, default: f64, cmd: &str) -> Result {
diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs
index 02bc111746..bed4b33b76 100644
--- a/crates/engine/src/cmd/appearance.rs
+++ b/crates/engine/src/cmd/appearance.rs
@@ -36,7 +36,7 @@ use super::paint::paint_from;
use super::{AppearanceAttrs, EyedropperAttrs, FillAttrs, StrokeAttrs};
use super::{
CommandSpec, NodeId, Result, Session, bad, check_numbers, check_switches, cmd, has_doc, has_selection, ids_param, index, leaf_targets,
- object_param, ok, paint_targets, str_param, switch_or, targets,
+ object_param, ok, paint_targets, str_param, switch_or, targets, u64_req,
};
use crate::EngineError;
@@ -604,9 +604,9 @@ pub(crate) fn edits_stroke(s: &Session, p: &Value, item: ItemTarget, default: bo
Ok(ids.get(item.anchor(s, &ids)).and_then(|id| d.node(*id)?.appearance.items.get(index)).map_or(default, |it| !it.is_fill()))
}
-/// The whole number of 0 or more that `key` holds, read with [`index`].
+/// The whole number of 0 or more that `key` holds, read with [`u64_req`] and [`index`].
pub(crate) fn index_param(p: &Value, key: &str, cmd: &str) -> Result {
- p.get(key).and_then(Value::as_u64).map(index).ok_or_else(|| bad(cmd, format!("missing integer `{key}`")))
+ u64_req(p, key, cmd).map(index)
}
fn set_active_item(s: &mut Session, p: &Value) -> Result {
diff --git a/crates/engine/src/cmd/assets.rs b/crates/engine/src/cmd/assets.rs
index 1ebde5c026..e30e6efcdc 100644
--- a/crates/engine/src/cmd/assets.rs
+++ b/crates/engine/src/cmd/assets.rs
@@ -53,7 +53,7 @@ pub fn specs() -> Vec {
"Export Assets",
[],
None,
- "{assets?: [asset id…] (default all but the 3D assets, which assets.export3D writes), folder?, zip?, openLocation?, formats?, preset?, settings?, prefix?, subfolders?, antiAlias?} each asset's art alone, cropped to it, in every format row: document.exportForScreens {assets} at the document's export settings (document.exportSettings) under the ones given; with subfolders false every file goes into the folder itself, mobile and density rows too → what document.exportForScreens returns (no folder: the files, or one zip, as dataBase64). Leaves the remembered settings as they are (assets.settings.set changes them)",
+ "{assets?: [asset id…] (default all but the 3D assets, which assets.export3D writes), folder?, zip?, openLocation?, formats?, preset?, settings?, prefix?, subfolders?, antiAlias?} each asset's art alone, cropped to it, in every format row: document.exportForScreens {assets} at the document's export settings (document.exportSettings) under the ones given; with subfolders false every file goes into the folder itself, mobile and density rows too → what document.exportForScreens returns (no folder: the files, or one zip, as dataBase64; warnings name a key in the rows or settings given that only another format takes, not one in the remembered settings). Leaves the remembered settings as they are (assets.settings.set changes them)",
has_doc,
export
),
@@ -429,7 +429,7 @@ fn export(s: &mut Session, p: &Value) -> Result {
}
q.insert("assets".into(), json!(all));
}
- export_screens(s, &Value::Object(q)).map_err(|e| of_cmd(C, e))
+ export_screens(s, &Value::Object(q), p).map_err(|e| of_cmd(C, e))
}
fn settings_set(s: &mut Session, p: &Value) -> Result {
diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs
index 50c63b59cd..32b0f9fcd0 100644
--- a/crates/engine/src/cmd/brushsym.rs
+++ b/crates/engine/src/cmd/brushsym.rs
@@ -226,7 +226,7 @@ pub fn specs() -> Vec {
"Break Link to Symbol",
[],
None,
- "{ids?|id?} expand selected instances into plain art (with their appearance changes to a dynamic symbol), placed through the symbol's 9-slice guides when it has them, with the instance's opacity mask; a hidden or locked instance's art stays hidden or locked and is not selected → {ids}",
+ "{ids?|id?} expand selected instances into plain art (with their appearance changes to a dynamic symbol), placed through the symbol's 9-slice guides when it has them, with the instance's opacity mask and its own bounding box (square after Reset Bounding Box); a hidden or locked instance's art stays hidden or locked and is not selected → {ids}",
has_selection,
symbol_break_link
),
diff --git a/crates/engine/src/cmd/brushsym/symbols.rs b/crates/engine/src/cmd/brushsym/symbols.rs
index 2fb31c5f6b..1bfb8edea7 100644
--- a/crates/engine/src/cmd/brushsym/symbols.rs
+++ b/crates/engine/src/cmd/brushsym/symbols.rs
@@ -393,8 +393,10 @@ pub(super) fn symbol_place(s: &mut Session, p: &Value) -> Result {
/// The plain art of instance `inst` in document space (fresh ids), with the instance's own
/// appearance changes to a dynamic symbol's art when `own`, placed through its symbol's 9-slice
-/// guides when it has them. The instance's opacity mask goes on the art, or on a group around it
-/// when the art has a mask of its own.
+/// guides when it has them. The art's bounding boxes turn with the instance's own box, so after
+/// Reset Bounding Box they stand as the instance's box does. The instance's opacity mask goes on
+/// the art, or on a group around it when the art has a mask of its own; a group added around the
+/// art takes the instance's box angle.
fn expanded_instance(d: &mut Document, inst: &Node, own: bool) -> Option {
let NodeKind::SymbolInstance { symbol, xf } = &inst.kind else { return None };
let sym = d.symbols.iter().find(|s| &s.name == symbol)?;
@@ -402,12 +404,17 @@ fn expanded_instance(d: &mut Document, inst: &Node, own: bool) -> Option {
if let Some((color, amount)) = brush::stain(inst) {
brush::tint_node(&mut art, &color, amount);
}
- let art = d.place_symbol_art(symbol, &art, *xf)?;
+ let mut art = d.place_symbol_art(symbol, &art, *xf)?;
+ let turn = vectorcraft_doc::orient::placed_turn(inst.bbox_angle, *xf);
+ if turn != 0.0 {
+ art.turn_boxes(turn);
+ }
let mut art = d.reid(&art);
if inst.opacity < 1.0 || inst.blend != vectorcraft_color::BlendMode::Normal {
if !matches!(art.kind, NodeKind::Group { .. }) {
let id = d.alloc_id();
art = Node::group(id, vec![Arc::new(art)]);
+ art.bbox_angle = inst.bbox_angle;
}
art.opacity *= inst.opacity;
art.blend = inst.blend;
@@ -416,6 +423,7 @@ fn expanded_instance(d: &mut Document, inst: &Node, own: bool) -> Option {
if art.extras().mask.is_some() {
let id = d.alloc_id();
art = Node::group(id, vec![Arc::new(art)]);
+ art.bbox_angle = inst.bbox_angle;
}
art.extras_mut().mask = Some(mask.clone());
}
diff --git a/crates/engine/src/cmd/draw2/common.rs b/crates/engine/src/cmd/draw2/common.rs
index 0ca376b243..fd8967f3fa 100644
--- a/crates/engine/src/cmd/draw2/common.rs
+++ b/crates/engine/src/cmd/draw2/common.rs
@@ -231,9 +231,9 @@ pub(super) fn within_input_bound(q: Point) -> bool {
q.x.is_finite() && q.y.is_finite() && q.x.abs() < MAX_INPUT && q.y.abs() < MAX_INPUT
}
-/// The whole number of 0 or more that `key` holds, read with [`index`].
+/// The whole number of 0 or more that `key` holds, read with [`u64_req`](crate::cmd::u64_req) and [`index`].
pub(super) fn usize_req(p: &Value, key: &str, cmd: &str) -> Result {
- p.get(key).and_then(Value::as_u64).map(index).ok_or_else(|| bad(cmd, format!("missing integer `{key}`")))
+ crate::cmd::u64_req(p, key, cmd).map(index)
}
/// The whole number of 0 or more that `key` holds, or `None` when `key` is absent or null. Any other
diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs
index 990ac1a1c6..576b05ffd6 100644
--- a/crates/engine/src/cmd/edit.rs
+++ b/crates/engine/src/cmd/edit.rs
@@ -11,7 +11,7 @@ use super::clipboard::SwatchChoices;
use super::{
CommandSpec, Result, Session, always, bad, can_clear_history, can_redo, can_undo, check_numbers, checked_ids_param, clock_date, cmd, date_param,
f64_req, finite_numbers, has_clipboard, has_doc, has_selection, has_selection_guides_or_artboard_tool, has_selection_or_guides, index, number_or,
- object_param, ok, point_param, str_param, switch_or,
+ object_param, ok, point_param, str_param, switch_or, u64_req,
};
use crate::{Clipboard, EngineError, SourceLayer, inspect};
@@ -249,7 +249,10 @@ fn file_close(s: &mut Session, p: &Value) -> Result {
}
fn doc_activate(s: &mut Session, p: &Value) -> Result {
- let i = p.get("index").and_then(Value::as_u64).map(index).ok_or_else(|| bad("document.activate", "missing index"))?;
+ let i = match p.get("index") {
+ None | Some(Value::Null) => return Err(bad("document.activate", "missing index")),
+ Some(v) => v.as_u64().map(index).ok_or_else(|| bad("document.activate", "`index` must be a whole number"))?,
+ };
if s.set_active(i) { ok() } else { Err(bad("document.activate", "no such document")) }
}
@@ -262,7 +265,10 @@ fn doc_move(s: &mut Session, p: &Value) -> Result {
if from >= n {
return Err(bad("document.move", "no such document"));
}
- let to = p.get("to").and_then(Value::as_u64).map(index).ok_or_else(|| bad("document.move", "missing `to`: a non-negative integer"))?;
+ let to = match p.get("to") {
+ None | Some(Value::Null) => return Err(bad("document.move", "missing `to`: a non-negative integer")),
+ Some(v) => v.as_u64().map(index).ok_or_else(|| bad("document.move", "`to` must be a whole number"))?,
+ };
let to = to.min(n.saturating_sub(1));
if !s.move_document(from, to) {
return Err(bad("document.move", "no such document"));
@@ -1190,8 +1196,7 @@ fn clear(s: &mut Session, p: &Value) -> Result {
/// Document ids scope node ids: two tabs may contain objects with the same node id.
fn drop_selection(s: &mut Session, p: &Value) -> Result {
const CMD: &str = "edit.dropSelection";
- let key = "sourceDocument";
- let uid = p[key].as_u64().ok_or_else(|| bad(CMD, format!("missing integer `{key}`")))?;
+ let uid = u64_req(p, "sourceDocument", CMD)?;
let point = |key: &str| finite_numbers::<2>(&p[key]).ok_or_else(|| bad(CMD, format!("{key} must be [x, y] with finite coordinates")));
let grab = point("grab")?;
let at = point("at")?;
diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs
index d20c6c46b2..1ff1897c85 100644
--- a/crates/engine/src/cmd/fileio.rs
+++ b/crates/engine/src/cmd/fileio.rs
@@ -92,7 +92,7 @@ pub use pdfimport::{LoadOptions, page_document};
pub(crate) use save::job_for;
pub(crate) use save::save as save_document;
pub use save::{
- SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, TEMPLATE_FORMATS, dialog_folder, documents_dialog_folder, export_folder,
+ SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, TEMPLATE_FORMATS, dialog_folder, documents_dialog_folder, export_folder, file_reads,
leave_out_unreadable_save_paths, reads_option as save_reads_option, remembered_save_options, save_filters, save_format, save_job, save_plan,
save_with, stamp_save_dates, template_filters, templates_dialog_folder, templates_folder,
};
diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs
index 28e12c115d..03736c6022 100644
--- a/crates/engine/src/cmd/fileio/commands.rs
+++ b/crates/engine/src/cmd/fileio/commands.rs
@@ -54,7 +54,7 @@ pub fn specs() -> Vec {
"Export for Screens",
["File", "Export"],
None,
- "{folder?, zip?: false (one .zip of every file, each deflated or stored, whichever is smaller: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboard?: index | artboards?: [index…] | range?: \"1-3\" (default all), assets?: [asset id…] (instead of artboards: each asset's art alone, cropped to its bounds and named after the asset; see assets.list), layers?: [layer id…] (instead of artboards: each layer's art alone, sublayers included, named after the layer), selection?: one|each (instead of artboards: the selected art as one file, or a file per selected object, named after it), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), includeArtboardFill?: true (Include Artboard Fill: each artboard's background color behind its art; false leaves it out), pdfs?: multiple|single (Export PDFs As: with several artboards, single writes them as the pages of one PDF per PDF row, named after the document), exportPreset?: (a preset of document.exportPresets.list: its settings, under these params), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), folders?: none|rows (as subfolders)|format (a folder per extension: png, svg, pdf…)|item (a folder per artboard, asset, layer or object)|itemFormat (item/extension) (default: rows with subfolders or a preset, else none), name?: (a file name pattern instead of the item's name, with for the document, for the artboard, asset, layer or object, from 1, #### for the counter zero-padded to the run of #, , , (else the row's suffix ends the name), YYYYMMDD, YYYY-MM-DD, YYYY, , , , , , , and ; a trailing .ext is dropped, as in `COMPANY-Logo--####-YYYYMMDD`; different files it names alike get -2, -3…), counterStart?: 1, counterPer?: item (every file of an item has its number)|file, date?: YYYY-MM-DD (the date the pattern uses; default today, UTC), preset?: mobile (PNG 1x, 2x, 3x) | density (PNG 0.75x–4x in ldpi…xxxhdpi sub-folders) instead of formats (turns subfolders on), formats?: [{format: png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft, scale?: 1 | \"2x\" | \"100w\" | \"100h\" | \"72ppi\", width?: px | height?: px | ppi? (raster only; width, then height, then ppi win over scale), suffix?: (raster default: @2x, @100w, @100h, none at 1x; vector formats drop size suffixes), folder?: (its sub-folder), quality?: (jpg 0–100), …the format's options (antiAlias, background, preset (pdf)…)}], settings?: {png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft: {…options for every row of that format}} (rows' own options win), prefix?, antiAlias?: none|art|type (raster rows without their own), openLocation?: (remembered; the app shows the files after exporting)} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N; a row whose file would take a name another row's file has, in any case, gets -2, -3… too, and identical rows write one file; row folders that differ only in case are one folder, spelled as the first row spells it) → {files: [path…]} (into the folder of exportPreset, outside the automation read roots: no ZIP path, and files relative to that folder); no folder → {files: [{name, dataBase64}]} (names include sub-folders: 2x/Icon@2x.png); every reply has warnings: [the encoders' warnings for the files, each once: an image that couldn't be decoded, type written in the fallback font…]. eps, ai and vectorcraft rows write a document of the item alone (its one artboard). The params (but zip and assets) are remembered in the document (document.exportSettings; not an undo step). A key it doesn't take, a key no format takes in a format row or in settings, settings for a format it doesn't write, and an unknown key inside a PDF, EPS or SVG option's object, are refused. Under automation read roots, an svg or svgz row is refused, before the folder is made or any file is written, while the document links a JavaScript file outside them, as document.export refuses it",
+ "{folder?, zip?: false (one .zip of every file, each deflated or stored, whichever is smaller: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboard?: index | artboards?: [index…] | range?: \"1-3\" (default all), assets?: [asset id…] (instead of artboards: each asset's art alone, cropped to its bounds and named after the asset; see assets.list), layers?: [layer id…] (instead of artboards: each layer's art alone, sublayers included, named after the layer), selection?: one|each (instead of artboards: the selected art as one file, or a file per selected object, named after it), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), includeArtboardFill?: true (Include Artboard Fill: each artboard's background color behind its art; false leaves it out), pdfs?: multiple|single (Export PDFs As: with several artboards, single writes them as the pages of one PDF per PDF row, named after the document), exportPreset?: (a preset of document.exportPresets.list: its settings, under these params), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), folders?: none|rows (as subfolders)|format (a folder per extension: png, svg, pdf…)|item (a folder per artboard, asset, layer or object)|itemFormat (item/extension) (default: rows with subfolders or a preset, else none), name?: (a file name pattern instead of the item's name, with for the document, for the artboard, asset, layer or object, from 1, #### for the counter zero-padded to the run of #, , , (else the row's suffix ends the name), YYYYMMDD, YYYY-MM-DD, YYYY, , , , , , , and ; a trailing .ext is dropped, as in `COMPANY-Logo--####-YYYYMMDD`; different files it names alike get -2, -3…), counterStart?: 1, counterPer?: item (every file of an item has its number)|file, date?: YYYY-MM-DD (the date the pattern uses; default today, UTC), preset?: mobile (PNG 1x, 2x, 3x) | density (PNG 0.75x–4x in ldpi…xxxhdpi sub-folders) instead of formats (turns subfolders on), formats?: [{format: png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft, scale?: 1 | \"2x\" | \"100w\" | \"100h\" | \"72ppi\", width?: px | height?: px | ppi? (raster only; width, then height, then ppi win over scale), suffix?: (raster default: @2x, @100w, @100h, none at 1x; vector formats drop size suffixes), folder?: (its sub-folder), quality?: (jpg 0–100), …the format's options (antiAlias, background, preset (pdf)…)}], settings?: {png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft: {…options for every row of that format}} (rows' own options win), prefix?, antiAlias?: none|art|type (raster rows without their own), openLocation?: (remembered; the app shows the files after exporting)} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N; a row whose file would take a name another row's file has, in any case, gets -2, -3… too, and identical rows write one file; row folders that differ only in case are one folder, spelled as the first row spells it) → {files: [path…]} (into the folder of exportPreset, outside the automation read roots: no ZIP path, and files relative to that folder); no folder → {files: [{name, dataBase64}]} (names include sub-folders: 2x/Icon@2x.png); every reply has warnings: [the encoders' warnings for the files, each once: an image that couldn't be decoded, type written in the fallback font…]. eps, ai and vectorcraft rows write a document of the item alone (its one artboard). The params (but zip and assets) are remembered in the document (document.exportSettings; not an undo step). A key it doesn't take, a key no format takes in a format row or in settings, settings for a format it doesn't write, and an unknown key inside a PDF, EPS or SVG option's object, are refused; a key only another format takes in a row or in settings is not used, and warnings name it as given (formats[0].quality; a key in settings only when no row of that format reads it; not a key of an exportPreset). Under automation read roots, an svg or svgz row is refused, before the folder is made or any file is written, while the document links a JavaScript file outside them, as document.export refuses it",
has_doc,
screens::export_for_screens
),
diff --git a/crates/engine/src/cmd/fileio/export.rs b/crates/engine/src/cmd/fileio/export.rs
index d8af86802f..594193cd7b 100644
--- a/crates/engine/src/cmd/fileio/export.rs
+++ b/crates/engine/src/cmd/fileio/export.rs
@@ -32,26 +32,25 @@ pub fn unused_option_notes(cmd: &str, f: &Format, p: &Value) -> Vec {
}
let listed = |k: &str| super::FORMATS.iter().filter(|f| f.write).flat_map(|f| f.options).any(|o| o.name == k);
let written = super::save::written_as(cmd, f, p);
- let legacy = f.id == "ai" && super::legacy_ai::wanted(cmd, f, p).unwrap_or(false);
- let reads = |k: &str| match (f.id, k) {
- // A legacy `.ai` file is not a PDF: it reads its own options only.
- ("ai", _) if legacy => super::legacy_ai::reads(k),
- // The other `.ai` files don't read the legacy file's options, and `preserveEditing` is
- // on for them whatever is given.
- ("ai", "preserveEditing") => false,
- ("ai", _) if super::legacy_ai::LEGACY_ONLY.contains(&k) => false,
- // An export writes one file: only a save writes each artboard to a file of its own, and
- // a native file's `range` names those files.
- ("vectorcraft" | "template" | "ai", "separateArtboards") | ("vectorcraft" | "template", "range") => false,
- _ => super::save::reads_option(f, k),
- };
+ let legacy = super::save::legacy_file(cmd, f, p);
given
.iter()
- .filter(|(k, v)| !v.is_null() && !EXPORT_PARAMS.contains(&k.as_str()) && listed(k) && !reads(k))
+ .filter(|(k, v)| !v.is_null() && !EXPORT_PARAMS.contains(&k.as_str()) && listed(k) && !export_reads(f, legacy, k))
.map(|(k, _)| super::save::unused_option_note(k, &written))
.collect()
}
+/// Does the file an export writes in `f` read option `key`, `legacy` when it is a legacy `.ai`
+/// file? As a save's file does ([`super::save::file_reads`]), except that an export writes one
+/// file: only a save writes each artboard to a file of its own, and a native file's `range` names
+/// those files.
+pub(super) fn export_reads(f: &Format, legacy: bool, key: &str) -> bool {
+ match (f.id, key) {
+ ("vectorcraft" | "template" | "ai", "separateArtboards") | ("vectorcraft" | "template", "range") => false,
+ _ => super::save::file_reads(f, legacy, key),
+ }
+}
+
pub(super) fn serialize(s: &mut Session, p: &Value) -> Result {
let f = writable("document.serialize", Some(text_param(p, "format", "document.serialize")?.unwrap_or("vectorcraft")), None)?;
check_linked_scripts("document.serialize", f.id, s.doc()?)?;
diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs
index e56f6467a1..af9ed8e58a 100644
--- a/crates/engine/src/cmd/fileio/save.rs
+++ b/crates/engine/src/cmd/fileio/save.rs
@@ -95,14 +95,18 @@ pub struct SavePlan {
/// The `modified` param (as `date_param` reads it): the File Info date a Save or Save As to a file
/// stamps; `None`: the time it is written.
pub modified: Option>,
- /// Warnings about the params themselves: each native option (`compress`, `version`,
- /// `preview`, `precision`) given beside the path, and each key of `options`, that the format
- /// doesn't read, naming the file the save writes.
+ /// Warnings about the params themselves: each option a save format takes given beside the
+ /// path, and each key of `options`, that the file the save writes doesn't read
+ /// ([`file_reads`]), naming that file.
pub notes: Vec,
/// The file the document was read from and what reading it left out, when the save needs
/// `acknowledgeLoss` to write over it and was not given it: every file the save writes is
/// checked against it before the first is written.
pub lossy_source: Option<(String, Vec)>,
+ /// The `.ai` options the file doesn't read, with the values the document remembers from its
+ /// last `.ai` save: not in `options`, they go back into the remembered options once the file
+ /// is the document's own.
+ pub kept: Map,
}
impl SavePlan {
@@ -279,7 +283,8 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result {
}
}
// None given: the ones the document was last saved with in this format.
- let mut options = if given.is_empty() && format.id == st.format { remembered_save_options(st) } else { given };
+ let from_memory = given.is_empty() && format.id == st.format;
+ let mut options = if from_memory { remembered_save_options(st) } else { given };
// Remember the effective choice, including the encoder's default, so another document's
// later save cannot change what this one writes. Invalid non-null values still reach validation.
if matches!(format.id, "vectorcraft" | "template") {
@@ -311,21 +316,54 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result {
let given = str_param(p, "path").is_some_and(|g| !g.is_empty());
super::check_not_lossy_overwrite(st, path, p, cmd).map_err(|e| if given { e } else { super::access::naming_in_error(e, path) })?;
}
- let written = written_as(cmd, format, &Value::Object(options.clone()));
- let top = NATIVE_OPTIONS.iter().filter(|k| p.get(**k).is_some_and(|v| !v.is_null())).map(|k| k.to_string());
+ let chosen = Value::Object(options.clone());
+ let written = written_as(cmd, format, &chosen);
+ let legacy = legacy_file(cmd, format, &chosen);
+ // Beside the path, a key some save format takes; in `options`, any key.
+ let top = p.as_object().into_iter().flatten().filter(|(k, v)| !v.is_null() && save_listed(k)).map(|(k, _)| k.to_string());
let nested = p.get("options").and_then(Value::as_object).into_iter().flatten().filter(|(_, v)| !v.is_null()).map(|(k, _)| k);
let notes = top
- .filter(|k| !reads_option(format, k))
- .chain(nested.filter(|k| !reads_option(format, k)).map(|k| format!("options.{k}")))
+ .filter(|k| !file_reads(format, legacy, k))
+ .chain(nested.filter(|k| !file_reads(format, legacy, k)).map(|k| format!("options.{k}")))
.map(|k| unused_option_note(&k, &written))
.collect();
+ // When the document's own file is a `.ai`, the `.ai` options this file doesn't read and the
+ // request doesn't give keep their remembered values ([`SavePlan::kept`]), so that Save Options
+ // still shows them (it sends only the ones the file reads). They are neither written nor sent
+ // again.
+ let mut kept = Map::new();
+ if format.id == "ai" && format.id == st.format {
+ let last = remembered_save_options(st);
+ for o in format.options.iter().filter(|o| !file_reads(format, legacy, o.name)) {
+ let value = if from_memory {
+ options.remove(o.name)
+ } else if options.contains_key(o.name) {
+ None
+ } else {
+ last.get(o.name).cloned()
+ };
+ if let Some(v) = value {
+ kept.insert(o.name.to_string(), v);
+ }
+ }
+ }
let lossy_source = super::lossy_source(st, p);
- Ok(SavePlan { mode, path, format, options, name, folder, modified, notes, lossy_source })
+ Ok(SavePlan { mode, path, format, options, name, folder, modified, notes, lossy_source, kept })
}
/// The native save options, which a save also takes beside the path.
const NATIVE_OPTIONS: [&str; 4] = ["compress", "version", "preview", "precision"];
+/// Does some save format (or template format) take option `key`?
+fn save_listed(key: &str) -> bool {
+ NATIVE_OPTIONS.contains(&key)
+ || super::FORMATS
+ .iter()
+ .filter(|f| SAVE_FORMATS.contains(&f.id) || TEMPLATE_FORMATS.contains(&f.id))
+ .flat_map(|f| f.options)
+ .any(|o| o.name == key)
+}
+
/// The first option `p` gives both beside the path and in `options` with different values, as
/// `(key, top-level value, options value)`. For an SVG save (`svg`), an SVG option's value on
/// either side is the one its `svg: {…}` object gives, else the flat one.
@@ -467,6 +505,26 @@ pub fn reads_option(f: &Format, key: &str) -> bool {
f.options.iter().any(|o| o.name == key) || (is_pdf(f) && (super::pdf::OPTIONS.iter().any(|o| o.name == key) || super::pdf::is_setting(key)))
}
+/// Does the file a save or an export writes in `f` read option `key`, `legacy` when it is a legacy
+/// `.ai` file ([`super::legacy_ai::wanted`])? As [`reads_option`], except for `.ai` and `.ait`
+/// files: a legacy file reads its own options only ([`super::legacy_ai::reads`]), and the others
+/// don't read the legacy file's options or `preserveEditing`, which is on for them whatever is
+/// given.
+pub fn file_reads(f: &Format, legacy: bool, key: &str) -> bool {
+ match (f.id, key) {
+ ("ai", _) if legacy => super::legacy_ai::reads(key),
+ ("ai" | "ait", "preserveEditing") => false,
+ ("ai" | "ait", _) if super::legacy_ai::LEGACY_ONLY.contains(&key) => false,
+ _ => reads_option(f, key),
+ }
+}
+
+/// Is a save or an export of `cmd` in `f` with options `p` to write a legacy `.ai` file? An
+/// unknown version is not one (the save or export refuses it).
+pub(super) fn legacy_file(cmd: &str, f: &Format, p: &Value) -> bool {
+ f.id == "ai" && super::legacy_ai::wanted(cmd, f, p).unwrap_or(false)
+}
+
/// The note a save or an export gives for option `key`, as the request names it, which the file it
/// writes (`written`, see [`written_as`]) doesn't read.
pub(super) fn unused_option_note(key: &str, written: &Format) -> String {
@@ -1037,6 +1095,7 @@ impl SaveJob {
st.path = Some(path);
st.format = self.plan.format.id;
st.save_options = self.plan.options;
+ st.save_options.extend(self.plan.kept);
// A later save rounds only when it gives `precision` too.
st.save_options.remove("precision");
st.save_artboards = self.snapshot.artboards.iter().map(|a| a.id).collect();
@@ -1239,7 +1298,7 @@ pub(super) fn specs() -> Vec {
"Save Document",
[],
None,
- "{path?, format?: vectorcraft|template|pdf|svg|svgz|ai|eps (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings say when the format doesn't read compress, version, preview or precision given flat, or a key given in options), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), precision?: 0..9 (native: round path anchors and handles to this many decimal places, a smaller file; colors, transforms and type stay exact; default: exact, and a later save without it writes exactly again), separateArtboards?: false (native and .ai: also save each artboard of `range` to -. beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: true (native: also carry a PDF of every artboard; false omits it) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file and EPS (below): a .ai file is a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly, on the active artboard (the app's, else the one the document opened on; with editingData: false, the first); separateArtboards writes more files, one per artboard. EPS takes the options of document.exportEps: by default one file whose bounding box fits the visible art (the first artboard when no art shows), carrying the whole editable document; useArtboards: true (also when artboard, artboards or range names artboards; file.formatOptions then reports it true) makes the chosen artboard the page, drawn with its background color (without Use Artboards, only the first artboard's page when no art shows has its color), and with two or more chosen the file at the path is the central file of the visible art (again the first artboard when none shows) and each artboard is also written to _.eps beside it, holding that artboard; selectedOnly is refused; its PostScript appearance flattens transparency, as EPS requires. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF/EPS saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified. Under automation read roots, a reply leaves out the document's own file outside them when no path is given (files and linked then name the files by their file names), and a suggested folder outside them; an svg or svgz save (a document whose own format is SVG or SVGZ included) is refused, before any file is written, while the document links a JavaScript file outside them (svgInteractivity; a URL is not checked), also by file.saveAs and file.saveCopy. A key that no save format takes, beside the path or in options, and an unknown key inside a PDF option's object or svg, are refused. A terminal filesystem permission/read-only refusal returns READ_ONLY_LOCATION: followed by the reason; recovery-copy failures keep their original error. Non-UI callers never open a save panel",
+ "{path?, format?: vectorcraft|template|pdf|svg|svgz|ai|eps (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings name an option given flat or in options that the file doesn't read: a legacy .ai reads only its own options, and other .ai files don't read outlineText, hiddenObjects, transparentImages or preserveEditing), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), precision?: 0..9 (native: round path anchors and handles to this many decimal places, a smaller file; colors, transforms and type stay exact; default: exact, and a later save without it writes exactly again), separateArtboards?: false (native and .ai: also save each artboard of `range` to -. beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: true (native: also carry a PDF of every artboard; false omits it) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file and EPS (below): a .ai file is a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly, on the active artboard (the app's, else the one the document opened on; with editingData: false, the first); separateArtboards writes more files, one per artboard. EPS takes the options of document.exportEps: by default one file whose bounding box fits the visible art (the first artboard when no art shows), carrying the whole editable document; useArtboards: true (also when artboard, artboards or range names artboards; file.formatOptions then reports it true) makes the chosen artboard the page, drawn with its background color (without Use Artboards, only the first artboard's page when no art shows has its color), and with two or more chosen the file at the path is the central file of the visible art (again the first artboard when none shows) and each artboard is also written to _.eps beside it, holding that artboard; selectedOnly is refused; its PostScript appearance flattens transparency, as EPS requires. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF/EPS saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified. Under automation read roots, a reply leaves out the document's own file outside them when no path is given (files and linked then name the files by their file names), and a suggested folder outside them; an svg or svgz save (a document whose own format is SVG or SVGZ included) is refused, before any file is written, while the document links a JavaScript file outside them (svgInteractivity; a URL is not checked), also by file.saveAs and file.saveCopy. A key that no save format takes, beside the path or in options, and an unknown key inside a PDF option's object or svg, are refused. A terminal filesystem permission/read-only refusal returns READ_ONLY_LOCATION: followed by the reason; recovery-copy failures keep their original error. Non-UI callers never open a save panel",
has_doc,
|s, p| save(s, SaveMode::Save, p)
),
diff --git a/crates/engine/src/cmd/fileio/screens.rs b/crates/engine/src/cmd/fileio/screens.rs
index dcf3d6c9ae..c07dd8854a 100644
--- a/crates/engine/src/cmd/fileio/screens.rs
+++ b/crates/engine/src/cmd/fileio/screens.rs
@@ -18,7 +18,7 @@ use vectorcraft_geom::Rect;
use super::super::{EngineError, NodeId, Result, Session, bad, bool_or, str_param};
use super::encode::{add_note, single_artboard};
-use super::export::{export_crop, isolated, selected_artwork, without_artboards};
+use super::export::{export_crop, export_reads, isolated, selected_artwork, without_artboards};
use super::naming::{self, NameParts};
use super::{
ARTBOARD_PARAMS, ArtboardPick, Format, Note, artboard_file_names_keeping_dots, create_dir, distinct_names_by, encode_with_notes, merge,
@@ -524,6 +524,77 @@ fn rows_of(s: &Session, p: &Value, preset: Option<&str>) -> Result> {
Ok(rows)
}
+/// A note for each option of a format row (`formats`) or of a format's `settings` in the request
+/// `asked` that some format takes but the file the row writes, or every row of that format writes,
+/// doesn't read (`{format: png, quality: 80}`), in the wording of a save's note and named as the
+/// request names it (`formats[0].quality`, `settings.png.quality`). The rows and settings the
+/// export writes with are those of `p` (`asked` with a preset or the remembered settings laid
+/// under it). A row's own keys (`scale`, `suffix`…), the keys the export reads for every format
+/// (`includeArtboardFill`, `richBlack`, `params`) and an artboard choice (the export picks the
+/// artboards) get none, and so does an exporter plug-in's format.
+pub(crate) fn unused_option_notes(asked: &Value, p: &Value) -> Vec {
+ let listed = |k: &str| super::FORMATS.iter().filter(|f| f.write).flat_map(|f| f.options).any(|o| o.name == k);
+ let own = |k: &str| {
+ ROW_KEYS.contains(&k) || ARTBOARD_PARAMS.contains(&k) || matches!(k, "useArtboards" | "includeArtboardFill" | "richBlack" | "params")
+ };
+ // The keys of `given` that `f`, writing with `options` (the format's settings and the row's
+ // own), doesn't read.
+ let unread = |f: &Format, given: &Map, options: &Value| -> Vec {
+ if vectorcraft_plugins::export::by_format(f.id).is_some() {
+ return Vec::new();
+ }
+ let legacy = super::save::legacy_file(C, f, options);
+ given.iter().filter(|(k, v)| !v.is_null() && !own(k) && listed(k) && !export_reads(f, legacy, k)).map(|(k, _)| k.clone()).collect()
+ };
+ let note = |key: String, f: &Format, options: &Value| super::save::unused_option_note(&key, &super::save::written_as(C, f, options));
+ let settings = p.get("settings").and_then(Value::as_object);
+ let of_format = |id: &str| settings.and_then(|s| s.get(id)).and_then(Value::as_object).cloned().unwrap_or_default();
+ // A row's format and the options it writes with.
+ let row_of = |row: &Value| -> Option<(&'static Format, Value)> {
+ let (Some(row), Ok(f)) = (row.as_object(), writable(C, Some(str_param(row, "format").unwrap_or("png")), None)) else { return None };
+ let mut options = of_format(f.id);
+ options.extend(row.iter().map(|(k, v)| (k.clone(), v.clone())));
+ Some((f, Value::Object(options)))
+ };
+ let mut out: Vec = Vec::new();
+ if let Some(Value::Array(rows)) = asked.get("formats") {
+ for (i, row) in rows.iter().enumerate() {
+ let (Some((f, options)), Some(given)) = (row_of(row), row.as_object()) else { continue };
+ out.extend(unread(f, given, &options).into_iter().map(|k| note(format!("formats[{i}].{k}"), f, &options)));
+ }
+ }
+ // The rows written, each with its own keys: a format's settings are read as each of its rows
+ // writes them (a row may name another `.ai` version), else as they are.
+ let written: Vec<(&Format, Value, Map)> = match p.get("formats") {
+ Some(Value::Array(rows)) => {
+ rows.iter().filter_map(|row| row_of(row).map(|(f, options)| (f, options, row.as_object().cloned().unwrap_or_default()))).collect()
+ }
+ _ => Vec::new(),
+ };
+ for (id, given) in asked.get("settings").and_then(Value::as_object).into_iter().flatten() {
+ let (Some(given), Ok(f)) = (given.as_object(), writable(C, Some(id), None)) else { continue };
+ let mut rows: Vec<(&Value, Option<&Map>)> =
+ written.iter().filter(|(g, ..)| g.id == f.id).map(|(_, o, row)| (o, Some(row))).collect();
+ let alone = Value::Object(of_format(f.id));
+ if rows.is_empty() {
+ rows.push((&alone, None));
+ }
+ let unread_by: Vec> = rows.iter().map(|(o, _)| unread(f, given, o)).collect();
+ // A setting a row reads and doesn't give itself is used. One no row uses is noted when a
+ // row doesn't read it, naming the first such row's file.
+ for (k, v) in given {
+ // A row's own key, null too, replaces the setting.
+ let overridden = |row: Option<&Map>| row.is_some_and(|r| r.contains_key(k));
+ let used = rows.iter().zip(&unread_by).any(|((_, row), u)| !u.contains(k) && !overridden(*row));
+ let unread_row = rows.iter().zip(&unread_by).find(|(_, u)| u.contains(k));
+ if let (false, false, Some(((options, _), _))) = (v.is_null(), used, unread_row) {
+ out.push(note(format!("settings.{id}.{k}"), f, options));
+ }
+ }
+ }
+ out
+}
+
/// Check the export settings in `p` (formats, preset, settings…) as an export would read them.
pub(crate) fn check_settings(s: &Session, p: &Value) -> Result<()> {
rows_of(s, p, ScreenParams::of(p)?.preset.as_deref()).map(|_| ())
@@ -646,9 +717,10 @@ impl Output {
pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result {
// A folder the client gave, as against the one its `exportPreset` keeps.
let given = p.get("folder").is_some();
+ let asked = p;
let p = with_preset(s, p)?;
let hidden = if given { None } else { str_param(&p, "folder").filter(|f| !f.is_empty() && !super::access::shown(f)) };
- let mut r = export(s, &p).map_err(|e| match hidden {
+ let mut r = export(s, &p, asked).map_err(|e| match hidden {
Some(folder) => super::access::naming_in_error(e, folder),
None => e,
})?;
@@ -1022,9 +1094,12 @@ pub(crate) fn with_export_preset(s: &Session, cmd: &str, settings: &mut Map Result {
+/// first ([`super::note_texts`]), and a note for each option of the request `asked` (`p` before a
+/// preset or the remembered settings were laid under it) that the files the rows write don't read
+/// ([`unused_option_notes`]).
+pub(crate) fn export(s: &mut Session, p: &Value, asked: &Value) -> Result {
check_passwords(p)?;
+ let unused = unused_option_notes(asked, p);
let o = ScreenParams::of(p)?;
let rows = rows_of(s, p, o.preset.as_deref())?;
o.one_source()?;
@@ -1054,7 +1129,7 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result {
// Include Artboard Fill off: the encoders leave the background colors out.
let fill = if o.include_artboard_fill == Some(false) { json!({ "includeArtboardFill": false }) } else { json!({}) };
let rows = rows.into_iter().map(|r| Row { options: merge(with_black_output(s, &r.options), fill.clone()), ..r }).collect();
- let export = ScreenExport { cmd: C, o, rows, naming, items, hidden: None };
+ let export = ScreenExport { cmd: C, o, rows, naming, items, unused, hidden: None };
match &mut s.screens_hand_off {
Some(slot) => {
*slot = Some(export);
@@ -1075,6 +1150,8 @@ pub struct ScreenExport {
rows: Vec,
naming: Naming,
items: Vec- ,
+ /// The notes on options the files don't read ([`unused_option_notes`]).
+ unused: Vec
,
/// The export folder and the name the request that prepared the export may see it by, when the
/// folder came from the `exportPreset` and is outside that request's read roots: the reply and
/// the errors of [`ScreenExport::write`] name it as [`export_for_screens`] does.
@@ -1139,14 +1216,17 @@ impl ScreenExport {
/// [`ScreenExport::write`].
fn encode_and_write(export: ScreenExport, writer: &mut dyn FnMut(&str, &[u8]) -> Result<()>) -> Result {
- let ScreenExport { o, rows, naming, items, .. } = export;
+ let ScreenExport { o, rows, naming, items, unused, .. } = export;
let folder = o.folder.as_deref().filter(|f| !f.is_empty());
if let Some(dir) = folder {
create_dir(dir)?;
}
let mut out = Output::default();
- // The encoders' notes for every file, each once.
+ // The notes on the options, then the encoders' notes for every file, each once.
let mut notes: Vec = Vec::new();
+ for text in unused {
+ add_note(&mut notes, Note::setting(text));
+ }
// Include Bleed: a grown artboard already holds the bleed. Assets, layers and a selection are
// written on a page of their own bounds, which it doesn't grow.
let bleed_on_page = o.include_bleed && o.assets.is_none() && o.layers.is_none() && o.selection.is_none();
diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs
index 71c1bd06e5..d17af84b66 100644
--- a/crates/engine/src/cmd/fileio/tests.rs
+++ b/crates/engine/src/cmd/fileio/tests.rs
@@ -748,8 +748,8 @@ fn a_native_save_streams_the_bytes_the_encoder_makes() {
}
/// An option given beside the path (an SVG option flat or in `svg`) and in `options` with different
-/// values is refused before anything is written; `compress`, `version`, `preview` or `precision` beside the path of a format
-/// that doesn't read it is named in the warnings.
+/// values is refused before anything is written; a native option beside the path of a format that doesn't read it is named
+/// in the warnings.
#[test]
fn a_save_refuses_disagreeing_options_and_names_unread_native_ones() {
let d = tmp_dir("save-options");
diff --git a/crates/engine/src/cmd/fileio/tests_exportas.rs b/crates/engine/src/cmd/fileio/tests_exportas.rs
index cc1345060d..1908c39685 100644
--- a/crates/engine/src/cmd/fileio/tests_exportas.rs
+++ b/crates/engine/src/cmd/fileio/tests_exportas.rs
@@ -280,3 +280,102 @@ fn note_texts_give_the_losses_first_each_kind_in_order() {
assert_eq!(note_texts(&[Note::setting("s1"), Note::setting("s2")]), ["s1", "s2"]);
assert!(note_texts(&[]).is_empty());
}
+
+/// A save notes the `.ai` options its file doesn't read as an export does: a PDF-compatible file
+/// doesn't read the legacy file's options or `preserveEditing`, and a legacy file reads its own
+/// options only. The notes cover the options beside the path and in `options`.
+#[test]
+fn a_save_notes_the_ai_options_its_file_does_not_read() {
+ let (mut s, notes) = (with_art(), unused);
+ let r = s
+ .execute("file.saveCopy", &json!({"format": "ai", "outlineText": true, "preserveEditing": false, "options": {"hiddenObjects": "omit"}}))
+ .unwrap();
+ assert_eq!(
+ notes(&r),
+ [
+ "`outlineText` was not used: PDF-compatible .ai files don't read it",
+ "`preserveEditing` was not used: PDF-compatible .ai files don't read it",
+ "`options.hiddenObjects` was not used: PDF-compatible .ai files don't read it"
+ ],
+ "{r}"
+ );
+ let p = json!({"format": "ai", "aiVersion": "7", "compress": true, "marks": {"trim": true}, "options": {"pdfCompatible": false}});
+ let r = s.execute("file.saveCopy", &p).unwrap();
+ assert_eq!(
+ notes(&r),
+ [
+ "`compress` was not used: legacy .ai (version 7) files don't read it",
+ "`marks` was not used: legacy .ai (version 7) files don't read it",
+ "`options.pdfCompatible` was not used: legacy .ai (version 7) files don't read it"
+ ],
+ "{r}"
+ );
+ // The file's own options get none.
+ for p in [
+ json!({"format": "ai", "aiVersion": "7", "outlineText": true, "options": {"hiddenObjects": "omit", "range": "1"}}),
+ json!({"format": "ai", "compress": true, "marks": {"trim": true}, "options": {"editingData": false}}),
+ json!({"format": "pdf", "marks": {"trim": true}, "preserveEditing": false}),
+ ] {
+ let r = s.execute("file.saveCopy", &p).unwrap();
+ assert!(notes(&r).is_empty(), "{p}: {r}");
+ }
+}
+
+/// A save notes an option beside the path that only another save format takes, as it notes one in
+/// `options`.
+#[test]
+fn a_save_notes_an_option_beside_the_path_that_only_another_save_format_takes() {
+ let (mut s, notes) = (with_art(), unused);
+ let r = s.execute("file.saveCopy", &json!({"format": "vectorcraft", "marks": {"trim": true}, "svg": {"minify": true}})).unwrap();
+ assert_eq!(notes(&r), ["`marks` was not used: VectorCraft files don't read it", "`svg` was not used: VectorCraft files don't read it"], "{r}");
+}
+
+/// The `.ai` options a save's file doesn't read keep the values the document's last save in that
+/// format gave them, so a save from Save Options, which sends only the options the file reads,
+/// leaves the other version's choices as they were.
+#[test]
+fn a_save_keeps_the_ai_options_its_file_does_not_read_from_the_last_save() {
+ let mut s = with_art();
+ let dir = tmp_dir("ai-kept");
+ let path = dir.join("kept.ai").to_string_lossy().to_string();
+ let value = |s: &mut Session, key: &str| s.execute("file.formatOptions", &json!({"format": "ai"})).unwrap()["options"][key]["value"].clone();
+ s.execute("file.saveAs", &json!({"path": path, "format": "ai", "options": {"aiVersion": "7", "outlineText": true}})).unwrap();
+ s.execute("file.saveAs", &json!({"path": path, "format": "ai", "options": {"aiVersion": "pdf", "editingData": false}})).unwrap();
+ assert_eq!(value(&mut s, "outlineText"), json!(true));
+ assert_eq!(value(&mut s, "editingData"), json!(false));
+ s.execute("file.saveAs", &json!({"path": path, "format": "ai", "options": {"aiVersion": "7", "hiddenObjects": "omit"}})).unwrap();
+ assert_eq!(value(&mut s, "editingData"), json!(false));
+ // An option the request gives is the one saved.
+ s.execute("file.saveAs", &json!({"path": path, "format": "ai", "options": {"aiVersion": "7", "outlineText": false}})).unwrap();
+ assert_eq!(value(&mut s, "outlineText"), json!(false));
+ // The kept options are neither written nor sent again: a plain save's options (which a retry
+ // in another folder sends to Save As) hold only what the legacy file reads.
+ for p in [json!({}), json!({"options": {"aiVersion": "7"}})] {
+ let plan = save_plan(&s, SaveMode::Save, &p).unwrap();
+ assert!(!plan.options.contains_key("editingData") && !plan.options.contains_key("compress"), "{:?}", plan.options);
+ assert_eq!(plan.kept.get("editingData"), Some(&json!(false)));
+ assert!(plan.notes.is_empty(), "{:?}", plan.notes);
+ let retry = json!({"path": path, "format": "ai", "options": plan.options});
+ let r = s.execute("file.saveAs", &retry).unwrap();
+ assert!(!r["warnings"].to_string().contains("was not used"), "{r}");
+ assert_eq!(value(&mut s, "editingData"), json!(false));
+ }
+ std::fs::remove_dir_all(&dir).unwrap();
+}
+
+/// The first `.ai` save of a document whose own file is another format remembers only the options
+/// it was given: nothing from that format's options is kept.
+#[test]
+fn a_first_ai_save_keeps_only_the_options_it_is_given() {
+ let mut s = with_art();
+ let dir = tmp_dir("ai-first");
+ let native = dir.join("first.vectorcraft").to_string_lossy().to_string();
+ let path = dir.join("first.ai").to_string_lossy().to_string();
+ s.execute("file.saveAs", &json!({"path": native, "options": {"compress": false}})).unwrap();
+ let plan = save_plan(&s, SaveMode::SaveAs, &json!({"path": path, "format": "ai", "options": {"aiVersion": "7"}})).unwrap();
+ assert!(plan.kept.is_empty(), "{:?}", plan.kept);
+ s.execute("file.saveAs", &json!({"path": path, "format": "ai", "options": {"aiVersion": "7"}})).unwrap();
+ let o = s.execute("file.formatOptions", &json!({"format": "ai"})).unwrap();
+ assert_eq!(o["options"]["compress"]["value"], json!(true), "{o}");
+ std::fs::remove_dir_all(&dir).unwrap();
+}
diff --git a/crates/engine/src/cmd/fileio/tests_screens.rs b/crates/engine/src/cmd/fileio/tests_screens.rs
index 51954d8da3..1746be6c97 100644
--- a/crates/engine/src/cmd/fileio/tests_screens.rs
+++ b/crates/engine/src/cmd/fileio/tests_screens.rs
@@ -1163,3 +1163,93 @@ fn single_file_pdfs_hold_every_chosen_artboard() {
assert_eq!(names(&r), ["Artboard-1.pdf", "Artboard-2.pdf"]);
assert!(s.execute("document.exportForScreens", &json!({"pdfs": "both", "formats": [{"format": "pdf"}]})).is_err());
}
+
+/// The warnings of reply `r` that name an option the file doesn't read.
+fn unused(r: &Value) -> Vec {
+ r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).filter(|w| w.contains("was not used")).map(str::to_string).collect()
+}
+
+/// An option of a format row, or of a format's settings, that only another format reads is
+/// accepted, and the reply's warnings name it as the request does, in the words of a save's note.
+#[test]
+fn a_row_option_only_another_format_reads_gets_a_note() {
+ let mut s = session(1);
+ let p = json!({
+ "formats": [{"format": "png", "quality": 80}, {"format": "jpg", "quality": 80}, {"format": "ai", "aiVersion": "7", "compress": true}],
+ "settings": {"png": {"quality": 50, "interlaced": true}, "svg": {"interlaced": true}},
+ });
+ let r = export(&mut s, p);
+ assert_eq!(
+ unused(&r),
+ [
+ "`formats[0].quality` was not used: PNG files don't read it",
+ "`formats[2].compress` was not used: legacy .ai (version 7) files don't read it",
+ "`settings.png.quality` was not used: PNG files don't read it",
+ "`settings.svg.interlaced` was not used: SVG files don't read it"
+ ],
+ "{r}"
+ );
+ // A row's own keys, the keys every row takes and the artboard choices get none.
+ let p = json!({"formats": [
+ {"format": "png", "scale": "2x", "suffix": "@2x", "folder": "x", "includeArtboardFill": false, "artboard": 0, "useArtboards": true},
+ {"format": "pdf", "marks": {"trim": true}, "preset": "High Quality Print"},
+ {"format": "vectorcraft", "compress": true, "range": "1"},
+ ]});
+ let r = export(&mut s, p);
+ assert!(unused(&r).is_empty(), "{r}");
+ // A format's settings are read as each row of that format writes them: a row can name another
+ // `.ai` version than the settings do.
+ let r = export(&mut s, json!({"formats": [{"format": "ai", "aiVersion": "7"}], "settings": {"ai": {"marks": {"trim": true}}}}));
+ assert_eq!(unused(&r), ["`settings.ai.marks` was not used: legacy .ai (version 7) files don't read it"], "{r}");
+ let r = export(&mut s, json!({"formats": [{"format": "ai", "aiVersion": "pdf"}], "settings": {"ai": {"aiVersion": "7", "compress": true}}}));
+ assert!(unused(&r).is_empty(), "{r}");
+ // A setting another row of the format reads gets none; one no row reads names the first row's
+ // file.
+ let rows = json!([{"format": "ai", "aiVersion": "7"}, {"format": "ai"}]);
+ let r = export(&mut s, json!({"formats": rows, "settings": {"ai": {"editingData": false}}}));
+ assert!(unused(&r).is_empty(), "{r}");
+ let r = export(&mut s, json!({"formats": rows, "settings": {"ai": {"quality": 80}}}));
+ assert_eq!(unused(&r), ["`settings.ai.quality` was not used: legacy .ai (version 7) files don't read it"], "{r}");
+ // A row that gives the key itself doesn't use the setting.
+ let rows = json!([{"format": "ai", "aiVersion": "7"}, {"format": "ai", "editingData": true}]);
+ let r = export(&mut s, json!({"formats": rows, "settings": {"ai": {"editingData": false}}}));
+ assert_eq!(unused(&r), ["`settings.ai.editingData` was not used: legacy .ai (version 7) files don't read it"], "{r}");
+ let rows = json!([{"format": "ai", "aiVersion": "7"}, {"format": "ai", "editingData": null}]);
+ let r = export(&mut s, json!({"formats": rows, "settings": {"ai": {"editingData": false}}}));
+ assert_eq!(unused(&r), ["`settings.ai.editingData` was not used: legacy .ai (version 7) files don't read it"], "{r}");
+ // An export writes one file per row and item: a native row doesn't read `separateArtboards`.
+ let r = export(&mut s, json!({"formats": [{"format": "vectorcraft", "separateArtboards": true}]}));
+ assert_eq!(unused(&r), ["`formats[0].separateArtboards` was not used: VectorCraft files don't read it"], "{r}");
+}
+
+/// The Asset Export panel's export notes the options of its request, not those of the settings
+/// the document remembers.
+#[test]
+fn an_asset_export_notes_the_options_it_is_given() {
+ let mut s = session(1);
+ s.execute("select.all", &json!({})).unwrap();
+ s.execute("assets.add", &json!({"multiple": false})).unwrap();
+ s.execute("assets.settings.set", &json!({"formats": [{"format": "png", "quality": 80}]})).unwrap();
+ let r = s.execute("assets.export", &json!({})).unwrap();
+ assert!(unused(&r).is_empty(), "{r}");
+ let r = s.execute("assets.export", &json!({"formats": [{"format": "svg", "quality": 80}]})).unwrap();
+ assert_eq!(unused(&r), ["`formats[0].quality` was not used: SVG files don't read it"], "{r}");
+ // Settings given are read as the remembered rows write them.
+ s.execute("assets.settings.set", &json!({"formats": [{"format": "ai", "aiVersion": "7"}]})).unwrap();
+ let r = s.execute("assets.export", &json!({"settings": {"ai": {"editingData": false}}})).unwrap();
+ assert_eq!(unused(&r), ["`settings.ai.editingData` was not used: legacy .ai (version 7) files don't read it"], "{r}");
+ s.execute("assets.settings.set", &json!({"formats": [{"format": "png", "quality": 80}]})).unwrap();
+ // So does the export a client prepares.
+ let p = json!({"formats": [{"format": "svg", "quality": 80}]});
+ let r = s.prepare_screen_export("assets.export", &p).unwrap().write(&mut |_: &str, _: &[u8]| Ok(())).unwrap();
+ assert_eq!(unused(&r), ["`formats[0].quality` was not used: SVG files don't read it"], "{r}");
+}
+
+/// A preset's options add no notes: Export for Screens notes the options of its request.
+#[test]
+fn an_export_preset_adds_no_notes() {
+ let mut s = session(1);
+ s.execute("document.exportPresets.save", &json!({"name": "Mixed", "settings": {"formats": [{"format": "png", "quality": 80}]}})).unwrap();
+ let r = export(&mut s, json!({"exportPreset": "Mixed"}));
+ assert!(unused(&r).is_empty(), "{r}");
+}
diff --git a/crates/engine/src/cmd/recolor.rs b/crates/engine/src/cmd/recolor.rs
index ede74545b6..07f1f4c46b 100644
--- a/crates/engine/src/cmd/recolor.rs
+++ b/crates/engine/src/cmd/recolor.rs
@@ -1724,7 +1724,7 @@ fn apply(s: &mut Session, p: &Value) -> Result {
let mut out = json!({ "changed": changed, "swatches": added });
not_changed.report(&mut out);
if let Some(e) = edit {
- e.defaults(s);
+ e.defaults(s)?;
out["relinked"] = json!(e.relinked());
}
if converted {
diff --git a/crates/engine/src/cmd/recovery.rs b/crates/engine/src/cmd/recovery.rs
index de4d145840..e5107c247b 100644
--- a/crates/engine/src/cmd/recovery.rs
+++ b/crates/engine/src/cmd/recovery.rs
@@ -1176,6 +1176,7 @@ pub fn jobs(s: &mut Session, store: &Arc) -> Result<(Vec Result {
super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(C, e))?;
Ok(unlinked)
})?;
- map_default_paints(s, &mut unlink);
+ map_default_paints(s, &mut unlink)?;
Ok(json!({"deleted": names, "unlinked": unlinked}))
}
@@ -660,20 +660,41 @@ impl Relink {
renamed || recolored
}
- fn defaults(&self, s: &mut Session) {
- map_default_paints(s, &mut |c, l, t| self.apply(c, l, t));
+ fn defaults(&self, s: &mut Session) -> Result<()> {
+ map_default_paints(s, &mut |c, l, t| self.apply(c, l, t))
}
}
-/// Apply a swatch-link rewrite to the default fill and stroke for new art, except during a live
-/// preview (Cancel rolls the document back, not the session).
-pub(super) fn map_default_paints(s: &mut Session, f: &mut LinkFn) {
- if s.in_interaction() {
- return;
+/// Apply a swatch-link rewrite to the fill and stroke new art takes and to the paints of its
+/// template appearance, except during a live preview (Cancel rolls the document back, not the
+/// session). A `command.batch` step rewrites them as the command run on its own does: a failed
+/// batch, and a canceled batch preview, put back the paints the batch started with. A change is
+/// recorded with the step just made, so Undo and Redo put these paints back with the document.
+pub(super) fn map_default_paints(s: &mut Session, f: &mut LinkFn) -> Result<()> {
+ if s.in_interaction() && s.batch_stash.is_none() {
+ return Ok(());
}
- for p in [&mut s.paint.fill, &mut s.paint.stroke] {
- p.map_links(f);
+ let start = super::colormgmt::new_art_colors_before(s)?;
+ let pd = &mut s.paint;
+ let template = pd.appearance.iter_mut().flat_map(|a| a.items.iter_mut()).map(|it| match it {
+ vectorcraft_doc::AppearanceItem::Fill(f) => &mut f.paint,
+ vectorcraft_doc::AppearanceItem::Stroke(s) => &mut s.paint,
+ });
+ let mut changed = false;
+ for p in [&mut pd.fill, &mut pd.stroke].into_iter().chain(template) {
+ changed |= p.map_links(f);
}
+ if changed {
+ super::colormgmt::record_new_art_colors(s, start)?;
+ // The step takes the paints the command finally leaves, after the selection's appearance
+ // is inherited.
+ let st = s.doc()?;
+ if st.interaction.is_none() {
+ let uid = st.uid;
+ s.batch_paint_docs.push(uid);
+ }
+ }
+ Ok(())
}
/// Apply a swatch-link rewrite to the document ([`Document::map_solid_paints`]) and to the art of
@@ -888,7 +909,7 @@ fn swatch_edit(s: &mut Session, p: &Value) -> Result {
super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(C, e))?;
Ok(relinked)
})?;
- relink.defaults(s);
+ relink.defaults(s)?;
Ok(json!({"name": to, "relinked": relinked}))
}
@@ -1084,7 +1105,7 @@ fn swatch_merge(s: &mut Session, p: &Value) -> Result {
super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(C, e))?;
Ok(relinked)
})?;
- map_default_paints(s, &mut relink);
+ map_default_paints(s, &mut relink)?;
Ok(json!({"name": keep, "merged": merged, "relinked": relinked}))
}
@@ -1141,7 +1162,7 @@ fn swatch_spot_options(s: &mut Session, p: &Value) -> Result {
let relinked = map_swatch_links(d, &mut follow_bases(&bases));
Ok((relinked, bases))
})?;
- map_default_paints(s, &mut follow_bases(&bases));
+ map_default_paints(s, &mut follow_bases(&bases))?;
Ok(json!({"useLab": use_lab, "relinked": relinked}))
}
@@ -1165,8 +1186,11 @@ pub(super) struct GroupEdit {
impl GroupEdit {
/// Apply the link changes to the default fill and stroke for new art.
- pub(super) fn defaults(&self, s: &mut Session) {
- self.relinks.iter().for_each(|r| r.defaults(s));
+ pub(super) fn defaults(&self, s: &mut Session) -> Result<()> {
+ if self.relinks.is_empty() {
+ return Ok(());
+ }
+ map_default_paints(s, &mut |c, l, t| self.relinks.iter().any(|r| r.apply(c, l, t)))
}
/// How many paints linked to the group's swatches the rewrite changed.
@@ -1242,6 +1266,6 @@ fn swatch_edit_group(s: &mut Session, p: &Value) -> Result {
colors.into_iter().enumerate().map(|(i, c)| if old.get(i) == Some(&Some(c)) { c } else { super::paint::new_color(s, p, c) }).collect();
let rename = str_param(p, "rename");
let e = s.edit("Edit Color Group", |d, _| rewrite_group(d, group, &colors, rename, C))?;
- e.defaults(s);
+ e.defaults(s)?;
Ok(json!({"name": e.name, "swatches": e.swatches, "relinked": e.relinked}))
}
diff --git a/crates/engine/src/cmd/swatchlib.rs b/crates/engine/src/cmd/swatchlib.rs
index 8101cfff97..ed712d8509 100644
--- a/crates/engine/src/cmd/swatchlib.rs
+++ b/crates/engine/src/cmd/swatchlib.rs
@@ -980,7 +980,7 @@ fn reset_defaults(s: &mut Session, p: &Value) -> Result {
Ok(added)
})?;
if replace {
- map_default_paints(s, &mut |_, l, _| unlink_dead(&live, l));
+ map_default_paints(s, &mut |_, l, _| unlink_dead(&live, l))?;
}
Ok(json!({ "added": added }))
}
diff --git a/crates/engine/src/doc_state.rs b/crates/engine/src/doc_state.rs
index c3722f6ad2..780616c07a 100644
--- a/crates/engine/src/doc_state.rs
+++ b/crates/engine/src/doc_state.rs
@@ -369,12 +369,13 @@ impl DocState {
/// menu asks on every frame it is open, and measuring builds the solids of 3D effects, so the
/// answer is kept for this version of the document and those artboards, and a change of the
/// selection or the view keeps it. It is measured again when what the measure reads outside the
- /// document changes: the installed plug-ins, whose effects draw art, and the installed fonts,
- /// which set the bounds of all type.
+ /// document changes: the installed plug-ins, whose effects draw art, and the installed fonts and
+ /// the hyphenation exceptions, which set the bounds of all type.
pub fn any_art_to_fit(&self, indices: &[usize]) -> bool {
- self.any_art_to_fit_with(indices, (vectorcraft_plugins::registry::revision(), vectorcraft_text::FontDb::global().generation()))
+ self.any_art_to_fit_with(indices, (vectorcraft_plugins::registry::revision(), vectorcraft_text::layout_inputs()))
}
- /// [`Self::any_art_to_fit`], with `outside` the generations of the plug-ins and the fonts.
+ /// [`Self::any_art_to_fit`], with `outside` the generations of the plug-ins and the layout inputs
+ /// ([`vectorcraft_text::layout_inputs`]).
pub(crate) fn any_art_to_fit_with(&self, indices: &[usize], outside: (u64, u64)) -> bool {
// Measuring runs renderer and plug-in code; should it ever ask again while measuring, it
// is measured without being kept rather than borrowed twice.
diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs
index 5ceaf86aaf..a87b47be0e 100644
--- a/crates/engine/src/session/documents.rs
+++ b/crates/engine/src/session/documents.rs
@@ -25,9 +25,11 @@ impl Session {
// Clicks find an image's own fills and strokes where their effects draw them.
vectorcraft_doc::hit::set_image_paint(vectorcraft_render::effects::image_paint);
// Clicks find a blend's steps and an envelope's distorted art where the canvas draws them,
- // evaluated again when the fonts or the plug-ins change.
+ // evaluated again when the fonts, the hyphenation exceptions or the plug-ins change.
vectorcraft_doc::hit::set_live_art(vectorcraft_render::effects::expand_live, || {
- vectorcraft_text::FontDb::global().generation().rotate_left(32) ^ vectorcraft_render::effects::plugin_revision()
+ let mut h = std::hash::DefaultHasher::new();
+ std::hash::Hash::hash(&(vectorcraft_text::layout_inputs(), vectorcraft_render::effects::plugin_revision()), &mut h);
+ std::hash::Hasher::finish(&h)
});
// Envelopes map type from the bounds of its glyph outlines, underlines and strikethroughs.
vectorcraft_text::register_envelope_ink();
diff --git a/crates/engine/src/tests/bboxrotate.rs b/crates/engine/src/tests/bboxrotate.rs
index aa1c14885c..6dc1c9b410 100644
--- a/crates/engine/src/tests/bboxrotate.rs
+++ b/crates/engine/src/tests/bboxrotate.rs
@@ -560,6 +560,125 @@ fn reset_bounding_box_then_release_intertwine() {
reset_then_release("object.intertwine.make", "object.intertwine.release");
}
+/// A symbol of two rectangles, the first turned `inner` degrees before the symbol is made, placed
+/// as an instance that is then turned 20°.
+fn turned_instance(inner: f64) -> (Session, NodeId) {
+ let mut s = session();
+ let a = rect(&mut s, 100.0, 100.0, 100.0, 50.0);
+ let b = rect(&mut s, 150.0, 220.0, 100.0, 50.0);
+ if inner != 0.0 {
+ sel(&mut s, &[a]);
+ s.execute("object.rotate", &json!({"angle": inner})).unwrap();
+ }
+ sel(&mut s, &[a, b]);
+ let inst = id_of(&s.execute("symbol.new", &json!({"name": "Pair"})).unwrap());
+ sel(&mut s, &[inst]);
+ s.execute("object.rotate", &json!({"angle": 20})).unwrap();
+ assert!(near(angle(&s, inst), 20.0));
+ (s, inst)
+}
+
+/// Break Link and Expand with the parameters that turn instance `inst` into its art.
+fn expand_instance(s: &mut Session, command: &str, inst: NodeId) {
+ sel(s, &[inst]);
+ let params = if command == "symbol.breakLink" { json!({"ids": [inst.0]}) } else { json!({"object": true, "fill": false, "stroke": false}) };
+ s.execute(command, ¶ms).unwrap();
+ assert!(s.doc().unwrap().doc.node(inst).is_none_or(|n| !matches!(n.kind, vectorcraft_doc::NodeKind::SymbolInstance { .. })), "{command}");
+}
+
+/// The angles of the objects inside the document's only top-level object, that object included.
+fn art_angles(s: &Session) -> Vec {
+ let d = &s.doc().unwrap().doc;
+ let layer = d.layers.first().unwrap();
+ let top = layer.children().unwrap();
+ assert_eq!(top.len(), 1);
+ let mut out = vec![];
+ subtree_angles(&top[0], &mut out);
+ out
+}
+
+#[test]
+fn reset_bounding_box_then_expand_symbol_instance() {
+ for command in ["symbol.breakLink", "object.expand"] {
+ let (mut s, inst) = turned_instance(0.0);
+ assert_eq!(s.execute("object.resetBoundingBox", &json!({})).unwrap()["changed"], 1);
+ assert_eq!(angle(&s, inst), 0.0);
+ expand_instance(&mut s, command, inst);
+ let angles = art_angles(&s);
+ assert!(angles.len() >= 2 && angles.iter().all(|a| near(*a, 0.0)), "{command}: {angles:?}");
+ s.execute("select.all", &json!({})).unwrap();
+ assert_eq!(selection_box(&s).angle, 0.0, "{command}");
+ }
+}
+
+#[test]
+fn expanding_a_turned_symbol_instance_keeps_its_angle() {
+ for command in ["symbol.breakLink", "object.expand"] {
+ let (mut s, inst) = turned_instance(0.0);
+ expand_instance(&mut s, command, inst);
+ let angles = art_angles(&s);
+ assert!(angles.len() >= 2 && angles.iter().all(|a| near(*a, 20.0)), "{command}: {angles:?}");
+ }
+}
+
+#[test]
+fn reset_bounding_box_then_expand_keeps_the_angles_inside_the_symbol() {
+ for command in ["symbol.breakLink", "object.expand"] {
+ let (mut s, inst) = turned_instance(30.0);
+ s.execute("object.resetBoundingBox", &json!({})).unwrap();
+ expand_instance(&mut s, command, inst);
+ // The turned rectangle keeps the 30° it had in the symbol; the rest stand square.
+ let mut angles = art_angles(&s);
+ angles.sort_by(f64::total_cmp);
+ assert!(near(*angles.last().unwrap(), 30.0) && angles[..angles.len() - 1].iter().all(|a| near(*a, 0.0)), "{command}: {angles:?}");
+ }
+}
+
+/// The group that an instance's opacity adds around the art of a one-object symbol stands as the
+/// instance's box does, turned or reset.
+#[test]
+fn expanding_a_faded_symbol_instance_gives_its_group_the_instance_box() {
+ for command in ["symbol.breakLink", "object.expand"] {
+ for reset in [false, true] {
+ let mut s = session();
+ let a = rect(&mut s, 100.0, 100.0, 100.0, 50.0);
+ sel(&mut s, &[a]);
+ let inst = id_of(&s.execute("symbol.new", &json!({"name": "One"})).unwrap());
+ sel(&mut s, &[inst]);
+ s.execute("object.rotate", &json!({"angle": 20})).unwrap();
+ s.execute("object.setProps", &json!({"ids": [inst.0], "opacity": 50})).unwrap();
+ if reset {
+ s.execute("object.resetBoundingBox", &json!({})).unwrap();
+ }
+ expand_instance(&mut s, command, inst);
+ let want = if reset { 0.0 } else { 20.0 };
+ let angles = art_angles(&s);
+ assert!(angles.len() == 2 && angles.iter().all(|a| near(*a, want)), "{command}, reset {reset}: {angles:?}");
+ }
+ }
+}
+
+/// A mirrored instance: turned 170° and then reflected, its box stands 180° from the box its
+/// transform gives in one step. The art takes the instance's box, and stands square after a reset.
+#[test]
+fn expanding_a_reflected_symbol_instance_keeps_its_box() {
+ for command in ["symbol.breakLink", "object.expand"] {
+ for reset in [false, true] {
+ let (mut s, inst) = turned_instance(0.0);
+ s.execute("object.rotate", &json!({"angle": 150})).unwrap();
+ s.execute("object.reflect", &json!({"axis": "horizontal"})).unwrap();
+ assert!(near(angle(&s, inst), -170.0), "{}", angle(&s, inst));
+ if reset {
+ s.execute("object.resetBoundingBox", &json!({})).unwrap();
+ }
+ let want = angle(&s, inst);
+ expand_instance(&mut s, command, inst);
+ let angles = art_angles(&s);
+ assert!(angles.len() >= 2 && angles.iter().all(|a| near(*a, want)), "{command}, reset {reset}: {angles:?}");
+ }
+ }
+}
+
#[test]
fn reset_bounding_box_then_release_linked_opacity_mask() {
let mut s = session();
diff --git a/crates/engine/src/tests/params.rs b/crates/engine/src/tests/params.rs
index 6fb8d30467..8a33376075 100644
--- a/crates/engine/src/tests/params.rs
+++ b/crates/engine/src/tests/params.rs
@@ -572,6 +572,64 @@ fn a_malformed_required_number_is_not_reported_as_missing() {
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
}
+/// A required whole number given as something other than a whole number of 0 or more (`"3"`, `2.5`,
+/// `-1`, `true`, `[]`, `{}`) is refused as the wrong type, naming its key, not as a missing integer; one
+/// left out or given as null is still missing. An optional one left out or null keeps its default.
+/// Neither changes the document or records an undo step.
+#[test]
+fn a_malformed_required_integer_is_not_reported_as_missing() {
+ let mut s = Session::new();
+ s.execute("file.new", &json!({})).unwrap();
+ s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap();
+ let doc = s.doc().unwrap().doc.clone();
+ let undo = s.doc().unwrap().history.undo.len();
+ let with = |base: &serde_json::Value, key: &str, v: Option| {
+ let mut p = base.clone();
+ let o = p.as_object_mut().unwrap();
+ match v {
+ Some(v) => o.insert(key.into(), v),
+ None => o.remove(key),
+ };
+ p
+ };
+ let refused = |s: &mut Session, cmd: &str, p: &serde_json::Value, want: &str| match s.execute(cmd, p) {
+ Err(EngineError::BadParams { cmd: c, msg }) => assert_eq!((c.as_str(), msg.as_str()), (cmd, want), "{p}"),
+ other => panic!("{cmd} {p}: {other:?}"),
+ };
+ let ends = json!({"sourceDocument": 1, "grab": [0, 0], "at": [0, 0], "ids": [1]});
+ let cases = [
+ ("appearance.setItem", json!({"index": 0}), "index", "missing integer `index`"),
+ ("path.removeAnchor", json!({"id": 1, "anchor": 0}), "anchor", "missing integer `anchor`"),
+ ("path.split", json!({"id": 1, "anchor": 0}), "anchor", "need `anchor` or `segment` + `t`"),
+ ("addon.pen.removePoint", json!({"anchor": 0}), "anchor", "missing integer `anchor`"),
+ ("edit.dropSelection", ends, "sourceDocument", "missing integer `sourceDocument`"),
+ ("document.activate", json!({"index": 0}), "index", "missing index"),
+ ("document.move", json!({"to": 0}), "to", "missing `to`: a non-negative integer"),
+ ];
+ let malformed = [json!("3"), json!(2.5), json!(-1), json!(true), json!([]), json!({})];
+ for (cmd, base, key, missing) in &cases {
+ for v in &malformed {
+ refused(&mut s, cmd, &with(base, key, Some(v.clone())), &format!("`{key}` must be a whole number"));
+ }
+ for p in [with(base, key, None), with(base, key, Some(json!(null)))] {
+ refused(&mut s, cmd, &p, missing);
+ }
+ }
+ // An optional `subpath` given as anything else is refused the same way; left out or null, it is
+ // subpath 0 and the command goes on to read `anchor`.
+ let base = json!({"id": 1, "anchor": "3"});
+ for v in &malformed {
+ refused(&mut s, "path.removeAnchor", &with(&base, "subpath", Some(v.clone())), "`subpath` must be a whole number");
+ }
+ for p in [with(&base, "subpath", None), with(&base, "subpath", Some(json!(null)))] {
+ refused(&mut s, "path.removeAnchor", &p, "`anchor` must be a whole number");
+ }
+ assert_eq!(s.doc().unwrap().doc, doc);
+ assert_eq!(s.doc().unwrap().history.undo.len(), undo);
+ // A whole JSON number is a whole number.
+ s.execute("document.activate", &json!({"index": 0})).unwrap();
+}
+
/// A command that takes the keys of one that isn't checked yet isn't checked either.
#[test]
fn a_reference_to_an_unchecked_command_is_unchecked() {
diff --git a/crates/engine/src/tests/prefs.rs b/crates/engine/src/tests/prefs.rs
index 1010d9b547..d88b8250a2 100644
--- a/crates/engine/src/tests/prefs.rs
+++ b/crates/engine/src/tests/prefs.rs
@@ -693,7 +693,6 @@ fn use_precise_cursors_swaps_the_tools_own_pointers_for_a_crosshair() {
("blobBrush", Cursor::BlobBrush),
("shaper", Cursor::Shaper),
("pencil", Cursor::PencilNew),
- ("smooth", Cursor::Smooth),
("pathEraser", Cursor::PathEraser),
("join", Cursor::Join),
("eraser", Cursor::Eraser),
@@ -738,6 +737,27 @@ fn use_precise_cursors_swaps_the_tools_own_pointers_for_a_crosshair() {
}
}
+/// With no selected path, the Smooth tool shows Not Allowed, and Use Precise Cursors leaves it so; with a
+/// selected path, it shows its own pointer, or a crosshair with the preference on.
+#[test]
+fn use_precise_cursors_keeps_the_smooth_tools_not_allowed_pointer() {
+ use vectorcraft_tools::{Cursor, Mods};
+ let mut s = Session::new();
+ s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
+ let at = vectorcraft_geom::Point::new(300.0, 300.0);
+ s.select_tool("smooth", ViewInfo::default()).unwrap();
+ for precise in [false, true] {
+ s.execute("prefs.set", &json!({"key": "usePreciseCursors", "value": precise})).unwrap();
+ assert_eq!(s.cursor(at, Mods::default(), ViewInfo::default()), Cursor::NotAllowed, "precise {precise}");
+ }
+ let id = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap();
+ s.execute("select.set", &json!({"ids": [id]})).unwrap();
+ for (precise, c) in [(false, Cursor::Smooth), (true, Cursor::Crosshair)] {
+ s.execute("prefs.set", &json!({"key": "usePreciseCursors", "value": precise})).unwrap();
+ assert_eq!(s.cursor(at, Mods::default(), ViewInfo::default()), c, "precise {precise}");
+ }
+}
+
/// With Alt held while drawing, the Pencil and the Paintbrush show their closing cursors, and Use Precise Cursors
/// turns those into a crosshair too.
#[test]
diff --git a/crates/engine/src/tests/swatches.rs b/crates/engine/src/tests/swatches.rs
index a80769dd98..8abaec348c 100644
--- a/crates/engine/src/tests/swatches.rs
+++ b/crates/engine/src/tests/swatches.rs
@@ -1075,6 +1075,146 @@ fn deleting_a_global_swatch_unlinks_art_that_keeps_its_colour() {
assert_eq!(fill_of(&s, a), linked("#2a6fb0", "Brand"));
}
+/// A session whose default fill and stroke for new art are linked to the global swatch `Brand`
+/// (`#2a6fb0`), in color group `G`, beside the global swatch `Other` (`#ff8800`).
+fn brand_defaults_session() -> Session {
+ let mut s = session();
+ run(&mut s, "swatch.new", json!({"name": "Brand", "color": "#2a6fb0", "global": true}));
+ run(&mut s, "swatch.newGroup", json!({"name": "G", "swatches": ["Brand"]}));
+ run(&mut s, "swatch.new", json!({"name": "Other", "color": "#ff8800", "global": true}));
+ run(&mut s, "select.set", json!({"ids": []}));
+ run(&mut s, "paint.setFill", json!({"swatch": "Brand"}));
+ run(&mut s, "paint.setStroke", json!({"swatch": "Brand"}));
+ assert_eq!(s.paint.fill, linked("#2a6fb0", "Brand"));
+ s
+}
+
+fn fill_and_stroke(s: &Session, id: NodeId) -> (Paint, Paint) {
+ let a = &doc(s).node(id).unwrap().appearance;
+ (a.fill_paint(), a.stroke_paint())
+}
+
+/// Run `steps` and then draw a rectangle, once one command at a time and once as one
+/// `command.batch`, and check that both leave the same default paints and give the rectangle drawn
+/// in the batch, and one drawn after it, the same fill and stroke. Returns the batch's rectangle's
+/// fill and stroke.
+fn batch_matches_steps(steps: &[(&str, Value)]) -> (Paint, Paint) {
+ let shape = ("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}));
+ let mut one_by_one = brand_defaults_session();
+ for (id, p) in steps {
+ run(&mut one_by_one, id, p.clone());
+ }
+ let single = rect(&mut one_by_one);
+ let mut batched = brand_defaults_session();
+ let commands: Vec = steps.iter().chain([&shape]).map(|(id, p)| json!({"command": id, "params": p})).collect();
+ let r = run(&mut batched, "command.batch", json!({ "commands": commands }));
+ let in_batch = NodeId(r["results"][steps.len()]["id"].as_u64().unwrap());
+ assert_eq!(batched.paint.fill, one_by_one.paint.fill, "the default fill after {steps:?}");
+ assert_eq!(batched.paint.stroke, one_by_one.paint.stroke, "the default stroke after {steps:?}");
+ let expected = fill_and_stroke(&one_by_one, single);
+ assert_eq!(fill_and_stroke(&batched, in_batch), expected, "art drawn in the batch after {steps:?}");
+ let later = rect(&mut batched);
+ assert_eq!(fill_and_stroke(&batched, later), expected, "art drawn after the batch after {steps:?}");
+ expected
+}
+
+#[test]
+fn swatch_steps_in_a_batch_change_the_new_art_paints_as_commands_do() {
+ let blue = Paint::solid(Color::from_hex("#2a6fb0").unwrap());
+ assert_eq!(batch_matches_steps(&[("swatch.delete", json!({"name": "Brand"}))]), (blue.clone(), blue));
+ let link = linked("#2a6fb0", "Brand");
+ assert_eq!(batch_matches_steps(&[("swatch.delete", json!({"name": "Brand", "unlink": false}))]), (link.clone(), link));
+ let signal = linked("#00ff00", "Signal");
+ let edit = ("swatch.edit", json!({"name": "Brand", "newName": "Signal", "color": "#00ff00"}));
+ assert_eq!(batch_matches_steps(&[edit]), (signal.clone(), signal));
+ let other = linked("#ff8800", "Other");
+ assert_eq!(batch_matches_steps(&[("swatch.merge", json!({"names": ["Other", "Brand"]}))]), (other.clone(), other));
+ let gray = linked("#ababab", "Brand");
+ assert_eq!(batch_matches_steps(&[("swatch.editGroup", json!({"group": "G", "colors": ["#ababab"]}))]), (gray.clone(), gray));
+}
+
+#[test]
+fn undo_and_redo_of_a_swatch_edit_put_back_the_new_art_paints() {
+ let edit = json!({"name": "Brand", "newName": "Signal", "color": "#00ff00"});
+ let delete = json!({"name": "Brand"});
+ for (id, p, fill) in
+ [("swatch.edit", &edit, linked("#00ff00", "Signal")), ("swatch.delete", &delete, Paint::solid(Color::from_hex("#2a6fb0").unwrap()))]
+ {
+ for batch in [false, true] {
+ let mut s = brand_defaults_session();
+ let before = s.paint.clone();
+ if batch {
+ run(&mut s, "command.batch", json!({"commands": [{"command": id, "params": p}]}));
+ } else {
+ run(&mut s, id, p.clone());
+ }
+ assert_eq!(s.paint.fill, fill, "{id}, batch: {batch}");
+ let after = s.paint.clone();
+ run(&mut s, "edit.undo", json!({}));
+ assert_eq!(s.paint, before, "Undo of {id}, batch: {batch}");
+ run(&mut s, "edit.redo", json!({}));
+ assert_eq!(s.paint, after, "Redo of {id}, batch: {batch}");
+ }
+ }
+}
+
+#[test]
+fn swatch_edits_reach_the_paints_of_the_new_art_template() {
+ let mut s = brand_defaults_session();
+ s.paint.appearance = Some(vectorcraft_doc::Appearance::basic(linked("#2a6fb0", "Brand"), linked("#2a6fb0", "Brand"), 1.0));
+ run(&mut s, "swatch.edit", json!({"name": "Brand", "newName": "Signal", "color": "#00ff00"}));
+ let template = |s: &Session| -> Vec {
+ s.paint
+ .appearance
+ .iter()
+ .flat_map(|a| a.items.iter())
+ .map(|it| match it {
+ vectorcraft_doc::AppearanceItem::Fill(f) => f.paint.clone(),
+ vectorcraft_doc::AppearanceItem::Stroke(k) => k.paint.clone(),
+ })
+ .collect()
+ };
+ assert_eq!(template(&s), [linked("#00ff00", "Signal"), linked("#00ff00", "Signal")]);
+ run(&mut s, "swatch.delete", json!({"name": "Signal"}));
+ let green = Paint::solid(Color::from_hex("#00ff00").unwrap());
+ assert_eq!(template(&s), [green.clone(), green]);
+}
+
+#[test]
+fn a_failed_batch_puts_back_the_new_art_paints_a_swatch_step_changed() {
+ let mut s = brand_defaults_session();
+ let before = s.paint.clone();
+ let steps = json!([
+ {"command": "swatch.edit", "params": {"name": "Brand", "newName": "Signal", "color": "#00ff00"}},
+ {"command": "no.such.command", "params": {}},
+ ]);
+ assert!(s.execute("command.batch", &json!({ "commands": steps })).is_err());
+ assert_eq!(s.paint, before);
+ assert_eq!(doc(&s).swatch("Brand").unwrap().paint.color(), Some(Color::from_hex("#2a6fb0").unwrap()));
+}
+
+#[test]
+fn canceling_a_swatch_options_preview_keeps_the_new_art_paints() {
+ let mut s = brand_defaults_session();
+ let before = s.paint.clone();
+ s.begin_interaction("Swatch Options").unwrap();
+ s.preview("swatch.edit", &json!({"name": "Brand", "newName": "Signal", "color": "#00ff00"})).unwrap();
+ s.cancel_interaction().unwrap();
+ assert_eq!(s.paint, before);
+ assert!(doc(&s).swatch("Signal").is_none());
+ // A previewed batch that is canceled puts them back too, and one that is accepted keeps them.
+ let steps = json!([{"command": "swatch.edit", "params": {"name": "Brand", "newName": "Signal", "color": "#00ff00"}}]);
+ s.begin_interaction("Batch Preview").unwrap();
+ s.preview("command.batch", &json!({ "commands": steps })).unwrap();
+ s.cancel_interaction().unwrap();
+ assert_eq!(s.paint, before);
+ s.begin_interaction("Batch Preview").unwrap();
+ s.preview("command.batch", &json!({ "commands": steps })).unwrap();
+ s.commit_interaction().unwrap();
+ assert_eq!(s.paint.fill, linked("#00ff00", "Signal"));
+ assert_eq!(s.paint.stroke, linked("#00ff00", "Signal"));
+}
+
#[test]
fn deleting_several_swatches_and_groups() {
let mut s = session();
diff --git a/crates/engine/src/tests/symbol_nine_slice.rs b/crates/engine/src/tests/symbol_nine_slice.rs
index b0689d1587..cba8b8c984 100644
--- a/crates/engine/src/tests/symbol_nine_slice.rs
+++ b/crates/engine/src/tests/symbol_nine_slice.rs
@@ -20,9 +20,18 @@ fn close(a: Rect, b: Rect) -> bool {
/// A 100 × 100 frame with a 10 × 10 square in its top-left corner and one in its center, made
/// into symbol "Panel" with 9-slice guides on, its instance selected at its natural size.
fn panel_symbol() -> (Session, NodeId) {
+ panel_symbol_filled(None)
+}
+
+/// [`panel_symbol`] with the frame filled with `fill` when there is one.
+fn panel_symbol_filled(fill: Option) -> (Session, NodeId) {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
let ids = [rect(&mut s, 50.0, 50.0, 100.0, 100.0), rect(&mut s, 50.0, 50.0, 10.0, 10.0), rect(&mut s, 95.0, 95.0, 10.0, 10.0)];
+ if let Some(fill) = fill {
+ let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
+ doc.node_mut(ids[0]).unwrap().appearance = vectorcraft_doc::Appearance::basic(fill, vectorcraft_color::Paint::None, 0.0).into();
+ }
s.select(|_, sel| sel.set(ids)).unwrap();
let r = s.execute("symbol.new", &json!({"name": "Panel", "nineSlice": true})).unwrap();
assert_eq!(r["nineSlice"], json!([0.25, 0.25, 0.25, 0.25]));
@@ -350,3 +359,39 @@ fn guides_outside_the_art_survive_saving_and_reopening_a_native_file() {
assert!(back.symbol_slice_map(symbol, *xf).is_none(), "{bad}: scaled as a whole");
}
}
+
+/// [`panel_symbol`] with a gradient on the frame, so that the frame is drawn in a clip group for
+/// each cell, its instance resized to 200 × 150 and turned 20°.
+fn turned_gradient_panel() -> (Session, NodeId) {
+ let gradient = vectorcraft_color::Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient::default())));
+ let (mut s, id) = panel_symbol_filled(Some(gradient));
+ s.execute("object.setBounds", &json!({"x": 50, "y": 50, "width": 200, "height": 150, "reference": 0})).unwrap();
+ s.execute("object.rotate", &json!({"angle": 20})).unwrap();
+ (s, id)
+}
+
+/// The bounding box angles of the objects under the art that replaced an instance, and whether
+/// that art holds a clip group.
+fn expanded_angles(s: &Session, art: NodeId) -> (Vec, bool) {
+ let (mut angles, mut clips) = (vec![], false);
+ s.doc().unwrap().doc.node(art).unwrap().walk(&mut |c| {
+ angles.push(c.bbox_angle);
+ clips |= matches!(c.kind, NodeKind::Group { clip: true, .. });
+ });
+ (angles, clips)
+}
+
+#[test]
+fn break_link_turns_the_cells_of_a_sliced_instance_with_its_box() {
+ for reset in [false, true] {
+ let (mut s, _) = turned_gradient_panel();
+ if reset {
+ assert_eq!(s.execute("object.resetBoundingBox", &json!({})).unwrap()["changed"], 1);
+ }
+ let r = s.execute("symbol.breakLink", &json!({})).unwrap();
+ let (angles, clips) = expanded_angles(&s, NodeId(r["ids"][0].as_u64().unwrap()));
+ assert!(clips, "the frame is drawn in clip groups");
+ let want = if reset { 0.0 } else { 20.0 };
+ assert!(angles.iter().all(|a| (a - want).abs() < 1e-6), "reset {reset}: {angles:?}");
+ }
+}
diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs
index 967c982ad4..0f58e00181 100644
--- a/crates/eps/src/ai_editing/tests.rs
+++ b/crates/eps/src/ai_editing/tests.rs
@@ -3255,7 +3255,8 @@ fn images_are_written_again_as_the_same_bytes() {
/// The program of `doc` with LF line ends, and each image's samples (from after `XI` and its line
/// end to `%%EndData`, a `%_` before it kept) and each preview's rows (from after `32 17 ` to the
-/// line end before `%AI26_EndPlacedObjectPreview`) shown as ``.
+/// line end before `%AI26_EndPlacedObjectPreview` or `%_%AI26_EndPlacedObjectPreview`) shown as
+/// ``.
fn program_shown(doc: &Document) -> String {
shown(&export_ai_editing(doc, &opts()).unwrap().program)
}
@@ -3267,10 +3268,13 @@ fn shown(p: &[u8]) -> String {
let mut at = 0;
loop {
let xi = find(at, b"\r\nXI\r\n").map(|i| (i + 6, b"%%EndData".as_slice()));
- let pv = find(at, b" 32 17 ").map(|i| (i + 7, b"\r\n%AI26_EndPlacedObjectPreview".as_slice()));
+ let pv = find(at, b" 32 17 ").map(|i| (i + 7, b"%AI26_EndPlacedObjectPreview".as_slice()));
let Some((start, end)) = [xi, pv].into_iter().flatten().min_by_key(|(s, _)| *s) else { break };
let mut stop = find(start, end).unwrap();
- if end.starts_with(b"%%") && p.get(stop - 2..stop) == Some(b"%_") {
+ if p.get(stop - 2..stop) == Some(b"%_") {
+ stop -= 2;
+ }
+ if end.starts_with(b"%AI26") && p.get(stop - 2..stop) == Some(b"\r\n") {
stop -= 2;
}
out.extend_from_slice(&p[at..start]);
@@ -7354,18 +7358,147 @@ fn an_image_on_hidden_lines_does_not_come_in_beside_its_look() {
assert_eq!(art[0].name.as_deref(), Some("Photo"));
}
-/// A linked image, an image with an opacity mask, and an image with a Warp and another effect are
-/// written as their drawn art, with no art style.
+/// [`warped_photo`] linked to `/Users/someone/Job/art/photo.png`, in folder `art` next to the
+/// document's.
+fn linked_warped_photo(doc: &mut Document, effects: Vec) -> Node {
+ let mut n = warped_photo(doc, effects);
+ if let NodeKind::Image(im) = &mut n.kind {
+ let mut link = vectorcraft_doc::LinkInfo::new("/Users/someone/Job/art/photo.png");
+ link.relative = Some("art/photo.png".into());
+ im.link = Some(link);
+ }
+ n
+}
+
+/// A linked image with one Warp is written with an art style of that Warp, its bent pieces as its
+/// drawn look and the image itself as a placed file on `%_` lines, with its path, folder and
+/// preview: it reads back as the linked image, its pixels, placement, name, opacity and link as
+/// they were, with its Warp live and its settings kept.
+#[test]
+fn a_linked_image_with_a_warp_reads_back_linked_with_its_warp_live() {
+ // GUARDS: a linked image with a Warp was written as its drawn pieces alone, and read back
+ // without its Warp or its link.
+ let params = serde_json::json!({"bend": 40.0});
+ let doc = photo_doc(|d| linked_warped_photo(d, vec![fx("warp.arc", params.clone())]));
+ let out = export_ai_editing(&doc, &opts()).unwrap();
+ assert!(out.warnings.is_empty(), "{:?}", out.warnings);
+ let text = program_shown(&doc);
+ assert!(text.contains("\n%_%AI5_BeginPlace\n"), "{text}");
+ assert!(text.contains("\n%_(/Users/someone/Job/art/photo.png)`\n"), "{text}");
+ assert!(text.contains("\n%_(art/)\n"), "{text}");
+ assert!(text.contains("\n%_8 8 32 32 17 <256 bytes>\n"), "{text}");
+ assert!(text.contains("\n%_%AI5_EndPlace\n") && text.contains("\n1 (Anon 1) XW\n"), "{text}");
+ assert!(!text.contains("BeginRaster\n%_"), "no embedded copy: {text}");
+ let back = read_back(&doc);
+ let art = back.layers[0].children().unwrap();
+ assert_eq!(art.len(), 1, "{:#?}", lines(&back));
+ let (mut want, mut got) = (vec![], vec![]);
+ pictures(&doc, doc.layers[0].children().unwrap(), "", &mut want);
+ pictures(&back, art, "", &mut got);
+ assert_eq!(got, want);
+ let [warp] = art[0].appearance.effects.as_slice() else { panic!("{:?}", art[0].appearance) };
+ assert_eq!(warp.id, "warp.arc");
+ let (w, g) = (vectorcraft_effects::merged_params("warp.arc", ¶ms), vectorcraft_effects::merged_params(&warp.id, &warp.params));
+ for key in ["bend", "horizontal", "vertical"] {
+ assert!((g[key].as_f64().unwrap() - w[key].as_f64().unwrap()).abs() < 6e-3, "{key}: {g} for {w}");
+ }
+}
+
+/// A linked image with one Warp larger than a placed file's preview: its copy's preview and its drawn
+/// look's pieces (placed files of the same image) hold the same smaller preview, so fine detail
+/// reads back with the Warp live, the link kept and the preview's pixels, as a linked image without
+/// a Warp does.
+#[test]
+fn a_large_linked_image_with_a_warp_reads_back_from_its_preview() {
+ // GUARDS: a linked image with a Warp was written as its drawn pieces alone.
+ let mut doc = Document::new(200.0, 100.0);
+ let layer = doc.layers[0].id;
+ let checks =
+ image::RgbaImage::from_fn(600, 300, |x, y| if (x + y) % 2 == 0 { image::Rgba([255, 0, 0, 255]) } else { image::Rgba([0, 0, 255, 255]) });
+ let mut link = vectorcraft_doc::LinkInfo::new("/Users/someone/Job/art/checks.png");
+ link.relative = Some("art/checks.png".into());
+ let xf = Affine::new([0.25, 0.0, 0.0, 0.25, 20.0, 10.0]);
+ let mut n = image_node(&mut doc, ImageBlob::png(png_of(checks.into())), (600, 300), xf, Some(link));
+ n.appearance = Appearance { effects: vec![fx("warp.arc", serde_json::json!({"bend": 40.0}))], ..Appearance::default() }.into();
+ doc.insert(Some(layer), usize::MAX, n).unwrap();
+ let out = export_ai_editing(&doc, &opts()).unwrap();
+ assert!(out.warnings.is_empty(), "{:?}", out.warnings);
+ let text = program_shown(&doc);
+ assert!(text.contains("\n%_256 128 1024 32 17 <131072 bytes>\n"), "the copy's preview");
+ assert!(text.contains("\n256 128 1024 32 17 <131072 bytes>\n"), "the look's pieces' previews");
+ let back = read_back(&doc);
+ let art = back.layers[0].children().unwrap();
+ assert_eq!(art.len(), 1, "{:#?}", lines(&back));
+ let NodeKind::Image(im) = &art[0].kind else { panic!("{:?}", art[0].kind) };
+ assert_eq!((im.width, im.height), (256, 128));
+ assert_eq!(
+ im.link.as_ref().map(|l| (l.path.as_str(), l.relative.as_deref())),
+ Some(("/Users/someone/Job/art/checks.png", Some("art/checks.png")))
+ );
+ assert_eq!(art[0].appearance.effects.iter().map(|e| e.id.as_str()).collect::>(), ["warp.arc"]);
+}
+
+/// A hidden or locked image with one Warp, embedded or linked, keeps its state, its link and its
+/// live Warp.
+#[test]
+fn a_hidden_or_locked_image_with_a_warp_keeps_its_state() {
+ // GUARDS: a linked image with a Warp was written as its drawn pieces alone, and a hidden image's
+ // Warp was compared with a look that drew nothing, so it came in as its drawn pieces.
+ for (what, make) in [("embedded", warped_photo as fn(&mut Document, Vec) -> Node), ("linked", linked_warped_photo)] {
+ for (shown, locked) in [(true, true), (false, false), (false, true)] {
+ let doc = photo_doc(|d| {
+ let mut n = make(d, vec![fx("warp.arc", serde_json::json!({"bend": 40.0}))]);
+ (n.visible, n.locked) = (shown, locked);
+ n
+ });
+ let out = export_ai_editing(&doc, &opts()).unwrap();
+ assert!(out.warnings.is_empty(), "{what} {shown} {locked}: {:?}", out.warnings);
+ let back = read_back(&doc);
+ let art = back.layers[0].children().unwrap();
+ assert_eq!(art.len(), 1, "{what} {shown} {locked}: {:#?}", lines(&back));
+ assert_eq!(images_of(&back).len(), 1, "{what} {shown} {locked}");
+ assert_eq!(images_of(&back), images_of(&doc), "{what} {shown} {locked}");
+ assert_eq!(art[0].appearance.effects.iter().map(|e| e.id.as_str()).collect::>(), ["warp.arc"]);
+ }
+ }
+}
+
+/// A linked image with one Warp in an opacity mask's art keeps its live Warp; its copy on `%_` lines
+/// is embedded, as linked images in masks are, with that warning.
+#[test]
+fn a_linked_image_with_a_warp_in_a_mask_is_embedded_with_its_warp_live() {
+ // GUARDS: the placed file of a styled image written in an opacity mask's art.
+ let doc = photo_doc(|d| {
+ let art = linked_warped_photo(d, vec![fx("warp.arc", serde_json::json!({"bend": 40.0}))]);
+ let mut square =
+ Node::path(d.alloc_id(), shapes::rectangle(Rect::new(20.0, 10.0, 100.0, 90.0)), Appearance::basic(solid(Color::BLACK), Paint::None, 0.0));
+ square.extras_mut().mask = Some(Box::new(OpacityMask::new(art, false)));
+ square
+ });
+ let out = export_ai_editing(&doc, &opts()).unwrap();
+ assert_eq!(out.warnings, [writer::LINKS_EMBEDDED]);
+ let text = program_shown(&doc);
+ assert!(!text.contains("BeginPlace") && text.contains("1 (Anon 1) XW"), "{text}");
+ let back = read_back(&doc);
+ let mask = back.layers[0].children().unwrap()[0].extras().mask.as_ref().map(|m| m.art.clone()).expect("the mask");
+ let NodeKind::Image(im) = &mask.kind else { panic!("{:?}", mask.kind) };
+ assert!(im.link.is_none());
+ assert_eq!(mask.appearance.effects.iter().map(|e| e.id.as_str()).collect::>(), ["warp.arc"]);
+}
+
+/// An image with an opacity mask and an image with a Warp and another effect are written as their
+/// drawn art, with no art style, linked or embedded.
#[test]
fn other_warped_images_are_written_as_their_drawn_art() {
let arc = || fx("warp.arc", serde_json::json!({"bend": 40.0}));
- let linked = photo_doc(|d| {
- let mut n = warped_photo(d, vec![arc()]);
- if let NodeKind::Image(im) = &mut n.kind {
- im.link = Some(vectorcraft_doc::LinkInfo::new("/art/photo.png"));
- }
+ let linked_masked = photo_doc(|d| {
+ let mut n = linked_warped_photo(d, vec![arc()]);
+ let white =
+ Node::path(d.alloc_id(), shapes::rectangle(Rect::new(20.0, 10.0, 100.0, 90.0)), Appearance::basic(solid(Color::WHITE), Paint::None, 0.0));
+ n.extras_mut().mask = Some(Box::new(OpacityMask::new(white, false)));
n
});
+ let linked_stacked = photo_doc(|d| linked_warped_photo(d, vec![arc(), fx("stylize.feather", serde_json::json!({"radius": 2.0}))]));
let masked = photo_doc(|d| {
let mut n = warped_photo(d, vec![arc()]);
let white =
@@ -7374,7 +7507,7 @@ fn other_warped_images_are_written_as_their_drawn_art() {
n
});
let stacked = photo_doc(|d| warped_photo(d, vec![arc(), fx("stylize.feather", serde_json::json!({"radius": 2.0}))]));
- for (what, doc) in [("linked", linked), ("masked", masked), ("stacked", stacked)] {
+ for (what, doc) in [("linked masked", linked_masked), ("linked stacked", linked_stacked), ("masked", masked), ("stacked", stacked)] {
assert!(!program_shown(&doc).contains("1 (Anon"), "{what}");
let back = read_back(&doc);
let art = back.layers[0].children().unwrap();
diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs
index 879eb9d9f5..765856753c 100644
--- a/crates/eps/src/ai_editing/writer.rs
+++ b/crates/eps/src/ai_editing/writer.rs
@@ -206,6 +206,9 @@ pub(super) struct Writer<'a> {
mask_level: Option<(u32, u32)>,
/// The image just written was written as a placed file.
placed: bool,
+ /// The image being written is the copy of a styled object on the layers, on `%_` lines: linked,
+ /// it is written as a placed file there ([`Self::styled_object`]).
+ styled_copy: bool,
/// The type just written was written as type, not as its outlines.
typed: bool,
/// The objects bound to variables so far, in the order they are written: each one's id, its XML
@@ -299,6 +302,7 @@ impl<'a> Writer<'a> {
hidden: 0,
mask_level: None,
placed: false,
+ styled_copy: false,
typed: false,
bound: vec![],
texts: texts::TextDoc::default(),
diff --git a/crates/eps/src/ai_editing/writer/images.rs b/crates/eps/src/ai_editing/writer/images.rs
index 36c21c14eb..355f5c2709 100644
--- a/crates/eps/src/ai_editing/writer/images.rs
+++ b/crates/eps/src/ai_editing/writer/images.rs
@@ -94,7 +94,8 @@ impl Samples {
impl Writer<'_> {
/// Image object `im` of transparency `xy`: embedded, a raster ([`Self::raster`]); linked, a
/// placed file with a preview ([`Self::placed_file`]), except in a part of the data written on
- /// `%_` lines and in a symbol's art. One whose pixels can't be read is its outline filled mid gray. Where the art is
+ /// `%_` lines (other than a styled object's copy on the layers, [`Self::styled_object`]) and in
+ /// a symbol's art. One whose pixels can't be read is its outline filled mid gray. Where the art is
/// cut to a clipping path, the image is in a clipping group of that path.
pub(super) fn image(&mut self, im: &ImageObject, xy: Xy) {
let doc = self.doc;
@@ -125,7 +126,7 @@ impl Writer<'_> {
let fit =
Affine::scale_non_uniform(f64::from(im.width.max(1)) / f64::from(samples.width), f64::from(im.height.max(1)) / f64::from(samples.height));
match &im.link {
- Some(link) if self.hidden == 0 && !self.in_symbol => {
+ Some(link) if (self.hidden == 0 || self.styled_copy) && !self.in_symbol => {
// Cut to a region, the placed file is in a clipping group.
self.placed = cut.is_none();
self.placed_file(link, &samples, (im.width.max(1), im.height.max(1)), im.xf);
diff --git a/crates/eps/src/ai_editing/writer/styles.rs b/crates/eps/src/ai_editing/writer/styles.rs
index 163d70381c..5a80e50a78 100644
--- a/crates/eps/src/ai_editing/writer/styles.rs
+++ b/crates/eps/src/ai_editing/writer/styles.rs
@@ -42,7 +42,7 @@ pub(in crate::ai_editing) fn style_links(doc: &Document) -> usize {
/// The objects of `doc`'s layers and of their opacity masks' art that may be written with an art
/// style ([`Writer::styled_object`]), by id (a mask's art root shown, as [`Writer::mask`] writes
/// it): paths and compound paths with points and groups, with live effects, linked to a graphic
-/// style or (paths and compound paths) with a brush on a stroke, and embedded images with one Warp
+/// style or (paths and compound paths) with a brush on a stroke, and images with one Warp
/// ([`warped_image`]), without Opacity & Mask Define
/// Knockout Shape (the reader takes an object's transparency
/// from its look, which doesn't carry it). A group's members are paths, compound paths or such
@@ -100,12 +100,11 @@ fn candidate(n: &Node) -> bool {
}
}
-/// Is `n` an embedded image without an opacity mask whose appearance is one Warp, with no fills or
-/// strokes that paint and no effects on them: an image the reader reads with its Warp live
-/// ([`crate::import::read_art_style`]'s `image_warp`)?
+/// Is `n` an image, embedded or linked, without an opacity mask whose appearance is one Warp, with
+/// no fills or strokes that paint and no effects on them: an image the reader reads with its Warp
+/// live ([`crate::import::read_art_style`]'s `image_warp`)?
fn warped_image(n: &Node) -> bool {
- let NodeKind::Image(im) = &n.kind else { return false };
- im.link.is_none()
+ matches!(n.kind, NodeKind::Image(_))
&& n.extras().mask.is_none()
&& matches!(n.appearance.effects.as_slice(), [e] if e.id.starts_with("warp."))
&& n.appearance.items.iter().all(|i| i.paint().is_none() && i.effects().is_empty())
@@ -725,7 +724,8 @@ impl Writer<'_> {
/// glows or feathers, the object's transparency
/// after the group's end, `0 0 Xd`, `6 () XW`); the object itself on `%_` lines (a path's or
/// compound path's points painted with nothing, or with its gradient fill when its style has
- /// one; a group with its members; an image with its pixels), its name and opacity mask; then
+ /// one; a group with its members; an image with its pixels, or on the layers a linked image as
+ /// a placed file with its path, folder and preview), its name and opacity mask; then
/// `1 (name) XW`.
/// The look's group and the object itself are counted first ([`Self::take_objects`]): without
/// room for them, nothing is written, and the object counts neither as written with its graphic
@@ -772,6 +772,9 @@ impl Writer<'_> {
// The object, on `%_` lines, in its own state (the look's members may have changed it).
self.state(orig.locked, !orig.visible);
let saved = std::mem::take(&mut self.body);
+ // On the layers, a linked image's copy is a placed file; in an opacity mask's art, it is
+ // embedded, as other linked images there are.
+ let on_layers = self.hidden == 0;
self.hidden += 1;
let fit = drawn_outline(n, orig).map(|p| p.bounding_box());
// A path's or an image's objects were kept for it above; it counts them itself.
@@ -781,7 +784,11 @@ impl Writer<'_> {
match &orig.kind {
NodeKind::Path { path, rule, .. } => self.styled_outline(orig, path, *rule, fit, xy),
NodeKind::Compound { rule, .. } => self.styled_outline(orig, &compound_path(orig), *rule, fit, xy),
- NodeKind::Image(im) => self.image(&self.doc.shown_image(orig, im), xy),
+ NodeKind::Image(im) => {
+ let was = std::mem::replace(&mut self.styled_copy, on_layers);
+ self.image(&self.doc.shown_image(orig, im), xy);
+ self.styled_copy = was;
+ }
NodeKind::Group { children, .. } => {
self.line("u");
for c in children {
diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs
index b77bc32199..d31ada250c 100644
--- a/crates/eps/src/import/ai/read.rs
+++ b/crates/eps/src/import/ai/read.rs
@@ -8,7 +8,8 @@
//! next layers: a clipping group in each layer, read by [`masks`].
//! - Ruler guides: a straight `(N) *` guide path, directly on a layer, that runs across the whole
//! canvas (16,383 points around the template box's centre) or from edge to edge of one artboard
-//! is a ruler guide on that layer ([`Document::guides`]); other guide paths stay paths.
+//! is a ruler guide on that layer ([`Document::guides`]), shown or hidden, with the path's name,
+//! lock and visibility; other guide paths stay paths.
//! - Groups `u` … `U`, compound paths `*u` … `*U`, clipping groups `q` … `Q` (the path painted
//! with `W` clips); a group's transparency and name follow its end. A wraparound group `*w` …
//! `*W` (version 7 specification §15.4.2) is a group whose objects other than its type wrap
diff --git a/crates/eps/src/import/ai/read/art.rs b/crates/eps/src/import/ai/read/art.rs
index 7e0f91e993..3338968895 100644
--- a/crates/eps/src/import/ai/read/art.rs
+++ b/crates/eps/src/import/ai/read/art.rs
@@ -234,11 +234,10 @@ impl<'a> Reader<'a> {
}
let kind = NodeKind::Path { path, rule: gs.rule, live: None, clipping: clip, guide };
let mut node = self.new_node(kind, &gs);
- // A ruler guide is written as a straight `(N) *` guide path on its layer.
+ // A ruler guide is written as a straight `(N) *` guide path on its layer, shown or hidden.
if guide
&& op == "N"
&& !hidden
- && !gs.hidden
&& !clip
&& self.pattern.is_none()
&& matches!(self.frames.last(), Some(Frame { kind: Kind::Layer(_), .. }))
diff --git a/crates/eps/src/import/ai/read/document.rs b/crates/eps/src/import/ai/read/document.rs
index ded114bc67..a7451c8b99 100644
--- a/crates/eps/src/import/ai/read/document.rs
+++ b/crates/eps/src/import/ai/read/document.rs
@@ -1445,7 +1445,8 @@ fn ruler_guide(a: Point, b: Point, centre: Option<(f64, f64)>, boards: &[Rect])
}
/// Makes the children of the layers and sublayers of `nodes` that `pick` makes a guide of into
-/// ruler guide nodes with the same ids, moved to the top of their layer in their order.
+/// ruler guide nodes with the same ids, names, locks and visibility, moved to the top of their layer
+/// in their order.
fn take_ruler_guides(nodes: &mut [Arc], pick: &dyn Fn(NodeId) -> Option, depth: usize) {
if depth > MAX_DEPTH {
return;
@@ -1458,7 +1459,11 @@ fn take_ruler_guides(nodes: &mut [Arc], pick: &dyn Fn(NodeId) -> Option {
- guides.push(Arc::new(Node::new(k.id, NodeKind::RulerGuide { vertical: g.vertical, pos: g.pos, artboard: g.artboard })));
+ let mut n = Node::new(k.id, NodeKind::RulerGuide { vertical: g.vertical, pos: g.pos, artboard: g.artboard });
+ n.name = k.name.clone();
+ n.locked = k.locked;
+ n.visible = k.visible;
+ guides.push(Arc::new(n));
false
}
None => true,
diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs
index c1a6aa0385..87ba9752ef 100644
--- a/crates/eps/src/import/ai/read/ops.rs
+++ b/crates/eps/src/import/ai/read/ops.rs
@@ -1141,7 +1141,8 @@ fn image_warps(object: &Node, st: &crate::import::ai::style::ArtStyle) -> bool {
/// Does image `object` with `appearance` (its style's Warp) draw as `look`, its drawn look, compared
/// over all that the warp draws ([`crate::import::ai::envelope::warped_alike`]) on `layer`, a layer
-/// id no other node has? Both are drawn shown, opaque and in the Normal mode. Unless the look is on
+/// id no other node has? Both are drawn shown, opaque and in the Normal mode; a hidden look is drawn
+/// with every object in it shown, as the hidden object's look holds them. Unless the look is on
/// `%_` lines itself (`hidden`), the copies in it that are on `%_` lines (`hidden_ids`) are left out.
#[allow(clippy::too_many_arguments)]
fn image_warp_alike(
@@ -1161,12 +1162,24 @@ fn image_warp_alike(
if !hidden {
strip(&mut plain, hidden_ids);
}
+ if !plain.visible {
+ show_members(&mut plain);
+ }
for n in [&mut live, &mut plain] {
(n.visible, n.opacity, n.blend) = (true, 1.0, BlendMode::Normal);
}
crate::import::ai::envelope::warped_alike(&live, &plain, layer, shared, page, spent)
}
+/// Show every object in container `n`, at any depth.
+fn show_members(n: &mut Node) {
+ for c in n.children_mut().into_iter().flatten() {
+ let c = Arc::make_mut(c);
+ c.visible = true;
+ show_members(c);
+ }
+}
+
/// What [`placed_paints`] keys a pattern's name with, apart from the gradients' names.
const PATTERN_KEY: &str = "\u{1}pattern ";
diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs
index f1e201824d..8a9bab7530 100644
--- a/crates/eps/src/import/ai/tests.rs
+++ b/crates/eps/src/import/ai/tests.rs
@@ -8832,7 +8832,8 @@ fn other_guide_paths_stay_guide_paths() {
guide_line(0.0, 30.0, 200.0, 30.0, "S"),
guide_line(-7885.0, -7796.0, 8498.0, 8587.0, "N"),
format!("u\n{}U\n", guide_line(-7885.0, 40.0, 8498.0, 40.0, "N")),
- format!("1 Xw\n{}0 Xw\n", guide_line(-7885.0, 40.0, 8498.0, 40.0, "N")),
+ format!("1 Xw\n{}0 Xw\n", guide_line(20.0, 40.0, 80.0, 40.0, "N")),
+ format!("1 A\n{}0 A\n", guide_line(-7885.0, 40.0, 8498.0, 40.0, "S")),
"-7885 40 m\n306 40 L\n8498 40 L\n(N) *\n".to_string(),
];
for body in cases {
@@ -8842,6 +8843,35 @@ fn other_guide_paths_stay_guide_paths() {
}
}
+/// A hidden (`1 Xw`), locked (`1 A`) or named ruler guide path is a ruler guide that keeps its own
+/// visibility, lock and name, across the canvas or an artboard, on a shown layer.
+#[test]
+fn hidden_locked_and_named_guide_paths_are_ruler_guides() {
+ let body = format!(
+ "1 Xw\n{}0 Xw\n1 A\n{}0 A\n{}%_/ArtDictionary :\n%_(Mark) /String (AIArtName) ,\n%_;\n%_\n1 Xw\n{}0 Xw\n",
+ guide_line(-7885.0, 40.0, 8498.0, 40.0, "N"),
+ guide_line(25.0, 8587.0, 25.0, -7796.0, "N"),
+ guide_line(-7885.0, 90.0, 8498.0, 90.0, "N"),
+ guide_line(0.0, 30.0, 200.0, 30.0, "N"),
+ );
+ let doc = read(canvas_stream(&layer(&body)).as_bytes()).unwrap().doc;
+ let top = &doc.layers[0];
+ assert!(top.visible);
+ let kids = top.children().unwrap();
+ assert_eq!(kids.len(), 4);
+ assert!(kids.iter().all(|c| c.is_ruler_guide()), "no guide path is left");
+ let got: Vec<_> = doc.ruler_guides().into_iter().map(|g| (g.vertical, g.pos, g.artboard, g.layer, g.hidden, g.locked, g.name)).collect();
+ let want = [
+ (false, 60.0, None, Some(top.id), true, false, None),
+ (true, 25.0, None, Some(top.id), false, true, None),
+ (false, 10.0, None, Some(top.id), false, false, Some("Mark".to_string())),
+ (false, 70.0, Some(1), Some(top.id), true, false, None),
+ ];
+ assert_eq!(got, want);
+ // The nodes themselves carry the state, so the Layers rows show it.
+ assert_eq!(kids.iter().map(|k| (k.visible, k.locked)).collect::>(), [(false, false), (true, true), (true, false), (false, false)]);
+}
+
/// The layer whose `Lb` has `ops` as its options (the first one in the file).
fn layer_with(ops: &str) -> Node {
let doc = read_ok(&format!("%AI5_BeginLayer\n{ops} Lb\n(L) Ln\nLB\n%AI5_EndLayer--\n")).doc;
diff --git a/crates/eps/src/legacy_ai/tests.rs b/crates/eps/src/legacy_ai/tests.rs
index 15d7d07152..4436c3a09f 100644
--- a/crates/eps/src/legacy_ai/tests.rs
+++ b/crates/eps/src/legacy_ai/tests.rs
@@ -2067,3 +2067,17 @@ fn colr_glyphs_of_outlined_type_are_vector_art() {
assert!(rasters(&text).is_empty(), "no images: {text}");
assert!(text.contains("1 0 0 Xa"), "the red layer: {text}");
}
+
+/// An instance stained blue by its fill writes its symbol's red art in blue, as the canvas draws it.
+#[test]
+fn a_stained_instance_is_written_in_its_stained_color() {
+ let mut doc = Document::new(200.0, 100.0);
+ let art = shape(&mut doc, shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 40.0)), red(), Paint::None);
+ doc.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Mark".into(), art: Arc::new(art) });
+ let mut n = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "Mark".into(), xf: Affine::translate((50.0, 30.0)) });
+ n.appearance = Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), Paint::None, 0.0).into();
+ let l = doc.layers[0].id;
+ doc.insert(Some(l), 0, n).unwrap();
+ let text = String::from_utf8(write(&doc).bytes).unwrap();
+ assert!(text.contains("0 0 1 Xa") && !text.contains("1 0 0 Xa"), "{text}");
+}
diff --git a/crates/eps/src/legacy_ai/writer.rs b/crates/eps/src/legacy_ai/writer.rs
index ad0ccbce43..eda25a6322 100644
--- a/crates/eps/src/legacy_ai/writer.rs
+++ b/crates/eps/src/legacy_ai/writer.rs
@@ -297,7 +297,7 @@ impl<'a> Writer<'a> {
self.warn(LIVE);
if let Some(sym) = self.doc.symbols.iter().find(|s| &s.name == symbol)
&& self.nest < MAX_NEST
- && let Some(mut art) = self.doc.place_symbol_art(symbol, &vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n), *xf)
+ && let Some(mut art) = self.doc.place_symbol_art(symbol, &vectorcraft_doc::symbol_overrides::instance_art(&sym.art, n), *xf)
{
art.visible = true;
self.nest += 1;
diff --git a/crates/eps/src/scene.rs b/crates/eps/src/scene.rs
index c20c5847a2..75a7437e1d 100644
--- a/crates/eps/src/scene.rs
+++ b/crates/eps/src/scene.rs
@@ -511,7 +511,7 @@ impl<'a> Scene<'a> {
NodeKind::SymbolInstance { symbol, xf } => {
if let Some(sym) = self.doc.symbols.iter().find(|s| &s.name == symbol)
&& self.nest < MAX_NEST
- && let Some(art) = self.doc.place_symbol_art(symbol, &vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n), *xf)
+ && let Some(art) = self.doc.place_symbol_art(symbol, &vectorcraft_doc::symbol_overrides::instance_art(&sym.art, n), *xf)
{
self.nest += 1;
self.node(&art, true);
diff --git a/crates/eps/src/tests.rs b/crates/eps/src/tests.rs
index c98cb9689a..2ab96d3555 100644
--- a/crates/eps/src/tests.rs
+++ b/crates/eps/src/tests.rs
@@ -1390,6 +1390,20 @@ fn an_instance_whose_art_reaches_the_region_is_written() {
assert!(page.contains("1 0 0 rg"), "{page}");
}
+/// An instance stained blue by its fill writes its symbol's red art in blue, as the canvas draws it.
+#[test]
+fn a_stained_instance_is_written_in_its_stained_color() {
+ let mut d = doc_with(|d| {
+ let mut n = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Mark".into(), xf: Affine::translate((50.0, 50.0)) });
+ n.appearance = Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), Paint::None, 0.0).into();
+ vec![n]
+ });
+ let art = path(&mut d, shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 40.0)), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
+ d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Mark".into(), art: Arc::new(art) });
+ let page = page_of(&d, &opts(&d));
+ assert!(page.contains("0 0 1 rg") && !page.contains("1 0 0 rg"), "{page}");
+}
+
#[test]
fn an_instance_whose_brush_art_reaches_the_region_is_written() {
// The symbol's art: a line from (0, 40) to (0, 60) with a 1 pt red stroke painted by a 20 pt
diff --git a/crates/mcp/src/tests/fileio.rs b/crates/mcp/src/tests/fileio.rs
index 3a89588ecd..b9794c8263 100644
--- a/crates/mcp/src/tests/fileio.rs
+++ b/crates/mcp/src/tests/fileio.rs
@@ -284,7 +284,7 @@ fn save_file_warns_about_options_it_does_not_use() {
}
/// The notes on `options` name the `.ai` file the save writes, as the note on a top-level option
-/// does: a legacy `.ai` for `aiVersion: "7"` (beside `pdfCompatible: false` too), a
+/// does: a legacy `.ai` for `aiVersion: "7"` (beside `pdfCompatible: false` too, which it notes), a
/// non-PDF-compatible `.ai` for `pdfCompatible: false`, and a PDF-compatible `.ai` otherwise, also
/// when the save reuses the options the document was last saved with.
#[test]
@@ -301,8 +301,12 @@ fn save_file_notes_name_the_ai_file_it_writes() {
("pdf.ai", json!({"pdfCompatible": true, "preview": true}), "PDF-compatible .ai"),
] {
let r = text(&call_tool(&mut h, "save_file", &json!({"path": tmp(&dir, name), "version": 3, "options": options})));
- let expected =
- [format!("`version` was not used: {label} files don't read it"), format!("`options.preview` was not used: {label} files don't read it")];
+ // A legacy `.ai` doesn't read `pdfCompatible` either.
+ let unread = |k: &str| options.get(k).is_some() && (k != "pdfCompatible" || label.starts_with("legacy"));
+ let expected: Vec = std::iter::once("version".to_string())
+ .chain(["pdfCompatible", "preview"].into_iter().filter(|k| unread(k)).map(|k| format!("options.{k}")))
+ .map(|k| format!("`{k}` was not used: {label} files don't read it"))
+ .collect();
assert_eq!(notes(&r), expected, "{name}: {r}");
}
// A save given no option the file reads writes it as last saved: a legacy `.ai` again.
diff --git a/crates/mcp/src/ui_catalog.rs b/crates/mcp/src/ui_catalog.rs
index 9eb39e056f..a7ef587cd8 100644
--- a/crates/mcp/src/ui_catalog.rs
+++ b/crates/mcp/src/ui_catalog.rs
@@ -1465,7 +1465,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"ui.action.new",
"New Action",
"",
- "{set: index, name, key?: 0-15, cmd?, shift?, color?: red|yellow|green|blue|violet} add an action named name (spaces trimmed from both ends) after the last action of the Actions panel's set at set (0 is the top set) and start recording into it; Stop Playing/Recording (ui.action.stop) ends the recording. key 1 to 15 is the function key F1 to F15 that plays the action (0 or null: none), with cmd (Ctrl, Command on macOS) and shift; color is its Button Mode color (null or \"\": none), which the panel does not show. Action function keys take priority over command shortcuts and preserve the command's binding. On Windows F1, Ctrl+F4 and Ctrl+F6 cannot be used. The set becomes current and the new action is selected. The panel's Create New Action button and menu's New Action… (dialog `actionNew` with set, name, key, cmd, shift and color) run this on Record. While an action is recording, and for an index past the last set, an empty name, a key or color that is not one of these, or an action that would give the set more than 1,000 actions or an action set file larger than 8 MiB, it is an error, nothing is added and no recording starts → {set, action}",
+ "{set: index, name, key?: 0-15, cmd?, shift?, color?: red|yellow|green|blue|violet} add an action named name (spaces trimmed from both ends) after the last action of the Actions panel's set at set (0 is the top set) and start recording into it; Stop Playing/Recording (ui.action.stop) ends the recording. key 1 to 15 is the function key F1 to F15 that plays the action (0 or null: none), with cmd (Ctrl, Command on macOS) and shift; color is its Button Mode color (null or \"\": none), the color of its button in Button Mode (ui.action.buttonMode). Action function keys take priority over command shortcuts and preserve the command's binding. On Windows F1, Ctrl+F4 and Ctrl+F6 cannot be used. The set becomes current and the new action is selected. The panel's Create New Action button and menu's New Action… (dialog `actionNew` with set, name, key, cmd, shift and color) run this on Record. While an action is recording, and for an index past the last set, an empty name, a key or color that is not one of these, or an action that would give the set more than 1,000 actions or an action set file larger than 8 MiB, it is an error, nothing is added and no recording starts → {set, action}",
),
(
"ui.action.options",
@@ -1479,6 +1479,18 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"",
"{action: name, set?: name} play the Actions panel's action named action at the speed ui.action.playbackOptions sets; set names the set to look in (default: every set). Accelerated plays it as one undoable batch → what command.batch returns. Step by Step and Pause For play its first step now and the others one at a time, each its own undo step, until the last, ui.action.stop, or a step that comes due while a different document is active than after the step before → {results: [the first step's result], pending: the steps left}. An action with a stop, at any speed, plays up to the stop and waits until ui.action.continue or ui.action.stop; at Accelerated the steps between stops are one undoable batch each → {results, pending, atStop?: true when it already waits}. A name no action has, or one that more than one action in the sets looked in has, or an action played while another is still playing step by step or with pauses, is an error and nothing plays",
),
+ (
+ "ui.action.playAt",
+ "Play Action",
+ "",
+ "{set: index, action: index} play the Actions panel's action at action in set (0 is the top set) as ui.action.play plays it, and return what ui.action.play returns. A click on an action's button in Button Mode runs this. A missing index, or one that is not a whole number or is past the last set or action, is an error and nothing plays",
+ ),
+ (
+ "ui.action.buttonMode",
+ "Button Mode",
+ "",
+ "{enabled?: bool} with true, the Actions panel shows every action of every set, in set order and then action order, as a button with its name in its Action Options color (no color: an ordinary button), without set names, steps or checkmarks, and a click on a button plays its action (ui.action.playAt); with false, it shows its list again; left out or null, it switches to the other view. The panel menu's Button Mode runs this and shows a check mark while it is on. The view is kept with the UI preferences. Turning it on while an action records, or an enabled that is not true, false or null, is an error and the view stays → {enabled}",
+ ),
(
"ui.action.playbackOptions",
"Playback Options",
diff --git a/crates/metafile/src/scene.rs b/crates/metafile/src/scene.rs
index ecba61ce8e..6a6fd0b61f 100644
--- a/crates/metafile/src/scene.rs
+++ b/crates/metafile/src/scene.rs
@@ -217,7 +217,7 @@ impl<'a> Scene<'a> {
NodeKind::SymbolInstance { symbol, xf } => {
if let Some(sym) = self.doc.symbols.iter().find(|s| &s.name == symbol)
&& self.nest < MAX_NEST
- && let art = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n)
+ && let art = vectorcraft_doc::symbol_overrides::instance_art(&sym.art, n)
&& let Some(art) =
if self.clips { self.doc.place_symbol_art(symbol, &art, *xf) } else { self.doc.place_symbol_art_unclipped(symbol, &art, *xf) }
{
diff --git a/crates/metafile/src/tests.rs b/crates/metafile/src/tests.rs
index 602db3d55f..ceb65d6fe2 100644
--- a/crates/metafile/src/tests.rs
+++ b/crates/metafile/src/tests.rs
@@ -399,6 +399,22 @@ fn an_instance_whose_art_reaches_the_artboard_is_written() {
assert!(k.contains(&39), "a brush fills the art: {k:?}");
}
+/// An instance stained blue by its fill writes its symbol's red art in blue, as the canvas draws it,
+/// in EMF (clipped) and in WMF (unclipped).
+#[test]
+fn a_stained_instance_is_written_in_its_stained_color() {
+ let mut inst = Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: "Mark".into(), xf: Affine::translate((30.0, 30.0)) });
+ inst.appearance = Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), Paint::None, 0.0).into();
+ let mut d = doc_with(100.0, 100.0, vec![inst]);
+ let art = path(shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 40.0)), red(), Paint::None, 0.0);
+ d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Mark".into(), art: Arc::new(art) });
+ for clips in [true, false] {
+ let (ops, _) = crate::scene::Scene::new(&d, d.artboards[0].rect, clips).run();
+ let colors: Vec<[u8; 3]> = fills(&ops).into_iter().map(|(_, rgb)| rgb).collect();
+ assert_eq!(colors, [[0, 0, 255]], "clips: {clips}");
+ }
+}
+
/// A pattern `name` repeating `art` every point.
fn point_tiles(name: &str, art: Node) -> PatternDef {
let mut def = PatternDef::new(name, vec![Arc::new(art)]);
diff --git a/crates/pdf/src/export/nodes.rs b/crates/pdf/src/export/nodes.rs
index 8bafe017c6..097ae52bba 100644
--- a/crates/pdf/src/export/nodes.rs
+++ b/crates/pdf/src/export/nodes.rs
@@ -336,14 +336,15 @@ impl Exporter<'_> {
// instances multiply.
// Placing through 9-slice guides needs room for the most objects it can make
// (`NineSliceMap::most_placed`), and the objects it makes count.
- // A dynamic symbol's instance shows its own appearance changes.
+ // A dynamic symbol's instance shows its own appearance changes, and the instance's
+ // fill stains its art, as on the canvas.
let doc = self.doc;
if let Some(sym) = doc.symbols.iter().find(|x| &x.name == symbol) {
let changed;
- let art: &Node = if n.extras().symbol_overrides.is_empty() {
+ let art: &Node = if n.extras().symbol_overrides.is_empty() && vectorcraft_doc::symbol_overrides::stain(n).is_none() {
&sym.art
} else {
- changed = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n);
+ changed = vectorcraft_doc::symbol_overrides::instance_art(&sym.art, n);
&changed
};
let count = art.count() as u64;
diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs
index d63aaedb35..bee80238bd 100644
--- a/crates/pdf/src/tests.rs
+++ b/crates/pdf/src/tests.rs
@@ -271,6 +271,71 @@ fn a_scaled_nine_slice_instance_keeps_its_corners() {
assert!(close(boxes[2], Rect::new(105.0, 85.0, 135.0, 105.0), 0.01), "{boxes:?}");
}
+/// An instance of symbol `name` placed by `xf`, its fill red at `opacity` (shown when `visible`).
+fn stained_instance(name: &str, xf: Affine, opacity: f32, visible: bool) -> Node {
+ let mut n = Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: name.into(), xf });
+ n.appearance = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0).into();
+ n.appearance.items.retain(AppearanceItem::is_fill);
+ if let AppearanceItem::Fill(f) = &mut n.appearance.items[0] {
+ (f.opacity, f.visible) = (opacity, visible);
+ }
+ n
+}
+
+/// The fill colors of `d`'s leaves, in paint order.
+fn leaf_fills(d: &Document) -> Vec {
+ leaves(d).iter().filter_map(|n| n.appearance.fill_paint().color()).map(|c| c.to_hex()).collect()
+}
+
+/// A stained instance is written in the colors the canvas draws it in
+/// ([`vectorcraft_brush::instance_art`]): on its own, with a Transform effect that moves it, inside
+/// a group, after a dynamic symbol's own fill change, and placed through 9-slice guides. A hidden
+/// fill or one at 0% opacity leaves the art's colors as they are.
+#[test]
+fn a_stained_instance_keeps_its_stain() {
+ let blue = Color::rgb8(30, 90, 220);
+ let art = || Node::group(NodeId(0), vec![Arc::new(rect_node(Rect::new(0.0, 0.0, 100.0, 100.0), blue))]);
+ let canvas = |n: &Node| leaf_fills_of(&vectorcraft_brush::instance_art(&art(), n));
+ fn leaf_fills_of(n: &Node) -> Vec {
+ let mut d = doc(10.0, 10.0);
+ add(&mut d, n.clone());
+ leaf_fills(&d)
+ }
+ let write = |n: Node, nine_slice: bool| {
+ let mut d = doc(300.0, 200.0);
+ d.symbols.push(vectorcraft_doc::Symbol { dynamic: true, name: "Panel".into(), art: Arc::new(art()) });
+ if nine_slice {
+ d.set_symbol_nine_slice("Panel", Some(vectorcraft_doc::SymbolNineSlice::default()));
+ }
+ add(&mut d, n);
+ leaf_fills(&roundtrip(&d))
+ };
+ let half = stained_instance("Panel", Affine::translate((20.0, 20.0)), 0.5, true);
+ let want = canvas(&half);
+ assert_ne!(want, [blue.to_hex()], "the stain changes the color");
+ assert_eq!(write(half.clone(), false), want, "on its own");
+ let mut moved = half.clone();
+ moved.appearance.effects.push(vectorcraft_doc::Effect {
+ id: "distort.transform".into(),
+ params: serde_json::json!({"moveH": 30.0}),
+ visible: true,
+ });
+ assert_eq!(write(moved, false), want, "with a geometry effect");
+ assert_eq!(write(Node::group(NodeId(0), vec![Arc::new(half.clone())]), false), want, "in a group");
+ let mut scaled = half.clone();
+ scaled.kind = NodeKind::SymbolInstance { symbol: "Panel".into(), xf: Affine::translate((20.0, 20.0)) * Affine::scale_non_uniform(2.0, 1.5) };
+ assert_eq!(write(scaled, true), want, "through 9-slice guides");
+ let mut changed = half.clone();
+ changed.extras_mut().symbol_overrides =
+ vec![vectorcraft_doc::SymbolOverride { path: vec![0], fill: Some(Paint::solid(Color::rgb(0.0, 1.0, 0.0))), ..Default::default() }];
+ let changed_want = canvas(&changed);
+ assert_ne!(changed_want, want, "the part's own fill is stained");
+ assert_eq!(write(changed, false), changed_want, "after a dynamic symbol's change");
+ for (opacity, visible) in [(0.0, true), (0.5, false)] {
+ assert_eq!(write(stained_instance("Panel", Affine::IDENTITY, opacity, visible), false), [blue.to_hex()], "unstained");
+ }
+}
+
/// The placed art of all the 9-slice instances in one export holds at most `MAX_SYMBOL_NODES`
/// objects, opacity-mask art included, as on the canvas: of two instances whose art placed through
/// the guides holds about 9,009,000 objects each (a gradient square in all nine cells, each copy
diff --git a/crates/render/src/frame.rs b/crates/render/src/frame.rs
index e16f134aca..7d000aa64f 100644
--- a/crates/render/src/frame.rs
+++ b/crates/render/src/frame.rs
@@ -110,8 +110,8 @@ pub(crate) struct PlacedEntry {
pub(crate) art: std::sync::Weak,
/// The art placed, or `None` for art placed again in every frame ([`depends_on_document`]).
pub(crate) placed: Option>,
- /// The font generation ([`vectorcraft_text::FontDb::generation`]) the art was placed in, when
- /// it holds type: the art is placed again when the fonts change.
+ /// The layout inputs ([`vectorcraft_text::layout_inputs`]) the art was placed with, when it
+ /// holds type: the art is placed again when the fonts or the hyphenation exceptions change.
pub(crate) fonts: Option,
/// The 9-slice map the art was placed through ([`Renderer::sliced_art`]), or `None` for art
/// placed by the transform alone ([`Renderer::placed_art`]). An entry is used only by the
diff --git a/crates/render/src/fx/pixels.rs b/crates/render/src/fx/pixels.rs
index 72731f7330..cdf817f1e4 100644
--- a/crates/render/src/fx/pixels.rs
+++ b/crates/render/src/fx/pixels.rs
@@ -412,7 +412,7 @@ impl Renderer {
/// brush library it reads; for images and placed documents their pixels and files, the
/// layer's Dim Images and the options they are drawn with (with
/// [`crate::RenderOptions::kept_images_only`], also which pixels and undecodable notes the
- /// image store holds); for type the fonts, whether overprinting shows
+ /// image store holds); for type the fonts and the hyphenation exceptions, whether overprinting shows
/// ([`crate::proof::overprints`]) and Document Setup's substitution highlights; and the
/// plug-ins installed, with the names of the document's swatches and patterns and its pattern
/// definitions (a plug-in's painted pieces can name them), when an object in it has a plug-in
@@ -457,7 +457,7 @@ impl Renderer {
f.doc.setup.highlight_substituted_glyphs,
o.highlight_alternate_glyphs,
));
- (vectorcraft_text::FontDb::global().generation(), crate::proof::overprints(f.opts), highlights).hash(&mut h);
+ (vectorcraft_text::layout_inputs(), crate::proof::overprints(f.opts), highlights).hash(&mut h);
}
if reads.plugins || (doc.plugins && (reads.symbols || reads.patterns)) {
(effects::plugin_revision(), doc.names, doc.patterns).hash(&mut h);
diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs
index 141c3524c5..6a92d91e8f 100644
--- a/crates/render/src/lib.rs
+++ b/crates/render/src/lib.rs
@@ -65,8 +65,8 @@ pub struct Renderer {
/// The latest cached glyph geometry for each text object id. A move changes the node's Arc
/// but not its glyphs in text space, so the old geometry can still be used after comparison.
text_ids: PtrMap,
- /// The [`vectorcraft_text::FontDb::generation`] `texts` was laid out with: type lays out
- /// again when fonts are added or rescanned.
+ /// The [`vectorcraft_text::layout_inputs`] `texts` was laid out with: type lays out again
+ /// when fonts are added or rescanned and when the hyphenation exceptions change.
text_fonts: u64,
/// Clip regions per clipping path (see [`Self::clip_of`]).
clips: FrameCache,
diff --git a/crates/render/src/live.rs b/crates/render/src/live.rs
index 4d41e16af8..3777c98948 100644
--- a/crates/render/src/live.rs
+++ b/crates/render/src/live.rs
@@ -127,8 +127,9 @@ struct Walked {
}
impl Walked {
- /// The [`resources_key`] of the node walked: the walk's hash, the font generation when it found
- /// type or a placed file, the plug-in revision and the hash of the document's swatch and pattern
+ /// The [`resources_key`] of the node walked: the walk's hash, the layout inputs (fonts and
+ /// hyphenation exceptions, [`vectorcraft_text::layout_inputs`]) when it found type or a placed
+ /// file, the plug-in revision and the hash of the document's swatch and pattern
/// names (`names`) when it found a plug-in effect, and the color settings `cms`. Whether a
/// plug-in paints its pieces depends on the plug-ins installed, so the names count for every
/// plug-in effect.
@@ -136,7 +137,7 @@ impl Walked {
let mut h = DefaultHasher::new();
self.hash.hash(&mut h);
if self.text {
- vectorcraft_text::FontDb::global().generation().hash(&mut h);
+ vectorcraft_text::layout_inputs().hash(&mut h);
}
if self.plugins {
crate::effects::plugin_revision().hash(&mut h);
diff --git a/crates/render/src/pattern.rs b/crates/render/src/pattern.rs
index 2372abdbf2..317a423681 100644
--- a/crates/render/src/pattern.rs
+++ b/crates/render/src/pattern.rs
@@ -39,7 +39,8 @@ type TileKey = (String, u64, Ink, bool);
/// What a tile was drawn from besides its pattern's own art, for art that shows more than itself:
/// the art of every pattern and symbol and the pixels of every image in the document, the
-/// document's other data (which holds its brushes), and the fonts. Editing a symbol leaves the art
+/// document's other data (which holds its brushes), and the fonts and hyphenation exceptions
+/// ([`vectorcraft_text::layout_inputs`]). Editing a symbol leaves the art
/// of a pattern that shows it as it was, so the art alone kept the tile from before (#1723). Held
/// weakly: a weak reference keeps the allocation, so no other value gets one of these addresses
/// while the tile is kept.
@@ -66,13 +67,13 @@ impl Deps {
art: Self::art(doc).map(Arc::downgrade).collect(),
pixels: Self::pixels(doc).map(Arc::downgrade).collect(),
unknown: vectorcraft_doc::Shared::downgrade(&doc.unknown),
- fonts: vectorcraft_text::FontDb::global().generation(),
+ fonts: vectorcraft_text::layout_inputs(),
}
}
/// Does `doc` hold what the tile was drawn from?
fn current(&self, doc: &Document) -> bool {
- self.fonts == vectorcraft_text::FontDb::global().generation()
+ self.fonts == vectorcraft_text::layout_inputs()
&& std::ptr::eq(self.unknown.as_ptr(), &*doc.unknown)
&& self.art.iter().map(Weak::as_ptr).eq(Self::art(doc).map(Arc::as_ptr))
&& self.pixels.iter().map(Weak::as_ptr).eq(Self::pixels(doc).map(Arc::as_ptr))
diff --git a/crates/render/src/renderer/text.rs b/crates/render/src/renderer/text.rs
index b4a0dd0d2e..945ce5303b 100644
--- a/crates/render/src/renderer/text.rs
+++ b/crates/render/src/renderer/text.rs
@@ -55,7 +55,7 @@ impl Renderer {
/// objects) hit for them. Other art is placed again in every frame, and so is art that holds
/// symbol instances, type with inline graphics, images, placed documents or raster effects
/// ([`depends_on_document`]), and art that the cache has no room for. Art that holds type is
- /// placed again when the fonts change.
+ /// placed again when the fonts or the hyphenation exceptions change.
pub(super) fn placed_art(&mut self, art: &Arc, xf: Affine, scale_strokes: bool, inst: Option<&Node>) -> (Arc, bool) {
let kept_kind = matches!(art.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) || vectorcraft_doc::live::is_live(art);
// An instance with appearance changes of its own isn't kept: the key doesn't tell them apart.
@@ -67,7 +67,7 @@ impl Renderer {
let key = (Arc::as_ptr(art) as usize, xf.as_coeffs().map(f64::to_bits), scale_strokes, stain);
if let Some(e) = self.placed_arts.get(&key, self.stamp)
&& std::ptr::eq(e.art.as_ptr(), Arc::as_ptr(art))
- && e.fonts.is_none_or(|g| g == vectorcraft_text::FontDb::global().generation())
+ && e.fonts.is_none_or(|g| g == vectorcraft_text::layout_inputs())
&& e.slice.is_none()
{
return match &e.placed {
@@ -106,7 +106,7 @@ impl Renderer {
if keeps
&& let Some(e) = self.placed_arts.get(&key, self.stamp)
&& std::ptr::eq(e.art.as_ptr(), Arc::as_ptr(art))
- && e.fonts.is_none_or(|g| g == vectorcraft_text::FontDb::global().generation())
+ && e.fonts.is_none_or(|g| g == vectorcraft_text::layout_inputs())
&& e.slice.as_ref() == Some(map)
{
match e.placed.clone() {
@@ -134,7 +134,7 @@ impl Renderer {
fn keep_placed(&mut self, key: PlacedKey, art: &Arc, placed: &Arc, slice: Option) -> bool {
let kept = (!depends_on_document(art)).then(|| placed.clone());
let keeps = kept.is_some();
- let fonts = (keeps && holds_type(art)).then(|| vectorcraft_text::FontDb::global().generation());
+ let fonts = (keeps && holds_type(art)).then(vectorcraft_text::layout_inputs);
// The bytes count the key and the entry, the art's allocation that the weak reference
// keeps, and the kept art.
let bytes = size_of::<(PlacedKey, PlacedEntry, Node)>() + kept.as_deref().map_or(0, lru::node_bytes);
@@ -150,7 +150,7 @@ impl Renderer {
if !matches!(&a.kind, NodeKind::Text(own) if std::ptr::eq(&**own, t)) {
return (Arc::new(text_geom(t)), false);
}
- let fonts = vectorcraft_text::FontDb::global().generation();
+ let fonts = vectorcraft_text::layout_inputs();
if fonts != self.text_fonts {
self.texts.clear();
self.text_ids.clear();
diff --git a/crates/render/src/tests/fontchange.rs b/crates/render/src/tests/fontchange.rs
index bedffb6b4b..15389cd805 100644
--- a/crates/render/src/tests/fontchange.rs
+++ b/crates/render/src/tests/fontchange.rs
@@ -3,16 +3,16 @@
use std::sync::{PoisonError, RwLock, RwLockReadGuard, RwLockWriteGuard};
-use vectorcraft_doc::text::{CharStyle, TextObject};
+use vectorcraft_doc::text::{CharStyle, TextKind, TextObject};
use vectorcraft_doc::{Node, NodeKind};
-use vectorcraft_geom::Point;
+use vectorcraft_geom::{PathData, Point};
use crate::*;
-/// Taken to add fonts, or to look up an installed one by name (`FontDb::face` loads it, which
-/// changes the fonts too). Renderers lay type out again when the fonts change, so tests of what
-/// they keep between frames hold it shared ([`fonts_steady`]) and the tests that change the fonts
-/// hold it alone ([`adding_fonts`]).
+/// Taken to add fonts, to look up an installed one by name (`FontDb::face` loads it, which
+/// changes the fonts too) or to change the hyphenation exceptions. Renderers lay type out again
+/// when the fonts or the exceptions change, so tests of what they keep between frames hold it
+/// shared ([`fonts_steady`]) and the tests that change them hold it alone ([`adding_fonts`]).
static FONTS: RwLock<()> = RwLock::new(());
/// No fonts are added by these tests until the guard is dropped.
@@ -29,14 +29,12 @@ pub(crate) fn adding_fonts() -> RwLockWriteGuard<'static, ()> {
/// What `step` returns from a run in which the fonts stayed the same. The fonts can change while
/// [`fonts_steady`] is held: the first lookup by family name catalogs the installed fonts, and
/// resolving a cataloged family, or a character no loaded font has, loads a face. `step` runs up
-/// to 100 times, until [`vectorcraft_text::FontDb::generation`] is the same after a run as before
-/// it.
+/// to 100 times, until [`vectorcraft_text::layout_inputs`] is the same after a run as before it.
pub(crate) fn with_fonts_unchanged(mut step: impl FnMut() -> T) -> T {
- let db = vectorcraft_text::FontDb::global();
for _ in 0..100 {
- let before = db.generation();
+ let before = vectorcraft_text::layout_inputs();
let out = step();
- if db.generation() == before {
+ if vectorcraft_text::layout_inputs() == before {
return out;
}
}
@@ -466,6 +464,75 @@ fn enveloped_type_under_effects_redraws_when_its_font_becomes_available() {
}
}
+/// Hyphenated area type redraws with its new line breaks when Preferences › Hyphenation ›
+/// Exceptions change: its glyph geometry, the pixels of its raster effects, the art drawn for
+/// geometry effects, the art kept for a symbol instance and an envelope's evaluation are made
+/// again.
+#[test]
+fn hyphenated_type_redraws_when_the_exceptions_change() {
+ let _adding = adding_fonts();
+ struct Clear;
+ impl Drop for Clear {
+ fn drop(&mut self) {
+ vectorcraft_text::set_hyphenation_exceptions("");
+ }
+ }
+ let _clear = Clear;
+ use vectorcraft_doc::Effect;
+ let moved = || Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 0.0}), visible: true };
+ const WORDS: &str = "Extraordinarily comprehensive typographical considerations notwithstanding everything.";
+ const NEVER: &str = "extraordinarily, comprehensive, typographical, considerations, notwithstanding";
+ let cases = ["the area type", "the type with a drop shadow", "the group with a Transform effect", "the symbol instance", "the envelope"];
+ for (i, what) in cases.into_iter().enumerate() {
+ vectorcraft_text::set_hyphenation_exceptions("");
+ let mut d = Document::new(300.0, 150.0);
+ let mut t = TextObject::point(Point::ZERO, WORDS, CharStyle { size: 14.0, ..Default::default() });
+ t.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(10.0, 10.0, 130.0, 145.0).to_path(0.1)) };
+ t.xf = Affine::IDENTITY;
+ t.para.hyphenate = true;
+ let mut object = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)));
+ match i {
+ 0 => {}
+ 1 => {
+ let params = serde_json::json!({"x": 4, "y": 4, "blur": 1, "opacity": 100});
+ object.appearance.effects.push(Effect { id: "stylize.dropShadow".into(), params, visible: true });
+ }
+ 2 => {
+ object = Node::group(d.alloc_id(), vec![Arc::new(object)]);
+ object.appearance.effects.push(moved());
+ }
+ 3 => {
+ object.appearance.effects.push(moved());
+ let art = Node::group(d.alloc_id(), vec![Arc::new(object)]);
+ d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Words".into(), art: Arc::new(art) });
+ object = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Words".into(), xf: Affine::IDENTITY });
+ }
+ _ => {
+ let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true, version: 0 };
+ let envelope = NodeKind::Envelope {
+ content: vec![Arc::new(object)],
+ kind: Box::new(kind),
+ fidelity: 50.0,
+ editing: false,
+ options: Default::default(),
+ frame: Box::new(Affine::IDENTITY),
+ };
+ object = Node::new(d.alloc_id(), envelope);
+ }
+ }
+ let l = d.layers[0].id;
+ d.insert(Some(l), 0, object).unwrap();
+ let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
+ let mut r = Renderer::new();
+ let before = r.render(&d, 300, 150, Affine::IDENTITY, &opts).pixels;
+ vectorcraft_text::set_hyphenation_exceptions(NEVER);
+ let after = r.render(&d, 300, 150, Affine::IDENTITY, &opts).pixels;
+ let fresh = Renderer::new().render(&d, 300, 150, Affine::IDENTITY, &opts).pixels;
+ assert_ne!(before, fresh, "{what}: the words in the list are not broken");
+ assert!(after == fresh, "{what}: drawn as a new renderer draws it");
+ }
+}
+
/// Without an engine session, a renderer maps an envelope of type from its glyph outline bounds,
/// and measures the type again when its font becomes available.
#[test]
diff --git a/crates/render/src/tests/memory.rs b/crates/render/src/tests/memory.rs
index d286fa7e3c..1f7a49698c 100644
--- a/crates/render/src/tests/memory.rs
+++ b/crates/render/src/tests/memory.rs
@@ -320,13 +320,13 @@ fn kept_glyphs(r: &mut Renderer, d: &Document) -> Vec>> {
.collect()
}
-/// `f`'s result from a run during which the fonts did not change: other tests add fonts, and the
-/// renderer lays all type out again when the fonts change.
+/// `f`'s result from a run during which the fonts and the hyphenation exceptions did not change:
+/// other tests change them, and the renderer lays all type out again when they change.
fn fonts_unchanged(mut f: impl FnMut() -> T) -> T {
loop {
- let fonts = vectorcraft_text::FontDb::global().generation();
+ let inputs = vectorcraft_text::layout_inputs();
let t = f();
- if vectorcraft_text::FontDb::global().generation() == fonts {
+ if vectorcraft_text::layout_inputs() == inputs {
return t;
}
}
diff --git a/crates/render/src/tests/typeedges.rs b/crates/render/src/tests/typeedges.rs
index fae0207d0d..3295342ce8 100644
--- a/crates/render/src/tests/typeedges.rs
+++ b/crates/render/src/tests/typeedges.rs
@@ -859,7 +859,7 @@ fn type_outside_its_frame_is_drawn() {
fn a_pan_draws_the_type_from_the_kept_pixels() {
let _fonts = crate::tests_fontchange::fonts_steady();
let opts = RenderOptions::default();
- let fonts = || vectorcraft_text::FontDb::global().generation();
+ let fonts = vectorcraft_text::layout_inputs;
// How many type objects a renderer drew offscreen for `b` in `second` after drawing `a` in
// `first`. The second frame's pixels are a new renderer's, each channel within `near`. The
// frames are drawn again while the fonts change between them (other tests load fonts, and the
diff --git a/crates/svg/tests/it/symbols_layers.rs b/crates/svg/tests/it/symbols_layers.rs
index 8e83b6262e..005a03e0fa 100644
--- a/crates/svg/tests/it/symbols_layers.rs
+++ b/crates/svg/tests/it/symbols_layers.rs
@@ -272,6 +272,33 @@ fn instances_the_def_cant_stand_for_get_their_own_art() {
assert_similar(&render_artboard(&d), &resvg_render(&svg, 200, 120), 24.0, 0.002);
}
+/// An instance half stained red whose Transform effect moves it 60 pt right is written in its
+/// stained colors where the canvas draws it, on its own and inside a group.
+#[test]
+fn a_stained_instance_with_a_geometry_effect_keeps_its_stain() {
+ for grouped in [false, true] {
+ let mut d = symbol_doc(0.0, &[]);
+ let mut stained = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "My Mark".into(), xf: Affine::translate((40.0, 60.0)) });
+ stained.appearance = Appearance::basic(Paint::solid(Color::rgb8(255, 0, 0)), Paint::None, 0.0).into();
+ stained.appearance.items.retain(|i| i.is_fill());
+ if let vectorcraft_doc::AppearanceItem::Fill(f) = &mut stained.appearance.items[0] {
+ f.opacity = 0.5;
+ }
+ stained.appearance.effects.push(vectorcraft_doc::Effect {
+ id: "distort.transform".into(),
+ params: serde_json::json!({"moveH": 60.0}),
+ visible: true,
+ });
+ let n = if grouped { Node::group(d.alloc_id(), vec![Arc::new(stained)]) } else { stained };
+ let l = d.layers[0].id;
+ d.insert(Some(l), usize::MAX, n).unwrap();
+ let svg = export(&d, &ExportOptions::default());
+ let blue = Color::rgb8(30, 90, 220).to_hex().to_ascii_lowercase();
+ assert!(!svg.to_ascii_lowercase().contains(&blue), "the symbol's own blue is stained: {svg}");
+ assert_similar(&render_artboard(&d), &resvg_render(&svg, 200, 120), 24.0, 0.003);
+ }
+}
+
#[test]
fn pattern_paints_in_symbols_stay_on_the_page() {
let mut d = Document::new(200.0, 120.0);
diff --git a/crates/text/src/hyphen.rs b/crates/text/src/hyphen.rs
index e293346279..c940ed85a4 100644
--- a/crates/text/src/hyphen.rs
+++ b/crates/text/src/hyphen.rs
@@ -502,16 +502,16 @@ mod tests {
}
/// Process-wide store used by `prefs.set` / restore. Each change makes envelopes measure type
- /// again.
+ /// again, and caches of layouts lay type out again.
#[test]
fn set_hyphenation_exceptions_round_trips_the_source_string() {
- let inputs = crate::layout::envelope_ink_inputs();
+ let inputs = crate::layout_inputs();
set_hyphenation_exceptions("typography, hap-pen");
assert_eq!(hyphenation_exceptions(), "typography, hap-pen");
- let changed = crate::layout::envelope_ink_inputs();
- assert_ne!(changed, inputs, "the envelope inputs change with the exceptions");
+ let changed = crate::layout_inputs();
+ assert_ne!(changed, inputs, "the layout inputs change with the exceptions");
set_hyphenation_exceptions("");
assert!(hyphenation_exceptions().is_empty());
- assert_ne!(crate::layout::envelope_ink_inputs(), changed, "and again when they are cleared");
+ assert_ne!(crate::layout_inputs(), changed, "and again when they are cleared");
}
}
diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs
index 82a849a3dc..a7d7d1d3f3 100644
--- a/crates/text/src/layout.rs
+++ b/crates/text/src/layout.rs
@@ -46,9 +46,7 @@ pub(crate) use driver::{fit_passes, layout_faces_per_call};
pub(crate) use on_path::{layout_trying_each_start, path_arrangements};
use vertical::{TcyStyle, stands_upright, tate_chu_yoko, upright_cell, upright_centre, upright_extent, whole_character_widths};
-#[cfg(test)]
-pub(crate) use driver::envelope_ink_inputs;
-pub use driver::{envelope_ink, register_envelope_ink};
+pub use driver::{envelope_ink, layout_inputs, register_envelope_ink};
const EPS: f64 = 1e-6;
diff --git a/crates/text/src/layout/driver.rs b/crates/text/src/layout/driver.rs
index 9108cc171d..59c6ac38bf 100644
--- a/crates/text/src/layout/driver.rs
+++ b/crates/text/src/layout/driver.rs
@@ -191,12 +191,13 @@ pub fn envelope_ink(t: &TextObject, m: Affine, projection: Option<(vectorcraft_g
/// Make envelopes map type from [`envelope_ink`], measured again when the fonts or the hyphenation
/// exceptions change (the first registration stays, see `vectorcraft_doc::live::set_type_ink`).
pub fn register_envelope_ink() {
- vectorcraft_doc::live::set_type_ink(envelope_ink, envelope_ink_inputs);
+ vectorcraft_doc::live::set_type_ink(envelope_ink, layout_inputs);
}
-/// A number that changes whenever what [`envelope_ink`] reads besides the type changes: the
-/// installed fonts and the hyphenation exceptions.
-pub(crate) fn envelope_ink_inputs() -> u64 {
+/// A number that changes when the fonts ([`FontDb::generation`]) or the hyphenation exceptions
+/// (Preferences › Hyphenation › Exceptions) change. Caches of layouts made with the global font
+/// database, and of art made from them, key on it.
+pub fn layout_inputs() -> u64 {
FontDb::global().generation().rotate_left(32) ^ crate::hyphen::exceptions_generation()
}
diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs
index 2ff6b9ad5b..6ae85931ca 100644
--- a/crates/text/src/lib.rs
+++ b/crates/text/src/lib.rs
@@ -47,7 +47,7 @@ pub use fontdb::{
user_font_dirs,
};
pub use hyphen::{hyphenation_exceptions, set_hyphenation_exceptions};
-pub use layout::{envelope_ink, register_envelope_ink};
+pub use layout::{envelope_ink, layout_inputs, register_envelope_ink};
use std::sync::Arc;
pub use svg_glyph::{SvgGlyphReader, set_svg_glyph_reader};
diff --git a/crates/tools/src/addons/parametric_shape.rs b/crates/tools/src/addons/parametric_shape.rs
index d16cb63935..68a42a665f 100644
--- a/crates/tools/src/addons/parametric_shape.rs
+++ b/crates/tools/src/addons/parametric_shape.rs
@@ -4,7 +4,9 @@
//! dragging, ←/→ its second value or corner radius. A click selects a parametric shape and shows
//! its handles (centre, size, rotation, corner radius and one of the kind's own), whose drags run
//! `addon.pathtools.shape.set`; a double-click on the canvas places one with the last settings.
-//! Each gesture is one undo step.
+//! Each gesture is one undo step. A new shape's center (from a press or a double-click) and the
+//! point dragged out from it snap as a drawing tool's points do ([`DrawSnap`]), hovering before
+//! the press too.
//!
//! A parametric shape is an ordinary path that keeps its numbers in one entry of its object data
//! ([`KEY`]) with a fingerprint of the outline they drew. Moved, turned or scaled by any tool it
@@ -17,6 +19,7 @@ use vectorcraft_geom::{Affine, BezPath, PathData, Point, Vec2};
use vectorcraft_pathops::addons::pathtools::shapes::fingerprint;
pub use vectorcraft_pathops::addons::pathtools::shapes::{MAX_COUNT, MAX_SIZE, MIN_COUNT, MIN_SIZE, ParametricShape, ShapeKind};
+use crate::guides::DrawSnap;
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
/// The tool's id.
@@ -209,7 +212,7 @@ fn angle(c: Point, p: Point, shift: bool) -> f64 {
#[derive(Clone, Debug, PartialEq)]
enum Drag {
/// Drawing a shape out from its centre.
- Create { center: Point },
+ Create { center: Point, from: Point },
/// Dragging a handle of shape `id`, which was `shape` when the drag began at `from`.
Edit { id: NodeId, shape: ParametricShape, grip: Grip, from: Point },
}
@@ -221,11 +224,18 @@ pub struct ParametricShapeTool {
drag: Option,
/// Where the pointer last was during the drag, and the keys held.
last: (Point, Mods),
+ /// Smart Guides for a new shape's center and the point dragged out from it.
+ snap: DrawSnap,
}
impl Default for ParametricShapeTool {
fn default() -> Self {
- Self { next: ParametricShape::new(ShapeKind::Polygon, Point::ZERO, 100.0, 100.0), drag: None, last: (Point::ZERO, Mods::default()) }
+ Self {
+ next: ParametricShape::new(ShapeKind::Polygon, Point::ZERO, 100.0, 100.0),
+ drag: None,
+ last: (Point::ZERO, Mods::default()),
+ snap: DrawSnap::default(),
+ }
}
}
@@ -247,7 +257,7 @@ impl ParametricShapeTool {
fn shaped(&self, drag: &Drag, p: Point, mods: Mods) -> (&'static str, ParametricShape, Option) {
let fit = |v: f64| v.clamp(MIN_SIZE, MAX_SIZE);
match *drag {
- Drag::Create { center } => {
+ Drag::Create { center, .. } => {
let r = center.distance(p);
let aspect = self.next.width / self.next.height;
let s = ParametricShape {
@@ -288,6 +298,18 @@ impl ParametricShapeTool {
}
Some(Action::Preview(cmd.into(), p))
}
+
+ /// Show the Smart Guides of a new shape's center at `p`, the pointer hovering; none over a
+ /// handle of the selected shape or over a parametric shape, where a press does not draw.
+ fn hover(&mut self, cx: &ToolContext, p: Point) {
+ let tol = cx.point_tol();
+ let on_grip = selected(cx).is_some_and(|(_, s)| grips(cx, &s).iter().any(|(_, at)| at.distance(p) <= tol));
+ if on_grip || shape_at(cx, p).is_some() {
+ self.snap.clear();
+ } else {
+ self.snap.hover(cx, p, &[], None);
+ }
+ }
}
impl Tool for ParametricShapeTool {
@@ -300,6 +322,9 @@ impl Tool for ParametricShapeTool {
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec {
+ if ev.kind == PointerKind::Move && self.drag.is_none() {
+ self.hover(cx, ev.pos);
+ }
match ev.kind {
PointerKind::Move => vec![],
PointerKind::Down => {
@@ -309,15 +334,17 @@ impl Tool for ParametricShapeTool {
near.min_by(|a, b| a.1.distance(ev.pos).total_cmp(&b.1.distance(ev.pos))).map(|(g, _)| (id, s, g))
});
if let Some((id, shape, grip)) = grip {
+ self.snap.clear();
self.drag = Some(Drag::Edit { id, shape, grip, from: ev.pos });
self.last = (ev.pos, ev.mods);
return vec![Action::Begin(EDIT.into())];
}
if let Some(id) = shape_at(cx, ev.pos) {
+ self.snap.clear();
return if cx.selection.objects == [id] { vec![] } else { vec![Action::Exec("select.set".into(), json!({ "ids": [id.0] }))] };
}
- let (center, _) = crate::guides::snap_draw(cx, ev.pos, &[]);
- self.drag = Some(Drag::Create { center });
+ let center = self.snap.press(cx, ev.pos, &[], None);
+ self.drag = Some(Drag::Create { center, from: ev.pos });
self.last = (center, ev.mods);
vec![Action::Begin(CREATE.into())]
}
@@ -325,15 +352,19 @@ impl Tool for ParametricShapeTool {
if self.drag.is_none() {
return vec![];
}
- self.last = (ev.pos, ev.mods);
+ // The point a new shape is dragged out to snaps; a handle's drag does not.
+ let pos = if matches!(self.drag, Some(Drag::Create { .. })) { self.snap.drag(cx, ev.pos, None) } else { ev.pos };
+ self.last = (pos, ev.mods);
let Some(preview) = self.preview() else { return vec![] };
if ev.kind == PointerKind::Drag {
return vec![preview];
}
+ self.snap.clear();
let Some(drag) = self.drag.take() else { return vec![] };
match drag {
- // A click draws nothing (a double-click places a shape).
- Drag::Create { center } if center.distance(ev.pos) <= cx.tol(2.0) => vec![Action::Cancel],
+ // A click draws nothing (a double-click places a shape), even when its center
+ // snapped a few pixels from where it was pressed.
+ Drag::Create { from, .. } if from.distance(ev.pos) <= cx.tol(2.0) => vec![Action::Cancel],
Drag::Create { .. } => {
if let Action::Preview(_, p) = &preview
&& let Ok(s) = apply(self.next, p)
@@ -350,7 +381,8 @@ impl Tool for ParametricShapeTool {
if self.drag.is_some() || shape_at(cx, ev.pos).is_some() {
return vec![];
}
- let s = ParametricShape { center: ev.pos, ..self.next };
+ let s = ParametricShape { center: self.snap.press(cx, ev.pos, &[], None), ..self.next };
+ self.snap.clear();
vec![Action::Exec("addon.pathtools.shape.create".into(), to_json(&s))]
}
}
@@ -358,6 +390,7 @@ impl Tool for ParametricShapeTool {
fn key(&mut self, cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec {
if key == ToolKey::Escape && self.drag.take().is_some() {
+ self.snap.clear();
return vec![Action::Cancel];
}
if !matches!(self.drag, Some(Drag::Create { .. })) {
@@ -385,13 +418,16 @@ impl Tool for ParametricShapeTool {
}
fn overlays(&self, cx: &ToolContext) -> Vec {
- if let Some(Drag::Create { center }) = &self.drag {
- let (_, s, _) = self.shaped(&Drag::Create { center: *center }, self.last.0, self.last.1);
+ if let Some(drag @ Drag::Create { .. }) = &self.drag {
+ let (_, s, _) = self.shaped(drag, self.last.0, self.last.1);
let text = format!("{}\nAngle: {:.1}°", cx.size_label(s.width, s.height), s.rotation);
- return vec![Overlay::Measure { p: self.last.0 + Vec2::new(cx.tol(12.0), cx.tol(12.0)), text }];
+ let mut out = self.snap.guides().to_vec();
+ out.push(Overlay::Measure { p: self.last.0 + Vec2::new(cx.tol(12.0), cx.tol(12.0)), text });
+ return out;
}
- let Some((_, s)) = selected(cx) else { return vec![] };
- // The shape's box, then its handles.
+ // The hovering pointer's Smart Guides, then the selected shape's box and handles.
+ let mut out = self.snap.guides().to_vec();
+ let Some((_, s)) = selected(cx) else { return out };
let (rx, ry) = (s.width / 2.0, s.height / 2.0);
let mut bp = BezPath::new();
let f: Affine = s.frame();
@@ -400,7 +436,7 @@ impl Tool for ParametricShapeTool {
if i == 0 { bp.move_to(*p) } else { bp.line_to(*p) }
}
bp.close_path();
- let mut out = vec![Overlay::Path { path: bp, color: BLUE, width: 1.0, dashed: true }];
+ out.push(Overlay::Path { path: bp, color: BLUE, width: 1.0, dashed: true });
for (grip, p) in grips(cx, &s) {
if grip == Grip::Rotate {
out.push(Overlay::Line { a: f * Point::new(0.0, -ry), b: p, color: BLUE, dashed: false });
@@ -418,6 +454,11 @@ impl Tool for ParametricShapeTool {
Cursor::ParametricShape
}
+ fn deactivate(&mut self, _cx: &ToolContext) -> Vec {
+ self.snap.clear();
+ vec![]
+ }
+
/// The next shape's numbers (its size and rotation are a double-click's).
fn options(&self) -> Value {
let mut v = to_json(&self.next);
diff --git a/crates/tools/src/builder.rs b/crates/tools/src/builder.rs
index 5a8d44d400..f5be7ac4c5 100644
--- a/crates/tools/src/builder.rs
+++ b/crates/tools/src/builder.rs
@@ -695,6 +695,14 @@ impl Tool for ShapeBuilderTool {
self.finish_edit(cx)
}
+ /// The pointer hovers nothing for the tool: its highlight, the stroke a click would split and
+ /// the cursor swatch preview go, and a map that arrives afterwards highlights nothing.
+ fn hover_ended(&mut self, _cx: &ToolContext) {
+ self.hover = None;
+ self.stroke = None;
+ self.at = None;
+ }
+
fn poll(&mut self, cx: &ToolContext, wait: bool) -> Vec {
let mut acts = self.poll_merge(cx, wait);
if !acts.is_empty() || self.dragging {
diff --git a/crates/tools/src/builder/live_paint.rs b/crates/tools/src/builder/live_paint.rs
index 2ae2f082ee..e5f696a5a7 100644
--- a/crates/tools/src/builder/live_paint.rs
+++ b/crates/tools/src/builder/live_paint.rs
@@ -769,6 +769,11 @@ impl Tool for LivePaintSelectionTool {
vec![]
}
+ /// The pointer hovers nothing for the tool: its highlight goes.
+ fn hover_ended(&mut self, _cx: &ToolContext) {
+ self.hover = LpHover::None;
+ }
+
fn overlays(&self, cx: &ToolContext) -> Vec {
if let Some(r) = self.drag.and_then(|d| d.marquee(cx)) {
return vec![Overlay::Marquee(r)];
diff --git a/crates/tools/src/builder/tests.rs b/crates/tools/src/builder/tests.rs
index 01b64ee8c3..90e4832771 100644
--- a/crates/tools/src/builder/tests.rs
+++ b/crates/tools/src/builder/tests.rs
@@ -920,6 +920,43 @@ fn shape_builder_shift_drag_shows_no_scissors_or_stroke_highlight() {
assert_eq!(t.cursor(&cx, Point::new(75.0, 75.0), shift), Cursor::ShapeBuilder);
}
+/// When the pointer leaves the canvas, the Shape Builder's region or stroke highlight and its
+/// cursor swatch preview go.
+#[test]
+fn shape_builder_hover_ended_drops_the_highlight_and_the_swatch_preview() {
+ let (d, s) = corner_rects();
+ let p = paint();
+ let cx = cx(&d, &s, &p);
+ let mut t = ShapeBuilderTool::default();
+ for (key, v) in [("pickColorFrom", json!("swatches")), ("cursorSwatchPreview", json!(true)), ("clickStrokeSplitsPath", json!(true))] {
+ t.set_option(key, &v);
+ }
+ // Over a region, then over a stroke a click would split.
+ for (x, y) in [(25.0, 25.0), (-1.0, 25.0)] {
+ t.pointer(&cx, &ev(PointerKind::Move, x, y));
+ let ov = t.overlays(&cx);
+ assert!(ov.iter().any(|o| matches!(o, Overlay::Swatch { .. })) && ov.iter().any(|o| matches!(o, Overlay::Path { .. })), "{ov:?}");
+ t.hover_ended(&cx);
+ assert!(t.overlays(&cx).is_empty());
+ }
+}
+
+/// A map of a large selection that arrives after the pointer leaves the canvas highlights nothing
+/// under the point where the pointer was last seen.
+#[test]
+fn shape_builder_map_arriving_after_hover_ended_highlights_nothing() {
+ let (d, s) = square_row(40);
+ let p = paint();
+ let cx = ToolContext { revision: (1, 1), ..cx(&d, &s, &p) };
+ let mut t = ShapeBuilderTool::default();
+ t.pointer(&cx, &ev(PointerKind::Move, 15.0, 10.0));
+ assert!(t.waiting() && t.hover.is_none(), "the map is built on another thread");
+ t.hover_ended(&cx);
+ t.poll(&cx, true);
+ assert!(!t.waiting());
+ assert!(t.hover.is_none() && t.overlays(&cx).is_empty(), "{:?}", t.hover);
+}
+
#[test]
fn shape_builder_stroke_options_round_trip_and_pick_the_side_under_the_pointer() {
let (d, s) = corner_rects();
@@ -1593,6 +1630,21 @@ fn live_paint_selection_hover_builds_no_map_of_the_selection() {
assert!(!t.waiting());
}
+/// When the pointer leaves the canvas, the Live Paint Selection tool's highlight goes.
+#[test]
+fn live_paint_selection_hover_ended_drops_the_highlight() {
+ let (d, ..) = live_group_doc();
+ let s = Selection::default();
+ let p = paint();
+ let cx = cx(&d, &s, &p);
+ let mut t = LivePaintSelectionTool::default();
+ t.pointer(&cx, &ev(PointerKind::Move, 75.0, 25.0));
+ let ov = t.overlays(&cx);
+ assert!(ov.iter().any(|o| matches!(o, Overlay::Path { .. })), "{ov:?}");
+ t.hover_ended(&cx);
+ assert!(t.overlays(&cx).is_empty());
+}
+
/// A Live Paint Selection click pressed with `down`: the press does nothing, and the release does
/// what is returned.
fn lp_click(t: &mut LivePaintSelectionTool, cx: &ToolContext, down: PointerEvent) -> Vec {
diff --git a/crates/tools/src/cursors.toml b/crates/tools/src/cursors.toml
index 6f5b32e56a..674ecebf60 100644
--- a/crates/tools/src/cursors.toml
+++ b/crates/tools/src/cursors.toml
@@ -453,6 +453,7 @@ group = "Navigation"
shown_when = [
"The Rotate, Reflect, Scale and Shear tools with nothing selected.",
"Puppet Warp off the mesh, where no pin can go.",
+ "The Smooth tool, and the Pencil or Blob Brush where Alt gives the Smooth tool, when no path is selected that the Smooth tool can act on: a drag changes nothing.",
]
hotspot = "The centre."
designer = false
@@ -690,10 +691,10 @@ look = "Only a small dot at the centre, as the Blob Brush's: the tip outline dra
id = "Smooth"
group = "Drawing and editing tools"
shown_when = [
- "The Smooth tool: a drag along a selected path smooths it.",
- "The Pencil tool with Alt key toggles to Smooth Tool on: with Alt held before a stroke begins to draw, and through a drag that smooths.",
- "The Paintbrush tool with Alt held before a press while a path is selected that the Smooth tool can act on (an editable path, not a guide, with three or more anchors on one of its subpaths), and through a drag that smooths.",
- "The Blob Brush tool with Alt held before a press, or before the drag has moved from the press point, and through a drag that smooths.",
+ "The Smooth tool while a path is selected that it can act on (an editable path, not a guide, with three or more anchors on one of its subpaths), wherever the pointer is: a drag along the path smooths it. With no such path selected, Not Allowed shows.",
+ "The Pencil tool with Alt key toggles to Smooth Tool on and such a path selected: with Alt held before a stroke begins to draw, and through a drag that smooths.",
+ "The Paintbrush tool with Alt held before a press while such a path is selected, and through a drag that smooths.",
+ "The Blob Brush tool with such a path selected: with Alt held before a press, or before the drag has moved from the press point, and through a drag that smooths.",
]
hotspot = "The tip."
diff --git a/crates/tools/src/dimension.rs b/crates/tools/src/dimension.rs
index 14f7f85881..ff99091153 100644
--- a/crates/tools/src/dimension.rs
+++ b/crates/tools/src/dimension.rs
@@ -344,10 +344,19 @@ impl DimensionTool {
_ => Orientation::Aligned,
};
}
- d.place(p);
+ // A dimension dragged out from art starts with placeholder points. Read the art's points
+ // before placing the dimension at the pointer. Clear the offset first: an out-of-range
+ // offset from the last pointer position fails the read.
+ if !matches!(d.mode, DimensionMode::Radius | DimensionMode::Diameter) {
+ d.offset = 0.0;
+ }
if d.resolve(cx.doc).is_err() {
return vec![];
}
+ d.place(p);
+ if d.validate().is_err() {
+ return vec![];
+ }
let mut actions = vec![];
if !self.began {
actions.push(Action::Begin("Create Dimension".into()));
@@ -675,6 +684,53 @@ mod tests {
assert!(matches!(placed.last(), Some(Action::Commit)), "{placed:?}");
}
+ /// A pointer position too far away to place the dimension shows no preview, and the preview
+ /// comes back where the pointer returns.
+ #[test]
+ fn placement_recovers_after_a_position_out_of_range() {
+ let (d, _) = doc_with_rect();
+ let (s, p) = (Selection::default(), paint());
+ let c = cx(&d, &s, &p);
+ let mut t = DimensionTool::default();
+ for (x, y) in [(400.0, 400.0), (450.0, 400.0)] {
+ t.pointer(&c, &PointerEvent::new(PointerKind::Down, x, y));
+ t.pointer(&c, &PointerEvent::new(PointerKind::Up, x, y));
+ }
+ assert!(t.pointer(&c, &PointerEvent::new(PointerKind::Move, 420.0, 3.0e6)).is_empty());
+ let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Move, 420.0, 450.0));
+ let Some(Action::Preview(_, params)) = acts.last() else { panic!("{acts:?}") };
+ assert_eq!(params["offset"], json!(50.0));
+ }
+
+ /// Dragging out from a segment previews the dimension at the pointer from the first drag on.
+ #[test]
+ fn the_first_preview_of_a_segment_drag_is_at_the_pointer() {
+ let (d, _) = doc_with_rect();
+ let (s, p) = (Selection::default(), paint());
+ let c = cx(&d, &s, &p);
+ let mut t = DimensionTool::default();
+ t.pointer(&c, &PointerEvent::new(PointerKind::Down, 150.0, 100.0));
+ let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 150.0, 60.0));
+ let Some(Action::Preview(_, params)) = acts.last() else { panic!("{acts:?}") };
+ assert_eq!(params["offset"], json!(-40.0), "40 pt above the top edge");
+ }
+
+ /// Dragging out from a corner previews the angle at the pointer's distance from the corner
+ /// from the first drag on.
+ #[test]
+ fn the_first_preview_of_a_corner_drag_is_at_the_pointer() {
+ let (d, _) = doc_with_rect();
+ let (s, p) = (Selection::default(), paint());
+ let c = cx(&d, &s, &p);
+ let mut t = DimensionTool::default();
+ t.set_option("mode", &json!("angular"));
+ t.pointer(&c, &PointerEvent::new(PointerKind::Down, 100.0, 100.0));
+ let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 130.0, 140.0));
+ let Some(Action::Preview(_, params)) = acts.last() else { panic!("{acts:?}") };
+ assert_eq!(params["vertex"], json!([100.0, 100.0]));
+ assert_eq!(params["offset"], json!(50.0), "50 pt from the corner");
+ }
+
/// A Linear press on an anchor picks that anchor even when the pointer moves a few pixels
/// before the release: it does not drag out the dimension of a segment that ends there. Both
/// picks keep their links to the path, so the dimension measures between the two anchors.
diff --git a/crates/tools/src/draw2/gesture.rs b/crates/tools/src/draw2/gesture.rs
index 9aed6f545b..61396e01d9 100644
--- a/crates/tools/src/draw2/gesture.rs
+++ b/crates/tools/src/draw2/gesture.rs
@@ -777,7 +777,8 @@ fn has_selected_smooth_path(cx: &ToolContext) -> bool {
cx.selection.objects.iter().filter_map(|id| cx.doc.node(*id)).any(|n| {
let mut found = false;
n.walk(&mut |child| {
- if matches!(&child.kind, NodeKind::Path { guide: false, path, .. } if path.control_bounds().is_some() && path.subpaths.iter().any(smoothable))
+ if !found
+ && matches!(&child.kind, NodeKind::Path { guide: false, path, .. } if path.control_bounds().is_some() && path.subpaths.iter().any(smoothable))
&& editable(child.id)
{
found = true;
@@ -787,6 +788,13 @@ fn has_selected_smooth_path(cx: &ToolContext) -> bool {
})
}
+/// The Smooth tool's cursor: Not Allowed when no selected path can be smoothed, since a drag then
+/// changes nothing. A selected path anywhere on the canvas is enough, so the cursor does not depend
+/// on where the pointer is.
+fn smooth_cursor(cx: &ToolContext) -> Cursor {
+ if has_selected_smooth_path(cx) { Cursor::Smooth } else { Cursor::NotAllowed }
+}
+
impl GestureTool {
pub fn new(id: &str) -> Self {
let id: &'static str = match id {
@@ -1173,7 +1181,7 @@ impl GestureTool {
let tol = cx.tol(self.edit_within);
let radius = cx.tol(SMOOTH_RADIUS);
if self.smoothing {
- if has_sel && pts.len() >= 2 {
+ if pts.len() >= 2 && has_selected_smooth_path(cx) {
return vec![Action::Exec(
"path.smoothRegion".into(),
json!({"points": points_json(pts), "radius": radius, "fidelity": cx.tol(self.smooth_fidelity)}),
@@ -1225,7 +1233,7 @@ impl GestureTool {
}),
)],
"knife" if pts.len() >= 2 => vec![Action::Exec("path.knife".into(), json!({"points": points_json(pts)}))],
- "smooth" if has_sel && pts.len() >= 2 => {
+ "smooth" if pts.len() >= 2 && has_selected_smooth_path(cx) => {
vec![Action::Exec(
"path.smoothRegion".into(),
json!({"points": points_json(pts), "radius": radius, "fidelity": cx.tol(self.fidelity)}),
@@ -1534,13 +1542,15 @@ impl Tool for GestureTool {
}
fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor {
// An active smoothing drag, or Alt before a press that would switch the Pencil, Paintbrush
- // or Blob Brush to Smooth. A Paintbrush stroke already drawing keeps drawing with Alt.
+ // or Blob Brush to Smooth. A Paintbrush stroke already drawing keeps drawing with Alt. With
+ // no selected path to smooth, the Pencil and the Blob Brush show Not Allowed, as the Smooth
+ // tool does.
if self.smoothing
|| (self.id == "pencil" && self.alt_smooth && m.alt && !self.began)
|| (!self.active && self.id == "paintbrush" && m.alt && has_selected_smooth_path(cx))
|| self.blob_alt_smooths(m)
{
- return Cursor::Smooth;
+ return smooth_cursor(cx);
}
// Before a press that would draw a straight line: over an end of a selected open path,
// the line continues that path from the end (`PointerKind::Down`).
@@ -1563,7 +1573,7 @@ impl Tool for GestureTool {
"blobBrush" => Cursor::BlobBrush,
"eraser" => Cursor::Eraser,
"knife" => Cursor::Knife,
- "smooth" => Cursor::Smooth,
+ "smooth" => smooth_cursor(cx),
"pathEraser" => Cursor::PathEraser,
"join" => Cursor::Join,
// Before a press that draws a new path the Pencil shows its star; over a selected path
diff --git a/crates/tools/src/draw2/gesture/tests.rs b/crates/tools/src/draw2/gesture/tests.rs
index 4703305d85..ece7a369bb 100644
--- a/crates/tools/src/draw2/gesture/tests.rs
+++ b/crates/tools/src/draw2/gesture/tests.rs
@@ -369,7 +369,7 @@ fn the_freehand_tools_want_every_sample() {
#[test]
fn alt_shows_the_closing_cursor_and_a_release_with_alt_closes() {
- let (d, _) = doc_with_rect();
+ let (d, rect) = doc_with_rect();
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
@@ -396,18 +396,26 @@ fn alt_shows_the_closing_cursor_and_a_release_with_alt_closes() {
}
// With Alt key toggles to Smooth Tool on, Alt lends the Pencil the Smooth tool instead, until
// a stroke has begun to draw: Alt then neither smooths nor closes, and the cursor stays the Pencil's.
+ // With nothing selected to smooth, the Smooth tool it lends shows Not Allowed.
let mut t = GestureTool::new("pencil");
t.set_option("altSmooth", &json!(true));
- assert_eq!(t.cursor(&cx, at, alt), Cursor::Smooth);
+ assert_eq!(t.cursor(&cx, at, alt), Cursor::NotAllowed);
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 10.0, 5.0));
assert_eq!(t.cursor(&cx, at, alt), Cursor::Pencil, "a stroke that has begun");
let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 20.0, 0.0).with_mods(alt));
assert!(matches!(&acts[0], Action::Preview(_, v) if v["closed"] == false), "{acts:?}");
- // A smoothing drag keeps the Smooth cursor when Alt is let go.
- t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0).with_mods(alt));
- t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 10.0, 5.0).with_mods(alt));
- assert_eq!(t.cursor(&cx, at, Mods::default()), Cursor::Smooth, "a smoothing drag");
+ // A smoothing drag keeps the lent cursor when Alt is let go: Smooth with a selected path, Not
+ // Allowed without one.
+ let mut selected = Selection::default();
+ selected.set([rect]);
+ for (sel, c) in [(&s, Cursor::NotAllowed), (&selected, Cursor::Smooth)] {
+ let cx = crate::testutil::cx(&d, sel, &p);
+ t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0).with_mods(alt));
+ t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 10.0, 5.0).with_mods(alt));
+ assert_eq!(t.cursor(&cx, at, Mods::default()), c, "a smoothing drag");
+ t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 20.0, 0.0).with_mods(alt));
+ }
}
#[test]
@@ -2531,6 +2539,117 @@ fn smooth_needs_selection_and_brackets_resize() {
assert_eq!(b.options()["size"], 11.0);
}
+/// The Smooth tool shows Not Allowed while no selected path can be smoothed: nothing selected, only
+/// type, a path of two anchors or a locked path. A selected path anywhere, alone or in a selected
+/// group, gives the Smooth cursor wherever the pointer is, and the cursor follows the selection while
+/// the tool stays chosen.
+#[test]
+fn smooth_shows_not_allowed_without_a_selected_path_to_smooth() {
+ use std::sync::Arc;
+ let (mut d, rect) = doc_with_rect();
+ let layer = d.layers[0].id;
+ let line_id = d.alloc_id();
+ let line = vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::BezPath::from_vec(vec![
+ vectorcraft_geom::PathEl::MoveTo(Point::new(300.0, 300.0)),
+ vectorcraft_geom::PathEl::LineTo(Point::new(400.0, 300.0)),
+ ]));
+ d.insert(Some(layer), 1, vectorcraft_doc::Node::path(line_id, line, vectorcraft_doc::Appearance::default_art())).unwrap();
+ let square = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(300.0, 350.0, 350.0, 400.0));
+ let inner = vectorcraft_doc::Node::path(d.alloc_id(), square, vectorcraft_doc::Appearance::default_art());
+ let group = vectorcraft_doc::Node::group(d.alloc_id(), vec![Arc::new(inner)]);
+ let group_id = group.id;
+ d.insert(Some(layer), 2, group).unwrap();
+ let (text_doc, text) = doc_with_area_type();
+ let mut locked = d.clone();
+ locked.node_mut(rect).unwrap().locked = true;
+ let p = paint();
+ let sel = |ids: &[NodeId]| {
+ let mut s = Selection::default();
+ s.set(ids.iter().copied());
+ s
+ };
+ let t = GestureTool::new("smooth");
+ let far = Point::new(480.0, 20.0);
+ let cases: [(&vectorcraft_doc::Document, Selection, Cursor, &str); 7] = [
+ (&d, Selection::default(), Cursor::NotAllowed, "nothing selected"),
+ (&text_doc, sel(&[text]), Cursor::NotAllowed, "type"),
+ (&d, sel(&[line_id]), Cursor::NotAllowed, "a two-anchor path"),
+ (&locked, sel(&[rect]), Cursor::NotAllowed, "a locked path"),
+ (&d, sel(&[rect]), Cursor::Smooth, "a path away from the pointer"),
+ (&d, sel(&[line_id, rect]), Cursor::Smooth, "a path beside one it cannot smooth"),
+ (&d, sel(&[group_id]), Cursor::Smooth, "a group holding a path"),
+ ];
+ for (doc, s, c, what) in &cases {
+ assert_eq!(t.cursor(&cx(doc, s, &p), far, Mods::default()), *c, "{what}");
+ }
+ // The cursor changes as the selection does, with the same tool.
+ let (none, one) = (Selection::default(), sel(&[rect]));
+ for (s, c) in [(&none, Cursor::NotAllowed), (&one, Cursor::Smooth), (&none, Cursor::NotAllowed)] {
+ assert_eq!(t.cursor(&cx(&d, s, &p), Point::new(150.0, 150.0), Mods::default()), c);
+ }
+ // A drag with Not Allowed showing sends nothing.
+ let mut t = GestureTool::new("smooth");
+ let acts = drag(&mut t, &cx(&d, &none, &p), &[(50.0, 150.0), (150.0, 150.0), (250.0, 150.0)], Mods::default());
+ assert!(acts.iter().all(|a| a.is_empty()), "{acts:?}");
+}
+
+/// The Blob Brush's Alt shows Not Allowed with no selected path to smooth, and the Smooth cursor with
+/// one; the Paintbrush's Alt without one keeps the closing cursor, since its release closes the stroke.
+#[test]
+fn borrowed_smooth_shows_not_allowed_without_a_selected_path_to_smooth() {
+ let (d, rect) = doc_with_rect();
+ let none = Selection::default();
+ let mut one = Selection::default();
+ one.set([rect]);
+ let p = paint();
+ let alt = Mods { alt: true, ..Default::default() };
+ let at = Point::new(400.0, 400.0);
+ let blob = GestureTool::new("blobBrush");
+ assert_eq!(blob.cursor(&cx(&d, &none, &p), at, alt), Cursor::NotAllowed);
+ assert_eq!(blob.cursor(&cx(&d, &one, &p), at, alt), Cursor::Smooth);
+ assert_eq!(blob.cursor(&cx(&d, &none, &p), at, Mods::default()), Cursor::BlobBrush);
+ let mut pencil = GestureTool::new("pencil");
+ pencil.set_option("altSmooth", &json!(true));
+ assert_eq!(pencil.cursor(&cx(&d, &none, &p), at, alt), Cursor::NotAllowed);
+ assert_eq!(pencil.cursor(&cx(&d, &one, &p), at, alt), Cursor::Smooth);
+ let brush = GestureTool::new("paintbrush");
+ assert_eq!(brush.cursor(&cx(&d, &none, &p), at, alt), Cursor::PaintbrushClose);
+ assert_eq!(brush.cursor(&cx(&d, &one, &p), at, alt), Cursor::Smooth);
+}
+
+/// A Smooth release sends no command while the selection holds no path it can smooth, from the
+/// Smooth tool and from the Blob Brush and Pencil with Alt; with a path selected it smooths.
+#[test]
+fn smoothing_release_sends_nothing_without_a_selected_path_to_smooth() {
+ let (text_doc, text) = doc_with_area_type();
+ let (d, rect) = doc_with_rect();
+ let mut type_only = Selection::default();
+ type_only.set([text]);
+ let mut one = Selection::default();
+ one.set([rect]);
+ let p = paint();
+ let alt = Mods { alt: true, ..Default::default() };
+ let stroke = [(50.0, 150.0), (150.0, 150.0), (250.0, 160.0)];
+ let smooths = |acts: &[Vec]| acts.iter().flatten().any(|a| matches!(a, Action::Exec(c, _) if c == "path.smoothRegion"));
+ for (id, mods) in [("smooth", Mods::default()), ("blobBrush", alt), ("pencil", alt)] {
+ let tool = || {
+ let mut t = GestureTool::new(id);
+ if id == "pencil" {
+ t.set_option("altSmooth", &json!(true));
+ }
+ t
+ };
+ let mut c = cx(&text_doc, &type_only, &p);
+ c.smart_guides = false;
+ let acts = drag_held(&mut tool(), &c, &stroke, mods);
+ assert!(!smooths(&acts), "{id} with only type selected: {acts:?}");
+ let mut c = cx(&d, &one, &p);
+ c.smart_guides = false;
+ let acts = drag_held(&mut tool(), &c, &stroke, mods);
+ assert!(smooths(&acts), "{id} with a path selected: {acts:?}");
+ }
+}
+
#[test]
fn pencil_alt_toggles_to_smooth_when_enabled_and_closes_when_disabled() {
let (d, id) = doc_with_rect();
diff --git a/crates/tools/src/guides/tests.rs b/crates/tools/src/guides/tests.rs
index 1b6ddbaa12..602242d1cd 100644
--- a/crates/tools/src/guides/tests.rs
+++ b/crates/tools/src/guides/tests.rs
@@ -714,6 +714,81 @@ fn pen_click_near_a_glyph_anchor_lands_on_it() {
assert!(q.distance(anchor) > 1e-3, "{q:?} landed on the glyph anchor with Smart Guides off");
}
+/// Snap to Glyph: the Parametric Shape tool's center (a drag and a double-click) and the point
+/// dragged out from it, and the Slice tool's corners, land on a glyph's anchor point as a Pen
+/// click does, with the guides shown while hovering, and not with Snap to Glyph off.
+#[test]
+fn parametric_shape_and_slice_points_land_on_a_glyph_anchor() {
+ use crate::addons::parametric_shape::ParametricShapeTool;
+ use crate::slice::SliceTool;
+ use crate::{Action, PointerEvent, PointerKind, Tool};
+ let (d, t) = glyph_doc("H", 0.0);
+ let lay = layout_text(FontDb::global(), &t);
+ let g = lay.glyphs.iter().find(|g| !g.outline.elements().is_empty()).expect("a glyph with an outline");
+ // An anchor inside the glyph's bounds, which no glyph bounds line passes through.
+ let bb = g.outline.bounding_box();
+ let inside = |p: Point| p.x > bb.x0 + 1.0 && p.x < bb.x1 - 1.0 && p.y > bb.y0 + 1.0 && p.y < bb.y1 - 1.0;
+ let anchor = t.xf * g.outline.segments().map(|s| s.start()).find(|p| inside(*p)).expect("an anchor inside the glyph");
+ let (s, p) = (Selection::default(), paint());
+ let click = anchor + Vec2::new(1.5, -1.0);
+ let ev = |kind, q: Point| PointerEvent::new(kind, q.x, q.y);
+ let center = |v: &serde_json::Value| Point::new(v["center"][0].as_f64().unwrap(), v["center"][1].as_f64().unwrap());
+ // A press away from the type, for drags that end at the click.
+ let away = anchor + Vec2::new(-83.3, -61.7);
+ for glyphs in [true, false] {
+ let c = ToolContext { snap_to_glyph: glyphs, ..cx(&d, &s, &p) };
+ let lands = |q: Point| q.distance(anchor) < 1e-9;
+ // A Parametric Shape drawn out from the click.
+ let mut tool = ParametricShapeTool::default();
+ tool.pointer(&c, &ev(PointerKind::Down, click));
+ let acts = tool.pointer(&c, &ev(PointerKind::Drag, click + Vec2::new(80.0, 60.0)));
+ let Some(Action::Preview(_, v)) = acts.first() else { panic!("{acts:?}") };
+ assert_eq!(lands(center(v)), glyphs, "drag, Snap to Glyph {glyphs}: {:?} vs {anchor:?}", center(v));
+ // A Parametric Shape dragged out to the click is sized and turned as one dragged out to
+ // the anchor itself.
+ let drawn_to = |q: Point| {
+ let mut tool = ParametricShapeTool::default();
+ tool.pointer(&c, &ev(PointerKind::Down, away));
+ tool.pointer(&c, &ev(PointerKind::Drag, q))
+ };
+ assert_eq!(drawn_to(click) == drawn_to(anchor), glyphs, "dragged point, Snap to Glyph {glyphs}");
+ // A click 3 px from the anchor draws nothing, though its center snaps onto the anchor.
+ let near = anchor + Vec2::new(2.5, -1.5);
+ let mut tool = ParametricShapeTool::default();
+ tool.pointer(&c, &ev(PointerKind::Down, near));
+ let acts = tool.pointer(&c, &ev(PointerKind::Drag, near));
+ let Some(Action::Preview(_, v)) = acts.first() else { panic!("{acts:?}") };
+ assert_eq!(lands(center(v)), glyphs, "click, Snap to Glyph {glyphs}: {:?} vs {anchor:?}", center(v));
+ assert_eq!(tool.pointer(&c, &ev(PointerKind::Up, near)), vec![Action::Cancel], "click, Snap to Glyph {glyphs}");
+ // One placed with a double-click.
+ let mut tool = ParametricShapeTool::default();
+ let acts = tool.pointer(&c, &ev(PointerKind::DoubleClick, click));
+ let Some(Action::Exec(_, v)) = acts.first() else { panic!("{acts:?}") };
+ assert_eq!(lands(center(v)), glyphs, "double-click, Snap to Glyph {glyphs}: {:?} vs {anchor:?}", center(v));
+ // The pointer hovering at the click shows the guides of the anchor it would land on.
+ let mut tool = ParametricShapeTool::default();
+ tool.pointer(&c, &ev(PointerKind::Move, click));
+ assert_eq!(!tool.overlays(&c).is_empty(), glyphs, "hover, Snap to Glyph {glyphs}");
+ // A slice dragged from the click down and to the right starts at its top-left corner.
+ let mut tool = SliceTool::default();
+ tool.pointer(&c, &ev(PointerKind::Move, click));
+ assert_eq!(!tool.overlays(&c).is_empty(), glyphs, "slice hover, Snap to Glyph {glyphs}");
+ tool.pointer(&c, &ev(PointerKind::Down, click));
+ let acts = tool.pointer(&c, &ev(PointerKind::Drag, click + Vec2::new(80.0, 60.0)));
+ let Some(Action::Preview(_, v)) = acts.get(1) else { panic!("{acts:?}") };
+ let corner = Point::new(v["x"].as_f64().unwrap(), v["y"].as_f64().unwrap());
+ assert_eq!(lands(corner), glyphs, "slice, Snap to Glyph {glyphs}: {corner:?} vs {anchor:?}");
+ // One dragged from up and to the left ends at its bottom-right corner.
+ let mut tool = SliceTool::default();
+ tool.pointer(&c, &ev(PointerKind::Down, away));
+ let acts = tool.pointer(&c, &ev(PointerKind::Drag, click));
+ let Some(Action::Preview(_, v)) = acts.get(1) else { panic!("{acts:?}") };
+ let f = |k: &str| v[k].as_f64().unwrap();
+ let end = Point::new(f("x") + f("width"), f("y") + f("height"));
+ assert_eq!(end.distance(anchor) < 1e-6, glyphs, "slice end, Snap to Glyph {glyphs}: {end:?} vs {anchor:?}");
+ }
+}
+
/// Snap to Glyph: a single line of type has an x-height guide; a paragraph shows glyph bounds only.
#[test]
fn drawn_points_line_up_with_the_x_height_of_a_single_line() {
diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs
index 3ee1d48256..1119bb0764 100644
--- a/crates/tools/src/lib.rs
+++ b/crates/tools/src/lib.rs
@@ -624,8 +624,16 @@ mod cursor_tests {
/// Use Precise Cursors turns it into the crosshair.
#[test]
fn drawing_tools_show_their_own_cursor() {
- let (d, _) = doc_with_rect();
+ let (d, rect) = doc_with_rect();
let away = Point::new(400.0, 400.0);
+ // The Smooth tool shows its own cursor only with a selected path (Not Allowed without one).
+ let mut selected = Selection::default();
+ selected.set([rect]);
+ let paint = paint();
+ let cursor_of = |id: &str| match id {
+ "smooth" => create(id).cursor(&cx(&d, &selected, &paint), away, Mods::default()),
+ _ => cursor_at(id, &d, away),
+ };
for (id, c) in [
("lasso", Cursor::Lasso),
(addons::enclosing_lasso::ID, Cursor::Lasso),
@@ -651,7 +659,7 @@ mod cursor_tests {
("symbolStyler", Cursor::SymbolStyler),
("mesh", Cursor::MeshAdd),
] {
- assert_eq!(cursor_at(id, &d, away), c, "{id}");
+ assert_eq!(cursor_of(id), c, "{id}");
assert_eq!(c.precise(), Cursor::Crosshair, "{id}");
// The Blob Brush's and the Eraser's dot is drawn: their tip outline goes round it. The
// Live Paint Bucket's bucket is drawn.
diff --git a/crates/tools/src/slice.rs b/crates/tools/src/slice.rs
index 0f804a8ef6..c4062123e8 100644
--- a/crates/tools/src/slice.rs
+++ b/crates/tools/src/slice.rs
@@ -14,7 +14,7 @@ use vectorcraft_doc::{NodeId, OrientedBox, SliceSource};
use vectorcraft_geom::{Affine, Point, Rect, Vec2};
use crate::bbox::{Handle, hit_handle, move_delta, scale_for_drag};
-use crate::guides::{PointSnap, Targets};
+use crate::guides::{DrawSnap, PointSnap, Targets};
use crate::shape::drag_rect;
use crate::xform::{BLUE, polygon, rect_corners};
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey, json_ids};
@@ -37,9 +37,26 @@ fn has_area(r: Rect) -> bool {
r.width() > 1e-6 && r.height() > 1e-6
}
-/// Snap `p` as the drawing tools do, then to the nearest ruler guide within reach (guides win).
-fn snap(cx: &ToolContext, p: Point) -> (Point, Vec) {
- let (mut q, ov) = crate::guides::snap_draw(cx, p, &[]);
+/// When the Slice tool snaps a point.
+#[derive(Clone, Copy, PartialEq)]
+enum Phase {
+ /// The pointer hovers before a press.
+ Hover,
+ /// The first corner is pressed: the targets are gathered afresh.
+ Press,
+ /// The other corner is dragged: the press's targets are kept.
+ Drag,
+}
+
+/// Snap `p` as the drawing tools do ([`DrawSnap`]), then to the nearest ruler guide within reach
+/// (guides win, and the Smart Guides are then not shown).
+fn snap(draw: &mut DrawSnap, cx: &ToolContext, p: Point, phase: Phase) -> (Point, Vec) {
+ let mut q = match phase {
+ Phase::Hover => draw.hover(cx, p, &[], None),
+ Phase::Press => draw.press(cx, p, &[], None),
+ Phase::Drag => draw.drag(cx, p, None),
+ };
+ let mut ov = draw.guides().to_vec();
let tol = cx.tol(5.0);
let guides = cx.doc.ruler_guides();
let nearest = |vertical: bool, v: f64| {
@@ -50,9 +67,11 @@ fn snap(cx: &ToolContext, p: Point) -> (Point, Vec) {
};
if let Some(g) = nearest(true, p.x) {
q.x = g.pos;
+ ov.clear();
}
if let Some(g) = nearest(false, p.y) {
q.y = g.pos;
+ ov.clear();
}
(q, ov)
}
@@ -64,6 +83,8 @@ pub struct SliceTool {
last: Point,
began: bool,
guides: Vec,
+ /// Smart Guides for the slice's corners, and for the pointer hovering before a press.
+ snap: DrawSnap,
}
impl Tool for SliceTool {
@@ -77,14 +98,20 @@ impl Tool for SliceTool {
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec {
match ev.kind {
+ PointerKind::Move => {
+ if self.start.is_none() {
+ self.guides = snap(&mut self.snap, cx, ev.pos, Phase::Hover).1;
+ }
+ vec![]
+ }
PointerKind::Down => {
- let (p, _) = snap(cx, ev.pos);
- (self.start, self.last, self.began) = (Some(p), p, false);
+ let (p, guides) = snap(&mut self.snap, cx, ev.pos, Phase::Press);
+ (self.start, self.last, self.began, self.guides) = (Some(p), p, false, guides);
vec![]
}
PointerKind::Drag => {
let Some(start) = self.start else { return vec![] };
- let (p, guides) = snap(cx, ev.pos);
+ let (p, guides) = snap(&mut self.snap, cx, ev.pos, Phase::Drag);
(self.last, self.guides) = (p, guides);
let mut out = vec![];
if !self.began {
@@ -102,6 +129,7 @@ impl Tool for SliceTool {
}
PointerKind::Up => {
self.guides.clear();
+ self.snap.clear();
let began = std::mem::take(&mut self.began);
if self.start.take().is_some() && began { vec![Action::Commit] } else { vec![] }
}
@@ -123,6 +151,8 @@ impl Tool for SliceTool {
}
o
}
+ // The hovering pointer's Smart Guides.
+ None => self.guides.clone(),
_ => vec![],
}
}
@@ -134,6 +164,7 @@ impl Tool for SliceTool {
fn deactivate(&mut self, _cx: &ToolContext) -> Vec {
self.start = None;
self.guides.clear();
+ self.snap.clear();
if std::mem::take(&mut self.began) { vec![Action::Cancel] } else { vec![] }
}
}
@@ -152,8 +183,8 @@ impl Drag {
}
}
-/// What a user slice's handle snaps to, gathered when it is pressed: what the Slice tool's drag
-/// snaps to ([`snap`]), the art through [`PointSnap`] and the ruler guides on their own.
+/// What a user slice's handle snaps to, gathered when it is pressed: the art's bounds through
+/// [`PointSnap`], as a bounding-box handle snaps, and the ruler guides on their own.
struct ResizeSnap {
art: PointSnap,
rulers: Targets,
@@ -164,10 +195,10 @@ impl ResizeSnap {
Self { art: PointSnap::scaling(cx, || Targets::collect(cx.doc, &[], None)), rulers: Targets::rulers(cx) }
}
- /// Snap the resize `a` ([`scale_for_drag`]) of the slice `rect` by `handle` as [`snap`] snaps
- /// a drawn slice's corner: the handle snaps as a bounding-box handle does
- /// ([`PointSnap::snap_scale`]), then a ruler guide within 5 px of where that leaves it takes
- /// what the handle moves on its axis. Returns the scale and the guides showing why.
+ /// Snap the resize `a` ([`scale_for_drag`]) of the slice `rect` by `handle`: the handle snaps
+ /// as a bounding-box handle does ([`PointSnap::snap_scale`]), then a ruler guide within 5 px of
+ /// where that leaves it takes what the handle moves on its axis, as [`snap`] does for a drawn
+ /// slice's corner. Returns the scale and the guides showing why.
fn scale(&self, cx: &ToolContext, rect: Rect, handle: Handle, a: Affine, m: Mods) -> (Affine, Vec) {
let bx = OrientedBox::aligned(rect);
let (a, ov) = self.art.snap_scale(cx, &bx, handle, a, m.shift, m.alt);
diff --git a/crates/tools/src/text.rs b/crates/tools/src/text.rs
index bd0c229ddf..763325f90e 100644
--- a/crates/tools/src/text.rs
+++ b/crates/tools/src/text.rs
@@ -134,7 +134,7 @@ fn text_hash(s: &str) -> u64 {
h.finish()
}
-/// The edited text's layout, keyed by the font database's generation and the text.
+/// The edited text's layout, keyed by [`vectorcraft_text::layout_inputs`] and the text.
type CachedLayout = ((u64, TextObject), Arc);
/// Process-global Type-tool edit-session counter: the IME target's replacement epoch.
@@ -281,9 +281,9 @@ impl TypeTool {
}
/// Layout of `t`, cached by content (overlays run every frame) and by the fonts available (the
- /// web build adds fonts after startup).
+ /// web build adds fonts after startup) and the hyphenation exceptions.
fn layout(&self, t: &TextObject) -> Arc {
- let fonts = FontDb::global().generation();
+ let fonts = vectorcraft_text::layout_inputs();
let mut c = self.cache.lock().unwrap_or_else(|e| e.into_inner());
if let Some(((g, k), l)) = c.as_ref()
&& *g == fonts
diff --git a/crates/tools/src/text/ports.rs b/crates/tools/src/text/ports.rs
index 554a1cd611..6928fbe6f0 100644
--- a/crates/tools/src/text/ports.rs
+++ b/crates/tools/src/text/ports.rs
@@ -167,7 +167,8 @@ pub(crate) fn port_near(cx: &ToolContext, p: Point) -> Option<(NodeId, Port, f64
}
/// Which selected text overflows, for [`thread_ports`]: each text object's layout verdict kept
-/// with the object and the fonts it was made from, as overlays run every frame.
+/// with the object and the layout inputs it was made with (fonts and hyphenation exceptions,
+/// [`vectorcraft_text::layout_inputs`]), as overlays run every frame.
#[derive(Default)]
pub struct PortMarks {
seen: std::collections::HashMap,
@@ -178,7 +179,7 @@ pub struct PortMarks {
/// port whether text is hidden by the size of its frame, whatever tool is active. None when more
/// than [`PORTS_MAX`] of them are selected.
pub fn thread_ports(marks: &mut PortMarks, cx: &ToolContext) -> Vec {
- let fonts = FontDb::global().generation();
+ let fonts = vectorcraft_text::layout_inputs();
let mut o = Vec::new();
let mut kept = std::collections::HashMap::new();
if !ports_shown(cx.doc, &cx.selection.objects) {
diff --git a/crates/tools/src/text/tests.rs b/crates/tools/src/text/tests.rs
index a7f302db65..94063166bc 100644
--- a/crates/tools/src/text/tests.rs
+++ b/crates/tools/src/text/tests.rs
@@ -1538,6 +1538,36 @@ fn selected_type_shows_its_ports_in_each_state() {
assert!(thread_ports(&mut marks, &cx(&d, &sel, &p)).is_empty(), "only selected type shows them");
}
+/// The out port shows whether the text fits as the hyphenation exceptions lay it out now: when
+/// Preferences › Hyphenation › Exceptions change, the selected type is laid out again.
+#[test]
+fn the_out_port_follows_the_hyphenation_exceptions() {
+ struct Clear;
+ impl Drop for Clear {
+ fn drop(&mut self) {
+ vectorcraft_text::set_hyphenation_exceptions("");
+ }
+ }
+ let _clear = Clear;
+ const WORDS: &str = "Extraordinarily comprehensive typographical considerations notwithstanding everything.";
+ const NEVER: &str = "extraordinarily, comprehensive, typographical, considerations, notwithstanding";
+ let (mut d, id) = crate::testutil::doc_with_area_type();
+ // A 70 by 170 pt frame holds the hyphenated text, and the text without those breaks overflows
+ // it.
+ let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { panic!("not text") };
+ t.runs = vec![TextRun::new(WORDS, CharStyle { size: 14.0, ..Default::default() })];
+ t.para.hyphenate = true;
+ t.kind = TextKind::Area { frame: vectorcraft_geom::PathData::from_bezpath(&Rect::new(0.0, 0.0, 70.0, 170.0).to_path(0.1)) };
+ let (mut sel, p) = (Selection::default(), paint());
+ sel.set([id]);
+ let mut marks = PortMarks::default();
+ let red = |ov: &[Overlay]| ov.iter().filter(|o| matches!(o, Overlay::Path { color: [230, 30, 30], .. })).count();
+ vectorcraft_text::set_hyphenation_exceptions("");
+ assert_eq!(red(&thread_ports(&mut marks, &cx(&d, &sel, &p))), 0, "the hyphenated text fits");
+ vectorcraft_text::set_hyphenation_exceptions(NEVER);
+ assert_eq!(red(&thread_ports(&mut marks, &cx(&d, &sel, &p))), 1, "the text without those breaks does not");
+}
+
/// Past [`PORTS_MAX`] selected frames (select all of an opened PDF) no ports are drawn or clicked:
/// they would bury the canvas in boxes; up to it every frame has its two.
#[test]
diff --git a/crates/tools/src/touchtype.rs b/crates/tools/src/touchtype.rs
index 172487c197..018a65aefc 100644
--- a/crates/tools/src/touchtype.rs
+++ b/crates/tools/src/touchtype.rs
@@ -99,7 +99,7 @@ struct Drag {
began: bool,
}
-/// The type object's layout, keyed by the font database's generation and the object.
+/// The type object's layout, keyed by [`vectorcraft_text::layout_inputs`] and the object.
type CachedLayout = ((u64, TextObject), Arc);
#[derive(Default)]
@@ -196,9 +196,10 @@ fn char_box(t: &TextObject, lay: &TextLayout, gi: usize) -> Option {
}
impl TouchTypeTool {
- /// Layout of `t`, cached by content (overlays run every frame) and by the fonts available.
+ /// Layout of `t`, cached by content (overlays run every frame), by the fonts available and by
+ /// the hyphenation exceptions.
fn layout(&self, t: &TextObject) -> Arc {
- let fonts = FontDb::global().generation();
+ let fonts = vectorcraft_text::layout_inputs();
let mut c = self.cache.lock().unwrap_or_else(|e| e.into_inner());
if let Some(((g, k), l)) = c.as_ref()
&& *g == fonts
diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs
index 5888b6dfcb..412dbcb4ef 100644
--- a/crates/ui-egui/src/app.rs
+++ b/crates/ui-egui/src/app.rs
@@ -136,6 +136,7 @@ impl VectorcraftApp {
custom_titlebar: false,
caption_layout: Default::default(),
desktop_frame: false,
+ desktop_keeps_frame: false,
last_window_title: String::new(),
fullscreen_for_mode: false,
graphics_adapter: None,
diff --git a/crates/ui-egui/src/canvas/home.rs b/crates/ui-egui/src/canvas/home.rs
index cd2ac9c44a..bffab8f06c 100644
--- a/crates/ui-egui/src/canvas/home.rs
+++ b/crates/ui-egui/src/canvas/home.rs
@@ -22,7 +22,7 @@ pub(super) fn home(app: &mut VectorcraftApp, ui: &mut Ui, rect: egui::Rect) {
});
}
-/// The Home screen's column: welcome, New / Open, Recent Files, presets and community links.
+/// The Home screen's column: welcome, New / Open, Recent Files, Learn, presets and community links.
fn home_content(app: &mut VectorcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
// The welcome and the New / Open buttons sit centred, as PhotoCraft's Home screen does (#4684).
@@ -46,6 +46,7 @@ fn home_content(app: &mut VectorcraftApp, ui: &mut Ui) {
});
});
recent(app, ui);
+ learn(app, ui);
ui.add_space(28.0);
ui.label(egui::RichText::new(tl!("Quickly start a new file")).font(theme::semibold(14.0)).color(t.text));
ui.add_space(10.0);
@@ -68,6 +69,40 @@ fn home_content(app: &mut VectorcraftApp, ui: &mut Ui) {
});
}
+/// The id of the Home screen's Quick skill guide card.
+const LEARN_GUIDE_ID: &str = "home-quick-skill-guide";
+
+/// The Home screen's Learn section: the Quick skill guide card, which shows the Learn panel's list
+/// of skills, and the Learn panel's tutorials under their skills, each a button that shows the
+/// panel at its steps (`learn.show`). Neither opens, closes or switches a document, and the panel
+/// shows beside the Home screen.
+fn learn(app: &mut VectorcraftApp, ui: &mut Ui) {
+ use crate::panels::learn::{catalog, guide_card};
+ let t = Tokens::get(ui.ctx());
+ ui.add_space(28.0);
+ ui.label(egui::RichText::new(tl!("Learn")).font(theme::semibold(14.0)).color(t.text));
+ ui.add_space(10.0);
+ guide_card(app, ui, egui::Id::unique(LEARN_GUIDE_ID), t.panel);
+ let mut open = None;
+ for skill in catalog::SKILLS {
+ ui.add_space(10.0);
+ ui.label(egui::RichText::new(skill.title).size(12.0).color(t.text_dim));
+ ui.add_space(4.0);
+ ui.horizontal_wrapped(|ui| {
+ for tutorial in skill.tutorials {
+ if widgets::secondary_button(ui, tutorial.title).clicked() {
+ open = Some(tutorial.id);
+ }
+ }
+ });
+ }
+ if let Some(id) = open
+ && let Err(e) = app.run("learn.show", json!({ "tutorial": id }))
+ {
+ app.status(e);
+ }
+}
+
/// The Home screen's Recent Files (#663): the first of File › Open Recent Files, each its name and
/// folder; a click opens it. Nothing when there are none.
fn recent(app: &mut VectorcraftApp, ui: &mut Ui) {
diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs
index 1f3a01528c..d9d89e0bb9 100644
--- a/crates/ui-egui/src/dialogs.rs
+++ b/crates/ui-egui/src/dialogs.rs
@@ -719,7 +719,8 @@ pub fn confirm(app: &mut VectorcraftApp) -> DialogResult {
/// After a dialog closes, and each frame: a Missing Fonts dialog that another dialog replaced waits
/// for its turn again, the search a Missing Fonts or Find Linked Files dialog started stops once
/// that dialog is gone, and with no dialog open the choice about a stopped plug-in that another
-/// dialog replaced opens again, else the next document's Missing Fonts dialog (one dialog at a
+/// dialog replaced opens again, else the questions about the next restored document's linked files
+/// ([`crate::recovery::ask_next`]), else the next document's Missing Fonts dialog (one dialog at a
/// time).
pub(crate) fn settle(app: &mut VectorcraftApp) {
crate::placing::settle_swatch_paste(app);
@@ -727,6 +728,7 @@ pub(crate) fn settle(app: &mut VectorcraftApp) {
missing_fonts::stop_when_closed(app);
find_links::stop_when_closed(app);
safe_mode::reopen(app);
+ crate::recovery::ask_next(app);
missing_fonts::open_next(app);
}
diff --git a/crates/ui-egui/src/dialogs/missing_links.rs b/crates/ui-egui/src/dialogs/missing_links.rs
index 70c2370f1b..46a8dca322 100644
--- a/crates/ui-egui/src/dialogs/missing_links.rs
+++ b/crates/ui-egui/src/dialogs/missing_links.rs
@@ -54,14 +54,7 @@ fn after_open_for(app: &mut VectorcraftApp, r: &Value, notes: Vec, web:
let rows = |k: &str| r[k].as_array().cloned().unwrap_or_default();
let (missing, modified) = (rows("missingLinks"), ids(&rows("modifiedLinks")));
let notice = if missing.is_empty() || web {
- let unreadable = (!missing.is_empty()).then(|| {
- crate::i18n::msg_n(
- missing.len() as u64,
- "{n} linked file can't be read here: its preview shows",
- "{n} linked files can't be read here: their previews show",
- &[],
- )
- });
+ let unreadable = (!missing.is_empty()).then(|| unreadable_status(missing.len()));
ask_update(app, modified).or(unreadable)
} else {
open(app, missing, modified, ¬es);
@@ -71,6 +64,40 @@ fn after_open_for(app: &mut VectorcraftApp, r: &Value, notes: Vec, web:
super::settle(app);
}
+/// The status bar's line for `n` linked files the web can't read.
+fn unreadable_status(n: usize) -> String {
+ crate::i18n::msg_n(
+ n as u64,
+ "{n} linked file can't be read here: its preview shows",
+ "{n} linked files can't be read here: their previews show",
+ &[],
+ )
+}
+
+/// What the linked files of a document that opened (`document.open`'s result `r`) need, without
+/// asking yet → the status bar's lines about them, the missing files to ask about and the ids of
+/// the modified images to offer for update ([`ask_next`]). The web reads no linked files, so the
+/// missing ones get a line there; without Update Links: Ask When Modified the modified ones get a
+/// line instead of the offer.
+pub(crate) fn questions(app: &VectorcraftApp, r: &Value) -> (Vec, Vec, Vec) {
+ questions_for(app, r, cfg!(target_arch = "wasm32"))
+}
+
+fn questions_for(app: &VectorcraftApp, r: &Value, web: bool) -> (Vec, Vec, Vec) {
+ let rows = |k: &str| r[k].as_array().cloned().unwrap_or_default();
+ let (mut missing, mut modified) = (rows("missingLinks"), ids(&rows("modifiedLinks")));
+ let mut lines = vec![];
+ if web && !missing.is_empty() {
+ lines.push(unreadable_status(missing.len()));
+ missing.clear();
+ }
+ if !modified.is_empty() && app.session.prefs.update_links != "askWhenModified" {
+ lines.push(changed_on_disk_status(modified.len()));
+ modified.clear();
+ }
+ (lines, missing, modified)
+}
+
fn open(app: &mut VectorcraftApp, missing: Vec, modified: Vec, notes: &[String]) {
let fields = json!({ "missing": missing, "modified": modified, "notes": notes, "applyToAll": false, "path": "" });
app.ui.dialog = Some(Dialog::new(KIND, fields));
@@ -85,7 +112,7 @@ pub(super) fn notes(d: &Dialog) -> Vec {
/// Ask about the missing files `rest`, one at a time, then offer the modified ones `modified` for
/// update. A line about the modified files is followed by the open's `notes`
/// ([`crate::io::show_opened`]).
-pub(super) fn ask_next(app: &mut VectorcraftApp, rest: Vec, modified: Vec, notes: Vec) {
+pub(crate) fn ask_next(app: &mut VectorcraftApp, rest: Vec, modified: Vec, notes: Vec) {
if rest.is_empty() {
app.ui.dialog = None;
if let Some(notice) = ask_update(app, modified) {
@@ -309,6 +336,23 @@ mod tests {
let _ = std::fs::remove_dir_all(dir);
}
+ /// A restored document's linked files: the web gets a line about the missing ones, and
+ /// without Update Links: Ask When Modified the modified ones get a line; the rest are asked
+ /// about.
+ #[test]
+ fn the_questions_about_linked_files_wait_or_become_lines() {
+ let mut app = VectorcraftApp::new(Session::new(), Default::default());
+ let r = json!({ "missingLinks": [{"name": "a.png", "path": "/x/a.png", "ids": [1]}], "modifiedLinks": [{"ids": [2]}, {"ids": [3]}] });
+ app.session.prefs.update_links = "askWhenModified".into();
+ assert_eq!(questions_for(&app, &r, false), (vec![], r["missingLinks"].as_array().unwrap().clone(), vec![json!(2), json!(3)]));
+ let changed = "2 linked images changed on disk: Update Links shows the new versions".to_string();
+ app.session.prefs.update_links = "manually".into();
+ assert_eq!(questions_for(&app, &r, false), (vec![changed.clone()], r["missingLinks"].as_array().unwrap().clone(), vec![]));
+ let unreadable = "1 linked file can't be read here: its preview shows".to_string();
+ assert_eq!(questions_for(&app, &r, true), (vec![unreadable, changed], vec![], vec![]));
+ assert!(app.ui.dialog.is_none(), "nothing is asked yet");
+ }
+
/// The question about modified linked files uses the form the language's plural rule picks for
/// the count.
#[test]
diff --git a/crates/ui-egui/src/dialogs/save_options.rs b/crates/ui-egui/src/dialogs/save_options.rs
index 5dfb886044..974854a6c3 100644
--- a/crates/ui-egui/src/dialogs/save_options.rs
+++ b/crates/ui-egui/src/dialogs/save_options.rs
@@ -253,7 +253,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
}
// Version first; the legacy version takes only its own options.
let legacy = legacy_ai(d);
- let mut rows: Vec<&FormatOption> = shown(f).filter(|o| LEGACY_ONLY.contains(&o.name) == legacy || o.name == "aiVersion").collect();
+ let mut rows: Vec<&FormatOption> = shown(f).filter(|o| fileio::file_reads(f, legacy, o.name)).collect();
// EPS's PostScript level comes last, as in Export's EPS Options.
rows.sort_by_key(|o| (o.name != "aiVersion", f.id == "eps" && o.name == "level"));
for o in rows {
@@ -269,10 +269,6 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
false
}
-/// The `.ai` options only the legacy version takes (and the only ones it takes, besides the
-/// version).
-const LEGACY_ONLY: &[&str] = &["transparentImages", "hiddenObjects", "outlineText"];
-
/// Is a `.ai` file to be saved in the legacy version 7 (`aiVersion: "7"`)?
fn legacy_ai(d: &Dialog) -> bool {
d.str("format") == "ai" && d.str("aiVersion").trim() == "7"
@@ -615,9 +611,13 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> DialogResult {
return Err("name the file".into());
}
let path = if d.bool("__pick") { with_extension(path.trim(), f) } else { path };
+ // Only the options the file reads: a `.ai` file's version takes its own (when the document's
+ // own file is a `.ai`, the save remembers the others as they were).
+ let legacy = legacy_ai(d);
let mut options: Map = f
.options
.iter()
+ .filter(|o| fileio::file_reads(f, legacy, o.name))
.filter_map(|o| d.fields.get(o.name).filter(|v| !v.is_null() && v.as_str() != Some("")).map(|v| (o.name.to_string(), v.clone())))
.collect();
if uncompressed(d) {
diff --git a/crates/ui-egui/src/dialogs/save_options/tests.rs b/crates/ui-egui/src/dialogs/save_options/tests.rs
index 7a1aeb3849..286550bac7 100644
--- a/crates/ui-egui/src/dialogs/save_options/tests.rs
+++ b/crates/ui-egui/src/dialogs/save_options/tests.rs
@@ -125,6 +125,33 @@ fn save_as_ai_version_7_writes_a_file_per_artboard() {
assert_eq!((st.save_options["hiddenObjects"].clone(), st.save_options["outlineText"].clone()), (json!("visible"), json!(true)));
}
+/// Save Options sends a `.ai` save only the options its version reads, so a person's save gets no
+/// note about an option the file doesn't read, and the other version's choices stay as the last
+/// save left them.
+#[test]
+fn ai_save_options_send_only_the_options_the_version_reads() {
+ let (mut app, _, _) = desktop("art.ai");
+ for (round, legacy) in [true, false, true].into_iter().enumerate() {
+ assert_eq!(app.run("file.saveAs", json!({})).unwrap()["pending"], KIND);
+ frame(&mut app, false);
+ match round {
+ 1 => assert!(dialog(&app).bool("outlineText"), "the legacy choice is shown as last saved"),
+ 2 => assert!(!dialog(&app).bool("editingData"), "the PDF-compatible choice is shown as last saved"),
+ _ => {}
+ }
+ let d = app.ui.dialog.as_mut().unwrap();
+ d.fields.insert("aiVersion".into(), json!(if legacy { "7" } else { "pdf" }));
+ let (key, value) = if legacy { ("outlineText", true) } else { ("editingData", false) };
+ d.fields.insert(key.into(), json!(value));
+ let d = d.clone();
+ app.ui.dialog = None;
+ let r = confirm(&mut app, &d).unwrap();
+ assert!(!r["warnings"].to_string().contains("was not used"), "round {round}: {r}");
+ }
+ let st = app.session.active().unwrap();
+ assert_eq!((st.save_options["outlineText"].clone(), st.save_options["editingData"].clone()), (json!(true), json!(false)));
+}
+
/// An Illustrator file opened, then saved as `.ai`: the options dialog stays open through the
/// Enter that closed the save panel, and only an Enter after it is let go confirms it.
#[test]
diff --git a/crates/ui-egui/src/dialogs/tests_screens.rs b/crates/ui-egui/src/dialogs/tests_screens.rs
index 7ae09dbdf6..1bd4f10afc 100644
--- a/crates/ui-egui/src/dialogs/tests_screens.rs
+++ b/crates/ui-egui/src/dialogs/tests_screens.rs
@@ -88,6 +88,39 @@ fn opens_on_defaults_and_draws_every_view() {
assert_eq!((spec(export_for_screens::KIND).ok_label.unwrap())(&app), "Export Artboard");
}
+/// The dialog's own request, with a row of every format its Format menu offers and every format's
+/// settings as it opens, gets no note about an option a file doesn't read.
+#[test]
+fn the_dialogs_own_rows_and_settings_get_no_unused_option_notes() {
+ let (mut app, _) = app(1, true);
+ open(&mut app);
+ let rows: Vec = [
+ json!({"format": "png"}),
+ json!({"format": "png8"}),
+ json!({"format": "jpg", "quality": 100}),
+ json!({"format": "webp"}),
+ json!({"format": "gif"}),
+ json!({"format": "tiff"}),
+ json!({"format": "svg"}),
+ json!({"format": "pdf"}),
+ json!({"format": "pdf", "marks": {"trim": true}, "suffix": "-print"}),
+ json!({"format": "eps"}),
+ json!({"format": "ai"}),
+ json!({"format": "vectorcraft"}),
+ ]
+ .into_iter()
+ .map(|mut r| {
+ r["scale"] = json!("1x");
+ r
+ })
+ .collect();
+ set(&mut app, "formats", json!(rows));
+ let p = export_for_screens::params(&app, app.ui.dialog.as_ref().unwrap()).unwrap();
+ let r = app.session.execute("document.exportForScreens", &p).unwrap();
+ let unused: Vec<&str> = r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).filter(|w| w.contains("was not used")).collect();
+ assert!(unused.is_empty(), "{unused:?}");
+}
+
#[test]
fn checked_artboards_read_as_a_range() {
let (mut app, _) = app(5, false);
diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv
index 1f469c69c9..4b5982cecd 100644
--- a/crates/ui-egui/src/i18n/bg.tsv
+++ b/crates/ui-egui/src/i18n/bg.tsv
@@ -4702,11 +4702,11 @@ axis Both И двете
@msg unknown effect `{id}` непознат ефект `{id}`
@msg unknown format непознат формат
@msg unknown format `{f}` (Save writes {_1}) непознат формат `{f}` (Запазването записва {_1})
-@msg unknown freeform mode `{m}` (points, lines) непознат режим на свободна форма `{m}` (точки, линии)
-@msg unknown gradient kind `{k}` (linear, radial, conic, diamond, freeform) непознат вид градиент `{k}` (линеен, радиален, конусен, ромбовиден, свободна форма)
+@msg unknown freeform mode `{m}` (points, lines) непознат режим на свободна форма `{m}` (points, lines)
+@msg unknown gradient kind `{k}` (linear, radial, conic, diamond, freeform) непознат вид градиент `{k}` (linear, radial, conic, diamond, freeform)
@msg unknown highlight `{h}` непознат акцент `{h}`
@msg unknown option `{path}.{k}` непозната опция `{path}.{k}`
-@msg unknown overprints `{o}` (preserve, simulate or discard) непознати надписи `{o}` (запазване, симулиране или отхвърляне)
+@msg unknown overprints `{o}` (preserve, simulate or discard) непознати надписи `{o}` (preserve, simulate или discard)
@msg unknown panel `{raw}` непознат панел `{raw}`
@msg unknown preference `{key}` непозната настройка `{key}`
@msg Preferences imported. Настройките са импортирани.
@@ -4724,7 +4724,7 @@ axis Both И двете
@msg `{key}` must be an object `{key}` трябва да бъде обект
@msg `prefs.import` runs on its own, not as a batch step `prefs.import` се изпълнява самостоятелно, а не като стъпка от пакет
@msg unknown preset (one of {_1}) непозната предварителна настройка (една от {_1})
-@msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) непозната предварителна настройка `{name}` (висока, средна, ниска или запазена: вижте flattener.presets.list)
+@msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) непозната предварителна настройка `{name}` (high, medium, low или запазена: вижте flattener.presets.list)
@msg unknown preset `{name}` (see flattener.presets.list) непозната предварителна настройка `{name}` (вижте flattener.presets.list)
@msg unknown preset `{name}` (see webExport.presets.list) непозната предварителна настройка `{name}` (вижте webExport.presets.list)
@msg unknown side `{other}` (left|right|top|bottom) непозната страна `{other}` (left|right|top|bottom)
@@ -5027,6 +5027,7 @@ effect Soft Меко
VectorCraft Title Bar on Every Desktop Заглавна лента на VectorCraft на всеки работен плот
The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Докато е зададена, променливата на средата VECTORCRAFT_TITLEBAR избира заглавната лента.
System Title Bar takes precedence over this option while it is on. Докато е включена, „Заглавна лента на системата“ има предимство пред тази опция.
+ This desktop draws the window's title bar with this option on or off while VectorCraft Title Bar on Every Desktop is off. Докато „Заглавна лента на VectorCraft на всеки работен плот“ е изключена, този работен плот изчертава заглавната лента на прозореца независимо дали тази опция е включена.
Value Axis Ос на стойностите
On Left Side От лявата страна
On Right Side От дясната страна
@@ -6057,6 +6058,8 @@ axis Y Axis Y ос
Action Options… Опции на действието…
Action Options Опции на действието
Playback Options… Опции за възпроизвеждане…
+ Button Mode Режим с бутони
+ No actions. Няма действия.
Insert Stop… Вмъкване на спиране…
Insert Stop Вмъкване на спиране
Record Stop Записване на спиране
diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv
index a23133933a..1576b72373 100644
--- a/crates/ui-egui/src/i18n/bs.tsv
+++ b/crates/ui-egui/src/i18n/bs.tsv
@@ -982,6 +982,7 @@
VectorCraft Title Bar on Every Desktop Naslovna traka VectorCrafta na svakoj radnoj površini
The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Dok je varijabla okruženja VECTORCRAFT_TITLEBAR postavljena, ona bira naslovnu traku.
System Title Bar takes precedence over this option while it is on. Dok je uključena, Sistemska naslovna traka ima prednost pred ovom opcijom.
+ This desktop draws the window's title bar with this option on or off while VectorCraft Title Bar on Every Desktop is off. Dok je Naslovna traka VectorCrafta na svakoj radnoj površini isključena, ova radna površina crta naslovnu traku prozora bez obzira na to je li ova opcija uključena.
Exit Izlaz
Distort Izobliči
Diffuse Glow… Difuzni sjaj…
@@ -6099,6 +6100,8 @@ axis Y Axis Y osa
Action Options… Opcije akcije…
Action Options Opcije akcije
Playback Options… Opcije reprodukcije…
+ Button Mode Način dugmadi
+ No actions. Nema akcija.
Insert Stop… Umetni zaustavljanje…
Insert Stop Umetni zaustavljanje
Record Stop Snimi zaustavljanje
diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv
index 373eb0b79d..c098a7cb4b 100644
--- a/crates/ui-egui/src/i18n/ca.tsv
+++ b/crates/ui-egui/src/i18n/ca.tsv
@@ -4095,6 +4095,8 @@ digits Malayalam Malaiàlam
Action Options… Opcions de l’acció…
Action Options Opcions de l’acció
Playback Options… Opcions de reproducció…
+ Button Mode Mode de botons
+ No actions. No hi ha accions.
Insert Stop… Insereix una aturada…
Insert Stop Insereix una aturada
Record Stop Enregistra una aturada
@@ -4613,6 +4615,7 @@ digits Malayalam Malaiàlam
VectorCraft Title Bar on Every Desktop Barra de títol de VectorCraft a tots els escriptoris
The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Mentre està definida, la variable d’entorn VECTORCRAFT_TITLEBAR determina la barra de títol.
System Title Bar takes precedence over this option while it is on. Mentre està activada, Barra de títol del sistema té prioritat sobre aquesta opció.
+ This desktop draws the window's title bar with this option on or off while VectorCraft Title Bar on Every Desktop is off. Mentre Barra de títol de VectorCraft a tots els escriptoris està desactivada, aquest escriptori dibuixa la barra de títol de la finestra tant si aquesta opció està activada com si no.
Tab Tabulació
Tab Stops Aturades de tabulació
Table Taula
diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv
index 7178be3143..bfe8615b9c 100644
--- a/crates/ui-egui/src/i18n/cs.tsv
+++ b/crates/ui-egui/src/i18n/cs.tsv
@@ -1001,6 +1001,7 @@
VectorCraft Title Bar on Every Desktop Záhlaví okna VectorCraftu v každém desktopovém prostředí
The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Dokud je proměnná prostředí VECTORCRAFT_TITLEBAR nastavena, záhlaví okna volí ona.
System Title Bar takes precedence over this option while it is on. Dokud je Systémové záhlaví okna zapnuté, má přednost před touto volbou.
+ This desktop draws the window's title bar with this option on or off while VectorCraft Title Bar on Every Desktop is off. Dokud je Záhlaví okna VectorCraftu v každém desktopovém prostředí vypnuté, toto desktopové prostředí kreslí záhlaví okna bez ohledu na to, zda je tato volba zapnutá.
Exit Ukončit
Distort Zkreslení
Diffuse Glow… Rozptýlená záře…
@@ -6600,6 +6601,8 @@ axis Y Axis osa Y
Action Options… Možnosti akce…
Action Options Možnosti akce
Playback Options… Možnosti přehrávání…
+ Button Mode Režim tlačítek
+ No actions. Žádné akce.
Insert Stop… Vložit zastavení…
Insert Stop Vložit zastavení
Record Stop Zaznamenat zastavení
@@ -7492,3 +7495,1816 @@ breakText Glyphs Glyfy
Grommets Průchodky
Grommet Inset Odstup průchodek od okraje
Diameter Průměr
+@msg 'caller', 'callee' and 'arguments' cannot be used on strict mode functions 'caller', 'callee' a 'arguments' nelze použít u funkcí ve striktním režimu
+@msg A profile supports one Revolve effect. Edit the existing effect's options instead. Profil podporuje jeden efekt Rotační těleso. Upravte místo toho volby stávajícího efektu.
+@msg Add Points at Crossings: a path went missing Přidat body v křížení: jedna cesta se ztratila
+@msg Alignment must be one of the choices Zarovnání musí být jedna z nabízených voleb
+@msg Boolean.prototype.{name} was called on a value that is not a Boolean Boolean.prototype.{name} byla zavolána na hodnotě, která není Boolean
+@msg CMYK images whose samples couldn't be read in CMYK were imported in RGB Obrazy CMYK, jejichž vzorky nešlo přečíst v CMYK, byly importovány v RGB
+@msg CSS options: {e} Volby CSS: {e}
+@msg CSV has a header but no data rows CSV má záhlaví, ale žádné řádky dat
+@msg CSV holds NUL bytes: save it as UTF-8, or as UTF-16 with a byte-order mark CSV obsahuje bajty NUL: uložte ho jako UTF-8, nebo jako UTF-16 se značkou pořadí bajtů
+@msg CSV is empty CSV je prázdné
+@msg CSV is larger than {MAX_CSV_BYTES} bytes CSV je větší než {MAX_CSV_BYTES} bajtů
+@msg CSV is not UTF-16 CSV není v UTF-16
+@msg CSV is not UTF-8 CSV není v UTF-8
+@msg CSV would be larger than {MAX_CSV_BYTES} bytes CSV by bylo větší než {MAX_CSV_BYTES} bajtů
+@msg Cannot write to the location of “{name}”; choose where to save it Do umístění souboru „{name}“ nelze zapisovat; zvolte, kam ho uložit
+@msg Choose original artwork, not a dimension's generated paths Zvolte původní kresbu, ne cesty vytvořené kótou
+@msg Contour: select paths or shapes Obrys: vyberte cesty nebo tvary
+@msg Contour: the Offset Path effect is not available Obrys: efekt Odsadit cestu není dostupný
+@msg Create needs fills, strokes or both Vytvořit vyžaduje výplně, tahy nebo obojí
+@msg DCS files can only be placed linked: use File › Place with Link on Soubory DCS lze umístit jen jako propojené: použijte Soubor › Umístit se zapnutou volbou Propojení
+@msg DXF options: {e} Volby DXF: {e}
+@msg DXF scale must be a positive number up to {_1}, not {scale} Měřítko DXF musí být kladné číslo nejvýše {_1}, ne {scale}
+@msg DXF {field} `{s}`: {allowed} DXF {field} `{s}`: {allowed}
+@msg Date.prototype method called on a value that is not a Date object Metoda Date.prototype byla zavolána na hodnotě, která není objekt Date
+@msg Dimension is locked or hidden Kóta je zamknutá nebo skrytá
+@msg Dimension mode does not match its source Režim kóty neodpovídá jejímu zdroji
+@msg Draw Inside needs exactly one selected path, compound path or text Kreslit dovnitř vyžaduje právě jednu vybranou cestu, složenou cestu nebo text
+@msg EPS Save writes all artwork: use document.export for selected-only EPS Uložení EPS zapisuje celou kresbu: pro EPS jen z výběru použijte document.export
+@msg EPS flattener: {e} Sloučení průhlednosti EPS: {e}
+@msg EPS options: {e} Volby EPS: {e}
+@msg EPS {field} `{s}`: {allowed} EPS {field} `{s}`: {allowed}
+@msg Error.prototype.toString was called on a value that is not an object Error.prototype.toString byla zavolána na hodnotě, která není objekt
+@msg Export for Screens writes png, png8, jpg, webp, gif, tiff, bmp, tga, psd, svg, svgz, pdf, eps, ai or vectorcraft Exportovat pro obrazovky zapisuje png, png8, jpg, webp, gif, tiff, bmp, tga, psd, svg, svgz, pdf, eps, ai nebo vectorcraft
+@msg Flare click coordinates must be finite numbers Souřadnice kliknutí pro Odlesk musí být konečná čísla
+@msg Function.prototype.toString was called on a value that is not a function Function.prototype.toString byla zavolána na hodnotě, která není funkce
+@msg HTML Text applies to object slices of type only Text HTML se týká jen řezů objektů s textem
+@msg Identify Objects By is XML ID: the name must be a valid XML ID (start with a letter, underscore or colon; no spaces) Identifikovat objekty podle je nastaveno na XML ID: název musí být platné XML ID (začíná písmenem, podtržítkem nebo dvojtečkou; bez mezer)
+@msg Image Trace failed: internal error: its thread stopped (please report this bug) Vektorizace obrazu selhala: vnitřní chyba: její vlákno se zastavilo (nahlaste prosím tuto chybu)
+@msg Image Trace failed: {e} Vektorizace obrazu selhala: {e}
+@msg Image Trace finished, but its document was closed Vektorizace obrazu skončila, ale její dokument byl zavřen
+@msg Image Trace finished, but the image or its settings changed meanwhile: trace it again Vektorizace obrazu skončila, ale obraz nebo jeho nastavení se mezitím změnily: vektorizujte ho znovu
+@msg Import SVG Filter reads SVG files, not SVGZ Importovat filtr SVG čte soubory SVG, ne SVGZ
+@msg Invalid dimension offset Neplatné odsazení kóty
+@msg Invalid point Neplatný bod
+@msg JSON.parse: {why} (at offset {at}) JSON.parse: {why} (na pozici {at})
+@msg JSON.stringify cannot write a structure that contains itself JSON.stringify nemůže zapsat strukturu, která obsahuje sama sebe
+@msg Join: a subpath went missing Spojit: jedna dílčí cesta se ztratila
+@msg Line Gap must be a number Mezera řádků musí být číslo
+@msg Lock Workspace is not available in VectorCraft Zamknout pracovní prostor není ve VectorCraft dostupné
+@msg Move Panel to New Window is not available: floating panels stay in the main window Přesunout panel do nového okna není dostupné: plovoucí panely zůstávají v hlavním okně
+@msg Namespace.prototype.toString needs a Namespace Namespace.prototype.toString vyžaduje Namespace
+@msg New Document profiles need the preferences folder, which this run doesn't have Profily nového dokumentu vyžadují složku předvoleb, kterou toto spuštění nemá
+@msg Number.prototype.{name} was called on a value that is not a number Number.prototype.{name} byla zavolána na hodnotě, která není číslo
+@msg Object.create's prototype must be an object or null Prototyp pro Object.create musí být objekt nebo null
+@msg Object.{function} was given a value that is not an object Object.{function} dostala hodnotu, která není objekt
+@msg Offset Path: the path is too complex to offset Odsadit cestu: cesta je příliš složitá na odsazení
+@msg Offset Path: {e} Odsadit cestu: {e}
+@msg PDF content is incomplete or exceeds the reader's limits; no document was imported Obsah PDF je neúplný nebo překračuje limity čtení; žádný dokument nebyl importován
+@msg PDF flattener: {e} Sloučení průhlednosti PDF: {e}
+@msg PDF options: {e} Volby PDF: {e}
+@msg QName.prototype.toString needs a QName QName.prototype.toString vyžaduje QName
+@msg Recolor Artwork is no longer open Přebarvit kresbu už není otevřené
+@msg RegExp.prototype.{name} was called on a value that is not a RegExp object RegExp.prototype.{name} byla zavolána na hodnotě, která není objekt RegExp
+@msg SVG is not UTF-8, UTF-16 or ISO 8859-1 SVG není v UTF-8, UTF-16 ani ISO 8859-1
+@msg Save writes one artboard: name one, or export several with document.export Uložit zapisuje jednu pracovní plochu: uveďte jednu, nebo jich několik exportujte pomocí document.export
+@msg Select a dimension first Nejprve vyberte kótu
+@msg Select dimensions to update Vyberte kóty k aktualizaci
+@msg Select only dimensions Vyberte jen kóty
+@msg Select two or more objects, or a group Vyberte dva nebo více objektů, nebo skupinu
+@msg Shaper needs 1–64 source paths Shaper vyžaduje 1–64 zdrojových cest
+@msg Stencil Bridges: the selected art is too large or too detailed to bridge Můstky šablony: vybraná kresba je příliš velká nebo příliš podrobná na přemostění
+@msg String.prototype.toString was called on a value that is not a string String.prototype.toString byla zavolána na hodnotě, která není řetězec
+@msg Switch x/y is for scatter graphs Prohodit x/y je určeno pro bodové grafy
+@msg Text options: {e} Volby textu: {e}
+@msg The {name} profile could not be read, so the new document has the default settings: {reason} Profil {name} nešlo přečíst, takže nový dokument má výchozí nastavení: {reason}
+@msg Trap: more than {_1} pairs of objects lie close enough to trap, or too many objects share a horizontal range; trap fewer objects at a time Přesah barev: více než {_1} dvojic objektů leží dost blízko pro přesah, nebo příliš mnoho objektů sdílí vodorovný rozsah; zpracujte najednou méně objektů
+@msg Weed Box and Lines: no weeding line fits outside the art, and the weed box is off Rámeček a linie pro vybírání: mimo kresbu se nevejde žádná linie pro vybírání a rámeček je vypnutý
+@msg \ at end of pattern \ na konci vzoru
+@msg `alignOn` cannot be empty `alignOn` nesmí být prázdné
+@msg `alignOn` must be a string `alignOn` musí být řetězec
+@msg `align` is start, center or end `align` je start, center nebo end
+@msg `align` must be a string `align` musí být řetězec
+@msg `anchors` must be an array of [subpath, anchor] pairs `anchors` musí být pole dvojic [subpath, anchor]
+@msg `anchors` must list anchor indices (0, 1, 2…), not {k} `anchors` musí uvádět indexy kotevních bodů (0, 1, 2…), ne {k}
+@msg `anchors` must list anchor indices (0, 1, 2…), not {v} `anchors` musí uvádět indexy kotevních bodů (0, 1, 2…), ne {v}
+@msg `answer` for this {kind} dialog must be one of: {_1} `answer` pro tento dialog {kind} musí být jedna z hodnot: {_1}
+@msg `answers` has more than {MAX_ANSWERS} answers `answers` má více než {MAX_ANSWERS} odpovědí
+@msg `answers` is larger than 1 MiB `answers` je větší než 1 MiB
+@msg `answers` must be a list of answers `answers` musí být seznam odpovědí
+@msg `artboard` must be the index of an artboard of the document `artboard` musí být index pracovní plochy dokumentu
+@msg `artboardsAt` must be [x, y] `artboardsAt` musí být [x, y]
+@msg `artboards` must be a list of artboard indices `artboards` musí být seznam indexů pracovních ploch
+@msg `artboards` names an artboard the document does not have `artboards` uvádí pracovní plochu, kterou dokument nemá
+@msg `artboards` names an artboard twice `artboards` uvádí některou pracovní plochu dvakrát
+@msg `asset` is the asset id (see assets.list) `asset` je ID prvku (viz assets.list)
+@msg `at` must be [x, y] (got {at}) `at` musí být [x, y] (zadáno {at})
+@msg `at` must be a byte offset `at` musí být bajtový posun
+@msg `at` must be a point [x, y] `at` musí být bod [x, y]
+@msg `autoLeading` must be a number `autoLeading` musí být číslo
+@msg `background` must be "", "matte" or #rrggbb `background` musí být "", "matte" nebo #rrggbb
+@msg `beside` needs `side` `beside` vyžaduje `side`
+@msg `burasagari` must be "none", "standard" or "forced" `burasagari` musí být "none", "standard" nebo "forced"
+@msg `center` must be [x, y] `center` musí být [x, y]
+@msg `charAlign` must be "romanBaseline", "emBoxTop", "emBoxCenter", "emBoxBottom", "icfTop" or "icfBottom" `charAlign` musí být "romanBaseline", "emBoxTop", "emBoxCenter", "emBoxBottom", "icfTop" nebo "icfBottom"
+@msg `charDirection` must be "default", "leftToRight" or "rightToLeft" `charDirection` musí být "default", "leftToRight" nebo "rightToLeft"
+@msg `clearStroke` requires `stroke: false`, `ids: []` and no `stop` `clearStroke` vyžaduje `stroke: false`, `ids: []` a žádné `stop`
+@msg `clear` must be a list of type object ids, not {v} `clear` musí být seznam ID textových objektů, ne {v}
+@msg `clear` must list type objects; {_1} is not one `clear` musí uvádět textové objekty; {_1} jím není
+@msg `clear` must not list `id` `clear` nesmí uvádět `id`
+@msg `code` must be a whole number `code` musí být celé číslo
+@msg `colorMode` must be one of rgb, cmyk `colorMode` musí být jedna z hodnot rgb, cmyk
+@msg `color` needs a `rule` to make the theme from `color` vyžaduje `rule`, podle kterého se motiv vytvoří
+@msg `colors` does not apply to the {name} reduction `colors` se netýká redukce {name}
+@msg `colors` is empty `colors` je prázdné
+@msg `colors` must be a whole number from 2 to 256 `colors` musí být celé číslo od 2 do 256
+@msg `colors` must be at least 9 for the uniform reduction `colors` musí být pro rovnoměrnou redukci nejméně 9
+@msg `colors` must be between 1 and {MAX_THEME_COLORS} (got {n}) `colors` musí být mezi 1 a {MAX_THEME_COLORS} (zadáno {n})
+@msg `columnType` is vertical, uniform, repeating or sliding `columnType` je vertical, uniform, repeating nebo sliding
+@msg `columns` must be a whole number 1..50 `columns` musí být celé číslo 1..50
+@msg `commands` must not be empty `commands` nesmí být prázdné
+@msg `contents` goes with `to` `contents` patří k `to`
+@msg `corners` must be sharp, round or square, not `{other}` `corners` musí být sharp, round nebo square, ne `{other}`
+@msg `corners` must list anchor indices (0, 1, 2…), not {v} `corners` musí uvádět indexy kotevních bodů (0, 1, 2…), ne {v}
+@msg `count` must be a whole number from 0 to {MAX_COUNT} `count` musí být celé číslo od 0 do {MAX_COUNT}
+@msg `count` must be a whole number from 1 to 100 `count` musí být celé číslo od 1 do 100
+@msg `dataBase64` is larger than the module limit `dataBase64` je větší než limit modulu
+@msg `dataBase64` is not valid base64 `dataBase64` není platný base64
+@msg `defaultsOnly` cannot be combined with `ids`, `reverse` or `append` `defaultsOnly` nelze kombinovat s `ids`, `reverse` ani `append`
+@msg `depth` and `childLimit` slice the summary: pass `summary: true` `depth` a `childLimit` omezují souhrn: předejte `summary: true`
+@msg `design` is a design name, or null for plain columns `design` je název návrhu, nebo null pro obyčejné sloupce
+@msg `design` is a design name, or null for the default marker `design` je název návrhu, nebo null pro výchozí značku
+@msg `destinationFolder` is required when destination is `folder` `destinationFolder` je povinné, když je cíl `folder`
+@msg `destination` must be `none`, `saveAndClose` or `folder` `destination` musí být `none`, `saveAndClose` nebo `folder`
+@msg `diameter` must be from 0.1 to 2500 pt `diameter` musí být od 0.1 do 2500 pt
+@msg `digits` must be one of {_1} `digits` musí být jedna z hodnot {_1}
+@msg `dimImages` is a percentage or false `dimImages` je procento nebo false
+@msg `direction` must be "auto", "leftToRight" or "rightToLeft" `direction` musí být "auto", "leftToRight" nebo "rightToLeft"
+@msg `ditherAmount` must be a number from 0 to 100 `ditherAmount` musí být číslo od 0 do 100
+@msg `each` is the value one design stands for, more than 0 `each` je hodnota, kterou představuje jeden návrh, větší než 0
+@msg `end` must be "start" or "end" `end` musí být "start" nebo "end"
+@msg `ends` applies to joining exactly two paths `ends` se týká spojení právě dvou cest
+@msg `event` must be one of {_1} `event` musí být jedna z hodnot {_1}
+@msg `every` must be "all", "odd" or "even", not {v} `every` musí být "all", "odd" nebo "even", ne {v}
+@msg `expand` must be true or false `expand` musí být true nebo false
+@msg `fidelity` must be from {lo} to {hi} pt `fidelity` musí být od {lo} do {hi} pt
+@msg `files` must be 1 to 1000 paths of font files the last text.findFontFiles search found `files` musí být 1 až 1000 cest k souborům písem, které našlo poslední hledání text.findFontFiles
+@msg `focal` and `radius` cannot be used together `focal` a `radius` nelze použít společně
+@msg `focal` must be an array of [subpath, anchor] pairs `focal` musí být pole dvojic [subpath, anchor]
+@msg `focal` names [{si}, {ai}], which is not an anchor of the path `focal` uvádí [{si}, {ai}], což není kotevní bod cesty
+@msg `folder` must be an absolute folder path `folder` musí být absolutní cesta ke složce
+@msg `fontAxes` must be an object of axis tags and values, such as {"wght"} `fontAxes` musí být objekt se značkami os a hodnotami, například {"wght"}
+@msg `format` must be a string `format` musí být řetězec
+@msg `format` must be one of {_1} `format` musí být jedna z hodnot {_1}
+@msg `fractions` is chop or scale `fractions` je chop nebo scale
+@msg `from` and `to` must differ `from` a `to` se musí lišit
+@msg `from` has an empty frame `from` má prázdný rámec
+@msg `from` must be a non-negative integer `from` musí být nezáporné celé číslo
+@msg `from` must be area type or type on a path `from` musí být text v oblasti nebo text na cestě
+@msg `gid` must be a glyph id, not {v} `gid` musí být ID glyfu, ne {v}
+@msg `glyphAlternate` belongs to one character, not to a character style (text.setGlyphAlternate sets it) `glyphAlternate` patří jednomu znaku, ne stylu znaků (nastavuje ho text.setGlyphAlternate)
+@msg `glyphAlternate` takes only null, which takes a glyph choice away (text.setGlyphAlternate makes one) `glyphAlternate` přijímá jen null, které volbu glyfu odebere (volbu nastavuje text.setGlyphAlternate)
+@msg `group` must be one of {_1} `group` musí být jedna z hodnot {_1}
+@msg `group` must be the Objects on Path group of `member` and `target` `group` musí být skupina Objekty na cestě, do které patří `member` a `target`
+@msg `guide` must be a guide index (guide.list) `guide` musí být index vodítka (guide.list)
+@msg `hAlign` must be default, left, center or right `hAlign` musí být default, left, center nebo right
+@msg `id` is an object id, not {v} `id` je ID objektu, ne {v}
+@msg `id` must be a node id `id` musí být ID uzlu
+@msg `id` must be an object id, not {v} `id` musí být ID objektu, ne {v}
+@msg `ids` must name Objects on Path groups or objects in them `ids` musí uvádět skupiny Objekty na cestě nebo objekty v nich
+@msg `ids` takes at most {MAX_INSPECT_IDS} ids `ids` přijímá nejvýše {MAX_INSPECT_IDS} ID
+@msg `imageMap` must be none, rectangle or polygon `imageMap` musí být none, rectangle nebo polygon
+@msg `includeArtboardFill` must be true or false `includeArtboardFill` musí být true nebo false
+@msg `includeSubdirs` must be true or false `includeSubdirs` musí být true nebo false
+@msg `index` must be a point index `index` musí být index bodu
+@msg `index` must be a whole number or null `index` musí být celé číslo nebo null
+@msg `indices` must be a non-empty array of item indices `indices` musí být neprázdné pole indexů položek
+@msg `item` needs `index` `item` vyžaduje `index`
+@msg `items` is a list of {kind, item} `items` je seznam {kind, item}
+@msg `items` is empty `items` je prázdné
+@msg `items` must be a list of {id, points} `items` musí být seznam {id, points}
+@msg `items` must be a list of {{id, anchors?}}, not {v} `items` musí být seznam {{id, anchors?}}, ne {v}
+@msg `items` must be an array of {id, anchors} `items` musí být pole {id, anchors}
+@msg `items` must be an array of {{id, corners?}}, not {v} `items` musí být pole {{id, corners?}}, ne {v}
+@msg `kashida` must be "none", "short", "medium", "long" or "stylistic" `kashida` musí být "none", "short", "medium", "long" nebo "stylistic"
+@msg `kerningBefore` belongs to a line's first character, not to a character style (text.setRangeStyle sets it) `kerningBefore` patří prvnímu znaku řádku, ne stylu znaků (nastavuje ho text.setRangeStyle)
+@msg `kind` is one of {_1} `kind` je jedna z hodnot {_1}
+@msg `kind` must be "round", "invertedRound" or "chamfer", not {v} `kind` musí být "round", "invertedRound" nebo "chamfer", ne {v}
+@msg `kind` must be `text`, `visibility`, `image` or `graph` `kind` musí být `text`, `visibility`, `image` nebo `graph`
+@msg `kind` must be a string `kind` musí být řetězec
+@msg `kind` must be image, noImage or htmlText `kind` musí být image, noImage nebo htmlText
+@msg `kinsokuType` must be "pushInFirst", "pushOutFirst" or "pushOutOnly" `kinsokuType` musí být "pushInFirst", "pushOutFirst" nebo "pushOutOnly"
+@msg `kinsoku` must be "none", "hard" or "soft" `kinsoku` musí být "none", "hard" nebo "soft"
+@msg `label` must be a non-empty name of at most 200 characters `label` musí být neprázdný název o nejvýše 200 znacích
+@msg `language` must be a language name or tag, such as "Turkish" or "tr" `language` musí být název nebo značka jazyka, například "Turkish" nebo "tr"
+@msg `leader` must be a string `leader` musí být řetězec
+@msg `leadingModel` must be "romanBaseline" or "emBoxTop" `leadingModel` musí být "romanBaseline" nebo "emBoxTop"
+@msg `leading` must be from 0.1 to 5000, or null for Auto `leading` musí být od 0.1 do 5000, nebo null pro Automaticky
+@msg `length` must be a number of at most 1000000 pt either way `length` musí být číslo nejvýše 1000000 pt v obou směrech
+@msg `line` must be true or false `line` musí být true nebo false
+@msg `max` must be 1 to {MAX_SUGGESTIONS} `max` musí být 1 až {MAX_SUGGESTIONS}
+@msg `minAngle` ({min_angle}°) can't be more than `maxAngle` ({max_angle}°) `minAngle` ({min_angle}°) nemůže být větší než `maxAngle` ({max_angle}°)
+@msg `mode` must be "handles", "straight" or "smooth" `mode` musí být "handles", "straight" nebo "smooth"
+@msg `mode` must be "straight", "bezier" or "arc" `mode` musí být "straight", "bezier" nebo "arc"
+@msg `mode` must be one of {_1} `mode` musí být jedna z hodnot {_1}
+@msg `mojikumi` must be "none", "yakumonoHankaku", "gyomatsuYakumonoHankaku", "gyomatsuYakumonoZenkaku", "yakumonoZenkaku" or "lineEndHalf" `mojikumi` musí být "none", "yakumonoHankaku", "gyomatsuYakumonoHankaku", "gyomatsuYakumonoZenkaku", "yakumonoZenkaku" nebo "lineEndHalf"
+@msg `name` must be a name that is not empty `name` musí být název, který není prázdný
+@msg `name` must not be empty `name` nesmí být prázdné
+@msg `names` is a list of preset names `names` je seznam názvů přednastavení
+@msg `names` lists preset names `names` uvádí názvy přednastavení
+@msg `names` must be a list of names `names` musí být seznam názvů
+@msg `names` must be a list of swatch names `names` musí být seznam názvů položek vzorníku
+@msg `names` must be an array of style names `names` musí být pole názvů stylů
+@msg `new` is a design name, or true `new` je název návrhu, nebo true
+@msg `origin` must be [x, y] in points `origin` musí být [x, y] v bodech
+@msg `path` must be a folder `path` musí být složka
+@msg `path` must be a path object `path` musí být objekt cesty
+@msg `picks` must be an array (got {v}) `picks` musí být pole (zadáno {v})
+@msg `picks` must hold 1 to {MAX_PICKS} picks (got {_1}) `picks` musí obsahovat 1 až {MAX_PICKS} voleb (zadáno {_1})
+@msg `pivot` must be a whole number from 0 to 8 `pivot` musí být celé číslo od 0 do 8
+@msg `points` must be a list of [x, y] points `points` musí být seznam bodů [x, y]
+@msg `points` must be a list of point indexes `points` musí být seznam indexů bodů
+@msg `points` must be an array of point indices `points` musí být pole indexů bodů
+@msg `points` must be point indexes `points` musí být indexy bodů
+@msg `points` must hold from 2 to 100000 points `points` musí obsahovat 2 až 100000 bodů
+@msg `port` must be "in" or "out" `port` musí být "in" nebo "out"
+@msg `position` is above or below `position` je above nebo below
+@msg `preset` must be a string `preset` musí být řetězec
+@msg `radius` must be 0 or more `radius` musí být 0 nebo více
+@msg `ratio` must be from 0.1 to 0.5 `ratio` musí být od 0.1 do 0.5
+@msg `rect` must be [x, y, width, height] `rect` musí být [x, y, width, height]
+@msg `rect` must be [x, y, width, height] with a positive size `rect` musí být [x, y, width, height] s kladnou velikostí
+@msg `rect` must be [x0, y0, x1, y1] (got {r}) `rect` musí být [x0, y0, x1, y1] (zadáno {r})
+@msg `relation` must be overlap, inside or outside `relation` musí být overlap, inside nebo outside
+@msg `replace` must be a string `replace` musí být řetězec
+@msg `reversed` must be true or false `reversed` musí být true nebo false
+@msg `rotate` must be a finite number `rotate` musí být konečné číslo
+@msg `rows` must be lists of numbers or nulls, not {r} `rows` musí být seznamy čísel nebo hodnot null, ne {r}
+@msg `rule` must be nonZero or evenOdd `rule` musí být nonZero nebo evenOdd
+@msg `scale` must be a number from 0.001 to 1000 `scale` musí být číslo od 0.001 do 1000
+@msg `scale` must be a number in (0, 100] `scale` musí být číslo v rozsahu (0, 100]
+@msg `scope.{key}` must be a non-empty array `scope.{key}` musí být neprázdné pole
+@msg `scope` must be "document", {artboards} or {layers} `scope` musí být "document", {artboards} nebo {layers}
+@msg `scope` must be "selection" or "document", not {other} `scope` musí být "selection" nebo "document", ne {other}
+@msg `script` must be a string `script` musí být řetězec
+@msg `seriesIndexes` is a non-empty list of series indexes `seriesIndexes` je neprázdný seznam indexů řad
+@msg `seriesIndexes` needs a `type` or a `valueAxis` `seriesIndexes` vyžaduje `type` nebo `valueAxis`
+@msg `shift` must be a number of points `shift` musí být počet bodů
+@msg `showCenter` must be true or false `showCenter` musí být true nebo false
+@msg `side` must be "left", "right", "top" or "bottom" `side` musí být "left", "right", "top" nebo "bottom"
+@msg `side` places the artboard: leave out x, y, width and height `side` umísťuje pracovní plochu: vynechte x, y, width a height
+@msg `singleWordJustification` must be "fullJustify", "alignLeft", "alignCenter" or "alignRight" `singleWordJustification` musí být "fullJustify", "alignLeft", "alignCenter" nebo "alignRight"
+@msg `size` is a percentage of the base text's size, 1 to 100 `size` je procento velikosti základního textu, 1 až 100
+@msg `source` and `target` are the same object `source` a `target` jsou tentýž objekt
+@msg `source` is the only path in series group `target` `source` je jediná cesta ve skupině řady `target`
+@msg `source` must be `folder` or `datasets` `source` musí být `folder` nebo `datasets`
+@msg `spacing` makes more than {MAX_COUNT} lines across the box `spacing` vytvoří napříč rámečkem více než {MAX_COUNT} linií
+@msg `spacing` must be a number `spacing` musí být číslo
+@msg `spacing` must be a number of points, 0 or more, or "auto" `spacing` musí být počet bodů, 0 nebo více, nebo "auto"
+@msg `spacing` must be more than 0 and at most {MAX_DISTANCE} pt `spacing` musí být více než 0 a nejvýše {MAX_DISTANCE} pt
+@msg `start` must be a finite number `start` musí být konečné číslo
+@msg `starts` must be an array of anchor indices (or nulls) `starts` musí být pole indexů kotevních bodů (nebo hodnot null)
+@msg `status` must be linked, embedded, missing or modified, or a list of them `status` musí být linked, embedded, missing nebo modified, nebo jejich seznam
+@msg `stop` must be a stop index `stop` musí být index zarážky
+@msg `story` styles the whole story: give no `start` or `end` `story` nastavuje styl celého článku: nezadávejte `start` ani `end`
+@msg `story` takes the whole story: give no `start` or `end` `story` zahrnuje celý článek: nezadávejte `start` ani `end`
+@msg `strokeMode` applies to strokes (stroke: true) `strokeMode` platí pro tahy (stroke: true)
+@msg `style` must be roman or japanese `style` musí být roman nebo japanese
+@msg `subpaths` must be two integer indices `subpaths` musí být dva celočíselné indexy
+@msg `symbol` must be a symbol name `symbol` musí být název symbolu
+@msg `target` must be "object" or "contents", not {v} `target` musí být "object" nebo "contents", ne {v}
+@msg `target` must be an object of the same Objects on Path group as `member` `target` musí být objekt ze stejné skupiny Objekty na cestě jako `member`
+@msg `tint` applies to global and spot colours `tint` platí pro globální a přímé barvy
+@msg `tint` goes with a global or spot `swatch` `tint` patří ke globální nebo přímé barvě v `swatch`
+@msg `tint` must be a number (%) `tint` musí být číslo (%)
+@msg `tint` needs a global or spot colour `tint` vyžaduje globální nebo přímou barvu
+@msg `to` is already threaded `to` je už zřetězené
+@msg `to` is the design's new name `to` je nový název návrhu
+@msg `to` must be [x, y] `to` musí být [x, y]
+@msg `to` must be a closed path, area type or type on a path other than `from` `to` musí být uzavřená cesta, text v oblasti nebo text na cestě jiný než `from`
+@msg `to` must be a whole number `to` musí být celé číslo
+@msg `to` takes the rows of one object's stack, not aligned rows `to` přijímá řádky zásobníku jednoho objektu, ne zarovnané řádky
+@msg `tolerance` must be from 0 to 100 pt `tolerance` musí být od 0 do 100 pt
+@msg `useLab` must be true or false `useLab` musí být true nebo false
+@msg `vAlign` must be default, top, middle, baseline or bottom `vAlign` musí být default, top, middle, baseline nebo bottom
+@msg `values` must be a list `values` musí být seznam
+@msg `values` must be an object `values` musí být objekt
+@msg `values` must name hex, rgb, cmyk, lab or gray `values` musí uvádět hex, rgb, cmyk, lab nebo gray
+@msg `values` names a kind twice `values` uvádí některý druh dvakrát
+@msg `version` must be 0 or {_1} `version` musí být 0 nebo {_1}
+@msg `version` must be 0 or {newest} `version` musí být 0 nebo {newest}
+@msg `warichuAlign` must be one of {_1} `warichuAlign` musí být jedna z hodnot {_1}
+@msg `warichuLines` must be a whole number from {_1} to {_2} `warichuLines` musí být celé číslo od {_1} do {_2}
+@msg `weight` must be from 0.01 to 1000 pt `weight` musí být od 0.01 do 1000 pt
+@msg `weights` must be a list of {_1} numbers, one per row (got {v}) `weights` musí být seznam {_1} čísel, jedno na řádek (zadáno {v})
+@msg `xml` must be a string `xml` musí být řetězec
+@msg `{KEY}` entry {n} gives the paragraph at byte {start} of {_1} other indents than an entry before it položka {n} v `{KEY}` dává odstavci na bajtu {start} objektu {_1} jiná odsazení než některá položka před ní
+@msg `{KEY}` entry {n} must be an object položka {n} v `{KEY}` musí být objekt
+@msg `{KEY}` entry {n} sets neither `leftIndent` nor `firstLineIndent` položka {n} v `{KEY}` nenastavuje `leftIndent` ani `firstLineIndent`
+@msg `{KEY}` entry {n}: `id` must be an object id položka {n} v `{KEY}`: `id` musí být ID objektu
+@msg `{KEY}` entry {n}: `start` must be a byte offset položka {n} v `{KEY}`: `start` musí být bajtový posun
+@msg `{KEY}` entry {n}: `{k}` must be a number from -{MAX_PARA} to {MAX_PARA} pt položka {n} v `{KEY}`: `{k}` musí být číslo od -{MAX_PARA} do {MAX_PARA} pt
+@msg `{KEY}` entry {n}: no paragraph of {_1} starts at byte {start} položka {n} v `{KEY}`: žádný odstavec objektu {_1} nezačíná na bajtu {start}
+@msg `{KEY}` entry {n}: unknown key `{k}` položka {n} v `{KEY}`: neznámý klíč `{k}`
+@msg `{KEY}` entry {n}: {_1} is not a type object položka {n} v `{KEY}`: {_1} není textový objekt
+@msg `{KEY}` is empty `{KEY}` je prázdné
+@msg `{KEY}` must be a list `{KEY}` musí být seznam
+@msg `{KEY}` takes at most {MAX_PARAGRAPH_INDENTS} entries (got {_1}) `{KEY}` přijímá nejvýše {MAX_PARAGRAPH_INDENTS} položek (zadáno {_1})
+@msg `{KEY}` takes no `{other}` beside it `{KEY}` vedle sebe nepřijímá `{other}`
+@msg `{_1}` can't be edited or removed `{_1}` nelze upravit ani odebrat
+@msg `{_1}` is built in and always listed `{_1}` je vestavěné a je uvedeno vždy
+@msg `{_1}` is not a font file the last text.findFontFiles search found `{_1}` není soubor písma, který našlo poslední hledání text.findFontFiles
+@msg `{_1}` would be overwritten by the package: choose a different folder or name `{_1}` by balíček přepsal: zvolte jinou složku nebo název
+@msg `{base}` is larger than {_1} MB `{base}` je větší než {_1} MB
+@msg `{base}` is not in the DCS file's folder `{base}` není ve složce souboru DCS
+@msg `{file}` has no art to place `{file}` nemá žádnou kresbu k umístění
+@msg `{file}` is over {_1} MB `{file}` má více než {_1} MB
+@msg `{f}` belongs to a VectorCraft that is running `{f}` patří běžící aplikaci VectorCraft
+@msg `{f}` is the copy of a document open now `{f}` je kopie právě otevřeného dokumentu
+@msg `{id}` is no writable format (see document.formats) `{id}` není formát, který lze zapisovat (viz document.formats)
+@msg `{id}` isn't a loaded library `{id}` není načtená knihovna
+@msg `{key}` has no colours `{key}` neobsahuje žádné barvy
+@msg `{key}` holds more than {MAX_EDIT_WORDS} words `{key}` obsahuje více než {MAX_EDIT_WORDS} slov
+@msg `{key}` is a design name `{key}` je název návrhu
+@msg `{key}` is a design name or a list of them `{key}` je název návrhu nebo jejich seznam
+@msg `{key}` is longer than {MAX_NAME} characters `{key}` je delší než {MAX_NAME} znaků
+@msg `{key}` is out of range `{key}` je mimo rozsah
+@msg `{key}` must be 0 or more `{key}` musí být 0 nebo více
+@msg `{key}` must be [min, desired, max] or {{min?, desired?, max?}}, in percent `{key}` musí být [min, desired, max] nebo {{min?, desired?, max?}}, v procentech
+@msg `{key}` must be [x, y] numbers `{key}` musí být čísla [x, y]
+@msg `{key}` must be a list of words `{key}` musí být seznam slov
+@msg `{key}` must be a list, not {v} `{key}` musí být seznam, ne {v}
+@msg `{key}` must be a non-negative integer `{key}` musí být nezáporné celé číslo
+@msg `{key}` must be a number or null, not {v} `{key}` musí být číslo nebo null, ne {v}
+@msg `{key}` must be a number {_1}..{_2} `{key}` musí být číslo {_1}..{_2}
+@msg `{key}` must be a whole number of 0 or more `{key}` musí být celé číslo 0 nebo větší
+@msg `{key}` must be a whole number of at least 0, not {n} `{key}` musí být celé číslo nejméně 0, ne {n}
+@msg `{key}` must be an appearance item index (paint order) or null `{key}` musí být index položky vzhledu (pořadí vykreslení) nebo null
+@msg `{key}` must be an array of asset ids (see assets.list) `{key}` musí být pole ID prvků (viz assets.list)
+@msg `{key}` must be from 0 to 180 degrees, not {a} `{key}` musí být od 0 do 180 stupňů, ne {a}
+@msg `{key}` must be from 0 to {MAX_DISTANCE} pt `{key}` musí být od 0 do {MAX_DISTANCE} pt
+@msg `{key}` must be from {_1} to {shift_max} `{key}` musí být od {_1} do {shift_max}
+@msg `{key}` must be from {lo} to {hi}, not {v} `{key}` musí být od {lo} do {hi}, ne {v}
+@msg `{key}` must be one of auto, always, never `{key}` musí být jedna z hodnot auto, always, never
+@msg `{key}` must be one of miter, round, bevel `{key}` musí být jedna z hodnot miter, round, bevel
+@msg `{key}` must be one word of at most {_1} characters `{key}` musí být jedno slovo o nejvýše {_1} znacích
+@msg `{key}` must hold single words of at most {_1} characters `{key}` musí obsahovat jednotlivá slova o nejvýše {_1} znacích
+@msg `{key}` must name an object attached to the path of an Objects on Path group `{key}` musí uvádět objekt připojený k cestě skupiny Objekty na cestě
+@msg `{key}` names an object that is hidden or locked `{key}` uvádí objekt, který je skrytý nebo zamknutý
+@msg `{key}`: not a colour: {v} `{key}`: není barva: {v}
+@msg `{k}.family` is required `{k}.family` je povinné
+@msg `{k}` is a number 0..1 `{k}` je číslo 0..1
+@msg `{k}` is true or false `{k}` je true nebo false
+@msg `{k}` isn't an export setting: {_1} `{k}` není nastavení exportu: {_1}
+@msg `{k}` must be a byte offset `{k}` musí být bajtový posun
+@msg `{k}` must be a number `{k}` musí být číslo
+@msg `{k}` must be a number of points `{k}` musí být počet bodů
+@msg `{k}` must be a string `{k}` musí být řetězec
+@msg `{k}` must be a whole number `{k}` musí být celé číslo
+@msg `{k}` must be from {lo} to {hi} for {key} `{k}` musí být pro {key} od {lo} do {hi}
+@msg `{k}` must be {what}, not {v} `{k}` musí být {what}, ne {v}
+@msg `{l}` isn't a global or spot colour `{l}` není globální ani přímá barva
+@msg `{m}` isn't a bitmap: use clipboard.importSvg, importPdf or importText `{m}` není bitmapa: použijte clipboard.importSvg, importPdf nebo importText
+@msg `{name}` can't be deleted `{name}` nelze odstranit
+@msg `{name}` can't be duplicated `{name}` nelze duplikovat
+@msg `{name}` can't be edited `{name}` nelze upravit
+@msg `{name}` can't be edited this way `{name}` nelze tímto způsobem upravit
+@msg `{name}` can't be renamed `{name}` nelze přejmenovat
+@msg `{name}` can't name a folder `{name}` nemůže být názvem složky
+@msg `{name}` has no art to place `{name}` nemá žádnou kresbu k umístění
+@msg `{name}` has no text to place `{name}` nemá žádný text k umístění
+@msg `{name}` in the Word document is not UTF-8 or UTF-16 text `{name}` v dokumentu Word není text v UTF-8 ani UTF-16
+@msg `{name}` in the Word document is not well-formed XML: {e} `{name}` v dokumentu Word není správně utvořené XML: {e}
+@msg `{name}` in the Word document nests elements more than {MAX_NESTING} deep `{name}` v dokumentu Word vnořuje prvky hlouběji než {MAX_NESTING} úrovní
+@msg `{name}` is a built-in preset and is read-only: save it under another name `{name}` je vestavěné přednastavení jen pro čtení: uložte ho pod jiným názvem
+@msg `{name}` is a built-in preset: save under another name `{name}` je vestavěné přednastavení: uložte ho pod jiným názvem
+@msg `{name}` is a built-in profile and stays `{name}` je vestavěný profil a zůstává
+@msg `{name}` is a file of its own, which can only be found next to the DCS file's path `{name}` je samostatný soubor, který lze najít jen vedle cesty souboru DCS
+@msg `{name}` is a tint of `{base}`: edit `{base}` (a tint swatch only takes a new name) `{name}` je tón barvy `{base}`: upravte `{base}` (položce vzorníku s tónem lze dát jen nový název)
+@msg `{name}` is a working space: choose another with edit.colorSettings first `{name}` je pracovní barevný prostor: nejprve zvolte jiný pomocí edit.colorSettings
+@msg `{name}` is built in and stays `{name}` je vestavěné a zůstává
+@msg `{name}` takes a paint of its own kind (colour, gradient or pattern) `{name}` přijímá nátěr svého druhu (barvu, přechod nebo vzorek)
+@msg `{name}`: {_1}: {e} `{name}`: {_1}: {e}
+@msg `{n}` can't be deleted `{n}` nelze odstranit
+@msg `{n}` can't be merged `{n}` nelze sloučit
+@msg `{n}` can't be moved `{n}` nelze přesunout
+@msg `{n}` is not a colour swatch `{n}` není barevná položka vzorníku
+@msg `{n}` isn't a solid colour `{n}` není plná barva
+@msg `{n}`: colour groups hold solid colours only `{n}`: skupiny barev obsahují jen plné barvy
+@msg `{other}` doesn't trace an image `{other}` nevektorizuje obraz
+@msg `{s}`: the step's references copy in more than {_1} MB of values `{s}`: odkazy kroku by zkopírovaly více než {_1} MB hodnot
+@msg `{tag}` is not an axis tag: a tag is four characters, such as "wght" `{tag}` není značka osy: značka má čtyři znaky, například "wght"
+@msg `{to}` is empty or already used `{to}` je prázdné nebo už použité
+@msg `{u}` is no Unicode character `{u}` není znak Unicode
+@msg `{what}` is longer than {MAX_TEXT} bytes `{what}` je delší než {MAX_TEXT} bajtů
+@msg `{what}` must be text `{what}` musí být text
+@msg `{word}` isn't in the custom dictionary `{word}` není ve vlastním slovníku
+@msg a CMap has too many mappings CMap má příliš mnoho mapování
+@msg a DXF drawing has one origin: pass useArtboards: true for one drawing per artboard výkres DXF má jeden počátek: předejte useArtboards: true pro jeden výkres na každou pracovní plochu
+@msg a PDF presets file with over {_1} presets soubor přednastavení PDF s více než {_1} přednastaveními
+@msg a \u escape needs four hexadecimal digits sekvence \u vyžaduje čtyři šestnáctkové číslice
+@msg a batch preview cannot switch documents náhled dávky nemůže přepínat dokumenty
+@msg a brush named `{nn}` exists štětec s názvem `{nn}` už existuje
+@msg a comma in an XPath expression must separate function arguments čárka ve výrazu XPath musí oddělovat argumenty funkce
+@msg a container of the listed objects is gone kontejner uvedených objektů už neexistuje
+@msg a content stream can't be read proud obsahu nelze přečíst
+@msg a content stream isn't a stream proud obsahu není proud
+@msg a design holds at most {_1} objects návrh obsahuje nejvýše {_1} objektů
+@msg a design needs a name návrh potřebuje název
+@msg a dictionary grew too large slovník se příliš zvětšil
+@msg a document holds at most {_1} SVG filters dokument obsahuje nejvýše {_1} filtrů SVG
+@msg a document keeps at most {_1} graph designs dokument uchovává nejvýše {_1} návrhů grafu
+@msg a document keeps at most {_1} saved selections dokument uchovává nejvýše {_1} uložených výběrů
+@msg a document keeps at most {_1} views dokument uchovává nejvýše {_1} zobrazení
+@msg a document links at most {MAX_SVG_SCRIPTS} JavaScript files dokument odkazuje na nejvýše {MAX_SVG_SCRIPTS} souborů JavaScript
+@msg a document with unsaved changes has no recovery copy dokument s neuloženými změnami nemá záložní kopii
+@msg a feConvolveMatrix filter's order is {x}×{y}, which overflows the kernel-size multiply usvg makes řád filtru feConvolveMatrix je {x}×{y}, což přeteče při násobení velikosti jádra, které provádí usvg
+@msg a font's data is too large data písma jsou příliš velká
+@msg a font's subroutines are too large podprogramy písma jsou příliš velké
+@msg a function declaration without a name deklarace funkce bez názvu
+@msg a gradient preset is already called {n} některé přednastavení přechodu se už jmenuje {n}
+@msg a graph has at most {_1} series, so it transposes with at most that many categories graf má nejvýše {_1} řad, takže ho lze transponovat nejvýše s tolika kategoriemi
+@msg a host value has too many entries hodnota hostitele má příliš mnoho položek
+@msg a host value is nested too deep hodnota hostitele je vnořena příliš hluboko
+@msg a layer can't be merged into its own sublayer vrstvu nelze sloučit do její vlastní podvrstvy
+@msg a layer's form box isn't an array rámeček formuláře vrstvy není pole
+@msg a layer's form can't be read formulář vrstvy nelze přečíst
+@msg a layer's form has no box formulář vrstvy nemá rámeček
+@msg a layer's form has no dictionary formulář vrstvy nemá slovník
+@msg a legend draws at most {MAX_CHIPS} swatches legenda vykreslí nejvýše {MAX_CHIPS} barevných políček
+@msg a name names one asset: give multiple: false, or one object název určuje jeden prvek: zadejte multiple: false, nebo jeden objekt
+@msg a namespace declaration uses a reserved prefix or URI deklarace jmenného prostoru používá vyhrazenou předponu nebo URI
+@msg a namespace qualifier is undefined kvalifikátor jmenného prostoru není definován
+@msg a namespace with an empty URI cannot have a prefix jmenný prostor s prázdným URI nemůže mít předponu
+@msg a number has no digits číslo nemá žádné číslice
+@msg a number's exponent has no digits exponent čísla nemá žádné číslice
+@msg a number's fraction has no digits zlomková část čísla nemá žádné číslice
+@msg a page can't be read stránku nelze přečíst
+@msg a page object is used by something else objekt stránky používá něco jiného
+@msg a page thumbnail must be RGB pixels, 1 to {THUMBNAIL_SIZE} a side (got {w} × {h}) miniatura stránky musí být z obrazových bodů RGB, 1 až {THUMBNAIL_SIZE} na stranu (zadáno {w} × {h})
+@msg a pattern layer doesn't end what it starts vrstva vzorku neukončuje to, co začíná
+@msg a pattern layer's colors hold something else barvy vrstvy vzorku obsahují něco jiného
+@msg a pattern named `{n}` exists vzorek s názvem `{n}` už existuje
+@msg a plug-in with id {_1} is already installed zásuvný modul s ID {_1} je už nainstalován
+@msg a preset named `{n}` exists přednastavení s názvem `{n}` už existuje
+@msg a print presets file with over {_1} presets soubor přednastavení tisku s více než {_1} přednastaveními
+@msg a profile is already called {n} některý profil se už jmenuje {n}
+@msg a property descriptor cannot have both a value or writable and a get or set popisovač vlastnosti nemůže mít zároveň value nebo writable a get nebo set
+@msg a property descriptor must be an object popisovač vlastnosti musí být objekt
+@msg a property's {name} must be a function or undefined {name} vlastnosti musí být funkce nebo undefined
+@msg a row can't go into itself řádek nelze vložit do sebe samého
+@msg a series goes on the left or the right value axis řada patří na levou nebo pravou osu hodnot
+@msg a start must be an anchor index ≥ 0 or null začátek musí být index kotevního bodu ≥ 0 nebo null
+@msg a stream in it would decode to more than 256 MB, or its decode parameters can't be applied některý proud v něm by se dekódoval na více než 256 MB, nebo nelze použít jeho parametry dekódování
+@msg a string holds a control character řetězec obsahuje řídicí znak
+@msg a string holds an escape sequence JSON does not have řetězec obsahuje řídicí sekvenci, kterou JSON nezná
+@msg a string is not closed řetězec není uzavřen
+@msg a style named `{name}` already exists styl s názvem `{name}` už existuje
+@msg a style named `{to}` already exists styl s názvem `{to}` už existuje
+@msg a swatch needs a colour, gradient or pattern položka vzorníku potřebuje barvu, přechod nebo vzorek
+@msg a symbol named `{n}` already exists symbol s názvem `{n}` už existuje
+@msg a theme has 1 to {THEME_SIZE} colours motiv má 1 až {THEME_SIZE} barev
+@msg a theme is already called {name} (replace: "{name}" overwrites it) některý motiv se už jmenuje {name} (replace: "{name}" ho přepíše)
+@msg a toolbar name has at most {MAX_NAME} characters název panelu nástrojů má nejvýše {MAX_NAME} znaků
+@msg a view named `{_1}` exists zobrazení s názvem `{_1}` už existuje
+@msg a view named `{name}` exists zobrazení s názvem `{name}` už existuje
+@msg a workspace keeps at most {MAX} toolbars pracovní prostor uchovává nejvýše {MAX} panelů nástrojů
+@msg a {_1} series can't go in a {_2} graph řadu typu {_1} nelze vložit do grafu typu {_2}
+@msg aiVersion {v}: "pdf" (PDF-compatible, the default) or "7" (legacy .ai, version 7) aiVersion {v}: "pdf" (kompatibilní s PDF, výchozí) nebo "7" (starší .ai, verze 7)
+@msg already editing an opacity mask maska krytí se už upravuje
+@msg already editing another object's opacity mask už se upravuje maska krytí jiného objektu
+@msg already in pattern editing mode režim úprav vzorku je už aktivní
+@msg an SVG filter is named by its id: give `xml` with `name` filtr SVG je pojmenován svým ID: zadejte `xml` spolu s `name`
+@msg an XML element has two attributes with the same name prvek XML má dva atributy se stejným názvem
+@msg an XML value cannot be inserted into itself or its descendants hodnotu XML nelze vložit do sebe samé ani do jejích potomků
+@msg an XML value's children cannot be assigned by position potomky hodnoty XML nelze přiřazovat podle pozice
+@msg an XML value's children cannot be deleted by position potomky hodnoty XML nelze odstraňovat podle pozice
+@msg an XMLList converts to XML only when it has one item XMLList se převede na XML, jen když má jednu položku
+@msg an XPath node test is missing chybí test uzlu XPath
+@msg an XPath operand is missing chybí operand XPath
+@msg an XPath operator is missing chybí operátor XPath
+@msg an XPath predicate or step cannot follow here predikát ani krok XPath zde nemůže následovat
+@msg an XPath predicate or step follows a value that is not a node-set predikát nebo krok XPath následuje za hodnotou, která není množina uzlů
+@msg an XPath step is missing chybí krok XPath
+@msg an artboard is listed twice některá pracovní plocha je uvedena dvakrát
+@msg an empty artboard has no width or height to scale to prázdná pracovní plocha nemá šířku ani výšku, na kterou by šlo měnit velikost
+@msg an image couldn't be decoded and is missing from the picture obraz nešlo dekódovat a v obrázku chybí
+@msg an image is too large for DXF and was left out obraz je pro DXF příliš velký a byl vynechán
+@msg an image of {_1} MB, at most {_2} MB obraz o velikosti {_1} MB, nejvýše {_2} MB
+@msg an image of {declared_w} × {declared_h} pixels is too large to draw obraz o rozměrech {declared_w} × {declared_h} obrazových bodů je příliš velký na vykreslení
+@msg an image of {width} × {height} pixels is too large to draw obraz o rozměrech {width} × {height} obrazových bodů je příliš velký na vykreslení
+@msg an image of {w} × {h} pixels is too large to convert (at most {_1} pixels) obraz o rozměrech {w} × {h} obrazových bodů je příliš velký na převod (nejvýše {_1} obrazových bodů)
+@msg an index must be a whole number index musí být celé číslo
+@msg an inline graphic has no glyphs vložená grafika v textu nemá žádné glyfy
+@msg an invisible text form can't be read formulář neviditelného textu nelze přečíst
+@msg an object is missing chybí objekt
+@msg an object's header is missing chybí záhlaví objektu
+@msg an overprinting form can't be read formulář s přetiskem nelze přečíst
+@msg an unread object has no readable bounds nepřečtený objekt nemá čitelné hranice
+@msg an unread object holds a frame of threaded type nepřečtený objekt obsahuje rámec zřetězeného textu
+@msg an unread object is missing from its layer nepřečtený objekt chybí ve své vrstvě
+@msg anchor `smooth` must be true or false `smooth` kotevního bodu musí být true nebo false
+@msg anchor `{key}` must be [x, y] `{key}` kotevního bodu musí být [x, y]
+@msg anchor `{key}` must be a number `{key}` kotevního bodu musí být číslo
+@msg angle and roundness must be finite úhel a zaoblení musí být konečné
+@msg angles must be a list as long as pins (degrees or null) angles musí být seznam stejně dlouhý jako pins (stupně nebo null)
+@msg appearance must be flat|center|edge appearance musí být flat|center|edge
+@msg apply is "fill" or "stroke", not `{other}` apply je "fill" nebo "stroke", ne `{other}`
+@msg apply was called on a value that is not a function apply byla zavolána na hodnotě, která není funkce
+@msg area must be a closed polygon of 3 to 100000 [x, y] points area musí být uzavřený mnohoúhelník o 3 až 100000 bodech [x, y]
+@msg area type in a thread cannot use Dynamic Text: {frames} text v oblasti ve zřetězeném textu nemůže používat dynamický text: {frames}
+@msg area type needs a path that encloses an area text v oblasti vyžaduje cestu, která uzavírá plochu
+@msg area type {_1} is named twice text v oblasti {_1} je uveden dvakrát
+@msg array literal too long literál pole je příliš dlouhý
+@msg arrowAlign must be extend|tip, got {o} arrowAlign musí být extend|tip, zadáno {o}
+@msg arrowScale must be [start, end] arrowScale musí být [start, end]
+@msg artboard is a 0-based index artboard je index počítaný od 0
+@msg artboard must be a 0-based index artboard musí být index počítaný od 0
+@msg artboard {i} doesn't print pracovní plocha {i} se netiskne
+@msg artboardLayout must be {layout?, columns?, spacing?, rightToLeft?} artboardLayout musí být {layout?, columns?, spacing?, rightToLeft?}
+@msg artboardLayout.columns must be 1 or more artboardLayout.columns musí být 1 nebo více
+@msg artboardLayout.layout must be gridByRow, gridByColumn, row or column artboardLayout.layout musí být gridByRow, gridByColumn, row nebo column
+@msg artboardLayout.rightToLeft must be true or false artboardLayout.rightToLeft musí být true nebo false
+@msg artboardLayout.spacing must be 0–{MAX_SPACING} pt artboardLayout.spacing musí být 0–{MAX_SPACING} pt
+@msg artboards must be a non-empty list artboards musí být neprázdný seznam
+@msg artboards must be a number artboards musí být číslo
+@msg asset `{_1}` has no art to export prvek `{_1}` nemá žádnou kresbu k exportu
+@msg assignment to a constant binding přiřazení do konstantní vazby
+@msg at least one row that isn't excluded needs a weight above 0 alespoň jeden nevyloučený řádek potřebuje váhu větší než 0
+@msg at most {MAX_KEYWORDS} keywords nejvýše {MAX_KEYWORDS} klíčových slov
+@msg at most {MAX_MATCHES} matches are kept uchovává se nejvýše {MAX_MATCHES} shod
+@msg at most {MAX_POINTS} points nejvýše {MAX_POINTS} bodů
+@msg at most {MAX_QUEUE} files nejvýše {MAX_QUEUE} souborů
+@msg at must be [x, y] at musí být [x, y]
+@msg at/rect lie outside the canvas at/rect leží mimo plátno
+@msg attribute must be size|leading|tracking|kerning|baselineShift attribute musí být size|leading|tracking|kerning|baselineShift
+@msg attrs must be an object attrs musí být objekt
+@msg automation path rejected: the folder's name isn't valid text cesta automatizace byla odmítnuta: název složky není platný text
+@msg automation path rejected: {_1} isn't valid text cesta automatizace byla odmítnuta: {_1} není platný text
+@msg automation path rejected: {why}: {path} cesta automatizace byla odmítnuta: {why}: {path}
+@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{given}` axis musí být "vertical", "horizontal" nebo úhel ve stupních, ne `{given}`
+@msg backgroundContents must be transparent or white backgroundContents musí být transparent nebo white
+@msg bad AES key chybný klíč AES
+@msg bad `runs`: {e} chybné `runs`: {e}
+@msg bad arrowScale chybné arrowScale
+@msg bad base64 chybný base64
+@msg bad color {c} chybná barva {c}
+@msg bad colour chybná barva
+@msg bad colour in `colors` chybná barva v `colors`
+@msg bad colour {v} chybná barva {v}
+@msg bad colour {v} in groupColors chybná barva {v} v groupColors
+@msg bad cross-reference entry chybná položka křížových odkazů
+@msg bad cross-reference table chybná tabulka křížových odkazů
+@msg bad current colour {v} chybná aktuální barva {v}
+@msg bad dataBase64 chybné dataBase64
+@msg bad edit chybná úprava
+@msg bad fill {v} chybná výplň {v}
+@msg bad fit: {e} chybné přizpůsobení: {e}
+@msg bad model {other} (keep, new or document) chybný model {other} (keep, new nebo document)
+@msg bad new colour {v} chybná nová barva {v}
+@msg bad object chybný objekt
+@msg bad object number chybné číslo objektu
+@msg bad offset chybný posun
+@msg bad path data: {e} chybná data cesty: {e}
+@msg bad point {v}: expected [x, y] chybný bod {v}: očekáváno [x, y]
+@msg bad point {v}: expected [x, y] or [x, y, pressure] chybný bod {v}: očekáváno [x, y] nebo [x, y, pressure]
+@msg bad reference chybný odkaz
+@msg bad trailer size chybná velikost v zakončení
+@msg bad {k} colour chybná barva {k}
+@msg batches cannot nest dávky nelze vnořovat
+@msg bind something to a variable first nejprve něco svažte s proměnnou
+@msg bleed must be 0–{MAX_BLEED} pt: a number, [top, bottom, left, right] or {{top, bottom, left, right}} bleed musí být 0–{MAX_BLEED} pt: číslo, [top, bottom, left, right] nebo {{top, bottom, left, right}}
+@msg blend must be a mode name blend musí být název režimu
+@msg both sides of an XPath | must select nodes obě strany operátoru | v XPath musí vybírat uzly
+@msg brush must be a name or null brush musí být název nebo null
+@msg by must be a number by musí být číslo
+@msg can't add page thumbnails: {why} miniatury stránek nelze přidat: {why}
+@msg can't add the editing data for other apps: {why} data pro úpravy v jiných aplikacích nelze přidat: {why}
+@msg can't edit the written PDF: {why} zapsané PDF nelze upravit: {why}
+@msg can't encrypt the PDF: {why} PDF nelze zašifrovat: {why}
+@msg can't linearise the PDF: {why} PDF nelze linearizovat: {why}
+@msg can't open `{_1}`: {_2} `{_1}` nelze otevřít: {_2}
+@msg can't open `{_1}`: {e} `{_1}` nelze otevřít: {e}
+@msg can't open `{name}`: not a format VectorCraft reads (see document.formats) `{name}` nelze otevřít: nejde o formát, který VectorCraft čte (viz document.formats)
+@msg can't place `{name}`: not a format VectorCraft reads (see document.formats) `{name}` nelze umístit: nejde o formát, který VectorCraft čte (viz document.formats)
+@msg can't read back the written PDF: {e} zapsané PDF nelze znovu načíst: {e}
+@msg can't read this Word document: {why} tento dokument Word nelze přečíst: {why}
+@msg can't write .{e} images: use .png, .jpg, .gif, .webp, .tif or .bmp obrázky .{e} nelze zapisovat: použijte .png, .jpg, .gif, .webp, .tif nebo .bmp
+@msg can't write PDF layers, overprint or invisible text: {why} vrstvy PDF, přetisk ani neviditelný text nelze zapsat: {why}
+@msg can't write the file's copyright: {why} údaj o autorských právech souboru nelze zapsat: {why}
+@msg can't write the file's modification date: {why} datum změny souboru nelze zapsat: {why}
+@msg can't write the pages' user units: {why} uživatelské jednotky stránek nelze zapsat: {why}
+@msg canceled zrušeno
+@msg cannot convert the object to a primitive value objekt nelze převést na primitivní hodnotu
+@msg cannot convert undefined or null to an object undefined ani null nelze převést na objekt
+@msg cannot distort a zero-size or non-finite bounding box ohraničovací rámeček s nulovou nebo nekonečnou velikostí nelze deformovat
+@msg cannot move an object into itself objekt nelze přesunout do sebe samého
+@msg case must be upper|lower|title|sentence case musí být upper|lower|title|sentence
+@msg category `{c}`: one of {_1} kategorie `{c}`: jedna z hodnot {_1}
+@msg center must be [x, y] center musí být [x, y]
+@msg character dimensions cannot be represented with the source text transform rozměry znaků nelze vyjádřit transformací zdrojového textu
+@msg character dimensions cannot be represented with the target text transform rozměry znaků nelze vyjádřit transformací cílového textu
+@msg choose at least one artboard zvolte alespoň jednu pracovní plochu
+@msg choose at least one status zvolte alespoň jeden stav
+@msg clip must be true or false clip musí být true nebo false
+@msg clippingMask must be true or false clippingMask musí být true nebo false
+@msg closed must be a boolean closed musí být logická hodnota
+@msg collapsed must be true or false collapsed musí být true nebo false
+@msg color.profile: no RGB or CMYK profile is called “{profile}” color.profile: žádný profil RGB ani CMYK se nejmenuje „{profile}“
+@msg colorMode must be rgb or cmyk, not `{m}` colorMode musí být rgb nebo cmyk, ne `{m}`
+@msg colorModel must be {own} (the document's mode), grayscale or bitmap colorModel musí být {own} (režim dokumentu), grayscale nebo bitmap
+@msg colour groups can't go into colour groups skupiny barev nelze vkládat do skupin barev
+@msg colour, mode and spot apply to solid-colour swatches only colour, mode a spot platí jen pro položky vzorníku s plnou barvou
+@msg column {column}: `{cell}` is a `%` column of graph data files, which are not read sloupec {column}: `{cell}` je sloupec `%` pro soubory dat grafu, které se nečtou
+@msg column {column}: `{cell}` names `{name}`, which is a {_1} variable sloupec {column}: `{cell}` uvádí `{name}`, což je proměnná typu {_1}
+@msg column {column}: `{cell}` names no variable sloupec {column}: `{cell}` neuvádí žádnou proměnnou
+@msg column {column}: the variable name is longer than {MAX_NAME} characters sloupec {column}: název proměnné je delší než {MAX_NAME} znaků
+@msg column: true floats the whole column and takes no onto, above or below column: true uvolní celý sloupec jako plovoucí a nepřijímá onto, above ani below
+@msg command must be a shape.* command command musí být příkaz shape.*
+@msg commands[{i}]: missing `command` commands[{i}]: chybí `command`
+@msg composer must be "singleLine" or "everyLine" composer musí být "singleLine" nebo "everyLine"
+@msg compound shapes take paths, compound paths, groups, compound shapes, type, blends and envelopes složené tvary přijímají cesty, složené cesty, skupiny, složené tvary, text, prolnutí a obálky
+@msg compound {_1} must contain exactly one open path to join složený objekt {_1} musí ke spojení obsahovat právě jednu otevřenou cestu
+@msg connect needs a path other than the one extended connect vyžaduje jinou cestu než tu prodlužovanou
+@msg connect needs id connect vyžaduje id
+@msg connect path has no open end cesta v connect nemá žádný otevřený konec
+@msg continuation must be an array of path chunks continuation musí být pole úseků cesty
+@msg continuation requires points continuation vyžaduje points
+@msg copy needs path: the file to copy into the document's folder copy vyžaduje path: soubor, který se má zkopírovat do složky dokumentu
+@msg copy takes images only: relink placed documents where their files are copy přijímá jen obrázky: umístěné dokumenty znovu propojte tam, kde jsou jejich soubory
+@msg copying a file isn't available on the web kopírování souboru není na webu dostupné
+@msg copyrightStatus must be unknown, copyrighted or publicDomain copyrightStatus musí být unknown, copyrighted nebo publicDomain
+@msg copyrightUrl must be a URL (no spaces, at most 2048 characters) copyrightUrl musí být adresa URL (bez mezer, nejvýše 2048 znaků)
+@msg corners must be 4 [x, y] points (TL, TR, BR, BL) corners musí být 4 body [x, y] (TL, TR, BR, BL)
+@msg counterPer `{c}`: item or file counterPer `{c}`: item nebo file
+@msg crop `{_1}`: only PDF pages have art, trim, bleed and media boxes; use "crop" or "bounding" crop `{_1}`: rámečky art, trim, bleed a media mají jen stránky PDF; použijte "crop" nebo "bounding"
+@msg crop rectangle doesn't overlap the image ořezový obdélník se nepřekrývá s obrazem
+@msg cross-reference and object streams aren't supported proudy křížových odkazů a proudy objektů nejsou podporovány
+@msg damaged compressed data: {e} poškozená komprimovaná data: {e}
+@msg dash lengths must be numbers, got {v} délky čárek musí být čísla, zadáno {v}
+@msg dash must be a list of lengths or null, got {v} dash musí být seznam délek nebo null, zadáno {v}
+@msg data key `{k}`: letters, digits, -, _ and . (not name or vc-…) klíč dat `{k}`: písmena, číslice, -, _ a . (ne name ani vc-…)
+@msg data must be an object of key: value, not {v} data musí být objekt ve tvaru klíč: hodnota, ne {v}
+@msg data set `{name}` holds a value for `{var}`, which the library doesn't declare sada dat `{name}` obsahuje hodnotu pro `{var}`, kterou knihovna nedeklaruje
+@msg data set `{name}` holds two values for `{var}` sada dat `{name}` obsahuje dvě hodnoty pro `{var}`
+@msg data set `{name}` is declared twice sada dat `{name}` je deklarována dvakrát
+@msg data takes at most {MAX_DATA} keys at once data přijímá najednou nejvýše {MAX_DATA} klíčů
+@msg dataset `{name}` already exists sada dat `{name}` už existuje
+@msg dataset `{to}` already exists sada dat `{to}` už existuje
+@msg date `{d}`: YYYY-MM-DD datum `{d}`: YYYY-MM-DD
+@msg decoded data is too large dekódovaná data jsou příliš velká
+@msg define a variable first nejprve definujte proměnnou
+@msg detect must be true or false detect musí být true nebo false
+@msg direct-select anchor points of a path vyberte kotevní body cesty přímým výběrem
+@msg distance must be > 0 distance musí být > 0
+@msg dx and dy give the arrow's direction dx a dy udávají směr šipky
+@msg dx and dy must be numbers dx a dy musí být čísla
+@msg dxf fitTo must be [width, height], positive dxf fitTo musí být [width, height], kladné
+@msg dxf must be an object of DXF import options dxf musí být objekt s volbami importu DXF
+@msg dxf scale must be a positive number up to {_1}, not {s} dxf scale musí být kladné číslo nejvýše {_1}, ne {s}
+@msg dxf unit `{u}`: a unit such as mm, cm, in or pt dxf unit `{u}`: jednotka jako mm, cm, in nebo pt
+@msg dxf: {e} dxf: {e}
+@msg each format is an object {format, scale?, suffix?} každý formát je objekt {format, scale?, suffix?}
+@msg each item needs an id každá položka potřebuje id
+@msg each match needs `kind` každá shoda potřebuje `kind`
+@msg each match needs a `name` každá shoda potřebuje `name`
+@msg each of `fonts` is {family, style?}; 1 to 1000 of them každá položka `fonts` je {family, style?}; 1 až 1000 položek
+@msg each of `items` is {kind, item} každá položka `items` je {kind, item}
+@msg each of `items` is {{id, corners?}} with an object id, not {it} každá položka `items` je {{id, corners?}} s ID objektu, ne {it}
+@msg each path chunk must contain 1 to 4096 points každý úsek cesty musí obsahovat 1 až 4096 bodů
+@msg each pick is {{at: [x, y]}} or {{rect: [x0, y0, x1, y1]}} (got {v}) každá volba je {{at: [x, y]}} nebo {{rect: [x0, y0, x1, y1]}} (zadáno {v})
+@msg each row needs `from` každý řádek potřebuje `from`
+@msg each stop needs a position každá zarážka potřebuje pozici
+@msg each subpath needs an anchors array každá dílčí cesta potřebuje pole anchors
+@msg each weight must be a number of 0 or more (got {v}) každá váha musí být číslo 0 nebo větší (zadáno {v})
+@msg easingType must be easeIn, easeOut, easeInOut or easeOutIn easingType musí být easeIn, easeOut, easeInOut nebo easeOutIn
+@msg editingData must be true or false editingData musí být true nebo false
+@msg editingData must be true, false or "always" editingData musí být true, false nebo "always"
+@msg edits overlap úpravy se překrývají
+@msg effects of objects whose appearances differ can't be moved together efekty objektů s odlišným vzhledem nelze přesouvat společně
+@msg empty header in column {_1} prázdné záhlaví ve sloupci {_1}
+@msg encoding `{_1}`: utf8 or utf16 kódování `{_1}`: utf8 nebo utf16
+@msg ends must be two of "first" and "last" ends musí být dvě z hodnot "first" a "last"
+@msg every numbered name for the result is taken každý číslovaný název pro výsledek je už obsazen
+@msg expanded SVG styles exceed the import size limit rozvinuté styly SVG překračují limit velikosti importu
+@msg export presets file is larger than {MAX_PRESET_FILE_BYTES} bytes soubor přednastavení exportu je větší než {MAX_PRESET_FILE_BYTES} bajtů
+@msg exportText must be editable or appearance exportText musí být editable nebo appearance
+@msg exportType must be movieClip or graphic, not `{s}` exportType musí být movieClip nebo graphic, ne `{s}`
+@msg extend needs id extend vyžaduje id
+@msg featureChanges lists at most {MAX_FEATURE_CHANGES} tags featureChanges uvádí nejvýše {MAX_FEATURE_CHANGES} značek
+@msg featureChanges must be a list of tags featureChanges musí být seznam značek
+@msg features must be a list of tags features musí být seznam značek
+@msg fidelity must be 0..100 fidelity musí být 0..100
+@msg files must be {name, dataBase64} files musí být {name, dataBase64}
+@msg finally table {t} out of range tabulka finally {t} je mimo rozsah
+@msg fit must be none|autoHeight|shrinkText|block, got {id} fit musí být none|autoHeight|shrinkText|block, zadáno {id}
+@msg flags cannot be given with a RegExp object příznaky nelze zadat spolu s objektem RegExp
+@msg flattener: {e} sloučení průhlednosti: {e}
+@msg flattenerPreset `{name}`: high, medium, low or a saved one (see flattener.presets.list) flattenerPreset `{name}`: high, medium, low nebo uložené přednastavení (viz flattener.presets.list)
+@msg flipProfile must be along|across, got {o} flipProfile musí být along|across, zadáno {o}
+@msg folders `{f}`: none, rows, format, item or itemFormat folders `{f}`: none, rows, format, item nebo itemFormat
+@msg folders deeper than {MAX_BATCH_DEPTH} levels složky hlubší než {MAX_BATCH_DEPTH} úrovní
+@msg folders deeper than {MAX_DEPTH} levels složky hlubší než {MAX_DEPTH} úrovní
+@msg font family `{family}` is not available rodina písma `{family}` není dostupná
+@msg font family `{tf}` is not available rodina písma `{tf}` není dostupná
+@msg format `{f}`: pdf or postscript formát `{f}`: pdf nebo postscript
+@msg format `{f}`: text or json formát `{f}`: text nebo json
+@msg format is `{format}` formát je `{format}`
+@msg formats must be an array of rows, not {v} formats musí být pole řádků, ne {v}
+@msg frequency must be between 1 and 300 lines per inch frequency musí být mezi 1 a 300 linkami na palec
+@msg from and to must be finite from a to musí být konečné
+@msg from must be [x0, y0, x1, y1] from musí být [x0, y0, x1, y1]
+@msg function declaration index out of range index deklarace funkce je mimo rozsah
+@msg give `before` or `after`, not both zadejte `before` nebo `after`, ne obojí
+@msg give `char`, `text`, `code` or `unicode` zadejte `char`, `text`, `code` nebo `unicode`
+@msg give `colors`, or `color` and `rule` zadejte `colors`, nebo `color` a `rule`
+@msg give `count` or `spacing`, not both zadejte `count` nebo `spacing`, ne obojí
+@msg give `event`, such as onclick zadejte `event`, například onclick
+@msg give `file`, the JavaScript file's path or URL zadejte `file`, cestu nebo adresu URL souboru JavaScript
+@msg give `fill` and/or `stroke` zadejte `fill` a/nebo `stroke`
+@msg give `fill`, `stroke` or both zadejte `fill`, `stroke` nebo obojí
+@msg give `folder` zadejte `folder`
+@msg give `fonts`, or open a document zadejte `fonts`, nebo otevřete dokument
+@msg give `from` or `indices`, not both zadejte `from` nebo `indices`, ne obojí
+@msg give `id` (no text is being edited) zadejte `id` (žádný text se právě neupravuje)
+@msg give `id` and `anchors`, or `items`, not both zadejte `id` a `anchors`, nebo `items`, ne obojí
+@msg give `id` or `guide`, not both zadejte `id` nebo `guide`, ne obojí
+@msg give `length` or `to` zadejte `length` nebo `to`
+@msg give `matches`: [{kind, name, target}] zadejte `matches`: [{kind, name, target}]
+@msg give `name` (or edit a pattern) zadejte `name` (nebo upravujte vzorek)
+@msg give `name` or `names` zadejte `name` nebo `names`
+@msg give `name`, an SVG filter's name zadejte `name`, název filtru SVG
+@msg give `path` (a .wasm file) or `dataBase64` (the module) zadejte `path` (soubor .wasm) nebo `dataBase64` (modul)
+@msg give `path`, `data` or `dataBase64` zadejte `path`, `data` nebo `dataBase64`
+@msg give `path`, `data`, `dataBase64` or `csv` zadejte `path`, `data`, `dataBase64` nebo `csv`
+@msg give `path`, `xml` or `dataBase64` zadejte `path`, `xml` nebo `dataBase64`
+@msg give `path`, an SVG file, or `svg`, SVG text zadejte `path`, soubor SVG, nebo `svg`, text SVG
+@msg give `script`, the JavaScript the event runs ("" removes the event) zadejte `script`, JavaScript, který událost spustí ("" událost odebere)
+@msg give `text` (the reading) or `remove` zadejte `text` (čtení) nebo `remove`
+@msg give `x` and `y` (and a `tolerance` of 0 or more) zadejte `x` a `y` (a `tolerance` 0 nebo více)
+@msg give `xml`, one SVG element zadejte `xml`, jeden prvek SVG
+@msg give a mode or r/g/b, c/m/y/k, gray or tint adjustments zadejte režim nebo úpravy r/g/b, c/m/y/k, gray či tint
+@msg give a name, or paint the active proxy with a saved preset zadejte název, nebo aktivní výplň či tah nastavte uloženým přednastavením
+@msg give a name, or select a stroke with a saved profile zadejte název, nebo vyberte tah s uloženým profilem
+@msg give a pattern name or select an object painted with a pattern zadejte název vzorku nebo vyberte objekt vybarvený vzorkem
+@msg give at least one of clip, invert, disabled, linked zadejte alespoň jedno z clip, invert, disabled, linked
+@msg give color, none, swatch or gradient zadejte color, none, swatch nebo gradient
+@msg give dataBase64 zadejte dataBase64
+@msg give either at (a click) or area (a path drawn around the overlaps) zadejte buď at (kliknutí), nebo area (cestu nakreslenou kolem překryvů)
+@msg give exactly one of `at`, `rect` and `points` zadejte právě jedno z `at`, `rect` a `points`
+@msg give exactly one of `path`, `data` and `dataBase64` zadejte právě jedno z `path`, `data` a `dataBase64`
+@msg give formats or a preset, not both zadejte formats nebo přednastavení, ne obojí
+@msg give index or indexes, not both zadejte index nebo indexes, ne obojí
+@msg give newName or delete zadejte newName nebo delete
+@msg give offset or by zadejte offset nebo by
+@msg give one element zadejte jeden prvek
+@msg give one of `to`, `rect` or `at` zadejte jedno z `to`, `rect` nebo `at`
+@msg give one of assets, layers, selection or fullDocument zadejte jedno z assets, layers, selection nebo fullDocument
+@msg give only one of onto, above, below, icon and a column place zadejte jen jedno z onto, above, below, icon a umístění ve sloupci
+@msg give opacity or blend zadejte opacity nebo blend
+@msg give origin: [x, y], or reset: true zadejte origin: [x, y], nebo reset: true
+@msg give overprintFill, overprintStroke, showCenter, imageMap, url or note zadejte overprintFill, overprintStroke, showCenter, imageMap, url nebo note
+@msg give path (a file) or folder zadejte path (soubor) nebo folder
+@msg give path, or name and dataBase64 zadejte path, nebo name a dataBase64
+@msg give paths or files zadejte paths nebo files
+@msg give rows, rowHeight, columns or columnWidth zadejte rows, rowHeight, columns nebo columnWidth
+@msg give svg, or dataBase64 of an SVG or SVGZ file zadejte svg, nebo dataBase64 souboru SVG či SVGZ
+@msg give text zadejte text
+@msg give the asset a name pojmenujte prvek
+@msg give the preset a `name` zadejte pro přednastavení `name`
+@msg give the preset a name pojmenujte přednastavení
+@msg give the preset a name (1–64 characters) pojmenujte přednastavení (1–64 znaků)
+@msg give the settings to change, e.g. {"formats"}]} zadejte nastavení, která se mají změnit, např. {"formats"}]}
+@msg give the style a name pojmenujte styl
+@msg give two or more style `names` zadejte v `names` dva nebo více stylů
+@msg give two or more swatches zadejte dvě nebo více položek vzorníku
+@msg give x, y, or a handle's length or angle zadejte x, y, nebo délku či úhel úchytu
+@msg give {key, value} or {values} zadejte {key, value} nebo {values}
+@msg global must be true or false global musí být true nebo false
+@msg glyph {_1} is not drawn for {ch} with the character's style (such as All Caps, Small Caps or a numeral system) glyf {_1} se pro {ch} se stylem znaku nevykresluje (například Verzálky, Kapitálky nebo číselná soustava)
+@msg glyph {g} is not an alternate of {ch} in {_1} {_2} glyf {g} není alternativou znaku {ch} v písmu {_1} {_2}
+@msg gradient must be "objects" or "mesh" gradient musí být "objects" nebo "mesh"
+@msg grays must be "profile" or "black", not `{g}` grays musí být "profile" nebo "black", ne `{g}`
+@msg gridColors must be a preset name or two colours gridColors musí být název přednastavení nebo dvě barvy
+@msg gridSize must be small, medium or large gridSize musí být small, medium nebo large
+@msg guide {g} is not selected vodítko {g} není vybráno
+@msg handle must be 0..3 handle musí být 0..3
+@msg handler at {_1} expects {_2} values but its range starts with {d} obslužná rutina na pozici {_1} očekává {_2} hodnot, ale její rozsah začíná s {d}
+@msg handler depth too large hloubka obslužné rutiny je příliš velká
+@msg handler range {_1}..{_2} out of range rozsah obslužné rutiny {_1}..{_2} je mimo rozsah
+@msg hiddenLayers must be true or false hiddenLayers musí být true nebo false
+@msg hiddenObjects {v}: "layer" (the default), "omit" or "visible" hiddenObjects {v}: "layer" (výchozí), "omit" nebo "visible"
+@msg highlight a layer to merge into zvýrazněte vrstvu, do které se má sloučit
+@msg highlight two or more rows in one layer or group zvýrazněte dva nebo více řádků v jedné vrstvě nebo skupině
+@msg highlight two or more rows to merge zvýrazněte dva nebo více řádků ke sloučení
+@msg hvcC hvcC
+@msg icon docks a panel in the icon column: use window.panel.dock icon ukotví panel ve sloupci ikon: použijte window.panel.dock
+@msg id must be a dimension id id musí být ID kóty
+@msg image has no bounds obraz nemá hranice
+@msg image {_1} is linked already obraz {_1} je už propojený
+@msg imageMap `{m}`: none, client or server imageMap `{m}`: none, client nebo server
+@msg images in a spot ink whose samples couldn't be read were imported in RGB obrazy v přímé barvě, jejichž vzorky nešlo přečíst, byly importovány v RGB
+@msg images past the most pixels one file may hold were left out obrazy nad největší počet obrazových bodů, který smí jeden soubor obsahovat, byly vynechány
+@msg images too large to read were left out obrazy příliš velké na přečtení byly vynechány
+@msg index out of range index je mimo rozsah
+@msg indexes may hold at most {MAX_GUIDE_INDEXES} entries indexes smí obsahovat nejvýše {MAX_GUIDE_INDEXES} položek
+@msg indexes must be an array of guide indexes indexes musí být pole indexů vodítek
+@msg instances must be 1..1000 instances musí být 1..1000
+@msg instruction out of range instrukce je mimo rozsah
+@msg instruction {pc} ({op}) pops below the stack instrukce {pc} ({op}) odebírá pod dno zásobníku
+@msg instruction {pc} reached with stack depths {seen} and {d} instrukce {pc} byla dosažena s hloubkami zásobníku {seen} a {d}
+@msg invalid `color` neplatné `color`
+@msg invalid anchor index {v}: expected a non-negative integer neplatný index kotevního bodu {v}: očekáváno nezáporné celé číslo
+@msg invalid anchor reference {v}: expected [subpath, anchor] neplatný odkaz na kotevní bod {v}: očekáváno [subpath, anchor]
+@msg invalid angle neplatný úhel
+@msg invalid array length neplatná délka pole
+@msg invalid brush: {e} neplatný štětec: {e}
+@msg invalid color neplatná barva
+@msg invalid colour {c} neplatná barva {c}
+@msg invalid delta neplatný rozdíl
+@msg invalid group neplatná skupina
+@msg invalid object id {v}: expected a non-negative integer neplatné ID objektu {v}: očekáváno nezáporné celé číslo
+@msg invalid point neplatný bod
+@msg invalid regular expression flag '{_1}' neplatný příznak regulárního výrazu '{_1}'
+@msg invalid regular expression: {m} neplatný regulární výraz: {m}
+@msg invalid size neplatná velikost
+@msg invalid t neplatné t
+@msg invalid time value neplatná hodnota času
+@msg invalid tolerance neplatná tolerance
+@msg is a confirm's answer, whose `value` must be true or false je odpověď na potvrzení, jejíž `value` musí být true nebo false
+@msg is a prompt's answer, whose `value` must be a string or null je odpověď na výzvu, jejíž `value` musí být řetězec nebo null
+@msg is an alert's answer, whose `value` must be "ok" je odpověď na upozornění, jejíž `value` musí být "ok"
+@msg is longer than 16 KiB je delší než 16 KiB
+@msg it draws its image through a clipping path vykresluje svůj obraz přes ořezovou cestu
+@msg it draws no image nevykresluje žádný obraz
+@msg it is larger than {MAX_DICTIONARY_FILE_BYTES} bytes je větší než {MAX_DICTIONARY_FILE_BYTES} bajtů
+@msg it isn't a file není to soubor
+@msg it names {_1} plates, and at most {MAX_PLATES} are read uvádí {_1} výtažků a čte se jich nejvýše {MAX_PLATES}
+@msg its art is too long or too detailed for a head jeho kresba je na hrot příliš dlouhá nebo příliš podrobná
+@msg its art paints nothing jeho kresba nic nevykresluje
+@msg its artboards all lie beyond the canvas (more than {_1} pt from the origin) všechny jeho pracovní plochy leží mimo plátno (více než {_1} pt od počátku)
+@msg its clipping masks are nested too deeply jeho ořezové masky jsou vnořeny příliš hluboko
+@msg its image is too large to read jeho obraz je příliš velký na přečtení
+@msg its metadata has no description jeho metadata nemají popis
+@msg its metadata has no direct length jeho metadata nemají přímou délku
+@msg its plates are too large: more than {MAX_SAMPLES} pixels together jeho výtažky jsou příliš velké: dohromady více než {MAX_SAMPLES} obrazových bodů
+@msg its plates are too large: {_1} plates of {w} × {h} pixels jeho výtažky jsou příliš velké: {_1} výtažků o {w} × {h} obrazových bodech
+@msg its plates can't be kept together jeho výtažky nelze udržet pohromadě
+@msg its shapes could not be combined into one jeho tvary nešlo spojit do jednoho
+@msg join two different paths spojte dvě různé cesty
+@msg jump to {pc}, past the end skok na {pc}, za konec
+@msg justify must be auto|left|center|right|justifyAll justify musí být auto|left|center|right|justifyAll
+@msg kbps must be a positive number, not {kbps} kbps musí být kladné číslo, ne {kbps}
+@msg keywords must be a list of words or a comma-separated string keywords musí být seznam slov nebo řetězec oddělený čárkami
+@msg keywords must be at most {MAX_KEYWORD} characters, without control characters keywords smí mít nejvýše {MAX_KEYWORD} znaků, bez řídicích znaků
+@msg kind must be 1, 2 or 3, or give a preset `name` (see perspective.presets.list) kind musí být 1, 2 nebo 3, nebo zadejte `name` přednastavení (viz perspective.presets.list)
+@msg kind must be graphic, fillColor, strokeColor, fillGradient, strokeGradient, fillPattern, strokePattern, charStyle or paraStyle kind musí být graphic, fillColor, strokeColor, fillGradient, strokeGradient, fillPattern, strokePattern, charStyle nebo paraStyle
+@msg knockout must be "on", "off", "neutral" or a bool, not {v} knockout musí být "on", "off", "neutral" nebo logická hodnota, ne {v}
+@msg layer `{name}` has no art to export vrstva `{name}` nemá žádnou kresbu k exportu
+@msg layer must name a layer or sublayer layer musí uvádět vrstvu nebo podvrstvu
+@msg layers can't be intertwined: select the objects in them vrstvy nelze proplést: vyberte objekty v nich
+@msg layers go only at the top level or in layers, objects in layers and groups vrstvy mohou být jen na nejvyšší úrovni nebo ve vrstvách, objekty ve vrstvách a skupinách
+@msg layers must be true or false layers musí být true nebo false
+@msg layout must be gridByRow, gridByColumn, row or column layout musí být gridByRow, gridByColumn, row nebo column
+@msg leading must be a number or "auto" leading musí být číslo nebo "auto"
+@msg left/right must be non-negative widths left/right musí být nezáporné šířky
+@msg legacyNameEncoding must be one of {_1} legacyNameEncoding musí být jedna z hodnot {_1}
+@msg level `{t}`: 2 or 3 úroveň `{t}`: 2 nebo 3
+@msg line {line}: more than {MAX_CSV_COLS} columns řádek {line}: více než {MAX_CSV_COLS} sloupců
+@msg line {line}: unclosed quote řádek {line}: neuzavřená uvozovka
+@msg line {line}: value for `{var}` must match its variable kind řádek {line}: hodnota pro `{var}` musí odpovídat druhu její proměnné
+@msg lineEndings `{other}`: lf or crlf lineEndings `{other}`: lf nebo crlf
+@msg loading profiles from disk isn't available in the browser načítání profilů z disku není v prohlížeči dostupné
+@msg localize: the first argument must be a localization object or a string localize: první argument musí být lokalizační objekt nebo řetězec
+@msg margin: a {m} pt margin leaves no imageable area on the paper okraj: okraj {m} pt nenechá na papíře žádnou tisknutelnou plochu
+@msg matrix must be finite and invertible, depth finite matrix musí být konečná a invertovatelná, depth konečná
+@msg matte: {e} matte: {e}
+@msg member is for order front, forward, backward or back member se používá s order front, forward, backward nebo back
+@msg missing `artboards` chybí `artboards`
+@msg missing `at` [x, y] chybí `at` [x, y]
+@msg missing `attrs` chybí `attrs`
+@msg missing `color` or `opacity` chybí `color` nebo `opacity`
+@msg missing `colors` chybí `colors`
+@msg missing `commands` chybí `commands`
+@msg missing `destinationFolder` chybí `destinationFolder`
+@msg missing `effect` id chybí ID `effect`
+@msg missing `family` chybí `family`
+@msg missing `find` chybí `find`
+@msg missing `gid` (a glyph id, or null for the default glyph) chybí `gid` (ID glyfu, nebo null pro výchozí glyf)
+@msg missing `group` chybí `group`
+@msg missing `id` chybí `id`
+@msg missing `id`: give one, or select one type object chybí `id`: zadejte ho, nebo vyberte jeden textový objekt
+@msg missing `index` (a point index, or null to clear) chybí `index` (index bodu, nebo null pro vymazání)
+@msg missing `index` (a stop index, or null to clear) chybí `index` (index zarážky, nebo null pro vymazání)
+@msg missing `index` (an item index or null) chybí `index` (index položky nebo null)
+@msg missing `item` chybí `item`
+@msg missing `library` chybí `library`
+@msg missing `library` (see graphicStyle.libraries) chybí `library` (viz graphicStyle.libraries)
+@msg missing `library` (see swatch.library.list) chybí `library` (viz swatch.library.list)
+@msg missing `map` (rows [{from, to}] or {current}) chybí `map` (řádky [{from, to}] nebo {current})
+@msg missing `matrix` [a,b,c,d,e,f] chybí `matrix` [a,b,c,d,e,f]
+@msg missing `name` (see gradient.presets.list) chybí `name` (viz gradient.presets.list)
+@msg missing `offset` (a distance in points) chybí `offset` (vzdálenost v bodech)
+@msg missing `path` chybí `path`
+@msg missing `path` (a path object id) chybí `path` (ID objektu cesty)
+@msg missing `picks` chybí `picks`
+@msg missing `rule` chybí `rule`
+@msg missing `source` id chybí ID `source`
+@msg missing `start` (the byte offset of a character) chybí `start` (bajtový posun znaku)
+@msg missing `status`: linked, embedded, missing or modified chybí `status`: linked, embedded, missing nebo modified
+@msg missing `target` id chybí ID `target`
+@msg missing `text` chybí `text`
+@msg missing `to` chybí `to`
+@msg missing `to` (an index) chybí `to` (index)
+@msg missing `to`: a non-negative integer chybí `to`: nezáporné celé číslo
+@msg missing `type` chybí `type`
+@msg missing `value` chybí `value`
+@msg missing `word` chybí `word`
+@msg missing `{key}` chybí `{key}`
+@msg missing `{k}` chybí `{k}`
+@msg missing anchor chybí kotevní bod
+@msg missing anchors chybí kotevní body
+@msg missing anchors [[subpath, anchor]…] chybí kotevní body [[subpath, anchor]…]
+@msg missing array `{key}` chybí pole `{key}`
+@msg missing case chybí case
+@msg missing color chybí barva
+@msg missing commands chybí příkazy
+@msg missing destination folder chybí cílová složka
+@msg missing from chybí from
+@msg missing group chybí skupina
+@msg missing id or ids chybí id nebo ids
+@msg missing ids chybí ids
+@msg missing indexes chybí indexes
+@msg missing matrix [a,b,c,d,e,f] chybí matrix [a,b,c,d,e,f]
+@msg missing name chybí název
+@msg missing op chybí op
+@msg missing or invalid `color` chybějící nebo neplatné `color`
+@msg missing or unknown `tool` chybějící nebo neznámé `tool`
+@msg missing point chybí bod
+@msg missing point [x,y] chybí bod [x,y]
+@msg missing point `{key}` ([x, y]) chybí bod `{key}` ([x, y])
+@msg missing points chybí body
+@msg missing points [[x,y]…] chybí body [[x,y]…]
+@msg missing segment chybí segment
+@msg missing stops chybí zarážky
+@msg missing subpaths chybí dílčí cesty
+@msg missing target chybí cíl
+@msg missing to chybí to
+@msg missing x, y chybí x, y
+@msg mode is set, add or toggle, not `{other}` mode je set, add nebo toggle, ne `{other}`
+@msg mode must be "cmyk" or "rgb" mode musí být "cmyk" nebo "rgb"
+@msg mode must be area|onPath, got {o} mode musí být area|onPath, zadáno {o}
+@msg more than 10000 linked files: package dependencies in smaller parts více než 10000 propojených souborů: zabalte závislosti po menších částech
+@msg more than 256 commands více než 256 příkazů
+@msg more than {MAX_BATCH_FILES} data sets více než {MAX_BATCH_FILES} sad dat
+@msg more than {MAX_BATCH_FILES} files více než {MAX_BATCH_FILES} souborů
+@msg more than {MAX_CSV_ROWS} data rows více než {MAX_CSV_ROWS} řádků dat
+@msg more than {MAX_FILES} images více než {MAX_FILES} obrázků
+@msg more than {MAX_IMPORT} presets více než {MAX_IMPORT} přednastavení
+@msg moved must be [[x,y]…] moved musí být [[x,y]…]
+@msg must be an object with `kind` and `value` musí být objekt s `kind` a `value`
+@msg must have `kind` alert, confirm or prompt musí mít `kind` alert, confirm nebo prompt
+@msg must have exactly `kind` and `value` musí mít právě `kind` a `value`
+@msg name (a toolbar's name) is required name (název panelu nástrojů) je povinné
+@msg name (a toolbar's name) must be a string name (název panelu nástrojů) musí být řetězec
+@msg name must be a toolbar's name name musí být název panelu nástrojů
+@msg names is empty names je prázdné
+@msg names must be an array of symbol names names musí být pole názvů symbolů
+@msg need `anchor` or `segment` + `t` je potřeba `anchor` nebo `segment` + `t`
+@msg need at least one anchor je potřeba alespoň jeden kotevní bod
+@msg need at least one point je potřeba alespoň jeden bod
+@msg need at least two points jsou potřeba alespoň dva body
+@msg needs a closing quote after the name vyžaduje za názvem uzavírací uvozovku
+@msg needs a name vyžaduje název
+@msg needs a name of at most 64 bytes vyžaduje název o nejvýše 64 bajtech
+@msg needs a name of letters, digits and _, or a quoted name vyžaduje název z písmen, číslic a _, nebo název v uvozovkách
+@msg needs a name without control characters vyžaduje název bez řídicích znaků
+@msg needs points vyžaduje body
+@msg nesting too deep příliš hluboké vnoření
+@msg nineSlice must be true or false nineSlice musí být true nebo false
+@msg nineSliceGuides must be [left, top, right, bottom], each -16..16, with left + right < 1 and top + bottom < 1 nineSliceGuides musí být [left, top, right, bottom], každá hodnota -16..16, přičemž left + right < 1 a top + bottom < 1
+@msg no HP-GL format žádný formát HP-GL
+@msg no New Document profile called `{shown}` ({_1}) žádný profil nového dokumentu se nejmenuje `{shown}` ({_1})
+@msg no PDF preset named `{n}` přednastavení PDF s názvem `{n}` neexistuje
+@msg no SVG filter at index {k} na indexu {k} není žádný filtr SVG
+@msg no action set is current žádná sada akcí není aktuální
+@msg no active data set žádná aktivní sada dat
+@msg no appearance item {index} položka vzhledu {index} neexistuje
+@msg no arrowheads file here zde není žádný soubor šipek
+@msg no artboard {_1} pracovní plocha {_1} neexistuje
+@msg no artboard {artboard} (0-based; the document has {_1}) pracovní plocha {artboard} neexistuje (počítáno od 0; dokument jich má {_1})
+@msg no asset {id} prvek {id} neexistuje
+@msg no asset {id} (see assets.list) prvek {id} neexistuje (viz assets.list)
+@msg no assets to export žádné prvky k exportu
+@msg no assets yet: collect art for export first (assets.add) zatím žádné prvky: nejprve shromážděte kresbu pro export (assets.add)
+@msg no brush named `{n}` štětec s názvem `{n}` neexistuje
+@msg no character at byte {start} na bajtu {start} není žádný znak
+@msg no character has code {code} žádný znak nemá kód {code}
+@msg no colour žádná barva
+@msg no colour group `{group}` skupina barev `{group}` neexistuje
+@msg no colour group `{name}` skupina barev `{name}` neexistuje
+@msg no custom arrowhead {name} (see stroke.arrowheads.list) vlastní šipka {name} neexistuje (viz stroke.arrowheads.list)
+@msg no data sets žádné sady dat
+@msg no dataset named `{from}` sada dat s názvem `{from}` neexistuje
+@msg no dataset named `{name}` sada dat s názvem `{name}` neexistuje
+@msg no datasets žádné sady dat
+@msg no design named `{name}` návrh s názvem `{name}` neexistuje
+@msg no design named `{n}` návrh s názvem `{n}` neexistuje
+@msg no design named `{old}` návrh s názvem `{old}` neexistuje
+@msg no encoder for {_1} yet pro {_1} zatím není žádný kodér
+@msg no export preset `{name}` (see document.exportPresets.list) přednastavení exportu `{name}` neexistuje (viz document.exportPresets.list)
+@msg no file to open žádný soubor k otevření
+@msg no folder: pass `path` or set Preferences › Plug-ins & Scratch Disks › Additional Plug-ins Folder žádná složka: předejte `path` nebo nastavte Předvolby › Zásuvné moduly a odkládací disky › Další složka zásuvných modulů
+@msg no fonts to look for: they are all available žádná písma k hledání: všechna jsou dostupná
+@msg no formats to export žádné formáty k exportu
+@msg no gradient preset `{name}` (see gradient.presets.list) přednastavení přechodu `{name}` neexistuje (viz gradient.presets.list)
+@msg no graphic style `{n}` in `{_1}` grafický styl `{n}` v `{_1}` neexistuje
+@msg no graphic style library `{key}` (see graphicStyle.libraries) knihovna grafických stylů `{key}` neexistuje (viz graphicStyle.libraries)
+@msg no guide selected není vybráno žádné vodítko
+@msg no guide selected (or give an index) není vybráno žádné vodítko (nebo zadejte index)
+@msg no guide {i} vodítko {i} neexistuje
+@msg no images in the folder ve složce nejsou žádné obrázky
+@msg no instructions žádné instrukce
+@msg no item at index {_1} na indexu {_1} není žádná položka
+@msg no item at index {from} na indexu {from} není žádná položka
+@msg no item at one of the indices {idx} na některém z indexů {idx} není žádná položka
+@msg no layer žádná vrstva
+@msg no layer to rename žádná vrstva k přejmenování
+@msg no layer {id} (see layer.list) vrstva {id} neexistuje (viz layer.list)
+@msg no layers to export žádné vrstvy k exportu
+@msg no library `{key}` knihovna `{key}` neexistuje
+@msg no metafile format žádný formát metasouboru
+@msg no object {_1} objekt {_1} neexistuje
+@msg no objects žádné objekty
+@msg no parent layer žádná nadřazená vrstva
+@msg no perspective grid preset named `{b}` (see perspective.presets.list) přednastavení perspektivní mřížky s názvem `{b}` neexistuje (viz perspective.presets.list)
+@msg no perspective grid preset named `{n}` přednastavení perspektivní mřížky s názvem `{n}` neexistuje
+@msg no point selected (give `index`) není vybrán žádný bod (zadejte `index`)
+@msg no preset called `{from}` (see file.newPresets) přednastavení s názvem `{from}` neexistuje (viz file.newPresets)
+@msg no preset called `{name}` (see file.newPresets) přednastavení s názvem `{name}` neexistuje (viz file.newPresets)
+@msg no preset named `{n}` přednastavení s názvem `{n}` neexistuje
+@msg no print preset named `{b}` (see print.presets.list) přednastavení tisku s názvem `{b}` neexistuje (viz print.presets.list)
+@msg no print preset named `{name}` (presets: {_1}) přednastavení tisku s názvem `{name}` neexistuje (přednastavení: {_1})
+@msg no print preset named `{n}` přednastavení tisku s názvem `{n}` neexistuje
+@msg no profile named `{name}` profil s názvem `{name}` neexistuje
+@msg no random numbers ({e}) náhodná čísla nejsou k dispozici ({e})
+@msg no readable files in the folder ve složce nejsou žádné čitelné soubory
+@msg no recovery copy `{f}` (see file.recovery.list) záložní kopie `{f}` neexistuje (viz file.recovery.list)
+@msg no rows žádné řádky
+@msg no saved PDF preset named `{name}` uložené přednastavení PDF s názvem `{name}` neexistuje
+@msg no saved export preset `{name}` uložené přednastavení exportu `{name}` neexistuje
+@msg no saved gradient preset called {name} uložené přednastavení přechodu s názvem {name} neexistuje
+@msg no saved gradient presets žádná uložená přednastavení přechodu
+@msg no saved preset called `{name}` uložené přednastavení s názvem `{name}` neexistuje
+@msg no saved presets to export (name [Default] in `names` to export it) žádná uložená přednastavení k exportu (chcete-li exportovat [Default], uveďte ho v `names`)
+@msg no saved presets to export (name built-in ones in `names`) žádná uložená přednastavení k exportu (vestavěná uveďte v `names`)
+@msg no saved print preset named `{name}` uložené přednastavení tisku s názvem `{name}` neexistuje
+@msg no saved profile called {name} uložený profil s názvem {name} neexistuje
+@msg no saved selection `{name}` uložený výběr `{name}` neexistuje
+@msg no saved theme called {name} (see colorTheme.list) uložený motiv s názvem {name} neexistuje (viz colorTheme.list)
+@msg no saved views žádná uložená zobrazení
+@msg no selected or given object has a visible 3D effect žádný vybraný ani zadaný objekt nemá viditelný 3D efekt
+@msg no series {i} řada {i} neexistuje
+@msg no slice {n} in the image (see slice.list) řez {n} v obrazu neexistuje (viz slice.list)
+@msg no source art žádná zdrojová kresba
+@msg no stop {i} (the gradient has {n}) zarážka {i} neexistuje (přechod jich má {n})
+@msg no style `{name}` styl `{name}` neexistuje
+@msg no such artboard {v} taková pracovní plocha neexistuje: {v}
+@msg no such assets (see assets.list) takové prvky neexistují (viz assets.list)
+@msg no such frame anchor, or the frame would reach past the canvas takový kotevní bod rámce neexistuje, nebo by rámec přesáhl plátno
+@msg no such objects takové objekty neexistují
+@msg no such path anchor, or the path would reach past the canvas takový kotevní bod cesty neexistuje, nebo by cesta přesáhla plátno
+@msg no such rows takové řádky neexistují
+@msg no swatch `{keep}` položka vzorníku `{keep}` neexistuje
+@msg no swatch `{n}` položka vzorníku `{n}` neexistuje
+@msg no swatch `{n}` (swatches and colour groups move separately) položka vzorníku `{n}` neexistuje (položky vzorníku a skupiny barev se přesouvají zvlášť)
+@msg no swatch library `{key}` (see swatch.library.list) knihovna vzorníku `{key}` neexistuje (viz swatch.library.list)
+@msg no swatch library `{key}` (see swatch.library.list; "document": the document's swatches) knihovna vzorníku `{key}` neexistuje (viz swatch.library.list; "document": vzorník dokumentu)
+@msg no swatch library `{key}` (see swatch.library.list; "{DOCUMENT_SWATCHES}": the document's swatches) knihovna vzorníku `{key}` neexistuje (viz swatch.library.list; "{DOCUMENT_SWATCHES}": vzorník dokumentu)
+@msg no swatch named `{n}` položka vzorníku s názvem `{n}` neexistuje
+@msg no swatch or colour group `{n}` položka vzorníku ani skupina barev `{n}` neexistuje
+@msg no swatch or colour group `{n}` in `{_1}` položka vzorníku ani skupina barev `{n}` v `{_1}` neexistuje
+@msg no symbol named `{_1}` symbol s názvem `{_1}` neexistuje
+@msg no symbol named `{name}` symbol s názvem `{name}` neexistuje
+@msg no text objects selected nejsou vybrány žádné textové objekty
+@msg no toolbar named `{_1}` panel nástrojů s názvem `{_1}` neexistuje
+@msg no toolbar named `{name}` panel nástrojů s názvem `{name}` neexistuje
+@msg no tracing preset `{preset}` (see imageTrace.presets) přednastavení vektorizace `{preset}` neexistuje (viz imageTrace.presets)
+@msg no user library folder here (save with a path, or without one to get the data) zde není žádná složka uživatelských knihoven (uložte s cestou, nebo bez ní, chcete-li získat data)
+@msg no variable named `{from}` proměnná s názvem `{from}` neexistuje
+@msg no variable named `{name}` proměnná s názvem `{name}` neexistuje
+@msg no variable named `{var}` proměnná s názvem `{var}` neexistuje
+@msg no variable named `{v}` proměnná s názvem `{v}` neexistuje
+@msg no view named `{name}` zobrazení s názvem `{name}` neexistuje
+@msg node {_1} already has the text variable `{_2}` uzel {_1} už má textovou proměnnou `{_2}`
+@msg node {_1} is not a graph uzel {_1} není graf
+@msg node {_1} is not an image uzel {_1} není obraz
+@msg node {_1} is not type uzel {_1} není text
+@msg not a DCS file nejde o soubor DCS
+@msg not a PDF presets file (format `{_1}`) nejde o soubor přednastavení PDF (formát `{_1}`)
+@msg not a PDF presets file: {e} nejde o soubor přednastavení PDF: {e}
+@msg not a RegExp object nejde o objekt RegExp
+@msg not a Word document: its WordDocument stream doesn't start with a file information block nejde o dokument Word: jeho proud WordDocument nezačíná blokem informací o souboru
+@msg not a Word document: its compound file has no WordDocument stream nejde o dokument Word: jeho složený soubor nemá proud WordDocument
+@msg not a Word document: its main part is not a WordprocessingML document nejde o dokument Word: jeho hlavní část není dokument WordprocessingML
+@msg not a Word document: its package has no main document part nejde o dokument Word: jeho balíček nemá hlavní část dokumentu
+@msg not a closed path nejde o uzavřenou cestu
+@msg not a flattener presets file (format `{_1}`) nejde o soubor přednastavení sloučení průhlednosti (formát `{_1}`)
+@msg not a flattener presets file: {e} nejde o soubor přednastavení sloučení průhlednosti: {e}
+@msg not a layer or group nejde o vrstvu ani skupinu
+@msg not a library file: {e} nejde o soubor knihovny: {e}
+@msg not a mesh envelope point nejde o bod síťové obálky
+@msg not a perspective grid presets file (format `{_1}`) nejde o soubor přednastavení perspektivní mřížky (formát `{_1}`)
+@msg not a perspective grid presets file: {e} nejde o soubor přednastavení perspektivní mřížky: {e}
+@msg not a print presets file (format `{_1}`) nejde o soubor přednastavení tisku (formát `{_1}`)
+@msg not a print presets file: {e} nejde o soubor přednastavení tisku: {e}
+@msg not a regular expression nejde o regulární výraz
+@msg not a stored or deflated central record nejde o uložený ani komprimovaný centrální záznam
+@msg not a valid SVGZ file: {e} nejde o platný soubor SVGZ: {e}
+@msg not an RTF file: it doesn't start with {\rtf nejde o soubor RTF: nezačíná na {\rtf
+@msg not an action set: the text doesn't start with /version and a number nejde o sadu akcí: text nezačíná na /version a číslo
+@msg not an image VectorCraft reads (see document.formats) nejde o obrázek, který VectorCraft čte (viz document.formats)
+@msg not area type (text in a frame) outside perspective nejde o text v oblasti (text v rámci) mimo perspektivu
+@msg not editing an opacity mask maska krytí se neupravuje
+@msg not type on a path outside perspective nejde o text na cestě mimo perspektivu
+@msg nothing prints nic se netiskne
+@msg nothing to bind není co svázat
+@msg nothing to change není co změnit
+@msg nothing to change: give dx, dy, hScale, vScale or rotation není co změnit: zadejte dx, dy, hScale, vScale nebo rotation
+@msg nothing to crop není co oříznout
+@msg nothing to export není co exportovat
+@msg nothing to export: the document has no visible art není co exportovat: dokument nemá žádnou viditelnou kresbu
+@msg nothing to mark není co označit
+@msg nothing to print: every artboard picked is blank není co tisknout: každá zvolená pracovní plocha je prázdná
+@msg nothing to print: the document has no art that prints není co tisknout: dokument nemá žádnou kresbu, která se tiskne
+@msg nothing to repeat není co opakovat
+@msg numbers out of order in {_1} quantifier čísla v kvantifikátoru {_1} jsou ve špatném pořadí
+@msg object is not a path objekt není cesta
+@msg object transparency cannot be sampled onto highlighted characters; disable Transparency in Eyedropper Options or select the whole object průhlednost objektu nelze nabrat na zvýrazněné znaky; vypněte Průhlednost ve volbách kapátka nebo vyberte celý objekt
+@msg object {_1} can't be set as point type objekt {_1} nelze nastavit jako bodový text
+@msg object {_1} has lost its file's contents: relink it objekt {_1} ztratil obsah svého souboru: propojte ho znovu
+@msg object {_1} has no path objekt {_1} nemá cestu
+@msg object {_1} has no size objekt {_1} nemá velikost
+@msg object {_1} is hidden or locked, or on a locked artboard objekt {_1} je skrytý nebo zamknutý, nebo je na zamknuté pracovní ploše
+@msg object {_1} is not a placed document objekt {_1} není umístěný dokument
+@msg object {_1} is not an Intertwine group objekt {_1} není skupina propletení
+@msg object {_1} is not an image objekt {_1} není obraz
+@msg object {_1} is not an image or a linked file objekt {_1} není obraz ani propojený soubor
+@msg object {_1} is not an image or a placed document objekt {_1} není obraz ani umístěný dokument
+@msg object {_1} is not in this Intertwine group objekt {_1} není v této skupině propletení
+@msg object {_1} needs a 3D effect objekt {_1} vyžaduje 3D efekt
+@msg object {_1} needs a 3D effect at index {index} objekt {_1} vyžaduje 3D efekt na indexu {index}
+@msg object {_1}: a hollow Extrude & Bevel has no caps: turn on Cap to map a graphic objekt {_1}: duté Vysunutí a zkosení nemá zakončení: chcete-li mapovat grafiku, zapněte Zakončení
+@msg object {_1}: with a depth of 0 the object has one face: map its graphics onto the front objekt {_1}: při hloubce 0 má objekt jednu plochu: mapujte jeho grafiku na přední stranu
+@msg object {_1}: {_2} objekt {_1}: {_2}
+@msg object {_1}: {e} objekt {_1}: {e}
+@msg object {_1}: {name} at index {index} has no lighting presets; set its lights with effect.setParams objekt {_1}: {name} na indexu {index} nemá žádná přednastavení osvětlení; světla nastavte pomocí effect.setParams
+@msg object {id} has anchors 0–{_1} for `corners`, not {past} objekt {id} má pro `corners` kotevní body 0–{_1}, ne {past}
+@msg object {id} has {n} anchors, so no anchor {past} objekt {id} má {n} kotevních bodů, takže kotevní bod {past} neexistuje
+@msg objects must be none, move or copy objects musí být none, move nebo copy
+@msg objects were left out objekty byly vynechány
+@msg offset out of range posun je mimo rozsah
+@msg on must be true or false on musí být true nebo false
+@msg only groups, layers, type and images have a Contents (Characters) row řádek Obsah (Znaky) mají jen skupiny, vrstvy, text a obrazy
+@msg only interior mesh lines can be deleted odstranit lze jen vnitřní linie sítě
+@msg only solid colours can be added (not gradients or patterns) přidat lze jen plné barvy (ne přechody ani vzorky)
+@msg only solid-colour swatches can be spot colours přímými barvami mohou být jen položky vzorníku s plnou barvou
+@msg only this Affinity file's embedded preview could be read: use File › Open to see it with its warning, or export SVG or PDF from Affinity first z tohoto souboru Affinity šlo přečíst jen vložený náhled: pomocí Soubor › Otevřít ho zobrazíte s varováním, nebo nejprve exportujte SVG či PDF z Affinity
+@msg only this Affinity file's embedded preview could be read; use File › Open to see it with its warning z tohoto souboru Affinity šlo přečíst jen vložený náhled; pomocí Soubor › Otevřít ho zobrazíte s varováním
+@msg onto, above, below, icon and a column place belong to window.panel.float and window.panel.dock onto, above, below, icon a umístění ve sloupci patří k window.panel.float a window.panel.dock
+@msg onto, above, below, icon and a column place take no anchor, side, zone or before onto, above, below, icon a umístění ve sloupci nepřijímají anchor, side, zone ani before
+@msg onto, above, below, icon and a column place take no x, y, width or height onto, above, below, icon a umístění ve sloupci nepřijímají x, y, width ani height
+@msg opacity must be a number opacity musí být číslo
+@msg open a document to look for its linked files otevřete dokument, jehož propojené soubory se mají hledat
+@msg operand too large operand je příliš velký
+@msg options must be an object (see file.formatOptions) options musí být objekt (viz file.formatOptions)
+@msg order is a whole number from 0 to {others}, not `{v}` order je celé číslo od 0 do {others}, ne `{v}`
+@msg order must be leftToRight or rightToLeft order musí být leftToRight nebo rightToLeft
+@msg orientation must be portrait or landscape, not `{o}` orientation musí být portrait nebo landscape, ne `{o}`
+@msg outlineText {v}: true or false outlineText {v}: true nebo false
+@msg output intent profile: {e} profil výstupního záměru: {e}
+@msg output.destination: no RGB or CMYK profile is called “{name}” output.destination: žádný profil RGB ani CMYK se nejmenuje „{name}“
+@msg output.destination: {_1} files are CMYK, and “{name}” is an RGB profile output.destination: soubory {_1} jsou CMYK a „{name}“ je profil RGB
+@msg output.destination: {e} output.destination: {e}
+@msg output.inks: no ink is set to print output.inks: žádná tisková barva není nastavena k tisku
+@msg output.outputIntent: no profile is called “{name}”, and a {std} output intent embeds its profile output.outputIntent: žádný profil se nejmenuje „{name}“ a výstupní záměr {std} svůj profil vkládá
+@msg output.outputIntent: no profile is called “{name}”: a {std} output intent embeds a profile, or names a registered printing condition (outputConditionId and registry) output.outputIntent: žádný profil se nejmenuje „{name}“: výstupní záměr {std} profil vkládá, nebo uvádí registrovanou tiskovou podmínku (outputConditionId a registry)
+@msg output.outputIntent: {std} files print in CMYK, and “{name}” is an RGB profile output.outputIntent: soubory {std} se tisknou v CMYK a „{name}“ je profil RGB
+@msg output.outputIntent: “{name}” is a grey profile, which can't be a {std} output intent output.outputIntent: „{name}“ je profil odstínů šedi, který nemůže být výstupním záměrem {std}
+@msg overlap: {o} pt is half a tile or more překrytí: {o} pt je polovina dlaždice nebo více
+@msg overlapping Shaper paths must be in the same layer or group překrývající se cesty Shaper musí být ve stejné vrstvě nebo skupině
+@msg overlapping edits překrývající se úpravy
+@msg page art conflicts with image data for {key} kresba stránky je v rozporu s obrazovými daty pro {key}
+@msg page art conflicts with pattern {name} kresba stránky je v rozporu se vzorkem {name}
+@msg page art conflicts with swatch {name} kresba stránky je v rozporu s položkou vzorníku {name}
+@msg page art is missing image data for {key} kresbě stránky chybí obrazová data pro {key}
+@msg page art is missing pattern {name} kresbě stránky chybí vzorek {name}
+@msg page art is missing swatch {name} kresbě stránky chybí položka vzorníku {name}
+@msg page must be a whole number from 1 page musí být celé číslo od 1
+@msg page thumbnail: {e} miniatura stránky: {e}
+@msg page {page} of the PDF has no objects stránka {page} dokumentu PDF nemá žádné objekty
+@msg page {page}: `{file}` has {n} {artboards} stránka {page}: `{file}` má {n} {artboards}
+@msg page {page}: `{name}` has {n} {pages} stránka {page}: `{name}` má {n} {pages}
+@msg pages must be a range such as "2-3" or a page number, not {v} pages musí být rozsah jako "2-3" nebo číslo stránky, ne {v}
+@msg pages: {e} pages: {e}
+@msg panel position must be finite pozice panelu musí být konečná
+@msg parameters must be an object parametry musí být objekt
+@msg params must be an object params musí být objekt
+@msg passwords can have up to {MAX_PASSWORD} bytes hesla mohou mít nejvýše {MAX_PASSWORD} bajtů
+@msg path chunks must share their boundary point úseky cesty musí sdílet svůj hraniční bod
+@msg path has no open end cesta nemá žádný otevřený konec
+@msg paths must be strings paths musí být řetězce
+@msg pdfCompatible: false needs every artboard to keep the editable artwork pdfCompatible: false vyžaduje všechny pracovní plochy, aby se zachovala upravitelná kresba
+@msg pdfCompatible: false needs the artboards to keep the editable artwork when useArtboards is false pdfCompatible: false vyžaduje pracovní plochy, aby se zachovala upravitelná kresba, když je useArtboards false
+@msg pdfs `{m}`: single or multiple pdfs `{m}`: single nebo multiple
+@msg pickColorFrom is "artwork" or "swatches", not {v} pickColorFrom je "artwork" nebo "swatches", ne {v}
+@msg pins and moved must be non-empty lists of the same length pins a moved musí být neprázdné seznamy stejné délky
+@msg pins must be [[x,y]…] pins musí být [[x,y]…]
+@msg pins must be finite pins musí být konečné
+@msg place is above, below or inside, not `{other}` place je above, below nebo inside, ne `{other}`
+@msg plane must be left|right|ground plane musí být left|right|ground
+@msg plane must be left|right|ground|none plane musí být left|right|ground|none
+@msg point is outside the mesh bod je mimo síť
+@msg point x must be a number x bodu musí být číslo
+@msg point y must be a number y bodu musí být číslo
+@msg points must be [t, left, right] with non-negative widths points musí být [t, left, right] s nezápornými šířkami
+@msg points must be [x,y] pairs points musí být dvojice [x,y]
+@msg points must be a non-empty [[x,y,pressure?]…] list points musí být neprázdný seznam [[x,y,pressure?]…]
+@msg points must be a non-empty list of finite [x,y] points musí být neprázdný seznam konečných [x,y]
+@msg points must be an array or null points musí být pole nebo null
+@msg points must be finite points musí být konečné
+@msg points must be finite [x,y] pairs points musí být konečné dvojice [x,y]
+@msg points must be finite canvas points points musí být konečné body plátna
+@msg points must contain 1–4096 coordinates points musí obsahovat 1–4096 souřadnic
+@msg position must be normal, superscript or subscript position musí být normal, superscript nebo subscript
+@msg position must be topLeft, topRight, bottomLeft or bottomRight position musí být topLeft, topRight, bottomLeft nebo bottomRight
+@msg positions must be between 0 and 100000 pt positions musí být mezi 0 a 100000 pt
+@msg ppi must be a positive number, not {ppi} ppi musí být kladné číslo, ne {ppi}
+@msg ppi must be between 1 and 2400 ppi musí být mezi 1 a 2400
+@msg preserve `{v}` is not transforms|bounds|fileDimensions|fit|fill preserve `{v}` není transforms|bounds|fileDimensions|fit|fill
+@msg preserveShape must be clippingMask|transparency preserveShape musí být clippingMask|transparency
+@msg preset `{id}`: mobile or density přednastavení `{id}`: mobile nebo density
+@msg preset must be standard, diffuse, topLeft or right preset musí být standard, diffuse, topLeft nebo right
+@msg preset must be the name of a print preset preset musí být název přednastavení tisku
+@msg previewMode must be default, pixel or overprint previewMode musí být default, pixel nebo overprint
+@msg print settings: {e} nastavení tisku: {e}
+@msg procedures nest too deeply procedury jsou vnořeny příliš hluboko
+@msg procedures nested too deeply procedury byly vnořeny příliš hluboko
+@msg quotes must be {double?, single?} quotes musí být {double?, single?}
+@msg quotes.{k} must be an opening and a closing quote, e.g. "“”" quotes.{k} musí být otevírací a uzavírací uvozovka, např. "“”"
+@msg range out of order in character class rozsah ve třídě znaků je ve špatném pořadí
+@msg rasterEffectsPpi must be 1–2400 (72 screen, 150 medium, 300 high) rasterEffectsPpi musí být 1–2400 (72 obrazovka, 150 střední, 300 vysoké)
+@msg rating must be a whole number from 0 to {MAX_RATING} rating musí být celé číslo od 0 do {MAX_RATING}
+@msg rect and {k} contradict each other: give the area or the artboards rect a {k} si odporují: zadejte oblast, nebo pracovní plochy
+@msg rect applies to raster formats, not {_1} rect platí pro rastrové formáty, ne pro {_1}
+@msg rect is [x, y, width, height] in points rect je [x, y, width, height] v bodech
+@msg rect must be [x, y, width, height] rect musí být [x, y, width, height]
+@msg rect must be [x, y, width, height] with a positive width and height rect musí být [x, y, width, height] s kladnou šířkou a výškou
+@msg rect needs finite numbers and a positive width and height rect vyžaduje konečná čísla a kladnou šířku a výšku
+@msg redraw needs id redraw vyžaduje id
+@msg redraw within must be a finite distance of 0 or more within v redraw musí být konečná vzdálenost 0 nebo větší
+@msg reduce of an empty array with no initial value reduce prázdného pole bez počáteční hodnoty
+@msg registration must be 0..8 registration musí být 0..8
+@msg regular expression flag '{_1}' given twice příznak regulárního výrazu '{_1}' je zadán dvakrát
+@msg release a layer or a group uvolněte vrstvu nebo skupinu
+@msg replace keeps the replaced object's place and transform: drop template, at and rect replace zachová umístění a transformaci nahrazeného objektu: vynechte template, at a rect
+@msg replace: select the one object to replace replace: vyberte jeden objekt, který se má nahradit
+@msg resolution must be a number or screen|medium|high resolution musí být číslo nebo screen|medium|high
+@msg resolution must be between 1 and 2400 ppi resolution musí být mezi 1 a 2400 ppi
+@msg rotation must be a number of degrees rotation musí být počet stupňů
+@msg rotation must be finite rotation musí být konečné
+@msg rows and cols must be 1..50 rows a cols musí být 1..50
+@msg rows and columns must be 1..500 rows a columns musí být 1..500
+@msg rows of objects whose appearances differ can't be reordered together řádky objektů s odlišným vzhledem nelze přeskupovat společně
+@msg sampled text would exceed the canvas (coordinates out of range) nabraný text by přesáhl plátno (souřadnice mimo rozsah)
+@msg save the document first: Package collects a saved document nejprve dokument uložte: Balíček shromažďuje uložený dokument
+@msg save the document first: copy puts the file in its folder nejprve dokument uložte: copy vloží soubor do jeho složky
+@msg savedPreset: {e} savedPreset: {e}
+@msg scale `{s}`: a factor such as 2x, a width (100w), a height (100h) or a resolution (72ppi) scale `{s}`: násobek jako 2x, šířka (100w), výška (100h) nebo rozlišení (72ppi)
+@msg scale must be a number above 0, at most 64 scale musí být číslo větší než 0, nejvýše 64
+@msg scale must be non-zero scale nesmí být nula
+@msg scope must be selection or all (got {other}) scope musí být selection nebo all (zadáno {other})
+@msg scribble is too long čmáranice je příliš dlouhá
+@msg segment does not exist segment neexistuje
+@msg select a Shaper group vyberte skupinu Shaper
+@msg select a blend to set easing or color shift vyberte prolnutí, u kterého se má nastavit náběh nebo posun barev
+@msg select a compound shape vyberte složený tvar
+@msg select a graph vyberte graf
+@msg select a group or layer with visible, unlocked members vyberte skupinu nebo vrstvu s viditelnými odemknutými členy
+@msg select a layer vyberte vrstvu
+@msg select a text object vyberte textový objekt
+@msg select an SVG filter vyberte filtr SVG
+@msg select an image (or an Image Trace group) vyberte obraz (nebo skupinu vektorizace obrazu)
+@msg select an image, or pass id vyberte obraz, nebo předejte id
+@msg select an object (or pass `id`) vyberte objekt (nebo předejte `id`)
+@msg select an object with a 3D effect vyberte objekt s 3D efektem
+@msg select an object with a 3D effect, or give its id in ids vyberte objekt s 3D efektem, nebo zadejte jeho ID v ids
+@msg select an object with a freeform gradient vyberte objekt s volným přechodem
+@msg select an object with an opacity mask vyberte objekt s maskou krytí
+@msg select area type vyberte text v oblasti
+@msg select area type (text in a frame) vyberte text v oblasti (text v rámci)
+@msg select area type and closed paths (at least one area type object) vyberte text v oblasti a uzavřené cesty (alespoň jeden objekt textu v oblasti)
+@msg select area type whose first line can fit its frame vyberte text v oblasti, jehož první řádek se vejde do rámce
+@msg select artwork to add its colours vyberte kresbu, jejíž barvy se mají přidat
+@msg select artwork to make a group of its colours vyberte kresbu, z jejíchž barev se má vytvořit skupina
+@msg select artwork to recolour, or give a colour `group` vyberte kresbu k přebarvení, nebo zadejte skupinu barev v `group`
+@msg select exactly two path ends to join vyberte právě dva konce cest ke spojení
+@msg select members of a compound shape vyberte členy složeného tvaru
+@msg select objects or give ids vyberte objekty nebo zadejte ids
+@msg select objects to align to the guide vyberte objekty, které se mají zarovnat k vodítku
+@msg select one Intertwine group vyberte jednu skupinu propletení
+@msg select one image or pass id vyberte jeden obraz nebo předejte id
+@msg select one layer, group or object to rename vyberte jednu vrstvu, skupinu nebo objekt k přejmenování
+@msg select paths vyberte cesty
+@msg select paths or compound paths vyberte cesty nebo složené cesty
+@msg select rows or objects to collect vyberte řádky nebo objekty ke shromáždění
+@msg select something to export vyberte něco k exportu
+@msg select something to export (or turn off selectedOnly) vyberte něco k exportu (nebo vypněte selectedOnly)
+@msg select text to redefine the style from vyberte text, podle kterého se má styl předefinovat
+@msg select the art and, on top of it, the mask object vyberte kresbu a nad ní objekt masky
+@msg select the art for the design vyberte kresbu pro návrh
+@msg select the art for the pattern tile vyberte kresbu pro dlaždici vzorku
+@msg select the art to add first nejprve vyberte kresbu, která se má přidat
+@msg select the art to collect (or give its ids) vyberte kresbu ke shromáždění (nebo zadejte její ids)
+@msg select the art to weed vyberte kresbu pro vybírání
+@msg select the content and a path on top vyberte obsah a cestu nad ním
+@msg select the images to relink, or pass ids vyberte obrazy k novému propojení, nebo předejte ids
+@msg select the images, or pass ids vyberte obrazy, nebo předejte ids
+@msg select the linked images or placed documents, or pass ids vyberte propojené obrazy nebo umístěné dokumenty, nebo předejte ids
+@msg select the object to copy from vyberte objekt, ze kterého se má kopírovat
+@msg select the objects (or give their ids) vyberte objekty (nebo zadejte jejich ids)
+@msg select the objects to attach to the path vyberte objekty, které se mají připojit k cestě
+@msg select the objects to bind vyberte objekty, které se mají svázat
+@msg select two or more objects to intertwine vyberte dva nebo více objektů k propletení
+@msg select two or more slices to combine vyberte dva nebo více řezů ke spojení
+@msg select type on a path vyberte text na cestě
+@msg select type on a path or area type vyberte text na cestě nebo text v oblasti
+@msg selection `{m}`: one or each selection `{m}`: one nebo each
+@msg settings is an object of document.exportForScreens params settings je objekt s parametry document.exportForScreens
+@msg settings must be an object settings musí být objekt
+@msg show `{show}`: all, missing, modified or embedded show `{show}`: all, missing, modified nebo embedded
+@msg show must be true or false show musí být true nebo false
+@msg side is left or right, not `{v}` side je left nebo right, ne `{v}`
+@msg size must be "small", "medium", "large" or a number of points over 0 up to {_1} size musí být "small", "medium", "large" nebo počet bodů větší než 0 a nejvýše {_1}
+@msg size must be positive size musí být kladné
+@msg sizeMode is "fixed" or "pressure", not `{other}` sizeMode je "fixed" nebo "pressure", ne `{other}`
+@msg sizeMode is "fixed" or "pressure", not {other} sizeMode je "fixed" nebo "pressure", ne {other}
+@msg sizeVariation must be 0 or more sizeVariation musí být 0 nebo více
+@msg smallCaps must be true or false smallCaps musí být true nebo false
+@msg sort `{x}`: name, kind or status sort `{x}`: name, kind nebo status
+@msg source paths are too large for Shaper zdrojové cesty jsou pro Shaper příliš velké
+@msg source text transform is collapsed or unrepresentable transformace zdrojového textu je zborcená nebo ji nelze vyjádřit
+@msg spot, mode and group apply to solid colours only spot, mode a group platí jen pro plné barvy
+@msg status `{s}`: linked, embedded, missing or modified status `{s}`: linked, embedded, missing nebo modified
+@msg steps must be 1..1000 steps musí být 1..1000
+@msg steps must be 1..{MAX_STEPS} steps musí být 1..{MAX_STEPS}
+@msg stop {i}: `plates` must be four numbers from 0 to 1 (C, M, Y, K) zarážka {i}: `plates` musí být čtyři čísla od 0 do 1 (C, M, Y, K)
+@msg stopped before the archive was finished zastaveno před dokončením archivu
+@msg strokeMode must be within|along|across, got {m} strokeMode musí být within|along|across, zadáno {m}
+@msg strokeOptions must be an object of stroke.set options strokeOptions musí být objekt s volbami stroke.set
+@msg strokeWidth must be a positive number of pixels strokeWidth musí být kladný počet obrazových bodů
+@msg subpath {si} is not an open subpath dílčí cesta {si} není otevřená dílčí cesta
+@msg subpaths required when joining the same path při spojování téže cesty je vyžadováno subpaths
+@msg swatchConflict must be "merge" or "add", not {v} swatchConflict musí být "merge" nebo "add", ne {v}
+@msg table must be true or false table musí být true nebo false
+@msg takes one name přijímá jeden název
+@msg target text transform is collapsed or unrepresentable transformace cílového textu je zborcená nebo ji nelze vyjádřit
+@msg text must be {characterSet?, platform?, removeLineReturns?, removeParagraphReturns?, replaceSpaces?} text musí být {characterSet?, platform?, removeLineReturns?, removeParagraphReturns?, replaceSpaces?}
+@msg text transform is collapsed or unrepresentable transformace textu je zborcená nebo ji nelze vyjádřit
+@msg text.replaceSpaces must be a whole number from 2 to 100 text.replaceSpaces musí být celé číslo od 2 do 100
+@msg text.{key} `{v}`: use "{_1}" or "{_2}" text.{key} `{v}`: použijte "{_1}" nebo "{_2}"
+@msg textureImage: give {path} or {name, dataBase64} of an image file, or a texture as effect.list gives it textureImage: zadejte {path} nebo {name, dataBase64} souboru obrázku, nebo texturu tak, jak ji vrací effect.list
+@msg textureImage: {e} textureImage: {e}
+@msg that makes more than {MAX_PIECES} slices tím vznikne více než {MAX_PIECES} řezů
+@msg that makes too many pieces (at most 100000) or dash steps: simplify the dash pattern or make the dashes longer tím vznikne příliš mnoho dílů (nejvýše 100000) nebo kroků čárkování: zjednodušte vzor čárkování nebo prodlužte čárky
+@msg that object is not in the overlap tento objekt není v překryvu
+@msg the Affinity document has no page with a size dokument Affinity nemá žádnou stránku s velikostí
+@msg the CMYK image couldn't be written obraz CMYK nešlo zapsat
+@msg the Layers panel is not shown panel Vrstvy není zobrazen
+@msg the Layers panel's search, filter or Show Layers Only hides its row jeho řádek skrývá hledání, filtr nebo volba Zobrazit pouze vrstvy v panelu Vrstvy
+@msg the Objects on Path group is hidden or locked skupina Objekty na cestě je skrytá nebo zamknutá
+@msg the PDF security settings were saved without their passwords: give openPassword or permissionsPassword again, or security without passwordRequired to export unprotected nastavení zabezpečení PDF bylo uloženo bez hesel: zadejte znovu openPassword nebo permissionsPassword, nebo pro export bez ochrany zadejte security bez passwordRequired
+@msg the RTF file has too many paragraphs or formatting changes to place as type (at most {MAX_PARAGRAPHS} paragraphs and {MAX_RUNS} runs) soubor RTF má příliš mnoho odstavců nebo změn formátování na umístění jako text (nejvýše {MAX_PARAGRAPHS} odstavců a {MAX_RUNS} úseků)
+@msg the RTF file is too large to place as type (at most {_1} MB) soubor RTF je příliš velký na umístění jako text (nejvýše {_1} MB)
+@msg the SVG expands to more than {0} nested SVG images SVG se rozvine na více než {0} vnořených obrázků SVG
+@msg the SVG expands to more than {0} objects SVG se rozvine na více než {0} objektů
+@msg the SVG file is larger than {MAX_IMPORT_BYTES} bytes soubor SVG je větší než {MAX_IMPORT_BYTES} bajtů
+@msg the SVG has no drawable objects SVG nemá žádné vykreslitelné objekty
+@msg the SVGZ file unpacks to more than {_1} MB soubor SVGZ se rozbalí na více než {_1} MB
+@msg the Word document has more than {MAX_PAGES} pages of properties, too many to place dokument Word má více než {MAX_PAGES} stránek vlastností, což je na umístění příliš mnoho
+@msg the Word document has more than {MAX_RUNS} runs of character or paragraph properties, too many to place dokument Word má více než {MAX_RUNS} úseků vlastností znaků nebo odstavců, což je na umístění příliš mnoho
+@msg the Word document has more than {_1} MB of formatting properties, too many to place dokument Word má více než {_1} MB vlastností formátování, což je na umístění příliš mnoho
+@msg the Word document has too much text or formatting to place as type (at most {MAX_PARAGRAPHS} paragraphs, {MAX_RUNS} runs and {_1} MB of text) dokument Word má příliš mnoho textu nebo formátování na umístění jako text (nejvýše {MAX_PARAGRAPHS} odstavců, {MAX_RUNS} úseků a {_1} MB textu)
+@msg the Word document has too much text to place as type (at most {_1} MB) dokument Word má příliš mnoho textu na umístění jako text (nejvýše {_1} MB)
+@msg the Word document is encrypted or protected with a password; save it without one to place it dokument Word je zašifrovaný nebo chráněný heslem; chcete-li ho umístit, uložte ho bez hesla
+@msg the Word document is too large to place (its {name} stream holds more than {_1} MB) dokument Word je příliš velký na umístění (jeho proud {name} obsahuje více než {_1} MB)
+@msg the Word document was last saved with fast save, which VectorCraft doesn't read; save it again with fast save turned off dokument Word byl naposledy uložen rychlým ukládáním, které VectorCraft nečte; uložte ho znovu s vypnutým rychlým ukládáním
+@msg the Word document was saved by Word 6.0 or Word 95, which VectorCraft doesn't read; save it as a Word 97-2003 document dokument Word byl uložen ve Wordu 6.0 nebo Wordu 95, což VectorCraft nečte; uložte ho jako dokument Word 97-2003
+@msg the Word document's text is made of more than {MAX_PIECES} pieces, too many to place text dokumentu Word se skládá z více než {MAX_PIECES} částí, což je na umístění příliš mnoho
+@msg the XML operation needs an XML or XMLList value operace XML vyžaduje hodnotu XML nebo XMLList
+@msg the XPath expression does not evaluate to a node-set výraz XPath se nevyhodnotí na množinu uzlů
+@msg the XPath expression ends early výraz XPath končí předčasně
+@msg the XPath expression has an unmatched ) výraz XPath obsahuje nespárovanou )
+@msg the XPath expression has an unmatched ] výraz XPath obsahuje nespárovanou ]
+@msg the XPath expression has an unmatched closing bracket výraz XPath obsahuje nespárovanou uzavírací závorku
+@msg the XPath expression selects a namespace node, which is not an XML value výraz XPath vybírá uzel jmenného prostoru, což není hodnota XML
+@msg the XPath expression selects the root node, which is not an XML value výraz XPath vybírá kořenový uzel, což není hodnota XML
+@msg the XPath function {name} needs a node-set funkce XPath {name} vyžaduje množinu uzlů
+@msg the XPath function {name} takes the wrong number of arguments funkce XPath {name} dostala nesprávný počet argumentů
+@msg the XPath name functions need a node-set funkce XPath pro názvy vyžadují množinu uzlů
+@msg the XPath prefix {shown} is not bound to a namespace předpona XPath {shown} není svázána se jmenným prostorem
+@msg the XPath variable ${shown} is not bound proměnná XPath ${shown} není svázána
+@msg the action set ids are not a list ID sad akcí nejsou seznam
+@msg the active paint is not a gradient aktivní nátěr není přechod
+@msg the argument list is not an object seznam argumentů není objekt
+@msg the artboards don't fit on the canvas: fewer artboards, less spacing or a smaller size pracovní plochy se nevejdou na plátno: zvolte méně pracovních ploch, menší rozestup nebo menší velikost
+@msg the artwork is too large for EPS: what follows 256 MB of PostScript was left out kresba je pro EPS příliš velká: vše, co následuje po 256 MB PostScriptu, bylo vynecháno
+@msg the bitmaps hold more pixels than one file may bitmapy obsahují více obrazových bodů, než smí mít jeden soubor
+@msg the character's size can't be measured velikost znaku nelze změřit
+@msg the code runs past its last instruction kód běží za svou poslední instrukci
+@msg the compound shape is too complex to expand složený tvar je příliš složitý na rozložení
+@msg the container of the listed objects is gone kontejner uvedených objektů už neexistuje
+@msg the copy `{user_id}` can't be read as a library kopii `{user_id}` nelze přečíst jako knihovnu
+@msg the copy would leave the canvas kopie by opustila plátno
+@msg the curve tolerance must be from 0.01 to 10 points tolerance křivky musí být od 0.01 do 10 bodů
+@msg the custom dictionary couldn't be saved to {_1}: {e} vlastní slovník nešlo uložit do {_1}: {e}
+@msg the custom dictionary {_1} can't be read ({why}), so it is left as it is vlastní slovník {_1} nelze přečíst ({why}), proto zůstává beze změny
+@msg the cut cannot be made on this art: {e} řez na této kresbě nelze provést: {e}
+@msg the data isn't an EMF or WMF picture data nejsou obrázek EMF ani WMF
+@msg the data isn't an EPS or PostScript picture data nejsou obrázek EPS ani PostScript
+@msg the deferred paste is no longer available odložené vložení už není dostupné
+@msg the design is too big to draw at every data point of this graph návrh je příliš velký na vykreslení v každém datovém bodě tohoto grafu
+@msg the design is too big to draw in every column of this graph návrh je příliš velký na vykreslení v každém sloupci tohoto grafu
+@msg the dialog it was opened over is no longer open dialog, nad kterým byl otevřen, už není otevřený
+@msg the dictionary stack overflowed zásobník slovníků přetekl
+@msg the document already has an SVG filter named {name} dokument už má filtr SVG s názvem {name}
+@msg the document has another SVG filter named {name}: use svgFilter.apply, or svgFilter.define with `replace` to edit it dokument má jiný filtr SVG s názvem {name}: použijte svgFilter.apply, nebo ho upravte pomocí svgFilter.define s `replace`
+@msg the document has no SVG filter named {name} dokument nemá žádný filtr SVG s názvem {name}
+@msg the document has no SVG filter named {r} dokument nemá žádný filtr SVG s názvem {r}
+@msg the document has no color swatches dokument nemá žádné barevné položky vzorníku
+@msg the document's swatches hold {_1} colors; the document reduction takes at most 255 vzorník dokumentu obsahuje {_1} barev; redukce podle dokumentu jich přijímá nejvýše 255
+@msg the editing data's hash has no place pro hash dat pro úpravy není místo
+@msg the entities the document declares expand to too much text entity, které dokument deklaruje, se rozvinou na příliš mnoho textu
+@msg the envelope's content has no bounds obsah obálky nemá hranice
+@msg the envelope's mesh is malformed síť obálky je poškozená
+@msg the file holds no `variableSets` element soubor neobsahuje žádný prvek `variableSets`
+@msg the file is larger than {_1} MB soubor je větší než {_1} MB
+@msg the file is too large soubor je příliš velký
+@msg the file isn't valid SVG: {e} soubor není platné SVG: {e}
+@msg the file names no plate soubor neuvádí žádný výtažek
+@msg the file was updated or encrypted already soubor už byl aktualizován nebo zašifrován
+@msg the first character of a line has no character before it to kern: it moves only up and down (dy) první znak řádku nemá před sebou znak, vůči kterému by se vyrovnával: posouvá se jen nahoru a dolů (dy)
+@msg the font {_1} is not available písmo {_1} není dostupné
+@msg the format has no file extension formát nemá příponu souboru
+@msg the function's prototype is not an object prototyp funkce není objekt
+@msg the graphic can't be read: {e} grafiku nelze přečíst: {e}
+@msg the graphic is empty grafika je prázdná
+@msg the graphic's data is damaged data grafiky jsou poškozená
+@msg the graphic's document has no artboard dokument grafiky nemá žádnou pracovní plochu
+@msg the guide is hidden or locked vodítko je skryté nebo zamknuté
+@msg the header `{cell}` for `{name}` would read back as the variable `{other}`: rename one of them záhlaví `{cell}` pro `{name}` by se zpětně načetlo jako proměnná `{other}`: jednu z nich přejmenujte
+@msg the image `{path}` isn't followed: {r} obrázek `{path}` se nenačítá: {r}
+@msg the image has no embedded pixels obraz nemá žádné vložené obrazové body
+@msg the image is empty obraz je prázdný
+@msg the image is fully transparent obraz je zcela průhledný
+@msg the image is not on an artboard; pass rect obraz není na pracovní ploše; předejte rect
+@msg the image's pixels are missing chybí obrazové body obrazu
+@msg the image's pixels aren't in the document obrazové body obrazu nejsou v dokumentu
+@msg the image's pixels can't be read: {e} obrazové body obrazu nelze přečíst: {e}
+@msg the image's transparency doesn't fit it průhlednost obrazu k němu nepasuje
+@msg the job would print more than {MAX_PAGES} pages úloha by vytiskla více než {MAX_PAGES} stránek
+@msg the job would print more than {_1} pages úloha by vytiskla více než {_1} stránek
+@msg the layers hold no art to mask vrstvy neobsahují žádnou kresbu k maskování
+@msg the layout moved rozvržení se posunulo
+@msg the library has a group `{name}` already knihovna už má skupinu `{name}`
+@msg the library has no group `{name}` knihovna nemá skupinu `{name}`
+@msg the library has no item `{item}` knihovna nemá položku `{item}`
+@msg the library has no item `{other}` knihovna nemá položku `{other}`
+@msg the library holds {MAX_GROUPS} groups already knihovna už obsahuje {MAX_GROUPS} skupin
+@msg the library holds {MAX_ITEMS} items of this kind already knihovna už obsahuje {MAX_ITEMS} položek tohoto druhu
+@msg the library's file is outside the read roots soubor knihovny je mimo kořenové složky pro čtení
+@msg the line is too long, or the paths it passes too many, to check čára je příliš dlouhá, nebo prochází přes příliš mnoho cest, takže ji nelze zkontrolovat
+@msg the method needs an XML value metoda vyžaduje hodnotu XML
+@msg the name is empty název je prázdný
+@msg the object has no fill, stroke or effect objekt nemá výplň, tah ani efekt
+@msg the object has no graphic style (pass `name`) objekt nemá grafický styl (předejte `name`)
+@msg the objects don't match the plan objekty neodpovídají plánu
+@msg the open and permissions passwords must differ heslo pro otevření a heslo pro oprávnění se musí lišit
+@msg the operand stack overflowed zásobník operandů přetekl
+@msg the page has no area stránka nemá plochu
+@msg the page's type in an unread object's box isn't in its page art text stránky v rámečku nepřečteného objektu není v kresbě jeho stránky
+@msg the paint behind the proxy is not a gradient nátěr za aktivním políčkem není přechod
+@msg the paint is not a freeform gradient (apply one with paint.editGradient {kind}) nátěr není volný přechod (použijte ho pomocí paint.editGradient {kind})
+@msg the parameters or body do not form one function parametry nebo tělo netvoří jednu funkci
+@msg the parent must be a layer or sublayer nadřazený prvek musí být vrstva nebo podvrstva
+@msg the pasted SVG filter {name} draws differently from the document's filter of that name, and no free name fits it vložený filtr SVG {name} se vykresluje jinak než filtr dokumentu s tímto názvem a nehodí se pro něj žádný volný název
+@msg the path can't be extended cestu nelze prodloužit
+@msg the path can't be inside one of the objects to attach cesta nemůže být uvnitř některého z připojovaných objektů
+@msg the path has no segments to measure cesta nemá žádné segmenty k měření
+@msg the path is empty cesta je prázdná
+@msg the path is too short to shorten that much cesta je příliš krátká na takové zkrácení
+@msg the paths are too many or too tangled to find where they cross cest je příliš mnoho nebo jsou příliš zamotané, takže nelze najít, kde se kříží
+@msg the pen table holds more than 255 pens tabulka per obsahuje více než 255 per
+@msg the picture has no objects obrázek nemá žádné objekty
+@msg the plates can't be kept výtažky nelze zachovat
+@msg the position table does not match the instructions tabulka pozic neodpovídá instrukcím
+@msg the program runs too long program běží příliš dlouho
+@msg the program uses too much memory program používá příliš mnoho paměti
+@msg the prompt's text is longer than {MAX_PROMPT_BYTES} bytes text výzvy je delší než {MAX_PROMPT_BYTES} bajtů
+@msg the radix must be between 2 and 36 základ soustavy musí být mezi 2 a 36
+@msg the rectangle must have a width and a height obdélník musí mít šířku a výšku
+@msg the rectangle needs a size and must lie on the canvas obdélník potřebuje velikost a musí ležet na plátně
+@msg the rectangle or line reaches too many paths to check obdélník nebo čára zasahuje příliš mnoho cest, takže je nelze zkontrolovat
+@msg the right side of 'in' is not an object pravá strana operátoru 'in' není objekt
+@msg the right side of 'instanceof' is not a function pravá strana operátoru 'instanceof' není funkce
+@msg the selected art has no width vybraná kresba nemá šířku
+@msg the selected art is nested more than {MAX_DEPTH} levels deep vybraná kresba je vnořena hlouběji než {MAX_DEPTH} úrovní
+@msg the selected art is too large to measure vybraná kresba je příliš velká na změření
+@msg the selection draws nothing výběr nic nevykresluje
+@msg the selection has no area výběr nemá plochu
+@msg the selection has no art to export výběr nemá žádnou kresbu k exportu
+@msg the selection has no bounds výběr nemá hranice
+@msg the selection has no graphic style výběr nemá grafický styl
+@msg the selection has no outlines to weed around výběr nemá žádné obrysy, kolem kterých by šlo vybírat
+@msg the slice would leave the canvas řez by opustil plátno
+@msg the spot colour couldn't be added přímou barvu nešlo přidat
+@msg the subpath has no ends to extend (it is closed or a single point) dílčí cesta nemá konce k prodloužení (je uzavřená nebo je to jediný bod)
+@msg the symbols are nested too deeply to expand their instances symboly jsou vnořeny příliš hluboko na rozložení jejich instancí
+@msg the text file is too large to place as type (at most {_1} MB) textový soubor je příliš velký na umístění jako text (nejvýše {_1} MB)
+@msg the text is empty text je prázdný
+@msg the text is over {_1} MB text má více než {_1} MB
+@msg the top object must be a path horní objekt musí být cesta
+@msg the trace was cancelled vektorizace byla zrušena
+@msg the trailer has no size zakončení nemá velikost
+@msg the type object is flattened to nothing textový objekt se sloučením změní v nic
+@msg the value bind was called on is not a function hodnota, na které byla zavolána bind, není funkce
+@msg the value is not a constructor hodnota není konstruktor
+@msg the value is not a function hodnota není funkce
+@msg the value is not a valid XML name hodnota není platný název XML
+@msg the value is not an XML attribute name hodnota není název atributu XML
+@msg the value is not an XML name hodnota není název XML
+@msg the value of axis `{tag}` must be a number or null hodnota osy `{tag}` musí být číslo nebo null
+@msg the variable library declares more than {MAX_LIBRARY_DATASETS} data sets knihovna proměnných deklaruje více než {MAX_LIBRARY_DATASETS} sad dat
+@msg the variable library declares more than {MAX_LIBRARY_ENTITIES} entities knihovna proměnných deklaruje více než {MAX_LIBRARY_ENTITIES} entit
+@msg the variable library declares more than {MAX_LIBRARY_VARIABLES} variables knihovna proměnných deklaruje více než {MAX_LIBRARY_VARIABLES} proměnných
+@msg the variable library declares no variables knihovna proměnných nedeklaruje žádné proměnné
+@msg the variable library is larger than {MAX_LIBRARY_BYTES} bytes knihovna proměnných je větší než {MAX_LIBRARY_BYTES} bajtů
+@msg the variable library is not UTF-8 knihovna proměnných není v UTF-8
+@msg the variable library is not well-formed XML: {e} knihovna proměnných není správně utvořené XML: {e}
+@msg the variable library nests elements more than {MAX_LIBRARY_NESTING} deep knihovna proměnných vnořuje prvky hlouběji než {MAX_LIBRARY_NESTING} úrovní
+@msg the variable library's document type declaration holds something other than entities of plain text deklarace typu dokumentu knihovny proměnných obsahuje něco jiného než entity s prostým textem
+@msg the variable library's entities expand to more than {MAX_LIBRARY_BYTES} bytes entity knihovny proměnných se rozvinou na více než {MAX_LIBRARY_BYTES} bajtů
+@msg the weeding lines cross the art's outlines too many times to measure; use fewer lines linie pro vybírání kříží obrysy kresby tolikrát, že je nelze změřit; použijte méně linií
+@msg the weeding lines pass too close to too many outline segments to measure; use fewer lines or a smaller gap linie pro vybírání procházejí příliš blízko příliš mnoha segmentů obrysu, takže je nelze změřit; použijte méně linií nebo menší mezeru
+@msg the weeding lines would be more than {max_pieces} pieces linie pro vybírání by tvořily více než {max_pieces} dílů
+@msg the window has no dock okno nemá žádný dok
+@msg the written PDF has no catalog zapsané PDF nemá katalog
+@msg the written PDF has no cross-reference table zapsané PDF nemá tabulku křížových odkazů
+@msg the written PDF's metadata can't be edited metadata zapsaného PDF nelze upravit
+@msg the {_1} plate lies outside the file výtažek {_1} leží mimo soubor
+@msg there are no colour swatches to draw nejsou žádné barevné položky vzorníku k vykreslení
+@msg there is already a design named `{name}` návrh s názvem `{name}` už existuje
+@msg there is already a design named `{to}` návrh s názvem `{to}` už existuje
+@msg there is already a toolbar named `{name}` panel nástrojů s názvem `{name}` už existuje
+@msg there is already a toolbar named `{to}` panel nástrojů s názvem `{to}` už existuje
+@msg there is no library: create one first žádná knihovna neexistuje: nejprve ji vytvořte
+@msg these paths are too degenerate for Shaper tyto cesty jsou pro Shaper příliš degenerované
+@msg this is a binary DXF file, which can't be read: save it from the CAD app as an ASCII DXF and open that toto je binární soubor DXF, který nelze přečíst: uložte ho z aplikace CAD jako ASCII DXF a otevřete ten
+@msg this warp's points can't be edited: use Reset with Mesh first body této deformace nelze upravovat: nejprve použijte Obnovit se sítí
+@msg thumbnail: no page {v} (the {what} has {count}) miniatura: stránka {v} neexistuje ({what} jich má {count})
+@msg tileRange of {what}: {e} tileRange pro {what}: {e}
+@msg title can't be empty title nesmí být prázdné
+@msg to (the new name) is required to (nový název) je povinné
+@msg to must be fill or stroke to musí být fill nebo stroke
+@msg to must be smooth or corner to musí být smooth nebo corner
+@msg toExponential() digits must be between 0 and 20 počet číslic pro toExponential() musí být mezi 0 a 20
+@msg toFixed() digits must be between 0 and 20 počet číslic pro toFixed() musí být mezi 0 a 20
+@msg toISOString is not a function toISOString není funkce
+@msg toPrecision() argument must be between 1 and 21 argument toPrecision() musí být mezi 1 a 21
+@msg toSource: a getter cannot be written as source toSource: getter nelze zapsat jako zdrojový kód
+@msg toSource: an array with properties besides its elements, a property that is not enumerable or another prototype cannot be written as source toSource: pole s vlastnostmi mimo své prvky, s vlastností, která není výčtová, nebo s jiným prototypem nelze zapsat jako zdrojový kód
+@msg toSource: an object with a property that is not enumerable, or whose prototype is not Object.prototype, cannot be written as source toSource: objekt s vlastností, která není výčtová, nebo jehož prototyp není Object.prototype, nelze zapsat jako zdrojový kód
+@msg toSource: the source is too long toSource: zdrojový kód je příliš dlouhý
+@msg toSource: the value contains itself toSource: hodnota obsahuje sama sebe
+@msg toSource: the value has too many entries toSource: hodnota má příliš mnoho položek
+@msg toSource: the value is nested too deep toSource: hodnota je vnořena příliš hluboko
+@msg toString is not a function toString není funkce
+@msg too large for a zip archive: package to a folder příliš velké pro archiv zip: zabalte do složky
+@msg too many Shaper edits příliš mnoho úprav Shaper
+@msg too many `points` (at most 100000) příliš mnoho `points` (nejvýše 100000)
+@msg too many arguments příliš mnoho argumentů
+@msg too many characters: at most {MAX_CHARS} příliš mnoho znaků: nejvýše {MAX_CHARS}
+@msg too many dots for this size: lower the frequency příliš mnoho bodů pro tuto velikost: snižte frekvenci
+@msg too many filters over one file příliš mnoho filtrů nad jedním souborem
+@msg too many overlapping paths for Shaper příliš mnoho překrývajících se cest pro Shaper
+@msg too many quantifiers příliš mnoho kvantifikátorů
+@msg too many saved clips příliš mnoho uložených ořezů
+@msg too many saved graphics states příliš mnoho uložených grafických stavů
+@msg toolbar (a toolbar's name) must be a string toolbar (název panelu nástrojů) musí být řetězec
+@msg toolbar name can't be empty název panelu nástrojů nesmí být prázdný
+@msg transparentImages {v}: "flatten" (the default) or "omit" transparentImages {v}: "flatten" (výchozí) nebo "omit"
+@msg two saved selections would be named `{_1}` dva uložené výběry by se jmenovaly `{_1}`
+@msg type dimension cannot be represented with this text transform rozměr textu nelze vyjádřit touto transformací textu
+@msg type dimension must be finite rozměr textu musí být konečný
+@msg unexpected resources neočekávané prostředky
+@msg unknown CMYK profile `{name}` neznámý profil CMYK `{name}`
+@msg unknown File Info field `{k}` neznámé pole informací o souboru `{k}`
+@msg unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {_1}, as `salt=2`) neznámá funkce OpenType `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt; předpona - funkci vypne; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom a aalt přijímají číslo alternativy jako `tag=N` s N od 0 do {_1}, například `salt=2`)
+@msg unknown OpenType feature `{t}` in featureChanges (the tags `features` takes) neznámá funkce OpenType `{t}` ve featureChanges (značky, které přijímá `features`)
+@msg unknown PDF preset `{name}` (presets: {_1}) neznámé přednastavení PDF `{name}` (přednastavení: {_1})
+@msg unknown Warp style neznámý styl deformace
+@msg unknown alignToPath `{a}` neznámé alignToPath `{a}`
+@msg unknown arrowhead {v} neznámá šipka {v}
+@msg unknown artboardLayout key `{k}` (layout, columns, spacing, rightToLeft) neznámý klíč artboardLayout `{k}` (layout, columns, spacing, rightToLeft)
+@msg unknown attribute `{attribute}` neznámý atribut `{attribute}`
+@msg unknown attribute `{k}` neznámý atribut `{k}`
+@msg unknown axis `{other}` (vertical|horizontal|free) neznámá osa `{other}` (vertical|horizontal|free)
+@msg unknown blend mode `{b}` neznámý režim míchání `{b}`
+@msg unknown blend mode {b} neznámý režim míchání {b}
+@msg unknown brush type `{o}` neznámý typ štětce `{o}`
+@msg unknown category `{c}` neznámá kategorie `{c}`
+@msg unknown category `{c}` ({_1}) neznámá kategorie `{c}` ({_1})
+@msg unknown character `{c}` neznámý znak `{c}`
+@msg unknown character style number field neznámé číselné pole stylu znaků
+@msg unknown dither `{d}` (diffusion, pattern, noise or none) neznámý rozklad barev `{d}` (diffusion, pattern, noise nebo none)
+@msg unknown effect `{e}` neznámý efekt `{e}`
+@msg unknown end {e} neznámý konec {e}
+@msg unknown flattenerPreset `{name}` (High Resolution, Medium Resolution, Low Resolution or a saved one: see flattener.presets.list) neznámé flattenerPreset `{name}` (High Resolution, Medium Resolution, Low Resolution nebo uložené přednastavení: viz flattener.presets.list)
+@msg unknown format `{f}` (vcswatches, gpl, ase or css) neznámý formát `{f}` (vcswatches, gpl, ase nebo css)
+@msg unknown from {f} neznámé from {f}
+@msg unknown graph type `{t}` neznámý typ grafu `{t}`
+@msg unknown grid colours `{name}` (one of {_1}) neznámé barvy mřížky `{name}` (jedna z hodnot {_1})
+@msg unknown gridType `{t}` neznámé gridType `{t}`
+@msg unknown highlight `{v}` neznámé zvýraznění `{v}`
+@msg unknown intent `{i}` neznámý záměr `{i}`
+@msg unknown issue kind {id} neznámý druh problému {id}
+@msg unknown issue kind {v} neznámý druh problému {v}
+@msg unknown knockout state {k} neznámý stav vykrojení {k}
+@msg unknown language `{language}` (one of {_1}) neznámý jazyk `{language}` (jeden z {_1})
+@msg unknown language `{l}` (one of {_1}) neznámý jazyk `{l}` (jeden z {_1})
+@msg unknown method `{m}` ({_1}) neznámá metoda `{m}` ({_1})
+@msg unknown mode `{other}` (gray, rgb, hsb, lab, cmyk or web) neznámý režim `{other}` (gray, rgb, hsb, lab, cmyk nebo web)
+@msg unknown mode `{other}` (mono|cmyk) neznámý režim `{other}` (mono|cmyk)
+@msg unknown mode `{other}` (rgb|cmyk|gray|global) neznámý režim `{other}` (rgb|cmyk|gray|global)
+@msg unknown mode `{other}` (set, add, subtract) neznámý režim `{other}` (set, add, subtract)
+@msg unknown mode `{o}` neznámý režim `{o}`
+@msg unknown mode {m} neznámý režim {m}
+@msg unknown model `{other}` neznámý model `{other}`
+@msg unknown model `{other}` (keep, new or document) neznámý model `{other}` (keep, new nebo document)
+@msg unknown op {o} neznámé op {o}
+@msg unknown order `{o}`: toggle, front, forward, backward or back neznámé pořadí `{o}`: toggle, front, forward, backward nebo back
+@msg unknown overprints `{v}` (preserve, simulate or discard) neznámé overprints `{v}` (preserve, simulate nebo discard)
+@msg unknown panel neznámý panel
+@msg unknown panel `{panel}` neznámý panel `{panel}`
+@msg unknown parameter `{key}` neznámý parametr `{key}`
+@msg unknown parameter `{within}settings.{format}`; {why} neznámý parametr `{within}settings.{format}`; {why}
+@msg unknown parameter `{within}{key}`; {why} neznámý parametr `{within}{key}`; {why}
+@msg unknown pieLegend `{v}` (none, standard or wedges) neznámé pieLegend `{v}` (none, standard nebo wedges)
+@msg unknown piePosition `{v}` (even, ratio or stacked) neznámé piePosition `{v}` (even, ratio nebo stacked)
+@msg unknown pieSort `{v}` (none, all or first) neznámé pieSort `{v}` (none, all nebo first)
+@msg unknown plate `{n}` neznámý výtažek `{n}`
+@msg unknown preference `{k}` neznámá předvolba `{k}`
+@msg unknown profile {id} (see stroke.widthProfile.list) neznámý profil {id} (viz stroke.widthProfile.list)
+@msg unknown proof target `{t}` neznámý cíl nátisku `{t}`
+@msg unknown quotes key `{k}` (double, single) neznámý klíč quotes `{k}` (double, single)
+@msg unknown reduction `{reduction}` (selective, perceptual, adaptive, web, gray, blackWhite, uniform or document) neznámá redukce `{reduction}` (selective, perceptual, adaptive, web, gray, blackWhite, uniform nebo document)
+@msg unknown resolution `{v}` neznámé rozlišení `{v}`
+@msg unknown rule `{rule}` ({_1}) neznámé pravidlo `{rule}` ({_1})
+@msg unknown rule `{r}` (see color.harmony) neznámé pravidlo `{r}` (viz color.harmony)
+@msg unknown setting `{k}` neznámé nastavení `{k}`
+@msg unknown shape `{v}` (circle|ellipse|square|diamond|line) neznámý tvar `{v}` (circle|ellipse|square|diamond|line)
+@msg unknown spacing `{o}` (smooth|steps|distance) neznámé rozestupy `{o}` (smooth|steps|distance)
+@msg unknown tileType `{t}` ({_1}) neznámé tileType `{t}` ({_1})
+@msg unknown units neznámé jednotky
+@msg unknown units `{u}` neznámé jednotky `{u}`
+@msg unknown value axis `{v}` (left, right or both) neznámá osa hodnot `{v}` (left, right nebo both)
+@msg unknown variation `{v}` (tintsShades|warmCool|vividMuted) neznámá variace `{v}` (tintsShades|warmCool|vividMuted)
+@msg unknown warp style `{s}` neznámý styl deformace `{s}`
+@msg unknown {key} `{v}` (none, short or full) neznámé {key} `{v}` (none, short nebo full)
+@msg unknown {key} profile `{n}` neznámý profil {key} `{n}`
+@msg unknown {k} key `{key}` (size, position) neznámý klíč {k} `{key}` (size, position)
+@msg unmatched ')' nespárovaná ')'
+@msg unreadable object nečitelný objekt
+@msg unsafe destination file name nebezpečný název cílového souboru
+@msg unterminated character class neukončená třída znaků
+@msg unterminated group neukončená skupina
+@msg use paths or compound paths použijte cesty nebo složené cesty
+@msg useArtboards must be true or false, not {v} useArtboards musí být true nebo false, ne {v}
+@msg value for `{var}` must match its variable kind hodnota pro `{var}` musí odpovídat druhu její proměnné
+@msg variable `{_1}` is already defined proměnná `{_1}` je už definována
+@msg variable `{name}` has no `trait` proměnná `{name}` nemá `trait`
+@msg variable `{name}` is already defined proměnná `{name}` je už definována
+@msg variable `{name}` is declared twice proměnná `{name}` je deklarována dvakrát
+@msg variable `{to}` already exists proměnná `{to}` už existuje
+@msg view is required view je povinné
+@msg view must be one of {_1} view musí být jedna z hodnot {_1}
+@msg warpStyle can only edit an applied Warp effect warpStyle může upravit jen použitý efekt Deformace
+@msg width and height must be more than 0 and at most {limit} width a height musí být více než 0 a nejvýše {limit}
+@msg width factors must be finite koeficienty šířky musí být konečné
+@msg width must be positive width musí být kladné
+@msg width out of range šířka je mimo rozsah
+@msg width/height must be > 0 width/height musí být > 0
+@msg wordGap and lineGap need area type in the selection wordGap a lineGap vyžadují ve výběru text v oblasti
+@msg x and y must be finite numbers x a y musí být konečná čísla
+@msg x and y must be given together x a y je nutné zadat společně
+@msg xpath needs its variables as an object xpath vyžaduje své proměnné jako objekt
+@msg xpath needs the expression as a string xpath vyžaduje výraz jako řetězec
+@msg zone needs an anchor: dock beside a panel with anchor, or in a new dock column with side (Start or End) zone vyžaduje kotvu: ukotvěte vedle panelu pomocí anchor, nebo do nového sloupce doku pomocí side (Start nebo End)
+@msg zoom must be positive zoom musí být kladné
+@msg {C}: no effect at index {list} {C}: na indexu {list} není žádný efekt
+@msg {DEFAULT_PRESET} is protected and stays {DEFAULT_PRESET} je chráněné a zůstává
+@msg {DEFAULT_PRESET} is protected: save the settings under another name {DEFAULT_PRESET} je chráněné: uložte nastavení pod jiným názvem
+@msg {GRANT_NOT_REMOVED}, and its settings and cache can't be moved back from {_1} to {_2}: {e} {GRANT_NOT_REMOVED} a jeho nastavení a mezipaměť nelze přesunout zpět z {_1} do {_2}: {e}
+@msg {LABEL} produced an empty result {LABEL} vytvořilo prázdný výsledek
+@msg {_1} MB of document, at most {_2} MB {_1} MB dokumentu, nejvýše {_2} MB
+@msg {_1} MB of images, at most {_2} MB {_1} MB obrázků, nejvýše {_2} MB
+@msg {_1} bytes of RGBA data for a {width} × {height} image {_1} bajtů dat RGBA pro obraz {width} × {height}
+@msg {_1} can't be read, or changed since it was read: relink or update it first {_1} nelze přečíst, nebo se od přečtení změnil: nejprve ho znovu propojte nebo aktualizujte
+@msg {_1} can't go into {into} {_1} nelze vložit do {into}
+@msg {_1} can't limit printing to low resolution: choose PDF 1.4 or later, or allow high-resolution printing {_1} nemůže omezit tisk na nízké rozlišení: zvolte PDF 1.4 nebo novější, nebo povolte tisk ve vysokém rozlišení
+@msg {_1} files can't be opened yet soubory {_1} zatím nelze otevřít
+@msg {_1} files can't be password-protected: remove the passwords or choose no standard soubory {_1} nelze chránit heslem: odeberte hesla nebo nezvolte žádný standard
+@msg {_1} files can't be {_2} soubory {_1} nemohou být {_2}
+@msg {_1} files can't carry editing data: turn off preserveEditing soubory {_1} nemohou obsahovat data pro úpravy: vypněte preserveEditing
+@msg {_1} files can't have PDF layers: turn off createLayers soubory {_1} nemohou mít vrstvy PDF: vypněte createLayers
+@msg {_1} files need a creation date, and this platform has no clock to give one soubory {_1} vyžadují datum vytvoření a tato platforma nemá hodiny, které by ho poskytly
+@msg {_1} is not a folder: {e} {_1} není složka: {e}
+@msg {_1} isn't a path {_1} není cesta
+@msg {_1} options: {e} volby {_1}: {e}
+@msg {_1} paints nothing to rasterize {_1} nevykresluje nic, co by šlo rastrovat
+@msg {_1} passwords can have up to {MAX_LEGACY_PASSWORD} ASCII characters (PDF 1.7 and later take any) hesla {_1} mohou mít nejvýše {MAX_LEGACY_PASSWORD} znaků ASCII (PDF 1.7 a novější přijímají libovolné znaky)
+@msg {_1} with linked images or an image map writes several files: use document.export {_1} s propojenými obrázky nebo obrazovou mapou zapisuje několik souborů: použijte document.export
+@msg {_1} writes one file per artboard here: use document.export, or name one artboard {_1} zde zapisuje jeden soubor na každou pracovní plochu: použijte document.export, nebo uveďte jednu pracovní plochu
+@msg {_1} × {_2} pixels is too large for {_3} (at most {max} pixels a side): lower the scale {_1} × {_2} obrazových bodů je pro {_3} příliš mnoho (nejvýše {max} obrazových bodů na stranu): zmenšete měřítko
+@msg {_1}: belongs to another account {_1}: patří jinému účtu
+@msg {_1}: not a folder of its own {_1}: nejde o jeho vlastní složku
+@msg {_1}: not a plain file {_1}: nejde o obyčejný soubor
+@msg {_1}: {_2} and {_3} are in the way {_1}: překáží {_2} a {_3}
+@msg {code}: {0} {code}: {0}
+@msg {dest}: {e} {dest}: {e}
+@msg {dir}: {e} {dir}: {e}
+@msg {doc} has no folder {doc} nemá složku
+@msg {file} isn't linked {file} není propojený
+@msg {file} names more than one linked JavaScript file {file} uvádí více než jeden propojený soubor JavaScript
+@msg {folder} is not a package folder {folder} není složka balíčku
+@msg {folder}: more than {max} files and folders {folder}: více než {max} souborů a složek
+@msg {folder}: {e} {folder}: {e}
+@msg {g} is not a Live Paint group {g} není skupina živé malby
+@msg {id} is not a slice {id} není řez
+@msg {key} must be 0–{MAX_ADD_AROUND} pt {key} musí být 0–{MAX_ADD_AROUND} pt
+@msg {key} must be [x,y] {key} musí být [x,y]
+@msg {key} must be a percentage {min}..100 {key} musí být procento {min}..100
+@msg {key} must be a positive number, not {n} {key} musí být kladné číslo, ne {n}
+@msg {key} must be a positive number, not {v} {key} musí být kladné číslo, ne {v}
+@msg {key} must be one of {_1} {key} musí být jedna z hodnot {_1}
+@msg {key} must be {_1}, got {s} {key} musí být {_1}, zadáno {s}
+@msg {key} must name a floating panel {key} musí uvádět plovoucí panel
+@msg {key} must name a panel in another group {key} musí uvádět panel v jiné skupině
+@msg {key} must name a panel that shows {key} musí uvádět panel, který je zobrazen
+@msg {key} must name a registered panel {key} musí uvádět registrovaný panel
+@msg {kind} files have no transparency, and the file would have {what}: flatten it first (object.flattenTransparency) soubory {kind} nemají průhlednost a soubor by obsahoval {what}: nejprve ji slučte (object.flattenTransparency)
+@msg {kind} images can't be opened (see document.formats) obrázky {kind} nelze otevřít (viz document.formats)
+@msg {k} `{v}` is not one of the choices {k} `{v}` není jedna z nabízených voleb
+@msg {k} has control characters{_1} {k} obsahuje řídicí znaky{_1}
+@msg {k} is too long (at most {MAX_TEXT} characters) {k} je příliš dlouhé (nejvýše {MAX_TEXT} znaků)
+@msg {k} must be > 0 {k} musí být > 0
+@msg {k} must be a number (pt) or a length such as "210 mm" {k} musí být číslo (pt) nebo délka jako "210 mm"
+@msg {k} must be a number from {min} to {max} {k} musí být číslo od {min} do {max}
+@msg {k} must be a number or "auto" {k} musí být číslo nebo "auto"
+@msg {k} must be a string {k} musí být řetězec
+@msg {k} must be text {k} musí být text
+@msg {k} must be true or false {k} musí být true nebo false
+@msg {k} must be {{size?, position?}} {k} musí být {{size?, position?}}
+@msg {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 {k}: cesta vlastní šipky potřebuje plochu k vyplnění a nejvýše {most} kotevních bodů a {most} dílčích cest, každý kotevní bod nejdále {far} jednotek hrotu od její špičky
+@msg {k}: bad custom arrowhead path: {e} {k}: chybná cesta vlastní šipky: {e}
+@msg {k}: bad custom arrowhead: {e} {k}: chybná vlastní šipka: {e}
+@msg {k}: can't read `{s}` as a length {k}: `{s}` nelze přečíst jako délku
+@msg {k}: custom must be a name {k}: custom musí být název
+@msg {label} must be a number at most {max} {label} musí být číslo nejvýše {max}
+@msg {label} must be a number from -1296 to 1296 {label} musí být číslo od -1296 do 1296
+@msg {label} must be a number from 1 to 10000 {label} musí být číslo od 1 do 10000
+@msg {name} `{s}`: {ids} {name} `{s}`: {ids}
+@msg {name} is built in and can't be deleted {name} je vestavěné a nelze ho odstranit
+@msg {name} must be a number {name} musí být číslo
+@msg {name} must be {lo}–{hi}{unit} (got {v}) {name} musí být {lo}–{hi}{unit} (zadáno {v})
+@msg {name}: CRC or size mismatch {name}: nesouhlasí CRC nebo velikost
+@msg {name}: only this Affinity file's embedded preview could be read; use File › Open to see it with its warning, or export SVG or PDF from Affinity before placing {name}: z tohoto souboru Affinity šlo přečíst jen vložený náhled; pomocí Soubor › Otevřít ho zobrazíte s varováním, nebo před umístěním exportujte SVG či PDF z Affinity
+@msg {name}: {e} {name}: {e}
+@msg {native}; its embedded preview can't be read either ({e}) {native}; jeho vložený náhled také nelze přečíst ({e})
+@msg {path}: copy takes a DCS file only when its plates are inside it; relink to it where its plate files are {path}: copy přijímá soubor DCS, jen když jsou jeho výtažky uvnitř něj; propojte ho znovu tam, kde jsou soubory jeho výtažků
+@msg {path}: larger than {_1} MB {path}: větší než {_1} MB
+@msg {shown} is not an XPath 1.0 function {shown} není funkce XPath 1.0
+@msg {std} files are CMYK, and the file would have colours in {_1} soubory {std} jsou CMYK a soubor by obsahoval barvy v {_1}
+@msg {std} files embed their fonts, and the file would have one that isn't soubory {std} vkládají svá písma a soubor by obsahoval písmo, které vložené není
+@msg {target} is the file this document was read from, and reading it left things out ({what}): writing over it would lose them for good. Write another file instead, or pass acknowledgeLoss: true to replace it anyway {target} je soubor, ze kterého byl tento dokument načten, a při čtení bylo něco vynecháno ({what}): přepsáním by se to nenávratně ztratilo. Zapište místo toho jiný soubor, nebo předejte acknowledgeLoss: true, chcete-li ho přesto nahradit
+@msg {ty} brushes need selected art (or params.{k}) štětce typu {ty} vyžadují vybranou kresbu (nebo params.{k})
+@msg {v} is not an asset id {v} není ID prvku
+@msg {v} names no layer or sublayer {v} neuvádí žádnou vrstvu ani podvrstvu
+@msg {what} can't be intertwined {what} nelze proplést
+@msg {what} doesn't fit the paper with its bleed and marks {what} se na papír se spadávkou a značkami nevejde
+@msg {what} is not a function {what} není funkce
+@msg {what} would print more than {MAX_COPIES_PER_SHEET} copies on a sheet: scale it up {what} by vytisklo více než {MAX_COPIES_PER_SHEET} kopií na arch: zvětšete měřítko
+@msg {what} would print on more than {MAX_TILES} tiles: scale it down {what} by se vytisklo na více než {MAX_TILES} dlaždic: zmenšete měřítko
+@msg {what} {i} out of range {what} {i} je mimo rozsah
+@msg `{key}` must be from 0.01 to 1000 pt `{key}` musí být od 0.01 do 1000 pt
+@msg select the banner, or mark every artboard vyberte banner, nebo označte každou pracovní plochu
+@msg the banner has no width or height banner nemá šířku nebo výšku
+@msg the grommet inset is too large for the banner odstup průchodek od okraje je pro banner příliš velký
+@msg the hem is too deep for the banner lem je pro banner příliš hluboký
+@msg the spacing makes more than {MAX_GROMMETS} grommets při tomto rozestupu vznikne více než {MAX_GROMMETS} průchodek
+@msg there is no banner to mark není žádný banner k označení
+@msg turn on at least one side zapněte alespoň jednu stranu
+@msg with no hem and no grommets there is nothing to mark bez lemu a bez průchodek není co označit
+@msg {saving} failed: {e} {saving} se nezdařilo: {e}
diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv
index 433c6af3d6..5fad269299 100644
--- a/crates/ui-egui/src/i18n/da.tsv
+++ b/crates/ui-egui/src/i18n/da.tsv
@@ -924,6 +924,7 @@
VectorCraft Title Bar on Every Desktop VectorCrafts titellinje på alle skriveborde
The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Miljøvariablen VECTORCRAFT_TITLEBAR vælger titellinjen, så længe den er angivet.
System Title Bar takes precedence over this option while it is on. Systemtitellinje har forrang for denne indstilling, så længe den er slået til.
+ This desktop draws the window's title bar with this option on or off while VectorCraft Title Bar on Every Desktop is off. Så længe VectorCrafts titellinje på alle skriveborde er slået fra, tegner dette skrivebord vinduets titellinje, uanset om denne indstilling er slået til eller fra.
Exit Afslut
Distort Forvræng
Diffuse Glow… Diffus glød…
@@ -4795,11 +4796,11 @@ imageTrace Outline Kontur
@msg unknown effect `{id}` ukendt effekt `{id}`
@msg unknown format ukendt format
@msg unknown format `{f}` (Save writes {_1}) ukendt format `{f}` (Gem skriver {_1})
-@msg unknown freeform mode `{m}` (points, lines) ukendt freeform-tilstand `{m}` (punkter, linjer)
-@msg unknown gradient kind `{k}` (linear, radial, conic, diamond, freeform) ukendt gradienttype `{k}` (lineær, radial, konisk, diamant, freeform)
+@msg unknown freeform mode `{m}` (points, lines) ukendt freeform-tilstand `{m}` (points, lines)
+@msg unknown gradient kind `{k}` (linear, radial, conic, diamond, freeform) ukendt gradienttype `{k}` (linear, radial, conic, diamond, freeform)
@msg unknown highlight `{h}` ukendt fremhævning `{h}`
@msg unknown option `{path}.{k}` ukendt indstilling `{path}.{k}`
-@msg unknown overprints `{o}` (preserve, simulate or discard) ukendt overprint `{o}` (bevar, simulér eller forkast)
+@msg unknown overprints `{o}` (preserve, simulate or discard) ukendt overprint `{o}` (preserve, simulate eller discard)
@msg unknown panel `{raw}` ukendt panel `{raw}`
@msg unknown preference `{key}` ukendt præference `{key}`
@msg Preferences imported. Præferencerne er importeret.
@@ -4817,10 +4818,10 @@ imageTrace Outline Kontur
@msg `{key}` must be an object `{key}` skal være et objekt
@msg `prefs.import` runs on its own, not as a batch step `prefs.import` køres alene, ikke som et trin i en batch
@msg unknown preset (one of {_1}) ukendt forudindstilling (én af {_1})
-@msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) ukendt forudindstilling `{name}` (høj, mellem, lav eller en gemt: se flattener.presets.list)
+@msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) ukendt forudindstilling `{name}` (high, medium, low eller en gemt: se flattener.presets.list)
@msg unknown preset `{name}` (see flattener.presets.list) ukendt forudindstilling `{name}` (se flattener.presets.list)
@msg unknown preset `{name}` (see webExport.presets.list) ukendt forudindstilling `{name}` (se webExport.presets.list)
-@msg unknown side `{other}` (left|right|top|bottom) ukendt side `{other}` (venstre|højre|top|bund)
+@msg unknown side `{other}` (left|right|top|bottom) ukendt side `{other}` (left|right|top|bottom)
@msg unknown tool `{tool}` ukendt værktøj `{tool}`
@msg unknown tool `{t}` ukendt værktøj `{t}`
@msg unknown tool or command `{key}` ukendt værktøj eller kommando `{key}`
@@ -6073,6 +6074,8 @@ axis Y Axis Y-akse
Action Options… Indstillinger for handling…
Action Options Indstillinger for handling
Playback Options… Afspilningsindstillinger…
+ Button Mode Knaptilstand
+ No actions. Ingen handlinger.
Insert Stop… Indsæt stop…
Insert Stop Indsæt stop
Record Stop Optag stop
diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv
index 951f6f9c37..e33ed2a7a9 100644
--- a/crates/ui-egui/src/i18n/de.tsv
+++ b/crates/ui-egui/src/i18n/de.tsv
@@ -5219,6 +5219,7 @@ theme Appearance Erscheinungsbild
VectorCraft Title Bar on Every Desktop VectorCraft-Titelleiste auf jedem Desktop
The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Solange die Umgebungsvariable VECTORCRAFT_TITLEBAR gesetzt ist, bestimmt sie die Titelleiste.
System Title Bar takes precedence over this option while it is on. Solange Systemtitelleiste eingeschaltet ist, hat sie Vorrang vor dieser Option.
+ This desktop draws the window's title bar with this option on or off while VectorCraft Title Bar on Every Desktop is off. Solange VectorCraft-Titelleiste auf jedem Desktop ausgeschaltet ist, zeichnet dieser Desktop die Titelleiste des Fensters, ob diese Option ein- oder ausgeschaltet ist.
# Brush Options: Scatter, Art and Pattern brushes.
Scatter Brush Options Optionen für Spezialpinsel
Art Brush Options Optionen für Bildpinsel
@@ -6192,6 +6193,8 @@ axis Y Axis Y-Achse
Action Options… Aktionsoptionen…
Action Options Aktionsoptionen
Playback Options… Wiedergabeoptionen…
+ Button Mode Schaltflächenmodus
+ No actions. Keine Aktionen.
Insert Stop… Stopp einfügen…
Insert Stop Stopp einfügen
Record Stop Stopp aufzeichnen
diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv
index 62e3adc491..9ff2eae9a2 100644
--- a/crates/ui-egui/src/i18n/el.tsv
+++ b/crates/ui-egui/src/i18n/el.tsv
@@ -5234,6 +5234,7 @@ digits Malayalam Μαλαγιάλαμ
VectorCraft Title Bar on Every Desktop Γραμμή τίτλου του VectorCraft σε κάθε επιφάνεια εργασίας
The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Όσο είναι ορισμένη, η μεταβλητή περιβάλλοντος VECTORCRAFT_TITLEBAR επιλέγει τη γραμμή τίτλου.
System Title Bar takes precedence over this option while it is on. Όσο είναι ενεργή, η Γραμμή τίτλου συστήματος υπερισχύει αυτής της επιλογής.
+ This desktop draws the window's title bar with this option on or off while VectorCraft Title Bar on Every Desktop is off. Όσο η Γραμμή τίτλου του VectorCraft σε κάθε επιφάνεια εργασίας είναι ανενεργή, αυτή η επιφάνεια εργασίας σχεδιάζει τη γραμμή τίτλου του παραθύρου, είτε αυτή η επιλογή είναι ενεργή είτε όχι.
Value Axis Άξονας τιμών
On Left Side Στην αριστερή πλευρά
On Right Side Στη δεξιά πλευρά
@@ -6552,6 +6553,8 @@ axis Y Axis Άξονας Y
Action Options… Επιλογές ενέργειας…
Action Options Επιλογές ενέργειας
Playback Options… Επιλογές αναπαραγωγής…
+ Button Mode Λειτουργία κουμπιών
+ No actions. Δεν υπάρχουν ενέργειες.
Insert Stop… Εισαγωγή διακοπής…
Insert Stop Εισαγωγή διακοπής
Record Stop Καταγραφή διακοπής
diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv
index d1c6aa53af..e51ba240a8 100644
--- a/crates/ui-egui/src/i18n/es.tsv
+++ b/crates/ui-egui/src/i18n/es.tsv
@@ -5323,6 +5323,7 @@ axis Both Ambos
VectorCraft Title Bar on Every Desktop Barra de título de VectorCraft en todos los escritorios
The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Mientras está definida, la variable de entorno VECTORCRAFT_TITLEBAR elige la barra de título.
System Title Bar takes precedence over this option while it is on. Mientras está activada, Barra de título del sistema tiene prioridad sobre esta opción.
+ This desktop draws the window's title bar with this option on or off while VectorCraft Title Bar on Every Desktop is off. Mientras Barra de título de VectorCraft en todos los escritorios está desactivada, este escritorio dibuja la barra de título de la ventana tanto si esta opción está activada como si no.
Value Axis Eje de valores
On Left Side En el lado izquierdo
On Right Side En el lado derecho
@@ -6543,6 +6544,8 @@ axis Y Axis Eje Y
Action Options… Opciones de acción…
Action Options Opciones de acción
Playback Options… Opciones de reproducción…
+ Button Mode Modo de botones
+ No actions. No hay acciones.
Insert Stop… Insertar parada…
Insert Stop Insertar parada
Record Stop Grabar parada
diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv
index 45e47fd355..36f4c9f5a9 100644
--- a/crates/ui-egui/src/i18n/fi.tsv
+++ b/crates/ui-egui/src/i18n/fi.tsv
@@ -985,6 +985,7 @@
VectorCraft Title Bar on Every Desktop VectorCraftin otsikkorivi kaikissa työpöytäympäristöissä
The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Ympäristömuuttuja VECTORCRAFT_TITLEBAR valitsee otsikkorivin niin kauan kuin se on asetettu.
System Title Bar takes precedence over this option while it is on. Järjestelmän otsikkorivi ohittaa tämän asetuksen niin kauan kuin se on käytössä.
+ This desktop draws the window's title bar with this option on or off while VectorCraft Title Bar on Every Desktop is off. Niin kauan kuin VectorCraftin otsikkorivi kaikissa työpöytäympäristöissä ei ole käytössä, tämä työpöytäympäristö piirtää ikkunan otsikkorivin, oli tämä asetus käytössä tai ei.
Exit Lopeta
Distort Vääristä
Diffuse Glow… Hajautettu hehku…
@@ -6630,6 +6631,8 @@ axis Y Axis Y-akseli
Action Options… Toiminnon asetukset…
Action Options Toiminnon asetukset
Playback Options… Toistoasetukset…
+ Button Mode Painiketila
+ No actions. Ei toimintoja.
Insert Stop… Lisää pysäytys…
Insert Stop Lisää pysäytys
Record Stop Nauhoita pysäytys
diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv
index 057dce6872..a3d1c9b785 100644
--- a/crates/ui-egui/src/i18n/fr.tsv
+++ b/crates/ui-egui/src/i18n/fr.tsv
@@ -5331,6 +5331,7 @@ effect Soft Doux
VectorCraft Title Bar on Every Desktop Barre de titre de VectorCraft sur tous les bureaux
The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Tant qu’elle est définie, la variable d’environnement VECTORCRAFT_TITLEBAR choisit la barre de titre.
System Title Bar takes precedence over this option while it is on. Tant qu’elle est activée, Barre de titre du système a priorité sur cette option.
+ This desktop draws the window's title bar with this option on or off while VectorCraft Title Bar on Every Desktop is off. Tant que Barre de titre de VectorCraft sur tous les bureaux est désactivée, ce bureau dessine la barre de titre de la fenêtre, que cette option soit activée ou non.
Value Axis Axe des valeurs
On Left Side À gauche
On Right Side À droite
@@ -6595,6 +6596,8 @@ axis Y Axis Axe Y
Action Options… Options de script…
Action Options Options de script
Playback Options… Options d’exécution…
+ Button Mode Mode Bouton
+ No actions. Aucun script.
Insert Stop… Insérer un arrêt…
Insert Stop Insérer un arrêt
Record Stop Enregistrer un arrêt
diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv
index 9d056e085a..dff4c3503c 100644
--- a/crates/ui-egui/src/i18n/hi.tsv
+++ b/crates/ui-egui/src/i18n/hi.tsv
@@ -3560,6 +3560,8 @@ antiAlias Sharp शार्प
@msg the action set would be larger than 8 MiB एक्शन सेट 8 MiB से बड़ा हो जाएगा
Playback Options प्लेबैक ऑप्शन्स
Playback Options… प्लेबैक ऑप्शन्स…
+ Button Mode बटन मोड
+ No actions. कोई कार्रवाई नहीं।
Plug-in Info प्लगइन जानकारी
Plug-in… प्लग-इन…
Plug-ins प्लग-इन्स
@@ -4997,6 +4999,7 @@ antiAlias Sharp शार्प
VectorCraft Title Bar on Every Desktop हर डेस्कटॉप पर VectorCraft टाइटल बार
The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. जब तक एनवायरनमेंट वेरिएबल VECTORCRAFT_TITLEBAR सेट है, टाइटल बार वही चुनता है।
System Title Bar takes precedence over this option while it is on. जब तक सिस्टम टाइटल बार चालू है, उसे इस विकल्प पर प्राथमिकता मिलती है।
+ This desktop draws the window's title bar with this option on or off while VectorCraft Title Bar on Every Desktop is off. जब तक हर डेस्कटॉप पर VectorCraft टाइटल बार बंद है, यह डेस्कटॉप विंडो का टाइटल बार बनाता है, चाहे यह विकल्प चालू हो या बंद।
Tab Tab
Tab Stops टैब स्टॉप्स
Table तालिका
@@ -5926,7 +5929,7 @@ antiAlias Sharp शार्प
@msg `{key}` is longer than {MAX_NAME} characters `{key}` {MAX_NAME} अक्षरों से अधिक लंबा है
@msg `{key}` is out of range `{key}` रेंज से बाहर है
@msg `{key}` must be [min, desired, max] or {{min?, desired?, max?}}, in percent `{key}` को प्रतिशत में [min, desired, max] या {{min?, desired?, max?}} होना चाहिए
-@msg `{key}` must be [x, y, width, height]: four finite numbers, width and height not negative `{key}` होना चाहिए [x, y, चौड़ाई, ऊंचाई]: चार परिमित संख्या, चौड़ाई और ऊंचाई नकारात्मक नहीं है
+@msg `{key}` must be [x, y, width, height]: four finite numbers, width and height not negative `{key}` होना चाहिए [x, y, width, height]: चार परिमित संख्या, चौड़ाई और ऊंचाई नकारात्मक नहीं है
@msg `{key}` must be [x, y] `{key}` होना चाहिए [x, y]
@msg `{key}` must be `auto` or a language code such as `en` or `zh-hant` `{key}` को `auto` या `en` या `zh-hant` जैसे भाषा कोड होना चाहिए।
@msg `{key}` must be 0..{max} `{key}` होना चाहिए 0.{max}
@@ -6011,7 +6014,7 @@ antiAlias Sharp शार्प
@msg `a` and `b` must be two different paths `a` और `b` को दो अलग-अलग पाथ होना चाहिए
@msg `align` is start, center or end `align` शुरू, केंद्र या अंत है
@msg `align` must be "left", "center" or "right", not {other} `align` होना चाहिए "left", "center" या "right", नहीं {other}
-@msg `anchors` must be an array of [subpath, anchor] pairs `anchors` को [subpath, एंकर] जोड़े की एक सारणी होना चाहिए
+@msg `anchors` must be an array of [subpath, anchor] pairs `anchors` को [subpath, anchor] जोड़े की एक सारणी होना चाहिए
@msg `anchors` must list anchor indices (0, 1, 2…), not {k} `anchors` में एंकर इंडेक्स (0, 1, 2…) की सूची होनी चाहिए, {k} नहीं
@msg `anchors` must list anchor indices (0, 1, 2…), not {v} `anchors` में एंकर इंडेक्स (0, 1, 2…) की सूची होनी चाहिए, {v} नहीं
@msg `antiAlias` must be "none", "sharp", "crisp" or "strong" `antiAlias` "none", "sharp", "crisp" या "strong" होना चाहिए
@@ -6397,7 +6400,7 @@ antiAlias Sharp शार्प
@msg area type in frames too short for a line of the font it shows in got taller frames ({_1}): their type wouldn't show otherwise अपने फ़ॉन्ट की एक लाइन के लिए बहुत छोटे फ़्रेम वाले एरिया टाइप को लंबे फ़्रेम दिए गए ({_1}): वरना उनका टाइप नहीं दिखता
@msg area type needs a path that encloses an area एरिया टाइप को ऐसा पाथ चाहिए जो किसी क्षेत्र को घेरता हो
@msg arrowAlign must be extend|tip, got {o} arrowAlign को extend|tip होना चाहिए, {o} मिला
-@msg arrowScale must be [start, end] arrowScale होना चाहिए [शुरू, अंत]
+@msg arrowScale must be [start, end] arrowScale होना चाहिए [start, end]
@msg artboard {0} does not exist आर्टबोर्ड {0} मौजूद नहीं है
@msg artboard {i} doesn't print आर्टबोर्ड {i} प्रिंट नहीं करता है
@msg artboard is a 0-based index artboard 0 से शुरू होने वाला इंडेक्स है
@@ -6458,14 +6461,14 @@ antiAlias Sharp शार्प
@msg bad edit बुरा संपादन
@msg bad fill {v} बुरा भर {v}
@msg bad fit: {e} बुरा फिट: {e}
-@msg bad model {other} (keep, new or document) बुरा मॉडल {other} (keep, नया या डॉक्यूमेंट)
+@msg bad model {other} (keep, new or document) बुरा मॉडल {other} (keep, new या document)
@msg bad new colour {v} बुरा नया रंग {v}
@msg bad object बुरा ऑब्जेक्ट
@msg bad object number खराब ऑब्जेक्ट संख्या
@msg bad offset बुरा ऑफसेट
@msg bad page tree बुरा पेज पेड़
@msg bad path data: {e} बुरा पाथ डेटा: {e}
-@msg bad point {v}: expected [x, y] or [x, y, pressure] बुरा बिंदु {v}: उम्मीद [x, y] या [x, y, दबाव]
+@msg bad point {v}: expected [x, y] or [x, y, pressure] बुरा बिंदु {v}: उम्मीद [x, y] या [x, y, pressure]
@msg bad reference बुरा संदर्भ
@msg bad shortcut बुरा शॉर्टकट
@msg bad stream length खराब धारा की लंबाई
@@ -6995,7 +6998,7 @@ antiAlias Sharp शार्प
@msg Internal error: {msg} (please report this bug) आंतरिक त्रुटि: {msg} (कृपया इस बग की रिपोर्ट करें)
@msg invalid `color` अमान्य `color`
@msg invalid anchor index {v}: expected a non-negative integer अमान्य एंकर इंडेक्स {v}: एक गैर-नकारात्मक पूर्णांक की उम्मीद
-@msg invalid anchor reference {v}: expected [subpath, anchor] अमान्य एंकर संदर्भ {v}: उम्मीद [subpath, एंकर]
+@msg invalid anchor reference {v}: expected [subpath, anchor] अमान्य एंकर संदर्भ {v}: उम्मीद [subpath, anchor]
@msg invalid angle अमान्य कोण
@msg invalid brush: {e} अमान्य ब्रश: {e}
@msg invalid color अमान्य रंग
@@ -7172,7 +7175,7 @@ antiAlias Sharp शार्प
@msg missing `value` लापता `value`
@msg missing anchor लापता एंकर
@msg missing anchors लापता एंकर
-@msg missing anchors [[subpath, anchor]…] लापता एंकर [[subpath, एंकर]...]
+@msg missing anchors [[subpath, anchor]…] लापता एंकर [[subpath, anchor]...]
@msg missing array `{key}` लापता सरणी `{key}`
@msg missing case लापता मामला
@msg missing color लापता रंग
@@ -8384,7 +8387,7 @@ antiAlias Sharp शार्प
@msg Unembedded to {_1} एम्बेड हटाकर {_1} में रखा गया
@msg unexpected host trap होस्ट में अप्रत्याशित ट्रैप
@msg unexpected resources अप्रत्याशित संसाधन
-@msg unknown {k} key `{key}` (size, position) अज्ञात {k} कुंजी `{key}` (आकार, स्थिति)
+@msg unknown {k} key `{key}` (size, position) अज्ञात {k} कुंजी `{key}` (size, position)
@msg unknown {key} `{v}` (none, short or full) अज्ञात {key} `{v}` (none, short या full)
@msg unknown {key} profile `{n}` अज्ञात {key} प्रोफ़ाइल `{n}`
@msg unknown align `{other}` अज्ञात संरेखण `{other}`
@@ -8417,9 +8420,9 @@ antiAlias Sharp शार्प
@msg unknown format अज्ञात स्वरूप
@msg unknown format `{f}` (Save writes {_1}) अज्ञात फ़ॉर्मैट `{f}` (सेव {_1} लिखता है)
@msg unknown format `{f}` (vcswatches, gpl, ase or css) अज्ञात फ़ॉर्मैट `{f}` (vcswatches, gpl, ase या css)
-@msg unknown freeform mode `{m}` (points, lines) अज्ञात फ्रीफॉर्म मोड `{m}` (बिंदुओं, लाइनों)
+@msg unknown freeform mode `{m}` (points, lines) अज्ञात फ्रीफॉर्म मोड `{m}` (points, lines)
@msg unknown from {f} {f} से अज्ञात
-@msg unknown gradient kind `{k}` (linear, radial, conic, diamond, freeform) अज्ञात ग्रेडिएंट `{k}` (रैखिक, रेडियल, शंक्वाकार, डायमंड, फ्रीफॉर्म)
+@msg unknown gradient kind `{k}` (linear, radial, conic, diamond, freeform) अज्ञात ग्रेडिएंट `{k}` (linear, radial, conic, diamond, freeform)
@msg unknown graph type `{t}` अज्ञात ग्राफ प्रकार `{t}`
@msg unknown grid colours `{name}` (one of {_1}) अज्ञात ग्रिड रंग `{name}` ({_1} में से एक)
@msg unknown gridType `{t}` अज्ञात gridType `{t}`
@@ -8434,20 +8437,20 @@ antiAlias Sharp शार्प
@msg unknown method `{m}` ({_1}) अज्ञात विधि `{m}` ({_1})
@msg unknown mode {m} अज्ञात मोड {m}
@msg unknown mode `{o}` अज्ञात मोड `{o}`
-@msg unknown mode `{other}` (gray, rgb, hsb, lab, cmyk or web) अज्ञात मोड `{other}` (ग्रे, RGB, hsb, लैब, cmyk या वेब)
+@msg unknown mode `{other}` (gray, rgb, hsb, lab, cmyk or web) अज्ञात मोड `{other}` (gray, rgb, hsb, lab, cmyk या web)
@msg unknown mode `{other}` (mono|cmyk) अज्ञात मोड `{other}` (mono|cmyk)
@msg unknown mode `{other}` (rgb|cmyk|gray|global) अज्ञात मोड `{other}` (rgb|cmyk|gray|global)
-@msg unknown mode `{other}` (set, add, subtract) अज्ञात मोड `{other}` (सेट, ऐड, घटाना)
+@msg unknown mode `{other}` (set, add, subtract) अज्ञात मोड `{other}` (set, add, subtract)
@msg unknown model `{other}` अज्ञात मॉडल `{other}`
-@msg unknown model `{other}` (keep, new or document) अज्ञात मॉडल `{other}` (keep, नया या डॉक्यूमेंट)
+@msg unknown model `{other}` (keep, new or document) अज्ञात मॉडल `{other}` (keep, new या document)
@msg unknown op {o} अज्ञात op {o}
@msg unknown OpenType feature `{t}` अज्ञात ओपन टाइप सुविधा `{t}`
@msg unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom; prefix - to turn off) अज्ञात OpenType फ़ीचर `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom; बंद करने के लिए आगे - लगाएँ)
@msg unknown OpenType feature `{t}` in featureChanges (the tags `features` takes) featureChanges में अज्ञात OpenType फ़ीचर `{t}` (वे टैग जो `features` लेता है)
@msg unknown option `{path}.{k}` अज्ञात ऑप्शन `{path}.{k}`
@msg unknown order `{o}`: toggle, front, forward, backward or back अज्ञात आदेश `{o}`: टॉगल, फ्रंट, फॉरवर्ड, बैकवर्ड या बैक
-@msg unknown overprints `{o}` (preserve, simulate or discard) अज्ञात ओवरप्रिंट `{o}` (preserve, अनुकरण या discard)
-@msg unknown overprints `{v}` (preserve, simulate or discard) अज्ञात ओवरप्रिंट `{v}` (प्ररक्षित, अनुकरण या त्यागपत्र)
+@msg unknown overprints `{o}` (preserve, simulate or discard) अज्ञात ओवरप्रिंट `{o}` (preserve, simulate या discard)
+@msg unknown overprints `{v}` (preserve, simulate or discard) अज्ञात ओवरप्रिंट `{v}` (preserve, simulate या discard)
@msg unknown panel अज्ञात पैनल
@msg unknown panel `{raw}` अज्ञात पैनल `{raw}`
@msg panels can't be shown in Presentation Mode प्रस्तुति मोड में पैनल्स नहीं दिखाए जा सकते
@@ -8462,7 +8465,7 @@ antiAlias Sharp शार्प
@msg `{path}` is nested too deeply `{path}` बहुत गहराई तक नेस्ट किया गया है
@msg unknown PDF preset `{name}` (presets: {_1}) अज्ञात PDF प्रीसेट `{name}` (presets: {_1})
@msg unknown pieLegend `{v}` (none, standard or wedges) अज्ञात pieLegend `{v}` (none, standard या wedges)
-@msg unknown piePosition `{v}` (even, ratio or stacked) अज्ञात piePosition `{v}` (यहां तक कि अनुपात या स्टैक्ड)
+@msg unknown piePosition `{v}` (even, ratio or stacked) अज्ञात piePosition `{v}` (even, ratio या stacked)
@msg unknown pieSort `{v}` (none, all or first) अज्ञात pieSort `{v}` (none, all या first)
@msg unknown plate `{n}` अज्ञात प्लेट `{n}`
@msg unknown preference `{k}` अज्ञात वरीयता `{k}`
@@ -8488,7 +8491,7 @@ antiAlias Sharp शार्प
@msg unknown preset `{name}`: not a built-in or saved preset (see imageTrace.presets) अज्ञात प्रीसेट `{name}`: एक अंतर्निहित या सेव किए गए प्रीसेट नहीं (imageTrace.presets देखें)
@msg unknown profile {id} (see stroke.widthProfile.list) अज्ञात प्रोफ़ाइल {id} (stroke.widthProfile.list देखें)
@msg unknown proof target `{t}` अज्ञात प्रमाण लक्ष्य `{t}`
-@msg unknown quotes key `{k}` (double, single) अज्ञात उद्धरण कुंजी `{k}` (डबल, सिंगल)
+@msg unknown quotes key `{k}` (double, single) अज्ञात उद्धरण कुंजी `{k}` (double, single)
@msg unknown resolution `{v}` अज्ञात रिज़ॉल्यूशन `{v}`
@msg unknown rule `{r}` (see color.harmony) अज्ञात नियम `{r}` (देखें color.harmony)
@msg unknown rule `{rule}` ({_1}) अज्ञात नियम `{rule}` ({_1})
@@ -8504,7 +8507,7 @@ antiAlias Sharp शार्प
@msg Unknown unit अज्ञात इकाई
@msg unknown units अज्ञात इकाइयों
@msg unknown units `{u}` अज्ञात इकाइयों `{u}`
-@msg unknown value axis `{v}` (left, right or both) अज्ञात मान अक्ष `{v}` (बाएं, दाएं या दोनों)
+@msg unknown value axis `{v}` (left, right or both) अज्ञात मान अक्ष `{v}` (left, right या both)
@msg unknown variation `{v}` (tintsShades|warmCool|vividMuted) अज्ञात variation `{v}` (tintsShades|warmCool|vividMuted)
@msg unknown Warp style अज्ञात ताना स्टाइल
@msg unknown warp style `{s}` अज्ञात ताना स्टाइल `{s}`
diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv
index c2f1a52cd9..8549bd5d63 100644
--- a/crates/ui-egui/src/i18n/hu.tsv
+++ b/crates/ui-egui/src/i18n/hu.tsv
@@ -4311,6 +4311,7 @@ mark-style Japanese Japán
VectorCraft Title Bar on Every Desktop A VectorCraft címsora minden asztali környezetben
The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Amíg a VECTORCRAFT_TITLEBAR környezeti változó be van állítva, az választja ki a címsort.
System Title Bar takes precedence over this option while it is on. Amíg a Rendszer címsora be van kapcsolva, elsőbbséget élvez ezzel a beállítással szemben.
+ This desktop draws the window's title bar with this option on or off while VectorCraft Title Bar on Every Desktop is off. Amíg a VectorCraft címsora minden asztali környezetben beállítás ki van kapcsolva, ez az asztali környezet rajzolja az ablak címsorát, akár be van kapcsolva ez a beállítás, akár nem.
Tab Tabulátor
Tab Stops Tabulátorpontok
Table Táblázat
@@ -6583,6 +6584,8 @@ axis Y Axis Y tengely
Action Options… Művelet beállításai…
Action Options Művelet beállításai
Playback Options… Lejátszási beállítások…
+ Button Mode Gomb mód
+ No actions. Nincs művelet.
Insert Stop… Megállás beszúrása…
Insert Stop Megállás beszúrása
Record Stop Megállás rögzítése
diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv
index a36c629332..e27253376e 100644
--- a/crates/ui-egui/src/i18n/id.tsv
+++ b/crates/ui-egui/src/i18n/id.tsv
@@ -924,6 +924,7 @@
VectorCraft Title Bar on Every Desktop Bilah judul VectorCraft di setiap desktop
The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Selama ditetapkan, variabel lingkungan VECTORCRAFT_TITLEBAR memilih bilah judul.
System Title Bar takes precedence over this option while it is on. Selama aktif, Bilah judul sistem lebih diutamakan daripada opsi ini.
+ This desktop draws the window's title bar with this option on or off while VectorCraft Title Bar on Every Desktop is off. Selama Bilah judul VectorCraft di setiap desktop nonaktif, desktop ini menggambar bilah judul jendela, baik opsi ini aktif maupun tidak.
Exit Keluar
Distort Distorsi
Diffuse Glow… Pendar difusi…
@@ -4795,11 +4796,11 @@ imageTrace Outline Kontur
@msg unknown effect `{id}` efek `{id}` tidak dikenal
@msg unknown format format tidak dikenal
@msg unknown format `{f}` (Save writes {_1}) format `{f}` tidak dikenal (Simpan menulis {_1})
-@msg unknown freeform mode `{m}` (points, lines) mode bentuk bebas `{m}` tidak dikenal (titik, garis)
-@msg unknown gradient kind `{k}` (linear, radial, conic, diamond, freeform) jenis gradien `{k}` tidak dikenal (linear, radial, kerucut, wajik, bentuk bebas)
+@msg unknown freeform mode `{m}` (points, lines) mode bentuk bebas `{m}` tidak dikenal (points, lines)
+@msg unknown gradient kind `{k}` (linear, radial, conic, diamond, freeform) jenis gradien `{k}` tidak dikenal (linear, radial, conic, diamond, freeform)
@msg unknown highlight `{h}` sorotan `{h}` tidak dikenal
@msg unknown option `{path}.{k}` opsi `{path}.{k}` tidak dikenal
-@msg unknown overprints `{o}` (preserve, simulate or discard) overprint `{o}` tidak dikenal (pertahankan, simulasikan, atau buang)
+@msg unknown overprints `{o}` (preserve, simulate or discard) overprint `{o}` tidak dikenal (preserve, simulate, atau discard)
@msg unknown panel `{raw}` panel `{raw}` tidak dikenal
@msg unknown preference `{key}` preferensi `{key}` tidak dikenal
@msg Preferences imported. Preferensi diimpor.
@@ -4817,10 +4818,10 @@ imageTrace Outline Kontur
@msg `{key}` must be an object `{key}` harus berupa objek
@msg `prefs.import` runs on its own, not as a batch step `prefs.import` dijalankan sendiri, bukan sebagai langkah batch
@msg unknown preset (one of {_1}) preset tidak dikenal (salah satu dari {_1})
-@msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) preset `{name}` tidak dikenal (tinggi, sedang, rendah, atau yang tersimpan: lihat flattener.presets.list)
+@msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) preset `{name}` tidak dikenal (high, medium, low, atau yang tersimpan: lihat flattener.presets.list)
@msg unknown preset `{name}` (see flattener.presets.list) preset `{name}` tidak dikenal (lihat flattener.presets.list)
@msg unknown preset `{name}` (see webExport.presets.list) preset `{name}` tidak dikenal (lihat webExport.presets.list)
-@msg unknown side `{other}` (left|right|top|bottom) sisi `{other}` tidak dikenal (kiri|kanan|atas|bawah)
+@msg unknown side `{other}` (left|right|top|bottom) sisi `{other}` tidak dikenal (left|right|top|bottom)
@msg unknown tool `{tool}` alat `{tool}` tidak dikenal
@msg unknown tool `{t}` alat `{t}` tidak dikenal
@msg unknown tool or command `{key}` alat atau perintah `{key}` tidak dikenal
@@ -6073,6 +6074,8 @@ axis Y Axis Sumbu Y
Action Options… Opsi tindakan…
Action Options Opsi tindakan
Playback Options… Opsi pemutaran…
+ Button Mode Mode tombol
+ No actions. Tidak ada aksi.
Insert Stop… Sisipkan henti…
Insert Stop Sisipkan henti
Record Stop Rekam henti
diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv
index 0ac2816b07..2381051c9b 100644
--- a/crates/ui-egui/src/i18n/it.tsv
+++ b/crates/ui-egui/src/i18n/it.tsv
@@ -5377,6 +5377,7 @@ effect Regular Normale
VectorCraft Title Bar on Every Desktop Barra del titolo di VectorCraft su ogni desktop
The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Finché è impostata, la variabile d'ambiente VECTORCRAFT_TITLEBAR sceglie la barra del titolo.
System Title Bar takes precedence over this option while it is on. Finché è attiva, Barra del titolo di sistema ha la precedenza su questa opzione.
+ This desktop draws the window's title bar with this option on or off while VectorCraft Title Bar on Every Desktop is off. Finché Barra del titolo di VectorCraft su ogni desktop è disattivata, questo desktop disegna la barra del titolo della finestra, sia con questa opzione attiva sia disattivata.
Value Axis Asse dei valori
On Left Side Sul lato sinistro
On Right Side Sul lato destro
@@ -6226,6 +6227,8 @@ axis Y Axis Asse Y
Action Options… Opzioni azione…
Action Options Opzioni azione
Playback Options… Opzioni di riproduzione…
+ Button Mode Modalità pulsante
+ No actions. Nessuna azione.
Insert Stop… Inserisci interruzione…
Insert Stop Inserisci interruzione
Record Stop Registra interruzione
diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv
index 71de1ce91c..1458bf2aa1 100644
--- a/crates/ui-egui/src/i18n/ja.tsv
+++ b/crates/ui-egui/src/i18n/ja.tsv
@@ -5419,6 +5419,7 @@ effect Vertical 垂直
VectorCraft Title Bar on Every Desktop すべてのデスクトップで VectorCraft のタイトルバー
The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. 環境変数 VECTORCRAFT_TITLEBAR が設定されている間は、この変数でタイトルバーが決まります。
System Title Bar takes precedence over this option while it is on. システムのタイトルバーがオンの間は、そちらがこのオプションより優先されます。
+ This desktop draws the window's title bar with this option on or off while VectorCraft Title Bar on Every Desktop is off. すべてのデスクトップで VectorCraft のタイトルバーがオフの間は、このオプションのオンとオフにかかわらず、このデスクトップがウィンドウのタイトルバーを描画します。
Value Axis 数値軸
On Left Side 左側
On Right Side 右側
@@ -6292,6 +6293,8 @@ axis Y Axis Y 軸
Action Options… アクションオプション…
Action Options アクションオプション
Playback Options… 再生オプション…
+ Button Mode ボタンモード
+ No actions. アクションがありません。
Insert Stop… 停止を挿入…
Insert Stop 停止を挿入
Record Stop 停止を記録
diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv
index 2cff51d94b..9105fe50e7 100644
--- a/crates/ui-egui/src/i18n/ka.tsv
+++ b/crates/ui-egui/src/i18n/ka.tsv
@@ -890,6 +890,7 @@
VectorCraft Title Bar on Every Desktop VectorCraft-ის სათაურის ზოლი ყველა სამუშაო მაგიდაზე
The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. სანამ გარემოს ცვლადი VECTORCRAFT_TITLEBAR დაყენებულია, სათაურის ზოლს ის ირჩევს.
System Title Bar takes precedence over this option while it is on. სანამ სისტემის სათაურის ზოლი ჩართულია, მას ამ პარამეტრზე უპირატესობა აქვს.
+ This desktop draws the window's title bar with this option on or off while VectorCraft Title Bar on Every Desktop is off. სანამ VectorCraft-ის სათაურის ზოლი ყველა სამუშაო მაგიდაზე გამორთულია, ეს სამუშაო მაგიდა ფანჯრის სათაურის ზოლს ხატავს, ეს პარამეტრი ჩართულია თუ გამორთული.
Exit გასვლა
Distort დეფორმაცია
Diffuse Glow… გაბნეული ნათება…
@@ -6551,6 +6552,8 @@ axis Y Axis Y ღერძი
Action Options… მოქმედების პარამეტრები…
Action Options მოქმედების პარამეტრები
Playback Options… შესრულების პარამეტრები…
+ Button Mode ღილაკების რეჟიმი
+ No actions. მოქმედებები არ არის.
Insert Stop… გაჩერების ჩასმა…
Insert Stop გაჩერების ჩასმა
Record Stop გაჩერების ჩაწერა
diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv
index 4715a413f4..fd75f983ce 100644
--- a/crates/ui-egui/src/i18n/ko.tsv
+++ b/crates/ui-egui/src/i18n/ko.tsv
@@ -5346,6 +5346,7 @@ effect Vertical 세로
VectorCraft Title Bar on Every Desktop 모든 데스크톱에서 VectorCraft 제목 표시줄
The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. 환경 변수 VECTORCRAFT_TITLEBAR가 설정되어 있는 동안에는 이 변수가 제목 표시줄을 정합니다.
System Title Bar takes precedence over this option while it is on. 시스템 제목 표시줄이 켜져 있는 동안에는 그 설정이 이 옵션보다 우선합니다.
+ This desktop draws the window's title bar with this option on or off while VectorCraft Title Bar on Every Desktop is off. 모든 데스크톱에서 VectorCraft 제목 표시줄이 꺼져 있는 동안에는 이 옵션을 켜든 끄든 이 데스크톱이 창의 제목 표시줄을 그립니다.
Value Axis 값 축
On Left Side 왼쪽
On Right Side 오른쪽
@@ -6187,6 +6188,8 @@ axis Y Axis Y축
Action Options… 액션 옵션…
Action Options 액션 옵션
Playback Options… 재생 옵션…
+ Button Mode 버튼 모드
+ No actions. 동작이 없습니다.
Insert Stop… 중지 삽입…
Insert Stop 중지 삽입
Record Stop 중지 기록
diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv
index 94957fb782..cd3aa43966 100644
--- a/crates/ui-egui/src/i18n/nl.tsv
+++ b/crates/ui-egui/src/i18n/nl.tsv
@@ -1441,6 +1441,7 @@
VectorCraft Title Bar on Every Desktop VectorCraft-titelbalk op elk bureaublad
The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Zolang de omgevingsvariabele VECTORCRAFT_TITLEBAR is ingesteld, kiest die de titelbalk.
System Title Bar takes precedence over this option while it is on. Zolang Systeemtitelbalk is ingeschakeld, heeft die voorrang op deze optie.
+ This desktop draws the window's title bar with this option on or off while VectorCraft Title Bar on Every Desktop is off. Zolang VectorCraft-titelbalk op elk bureaublad is uitgeschakeld, tekent dit bureaublad de titelbalk van het venster, of deze optie nu aan of uit staat.
Exit Afsluiten
Distort Vervormen
Diffuse Glow… Diffuse gloed…
@@ -5312,11 +5313,11 @@ imageTrace Outline Contour
@msg unknown effect `{id}` onbekend effect `{id}`
@msg unknown format onbekende indeling
@msg unknown format `{f}` (Save writes {_1}) onbekende indeling `{f}` (Opslaan schrijft {_1})
-@msg unknown freeform mode `{m}` (points, lines) onbekende vrijvormmodus `{m}` (punten, lijnen)
-@msg unknown gradient kind `{k}` (linear, radial, conic, diamond, freeform) onbekend verlooptype `{k}` (lineair, radiaal, conisch, ruit, vrijvorm)
+@msg unknown freeform mode `{m}` (points, lines) onbekende vrijvormmodus `{m}` (points, lines)
+@msg unknown gradient kind `{k}` (linear, radial, conic, diamond, freeform) onbekend verlooptype `{k}` (linear, radial, conic, diamond, freeform)
@msg unknown highlight `{h}` onbekende markering `{h}`
@msg unknown option `{path}.{k}` onbekende optie `{path}.{k}`
-@msg unknown overprints `{o}` (preserve, simulate or discard) onbekende overdruk `{o}` (behouden, simuleren of verwerpen)
+@msg unknown overprints `{o}` (preserve, simulate or discard) onbekende overdruk `{o}` (preserve, simulate of discard)
@msg unknown panel `{raw}` onbekend paneel `{raw}`
@msg unknown preference `{key}` onbekende voorkeur `{key}`
@msg Preferences imported. Voorkeuren geïmporteerd.
@@ -5334,10 +5335,10 @@ imageTrace Outline Contour
@msg `{key}` must be an object `{key}` moet een object zijn
@msg `prefs.import` runs on its own, not as a batch step `prefs.import` wordt los uitgevoerd, niet als stap van een batch
@msg unknown preset (one of {_1}) onbekende voorinstelling (één van {_1})
-@msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) onbekende voorinstelling `{name}` (hoog, middel, laag of een opgeslagen voorinstelling: zie flattener.presets.list)
+@msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) onbekende voorinstelling `{name}` (high, medium, low of een opgeslagen voorinstelling: zie flattener.presets.list)
@msg unknown preset `{name}` (see flattener.presets.list) onbekende voorinstelling `{name}` (zie flattener.presets.list)
@msg unknown preset `{name}` (see webExport.presets.list) onbekende voorinstelling `{name}` (zie webExport.presets.list)
-@msg unknown side `{other}` (left|right|top|bottom) onbekende zijde `{other}` (links|rechts|boven|onder)
+@msg unknown side `{other}` (left|right|top|bottom) onbekende zijde `{other}` (left|right|top|bottom)
@msg unknown tool `{tool}` onbekend gereedschap `{tool}`
@msg unknown tool `{t}` onbekend gereedschap `{t}`
@msg unknown tool or command `{key}` onbekend gereedschap of opdracht `{key}`
@@ -6695,6 +6696,8 @@ axis Y Axis Y-as
Action Options… Actieopties…
Action Options Actieopties
Playback Options… Afspeelopties…
+ Button Mode Knopmodus
+ No actions. Geen acties.
Insert Stop… Stop invoegen…
Insert Stop Stop invoegen
Record Stop Stop opnemen
diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv
index 48f2671c2d..5f274cef09 100644
--- a/crates/ui-egui/src/i18n/pl.tsv
+++ b/crates/ui-egui/src/i18n/pl.tsv
@@ -942,6 +942,7 @@
VectorCraft Title Bar on Every Desktop Pasek tytułu VectorCraft na każdym pulpicie
The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Dopóki zmienna środowiskowa VECTORCRAFT_TITLEBAR jest ustawiona, to ona wybiera pasek tytułu.
System Title Bar takes precedence over this option while it is on. Dopóki Systemowy pasek tytułu jest włączony, ma pierwszeństwo przed tą opcją.
+ This desktop draws the window's title bar with this option on or off while VectorCraft Title Bar on Every Desktop is off. Dopóki Pasek tytułu VectorCraft na każdym pulpicie jest wyłączony, ten pulpit rysuje pasek tytułu okna niezależnie od tego, czy ta opcja jest włączona.
Exit Zakończ
Distort Zniekształcenie
Diffuse Glow… Rozproszony blask…
@@ -6455,6 +6456,8 @@ axis Y Axis Oś Y
Action Options… Opcje operacji…
Action Options Opcje operacji
Playback Options… Opcje odtwarzania…
+ Button Mode Tryb przycisków
+ No actions. Brak operacji.
Insert Stop… Wstaw zatrzymanie…
Insert Stop Wstaw zatrzymanie
Record Stop Nagraj zatrzymanie
diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv
index f6e54552d4..a9e56041f4 100644
--- a/crates/ui-egui/src/i18n/pt-br.tsv
+++ b/crates/ui-egui/src/i18n/pt-br.tsv
@@ -3490,6 +3490,7 @@ imageTrace Outline Contorno
VectorCraft Title Bar on Every Desktop Barra de título do VectorCraft em todas as áreas de trabalho
The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Enquanto estiver definida, a variável de ambiente VECTORCRAFT_TITLEBAR escolhe a barra de título.
System Title Bar takes precedence over this option while it is on. Enquanto estiver ativada, Barra de título do sistema tem prioridade sobre esta opção.
+ This desktop draws the window's title bar with this option on or off while VectorCraft Title Bar on Every Desktop is off. Enquanto Barra de título do VectorCraft em todas as áreas de trabalho estiver desativada, esta área de trabalho desenha a barra de título da janela com esta opção ativada ou desativada.
Exit Sair
Distort Distorcer
Diffuse Glow… Brilho difuso…
@@ -6515,6 +6516,8 @@ axis Y Axis Eixo Y
Action Options… Opções da Ação…
Action Options Opções da Ação
Playback Options… Opções de Reprodução…
+ Button Mode Modo de Botão
+ No actions. Nenhuma ação.
Insert Stop… Inserir Parada…
Insert Stop Inserir Parada
Record Stop Gravar Parada
@@ -7641,7 +7644,7 @@ swatch Yellow Green Verde-amarelado
@msg `alignOn` must be a string `alignOn` deve ser uma string
@msg `align` is start, center or end `align` é start, center ou end
@msg `align` must be a string `align` deve ser uma string
-@msg `anchors` must be an array of [subpath, anchor] pairs `anchors` deve ser uma matriz de pares [subcaminho, ponto de ancoragem]
+@msg `anchors` must be an array of [subpath, anchor] pairs `anchors` deve ser uma matriz de pares [subpath, anchor]
@msg `anchors` must list anchor indices (0, 1, 2…), not {k} `anchors` deve listar índices de pontos de ancoragem (0, 1, 2…), não {k}
@msg `anchors` must list anchor indices (0, 1, 2…), not {v} `anchors` deve listar índices de pontos de ancoragem (0, 1, 2…), não {v}
@msg `answer` for this {kind} dialog must be one of: {_1} `answer` para esta caixa de diálogo {kind} deve ser um de: {_1}
@@ -7706,7 +7709,7 @@ swatch Yellow Green Verde-amarelado
@msg `fidelity` must be from {lo} to {hi} pt `fidelity` deve estar entre {lo} e {hi} pt
@msg `files` must be 1 to 1000 paths of font files the last text.findFontFiles search found `files` deve ter de 1 a 1000 caminhos de arquivos de fonte encontrados pela última pesquisa de text.findFontFiles
@msg `focal` and `radius` cannot be used together `focal` e `radius` não podem ser usados juntos
-@msg `focal` must be an array of [subpath, anchor] pairs `focal` deve ser uma matriz de pares [subcaminho, ponto de ancoragem]
+@msg `focal` must be an array of [subpath, anchor] pairs `focal` deve ser uma matriz de pares [subpath, anchor]
@msg `focal` names [{si}, {ai}], which is not an anchor of the path `focal` indica [{si}, {ai}], que não é um ponto de ancoragem do caminho
@msg `folder` must be an absolute folder path `folder` deve ser um caminho absoluto de pasta
@msg `fontAxes` must be an object of axis tags and values, such as {"wght"} `fontAxes` deve ser um objeto de tags de eixo e valores, como {"wght"}
@@ -8371,7 +8374,7 @@ swatch Yellow Green Verde-amarelado
@msg instruction {pc} reached with stack depths {seen} and {d} instrução {pc} alcançada com profundidades de pilha {seen} e {d}
@msg invalid `color` `color` inválido
@msg invalid anchor index {v}: expected a non-negative integer índice de ponto de ancoragem inválido {v}: esperado um inteiro não negativo
-@msg invalid anchor reference {v}: expected [subpath, anchor] referência de ponto de ancoragem inválida {v}: esperado [subcaminho, ponto de ancoragem]
+@msg invalid anchor reference {v}: expected [subpath, anchor] referência de ponto de ancoragem inválida {v}: esperado [subpath, anchor]
@msg invalid angle ângulo inválido
@msg invalid array length comprimento de matriz inválido
@msg invalid brush: {e} pincel inválido: {e}
@@ -8480,7 +8483,7 @@ swatch Yellow Green Verde-amarelado
@msg missing `{k}` falta `{k}`
@msg missing anchor falta o ponto de ancoragem
@msg missing anchors faltam os pontos de ancoragem
-@msg missing anchors [[subpath, anchor]…] faltam os pontos de ancoragem [[subcaminho, ponto de ancoragem]…]
+@msg missing anchors [[subpath, anchor]…] faltam os pontos de ancoragem [[subpath, anchor]…]
@msg missing array `{key}` falta a matriz `{key}`
@msg missing case falta case
@msg missing color falta a cor
diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv
index 8dc8336089..4ea3511c36 100644
--- a/crates/ui-egui/src/i18n/ru.tsv
+++ b/crates/ui-egui/src/i18n/ru.tsv
@@ -5362,6 +5362,7 @@ axis Both Оба
VectorCraft Title Bar on Every Desktop Строка заголовка VectorCraft на любом рабочем столе
The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Пока переменная среды VECTORCRAFT_TITLEBAR задана, строку заголовка выбирает она.
System Title Bar takes precedence over this option while it is on. Пока включена «Системная строка заголовка», она имеет приоритет над этим параметром.
+ This desktop draws the window's title bar with this option on or off while VectorCraft Title Bar on Every Desktop is off. Пока «Строка заголовка VectorCraft на любом рабочем столе» выключена, этот рабочий стол рисует строку заголовка окна независимо от того, включён ли этот параметр.
Value Axis Ось значений
On Left Side Слева
On Right Side Справа
@@ -6211,6 +6212,8 @@ axis Y Axis Ось Y
Action Options… Параметры действия…
Action Options Параметры действия
Playback Options… Параметры воспроизведения…
+ Button Mode Режим кнопок
+ No actions. Нет действий.
Insert Stop… Вставить остановку…
Insert Stop Вставить остановку
Record Stop Записать остановку
diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv
index cfb9d9734b..9a0de40468 100644
--- a/crates/ui-egui/src/i18n/sv.tsv
+++ b/crates/ui-egui/src/i18n/sv.tsv
@@ -5235,6 +5235,7 @@ effect Soft Mjuk
VectorCraft Title Bar on Every Desktop VectorCrafts namnlist på alla skrivbord
The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Miljövariabeln VECTORCRAFT_TITLEBAR väljer namnlisten så länge den är angiven.
System Title Bar takes precedence over this option while it is on. Systemets namnlist har företräde framför det här alternativet så länge den är på.
+ This desktop draws the window's title bar with this option on or off while VectorCraft Title Bar on Every Desktop is off. Så länge VectorCrafts namnlist på alla skrivbord är av ritar det här skrivbordet fönstrets namnlist, oavsett om det här alternativet är på eller av.
Value Axis Värdeaxel
On Left Side På vänster sida
On Right Side På höger sida
@@ -6580,6 +6581,8 @@ axis Y Axis Y-axel
Action Options… Alternativ för åtgärd…
Action Options Alternativ för åtgärd
Playback Options… Uppspelningsalternativ…
+ Button Mode Knappläge
+ No actions. Inga åtgärder.
Insert Stop… Infoga stopp…
Insert Stop Infoga stopp
Record Stop Spela in stopp
@@ -7545,7 +7548,7 @@ digits Hindi Hindi
@msg `{what}` is longer than {MAX_TEXT} bytes `{what}` är längre än {MAX_TEXT} byte
@msg `{what}` must be text `{what}` måste vara text
@msg `align` is start, center or end `align` är start, center eller end
-@msg `anchors` must be an array of [subpath, anchor] pairs `anchors` måste vara en array av par [delbana, ankarpunkt]
+@msg `anchors` must be an array of [subpath, anchor] pairs `anchors` måste vara en array av par [subpath, anchor]
@msg `anchors` must list anchor indices (0, 1, 2…), not {k} `anchors` måste lista ankarpunktsindex (0, 1, 2…), inte {k}
@msg `anchors` must list anchor indices (0, 1, 2…), not {v} `anchors` måste lista ankarpunktsindex (0, 1, 2…), inte {v}
@msg `artboards` must be a list of artboard indices `artboards` måste vara en lista med ritytsindex
@@ -8070,7 +8073,7 @@ digits Hindi Hindi
@msg instruction budget instruktionsbudget
@msg invalid `color` ogiltig `color`
@msg invalid anchor index {v}: expected a non-negative integer ogiltigt ankarindex {v}: ett icke-negativt heltal förväntades
-@msg invalid anchor reference {v}: expected [subpath, anchor] ogiltig ankarreferens {v}: [delbana, ankare] förväntades
+@msg invalid anchor reference {v}: expected [subpath, anchor] ogiltig ankarreferens {v}: [subpath, anchor] förväntades
@msg invalid angle ogiltig vinkel
@msg invalid brush: {e} ogiltig pensel: {e}
@msg invalid color ogiltig färg
@@ -8159,7 +8162,7 @@ digits Hindi Hindi
@msg missing `value` `value` saknas
@msg missing anchor ankare saknas
@msg missing anchors ankare saknas
-@msg missing anchors [[subpath, anchor]…] ankare saknas [[delbana, ankare]…]
+@msg missing anchors [[subpath, anchor]…] ankare saknas [[subpath, anchor]…]
@msg missing array `{key}` arrayen `{key}` saknas
@msg missing case skiftläge saknas
@msg missing color färg saknas
diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs
index 705fc68db2..bb79a093ec 100644
--- a/crates/ui-egui/src/i18n/tests.rs
+++ b/crates/ui-egui/src/i18n/tests.rs
@@ -4030,6 +4030,87 @@ fn translated_messages_keep_the_command_ids_their_english_names() {
assert!(missing.is_empty(), "{} rows drop a command id their English names:\n{}", missing.len(), missing.join("\n"));
}
+/// Whether `word` is an ASCII name a command reads: a letter followed by letters, digits or
+/// underscores.
+fn is_ascii_name(word: &str) -> bool {
+ word.chars().next().is_some_and(|c| c.is_ascii_alphabetic()) && word.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
+}
+
+/// The field names and accepted values that `s` spells out of its backticked spans: each name of a
+/// comma-separated list in square brackets, such as `[x, y, width, height]` or
+/// `[[subpath, anchor]…]`; each `a|b|c` list of values, whole; and the values an `unknown …` or
+/// `bad model …` message lists in parentheses, such as `(none, standard or wedges)`, up to the first
+/// item that is not a single name, so `(high, medium, low or a saved one: see …)` gives `high`,
+/// `medium` and `low`, and `(or give its ids)` gives none.
+fn field_names_and_values(s: &str) -> Vec {
+ let outside = s.split('`').step_by(2).collect::>().join(" ");
+ let mut found = Vec::new();
+ for piece in outside.split('[').skip(1) {
+ let Some(end) = piece.find(']') else { continue };
+ let names: Vec<&str> = piece[..end].split(',').map(str::trim).collect();
+ if names.len() > 1 && names.iter().all(|name| is_ascii_name(name)) {
+ found.extend(names.into_iter().map(str::to_string));
+ }
+ }
+ let lists = outside.split(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '|'));
+ found.extend(lists.filter(|w| w.contains('|') && w.split('|').all(is_ascii_name)).map(str::to_string));
+ for (at, _) in outside.match_indices('(') {
+ let before = &outside[..at];
+ if !(holds_ascii_word(before, "unknown") || before.contains("bad model")) {
+ continue;
+ }
+ let Some(end) = outside[at..].find(')') else { continue };
+ let items = outside[at + 1..at + end].split(", ").flat_map(|item| item.split(" or "));
+ let values: Vec<&str> = items.map(str::trim).take_while(|item| is_ascii_name(item)).collect();
+ if values.len() > 1 {
+ found.extend(values.into_iter().map(str::to_string));
+ }
+ }
+ found
+}
+
+#[test]
+fn field_names_and_values_finds_bracketed_names_and_listed_values() {
+ assert_eq!(field_names_and_values("`{key}` must be [x, y, width, height]: four numbers"), ["x", "y", "width", "height"]);
+ assert_eq!(field_names_and_values("missing anchors [[subpath, anchor]…]"), ["subpath", "anchor"]);
+ assert_eq!(field_names_and_values("arrowScale must be [start, end]"), ["start", "end"]);
+ assert_eq!(field_names_and_values("unknown pieLegend {v} (none, standard or wedges)"), ["none", "standard", "wedges"]);
+ assert_eq!(field_names_and_values("unknown side `{other}` (left|right|top|bottom)"), ["left|right|top|bottom"]);
+ assert_eq!(field_names_and_values("bad model {other} (keep, new or document)"), ["keep", "new", "document"]);
+ assert_eq!(
+ field_names_and_values("unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list)"),
+ ["high", "medium", "low"]
+ );
+ assert_eq!(field_names_and_values("unknown object (or give its ids)"), Vec::::new());
+ assert_eq!(field_names_and_values("unknown color `{c}` (#rrggbb or a preset name)"), Vec::::new());
+ assert_eq!(field_names_and_values("unknown resolution (72 screen, 150 medium, 300 high)"), Vec::::new());
+ assert_eq!(field_names_and_values("the art is [1, 2] (left, right) and `[a, b]`"), Vec::::new());
+}
+
+/// A translated status or error message keeps each field name and accepted value its English text
+/// spells out (see [`field_names_and_values`]), as the English spells it: the message tells a user
+/// what to pass, and a command reads only the English names and values.
+#[test]
+fn translated_messages_keep_the_field_names_and_values_their_english_lists() {
+ let mut missing = Vec::new();
+ for l in &LANGUAGES {
+ let (entries, _) = parse_entries(l.source);
+ for (ctx, src, tr) in entries.iter().filter(|(ctx, _, _)| ctx == "@msg" || ctx == "@msgplural") {
+ let plural = ctx == "@msgplural";
+ let english = if plural { src.rsplit('|').next().unwrap_or(src) } else { src.as_str() };
+ let forms: Vec<&str> = if plural { tr.split('|').collect() } else { vec![tr.as_str()] };
+ for form in forms {
+ for name in field_names_and_values(english) {
+ if !holds_ascii_word(form, &name) {
+ missing.push(format!("{}: {name} in {src:?} -> {form:?}", l.code));
+ }
+ }
+ }
+ }
+ }
+ assert!(missing.is_empty(), "{} rows drop a field name or value their English lists:\n{}", missing.len(), missing.join("\n"));
+}
+
/// Languages whose catalogs translate status and error messages; [`untranslated_report`] lists
/// the messages each one lacks.
const COMPLETE_MESSAGES: &[&str] = &[
diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv
index 2b3e843ba3..a457467ef6 100644
--- a/crates/ui-egui/src/i18n/tr.tsv
+++ b/crates/ui-egui/src/i18n/tr.tsv
@@ -4702,11 +4702,11 @@ axis Both Her İkisi
@msg unknown effect `{id}` bilinmeyen etki `{id}`
@msg unknown format bilinmeyen format
@msg unknown format `{f}` (Save writes {_1}) bilinmeyen format `{f}` (Kaydet, {_1} yazar)
-@msg unknown freeform mode `{m}` (points, lines) bilinmeyen serbest biçimli mod `{m}` (noktalar, çizgiler)
-@msg unknown gradient kind `{k}` (linear, radial, conic, diamond, freeform) bilinmeyen gradyan türü `{k}` (doğrusal, radyal, konik, elmas, serbest biçimli)
+@msg unknown freeform mode `{m}` (points, lines) bilinmeyen serbest biçimli mod `{m}` (points, lines)
+@msg unknown gradient kind `{k}` (linear, radial, conic, diamond, freeform) bilinmeyen gradyan türü `{k}` (linear, radial, conic, diamond, freeform)
@msg unknown highlight `{h}` bilinmeyen vurgu `{h}`
@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 overprints `{o}` (preserve, simulate or discard) bilinmeyen üst baskılar `{o}` (preserve, simulate veya discard)
@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}`
@@ -4725,11 +4725,11 @@ axis Both Her İkisi
@msg `{key}` must be an object `{key}` bir nesne olmalıdır
@msg `prefs.import` runs on its own, not as a batch step `prefs.import` tek başına çalışır, toplu iş adımı olarak değil
@msg unknown preset (one of {_1}) bilinmeyen ön ayar ({_1}'den biri)
-@msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) bilinmeyen ön ayar `{name}` (yüksek, orta, düşük veya kaydedilmiş bir ayar: bkz. flattener.presets.list)
+@msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) bilinmeyen ön ayar `{name}` (high, medium, low veya kaydedilmiş bir ayar: bkz. flattener.presets.list)
@msg unknown preset `{name}` (see flattener.presets.list) bilinmeyen ön ayar `{name}` (bkz. flattener.presets.list)
@msg unknown preset `{name}` (see webExport.presets.list) bilinmeyen ön ayar `{name}` (bkz. webExport.presets.list)
@msg unknown preset `{name}`: not a built-in or saved preset (see imageTrace.presets) bilinmeyen ön ayar `{name}`: yerleşik ya da kayıtlı bir ön ayar değil (bkz. imageTrace.presets)
-@msg unknown side `{other}` (left|right|top|bottom) bilinmeyen taraf `{other}` (sol|sağ|üst|alt)
+@msg unknown side `{other}` (left|right|top|bottom) bilinmeyen taraf `{other}` (left|right|top|bottom)
@msg unknown tool `{tool}` bilinmeyen araç `{tool}`
@msg unknown tool `{t}` bilinmeyen araç `{t}`
@msg unknown tool or command `{key}` bilinmeyen araç veya komut `{key}`
@@ -5038,6 +5038,7 @@ effect Soft Yumuşak
VectorCraft Title Bar on Every Desktop Her Masaüstünde VectorCraft Başlık Çubuğu
The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. VECTORCRAFT_TITLEBAR ortam değişkeni ayarlı olduğu sürece başlık çubuğunu o seçer.
System Title Bar takes precedence over this option while it is on. Sistem Başlık Çubuğu açık olduğu sürece bu seçeneğe göre önceliklidir.
+ This desktop draws the window's title bar with this option on or off while VectorCraft Title Bar on Every Desktop is off. Her Masaüstünde VectorCraft Başlık Çubuğu kapalı olduğu sürece, bu seçenek açık da olsa kapalı da olsa pencerenin başlık çubuğunu bu masaüstü çizer.
Value Axis Değer Ekseni
On Left Side Sol Tarafta
On Right Side Sağ Tarafta
@@ -6556,6 +6557,8 @@ axis Y Axis Y ekseni
Action Options… Eylem Seçenekleri…
Action Options Eylem Seçenekleri
Playback Options… Oynatma Seçenekleri…
+ Button Mode Düğme Modu
+ No actions. Eylem yok.
Insert Stop… Durak Ekle…
Insert Stop Durak Ekle
Record Stop Durak Kaydet
diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv
index 50690fa0c7..18a118822f 100644
--- a/crates/ui-egui/src/i18n/uk.tsv
+++ b/crates/ui-egui/src/i18n/uk.tsv
@@ -5359,6 +5359,7 @@ axis Both Обидві
VectorCraft Title Bar on Every Desktop Рядок заголовка VectorCraft на будь-якій стільниці
The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Поки змінну середовища VECTORCRAFT_TITLEBAR задано, рядок заголовка вибирає вона.
System Title Bar takes precedence over this option while it is on. Поки ввімкнено «Системний рядок заголовка», він має пріоритет над цим параметром.
+ This desktop draws the window's title bar with this option on or off while VectorCraft Title Bar on Every Desktop is off. Поки «Рядок заголовка VectorCraft на будь-якій стільниці» вимкнено, ця стільниця малює рядок заголовка вікна незалежно від того, чи ввімкнено цей параметр.
Value Axis Вісь значень
On Left Side Ліворуч
On Right Side Праворуч
@@ -6206,6 +6207,8 @@ axis Y Axis Вісь Y
Action Options… Параметри дії…
Action Options Параметри дії
Playback Options… Параметри відтворення…
+ Button Mode Режим кнопок
+ No actions. Нема дій.
Insert Stop… Вставити зупинку…
Insert Stop Вставити зупинку
Record Stop Записати зупинку
diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv
index d8469bce53..db36624af7 100644
--- a/crates/ui-egui/src/i18n/vi.tsv
+++ b/crates/ui-egui/src/i18n/vi.tsv
@@ -4036,6 +4036,7 @@ mark-style Japanese Kiểu Nhật
VectorCraft Title Bar on Every Desktop Thanh tiêu đề VectorCraft trên mọi màn hình nền
The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Khi biến môi trường VECTORCRAFT_TITLEBAR được đặt, biến này chọn thanh tiêu đề.
System Title Bar takes precedence over this option while it is on. Khi Thanh tiêu đề hệ thống được bật, tùy chọn đó được ưu tiên hơn tùy chọn này.
+ This desktop draws the window's title bar with this option on or off while VectorCraft Title Bar on Every Desktop is off. Khi Thanh tiêu đề VectorCraft trên mọi màn hình nền đang tắt, màn hình nền này vẽ thanh tiêu đề của cửa sổ dù tùy chọn này bật hay tắt.
Tab Tab
Tab Stops Vị trí tab
Table Bảng
@@ -6207,6 +6208,8 @@ axis Y Axis Trục Y
Action Options… Tùy chọn hành động…
Action Options Tùy chọn hành động
Playback Options… Tùy chọn phát lại…
+ Button Mode Chế độ nút
+ No actions. Không có hành động.
Insert Stop… Chèn điểm dừng…
Insert Stop Chèn điểm dừng
Record Stop Ghi điểm dừng
diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv
index d9d9110276..0b0639e602 100644
--- a/crates/ui-egui/src/i18n/zh-hans.tsv
+++ b/crates/ui-egui/src/i18n/zh-hans.tsv
@@ -5659,6 +5659,7 @@ effect Vertical 垂直
VectorCraft Title Bar on Every Desktop 在所有桌面上使用 VectorCraft 标题栏
The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. 设置了环境变量 VECTORCRAFT_TITLEBAR 时,由它选择标题栏。
System Title Bar takes precedence over this option while it is on. 开启系统标题栏时,它优先于此选项。
+ This desktop draws the window's title bar with this option on or off while VectorCraft Title Bar on Every Desktop is off. 未开启“在所有桌面上使用 VectorCraft 标题栏”时,无论此选项是否开启,此桌面都会绘制窗口的标题栏。
Value Axis 数值轴
On Left Side 左侧
On Right Side 右侧
@@ -6439,6 +6440,8 @@ axis Y Axis Y 轴
Action Options… 动作选项…
Action Options 动作选项
Playback Options… 播放选项…
+ Button Mode 按钮模式
+ No actions. 没有动作。
Insert Stop… 插入停止…
Insert Stop 插入停止
Record Stop 记录停止
diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv
index ec1178652c..e72c0d1a0d 100644
--- a/crates/ui-egui/src/i18n/zh-hant.tsv
+++ b/crates/ui-egui/src/i18n/zh-hant.tsv
@@ -5493,6 +5493,7 @@ effect Soft 柔和
VectorCraft Title Bar on Every Desktop 在所有桌面上使用 VectorCraft 標題列
The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. 設定了環境變數 VECTORCRAFT_TITLEBAR 時,由它選擇標題列。
System Title Bar takes precedence over this option while it is on. 開啟系統標題列時,它優先於此選項。
+ This desktop draws the window's title bar with this option on or off while VectorCraft Title Bar on Every Desktop is off. 未開啟「在所有桌面上使用 VectorCraft 標題列」時,無論此選項是否開啟,此桌面都會繪製視窗的標題列。
Value Axis 數值軸
On Left Side 左側
On Right Side 右側
@@ -6245,6 +6246,8 @@ axis Y Axis Y 軸
Action Options… 動作選項…
Action Options 動作選項
Playback Options… 播放選項…
+ Button Mode 按鈕模式
+ No actions. 沒有動作。
Insert Stop… 插入停止…
Insert Stop 插入停止
Record Stop 記錄停止
diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs
index 83f463d668..924950c1cb 100644
--- a/crates/ui-egui/src/io.rs
+++ b/crates/ui-egui/src/io.rs
@@ -26,13 +26,13 @@ pub(crate) use export::{
export_files, exported_with_notes, file_url, open_in_app, open_original, reveal_path, run_to_file, save_command_output_named,
};
pub(crate) use open::report_installed;
-pub(crate) use open::{EXPORT_PRESETS, import_presets, open_with_on, opened, show_opened};
+pub(crate) use open::{EXPORT_PRESETS, import_presets, open_notes, open_with_on, opened, opened_after_notice, show_opened};
pub use open::{
MAX_RECENT_FILES, OpensAs, ask_revert, install_module, install_plugin, new_from_template, note_recent, open_bytes, open_dialog, open_document,
open_path, open_path_with, open_reporting, open_with, opens_as, place_bytes, recent_files, report_open_error,
};
#[cfg(test)]
-pub(crate) use open::{open_file, opened_after_notice, opened_with_notes};
+pub(crate) use open::{open_file, opened_with_notes};
pub(crate) use save::save_current;
pub use save::{ask_format_options, ask_pdf_options, remember_active_artboard, remember_view, save, save_pdf};
pub(crate) use save::{export_path, is_web, notes_of, report_saved, show_with_notes, target_path, write_named, write_reporting, write_to};
diff --git a/crates/ui-egui/src/io/open.rs b/crates/ui-egui/src/io/open.rs
index 1dec3a1fc7..e0e4e4c404 100644
--- a/crates/ui-egui/src/io/open.rs
+++ b/crates/ui-egui/src/io/open.rs
@@ -162,7 +162,7 @@ pub(crate) fn show_opened(app: &mut VectorcraftApp, notice: Option, note
/// The notes of the open `r`, those of what reading the file left out (its document's
/// `import_losses`) first, each group in the order the import gave them.
-fn open_notes(app: &VectorcraftApp, r: &Value) -> Vec {
+pub(crate) fn open_notes(app: &VectorcraftApp, r: &Value) -> Vec {
let notes = notes_of(r);
let index = r["index"].as_u64().and_then(|i| usize::try_from(i).ok());
let losses = index.and_then(|i| app.session.documents().get(i)).map(|st| st.import_losses.as_slice()).unwrap_or_default();
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
index 4ef21414ee..50ed3d8ee5 100644
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -235,6 +235,12 @@ pub struct VectorcraftApp {
/// icon and title. Set by the desktop app when it opens the window; `false` for embedders
/// without a window of their own.
pub desktop_frame: bool,
+ /// Linux and the BSDs: `VECTORCRAFT_TITLEBAR` is unset and the desktop would draw the window's
+ /// frame on this session with Preferences › User Interface › System Title Bar on or off while
+ /// VectorCraft Title Bar on Every Desktop is off, so the dialog says so under System Title Bar.
+ /// It stays `true` while VectorCraft Title Bar on Every Desktop gives the app the frame. Set by
+ /// the desktop app when it opens the window; `false` elsewhere.
+ pub desktop_keeps_frame: bool,
/// Last window title sent to the OS (`ViewportCommand::Title`, see `chrome::sync_window_title`):
/// sent again only when it changes, so idle frames don't spam the backend.
pub(crate) last_window_title: String,
diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs
index 14e5d053ef..5e05732b62 100644
--- a/crates/ui-egui/src/menus/run.rs
+++ b/crates/ui-egui/src/menus/run.rs
@@ -985,6 +985,8 @@ fn run_ui_part11(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::panels::actions::new_action(app, p),
crate::panels::actions::ACTION_OPTIONS => crate::panels::actions::action_options(app, p),
crate::panels::actions::PLAY_ACTION => crate::panels::actions::play_action(app, p),
+ crate::panels::actions::PLAY_AT => crate::panels::actions::play_at(app, p),
+ crate::panels::actions::BUTTON_MODE => crate::panels::actions::button_mode(app, p),
crate::panels::actions::PLAYBACK_OPTIONS => crate::panels::actions::playback_options(app, p),
crate::panels::actions::START_RECORDING => crate::panels::actions::record(app, p),
crate::panels::actions::STOP_RECORDING => crate::panels::actions::stop(app, p),
diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs
index f56755e184..c6b72ac6b3 100644
--- a/crates/ui-egui/src/menus/ui_commands.rs
+++ b/crates/ui-egui/src/menus/ui_commands.rs
@@ -1466,7 +1466,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
crate::panels::actions::NEW_ACTION,
"New Action",
"",
- "{set: index, name, key?: 0-15, cmd?, shift?, color?: red|yellow|green|blue|violet} add an action named name (spaces trimmed from both ends) after the last action of the Actions panel's set at set (0 is the top set) and start recording into it; Stop Playing/Recording (ui.action.stop) ends the recording. key 1 to 15 is the function key F1 to F15 that plays the action (0 or null: none), with cmd (Ctrl, Command on macOS) and shift; color is its Button Mode color (null or \"\": none), which the panel does not show. Action function keys take priority over command shortcuts and preserve the command's binding. On Windows F1, Ctrl+F4 and Ctrl+F6 cannot be used. The set becomes current and the new action is selected. The panel's Create New Action button and menu's New Action… (dialog `actionNew` with set, name, key, cmd, shift and color) run this on Record. While an action is recording, and for an index past the last set, an empty name, a key or color that is not one of these, or an action that would give the set more than 1,000 actions or an action set file larger than 8 MiB, it is an error, nothing is added and no recording starts → {set, action}",
+ "{set: index, name, key?: 0-15, cmd?, shift?, color?: red|yellow|green|blue|violet} add an action named name (spaces trimmed from both ends) after the last action of the Actions panel's set at set (0 is the top set) and start recording into it; Stop Playing/Recording (ui.action.stop) ends the recording. key 1 to 15 is the function key F1 to F15 that plays the action (0 or null: none), with cmd (Ctrl, Command on macOS) and shift; color is its Button Mode color (null or \"\": none), the color of its button in Button Mode (ui.action.buttonMode). Action function keys take priority over command shortcuts and preserve the command's binding. On Windows F1, Ctrl+F4 and Ctrl+F6 cannot be used. The set becomes current and the new action is selected. The panel's Create New Action button and menu's New Action… (dialog `actionNew` with set, name, key, cmd, shift and color) run this on Record. While an action is recording, and for an index past the last set, an empty name, a key or color that is not one of these, or an action that would give the set more than 1,000 actions or an action set file larger than 8 MiB, it is an error, nothing is added and no recording starts → {set, action}",
),
(
crate::panels::actions::ACTION_OPTIONS,
@@ -1480,6 +1480,18 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"",
"{action: name, set?: name} play the Actions panel's action named action at the speed ui.action.playbackOptions sets; set names the set to look in (default: every set). Accelerated plays it as one undoable batch → what command.batch returns. Step by Step and Pause For play its first step now and the others one at a time, each its own undo step, until the last, ui.action.stop, or a step that comes due while a different document is active than after the step before → {results: [the first step's result], pending: the steps left}. An action with a stop, at any speed, plays up to the stop and waits until ui.action.continue or ui.action.stop; at Accelerated the steps between stops are one undoable batch each → {results, pending, atStop?: true when it already waits}. A name no action has, or one that more than one action in the sets looked in has, or an action played while another is still playing step by step or with pauses, is an error and nothing plays",
),
+ (
+ crate::panels::actions::PLAY_AT,
+ "Play Action",
+ "",
+ "{set: index, action: index} play the Actions panel's action at action in set (0 is the top set) as ui.action.play plays it, and return what ui.action.play returns. A click on an action's button in Button Mode runs this. A missing index, or one that is not a whole number or is past the last set or action, is an error and nothing plays",
+ ),
+ (
+ crate::panels::actions::BUTTON_MODE,
+ "Button Mode",
+ "",
+ "{enabled?: bool} with true, the Actions panel shows every action of every set, in set order and then action order, as a button with its name in its Action Options color (no color: an ordinary button), without set names, steps or checkmarks, and a click on a button plays its action (ui.action.playAt); with false, it shows its list again; left out or null, it switches to the other view. The panel menu's Button Mode runs this and shows a check mark while it is on. The view is kept with the UI preferences. Turning it on while an action records, or an enabled that is not true, false or null, is an error and the view stays → {enabled}",
+ ),
(
crate::panels::actions::PLAYBACK_OPTIONS,
"Playback Options",
diff --git a/crates/ui-egui/src/panels/actions.rs b/crates/ui-egui/src/panels/actions.rs
index 630d81e95f..20a57b7d63 100644
--- a/crates/ui-egui/src/panels/actions.rs
+++ b/crates/ui-egui/src/panels/actions.rs
@@ -32,8 +32,9 @@ pub struct Action {
/// saved value that is not a function key loads as none.
#[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "deserialize_key")]
pub key: Option,
- /// The action's Button Mode color, one of [`COLORS`], if one is assigned. The panel does not
- /// show it. A saved value that is not one of them loads as none.
+ /// The action's Button Mode color, one of [`COLORS`], if one is assigned: the color of its
+ /// button while the panel is in Button Mode ([`button_colors`]). A saved value that is not one
+ /// of them loads as none.
#[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "deserialize_color")]
pub color: Option,
}
@@ -144,6 +145,40 @@ impl FunctionKey {
/// The Button Mode colors an action can have, as (value, label).
pub const COLORS: [(&str, &str); 5] = [("red", "Red"), ("yellow", "Yellow"), ("green", "Green"), ("blue", "Blue"), ("violet", "Violet")];
+/// The fill of a Button Mode button in color `color`, one of [`COLORS`]: VectorCraft's own shades.
+fn button_fill(color: &str) -> Option {
+ let rgb = egui::Color32::from_rgb;
+ Some(match color {
+ "red" => rgb(0xd4, 0x3f, 0x3f),
+ "yellow" => rgb(0xf0, 0xc8, 0x3c),
+ "green" => rgb(0x3f, 0xa8, 0x5a),
+ "blue" => rgb(0x3d, 0x7b, 0xd6),
+ "violet" => rgb(0x8e, 0x5a, 0xd0),
+ _ => return None,
+ })
+}
+
+/// The relative luminance of `c`, as WCAG 2 defines it.
+fn luminance(c: egui::Color32) -> f32 {
+ let channel = |v: u8| {
+ let v = f32::from(v) / 255.0;
+ if v <= 0.040_45 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }
+ };
+ 0.2126 * channel(c.r()) + 0.7152 * channel(c.g()) + 0.0722 * channel(c.b())
+}
+
+/// The (fill, text) colors of the Button Mode button of an action with color `color`: a colored
+/// fill with black or white text, whichever contrasts more with it, or the theme's ordinary button
+/// for an action without a color.
+fn button_colors(color: Option<&str>, t: &Tokens) -> (egui::Color32, egui::Color32) {
+ match color.and_then(button_fill) {
+ // Black text contrasts more than white above this luminance.
+ Some(fill) if luminance(fill) > 0.179 => (fill, egui::Color32::BLACK),
+ Some(fill) => (fill, egui::Color32::WHITE),
+ None => (t.button, t.text_strong),
+ }
+}
+
/// Serde reader for [`Action::key`]: a value that is not a function key is none.
fn deserialize_key<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> {
let v = ::deserialize(d)?;
@@ -888,6 +923,37 @@ pub fn play_action(app: &mut VectorcraftApp, p: &Value) -> Result
}
}
+/// The command that plays an action by its place in the panel ([`play_at`]).
+pub const PLAY_AT: &str = "ui.action.playAt";
+
+/// `ui.action.playAt {set, action}`: plays the action at index `action` of set `set` (0 is the top
+/// set) at the Playback Options speed, as [`play`] does; a click on an action's button in Button
+/// Mode runs this. A missing index, or one that is not a whole number or is past the last set or
+/// action, is an error, and then nothing plays. → what [`play`] returns
+pub fn play_at(app: &mut VectorcraftApp, p: &Value) -> Result {
+ let index = |k: &str| p.get(k).and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok());
+ let (set, at) = index("set").zip(index("action")).ok_or("no such action")?;
+ play(app, set, at)
+}
+
+/// The command behind the panel menu's Button Mode ([`button_mode`]).
+pub const BUTTON_MODE: &str = "ui.action.buttonMode";
+
+/// `ui.action.buttonMode {enabled?}`: with `enabled: true` the Actions panel shows every action of
+/// every set as a button in its color ([`Action::color`]) that plays the action when clicked, and
+/// with `false` it shows its list of sets, actions and steps again; left out or null, it switches
+/// to the other view. The view is kept with the UI preferences. Turning Button Mode on while an
+/// action records is an error, and so is an `enabled` that is not true, false or null; then the
+/// view does not change. → {enabled}
+pub fn button_mode(app: &mut VectorcraftApp, p: &Value) -> Result {
+ let enabled = crate::menus::opt_bool(p, "enabled")?.unwrap_or(!app.ui.action_button_mode);
+ if enabled && !app.ui.action_button_mode && app.ui.recording.is_some() {
+ return Err("an action is already recording".into());
+ }
+ app.ui.action_button_mode = enabled;
+ Ok(json!({ "enabled": enabled }))
+}
+
/// The command that ends a recording ([`stop`]).
pub const STOP_RECORDING: &str = "ui.action.stop";
@@ -2007,9 +2073,18 @@ pub fn duplicate(app: &mut VectorcraftApp, p: &Value) -> Result {
/// The panel's (≡) menu.
pub fn menu(app: &mut VectorcraftApp, ui: &mut egui::Ui) {
+ // Button Mode is off while an action records, so the Stop button the list shows stays in reach.
+ let buttons = app.ui.action_button_mode;
+ if widgets::menu_item(ui, tl!("Button Mode"), buttons || app.ui.recording.is_none(), buttons)
+ && let Err(e) = app.run(BUTTON_MODE, json!({}))
+ {
+ app.status(e);
+ }
+ ui.separator();
// A selected action counts only while its set is the current one, as the panel shows it: a set
- // deleted since leaves the stored pair pointing at another set's action.
- let selected = pstate::>(ui.ctx(), "actions-selected").filter(|&(s, _)| app.ui.action_set == Some(s));
+ // deleted since leaves the stored pair pointing at another set's action. Button Mode shows no
+ // selected action and no current set, so the items that act on them are off there.
+ let selected = pstate:: >(ui.ctx(), "actions-selected").filter(|&(s, _)| !buttons && app.ui.action_set == Some(s));
let has = selected.and_then(|(s, a)| app.ui.action_sets.get(s).and_then(|set| set.actions.get(a))).is_some();
if widgets::menu_item(ui, tl!("New Action…"), crate::dialogs::action_new::can_open(app), false)
&& let Err(e) = crate::dialogs::action_new::open(app)
@@ -2019,11 +2094,11 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut egui::Ui) {
if widgets::menu_item(ui, tl!("New Set…"), true, false) {
crate::dialogs::action_set_options::open_new(app);
}
- let current = app.ui.action_set.filter(|&s| s < app.ui.action_sets.len());
+ let current = app.ui.action_set.filter(|&s| !buttons && s < app.ui.action_sets.len());
if widgets::menu_item(ui, tl!("Duplicate"), current.is_some(), false) {
duplicate_selection(app, ui.ctx());
}
- if widgets::menu_item(ui, tl!("Delete"), has || !selected_sets(app).is_empty(), false) {
+ if widgets::menu_item(ui, tl!("Delete"), has || (!buttons && !selected_sets(app).is_empty()), false) {
delete_selection(app, ui.ctx());
}
if widgets::menu_item(ui, tl!("Set Options…"), current.is_some(), false)
@@ -2156,8 +2231,78 @@ enum InclusionChange {
Step(usize, usize, usize, bool),
}
-/// Draw action sets, steps and playback controls, keeping the list above a pinned footer.
+/// Button Mode ([`button_mode`]): every action of every set, in set order and then action order,
+/// as a button with its name in its color ([`button_colors`]), with no set names, steps or
+/// checkmarks. A click on a button, or Enter or Space on the focused one, plays the action
+/// ([`PLAY_AT`]). The footer shows only while an action plays or records: Stop Playing/Recording,
+/// and Play Current Selection while a playback waits at a stop.
+fn show_buttons(app: &mut VectorcraftApp, ui: &mut egui::Ui) {
+ let t = Tokens::get(ui.ctx());
+ let mut play_req = None;
+ let list_height = widgets::panel_list_height(ui, 260.0, 0.0).min(260.0);
+ egui::ScrollArea::vertical().id_salt("actions-buttons").max_height(list_height).min_scrolled_height(list_height.min(64.0)).show(ui, |ui| {
+ for (si, set) in app.ui.action_sets.iter().enumerate() {
+ for (ai, a) in set.actions.iter().enumerate() {
+ let (r, resp) = ui.allocate_exact_size(egui::vec2(ui.available_width(), 24.0), egui::Sense::click());
+ resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &a.name));
+ let (fill, text) = button_colors(a.color.as_deref(), &t);
+ let button = r.shrink2(egui::vec2(2.0, 1.0));
+ ui.painter().rect_filled(button, 3.0, fill);
+ // The pointer over a button, or the keyboard focus on it, rings it in its text color.
+ let (width, ring) = if resp.hovered() || resp.has_focus() { (2.0, text) } else { (1.0, t.button_border) };
+ ui.painter().rect_stroke(button, 3.0, egui::Stroke::new(width, ring), egui::StrokeKind::Inside);
+ ui.painter().with_clip_rect(button.shrink2(egui::vec2(6.0, 0.0))).text(
+ button.left_center() + egui::vec2(8.0, 0.0),
+ egui::Align2::LEFT_CENTER,
+ &a.name,
+ egui::FontId::proportional(12.5),
+ text,
+ );
+ if resp.on_hover_text(&a.name).clicked() {
+ play_req = Some((si, ai));
+ }
+ }
+ }
+ });
+ if app.ui.action_sets.iter().all(|s| s.actions.is_empty()) {
+ dim_label(ui, tl!("No actions."));
+ }
+ let waiting = waiting_at_stop(app);
+ if waiting {
+ dim_label(ui, tl!("Press Play Current Selection in the Actions panel to go on."));
+ }
+ let recording = app.ui.recording.is_some();
+ if recording || app.ui.action_playing.is_some() {
+ widgets::bottom_bar(ui, |ui| {
+ if widgets::icon_button(ui, "square", tl!("Stop Playing/Recording"), false, 24.0).clicked()
+ && let Err(e) = app.run(STOP_RECORDING, json!({}))
+ {
+ app.status(e);
+ }
+ if waiting
+ && widgets::icon_button(ui, "dc-actions", tl!("Play Current Selection"), false, 24.0).clicked()
+ && let Err(e) = app.run(CONTINUE_PLAYBACK, json!({}))
+ {
+ app.status(e);
+ }
+ if recording {
+ ui.label(egui::RichText::new(tl!("● Recording")).color(egui::Color32::from_rgb(0xe0, 0x30, 0x30)));
+ }
+ });
+ }
+ if let Some((si, ai)) = play_req
+ && let Err(e) = app.run(PLAY_AT, json!({"set": si, "action": ai}))
+ {
+ app.status(e);
+ }
+}
+
+/// Draw action sets, steps and playback controls, keeping the list above a pinned footer, or in
+/// Button Mode the actions as buttons ([`show_buttons`]).
pub fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) {
+ if app.ui.action_button_mode {
+ return show_buttons(app, ui);
+ }
let t = Tokens::get(ui.ctx());
// The selected action as (set, action). Read as the type it is stored as: egui keeps a
// value per type, so reading the bare pair never found it and no action stayed selected (#1629).
@@ -2672,6 +2817,186 @@ mod tests {
assert_eq!(names(&app), before[1..], "nothing selected after a delete: nothing more goes");
}
+ /// The fills of the rectangles drawn in one headless frame of the panel.
+ fn drawn_fills(ctx: &egui::Context, app: &mut VectorcraftApp) -> Vec {
+ fn collect(s: &egui::Shape, out: &mut Vec) {
+ match s {
+ egui::Shape::Rect(r) => out.push(r.fill),
+ egui::Shape::Vec(v) => v.iter().for_each(|s| collect(s, out)),
+ _ => {}
+ }
+ }
+ let mut output = ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui));
+ output.textures_delta.clear();
+ let mut fills = vec![];
+ output.shapes.iter().for_each(|c| collect(&c.shape, &mut fills));
+ fills
+ }
+
+ /// The panel menu's Button Mode switches the panel to buttons and back, with a check mark while
+ /// it is on; `ui.action.buttonMode` does the same, and the view is kept with the UI preferences.
+ /// Turning it on while an action records is refused, so Stop stays in reach.
+ #[test]
+ fn button_mode_switches_the_view_and_is_kept_with_the_preferences() {
+ use super::super::tests_appearance::{click, frame_events};
+ let mut app = app_with_sets();
+ assert!(!app.ui.action_button_mode, "the list is the default");
+ let ctx = egui::Context::default();
+ let item = |ctx: &egui::Context, app: &mut VectorcraftApp| {
+ let texts = frame_events(ctx, app, vec![], menu);
+ texts.into_iter().find(|(s, _)| s.trim_start_matches('✓').trim() == "Button Mode").expect("Button Mode in the menu")
+ };
+ let (label, at) = item(&ctx, &mut app);
+ assert_eq!(label, " Button Mode", "no check mark while off");
+ click(&ctx, &mut app, at.center(), menu);
+ assert!(app.ui.action_button_mode, "the menu item turns it on");
+ assert_eq!(item(&ctx, &mut app).0, "✓ Button Mode", "a check mark while on");
+ let at = item(&ctx, &mut app).1;
+ click(&ctx, &mut app, at.center(), menu);
+ assert!(!app.ui.action_button_mode, "choosing it again returns to the list");
+
+ assert_eq!(app.run(BUTTON_MODE, json!({})).unwrap(), json!({"enabled": true}), "left out: the other view");
+ assert_eq!(app.run(BUTTON_MODE, json!({"enabled": true})).unwrap(), json!({"enabled": true}));
+ assert_eq!(app.run(BUTTON_MODE, json!({"enabled": "no"})).unwrap_err(), "`enabled` must be true or false");
+ assert!(app.ui.action_button_mode, "a bad value keeps the view");
+ let saved = serde_json::to_value(&app.ui).unwrap();
+ assert_eq!(saved["action_button_mode"], json!(true));
+ assert!(serde_json::from_value::(saved.clone()).unwrap().action_button_mode, "kept after a restart");
+ let mut older = saved;
+ older.as_object_mut().unwrap().remove("action_button_mode");
+ assert!(!serde_json::from_value::(older).unwrap().action_button_mode, "older preferences show the list");
+
+ assert_eq!(app.run(BUTTON_MODE, json!({"enabled": null})).unwrap(), json!({"enabled": false}));
+ app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
+ app.run(START_RECORDING, json!({})).unwrap();
+ assert_eq!(app.run(BUTTON_MODE, json!({})).unwrap_err(), "an action is already recording");
+ let at = item(&ctx, &mut app).1;
+ click(&ctx, &mut app, at.center(), menu);
+ assert!(!app.ui.action_button_mode, "the menu item is off while an action records");
+ assert_eq!(app.run(BUTTON_MODE, json!({"enabled": false})).unwrap(), json!({"enabled": false}), "turning it off is never refused");
+ app.run(STOP_RECORDING, json!({})).unwrap();
+ }
+
+ /// Button Mode shows every action of every set by its name, in set order and then action order,
+ /// without set names, step counts or steps, each in its Action Options color; an action without
+ /// a color is an ordinary button. The menu items for a selected action or the current set are
+ /// off, since the view shows neither.
+ #[test]
+ fn button_mode_shows_every_action_as_a_button_in_its_color() {
+ use super::super::tests_appearance::{click, frame_events, text_rect};
+ let mut app = app_with_sets();
+ app.run(ACTION_OPTIONS, json!({"set": 1, "action": 0, "color": "red"})).unwrap();
+ app.run(ACTION_OPTIONS, json!({"set": 1, "action": 1, "color": "yellow"})).unwrap();
+ let ctx = egui::Context::default();
+ let texts = frame_events(&ctx, &mut app, vec![], show);
+ click(&ctx, &mut app, text_rect(&texts, "Nudge").center(), show);
+ assert_eq!(pstate::>(&ctx, "actions-selected"), Some((1, 0)), "selected in the list");
+ app.run(BUTTON_MODE, json!({"enabled": true})).unwrap();
+ let texts = frame_events(&ctx, &mut app, vec![], show);
+ let names: Vec<&str> = texts.iter().map(|(s, _)| s.as_str()).collect();
+ assert_eq!(
+ names,
+ [
+ "Duplicate and Offset 10 pt",
+ "Unite and Simplify",
+ "Center on Artboard",
+ "Outline Text",
+ "Rotate 90° CW",
+ "Reflect Horizontal",
+ "Nudge",
+ "Nudge Again"
+ ],
+ "only the actions' names, in set and action order"
+ );
+ let tops: Vec = texts.iter().map(|(_, r)| r.top()).collect();
+ assert!(tops.windows(2).all(|w| w[0] < w[1]), "one button under the other: {tops:?}");
+ let t = Tokens::get(&ctx);
+ let fills = drawn_fills(&ctx, &mut app);
+ for color in ["red", "yellow"] {
+ let fill = button_fill(color).unwrap();
+ assert_eq!(fills.iter().filter(|f| **f == fill).count(), 1, "{color}: {fills:?}");
+ }
+ assert_eq!(fills.iter().filter(|f| **f == t.button).count(), 6, "the six actions without a color: {fills:?}");
+ for (color, _) in COLORS {
+ let (fill, text) = button_colors(Some(color), &t);
+ let (hi, lo) = (luminance(fill).max(luminance(text)), luminance(fill).min(luminance(text)));
+ assert!((hi + 0.05) / (lo + 0.05) >= 4.5, "{color}: its name is legible");
+ }
+ assert_eq!(button_colors(None, &t), (t.button, t.text_strong));
+ let menu_texts = frame_events(&ctx, &mut app, vec![], menu);
+ click(&ctx, &mut app, text_rect(&menu_texts, " Delete").center(), menu);
+ click(&ctx, &mut app, text_rect(&menu_texts, " Action Options…").center(), menu);
+ assert!(app.ui.dialog.is_none(), "Delete and Action Options… are off in Button Mode");
+ assert_eq!(set_names(&app), ["Default Actions", "Mine", "Other"]);
+ assert_eq!(action_names(&app, 1), ["Nudge", "Nudge Again"], "the action selected in the list stays");
+ }
+
+ /// A click on a button plays that action at once, as one undo step at Accelerated, without its
+ /// excluded steps, which stay excluded; `ui.action.playAt` refuses an index that names no action.
+ #[test]
+ fn a_click_on_a_button_plays_its_action_without_its_excluded_steps() {
+ use super::super::tests_appearance::{click, frame_events, text_rect};
+ let (mut app, x) = app_with_three_moves();
+ app.run(INCLUDE_STEPS, json!({"set": 0, "action": 0, "step": 1, "included": false})).unwrap();
+ app.run(BUTTON_MODE, json!({"enabled": true})).unwrap();
+ let ctx = egui::Context::default();
+ let texts = frame_events(&ctx, &mut app, vec![], show);
+ let undo = undo_steps(&app);
+ click(&ctx, &mut app, text_rect(&texts, "Three Moves").center(), show);
+ assert_eq!((x(&app), undo_steps(&app)), (5.0, undo + 1), "1 and 4 pt as one undo step; the 2 pt step is skipped");
+ assert_eq!(app.ui.action_sets[0].actions[0].excluded_steps, BTreeSet::from([1]), "the exclusion stays");
+ for (p, want) in [
+ (json!({"set": 0, "action": 1}), "no such action"),
+ (json!({"set": 1, "action": 0}), "no such action"),
+ (json!({"set": "0", "action": 0}), "no such action"),
+ (json!({"set": 0}), "no such action"),
+ ] {
+ assert_eq!(app.run(PLAY_AT, p.clone()).unwrap_err(), want, "{p}");
+ }
+ assert_eq!(x(&app), 5.0, "nothing more played");
+ app.run(PLAY_AT, json!({"set": 0, "action": 0})).unwrap();
+ assert_eq!(x(&app), 10.0);
+ }
+
+ /// Button Mode shows no bottom bar while nothing plays or records. While an action plays step by
+ /// step its Stop Playing/Recording button ends the playback, and while one waits at a stop its
+ /// Play Current Selection button plays on.
+ #[test]
+ fn button_mode_shows_stop_and_continue_only_while_an_action_plays() {
+ use super::super::tests_appearance::{click, frame_events, text_rect};
+ let bar = |ctx: &egui::Context, app: &mut VectorcraftApp| {
+ frame_events(ctx, app, vec![], show);
+ let mut bar = crate::toolbar::tests::widget_rects(ctx, egui::vec2(24.0, 24.0));
+ bar.sort_by(|a, b| a.left().total_cmp(&b.left()));
+ // A button can be listed more than once in a pass.
+ bar.dedup();
+ bar
+ };
+ let (mut app, x) = app_with_three_moves();
+ app.run(BUTTON_MODE, json!({"enabled": true})).unwrap();
+ app.run(PLAYBACK_OPTIONS, json!({"speed": "step"})).unwrap();
+ let ctx = egui::Context::default();
+ assert_eq!(bar(&ctx, &mut app), [], "no bottom bar while idle");
+ let texts = frame_events(&ctx, &mut app, vec![], show);
+ click(&ctx, &mut app, text_rect(&texts, "Three Moves").center(), show);
+ assert_eq!(x(&app), 1.0, "the first step played");
+ let [stop] = bar(&ctx, &mut app)[..] else { panic!("only Stop while it plays") };
+ click(&ctx, &mut app, stop.center(), show);
+ assert!(app.ui.action_playing.is_none(), "Stop ends the playback");
+ assert_eq!(x(&app), 1.0);
+
+ let (mut app, x) = app_with_a_stop(true);
+ app.run(BUTTON_MODE, json!({"enabled": true})).unwrap();
+ let texts = frame_events(&ctx, &mut app, vec![], show);
+ click(&ctx, &mut app, text_rect(&texts, "Three Moves").center(), show);
+ assert!(waiting_at_stop(&app));
+ assert_eq!(x(&app), 1.0, "played up to the stop");
+ let [_, play] = bar(&ctx, &mut app)[..] else { panic!("Stop and Play Current Selection at a stop") };
+ click(&ctx, &mut app, play.center(), show);
+ assert!(app.ui.action_playing.is_none());
+ assert_eq!(x(&app), 7.0, "Play Current Selection plays on");
+ }
+
/// #3712: Create New Set asks for a name (Set 1 when free), the new set is the current one, so a
/// recording goes into it, and a click on another set's name makes that one current. The sets
/// are saved with the UI preferences.
diff --git a/crates/ui-egui/src/panels/learn.rs b/crates/ui-egui/src/panels/learn.rs
index ee7967c8a3..5d24167c99 100644
--- a/crates/ui-egui/src/panels/learn.rs
+++ b/crates/ui-egui/src/panels/learn.rs
@@ -8,7 +8,8 @@
//! next tutorial in the same skill. `learn.list` lists the tutorials, `learn.skills` shows the list
//! of skills, `learn.show` opens one tutorial, `learn.locateTool` shows where a tool is,
//! `learn.openSample`, `learn.finish` and `learn.next` run the three practice controls, and
-//! `window.learn` shows or hides the panel.
+//! `window.learn` shows or hides the panel. The Home screen's Learn section shows the same card
+//! ([`guide_card`]) and the tutorials, which open the panel.
pub mod catalog;
pub mod locate;
@@ -68,8 +69,16 @@ fn for_you(app: &mut VectorcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
crate::widgets::subheader(ui, tl!("Get started"));
ui.add_space(4.0);
+ guide_card(app, ui, ui.scope_id().with("quick-skill-guide"), t.panel_darker);
+}
+
+/// The Quick skill guide card, filled with `fill`, as one button with the id `id`: a click, or Enter
+/// or Space while it has the keyboard focus, shows the panel's list of skills (`learn.skills`). The
+/// For you tab and the Home screen's Learn section show it.
+pub(crate) fn guide_card(app: &mut VectorcraftApp, ui: &mut Ui, id: egui::Id, fill: egui::Color32) {
+ let t = Tokens::get(ui.ctx());
let card = egui::Frame::NONE
- .fill(t.panel_darker)
+ .fill(fill)
.stroke(Stroke::new(1.0, t.border))
.corner_radius(egui::CornerRadius::same(t.radius))
.inner_margin(egui::Margin::same(10))
@@ -86,8 +95,12 @@ fn for_you(app: &mut VectorcraftApp, ui: &mut Ui) {
.color(t.text_dim),
));
});
- let resp = ui.interact(card.response.rect, ui.scope_id().with("quick-skill-guide"), Sense::click());
+ let resp = ui.interact(card.response.rect, id, Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!("Quick skill guide")));
+ if resp.has_focus() {
+ ui.painter().rect_stroke(resp.rect, egui::CornerRadius::same(t.radius), Stroke::new(1.0, t.selection), egui::StrokeKind::Inside);
+ }
+ let resp = resp.on_hover_cursor(egui::CursorIcon::PointingHand);
if resp.clicked()
&& let Err(e) = app.run("learn.skills", json!({}))
{
diff --git a/crates/ui-egui/src/prefs_dialog.rs b/crates/ui-egui/src/prefs_dialog.rs
index fbea47770b..9029459195 100644
--- a/crates/ui-egui/src/prefs_dialog.rs
+++ b/crates/ui-egui/src/prefs_dialog.rs
@@ -42,6 +42,9 @@ pub fn open(app: &mut VectorcraftApp, category: Option<&str>) {
// Read-only: whether VECTORCRAFT_TITLEBAR chose this window's frame (System Title Bar's dimmed
// row and VectorCraft Title Bar on Every Desktop's note).
fields.insert("__titlebarFromEnv".into(), json!(app.titlebar_from_env));
+ // Read-only: whether the desktop would draw this window's frame with System Title Bar on or off
+ // while VectorCraft Title Bar on Every Desktop is off (System Title Bar's note).
+ fields.insert("__desktopKeepsFrame".into(), json!(app.desktop_keeps_frame));
fields.insert("__search".into(), json!(""));
fields.insert("__focusSearch".into(), json!(true));
app.ui.dialog = Some(Dialog { kind: "preferences".into(), fields });
@@ -535,6 +538,14 @@ fn category_fields(ui: &mut egui::Ui, app: &mut VectorcraftApp, d: &mut Dialog,
if !APP_TITLE_BAR_SHOWN {
ui.label(egui::RichText::new(tl!("Applies the next time VectorCraft starts.")).color(t.text_dim).size(11.0));
}
+ // Where the desktop draws the frame, the desktop's title bar stays with this
+ // option on or off until VectorCraft Title Bar on Every Desktop is turned on.
+ if APP_TITLE_BAR_SHOWN && d.bool("__desktopKeepsFrame") && !d.bool("__titlebarFromEnv") && !d.bool("appTitleBar") {
+ let note = tl!(
+ "This desktop draws the window's title bar with this option on or off while VectorCraft Title Bar on Every Desktop is off."
+ );
+ ui.label(egui::RichText::new(note).color(t.text_dim).size(11.0));
+ }
}
}
// Linux and the BSDs only, where the desktop may draw the frame: dimmed while System
diff --git a/crates/ui-egui/src/prefs_dialog/tests.rs b/crates/ui-egui/src/prefs_dialog/tests.rs
index fe8dc72aa3..6c7382200f 100644
--- a/crates/ui-egui/src/prefs_dialog/tests.rs
+++ b/crates/ui-egui/src/prefs_dialog/tests.rs
@@ -581,6 +581,46 @@ fn system_title_bar_is_dimmed_while_the_environment_variable_chooses_the_frame()
}
}
+/// Where the desktop draws the frame with System Title Bar on or off (KDE, Sway, X11, a window
+/// opened through XWayland), a note under System Title Bar says so while VectorCraft Title Bar on
+/// Every Desktop is off and VECTORCRAFT_TITLEBAR is unset. Linux and the BSDs only.
+#[test]
+fn system_title_bar_says_when_the_desktop_keeps_the_frame() {
+ fn texts(s: &egui::Shape, out: &mut Vec) {
+ match s {
+ egui::Shape::Text(t) => out.push(t.galley.text().to_string()),
+ egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)),
+ _ => {}
+ }
+ }
+ let note = "This desktop draws the window's title bar with this option on or off while VectorCraft Title Bar on Every Desktop is off.";
+ let supported = cfg!(not(any(target_arch = "wasm32", target_os = "macos", target_os = "windows")));
+ for keeps in [false, true] {
+ for from_env in [false, true] {
+ for (app_title_bar, system) in [(false, false), (false, true), (true, false), (true, true)] {
+ let mut a = app();
+ a.desktop_keeps_frame = keeps;
+ a.titlebar_from_env = from_env;
+ open(&mut a, Some("User Interface"));
+ let fields = &mut a.ui.dialog.as_mut().unwrap().fields;
+ fields.insert("appTitleBar".into(), json!(app_title_bar));
+ fields.insert("systemTitleBar".into(), json!(system));
+ let ctx = egui::Context::default();
+ crate::theme::install_fonts(&ctx);
+ let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut a, ui.ctx()));
+ out.textures_delta.clear();
+ let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut a, ui.ctx()));
+ out.textures_delta.clear();
+ let mut shown = vec![];
+ out.shapes.iter().for_each(|c| texts(&c.shape, &mut shown));
+ let expected = supported && keeps && !from_env && !app_title_bar;
+ let label = format!("keeps {keeps}, variable {from_env}, app title bar {app_title_bar}, system {system}: {shown:?}");
+ assert_eq!(shown.iter().any(|t| t == note), expected, "{label}");
+ }
+ }
+ }
+}
+
/// Performance › Linux Display Server (#2028): on Linux it shows the drawing-tablet note, and
/// X11 (XWayland) When a Pen Is Attached is applied by OK and saved under the key the desktop
/// app reads before the window opens.
diff --git a/crates/ui-egui/src/recovery.rs b/crates/ui-egui/src/recovery.rs
index 3b05ba0120..aa0a25e50f 100644
--- a/crates/ui-egui/src/recovery.rs
+++ b/crates/ui-egui/src/recovery.rs
@@ -29,6 +29,17 @@ pub struct Timer {
/// The recovery folders that failure messages named, latest last, at most [`NAMED`]: replies to
/// automation clients hide those outside their read roots.
pub named: Vec,
+ /// The restored documents whose linked files are still to be asked about, next first
+ /// ([`ask_next`]).
+ links: Vec,
+}
+
+/// The questions about the linked files of a restored document (by uid): the missing files, then
+/// the modified images (ids) to offer for update ([`crate::dialogs::missing_links::ask_next`]).
+struct LinkQuestions {
+ uid: u64,
+ missing: Vec,
+ modified: Vec,
}
/// How many recovery folders [`Timer::named`] keeps.
@@ -187,28 +198,91 @@ fn named(app: &mut VectorcraftApp, folder: &str) {
}
/// The startup dialog's OK (Restore) or Discard: every copy it lists.
+///
+/// After Restore the status bar shows how many documents were restored, followed by the first of
+/// the restored documents' notes: for each document in the dialog's order, the lines about its
+/// linked files, then what reading the copy left out, then its other warnings, each named with
+/// its document's title when more than one was restored. Hovering the message lists them all. The
+/// questions about the documents' linked files are asked one document at a time ([`ask_next`]).
+/// When a copy can't be restored, the status bar shows the error, and its hover lists the count
+/// and the notes of the documents that were restored.
pub fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result {
app.ui.dialog = None;
- let id = if d.bool("discard") { "file.recovery.discard" } else { "file.recovery.restore" };
+ let discard = d.bool("discard");
+ let id = if discard { "file.recovery.discard" } else { "file.recovery.restore" };
let files: Vec =
d.fields.get("copies").and_then(Value::as_array).into_iter().flatten().filter_map(|c| c["file"].as_str()).map(str::to_string).collect();
- let (mut done, mut errors) = (vec![], vec![]);
+ let (mut done, mut errors, mut opened) = (vec![], vec![], vec![]);
for file in files {
match app.run(id, json!({ "file": file })) {
Ok(r) => {
- if let Some(restored) = r["restored"].as_array().and_then(|v| v.first()) {
- crate::dialogs::missing_links::after_open(app, restored, Vec::new());
+ for restored in r["restored"].as_array().into_iter().flatten() {
+ opened.push(restored_notes(app, restored));
}
+ let failed = r["failed"].as_array().into_iter().flatten();
+ errors.extend(failed.filter_map(|f| f["error"].as_str()).map(str::to_string));
done.push(r);
}
Err(e) => errors.push(e),
}
}
- if !errors.is_empty() {
- return Err(errors.join("; "));
+ if discard {
+ if !errors.is_empty() {
+ return Err(errors.join("; "));
+ }
+ app.status(recovered_status(true, done.len()));
+ return Ok(json!(done));
+ }
+ let summary = recovered_status(false, opened.len());
+ let titled = opened.len() > 1;
+ let restored = !opened.is_empty();
+ let notes: Vec = opened
+ .into_iter()
+ .flat_map(|(title, notes)| {
+ notes.into_iter().map(move |n| if titled { crate::i18n::fill_once("{path}: {e}", &[("path", &title), ("e", &n)]) } else { n })
+ })
+ .collect();
+ let result = if errors.is_empty() {
+ let status = match notes.first() {
+ Some(first) => crate::io::opened_after_notice(&summary, notes.len(), first),
+ None => summary,
+ };
+ crate::io::show_with_notes(app, status, notes);
+ Ok(json!(done))
+ } else {
+ let e = errors.join("; ");
+ if restored {
+ crate::io::show_with_notes(app, e.clone(), std::iter::once(summary).chain(notes).collect());
+ }
+ Err(e)
+ };
+ crate::dialogs::settle(app);
+ result
+}
+
+/// A document Restore just opened (`file.recovery.restore`'s entry `r`, the current document) →
+/// its title and notes ([`confirm`]). Its Missing Fonts dialog and the questions about its linked
+/// files wait for their turn.
+fn restored_notes(app: &mut VectorcraftApp, r: &Value) -> (String, Vec) {
+ crate::dialogs::missing_fonts::after_open(app, r);
+ let (mut notes, missing, modified) = crate::dialogs::missing_links::questions(app, r);
+ let uid = r["index"].as_u64().and_then(|i| usize::try_from(i).ok()).and_then(|i| app.session.documents().get(i)).map(|st| st.uid);
+ if let Some(uid) = uid.filter(|_| !missing.is_empty() || !modified.is_empty()) {
+ app.recovery.links.push(LinkQuestions { uid, missing, modified });
+ }
+ notes.extend(crate::io::open_notes(app, r));
+ (r["title"].as_str().unwrap_or_default().to_string(), notes)
+}
+
+/// With no dialog open: the questions about the linked files of the next restored document that
+/// is still open, with that document made the current one (the answers change it).
+pub(crate) fn ask_next(app: &mut VectorcraftApp) {
+ while app.ui.dialog.is_none() && !app.recovery.links.is_empty() {
+ let q = app.recovery.links.remove(0);
+ let Some(i) = app.session.documents().iter().position(|st| st.uid == q.uid) else { continue };
+ crate::chrome::show_document(app, i);
+ crate::dialogs::missing_links::ask_next(app, q.missing, q.modified, Vec::new());
}
- app.status(recovered_status(id == "file.recovery.discard", done.len()));
- Ok(json!(done))
}
/// The status bar's line for `n` recovered documents discarded or restored.
diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs
index a06dc2f1df..a29ca50e41 100644
--- a/crates/ui-egui/src/state.rs
+++ b/crates/ui-egui/src/state.rs
@@ -618,6 +618,11 @@ pub struct UiState {
/// Shift-click on a set's name added. Delete Selection deletes them. Not saved.
#[serde(skip)]
pub action_sets_selected: Vec,
+ /// The Actions panel shows its actions as buttons (panel menu › Button Mode,
+ /// [`crate::panels::actions::button_mode`]) instead of its list; kept with the UI preferences,
+ /// and off in preferences saved without it.
+ #[serde(default)]
+ pub action_button_mode: bool,
/// Recording in progress: (set index, action name, journal length when recording started).
#[serde(skip)]
pub recording: Option<(usize, String, usize)>,
@@ -1171,6 +1176,7 @@ impl Default for UiState {
action_sets: crate::panels::actions::default_sets(),
action_set: None,
action_sets_selected: vec![],
+ action_button_mode: false,
recording: None,
recording_into: None,
recording_at: None,
diff --git a/crates/ui-egui/src/tests/home.rs b/crates/ui-egui/src/tests/home.rs
index d1c20991af..6cfdf78f38 100644
--- a/crates/ui-egui/src/tests/home.rs
+++ b/crates/ui-egui/src/tests/home.rs
@@ -299,6 +299,91 @@ fn home_scrolls_in_a_short_window() {
assert_eq!(app.session.documents().len(), 1, "the card opened its preset");
}
+/// The visible text `text` on a tall Home screen frame drawn at `time` → its center.
+fn learn_text_at(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, text: &str) -> egui::Pos2 {
+ let texts = visible_home_texts(app, ctx, (1000.0, 2400.0), time, vec![]);
+ texts.iter().find(|(t, _)| t == text).map(|(_, r)| r.center()).unwrap_or_else(|| panic!("{text} is shown: {texts:?}"))
+}
+
+/// A click at `at` on a tall Home screen frame, pressed at `time` and released a frame later.
+fn click_home_at(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, at: egui::Pos2) {
+ let press = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
+ visible_home_texts(app, ctx, (1000.0, 2400.0), time, vec![egui::Event::PointerMoved(at), press(true)]);
+ visible_home_texts(app, ctx, (1000.0, 2400.0), time + 0.05, vec![press(false)]);
+}
+
+/// The Home screen has a Learn section: the Quick skill guide card and every tutorial of the Learn
+/// panel under its skill. A tutorial shows the Learn panel at its steps and the card shows the list
+/// of skills; a second choice keeps the panel shown, and no document is opened.
+#[test]
+fn home_has_a_learn_section_that_opens_the_learn_panel() {
+ use crate::panels::learn::{self, Tab, View, catalog};
+ let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
+ let ctx = egui::Context::default();
+ crate::theme::install_fonts(&ctx);
+ let texts = visible_home_texts(&mut app, &ctx, (1000.0, 2400.0), 0.0, vec![]);
+ let has = |s: &str| texts.iter().any(|(t, _)| t == s);
+ assert!(has("Learn") && has("Quick skill guide"), "{texts:?}");
+ for skill in catalog::SKILLS {
+ assert!(has(skill.title), "{} is shown", skill.title);
+ for tutorial in skill.tutorials {
+ assert!(has(tutorial.title), "{} is shown", tutorial.title);
+ }
+ }
+ assert!(!learn::visible(&app.ui), "the panel is not shown before a choice");
+ let at = learn_text_at(&mut app, &ctx, 0.5, "Draw paths with the Pen");
+ click_home_at(&mut app, &ctx, 1.0, at);
+ assert!(learn::visible(&app.ui), "a tutorial shows the Learn panel");
+ assert_eq!(app.ui.learn, View { tab: Tab::Skills, skill: Some("draw"), tutorial: Some("pen-paths") });
+ assert!(app.session.documents().is_empty(), "no document is opened");
+ assert!(crate::menus::home_showing(&app), "Home stays");
+ let at = learn_text_at(&mut app, &ctx, 1.5, "Quick skill guide");
+ click_home_at(&mut app, &ctx, 2.0, at);
+ assert!(learn::visible(&app.ui), "a second choice keeps the panel shown");
+ assert_eq!(app.ui.learn, View { tab: Tab::Skills, skill: None, tutorial: None }, "the card shows the list of skills");
+ assert!(app.session.documents().is_empty());
+}
+
+/// Home shown over open documents has the Learn section too; a tutorial chosen there leaves the
+/// documents and the active one as they are.
+#[test]
+fn home_learn_section_over_open_documents_leaves_them_as_they_are() {
+ use crate::panels::learn::{self, Tab, View};
+ let mut app = app_with_doc();
+ app.run("file.new", json!({})).unwrap();
+ app.run("document.activate", json!({"index": 0})).unwrap();
+ app.run("app.home", json!({})).unwrap();
+ let ctx = egui::Context::default();
+ crate::theme::install_fonts(&ctx);
+ let at = learn_text_at(&mut app, &ctx, 0.0, "Add a gradient");
+ click_home_at(&mut app, &ctx, 0.5, at);
+ assert!(learn::visible(&app.ui));
+ assert_eq!(app.ui.learn, View { tab: Tab::Skills, skill: Some("color"), tutorial: Some("gradient") });
+ assert_eq!((app.session.documents().len(), app.session.active_index()), (2, Some(0)), "the documents stay as they are");
+ assert!(crate::menus::home_showing(&app), "Home stays over the documents");
+}
+
+/// The Home screen's Quick skill guide card works from the keyboard: with the keyboard focus, Enter
+/// shows the Learn panel's list of skills.
+#[test]
+fn home_quick_skill_guide_card_works_from_the_keyboard() {
+ use crate::panels::learn::{self, Tab, View};
+ let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
+ let ctx = egui::Context::default();
+ crate::theme::install_fonts(&ctx);
+ visible_home_texts(&mut app, &ctx, (1000.0, 2400.0), 0.0, vec![]);
+ let card = egui::Id::unique("home-quick-skill-guide");
+ ctx.memory_mut(|m| m.request_focus(card));
+ visible_home_texts(&mut app, &ctx, (1000.0, 2400.0), 0.1, vec![]);
+ assert!(ctx.memory(|m| m.has_focus(card)), "the card takes the keyboard focus");
+ let key = |pressed| egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed, repeat: false, modifiers: Default::default() };
+ visible_home_texts(&mut app, &ctx, (1000.0, 2400.0), 0.2, vec![key(true)]);
+ visible_home_texts(&mut app, &ctx, (1000.0, 2400.0), 0.3, vec![key(false)]);
+ assert!(learn::visible(&app.ui));
+ assert_eq!(app.ui.learn, View { tab: Tab::Skills, skill: None, tutorial: None });
+ assert!(app.session.documents().is_empty());
+}
+
/// A file that is already open is not opened again, however it is asked for: Home › Recent Files,
/// File › Open Recent, File › Open, the Finder and a drop all focus its tab, unsaved edits and all
/// (#3455).
@@ -452,7 +537,8 @@ fn text_centre_x(app: &mut VectorcraftApp, text: &str) -> Option<(f32, f32)> {
app.canvas_rect = None;
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
- let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1000.0, 900.0));
+ // Tall enough to draw the whole screen, Learn section included.
+ let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1000.0, 1600.0));
let raw = egui::RawInput { screen_rect: Some(screen), ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| canvas::show(app, ui));
out.textures_delta.clear();
diff --git a/crates/ui-egui/src/tests/recovery.rs b/crates/ui-egui/src/tests/recovery.rs
index 00ccb59b79..76f56a1fb9 100644
--- a/crates/ui-egui/src/tests/recovery.rs
+++ b/crates/ui-egui/src/tests/recovery.rs
@@ -115,6 +115,118 @@ fn launching_after_a_crash_offers_the_copies() {
assert!(next.ui.dialog.is_none());
}
+/// A 40×30 PNG of one color.
+fn png(rgb: [u8; 3]) -> Vec {
+ let mut out = vec![];
+ let [r, g, b] = rgb;
+ image::RgbaImage::from_pixel(40, 30, image::Rgba([r, g, b, 255])).write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
+ out
+}
+
+/// The copies a crash left of `n` documents, each with an image linked to a file in `dir`. The
+/// files are deleted and the images' bytes are gone from the store, so each copy restores with a
+/// warning about its image and a missing linked file. → the store.
+fn crashed_with_lost_images(dir: &std::path::Path, n: u8) -> Arc {
+ let store = Arc::new(MemoryStore::default());
+ let mut app = VectorcraftApp::new(Session::new(), Services { recovery_store: Some(store.clone()), ..Default::default() });
+ app.services.read = Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string())));
+ for k in 0..n {
+ app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
+ let p = dir.join(format!("{k}.png"));
+ std::fs::write(&p, png([200, k * 100, 0])).unwrap();
+ app.run("file.place", json!({"path": p.to_string_lossy()})).unwrap();
+ }
+ app.run("file.recovery.save", json!({})).unwrap();
+ drop(app);
+ for k in 0..n {
+ std::fs::remove_file(dir.join(format!("{k}.png"))).unwrap();
+ }
+ for name in store.list().unwrap().into_iter().filter(|n| n.ends_with(".blob")) {
+ store.remove(&name).unwrap();
+ }
+ store
+}
+
+/// The app launched after the crash, with the Recover Documents dialog open → the app and the
+/// titles the restored documents get, in the dialog's order.
+fn launched(store: &Arc) -> (VectorcraftApp, Vec) {
+ let mut app = VectorcraftApp::new(Session::new(), Services { recovery_store: Some(store.clone()), ..Default::default() });
+ recovery::frame(&mut app, 0.0);
+ let d = app.ui.dialog.as_ref().expect("the Recover Documents dialog");
+ let titles = d.fields["copies"].as_array().unwrap().iter().map(|c| format!("{} [Recovered]", c["title"].as_str().unwrap())).collect();
+ (app, titles)
+}
+
+/// Is `note` the warning about a restored document's image that couldn't be read?
+fn lost_image(note: &str) -> bool {
+ note.starts_with("the image “") && note.contains("” of the recovered document couldn't be read")
+}
+
+/// Restore keeps the warnings each restored document opened with: the status bar names the first
+/// after the count, and its hover lists them all, each named with its document. The questions
+/// about the missing linked files come one document at a time, with that document current.
+#[test]
+fn restore_keeps_each_documents_notes_and_asks_about_its_links_in_turn() {
+ let dir = scratch("restore-notes");
+ let store = crashed_with_lost_images(&dir, 2);
+ let (mut app, titles) = launched(&store);
+ dialogs::confirm(&mut app).unwrap();
+ let (status, notes) = app.ui.status_notes.clone().expect("the notes are kept");
+ assert_eq!(status, app.ui.status);
+ assert_eq!(notes.len(), 2, "{notes:?}");
+ for (note, title) in notes.iter().zip(&titles) {
+ let lost = note.strip_prefix(&format!("{title}: ")).unwrap_or_default();
+ assert!(lost_image(lost), "{note}");
+ }
+ assert_eq!(status, format!("Restored 2 recovered documents: save them to keep them. Opened with 2 notes: {}", notes[0]));
+ // The first document's missing file is asked about first, and the second's waits.
+ for title in &titles {
+ let d = app.ui.dialog.clone().expect("the missing file's question");
+ assert_eq!(d.kind, dialogs::missing_links::KIND);
+ assert_eq!(&app.session.doc().unwrap().title(), title);
+ app.ui.dialog.as_mut().unwrap().fields.insert("discard".into(), json!(true));
+ dialogs::confirm(&mut app).unwrap();
+ }
+ assert!(app.ui.dialog.is_none());
+ assert_eq!(app.ui.status, status, "answering the questions keeps the line");
+ let _ = std::fs::remove_dir_all(&dir);
+}
+
+/// With one document restored, its notes are not named with its title.
+#[test]
+fn restoring_one_document_shows_its_notes_after_the_count() {
+ let dir = scratch("restore-one");
+ let store = crashed_with_lost_images(&dir, 1);
+ let (mut app, _) = launched(&store);
+ dialogs::confirm(&mut app).unwrap();
+ let (status, notes) = app.ui.status_notes.clone().expect("the notes are kept");
+ assert!(notes.len() == 1 && lost_image(¬es[0]), "{notes:?}");
+ assert_eq!(status, format!("Restored 1 recovered document: save it to keep it. Opened with 1 note: {}", notes[0]));
+ assert_eq!(app.ui.status, status);
+ let _ = std::fs::remove_dir_all(&dir);
+}
+
+/// A copy that can't be restored after another was: the status bar shows the error, and its hover
+/// still lists the count and the notes of the document that was restored.
+#[test]
+fn a_failed_restore_keeps_the_notes_of_the_documents_restored_before_it() {
+ let dir = scratch("restore-partial");
+ let store = crashed_with_lost_images(&dir, 2);
+ let (mut app, _) = launched(&store);
+ let second = app.ui.dialog.as_ref().unwrap().fields["copies"][1]["file"].as_str().unwrap().to_string();
+ store.write(&format!("{second}.vectorcraft"), b"not a document").unwrap();
+ let e = dialogs::confirm(&mut app).unwrap_err();
+ assert_eq!(app.session.documents().len(), 1);
+ assert_eq!(app.ui.status, e);
+ let (status, notes) = app.ui.status_notes.clone().expect("the notes are kept");
+ assert_eq!(status, e);
+ assert_eq!(notes.len(), 2, "{notes:?}");
+ assert_eq!(notes[0], "Restored 1 recovered document: save it to keep it");
+ assert!(lost_image(¬es[1]), "{}", notes[1]);
+ assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(dialogs::missing_links::KIND), "its missing file is asked about");
+ let _ = std::fs::remove_dir_all(&dir);
+}
+
#[test]
fn discard_at_launch_deletes_the_copies_and_cancel_keeps_them() {
let store = Arc::new(MemoryStore::default());
diff --git a/docs/control-protocol.md b/docs/control-protocol.md
index 40ad89d4b4..fdcc335b2c 100644
--- a/docs/control-protocol.md
+++ b/docs/control-protocol.md
@@ -373,7 +373,11 @@ each one that applies. `VECTORCRAFT_TITLEBAR` also takes precedence over System
`app` keeps the app bar as the title bar with System Title Bar on. Only the values `app`, `system` and `desktop` take
precedence (case-sensitive, surrounding spaces ignored); any other value, such as `unknown`, is ignored and does not
override System Title Bar. While the variable is set to one of those values, the Preferences dialog dims
-System Title Bar and its `__titlebarFromEnv` field is `true`.
+System Title Bar and its `__titlebarFromEnv` field is `true`. The dialog's `__desktopKeepsFrame` field is `true` when
+the variable is unset and the desktop would draw the frame without VectorCraft Title Bar on Every Desktop, whether
+or not that preference is on. The app works it out when it starts, so a Linux Display Server change does not change
+it until the next start. While the field is `true` and VectorCraft Title Bar on Every Desktop is off in the dialog, a
+note under System Title Bar says that the desktop draws the title bar with System Title Bar on or off.
Fill/Stroke chips and panel shortcuts: the Control bar's and Properties' Fill and Stroke chips bring their proxy
forward (`paint.toggleActive {fill}`) and open a popover with the Swatches panel (Shift-click: the Color panel's mixer);
@@ -813,7 +817,12 @@ instance keeps, so instances started side by side for screenshots don't get it).
saved}]`, from `file.recovery.list`) and `discard`. `ui.dialog.confirm` restores them all (`file.recovery.restore`
each, opening " [Recovered]" documents), `discard: true` then confirm deletes them, `ui.dialog.cancel` keeps them
for the next launch. While copies are written in the background `ui.inspect` lists them under `background`
-("Saving recovery data for ").
+("Saving recovery data for "). After a restore the status bar shows the count followed by the first of the
+restored documents' notes: each document's lines about its linked files, then its warnings (what reading the copy left
+out first), named with the document's title when more than one was restored. Hovering the message lists them all. The
+questions about each document's missing or modified linked files open one document at a time, with that document made
+the current one. When a copy can't be restored, confirm returns the error and the status bar shows it; its hover lists
+the count and the notes of the documents that were restored.
Native save options: File → Save As to a native or `.ai` file opens the `saveOptions` dialog after the save panel
(heading "VectorCraft Options" or "PDF-compatible .ai Options"). Fields: `separateArtboards`, `all` (true: every
diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md
index 92fd10cbb3..fe1dbab127 100644
--- a/docs/file-format-parity.md
+++ b/docs/file-format-parity.md
@@ -190,7 +190,7 @@ private editing data. VectorCraft:
editing data is read whole (#758), live effects that are not read or have settings that are not read, and
brushes come in as their drawn look (#637; pattern fills are read since #1025, and symbols and their instances
open live, as do the symbols of a symbol library without layers); legacy `.ai` without the prolog opens since #1027, its legacy type (`To` … `TO`) since #868; guides
- open as ruler guides and guide paths since #5057 (#779).
+ open as ruler guides and guide paths since #5057 (#779), hidden, locked and named ruler guides with their state.
Type under a Warp effect is compared with the art saved with it: type that draws as that art stays editable with
the effect live, and type that draws differently, or that the file's limit of checks has no check left for, opens
as that art, with one note that counts them. Type stays live without a comparison when the saved art paints
diff --git a/docs/localization-parity.md b/docs/localization-parity.md
index f13b3ec280..ca5906af9e 100644
--- a/docs/localization-parity.md
+++ b/docs/localization-parity.md
@@ -58,7 +58,7 @@ plus review. Native-speaker review is human work and isn't in the hours.
| Hungarian | `hu` | 5,253 rows (counted 2026-10-21): menus, `tl!` literals and messages complete when counted; one plural form | full |
| Polish | `pl` | 9,044 rows, 3,439 of them `@msg` and 48 `@msgplural` (counted 2026-11-13): the untranslated report lists 46 missing messages for it, and 47 menu strings, 26 `tl!` literals, 11 undo labels and 45 swatch names; one/few/many plurals | full; native review pending |
| Finnish | `fi` | 5,318 rows (counted 2026-10-21): menus, `tl!` literals and messages complete when counted; one/other plurals, 797 `@msg` rows | full; AI-assisted, native proofreading pending |
-| Czech | `cs` | 5,240 catalog entries, including 795 `@msg` rows (counted 2026-10-21); menus, literals and messages complete when counted | catalog complete; render-site gaps reduced; native review pending |
+| Czech | `cs` | 9,155 rows, 3,445 of them `@msg` and 48 `@msgplural` (counted 2026-11-13): the untranslated report lists 40 missing messages for it | catalog complete; render-site gaps reduced; native review pending |
| Bosnian | `bs` | 5,841 rows (counted 2026-10-30): menus, `tl!` literals and messages complete when counted; one/few/other plurals | full; AI-assisted, native review pending |
| Swedish | `sv` | 5,251 rows, 795 of them `@msg` (counted 2026-10-21): menus, `tl!` literals and messages complete when counted; one/other plurals | full; native review welcome |
| Bulgarian | `bg` | 5,245 rows, 763 of them `@msg` (counted 2026-10-21): menus and `tl!` literals complete when counted; its messages are partial | full; AI-drafted, native review pending |
diff --git a/docs/mcp.md b/docs/mcp.md
index d6d81ba67c..30a4e9e74a 100644
--- a/docs/mcp.md
+++ b/docs/mcp.md
@@ -566,7 +566,13 @@ key, and report a key left out or null as missing. The optional `group` of `live
(`x` of `shape.rectangle`, `pos` of `guide.add`, `index`, `x` and `y` of the `object.mesh.*` and
`object.envelope.setMeshPoint` point commands, and the others like them) refuses a value that is not a number with
`` `x` must be a number, not "40" ``, naming the key and showing the value as given, and reports a key left out or null
-as `` missing number `x` ``; it changes nothing either way. A command that leaves out an object selected inside a
+as `` missing number `x` ``; it changes nothing either way. A command that needs a whole number of 0 or more (`index`
+of `appearance.setItem`, `anchor` of `path.removeAnchor` and `addon.pen.removePoint`, `sourceDocument` of
+`edit.dropSelection`, `index` of `document.activate`, `to` of `document.move`, and the others like them) refuses a
+value such as `"3"`, `2.5`, `-1` or `true` with `` `index` must be a whole number ``, naming the key, and reports a key
+left out or null as missing (such as `` missing integer `index` ``). An optional one, such as `subpath`, keeps its default when
+left out or null; `appearance.setActiveItem` reads a null `index` as clearing the active item.
+A command that leaves out an object selected inside a
selected group also leaves it out when `ids` list it together with a group that holds it. Every command that takes
`ids` (or `slices`, for the slice commands) refuses a list with an entry that is not a non-negative integer, or a value
that is not a list, and changes nothing; it does not act on the other entries. The error for an entry names the key,
@@ -647,7 +653,7 @@ sampler.
| `open_panel` | `{panel}` | Remote only. `panel` is a panel id (`layers`, `swatches`, `colorGuide`, …, as `window.panel` takes) or its display label (`"Color Guide"`), in any case. |
| `screenshot` | `{path?, scale?, artboard?, window?, max_side?, traceViews?}` | Returns MCP image content (`image/png`, base64) plus a text block. Renders the artboard with the pixels of a PNG `export` with `background: "white"` at the same scale; `traceViews:true` draws Image Trace objects in the view set with `imageTrace.setView`, as in the app's window; `window:true` captures the app window (remote only), as `ui_screenshot` does, and `max_side` (1–4096, window captures only) scales the returned image down as described for `ui_screenshot`. With `path` the backend (the headless session or the app) writes the PNG there too; a window capture written there keeps its full size with `max_side`. An app that does not send the image back writes a window capture without `path` to a file in a new folder inside the system's temporary folder (on Unix only the user can open it); the server reads the file and then removes the folder, also when the capture or the read fails. |
| `open_file` | `{path, importer?, params?}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.vctemplate`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, `.eps`/`.epsf`, `.ps`, `.dxf`, `.emf`, `.wmf`, PICT (`.pct`/`.pic`/`.pict`, and a version 2 picture of any extension), HP-GL (`.plt`/`.hpgl`/`.hgl`/`.hpg`, and a file of an extension no other format reads that starts with plotter instructions), PNG (`.png`/`.pns`), JPEG, GIF, WebP, TIFF, BMP (`.bmp`/`.dib`/`.rle`), Targa (`.tga`/`.vda`/`.icb`/`.vst`), PCX, Pixar (`.pxr`), JPEG 2000 (`.jpf`/`.jpx`/`.jp2`/`.j2k`/`.j2c`/`.jpc`), HEIC/HEIF (`.heic`/`.heif`), JPEG XL (`.jxl`), PSD (`.psd`/`.pdd`)/PSB (an image opens as a document of its pixel size; a PSD or PSB file with its layers as layers, or as its merged image through `run_command document.open` with `layers: false`; a JPEG XL file as its first frame). Templates (`.vctemplate`, `.ait`) open as a new untitled document. PDF, `.ai` and SVG files saved with Preserve Editing reopen as the document they carry. The reply is `document.open`'s: its `warnings` say what didn't come in as it was (an EPS whose PostScript can't be read opens as its preview, and the warning names the PostScript error, the operator and the procedure). A file of an extension only an installed importer plug-in lists opens with that importer ([Plug-ins](#plug-ins)); `importer: "plugin."` opens the file with the importer it names, which must list the file's extension, also one a built-in format reads (in the app, as File › Open With does), and `params` gives that importer's parameters (their defaults when omitted). `run_command document.formats` lists the formats. |
-| `save_file` | `{path?, compress?, version?, preview?, precision?, options?}` | Runs `document.save`: the document's own file in its own format (native `.vectorcraft` unless it was opened from or saved as SVG, PDF or a restorable `.ai`; then `warnings` say what that format loses). A path's extension picks the format (`.vectorcraft`, `.vctemplate`, `.pdf`, `.svg`, `.svgz`, `.ai`: a PDF carrying the native document, which reopens editable; `.eps`: by default one file whose bounding box fits the visible art, the first artboard when no art shows; `options.useArtboards: true` makes the chosen artboard the page and, with two or more chosen, the file at the path is the central file of the visible art and each artboard is also written to `_.eps` beside it, listed in the reply's `files`). For a native file, `compress: true` gzips it (default: the `useCompression` preference, Preferences › File Handling › Use Compression), `version` (1 to 4) writes it for an older VectorCraft (1 and 2 are never compressed, and `version: 2` with `compress: true` is an error), `preview: true` embeds a PNG of the first artboard, and `precision` (0 to 9) rounds path anchors and handles to that many decimal places for that save only (colors, transforms and type stay exact). `options` passes the format's save options as `run_command document.formats` lists them. When `compress`, `version`, `preview` or `precision` is given both at the top level and in `options` with different values, the call is an error (`` `compress: true` and `options.compress: false` disagree: give the option once ``) and nothing is written. A key in `options` that no save format takes is an error, and nothing is written (see Parameter keys). The save drops each option the written format doesn't read. The reply's `warnings` then name each such option given (for example `` `compress` was not used: SVG files don't read it `` for an `.svg` path, and `` `options.preview` was not used: … `` for a key in `options`; a `.ai` file reads `compress` but not `version`, `preview` or `precision`). For a `.ai` file, these warnings name the kind of `.ai` written: a legacy .ai (version 7) for `aiVersion: "7"`, a non-PDF-compatible .ai for `pdfCompatible: false`, and a PDF-compatible .ai otherwise, including a save that reuses the options the document was last saved with. `run_command document.save` and the other save commands refuse any option given beside the path and in `options` with different values, and give the same warnings for a top-level `compress`, `version`, `preview` or `precision` and for each key in `options` that the format doesn't read. The headless session and the app receive the same `app.save` params, and the app is also sent `wait: true`: the call returns once the file is written, or with the error (such as a path outside the write root), even with Background Save on. When the save asks first (the question before replacing a file that opened with losses, or on macOS and Linux the file dialog for an untitled document without `path`), the reply is `{pending}` or `"cancelled"` as before, and the save that runs after the answer uses the Background Save preference. Location refusals return `READ_ONLY_LOCATION: ` without a fallback panel for automation; see [Save location errors](#save-location-errors). |
+| `save_file` | `{path?, compress?, version?, preview?, precision?, options?}` | Runs `document.save`: the document's own file in its own format (native `.vectorcraft` unless it was opened from or saved as SVG, PDF or a restorable `.ai`; then `warnings` say what that format loses). A path's extension picks the format (`.vectorcraft`, `.vctemplate`, `.pdf`, `.svg`, `.svgz`, `.ai`: a PDF carrying the native document, which reopens editable; `.eps`: by default one file whose bounding box fits the visible art, the first artboard when no art shows; `options.useArtboards: true` makes the chosen artboard the page and, with two or more chosen, the file at the path is the central file of the visible art and each artboard is also written to `_.eps` beside it, listed in the reply's `files`). For a native file, `compress: true` gzips it (default: the `useCompression` preference, Preferences › File Handling › Use Compression), `version` (1 to 4) writes it for an older VectorCraft (1 and 2 are never compressed, and `version: 2` with `compress: true` is an error), `preview: true` embeds a PNG of the first artboard, and `precision` (0 to 9) rounds path anchors and handles to that many decimal places for that save only (colors, transforms and type stay exact). `options` passes the format's save options as `run_command document.formats` lists them. When `compress`, `version`, `preview` or `precision` is given both at the top level and in `options` with different values, the call is an error (`` `compress: true` and `options.compress: false` disagree: give the option once ``) and nothing is written. A key in `options` that no save format takes is an error, and nothing is written (see Parameter keys). The save drops each option the written format doesn't read. The reply's `warnings` then name each such option given (for example `` `compress` was not used: SVG files don't read it `` for an `.svg` path, and `` `options.preview` was not used: … `` for a key in `options`; a PDF-compatible `.ai` file reads `compress` but not `version`, `preview`, `precision`, `outlineText`, `hiddenObjects`, `transparentImages` or `preserveEditing`, and a legacy `.ai` file reads only its own options). For a `.ai` file, these warnings name the kind of `.ai` written: a legacy .ai (version 7) for `aiVersion: "7"`, a non-PDF-compatible .ai for `pdfCompatible: false`, and a PDF-compatible .ai otherwise, including a save that reuses the options the document was last saved with. `run_command document.save` and the other save commands refuse any option given beside the path and in `options` with different values, and give the same warnings for each key given beside the path or in `options` that the file doesn't read. The headless session and the app receive the same `app.save` params, and the app is also sent `wait: true`: the call returns once the file is written, or with the error (such as a path outside the write root), even with Background Save on. When the save asks first (the question before replacing a file that opened with losses, or on macOS and Linux the file dialog for an untitled document without `path`), the reply is `{pending}` or `"cancelled"` as before, and the save that runs after the answer uses the Background Save preference. Location refusals return `READ_ONLY_LOCATION: ` without a fallback panel for automation; see [Save location errors](#save-location-errors). |
| `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `svgz`, `pdf`, `ai` (the PDF-compatible `.ai` that Save writes, which carries the document when every artboard is written in order), `eps`, `dxf`, `emf`, `wmf`, `hpgl` (a `.plt` plot file for plotters and cutters), `png`, `jpg`, `webp`, `gif`, `png8` (an indexed `.png`), `tiff`, `bmp`, `tga`, `psd` (layered), `txt` (the document's text), `vectorcraft` or `template` (a native template). The tool's `format` enum and `document.formats` list them, generated from the engine's format table. When `format` is omitted, it comes from the path's extension. PDF and `.ai` write one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based). SVG and SVGZ write one file per chosen artboard. EPS writes one file of the visible art; with `artboard`, `range`, `options.artboards` or `options: {"useArtboards": true}` it writes one file per chosen artboard, after a central file of the visible art when there are two or more, and `useArtboards: false` keeps the one file. Rasters, DXF, EMF, WMF and HP-GL write one artboard, or one file per chosen artboard with `options: {"useArtboards": true}`. Rasters also take `options: {"rect": [x, y, width, height]}` (page points, y down): that area of the page at the given `ppi`/`scale`, without an artboard and without changing the document; it is refused with `artboard`, `artboards`, `range` or `useArtboards`, with a non-positive or non-finite size, and for non-raster formats. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds (the reply adds their `bounds`, and reports `format` and the encoder's `warnings` as a whole-document export does); `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. |
| `add_text` | `{text, x?, y?, width?, height?, fit?, fitMinPercent?, path?, mode?, pathEffect?, size?, font?, color?, justify?, vertical?}` | Point type at (x, y) (`justify`: `left`, `center` or `right` starts, centres or ends each line on x); area type with `width`/`height` (`fit`: `none`, `autoHeight`, `shrinkText`, as `text.areaOptions`); or `path` + `mode` (`area`/`onPath`) to flow text in or along a path, with `pathEffect` (`rainbow`, `skew`, `3dRibbon`, `stairStep`, `gravity`; SVG export writes type on a path with any effect other than `rainbow` as outlines, with a warning); `justify` (also `auto`, `justifyAll`) aligns the paragraphs of every kind, as `text.setStyle` does later. `vertical: true` uses the existing vertical writing mode for every kind (default `false`, horizontal). Returns `{id}`, with `overflow: true` when the text doesn't fit its frame or path. |
| `apply_effect` | `{effect?, params?, ids?}` | Appends a live effect. Without `effect`, returns the effect catalogue with parameters and defaults. |
@@ -1722,8 +1728,13 @@ the same as leaving the key out.
The save commands limit them the same way. `document.save`, `file.saveAs`, `file.saveCopy`, `file.saveAsTemplate`,
`app.save` and `file.save` refuse a key that no save format takes, given beside the path or in `options`, and an
unknown key inside a PDF option object or the `svg` object, also when the file is not SVG. A save still leaves out a key
-that only another save format takes. It warns when that key is given in `options` or is one of `compress`, `version`,
-`preview` and `precision`:
+that only another save format takes, and its `warnings` name the key, given beside the path or in `options`
+("`options.marks` was not used: VectorCraft files don't read it"). These warnings follow an export's `.ai` rules: a
+legacy `.ai` file reads only its own options, and the other `.ai` and `.ait` files don't read `outlineText`,
+`hiddenObjects`, `transparentImages` or `preserveEditing`. The Save Options dialog sends a `.ai` save only the options
+the chosen version reads. When the document's own file is already a `.ai`, the `.ai` options that a save doesn't give
+and its file doesn't read keep their remembered values, so the dialog shows them as they were. The save doesn't write
+them:
```json
{"id": "document.save", "params": {"path": "a.pdf", "options": {"noSuchOption": true}}}
@@ -1735,7 +1746,12 @@ and in the options `settings` gives a format, they refuse a key that no format t
unknown key inside a PDF, EPS or SVG option object; `settings` takes only the formats the command writes. A row and a
format's settings also take `includeArtboardFill`, `richBlack`, `params` (an exporter plug-in's own) and
`security.passwordRequired` (which remembered settings hold where PDF passwords were left out), and `settings`
-takes an exporter plug-in's format. The error names the row (`formats[1].marks.useDocumentBleed`).
+takes an exporter plug-in's format. The error names the row (`formats[1].marks.useDocumentBleed`). A key that only
+another format takes is accepted and not used, and the reply's `warnings` name it as the request does
+("`formats[0].quality` was not used: PNG files don't read it", `settings.png.quality`), with the `.ai` rules of an
+export. A key in a format's settings gets a warning only when no row of that format, as the export writes it, reads it.
+A row's own keys (`scale`, `suffix`…), `includeArtboardFill`, `richBlack`, `params` and an artboard choice get no
+warning, and neither do the options of an `exportPreset` or, for `assets.export`, the settings the document keeps.
`document.exportPresets.save` checks its `settings`, with the rows and settings in it, the same way, and `assets.export`
and `assets.settings.set` check the rows and settings they are given.
@@ -2880,6 +2896,14 @@ not finished, or a skill's last tutorial, it is an error. For all three, a missi
is an error, and an error changes nothing. Behavior reference:
[Quick skill guide](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html).
+The Home screen has a Learn section between Recent Files and the new-file presets, as
+[the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/homescreen-overview.html)
+lists Learn, with in-app tutorials, among the Home screen's options. The section holds the Quick skill guide card,
+which runs `learn.skills`, and every tutorial of the Learn panel under its skill, each a button that runs
+`learn.show {tutorial}`. The panel shows beside the Home screen; no document is opened, closed or switched, and a
+second choice keeps the panel shown. The section's place, its layout and what a choice opens are VectorCraft's own
+rules.
+
```json
{"name":"invoke_menu","arguments":{"command":"learn.show","params":{"tutorial":"pen-paths"}}}
{"name":"invoke_menu","arguments":{"command":"learn.openSample","params":{"tutorial":"pen-paths"}}}
@@ -2991,7 +3015,8 @@ already walks Image Trace. Short agent entry points:
a symbol with guides keeps its corners at their size, and `symbol.breakLink` gives its art as it draws; dragging a
guide on the canvas in symbol editing mode sends `nineSliceGuides` in one undo step; `type` is
Symbol Type, `static` by default for the command and `dynamic` by default in the Symbol Options dialog),
- `symbol.place {name?, x?, y?}`, `symbol.breakLink {ids?}` (the art keeps the instance's opacity mask; a hidden or locked instance's
+ `symbol.place {name?, x?, y?}`, `symbol.breakLink {ids?}` (the art keeps the instance's opacity mask and its bounding box, square after
+ `object.resetBoundingBox`; a hidden or locked instance's
art stays hidden or locked and is not selected), `symbol.replace`,
`symbol.edit {id?, ids?, name?, x?, y?, artboard?, viewCenter?}` (symbol editing mode: the symbol's art on a temporary
isolated layer, where the instance `id` shows it or centered on (x, y), by default on the center of artboard
@@ -3274,7 +3299,8 @@ already walks Image Trace. Short agent entry points:
trimmed) after the last action of set `set` (0 is the top set), starts recording into it and returns
`{set, action}`; Stop Playing/Recording (`ui.action.stop`) ends the recording. `key` 1 to 15 is the function key F1 to F15 that plays the
action (0 or null for none), with `cmd` (Ctrl, Command on macOS) and `shift`. `color` is its Button Mode color
- (`red`, `yellow`, `green`, `blue` or `violet`; null or `""` for none), which the panel does not show. Action function
+ (`red`, `yellow`, `green`, `blue` or `violet`; null or `""` for none), the color of its button in Button Mode
+ (`ui.action.buttonMode`). Action function
keys take priority over command shortcuts and preserve the command's binding. On Windows F1, Ctrl+F4 and Ctrl+F6
cannot be used. While an action is recording, and for an index past the last set,
an empty name, or a key or color that is not one of these, it is an error and nothing is added. So is an action that
@@ -3330,6 +3356,21 @@ already walks Image Trace. Short agent entry points:
result is `{results}` with every step's result in order. A step that fails ends the playback with its error: the
batch it is in changes nothing, and the steps before that batch stay done. While another action records, the View
menu step is recorded too. Batch… refuses an action with an included View menu step.
+- Actions: `ui.action.playAt {set, action}` plays the action at `action` in set `set` (0 is the top set) as
+ `ui.action.play` plays it and returns what `ui.action.play` returns. A click on an action's button in Button Mode
+ runs this. A missing index, or one that is not a whole number or is past the last set or action, is an error, and
+ then nothing plays.
+- Actions: `ui.action.buttonMode {enabled?}` with `enabled: true` shows the Actions panel's actions as buttons, each
+ with the action's name in its Action Options color (`ui.action.options`), without set names, steps or checkmarks, as
+ [the help page](https://web.archive.org/web/20220911143322/https://helpx.adobe.com/pdf/cs6/illustrator_reference.pdf#page=467)
+ describes; a click on a button plays its action (`ui.action.playAt`) without its excluded steps. With `false` the
+ panel shows its list again, and with `enabled` left out or null the command switches to the other view. It returns
+ `{enabled}`. The panel menu's Button Mode runs this. VectorCraft's own rules: the menu item shows a check mark while
+ Button Mode is on; the actions of every set show, in set order and then action order; an action without a color is
+ an ordinary button; the bottom bar shows only Stop Playing/Recording while an action plays or records, and Play
+ Current Selection while one waits at a stop; the panel menu's items for a selected action or the current set are
+ off; and the mode is kept with the UI preferences. Turning Button Mode on while an action records, or an `enabled`
+ that is not true, false or null, is an error, and then the view does not change.
- Actions: `ui.action.insertStop {set?, action?, step?, message?, allowContinue?}` inserts a stop. With `set` and
`action`, the stop goes into that action, after step `step`, else after its last step, and the command returns
`{set, action, step}`. Without them it goes into the recording in progress, after the steps recorded so far, and
@@ -5142,7 +5183,7 @@ path by its control points when one of them has an x or y that is infinite or la
the canvas (coordinates out of range)".
`path.curvatureEdit`, `path.removeAnchor`, `path.convertAnchor`, `path.reshapeSegment`, `path.split`,
`path.setHandle`, `path.moveAnchor` and `path.insertAnchor` edit subpath 0 when `subpath` is absent or null, and refuse a `subpath` that
-is not a whole number of 0 or more, such as 1.5, -1 or "1" ("missing integer `subpath`"); `path.split` refuses such
+is not a whole number of 0 or more, such as 1.5, -1 or "1" ("`subpath` must be a whole number"); `path.split` refuses such
an `anchor` or `segment` in the same way. The document does not change and no undo step is recorded.
`path.moveAnchor` to the point the anchor is at, and `path.setHandle` to the point the handle is at on an anchor that
keeps its kind, leave the document as it is and record no undo step.
@@ -7379,8 +7420,8 @@ restore the selection that was cleared. While the slices are locked or hidden, a
```
The Slice tool (`slice`, Shift+K) and the Slice Selection tool (`sliceSelection`) reduce to commands:
-`object.slice.create {x, y, width, height}` (the Slice tool's drag; it snaps to ruler guides), `object.slice.select
-{slices?, toggle?}`, `object.slice.move {slices?, dx, dy}` (an object slice moves its object),
+`object.slice.create {x, y, width, height}` (the Slice tool's drag; it snaps as drawn points do, then to ruler guides),
+`object.slice.select {slices?, toggle?}`, `object.slice.move {slices?, dx, dy}` (an object slice moves its object),
`object.slice.setRect {id, x, y, width, height}` (user slices) and `object.slice.delete {slices?}`. Slice ids are user
slice ids and the ids of objects with an object slice. `object.slice.select` with `slices` left out or null deselects
the slices.
@@ -8129,7 +8170,9 @@ square to the page. `object.resetBoundingBox` squares the box again without movi
object inside the selected ones: the children of groups, compound paths, clip groups, blends, envelopes, repeats and
intertwines, and the art of linked opacity masks (an unlinked mask's art keeps its angle). Ungroup, Release Clipping
Mask, Release Compound Path, Blend Expand, Release Envelope, Release Repeat, Release Intertwine and Release Opacity Mask
-then bring back boxes square to the page.
+then bring back boxes square to the page. `symbol.breakLink` and `object.expand` give a symbol instance's art the
+instance's own box: after a reset the art stands square to the page, and an object turned inside the symbol keeps its
+angle relative to the instance's box.
```json
{"name":"run_command","arguments":{"command":"object.rotate","params":{"angle":45,"absolute":true}}}
@@ -10725,7 +10768,8 @@ Tools:
centre (move), the box's lower right corner (size; Shift keeps the proportions), a handle above the top (rotation;
Shift: 45° steps), a dot on the top edge (corner radius) and one of the kind's own (a star's or ring's inner radius, a
gear's tooth height, an arrow's head, a cross's bar, a heart's notch, a bubble's tail tip); each drag runs
- `addon.pathtools.shape.set`. A double-click on the canvas places a shape with the last settings. Options
+ `addon.pathtools.shape.set`. A double-click on the canvas places a shape with the last settings. A new shape's
+ center and the point dragged out from it snap as drawn points do. Options
(`tool.setOption`, or the panel with no shape selected) are the next shape's numbers, as the commands take them.
- **Corner Tool** (`cornerTool`, or the UI command `addon.pathtools.cornerTool`, which also shows the Anchor Inspector
panel with its Corners section): edits the app's Live Corners on any path, selected or not. Over an anchor where two
diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md
index 75b4abeed7..41c1a95a84 100644
--- a/docs/target-app-parity.md
+++ b/docs/target-app-parity.md
@@ -621,7 +621,8 @@ since 2026-10-01.
- **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.
- **Warp as a Bézier patch per style:** each of the fifteen Warp styles bends the object's bounds as one Bézier patch whose control points the style sets from the bend and the size of the bounds; the horizontal distortion then scales each column of control points about its middle and the vertical distortion scales each row. The Warp effects and warp envelopes share the map, and content with no width or no height takes the middle of the patch along that side. New warps, the built-in graphic styles' Warps and warps opened from `.ai` files carry `version` 1 for it. Warps in `.vectorcraft` documents saved before it read as `version` 0 and keep their earlier look, and edits keep a warp's version; a `version` 0 warp saved to `.ai` opens again with `version` 1, since the file holds only its settings. The patches are VectorCraft's own rules; the styles and ranges are those of the Warp options in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (doc `live::tests::warp_nets_bend_each_style_as_its_own_patch`, `warp_net_arc_edges_are_concentric_circles`, `warp_net_distortion_scales_the_columns_then_the_rows`, `warp_net_takes_any_numbers`, `warp_net_bends_a_huge_box_without_overflowing`, `warp_envelopes_bend_flat_content_as_the_middle_of_the_patch`, `warp_envelopes_write_their_map_version_only_when_it_has_one`, `style_libs::tests::builtin_warp_styles_take_the_nets_map`; effects `tests::a_warp_draws_with_the_map_its_version_names`, `tests::a_warp_bends_a_flat_box_as_the_middle_of_its_patch`, `tests::new_warps_take_the_nets_version`; engine `effectedit::warps_keep_the_map_their_version_names`, `envelope::new_warp_envelopes_take_the_net_and_saved_ones_keep_their_map`; eps `ai_editing::tests::every_effect_a_graphic_style_holds_is_read_back`, `import::ai::tests::a_warp_envelope_opens_live`, `import::ai::tests::warp_envelopes_read_every_style`, `import::ai::style::tests::a_stack_of_fills_with_effects_of_their_own_is_read_as_itself`; ui-egui `dialogs::tests_warp::warp_options_edit_keeps_each_targets_map_version`). Scores and hours unchanged.
- **Warp effects bend images:** a Warp effect on an image, or on a group that holds one, bends the image's pixels with its frame, as the Warp effect is documented to distort bitmap images in [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html): after the last effect, the image is cut into 10 × 10 cells of two triangles, each a clipped piece under its own affine map. Effects after the Warp reshape only the frame, a second Warp bends the same pieces, and warps saved without a version bend the pixels with their earlier map, as they bend the frame. A placed gradient on a group that holds the image follows the group's bounds as the pieces draw them. SVG writes the image once for the pieces; PDF, EPS, `.ai` and PNG write the bent pieces. The images stay unbent when their pieces would make the art pass 2 million objects, and so does an image whose frame keeps its shape past the anchor budget. A grayscale image's fill color now colorizes the pieces of a warp or an envelope, and an image under any geometry effect keeps that color and its smoothing setting. Existing documents with a Warp effect on an image, or on a group that holds one, draw differently: before, the image showed unbent, cut by the bent frame. So do existing documents with a colorized image under an envelope or a geometry effect, or an image drawn without smoothing under a geometry effect: before, those lost the color or the setting. The cell count and the limits are VectorCraft's own (effects `tests::warped_images_stay_within_the_object_budget`, `tests::warped_images_keep_document_keys_and_hidden_images_apart`, `tests::an_image_whose_frame_passes_the_budget_stays_whole`, `tests::effects_after_a_warp_reshape_an_images_frame_and_not_its_pieces`, `tests::an_images_pixels_take_the_map_their_warps_version_names`, `tests::an_image_under_a_geometry_effect_keeps_its_color_and_smoothing`, `tests::a_nested_groups_gradient_follows_its_warped_image`; engine `warp_images::an_images_pixels_bend_with_effect_warp`, `a_warped_image_keeps_its_bend_in_svg`, `a_warped_image_keeps_its_bend_in_pdf`, `a_warped_image_keeps_its_bend_in_ai`, `a_warped_image_keeps_its_bend_in_eps`, `a_warped_image_keeps_its_bend_in_png`, `svg_writes_an_image_clipped_twice_in_a_users_group_twice`, `expand_appearance_of_a_warped_image_draws_as_the_canvas_did`, `a_colorized_image_keeps_its_color_when_warped`, `a_warp_on_a_group_bends_the_image_in_it`, `envelope_distort::a_colorized_image_keeps_its_color_in_an_envelope`). Scores and hours unchanged.
-- **`.ai` files keep an image's Warp live:** an embedded image whose appearance is one Warp effect, with no opacity mask, is saved to the `.ai` editing data with an art style of that Warp: its bent pieces are its drawn look, and the image itself follows on `%_` lines, as for other styled objects. Opening an `.ai` file reads such an image as the image with its Warp live, its pixels, placement, name and opacity kept, when the live warp draws as the file's drawn look and the import has a comparison left; otherwise the drawn look stays, with the drawn-look note. The Warp effect applies to bitmap images ([the help page](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html)), and native files keep live effects ([the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/supported-file-formats-in-illustrator.html)). A linked image with a Warp is saved as its drawn art, as before, and its live Warp is not saved. Images with an opacity mask, and images with a Warp and another effect or with another geometry effect, are saved and opened as their drawn art. Existing `.ai` files with an embedded image on `%_` lines open differently: before, that image also came in beside its drawn look. Which images keep the Warp and the comparison are VectorCraft's own rules (eps `ai_editing::tests::an_image_with_a_warp_reads_back_with_its_warp_live`, `an_image_whose_warp_draws_otherwise_than_its_look_keeps_its_look`, `an_image_on_hidden_lines_does_not_come_in_beside_its_look`, `other_warped_images_are_written_as_their_drawn_art`, `import::ai::read::ops::tests::an_image_takes_a_style_of_one_warp_alone`). Scores and hours unchanged.
+- **`.ai` files keep an image's Warp live:** an embedded image whose appearance is one Warp effect, with no opacity mask, is saved to the `.ai` editing data with an art style of that Warp: its bent pieces are its drawn look, and the image itself follows on `%_` lines, as for other styled objects. Opening an `.ai` file reads such an image as the image with its Warp live, its pixels, placement, name and opacity kept, when the live warp draws as the file's drawn look and the import has a comparison left; otherwise the drawn look stays, with the drawn-look note. The Warp effect applies to bitmap images ([the help page](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html)), and native files keep live effects ([the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/supported-file-formats-in-illustrator.html)). Images with an opacity mask, and images with a Warp and another effect or with another geometry effect, are saved and opened as their drawn art. Existing `.ai` files with an embedded image on `%_` lines open differently: before, that image also came in beside its drawn look. Which images keep the Warp and the comparison are VectorCraft's own rules (eps `ai_editing::tests::an_image_with_a_warp_reads_back_with_its_warp_live`, `an_image_whose_warp_draws_otherwise_than_its_look_keeps_its_look`, `an_image_on_hidden_lines_does_not_come_in_beside_its_look`, `other_warped_images_are_written_as_their_drawn_art`, `import::ai::read::ops::tests::an_image_takes_a_style_of_one_warp_alone`). Scores and hours unchanged.
+- **`.ai` files keep a linked image's Warp live and its link:** a linked image whose appearance is one Warp effect, with no opacity mask, is saved to the `.ai` editing data with an art style of that Warp, as an embedded one is: its bent pieces are its drawn look, and the image itself follows on `%_` lines as a placed file with its path, its folder relative to the document's and its preview. Before, it was saved as its drawn art and its live Warp was lost. Opening the file reads it as the linked image with its Warp live, its link, pixels, placement, name, opacity, hide and lock kept, under the same drawn-look comparison; a hidden image with a Warp, embedded or linked, is now compared with its look drawn shown, and keeps its Warp too. A linked image with a Warp in an opacity mask's art keeps its Warp and is embedded, with the existing warning for linked images in masks. Linked files stay connected to their source and track it by relative path ([the help page](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/manage-linked-and-embedded-files/links-panel-overview.html)); the placed-file form is the one the `.ai` reader reads (`crates/eps/src/import/ai/read/art.rs` `end_place`). eps `ai_editing::tests::a_linked_image_with_a_warp_reads_back_linked_with_its_warp_live`, `a_large_linked_image_with_a_warp_reads_back_from_its_preview`, `a_hidden_or_locked_image_with_a_warp_keeps_its_state`, `a_linked_image_with_a_warp_in_a_mask_is_embedded_with_its_warp_live`. Scores and hours unchanged.
- **EPS writes a repeated image's samples once:** an image drawn more than once in an EPS file (the pieces of an envelope or a Warp effect, copies of one image, or one image in a symbol's instances) has its compressed samples written once in the setup, and each placement reads them from there: a reusable stream rewound for each placement at Language Level 3 ([PostScript Language Reference, 3rd ed., §3.13](https://www.adobe.com/jp/print/postscript/pdfs/PLRM.pdf)), and an array of strings of at most 49,152 bytes fed to the image by a procedure at Level 2. An image drawn once is written where it is drawn, as before. The 256 MB limit on the page counts the samples once, so an envelope of a large photo no longer loses pieces to it. A colorized image is reused per ink (eps `tests::an_image_placed_twice_writes_its_samples_once_at_both_levels`, `tests::an_envelopes_image_pieces_draw_one_copy_of_its_samples`, `tests::each_ink_of_a_colorized_image_placed_twice_is_written_once`, `tests::samples_longer_than_a_string_are_reused_at_both_levels`, `tests::the_page_limit_counts_a_repeated_images_samples_once`). Scores and hours unchanged.
- **PDF writes a warped image once:** with downsampling on, the pieces of an image bent by a Warp effect are resampled for the widest and the highest size any piece places the image at, so every piece paints one image XObject (a colorized image: one colorized image and its soft mask), instead of one image for each size a piece is placed at. Another placement of the same image is still resampled for its own size. Without downsampling, a colorized image and a CMYK image with a grayscale image mask on its grid are decoded and encoded once for all the sizes they are placed at (pdf `tests::compression::a_warped_image_is_written_once_for_all_its_pieces`, `tests::colorize::a_colorized_warped_image_is_written_once_for_all_its_pieces`, `export::images::tests::colorized_and_grid_masked_images_are_made_once_for_every_size_without_downsampling`). 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.
@@ -767,7 +768,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- Actions panel menu: Save Actions… does not write a set with a step that sets a folder preference (the Fonts, Plug-ins, Templates and Recovery folders and the two scratch disks) to a folder, and names the action and the preference instead; an empty folder and the scratch disk choices Startup and None save. Export Preferences leaves out both scratch disks, and `ui.inspect` under automation read roots leaves such a folder outside them out of recorded steps. These are VectorCraft's own rules (`ui.actionSet.save`, `prefs.export`).
- Actions panel menu: Load Actions… also reads action sets saved as `.aia` files ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html); `actionSet.read`, `ui.actionSet.load`). VectorCraft's own rules: the set comes in with all its actions. A step with a label and no recorded values comes in as the one menu command with the step's label, when that command is directly in a top menu, is what its menu bar item runs with no values and opens no dialog. A step that is off in the file stays excluded from playback. The other steps, recorded menu items included, are left out, and the status bar says how many.
- Actions panel: the panel menu's Action Options… renames the selected action and keeps its steps ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html): the menu item opens a dialog that renames the action or gives it a function key and color; `ui.action.options`). It also sets the action's function key and color.
- - Actions panel: Create New Action (bottom bar) and the panel menu's New Action… ask for the new action's name, set, function key and color, and Record adds the action and starts recording into it ([Create new actions](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/record-actions.html): the New Action dialog takes the action's name and the set it goes in; `ui.action.new`, `ui.action.play`). The function key (F1 to F15, with Ctrl or Command and Shift) plays the action. An action's function key plays the action even when a command has the same shortcut, and the command keeps it. VectorCraft's own rules, which the page does not cover: when several actions have the same key the first in panel order plays; a copied action has no key; New Action does not open while an action is recording. The color is kept with the action for Button Mode, which VectorCraft does not have yet.
+ - Actions panel: Create New Action (bottom bar) and the panel menu's New Action… ask for the new action's name, set, function key and color, and Record adds the action and starts recording into it ([Create new actions](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/record-actions.html): the New Action dialog takes the action's name and the set it goes in; `ui.action.new`, `ui.action.play`). The function key (F1 to F15, with Ctrl or Command and Shift) plays the action. An action's function key plays the action even when a command has the same shortcut, and the command keeps it. VectorCraft's own rules, which the page does not cover: when several actions have the same key the first in panel order plays; a copied action has no key; New Action does not open while an action is recording. The color is the color of the action's button in Button Mode.
+ - Actions panel: the panel menu's Button Mode shows the actions as buttons with their names in their Action Options colors, without sets or individual commands; a click on a button plays its action without its excluded steps, and choosing Button Mode again returns to the list ([the help page](https://web.archive.org/web/20220911143322/https://helpx.adobe.com/pdf/cs6/illustrator_reference.pdf#page=467) and [the next page](https://web.archive.org/web/20220911143322/https://helpx.adobe.com/pdf/cs6/illustrator_reference.pdf#page=468); [the help page](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/exclude-commands-from-actions.html) says commands cannot be excluded in Button Mode; `ui.action.buttonMode`, `ui.action.playAt`). VectorCraft's own rules, which the pages do not cover: the menu item shows a check mark while Button Mode is on; every set's actions show, in set order and then action order; an action without a color is an ordinary button; function keys still play their actions; the bottom bar shows only Stop Playing/Recording while an action plays or records, and Play Current Selection while one waits at a stop; Button Mode cannot be turned on while an action records; and the mode is kept with the UI preferences.
- Actions panel: with an action selected, Begin Recording adds the commands recorded to that action, after its selected step or at its end, and a step dragged within its action or to another action goes where a highlighted line shows when the button is released ([the help page](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/add-commands-to-actions.html); `ui.action.record`, `ui.action.moveStep`). VectorCraft's own rules, which the page does not cover: with no action selected Begin Recording adds a new action; a step can go into an empty action or an action in another set; a release off the actions' rows leaves the step where it was; a moved step keeps whether it is excluded; Undo does not move it back.
- Actions panel (record/playback, persisted), generic parameter dialogs for every "…" command.
- Actions panel: the current set is kept with the UI preferences, so after a restart a recording goes into the set that was current, not into the first set. With no set current, Begin Recording records into the first set and makes it current.
@@ -888,6 +890,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- Hindi wording (2026-11-13): rows outside the plug-in messages keep their English's meaning, quantities and clauses, and use the catalog's save, swatch, crop, resolution, path and type terms; a test checks those terms and the point unit in every row; native review remains; scores and estimates unchanged.
- Hindi terms (2026-11-13): every row that names a variable (data merge, variable fonts and widths, environment variables) says वेरिएबल, and joining paths, anchor points, point type and lines says जोड़ें instead of शामिल हों, which stays for joining the community; a test checks both terms in every row; scores and estimates unchanged.
- Hindi English words (2026-11-13): 264 rows no longer keep English words in their sentences, repeat a parameter's name in Hindi or garble them, among them "unknown align", which now names the alignment; lists of accepted values such as `(left|right|top|bottom)` stay as their English writes them; two tests check every row; native review remains; scores and estimates unchanged.
+ - Message field names and values (2026-11-13): 49 status and error messages in the Hindi, Bulgarian, Danish, Indonesian, Dutch, Brazilian Portuguese, Swedish and Turkish catalogs keep the field names in brackets, such as `[subpath, anchor]`, and the values an `unknown` message lists, such as `(points, lines)`, as their English writes them; a test checks every catalog; scores and estimates unchanged.
- Italian messages (2026-11-13): the Italian catalog (`it`) has 1,802 more status and error message rows, and the untranslated report's missing Italian messages go from 1,842 to 40; the translations are VectorCraft's own and await native review; scores and estimates unchanged. See [localization-parity.md](localization-parity.md).
- Window › Help Bar shows or hides the tool-help strip at the bottom of the window apart from the status bar (`window.helpBar`). Each setting is kept with the UI preferences and in saved workspaces, and Presentation Mode hides both bars. Layouts saved before the setting existed show the Help Bar where they show the status bar (`help_bar_toggle_is_independent_and_saved_in_workspaces`, `help_bar_and_status_bar_render_independently`, `legacy_help_bar_visibility_follows_each_layouts_status_bar`, `a_help_bar_setting_the_file_lacks_is_saved`).
- 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).
@@ -911,6 +914,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- The Transform panel menu's Transform Object Only, Transform Pattern Only and Transform Both (`transformPanelPatterns`) choose whether the fields, flips and menu flips of the Transform panel and the Properties panel's Transform section move the object and leave its pattern tiles, move the tiles alone, or both (until a choice they follow General › Transform Pattern Tiles, as before); Use Registration Point for Symbol (`useRegistrationPointForSymbol`) measures and transforms one selected symbol instance from its symbol's registration point, where the instance's flips, rotations and shears put it ([Transform panel overview](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/transform-panel-overview.html); `panels::transform::tests::the_menu_chooses_whether_the_object_its_pattern_or_both_transform`, `use_registration_point_for_symbol_measures_an_instance_from_its_registration_point`, `use_registration_point_for_symbol_follows_a_transformed_instance`, `tests_prefs::transform_panel_patterns_defaults_to_following_the_general_preference`).
- The Transform panel's Align to Pixel Grid checkbox (`object.setPixelAligned`) gives paths and compound paths a pixel-aligned property, saved in the native file: their horizontal and vertical straight segments sit on whole points (half points under an odd centered stroke) with whole stroke weights, and every move, scale, rotation, reflection, Transform Each, Align move, Transform panel change, move of direct-selected anchors or of one anchor, move of a compound path's path on its own, artboard move, resize or copy that carries the art, Duplicate or paste that moves a copy, stroke edit and appearance edit that adds, removes, hides, repaints or reweighs a stroke snaps them again, while diagonal segments and curves keep their shape; a compound path made from an aligned path or compound path is aligned and snaps its paths with its own stroke; the box shows a dash for a mixed selection and is unavailable for type, images and groups ([Pixel-aligned property](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/pixel-preview-mode-for-web-graphics.html); `tests::pixelalign::turning_it_on_snaps_the_path_in_one_undo_step`, `it_stays_aligned_through_transforms`, `it_stays_aligned_when_its_artboard_moves`, `diagonal_segments_and_curves_keep_their_shape`, `type_and_images_cannot_be_aligned`, `the_property_is_saved_and_read_back`, `stroke_edits_keep_it_aligned`, `a_compound_path_of_aligned_paths_is_aligned`, `a_compound_path_of_an_aligned_compound_path_is_aligned`, `a_stroke_without_paint_snaps_to_whole_points`, `moving_direct_selected_anchors_keeps_it_aligned`, `moving_one_anchor_keeps_it_aligned`, `moving_a_path_of_an_aligned_compound_path_keeps_it_aligned`, `artboard_options_and_artboard_copies_keep_it_aligned`, `appearance_edits_keep_it_aligned`, `placed_gradients_follow_the_snapped_path`, `duplicates_and_pastes_keep_it_aligned`, `paths_added_to_or_moved_in_an_aligned_compound_path_keep_it_aligned`, `the_art_of_a_linked_opacity_mask_keeps_it_aligned`, `a_linked_opacity_mask_follows_a_snap`, `aligning_direct_selected_anchors_keeps_it_aligned`, `global_edit_keeps_its_members_aligned`, `a_path_with_live_corners_snaps_its_inner_edges`, `geometry_edits_keep_it_aligned`, `other_path_edits_keep_it_aligned`, `panels::transform::tests::align_to_pixel_grid_shows_and_sets_the_property`).
- Align New Objects to Pixel Grid (`document.setup {alignNewObjectsToPixelGrid}`, a checkbox in New Document's Advanced Options and a checked item in the Transform panel menu) gives every path and compound path drawn in the document, the paths of a new grid or flare included, the pixel-aligned property, snapped in the step that draws it; it is on in the Mobile and Web presets and off in the others, saved presets and Recent keep it, and the document saves it. Type, images and groups do not take it, and art already in the document, pasted, duplicated or placed keeps its own property (VectorCraft's own rules) ([Pixel-aligned property](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/pixel-preview-mode-for-web-graphics.html); `tests::pixelalign::align_new_objects_makes_new_paths_pixel_aligned`, `align_new_objects_reaches_grids_flares_and_add_on_shapes`, `align_new_objects_is_saved_and_read_back`, `tests::newdoc::align_new_objects_to_pixel_grid_follows_the_preset`, `panels::transform::tests::align_new_objects_menu_item_toggles_the_active_document`, `dialogs::new_document::tests::align_new_objects_checkbox_follows_the_preset_and_reaches_the_document`).
+ - Break Link to Symbol and Object › Expand give a symbol instance's art the instance's own bounding box: after Reset Bounding Box the art stands square to the page, an object turned inside the symbol keeps its angle relative to the instance's box, a group added for the instance's opacity takes the instance's box, and the clip groups of 9-slice art take the angle of their cell. Compound shapes and Pathfinder receive an instance's art with the same angles (VectorCraft's own behavior; `bboxrotate::reset_bounding_box_then_expand_symbol_instance`, `reset_bounding_box_then_expand_keeps_the_angles_inside_the_symbol`, `expanding_a_faded_symbol_instance_gives_its_group_the_instance_box`, `expanding_a_reflected_symbol_instance_keeps_its_box`, `symbol_nine_slice::break_link_turns_the_cells_of_a_sliced_instance_with_its_box`, effects `outline_object_turns_an_instances_art_with_its_box`).
- Object › Transform › Reset Bounding Box squares the box of every object inside the selected ones as well: the children of groups, compound paths, clip groups, blends, envelopes, repeats and intertwines, and the art of linked opacity masks. Ungroup and the release commands then bring back boxes square to the page (VectorCraft's own behavior; `bboxrotate::reset_bounding_box_squares_the_objects_inside_a_group`, `reset_bounding_box_squares_the_turned_members_of_a_square_group`, `reset_bounding_box_then_release_clipping_mask` and the other `reset_bounding_box_then_*` tests, `reset_bounding_box_leaves_the_art_of_an_unlinked_opacity_mask`).
- After a rotation the Free Transform tool shows and drags the same turned bounding box as the Selection tool: its handles, their cursors and its rotate zone sit on the selection's own sides, a handle scales along those sides from the opposite handle ([the help page](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/scale-objects.html)), Cmd/Ctrl on a side handle shears along that side, and Perspective Distort and Free Distort start from the turned box's corners (`object.distort` takes `angle` with `from`). The Scale and Reflect tools, their dialogs and commands, and the Transform panel's flips keep the page's axes (VectorCraft's own rule; `bboxrotate::free_transform_handles_turn_with_the_object_and_scale_along_its_axes`, `free_transform_shears_along_the_turned_side`, `free_transform_distort_starts_from_the_turned_box`, `xform::distort_reads_from_in_a_turned_frame_and_rejects_a_bad_angle`, tools `xform::free::tests::the_box_and_its_handles_turn_with_the_selection`).
- Perspective Distort and Free Distort (`object.distort`) turn each warped object's bounding box as the warp turns the object at its own center, the objects inside groups, compound paths and clip groups and the art of linked opacity masks included, so the next Selection or Free Transform drag uses the tilted box; Object › Transform › Reset Bounding Box squares it again, and corners left where they are add no undo step (VectorCraft's own rule; `bboxrotate::distort_turns_an_upright_box_with_the_warp_at_its_center`, `distort_turns_each_member_and_linked_mask_by_its_own_warp`, `mirrored_and_collapsing_distorts_keep_valid_angles`, `distort_angles_undo_redo_and_reopen_together`, `xform::unchanged_distort_records_no_history`).
@@ -919,13 +923,14 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- The Learn panel (Window › Learn, or Explore tutorials beside the search icon on the app bar) has a For you tab with a Get started section and a Quick skill guide card, and a Skills tab that lists VectorCraft's own skills with their tutorial counts and empty progress rings; a skill lists its tutorials, a tutorial shows its numbered steps, and Back goes up a level; `learn.list` and `learn.show {tutorial}` script it ([the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html); `panels::learn::tests`).
- A tool's name in a Learn tutorial step is a blue link that shows where the tool is without choosing it (`learn.locateTool {tool}`): its toolbar button is marked, a collapsed group's flyout opens with the tool's row marked, a torn-off strip comes to the front, and a tool no shown toolbar holds is marked in the All Tools drawer. The mark, its 3 seconds and the hidden-toolbar cases are VectorCraft's own rules ([the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html); `panels::learn::tests`, the `a_learn_link_*` tests in `toolbar::tests`).
- Under a tutorial's steps, View more opens the tutorial's own practice sample as a new untitled document, and once the tutorial is finished, Next Up moves to the next tutorial in the same skill, as the help page describes ([the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html)). The samples are VectorCraft's own drawings. A Finish tutorial button that shows once the sample is open, the untitled copy, Back to skills in place of Next Up after a skill's last tutorial, and Next Up showing the next tutorial without opening its sample are VectorCraft's own rules. `learn.openSample`, `learn.finish`, `learn.next` and `learn.skills` (Back to skills) script them. Not done: the help page's short video, and progress kept between sessions and shown in the skill rings. Regression tests: `view_more_opens_the_tutorial_s_sample_as_a_new_untitled_document`, `finishing_a_tutorial_offers_next_up_in_its_skill`, `the_practice_controls_work_from_the_keyboard`, `the_last_tutorial_of_a_skill_offers_back_to_skills`, `learn_finish_and_learn_next_follow_the_practice`, `learn_skills_shows_the_list_of_skills`, `the_practice_commands_reject_a_missing_or_unknown_tutorial`, `every_tutorial_has_its_own_readable_sample` and `the_next_tutorial_is_the_next_one_in_the_same_skill` in `crates/ui-egui/src/panels/learn/tests.rs`.
+ - The Home screen has a Learn section with the in-app tutorials, as the help page lists among the Home screen's options ([the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/homescreen-overview.html)): the Quick skill guide card shows the Learn panel's list of skills (`learn.skills`), and each tutorial, listed under its skill, shows the panel at its steps (`learn.show`). It shows with no document open and over open documents, and a choice leaves the documents as they are. Its place between Recent Files and the new-file presets, its layout and what a choice opens are VectorCraft's own rules (`tests::home::home_has_a_learn_section_that_opens_the_learn_panel`, `home_learn_section_over_open_documents_leaves_them_as_they_are`, `home_quick_skill_guide_card_works_from_the_keyboard`).
- The errors of the blend spine commands (Move Spine Point, Reshape Spine Segment, Add Spine Point and Delete Spine Point) show in the status bar in the interface language: each has a row in every catalog that translates all status and error messages and in Bulgarian (`blend_spine_param_errors_are_translated`).
- The errors of the 3D effects (Extrude & Bevel, Revolve, Inflate, Rotate and their Classic forms) show in the status bar in the interface language: the art a 3D effect cannot build a solid from, invalid light, graphics and surface settings, and the notes for an object that draws no 3D faces or more wireframe edges than the limit. Each has a row in every catalog that translates all status and error messages, and the effect's name and the values in the message are translated or kept as written (`three_d_refusals_are_translated_in_every_complete_catalog`, `three_d_refusals_translate_with_their_values`).
- The errors of the parameter checks that many commands share show in the status bar in the interface language: a value that is not a number where a number goes (`object.move` with `"dx": "60 mm"`, and `object.rotate`, `object.setBounds` and the other commands that call `check_numbers`), a missing number or index, `ids` that are not a list of object ids, an object id that is not a non-negative integer or names no object, and a date that is not Unix seconds. Each has a row in every catalog that translates all status and error messages, and in Bulgarian, Danish, Indonesian and Dutch (`shared_param_check_errors_are_translated`).
- A parameter schema's errors show in the status bar with what the value must be in the interface language, for every type a schema has (a number with each kind of range and with a unit, an integer and its range, true or false, a string, a list of names, an object id, a list of object ids, a color, a point, an object and a list), in every catalog that translates all status and error messages; the path, the refused value, the bounds, the unit and the names stay as the English gives them. The error for a value that is not one of its names is also translated in Bulgarian, Bosnian, Danish, Indonesian and Dutch (`parameter_schema_expected_phrases_are_translated`, `bracketed_message_parts_translate_only_as_exact_messages`).
- The untranslated report lists the status and error messages of the effects, swatch libraries, raster sizes and encoders, 3D solids and path operations that a catalog lacks, the messages that a `.map_err` closure or a named error handler returns, and the phrases a parameter schema's error builds for what a value must be (`untranslated_report_checks_every_message_crate_and_schema_phrase`, `message_scan_reads_map_err_closures`, `message_scan_reads_named_map_err_handlers`).
- Export commands run through MCP, the control channel or the CLI refuse a key that no format takes, and warn about a key that only another format takes (`{"format": "pdf", "quality": 80}` writes the PDF with the warning "`quality` was not used: PDF files don't read it"; `an_option_only_another_format_reads_gets_a_note`, `control_export_notes_an_option_the_format_does_not_read`, `convert_warns_about_an_unused_option_only_when_one_was_given`), and a command that writes one format (`document.exportPdf`, `document.exportEps`, `document.exportDxf`) or takes one format's options (`document.pdfSettings`, `pdf.preset.save`) refuses a key that format doesn't take. An unknown key inside `marks`, `bleed`, `compression`, `output`, `advanced`, `security` or `flattener` is refused by its path.
- - Save commands run through MCP, the control channel or the CLI (`document.save`, `file.saveAs`, `file.saveCopy`, `file.saveAsTemplate`, `app.save`) refuse a key that no save format takes, beside the path or in `options`. Export for Screens refuses a key it doesn't take, and a key that no format takes in a format row or in a format's settings, by its path (`formats[1].marks.useDocumentBleed`); Export Assets (`assets.export`, `assets.settings.set`) checks the rows and settings it is given the same way.
+ - Save commands run through MCP, the control channel or the CLI (`document.save`, `file.saveAs`, `file.saveCopy`, `file.saveAsTemplate`, `app.save`) refuse a key that no save format takes, beside the path or in `options`, and warn about a key the file doesn't read, beside the path or in `options`, with an export's `.ai` rules; Save Options sends a `.ai` save only the options the chosen version reads (`a_save_notes_the_ai_options_its_file_does_not_read`, `ai_save_options_send_only_the_options_the_version_reads`). Export for Screens refuses a key it doesn't take, and a key that no format takes in a format row or in a format's settings, by its path (`formats[1].marks.useDocumentBleed`), and warns about a key there that only another format takes (`a_row_option_only_another_format_reads_gets_a_note`); Export Assets (`assets.export`, `assets.settings.set`) checks the rows and settings it is given the same way.
- Commands that read their `ids` (or `slices`) list themselves refuse a list with an entry that is not a non-negative integer, and a value that is not a list, and change nothing; they no longer drop the entry and act on the rest (Copy Appearance From, the Edit Colors commands and Overprint Black, Find and Replace, `layer.highlight`, `layer.move`, `library.add`, the Links panel commands, `object.transform`, Make Pattern, the Repeat commands, the slice commands, Join, Average and Join, `plugin.run`, `symbol.edit`, Make Opacity Mask; `tests::ids_without_selection::an_ids_entry_that_is_not_an_id_is_refused_and_changes_nothing`). The error for an entry of an object id list names the key, the entry and its value ("`ids` entry 1 is true, not a non-negative integer object id"), with a row in every catalog that has the other object id errors.
- The errors of Enter Isolation Mode (`object.isolate`) for a type object, for hidden or locked art and for a call with no `id` while nothing is selected ("missing id", which other commands that take an `id` also give), and the error of the Layers panel's target circle (`layer.target`) for an object outside the isolated group, show in the status bar in the interface language: each has a row in every catalog that translates all status and error messages, and in Bulgarian, Danish, Indonesian and Dutch.
- An EPS or PostScript file that stops on a PostScript error gives the error's name as PostScript spells it (`typecheck`, `rangecheck` and the other names the reader raises, except `undefined`, whose message names the unknown name) followed by what it means, in the warning after opening and in the error when the file can't be opened. The status bar and the open-error dialog show the meaning in the interface language: each meaning has a row in every catalog (`postscript_error_meanings_are_translated`).
@@ -939,6 +944,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Dynamic symbol appearance follows what an object draws** (VectorCraft's own rule): `symbol.instance.setAppearance` refuses a fill or stroke for an object of the art that does not draw it (a guide, blend, envelope, Repeat, Intertwine group, gradient mesh or placed document; a nested symbol instance's stroke, and its fill unless a solid color or none, which stains its art; an image's fill or stroke without a Convert to Shape or Outline Object effect) with an error that names the key and the object, and the call changes nothing. Opacity, blend and `null`, which takes a stored change back, are taken for every object. Tests: `crates/engine/src/tests/dynamic_symbols.rs`.
- Symbol editing mode keeps every instance in step: instances of a dynamic symbol inside other symbols' art, pattern tiles, graph designs, stored brushes and opacity masks keep their appearance changes on the objects an edit reorders and drop the changes to objects it deletes. The view does not draw the instance the edit was entered from. Area type in the symbol's art takes the text wrap that moving a wrap object gives it. Editing another symbol at a place that fails keeps the current edit open, and only the editing layer or art on it can be isolated. Tests: `crates/engine/src/tests/symboledit.rs`, `isolation::symbol_editing_does_not_draw_the_instance_it_was_entered_from`, `panels::symbols::tests::double_clicking_a_symbol_in_the_panel_edits_it_in_either_view`, `the_panel_menus_edit_symbol_edits_the_instances_symbol_or_the_panels`, `the_control_bars_edit_symbol_edits_the_selected_instances_symbol`, `chrome::tests::the_control_bar_exits_symbol_editing_mode`.
- **Dynamic symbol instances on the canvas** ([Symbols, archived copy](https://web.archive.org/web/20240206075757/https://helpx.adobe.com/illustrator/using/symbols.html)): each instance of a dynamic symbol can have an appearance of its own while it shares the symbol's shape. The rest of this entry is VectorCraft's own rules. A Direct Selection click on an instance of a dynamic symbol selects the path, compound path or type object of the symbol's art under the pointer and outlines it (`symbol.instance.selectPart`); the Swatches and Color panels, the Control bar, the Eyedropper and the other fill and stroke controls then change that object's fill or stroke (solid, none, gradient or pattern) for this instance only, each as one undo step, and `transparency.set` changes its opacity and blend the same way; the symbol's art and its other instances stay as they were. Alt or Shift picks the instance whole. An Eyedropper source that is not a path, compound path or type, a click on any other object of the art (a group, image, blend or nested instance) and the commands that change the selected objects (moving, Delete, Group, Hide, Lock, Arrange, Expand, the gradient annotator, isolate/knockout on Transparency, the Appearance panel's rows and others) are refused with a status message. Copy, Export Selection, Fit to Selection, Liquify, Isolate, Add to Library and Save Selection use the whole instance (`docs/mcp.md`), and another selection tool selects the instance whole again. Tests: `crates/engine/src/tests/dynamic_symbols.rs` (`gradients_and_patterns_on_a_picked_object_are_taken`, `opacity_and_blend_on_a_picked_object_go_through_transparency_set`), `direct::tests::a_click_on_a_dynamic_instance_picks_the_object_under_the_pointer`.
+ - **Stained symbol instances in export** ([the help page](https://web.archive.org/web/20220911143322/https://helpx.adobe.com/pdf/cs6/illustrator_reference.pdf#page=136), Stain symbol instances): an instance colored with the Symbol Stainer is exported in the colors the canvas draws it in. The mix is VectorCraft's own rule: after a dynamic symbol's own changes, the instance's visible fill mixes the solid fills and strokes of the symbol's art, type included, toward the fill's color by the fill's opacity; a hidden fill or one at 0% leaves the art's colors as they are. SVG, PDF (printing to PDF included), EPS, legacy `.ai`, DXF, EMF and WMF write the stained colors for an instance on its own, in a group, placed through 9-slice guides or reshaped by its geometry effects, and a color adjustment on the instance recolors the stained art. Tests: `bake::tests::a_stained_instance_is_baked_in_its_stained_colors` (vectorcraft-effects), `tests::a_stained_instance_keeps_its_stain` (vectorcraft-pdf), `symbols_layers::a_stained_instance_with_a_geometry_effect_keeps_its_stain` (vectorcraft-svg), and `a_stained_instance_is_written_in_its_stained_color` in vectorcraft-eps (EPS and legacy `.ai`), vectorcraft-cad and vectorcraft-metafile (EMF and WMF).
- **9-slice guides on the canvas** ([public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/maintain-proportions-while-scaling-symbols.html)): while a symbol with Enable Guides for 9-Slice Scaling on is edited in symbol editing mode, the canvas draws its two horizontal and two vertical guides as dotted lines across its art, with any tool and with ruler guides hidden. With the Selection tool the pointer near a guide shows the Move cursor, and dragging the guide moves it (`symbol.options`'s `nineSliceGuides`) as one undo step; Escape during the drag puts it back. VectorCraft's own rules: a guide within 4 screen pixels takes the press before the art and the bounding box; it may pass the art's edges (see the next entry) and stops short of the opposite guide; the drag does not snap. Tests: `nineslice::tests`, `symbol_nine_slice::tests::the_edited_symbols_guides_cross_its_art_where_the_editing_layer_shows_it`, `a_moved_guide_passes_the_arts_edges_and_stops_short_of_the_opposite_guide`, `canvas::tests::the_edited_symbols_nine_slice_guides_are_drawn_and_dragged_on_the_canvas`.
- **9-slice guides outside the art** ([the help page](https://help.adobe.com/archive/en/illustrator/cs6/illustrator_reference.pdf), Use 9-slice scaling section): a guide may lie outside the symbol's art, set by dragging it past the art's edge in symbol editing mode, by `symbol.options`'s `nineSliceGuides` or by a Symbol Options field below 0% or above 100%, and a scaled instance still scales, by the cells its art falls in. VectorCraft's own rules: each guide lies within 16 times the art's width or height of its edge (`-16..16` as a fraction, `-1600..1600` in percent) and the two guides of an axis keep their order; a guide past an edge of the art cuts the art at that edge, so the outer cell on its side holds no art; with both guides of an axis past opposite edges the art scales as a whole along it; with both past the same edge the art lies in the outer cell at the other edge and keeps its size, from that edge of the instance, or shrinks to the instance's width or height when the instance is smaller than the art; Symbol Options shows and keeps the guides unrounded. Native files keep the guides exactly; SVG and `.ai` write such an instance as its placed art and keep no guides. Tests: `symbol_nine_slice::tests::guides_outside_the_art_are_accepted_in_order_and_within_the_limit`, `art_scales_by_the_cells_it_falls_in_when_guides_lie_outside_it`, `the_map_with_guides_outside_the_art_is_continuous_and_monotone` in `crates/doc`, `guides_outside_the_art_are_set_by_command_and_scale_by_the_cells_that_hold_art`, `a_guide_dragged_past_the_arts_edge_lies_outside_it`, `guides_outside_the_art_survive_saving_and_reopening_a_native_file` in `crates/engine`, `nineslice::tests::a_guide_outside_the_art_is_taken_and_dragged_past_the_arts_edge`, `a_nine_slice_instance_with_a_guide_outside_its_art_is_written_as_drawn` (SVG and `.ai`).
- **Delete Symbol asks first** ([Symbols panel](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html), [symbol libraries](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-or-import-symbol-libraries.html), [Alt-click Delete](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)): the Symbols panel's Delete Symbol button and menu item ask "Delete the selected symbol?" with Yes and No for a symbol no instance uses. For a symbol with placed instances they ask with Expand Instances (the instances become ordinary art), Delete Instances and Cancel; either Instances button deletes the symbol in one undo step. Alt-click on the button deletes an unused symbol without asking and still asks about one with instances. A question left open while another document became active deletes nothing. `symbol.delete` itself does not ask. Tests: `panels::symbols::tests::delete_symbol_asks_first_and_alt_click_deletes_an_unused_symbol_at_once`, `the_in_use_question_expands_or_deletes_the_instances`, `a_stale_delete_symbol_question_deletes_nothing`, `the_delete_symbol_questions_show_their_buttons`.
@@ -1020,6 +1026,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- On Windows and Linux the app bar is the window's title bar (its own minimize, maximize and close buttons, drag to move, edges to resize), with the dragon app icon as the brand mark; dialogs and menus size to their content. As on a native menu bar, once one menu is open, moving the pointer onto another title opens that one.
- On Linux and the BSDs, when the desktop leaves the window's frame to the app (a Wayland session without server-side decorations, such as GNOME) and the window is not opened through XWayland, the app bar's window buttons follow the `button-layout` key of `org.gnome.desktop.wm.preferences` ([schema](https://gitlab.gnome.org/GNOME/gsettings-desktop-schemas/-/blob/master/schemas/org.gnome.desktop.wm.preferences.gschema.xml.in)): Close alone at the right, all three at the left, or split between the ends, in the listed order with the first one leftmost. When the key cannot be read or names no window button, and for an app frame turned on by `VECTORCRAFT_TITLEBAR=app` or VectorCraft Title Bar on Every Desktop, all three stay at the right. Tests `titlebar::tests::caption_buttons_follow_the_caption_layout`, `tests::the_caption_buttons_follow_the_frame_layout` and `tests::the_start_frame_applies_the_app_title_bar_preference`.
- On Linux and the BSDs, Preferences › User Interface › VectorCraft Title Bar on Every Desktop (off by default) uses the app bar as the title bar from the next start in the sessions where the desktop would draw the window's frame (X11, Wayland compositors with server-side decorations such as KDE Plasma, a window opened through XWayland). System Title Bar and `VECTORCRAFT_TITLEBAR` take precedence over it, and `VECTORCRAFT_TITLEBAR` also takes precedence over System Title Bar for the run it is set in. Only the values `app`, `system` and `desktop` take precedence (case-sensitive, surrounding spaces ignored); any other value, such as `unknown`, is ignored and does not override System Title Bar.
+ - On Linux and the BSDs, in the sessions where the desktop would draw the window's frame, Preferences › User Interface shows a note under System Title Bar while VectorCraft Title Bar on Every Desktop is off and `VECTORCRAFT_TITLEBAR` is unset: the desktop draws the title bar with System Title Bar on or off. The app works out the session when it starts, so a Linux Display Server change shows in the note from the next start. Tests `prefs_dialog::tests::system_title_bar_says_when_the_desktop_keeps_the_frame` and `tests::the_desktop_keeps_the_frame_where_system_title_bar_does_not_change_it`.
- Edit › Keyboard Shortcuts lists items of the menu bar and panel menus, as the reference app's [Customize keyboard shortcuts](https://web.archive.org/web/20221205131209id_/https://helpx.adobe.com/illustrator/using/customizing-keyboard-shortcuts.html) page describes for its Menu Commands. A menu command with no default shortcut that needs a value a key press does not give, such as File › Document Setup › Units (`document.setUnits {units}`), is listed only while the user has changed its shortcut. A command stays listed when a menu bar item runs it without a value or when it opens a dialog run without one, as the Variables panel's commands do.
- No two rows of Edit › Keyboard Shortcuts have the same English name (VectorCraft's own rule). A command that an item of the menu bar runs is listed under that item's menu path (Window › Swatch Libraries › Other Library…), a command whose id extends another command's id is listed under that command's label (Character Style Options › Reset Panel), and the rows that still share a name show their id after it, except the one row an item of the menu bar runs (File › New… and File › New… (file.new)). The search finds a row by the id it shows, and a message about a shortcut already in use names the other row with its group. Test `shortcut_editor::tests::no_two_entries_have_the_same_name`.
- A shortcut on the key left of 1 runs whatever character that key types, and Edit › Keyboard Shortcuts, the menus in the window, the command palette and tool tips show it as that character once the key has been pressed: `"` on a Turkish Q layout, `~` on a US layout as before. Shortcuts saved as `~` keep working (#4559).
@@ -1071,7 +1078,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join. Direction handles dragged with Direct Selection or the Anchor Point tool snap to smart guides, and Shift keeps them at 45° steps from their anchor. The Pen continues any open path from the end clicked, and while drawing, a click on an end of another open path joins the two into one (#776). The Curvature tool edits any selected path, whatever drew it, keeping its shape except where edited (#798): a click on a point makes it current and a drag moves it, re-curving only its two segments; double-click, or Alt-click on any point but an end of an open path, toggles it smooth or corner and Delete removes it; a click on a segment adds a point without changing the shape; with an end of an open path current, clicks go on from it and its other end closes it (`path.curvatureEdit`). An Alt-click on an end of a selected open path makes that end current without changing it, so the next click goes on from it, as [the help page](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/draw-shapes-with-the-curvature-tool.html) describes for continuing a path; an Alt-click that starts a new path places a corner point, as later Alt-clicks do. Every point a drawing tool places snaps to Smart Guides through one snapper (#506): the Pen's and Curvature tool's anchors, the Pencil's ends, the shape tools' start and dragged corner or radius (Rectangle, Rounded Rectangle, Ellipse, Polygon, Star, Line Segment, Arc, Spiral, the grids), the Type tool's click and frame, a new artboard's corners. A filled Pencil path keeps a stroke of None (#1804, part of #1679). While a Pencil or Paintbrush stroke is drawn back to within Close Paths When Ends Are Within of its start, the cursor shows the closing ring and a release there closes the path, as [Draw straight lines with the Pencil tool](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/draw-straight-lines-with-the-pencil-tool.html) describes for the Pencil. The [ and ] keys reach the Blob Brush, the Eraser and the Symbolism tools in the app (#1734, part of #1661).
Blob Brush and Eraser Tool Options' Size is Fixed or Pressure with a Variation (#4590, #3961): with Pressure a pen stroke is Size − Variation wide where it presses lightest and Size + Variation where it presses hardest, and a mouse stroke keeps the Size.
With the Blob Brush, Shift held during a drag keeps the stroke on the horizontal or vertical line through the press point, whichever is nearer to the pointer (90° steps from the Constrain Angle preference), and the points dragged since Shift went down move to the other line when the pointer crosses to it; the points dragged while Shift is up follow the pointer.
- With the Blob Brush, Alt held before the press, or pressed before the drag has moved, switches to the Smooth tool, as the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists: the cursor shows the Smooth tool, the drag shows a thin line and, with a path selected that it can smooth, the dashed band it smooths, without the length label, Shift does not constrain it, and the release smooths the selected paths under the drag (`path.smoothRegion`) instead of painting; with nothing selected the release changes no path. A stroke that has moved keeps painting when Alt goes down.
+ With the Blob Brush, Alt held before the press, or pressed before the drag has moved, switches to the Smooth tool, as the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists: the cursor shows the Smooth tool (Not Allowed with no path selected that it can act on), the drag shows a thin line and, with a path selected that it can smooth, the dashed band it smooths, without the length label, Shift does not constrain it, and the release smooths the selected paths under the drag (`path.smoothRegion`) instead of painting; with nothing selected the release changes no path. A stroke that has moved keeps painting when Alt goes down.
Curvature point markers are circular, with no direction handles. While drawing, its own point feedback replaces generic selection markers; existing paths use the selection overlay, including partial selections and Hide Edges. Anchor Size applies, and Direct Selection and Pen retain their handles. The real egui-frame regressions in `crates/ui-egui/src/canvas.rs` verify marker positions, counts and unchanged documents (#1236, in part); the existing-path interpolation request remains open. The curve through the points (`curvature_spline` in `crates/tools/src/draw2.rs`) makes each stretch of three points follow the circle through them: a smooth point's handles lie along that circle's tangent, each `chord / (3 cos²(α/2))` long (α between handle and chord, capped at 90°), and the ends of an open path mirror their neighbour's tangent in their chord, so a J or a U curves evenly, a turn tighter than 30° comes to a point, and uneven spacing doesn't loop; the rubber band, a dragged or inserted point and `path.convertAnchor` to smooth use the same rule, while the Pencil's fitting keeps its own; `tests/draw2.rs` checks that the rubber band is the path the click makes (#1236, in part). The Smooth tool smooths only the stretch of path under the drag (#1547, fixes #1512). A path the Smooth tool changes stays selected whole, and the anchor points and segments selected on it with Direct Selection are deselected. Selected paths the drag does not change keep their selected anchors, and undo restores the earlier selection (`tests/draw2.rs`, `smoothing_a_path_drops_its_direct_selected_anchors`). While the Smooth tool drags with a path selected that it can smooth, a dashed outline shows the band it smooths: everything within its radius of the drag (12 screen pixels, VectorCraft's own value), round at both ends; Alt smoothing with the Pencil, Paintbrush and Blob Brush shows the same band.
While Alt is held during a Paintbrush stroke or before a press with no path selected that the Smooth tool can act on, the Paintbrush shows a closing cursor (a crosshair with a ring), and so does the Pencil with Alt key toggles to Smooth Tool off when Alt is pressed after the drag starts; releasing the mouse button with Alt still held closes the path. Before a Paintbrush press with such a path selected, Alt shows the Smooth cursor, and holding Alt at the press smooths that path with the Smooth tool's radius and Smooth Tool Options' Fidelity. A Pencil drag started with Shift or Alt held draws a straight line instead, which stays open.
Smooth Tool Options' Fidelity is in pixels, as [the help page](https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf#page=79) gives it: the Smooth tool, and an Alt smoothing drag with the Pencil, Paintbrush or Blob Brush, refits the path at Fidelity divided by the zoom, so a drag smooths a path as much on screen at 25 % as at 800 %. Reading the pixels as screen pixels is VectorCraft's own rule, as for the Pencil and the Paintbrush (#5729); the range (0.05 to 100 px) and the default (2.5 px) are VectorCraft's own values, and a saved Fidelity keeps its number (`crates/tools/src/draw2/gesture/tests.rs` test `smooth_fidelity_is_in_screen_pixels_at_any_zoom`, `crates/engine/src/tests/draw2.rs` test `smooth_region_refits_at_the_tolerance_sent_at_any_zoom`).
@@ -1103,7 +1110,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
A path segment with a control point whose x or y is infinite or larger in size than `f64::MAX / 16` is measured by its control points, so its bounds hold the whole curve and the canvas check refuses an edit that leaves such a path selected (`path.create` included).
With Direct Selection, the Pen and the anchor tools, a group, compound path or clip group selected whole shows the anchors of its paths filled, as a path selected whole does; the direction handles still show only on a path that is selected itself. Public references: [Adjust anchor point, handle, and bounding box display size](https://web.archive.org/web/20251114182206/https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/learn-drawing-basics/adjust-anchor-point-handle-and-bounding-box-display-size.html) and [Drawing basics](https://web.archive.org/web/20231130064834/https://helpx.adobe.com/illustrator/using/drawing-basics.html) (archived copies).
When the Add Anchor Point tool, the Pen's Auto Add, the Smart Pen's Alt-click on a segment or Alt-click with the Scissors adds an anchor to a path with direct-selected anchors or segments (`path.insertAnchor`), the same points stay selected: the anchors and segments after the new anchor are numbered one higher, and a selected segment that the new anchor splits stays selected as its two halves.
- `path.curvatureEdit`, `path.removeAnchor`, `path.convertAnchor`, `path.reshapeSegment`, `path.split`, `path.setHandle`, `path.moveAnchor` and `path.insertAnchor` refuse a `subpath` that is not a whole number of 0 or more, and `path.split` refuses such an `anchor` or `segment`; without a `subpath` they edit subpath 0.
+ `path.curvatureEdit`, `path.removeAnchor`, `path.convertAnchor`, `path.reshapeSegment`, `path.split`, `path.setHandle`, `path.moveAnchor` and `path.insertAnchor` refuse a `subpath` that is not a whole number of 0 or more, and `path.split` refuses such an `anchor` or `segment`; without a `subpath` they edit subpath 0. A value given that is not a whole number, such as `"1"` or `1.5`, is refused with `` `subpath` must be a whole number ``, naming its key, as are a required index of the Appearance panel and Smart Pen commands, `sourceDocument` of `edit.dropSelection` and `index` and `to` of `document.activate` and `document.move`; a required one left out or null is reported as missing (`tests::params::a_malformed_required_integer_is_not_reported_as_missing`).
`path.reshapeSegment` refuses a delta of 1e9 pt or longer, as `object.blend.spine.reshapeSegment` does, and `path.convertAnchor` and `path.freehand` refuse a point whose x or y is 1e9 pt or more in size.
`path.reshapeSegment`, `object.blend.spine.reshapeSegment`, `path.convertAnchor`, `path.curvatureEdit`, `path.insertAnchor`, `path.moveAnchors`, `path.reshape`, `path.close` and the Pencil, Curvature, Knife, Eraser, Path Eraser, Smooth, Join and Blob Brush commands refuse a number, point or handle given as text, such as "10", and leave the document unchanged; `path.convertAnchor` refuses an `x` without a `y`, or a `y` without an `x`.
`path.close` and `path.setHandle` (`independent`), `path.moveAnchors` (`patterns`), the Pencil (`closed`, `keepSelected`, `fill`), Curvature (`closed` and a point's `corner`), `path.curvatureEdit` (`corner`), `path.reshapeSegment` (`perpendicular`) and the Blob Brush (`keepSelected`, `mergeOnlySelection`, `merge`) refuse a value other than true, false or null, such as "true", and leave the document unchanged.
@@ -1152,6 +1159,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
The Pencil's and the Paintbrush's Fidelity is in screen pixels, the same at any zoom: their Tool Options give it in px, from 0.01 px on the logarithmic slider to 20 px and up to 100 px in the field, and the tool sends `path.freehand` the value in points at the current zoom (#5729). The Smooth tool's and the Blob Brush's Fidelity stays in points.
`path.freehand` and `brush.freehand` take a Fidelity of up to 100 / 0.0313 pt (about 3194.89 pt), the 100 px field maximum at the lowest zoom (3.13 %), and reject a value outside their range with an error, so a Pencil or Paintbrush stroke drawn zoomed out keeps the Fidelity set in pixels (engine tests `pencil_fit_keeps_its_anchors_on_a_stroke_drawn_zoomed_out` and `pencil_takes_fidelity_within_its_range_and_rejects_the_rest`).
Each drag of the Smooth tool smooths the stretch of a selected path in its reach along its length before refitting it within Fidelity, so a wobble wider than Fidelity is smoothed out and another drag over the same place smooths it further, as [the help page](https://helpx.adobe.com/illustrator/using/tool-techniques/smooth-tool.html) describes (`path.smoothRegion`). The smoothing spread (a third of the reach) and the rule that each refit stays within twice the distance the smoothing moved the path, so repeated drags do not drift from it, are VectorCraft's own. A drag over the whole of a closed path smooths it across its first anchor too, so a bump there is smoothed like any other and no corner is left; Object › Path › Smooth smooths a closed path across its first anchor the same way.
+ The Smooth tool shows the Not Allowed cursor while no path is selected that it can act on (an editable path, not a guide, with three or more anchors on one of its subpaths or two anchors joined by a curve), and the release of a drag then sends no command and changes nothing; [the help page](https://helpx.adobe.com/illustrator/using/tool-techniques/smooth-tool.html) has the path selected first. A selected path anywhere on the canvas gives the Smooth cursor wherever the pointer is. The Pencil with Alt key toggles to Smooth Tool on and the Blob Brush show Not Allowed the same way when Alt gives them the Smooth tool; the Paintbrush keeps its closing cursor, since its release with Alt closes the stroke. Use Precise Cursors leaves Not Allowed as it is. This cursor rule is VectorCraft's own (tools `smooth_shows_not_allowed_without_a_selected_path_to_smooth`, `borrowed_smooth_shows_not_allowed_without_a_selected_path_to_smooth`, `smoothing_release_sends_nothing_without_a_selected_path_to_smooth`; engine `use_precise_cursors_keeps_the_smooth_tools_not_allowed_pointer`).
When a Smooth tool drag reaches the first or last anchor of an open path, that anchor stays where it is and the curve there leaves or arrives along the smoothed path within the reach, so a jagged first or last segment no longer sets the end's direction; an end where the smoothed path turns back by 90° or more keeps its segment's direction, and where the refitted stretch ends inside the path the curve keeps the direction of the part outside the drag. This rule is VectorCraft's own (`tests/draw2.rs`, `smoothing_an_open_path_turns_its_jagged_ends_with_the_path` and `a_smooth_drag_turns_only_the_end_it_reaches`).
A Smooth drag along a curved segment smooths the stretch of it in the drag's reach even when neither of the segment's anchors is in reach, as [the help page](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/refine-paths.html) describes dragging along the path segment; the segment is cut where the drag enters and leaves the reach, and the parts outside keep their shape (`path.smoothRegion`). Paths of two anchors are smoothed too, and Alt with the Paintbrush smooths a selected two-anchor curve; a two-anchor straight line still gets the closed stroke. How the reach is found on a segment (samples no more than half the reach apart) is VectorCraft's own, and a straight segment is left as it is.
Before a press that draws a new path, the Pencil cursor shows a small star, as [Draw freeform paths with the Pencil tool](https://web.archive.org/web/20260926095638/https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/draw-freeform-paths-with-the-pencil-tool.html) describes; over a selected path within Edit selected paths' distance, which the press continues from an end or redraws, the star is not shown ([Reshape paths with the Pencil tool](https://web.archive.org/web/20260106004407/https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/reshape-paths-with-the-pencil-tool.html)).
@@ -1192,8 +1200,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
Distance Guides: with Smart Guides on (View › Smart Guides and Preferences › Smart Guides › Invoke Distance Guides, on by default), something selected and the Selection tool, holding Alt/Option over another object shows the distances between the selection's bounds and that object's: on an axis on which they lie apart, one line across the gap between their facing edges; on one on which they overlap, the offsets of their left and right (top and bottom) edges, each line running across the middle of where they overlap, with a dashed line out to the other object's edge when it passes beside it. Over an empty part of an artboard it shows the selection's distances from the artboard's left, right, top and bottom edges. Each line has ticks at its ends and its length in a pill of the smart guide colour, in the General unit; letting Alt go, pressing (a pan or zoom press too), moving off them, leaving the canvas or a floating panel, menu or dialog over the pointer takes them away, and with the pointer still they follow what lies under it as the view scrolls, zooms or turns, as the document, the tab or the selection changes, and when the Selection tool is switched to. Behaviour from the public [Use Distance Guides for accurate placement](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/use-distance-guides-for-accurate-placement.html) and [Smart Guides options](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/smart-guides-options.html) pages, which describe hovering only: nothing shows while moving or nudging. Tests: `select::tests::alt_over_*`, `select::tests::distance_guides_need_alt_a_selection_and_smart_guides`, `tests_prefs::invoke_distance_guides_measures_the_selection_to_the_object_under_alt`, `canvas::tests::alt_over_another_object_draws_the_distance_guide_label`, `canvas::tests::distance_guides_go_when_the_hover_ends`, `canvas::tests::distance_guides_follow_what_lies_under_a_still_pointer`.
A move pressed on a path's stroke, or within 6 px of its outline away from its anchors and center, is held by the nearest point of that path, with the Selection, Group Selection or Direct Selection tool, and a straight segment Direct Selection drags is held by its own nearest point. That point is what snaps, so the path lands exactly on a ruler guide (with "align") instead of the pressed spot landing there and the path a few pixels beside it, an offset that showed only zoomed far in (#3701). A press in the middle of a fill still snaps the pressed spot: the public [Align objects with guides](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/align-graphic-objects-with-guides.html) page has Snap to Point align the point under the pointer, which is placed exactly where the object should meet the guide.
A bounding-box handle dragged with the Selection or Free Transform tool snaps what it moves as a dragged point snaps (#2054). With View › Snap to Grid on, it snaps to the nearest gridline, and with Snap to Pixel on, it snaps to whole pixels. With Smart Guides off and Snap to Point on, it snaps to the other objects' anchors and the ruler guides within the Snap to Point distance. With Smart Guides and Snap to Point both on, it lines up with the ruler guides as well as the other objects. Shift still keeps the proportions, and Alt still scales about the center.
- A user slice's edge or corner handle dragged with the Slice Selection tool snaps as the Slice tool's drag does. With View › Snap to Grid on, it snaps to the nearest gridline, and with Snap to Pixel on, it snaps to whole pixels. Otherwise, with Smart Guides on, it lands on or lines up with the anchors, centers and edges of the art and the artboards, and with Smart Guides off and Snap to Point on, it snaps to the art's anchors and the ruler guides within the Snap to Point distance. Then a ruler guide within 5 px, shown and unlocked, moves the side or corner onto it. Shift still keeps the proportions, and Alt still resizes about the center.
- View › Snap to Glyph: with Smart Guides on, a point drawn near a single line of live type lands on its glyphs' anchor points and lines up with its baseline, its x-height and the edges of each character's advance width (proximity guides), as well as the glyph bounds: each glyph's box gives edge guides and four corners a point lands on, even where its outline curves (the right of `B`), and the box follows the glyph's size, scaling and rotation (#5112). In rotated type these guides run at the type's angle, and type of one glyph also has angular guides along its slanted straight segments. A paragraph shows glyph bounds only.
+ A user slice's edge or corner handle dragged with the Slice Selection tool snaps as a bounding-box handle does. With View › Snap to Grid on, it snaps to the nearest gridline, and with Snap to Pixel on, it snaps to whole pixels. Otherwise, with Smart Guides on, it lands on or lines up with the anchors, centers and edges of the art and the artboards, and with Smart Guides off and Snap to Point on, it snaps to the art's anchors and the ruler guides within the Snap to Point distance. Then a ruler guide within 5 px, shown and unlocked, moves the side or corner onto it. Shift still keeps the proportions, and Alt still resizes about the center.
+ View › Snap to Glyph: with Smart Guides on, a point drawn near a single line of live type lands on its glyphs' anchor points and lines up with its baseline, its x-height and the edges of each character's advance width (proximity guides), as well as the glyph bounds: each glyph's box gives edge guides and four corners a point lands on, even where its outline curves (the right of `B`), and the box follows the glyph's size, scaling and rotation (#5112). In rotated type these guides run at the type's angle, and type of one glyph also has angular guides along its slanted straight segments. A paragraph shows glyph bounds only. A Parametric Shape's center (drawn with a drag or placed with a double-click) and the point dragged out from it, and the corners of a slice drawn with the Slice tool, snap as other drawn points do: to these guides, and to the anchors and segments of paths. Tested in `crates/tools/src/guides/tests.rs`.
Snap to Glyph options (#4232, [Snap to Glyph options](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/snap-to-glyph-options.html)): the Character panel menu's Show Snap to Glyph Options (Hide Snap to Glyph Options) shows a Snap to Glyph section in the panel, with or without type selected, with a checkbox for each kind of guide: Baseline, x-height, Glyph Bounds, Proximity Guides (the edges of each character's width), Angular Guides and Anchor Point. Each off takes its guides away and leaves the others (`view.snapToGlyph.options`, the `glyphSnap` preference group, all on by default). Angular Guides covers a glyph's slanted strokes and every guide of rotated type, which runs at its angle; the boxes are dimmed while View › Snap to Glyph is off, and the section starts with a Snap to Glyph checkbox for it (VectorCraft choices; the page settles neither). Showing the section scrolls the panel to it, since it comes after every character attribute (#5273). Tested in `crates/tools/src/guides/tests.rs`, `crates/ui-egui/src/panels/character/tests.rs` and `crates/engine/src/tests/prefs.rs`.
- **Artboard click-to-fit (#1227):** a plain click fits the topmost editable object or group to a new artboard, including its stroke like Fit to Selected Art, and makes the new artboard active. Art and its selection stay unchanged; creation is one undo step. Handles, modified clicks, drags, Escape and tool changes retain their meaning. Tested inside an artboard and on the pasteboard (`crates/engine/tests/it/artboard_click.rs`); the public [CS6 Printing Guide](https://helpx.adobe.com/content/dam/help/en/indesign/pdf/CS6_PrintGuide.pdf) documents the object-click gesture. This legacy gesture adds evidence without regrading the 2026 canvas controls.
- **Rotated views:** with the view rotated, Fit Artboard in Window, Fit All in Window and the status bar's artboard navigator fit the artboard or the art as the view turns it, and a Zoom tool marquee (Animated Zoom or GPU Performance off) zooms to the area dragged across. Ruler guides on no artboard turn with the view. The canvas draws the selection marquee where the tools select, Show Hidden Characters draws the marks of type partly inside the canvas, the Contextual Task Bar stays under the selection, and the Navigator's red box outlines what the window shows.
@@ -1474,6 +1482,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **CSS swatch libraries:** fractional linear and radial gradient stop positions are preserved, so nearby stops such as 12.5% and 12.890625% remain distinct instead of collapsing to 13%. Reference: [CSS Images 3 color stops](https://www.w3.org/TR/css-images-3/#color-stop-syntax). The original synthetic regression is `palette_io::tests::css_preserves_fractional_gradient_stop_positions` in `crates/color/src/palette_io.rs`. Scores and hours are unchanged.
- **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries, seven gradient libraries (among them Organic Fades, soft perceptually mixed multi-stop fades that thin out to transparent, and Holographic, seven opaque linear foils of narrow pastel and pearl bands and seven radial glows that fade out to a clear edge) and three pattern libraries (Geometric Patterns; Grit Textures with eight seamless grain, speckle, stipple, scratch and fleck tiles; and Optical Effects with eight of VectorCraft's own seamless wave, ring, warped-check, stripe, chevron and lens tiles; their swatches bring their patterns) in a library panel, saved as `.vcswatches`, `.gpl`, swatch exchange (`.ase`) or CSS, and loaded from `.vcswatches`, `.gpl` and `.ase` files (`.ase` both ways: RGB, CMYK, Lab and Gray; global, spot and process; groups) and from color books (`.acb`, read only: the RGB, CMYK or Lab colors of a book such as a Pantone book a user owns, named with its prefix and suffix, spot unless it says process, #831); `swatch.library.copyToUser` copies a loaded library into the User Defined folder without replacing a file, and a file with the same extension and bytes as a User Defined library opens as that library. Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate.
Editing, renaming, deleting or merging a global swatch also changes the colors linked to it in graph designs, the art of brushes and the tile that pattern editing's Cancel puts back, in the same undo step as the rest of the art; Default Swatches with replace unlinks them there too.
+ A swatch step in `command.batch` (Delete, Swatch Options, Merge, Edit Color Group) changes the fill and stroke for new art as the command run on its own does, so art drawn later in the batch or after it takes the same paint; a batch that fails, and a canceled batch preview, put back the paints it started with, and a canceled Swatch Options preview leaves them as they were. These swatch commands also change the fills and strokes of the appearance new art takes, and Undo and Redo of the command or the batch put the paints for new art back with the swatches. Evidence: `swatch_steps_in_a_batch_change_the_new_art_paints_as_commands_do`, `undo_and_redo_of_a_swatch_edit_put_back_the_new_art_paints`, `swatch_edits_reach_the_paints_of_the_new_art_template`, `a_failed_batch_puts_back_the_new_art_paints_a_swatch_step_changed` and `canceling_a_swatch_options_preview_keeps_the_new_art_paints` in `crates/engine/src/tests/swatches.rs`.
Add Used Colors (`swatch.addUsedColors`) and Select All Unused (`swatch.unused`) look colors, swatch names, links, tints and gradients up in hash sets instead of searching every swatch and color for each color or linked paint (New Color Group from artwork too), and New Swatch with several colors (`swatch.new`), New Color Group (`swatch.newGroup`) and Edit Color Group (`swatch.editGroup`) name their new swatches from one set of the names already taken. The new swatches keep their names, order and links, and Select All Unused lists the same swatches. Evidence: `add_used_colors_adds_each_new_color_once_with_free_names`, `add_used_colors_global_links_every_paint_of_a_new_color`, `unused_matches_links_tints_colors_gradients_and_patterns_exactly`, `new_swatches_from_colors_are_numbered_after_names_already_taken`, `new_color_group_numbers_its_colors_after_the_group_and_its_moved_swatches`, `new_color_group_from_artwork_lists_each_color_once_with_free_names` and `edit_color_group_numbers_extra_colors_after_the_names_it_keeps` in `crates/engine/src/tests/swatchcmds.rs`; `free_swatch_names_match_naming_each_swatch_as_it_is_added` and `free_swatch_names_match_new_swatch_names_as_swatches_are_added` in `crates/doc/src/swatches.rs`.
- **Colorized images (#1466):** a fill color applied to a selected grayscale or 1-bit image (`paint.setFill`) colorizes it with black, white, a process color or a spot color, and refuses a process color when the image has an alpha channel ([Convert color modes](https://web.archive.org/web/20251220223850/https://helpx.adobe.com/illustrator/desktop/manage-colors/select-and-adjust-colors/convert-color-modes.html), "Colorize Grayscale or 1-bit images"); black and white are accepted there too. The canvas draws each pixel in the color as far as the pixel is dark, with white pixels transparent; PDF export writes the image in the color (a spot color as its ink, Registration in every ink) with the image as its soft mask; Separations Preview and printed separations show it on its color's plates; native files keep the color. SVG writes the fill color through a coverage mask (or a colorized PNG in Tiny 1.2); EPS writes binary coverage as an image mask in the color's ink, and a grayscale image as ink samples scaled by coverage, with white pixels left out at Level 3 and gray midtones written over white.
The prompt for deleting several swatches and groups uses the interface language's plural form for the count. Evidence: `deleting_several_swatches_uses_each_count_form` in `crates/ui-egui/src/panels/swatches/tests.rs`.
@@ -1620,6 +1629,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
Preferences › File Handling › Data Recovery › Folder has a Choose… button on the desktop that picks the folder Data Recovery keeps its copies in ([Illustrator reference, Set Data Recovery preferences](https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf)); a picked folder that Data Recovery cannot create its folders in or list is refused with a status message, and the field keeps its value. OK applies the same check to a folder typed into the field (VectorCraft's own rule): when it fails, the dialog stays open, the status bar says why and no preference changes; an empty field (the default folder) and the folder already saved are not checked. Preferences › Plug-ins & Scratch Disks is where additional plug-ins folders are set ([Illustrator preferences help](https://helpx.adobe.com/illustrator/desktop/get-started/preferences-and-settings/set-app-preferences.html)); its Additional Plug-ins Folder has a Choose… button on the desktop as well, VectorCraft's own, matching Data Recovery's, and VectorCraft's own rule refuses a picked or typed folder whose contents can't be listed. Additional Fonts Folder and Templates Folder stay typed only.
`prefs.set` applies the dialog's check to `recoveryFolder` and `pluginsFolder` for every caller (VectorCraft's own rule): a folder that fails returns an error and no preference changes; an empty value and the folder already saved are not checked.
When a Data Recovery copy can't be written, the status bar and the log show the error (with the document's title when one document's copy fails), the folder copies go to, whether that is the default recovery folder or the folder set in Preferences, and the preference that picks another folder (`recoveryFolder`; VectorCraft's own message).
+ After Restore in the dialog Data Recovery shows at launch, the status bar keeps the restored documents' notes (VectorCraft's own rule): it shows the count followed by the first note, and hovering it lists each document's lines about its linked files and its warnings, named with the document's title when more than one was restored. The questions about the documents' missing or modified linked files come one document at a time, with that document made the current one, and a copy that can't be restored shows its error while the hover keeps the count and the restored documents' notes (`tests/recovery.rs`).
A name typed in the desktop save panel without the file type's extension gets it, and when a file of that name exists, which the panel did not ask about, Save, Save As, Save a Copy, Save as Template, the exports and every other write through a save panel ask before replacing it (dialog `replaceFile`; Cancel writes nothing).
A name returned by the desktop save panel with the extension of a format the save does not write has it replaced with the saved format's: Save As of a new document saves `card.ait` and `card.png` as `card.vectorcraft`, and Save as Template saves `card.pdf` as `card.vctemplate`. An extension that names no format stays part of the name (`notes.v2` is saved as `notes.v2.vectorcraft`).
Export As, Save as PDF, Save for Office Documents, Save for Web, Text Export Options, print to file, the presets' Export… and the library, swatch and Document Info saves replace a typed extension of another format than the one they write in the same way: Export As PNG writes `card.svg` as `card.png`, Save as PDF writes `card.png` as `card.pdf`, and `document.exportSelection` with a `format` suggests and adds that format's extension. An extension of the written format stays as typed in any letter case (`photo.JPEG` for JPEG), Export As SVG writes `art.svgz` compressed, and `document.exportSelection` without a `format` writes the format the typed extension names (`tests/saveext.rs`).
@@ -1699,6 +1709,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
In a `.ai` or EPS opened from its editing data, and in a legacy `.ai`, each anchor is smooth or a corner as the case of the segment operator ending on it says (`l`, `c`, `v`, `y` smooth; `L`, `C`, `V`, `Y` corner), and a closed path's first anchor takes the case of the segment back to it; points and handles stay where they are. Paths, compound paths, clipping paths, guide paths, area type frames and type-on-a-path paths all keep it (`crates/eps/src/import/ai/tests.rs`, `crates/eps/src/legacy_ai/tests.rs`).
In `.ai` and EPS editing-data import, a clipping group, layer or opacity mask's art with several clipping paths (`W`) clips to their intersection: each clipping path after the first clips a clipping group nested inside the one before it, and each keeps its own stroke and transparency. A compound path of clipping paths (`*u` … `*U`) is still one clipping path, and clipping paths that would nest the art past the document's 32 levels stop the editing data from being read (`crates/eps/src/import/ai/tests.rs`).
`.ai` and EPS editing-data import keeps template layers, and opens canvas and artboard ruler guides as ruler guides on their layers; guide objects stay editable guide paths (#779; `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`).
+ A hidden (`1 Xw`) ruler guide path in `.ai` and EPS editing data opens as a hidden ruler guide rather than a guide path, and every ruler guide keeps its path's hide, lock and name, so its Layers row shows them (`hidden_locked_and_named_guide_paths_are_ruler_guides`).
`.ai` and EPS editing-data import keeps each path's Attributes panel note (`%AI3_Note:`, `%%Note:`, more lines in `%AI5_NoteMore:`, up to 1,024 characters) and Show Center (`1 Ap`); a note or Show Center applies to every path after it until the next one, and a note before a group or compound path is not theirs (`crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_illustrator.rs`).
`.ai` and EPS editing-data import reads object tags (`/identifier (string) XT`) on the object before them, whatever kind of object it is: `AdobeURL` becomes the object's URL, and every other tag is kept in the object's data in order, up to 64 per object; a tag longer than 64 KiB, and tags in a pattern's or brush's art, are ignored. A tag on art that shows no longer stops the editing data from being read, and brush art that holds tags is read (`crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_illustrator.rs`).
A `.ai` saved without PDF compatibility (its PDF part only a placeholder page) is placed and pasted from its editing data, as it opens: File › Place places the art of the layers that show on the first artboard (`file.place`'s `page` places another one), and Paste of it as PDF data (`clipboard.importPdf`) takes every object. `document.open` with `pages` naming every page of such a file opens it the same way (`crates/engine/src/cmd/fileio/tests_aiimport.rs`).
diff --git a/docs/ui-parity.md b/docs/ui-parity.md
index 62a99726a2..6bffa0595b 100644
--- a/docs/ui-parity.md
+++ b/docs/ui-parity.md
@@ -408,11 +408,12 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po
| Toolbar without group labels by default, one or two columns; All Tools drawer by flyout group with shortcuts, its menu (Reset, Basic, Advanced) and Show section | done | #1751; a preference brings the labels back; `toolbar.show` sets the Fill/Stroke, Coloring, Draw Mode and Screen Mode controls |
| Actions panel: ≡ › Save Actions… saves the current set with all its actions to a file and ≡ › Load Actions… adds a saved set ([Manage a set of actions](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/manage-a-set-of-actions.html)) | partial | `ui.actionSet.save`, `ui.actionSet.load`; `panels/actions.rs` tests. The file is VectorCraft's own `.vcactions` JSON; Reset, Clear and Replace Actions remain |
| Actions panel: ≡ › Load Actions… also reads action sets saved as `.aia` files ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html)) | partial | `actionSet.read`, `ui.actionSet.load`; `tests/actionset.rs`, `panels/actions.rs` tests, `import_fuzz`. Only steps with a label and no recorded values come in, as menu commands that run with no values and open no dialog, matched by label; the other steps, recorded menu items included, are left out and counted |
-| Actions panel: ≡ › Action Options… renames the selected action and sets its function key and color ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html)) | partial | `ui.action.options`; `panels/actions.rs` tests (part of #1629). The color shows nowhere until Button Mode exists |
+| Actions panel: ≡ › Action Options… renames the selected action and sets its function key and color ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html)) | partial | `ui.action.options`; `panels/actions.rs` tests (part of #1629). Button Mode shows the color |
+| Actions panel: ≡ › Button Mode shows each action by name as a button in its Action Options color, without sets or steps; a click on a button plays the action, and choosing Button Mode again shows the list ([the help page](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html)) | done | `ui.action.buttonMode`, `ui.action.playAt`; `panels/actions.rs` tests. VectorCraft's own rules: every set's actions show in set order, an action without a color is an ordinary button, the mode is kept with the UI preferences, and it cannot be turned on while an action records |
| Actions panel: ≡ › Playback Options… plays actions Accelerated, Step by Step or with a pause between steps ([Specify playback speed](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/specify-playback-speed.html)) | done | `ui.action.playbackOptions`; `panels/actions.rs` tests. The pause is a whole number of seconds from 1 to 60 (VectorCraft's own range); Batch… plays at full speed |
| Actions panel: a clicked action stays selected for Play Current Selection and Delete Selection | partial | #1853 (part of #1629); editing actions and action shortcuts remain |
| Actions panel: Create New Set (bottom bar, ≡ › New Set…) names a new set and Set Options… renames the current one; a click on a set's name makes it current and a recording goes into the current set ([helpx: Automation with actions](https://helpx.adobe.com/illustrator/using/automation-actions.html)); the current set is kept with the UI preferences, and with no set current a recording goes into the first set, which becomes current | partial | #3712; dragging actions between sets remains |
-| Actions panel: Create New Action (bottom bar, ≡ › New Action…) asks for the name, set, function key and color, and Record adds the action and records into it ([Create new actions](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/record-actions.html)) | partial | `ui.action.new`, `ui.action.play`; `panels/actions.rs` tests. A function key plays its action, even when a command has the same shortcut, and the command keeps it. Button Mode, which shows the color, remains |
+| Actions panel: Create New Action (bottom bar, ≡ › New Action…) asks for the name, set, function key and color, and Record adds the action and records into it ([Create new actions](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/record-actions.html)) | partial | `ui.action.new`, `ui.action.play`; `panels/actions.rs` tests. A function key plays its action, even when a command has the same shortcut, and the command keeps it. Button Mode shows the color |
| Actions panel: Delete Selection (bottom bar) deletes the selected set with its actions and keeps the other sets ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html)) | partial | `ui.actionSet.delete`; `panels/actions.rs` tests. VectorCraft's own rules, which the overview does not cover: deleting a set asks first (a selected action is still deleted at once), Shift-click on a set's name selects a range of sets, ≡ › Delete does the same as the button, and Default Actions and the last set can be deleted. Undo does not restore a deleted set, and recorded commands cannot be selected or deleted |
| Actions panel: ≡ › Duplicate copies the selected action or set ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html)) | partial | `ui.actionSet.duplicate`; `panels/actions.rs` tests. VectorCraft's own rules, which the overview does not cover: the copy goes right after the original, is named after it with " copy" added and is selected, and a copied set holds copies of all its actions. Undo does not remove the copy, and recorded commands cannot be duplicated |
| Properties panel per context (every selection type's Quick Actions) | partial | one clip group: Edit Clipping Path and Edit Contents, at the top of the panel rather than in Quick Actions (G1); the overview's additional controls ([Properties panel overview](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/properties-panel-overview.html)): Blend Options (one blend selected, with any tool, #4817), Repeat Options (one radial, grid or mirror repeat selected: Object › Repeat › Options, #5181; the overview does not list it) and Merge Live Paint (a Live Paint group with more) in Quick Actions, the Width Profile list (Width tool or a variable-width stroke) and an applied effect's Delete icon in Appearance, Corner and Corner Radius for corners selected with Direct Selection; Variable Font was already there; Edit Artboards: a Transform section (reference point, X, Y, W, H, preset, orientation) above the Artboards section (#5219) |