Merge remote-tracking branch 'upstream/main' into fix/pdf-empty-actualtext-art-6971

This commit is contained in:
Don Omondi
2026-11-12 21:44:46 -05:00
66 changed files with 2266 additions and 117 deletions
+7 -2
View File
@@ -250,7 +250,8 @@ impl ParamSchema {
/// What is wrong with the schema itself, or `None`: a parameter name that is empty, repeated
/// or holds `.`, `[` or `]`; a required parameter with a default; a default that isn't JSON or
/// doesn't pass its parameter's type; an enum with no names or a repeated name; a range with
/// an end that isn't finite or that no value can be in; nesting past [`MAX_DEPTH`].
/// an end that isn't finite or that no value can be in; nesting past [`MAX_DEPTH`], or a
/// [`Type::Ids`] or [`Type::Point`], whose values are all lists, at the nesting limit.
pub fn definition_problem(&self) -> Option<String> {
object_problem("", self.params, 0)
}
@@ -319,7 +320,9 @@ pub enum Problem {
Missing,
/// The value is not of the parameter's type: `expected` says what it must be.
WrongType { expected: String, got: String },
/// The schema nests deeper than [`MAX_DEPTH`] here ([`ParamSchema::definition_problem`]).
/// The value nests deeper than [`MAX_DEPTH`] here: a list given for a [`Type::Color`] at the
/// nesting limit, or any value under a schema that nests too deeply itself
/// ([`ParamSchema::definition_problem`]).
TooDeep,
}
@@ -779,6 +782,8 @@ fn type_problem(at: &str, ty: &Type, depth: usize) -> Option<String> {
Type::Object(params) => object_problem(at, params, depth + 1),
Type::Array(_) if depth >= MAX_DEPTH => Some(format!("`{at}` nests deeper than {MAX_DEPTH} levels")),
Type::Array(item) => type_problem(&format!("{at}[]"), item, depth + 1),
// Every value of these is a list, and a list at the nesting limit is refused.
Type::Ids | Type::Point if depth >= MAX_DEPTH => Some(format!("`{at}` nests deeper than {MAX_DEPTH} levels")),
_ => None,
}
}
+30
View File
@@ -156,6 +156,36 @@ fn id_lists_points_and_color_lists_count_toward_the_nesting_limit() {
assert!(check(MAX_DEPTH, &Type::Color, &json!("#ff0000")).is_ok());
}
/// `ty` inside `levels` lists.
fn in_lists(levels: usize, ty: Type) -> Type {
(0..levels).fold(ty, |inner, _| Type::Array(Box::leak(Box::new(inner))))
}
/// `v` inside `levels` lists.
fn wrapped(levels: usize, v: Value) -> Value {
(0..levels).fold(v, |inner, _| Value::Array(vec![inner]))
}
/// The schema check and the value check agree on the fixed-shape lists at the nesting limit: an id
/// list or a point there could take no value, so the schema is wrong in itself, and a color there
/// takes a string, while a color list is refused as nested too deeply, at its path.
#[test]
fn fixed_shape_lists_at_the_nesting_limit_follow_the_schema_check() {
for ty in [Type::Ids, Type::Point, Type::Color] {
assert_eq!(one(in_lists(MAX_DEPTH - 1, ty)).definition_problem(), None, "{ty:?}");
}
for ty in [Type::Ids, Type::Point] {
let problem = one(in_lists(MAX_DEPTH, ty)).definition_problem();
assert!(problem.as_deref().is_some_and(|p| p.contains("nests deeper")), "{ty:?}: {problem:?}");
}
let color = one(in_lists(MAX_DEPTH, Type::Color));
assert_eq!(color.definition_problem(), None);
assert_eq!(color.validate(&json!({"x": wrapped(MAX_DEPTH, json!("#ff0000"))})).unwrap(), None);
let e = color.validate(&json!({"x": wrapped(MAX_DEPTH, json!([1, 0, 0]))})).unwrap_err();
assert_eq!(e.problem, Problem::TooDeep);
assert_eq!(e.path, format!("x{}", "[0]".repeat(MAX_DEPTH)));
}
#[test]
fn a_saved_retired_key_past_the_nesting_limit_is_refused() {
let params = holding("older", nested_lists(500_000));
+38 -2
View File
@@ -104,6 +104,15 @@ fn path_kind(d: &mut Document, path: PathData, rule: FillRule) -> NodeKind {
}
}
thread_local! {
/// How many symbol instances deep [`bake_node`] is baking, through the art of instances whose
/// effects reshape it.
static INSTANCE_DEPTH: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
/// The objects of symbol art that [`bake_document`] has baked for instances inside the art of
/// other instances, counted against [`crate::SYMBOL_ART_MAX_NODES`].
static NESTED_NODES: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
}
/// `n` with its geometry effects evaluated (`None` = unchanged).
fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
// A cancelled export leaves the rest of the document as it is: the caller drops the result.
@@ -143,8 +152,27 @@ fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
}
_ => None,
};
// Instances baked inside the art of others stop at the depth limit, or once their art
// passes the objects one export bakes that way: an instance of a symbol whose art holds
// instances of itself is kept as it is there.
let instance = matches!(n.kind, NodeKind::SymbolInstance { .. });
if instance && INSTANCE_DEPTH.with(|c| c.get()) > 0 {
let count = symbol.as_ref().map_or(0, |a| a.count() as u64);
let spent = NESTED_NODES.with(|c| c.get()).saturating_add(count);
if INSTANCE_DEPTH.with(|c| c.get()) >= vectorcraft_doc::depth::MAX_DEPTH || spent > crate::SYMBOL_ART_MAX_NODES {
return None;
}
NESTED_NODES.with(|c| c.set(spent));
}
let m = reshape(Some(d), n, symbol.as_ref())?;
return Some(bake_node(d, &m).unwrap_or(m));
if instance {
INSTANCE_DEPTH.with(|c| c.set(c.get() + 1));
}
let baked = bake_node(d, &m);
if instance {
INSTANCE_DEPTH.with(|c| c.set(c.get() - 1));
}
return Some(baked.unwrap_or(m));
}
// A path whose Transform effect copies it with a gradient, or scales its strokes or moves its
// patterns: each copy an object of its own, so its gradient fits it rather than spanning
@@ -531,7 +559,9 @@ fn hoist_transparency(mut p: Node) -> Node {
}
/// [`expand_leaf`] every path and compound path in `n` (art made for Expand Appearance, such as a
/// group's own fills and strokes or outlined type), in place.
/// group's own fills and strokes or outlined type), in place. A group or layer in it with an
/// appearance of its own ([`crate::has_container_appearance`]) is first made into the art that
/// appearance paints ([`crate::evaluate_container`]).
pub fn expand_art(d: &mut Document, n: &mut Node, stroke_art: StrokeArt) {
if matches!(n.kind, NodeKind::Path { clipping: false, guide: false, .. } | NodeKind::Compound { .. }) {
if let Some(m) = expand_leaf(d, n, stroke_art) {
@@ -539,6 +569,11 @@ pub fn expand_art(d: &mut Document, n: &mut Node, stroke_art: StrokeArt) {
}
return;
}
if crate::has_container_appearance(n)
&& let Some(m) = crate::evaluate_container(Some(&*d), n)
{
*n = m;
}
for c in n.children_mut().into_iter().flatten() {
expand_art(d, Arc::make_mut(c), stroke_art);
}
@@ -661,6 +696,7 @@ pub fn bake_document(doc: &Document) -> Option<Document> {
};
}
let mut d = placed.into_owned();
NESTED_NODES.with(|c| c.set(0));
let layers = d.layers.clone();
d.layers = layers.iter().map(|l| bake_node(&mut d, l).map(Arc::new).unwrap_or_else(|| l.clone())).collect();
Some(d)
+136 -6
View File
@@ -130,19 +130,142 @@ pub fn has_painted_effects(n: &Node) -> bool {
}
/// Is `n` type without inline graphics that has a fill or stroke of its own that paints and has a
/// visible geometry effect, or an image with a fill or stroke of its own that paints a shape
/// ([`image_rows`])? On type, such an effect reshapes the whole outline that its fill or stroke
/// paints ([`outline_text`], which leaves inline graphics out).
/// visible geometry effect, an image with a fill or stroke of its own that paints a shape
/// ([`image_rows`]), or a symbol instance whose active fill or stroke has a visible geometry effect
/// ([`active_fill`], [`active_stroke`])? On type, such an effect reshapes the whole outline that its
/// fill or stroke paints ([`outline_text`], which leaves inline graphics out). On a symbol
/// instance, it reshapes the fills or strokes of the art it places ([`add_instance_row_effects`]).
pub fn has_item_geometry(n: &Node) -> bool {
match &n.kind {
NodeKind::Text(t) => {
t.runs.iter().all(|r| r.inline.is_none()) && n.appearance.items.iter().any(|i| crate::paints(i) && crate::has_geometry(i.effects()))
}
NodeKind::Image(_) => n.appearance.items.iter().any(shapes_image),
NodeKind::SymbolInstance { .. } => {
active_fill(n).is_some_and(|f| crate::has_geometry(&f.effects)) || active_stroke(n).is_some_and(|s| crate::has_geometry(&s.effects))
}
_ => false,
}
}
/// The fill of symbol instance `n` whose effects its art takes: its topmost fill, the one that
/// stains the art, when it is visible and its opacity is above 0. A lower fill's effects do not
/// reach the art.
fn active_fill(n: &Node) -> Option<&vectorcraft_doc::FillLayer> {
n.appearance.fill().filter(|f| f.visible && f.opacity > 0.0)
}
/// The stroke of symbol instance `n` whose effects its art takes: its topmost stroke, when it is
/// visible and its opacity is above 0.
fn active_stroke(n: &Node) -> Option<&vectorcraft_doc::StrokeLayer> {
n.appearance.stroke().filter(|s| s.visible && s.opacity > 0.0)
}
/// The visible geometry effects in `fx`, in order.
fn geometry_of(fx: &[Effect]) -> Vec<Effect> {
fx.iter().filter(|e| e.visible && is_geometry(&e.id)).cloned().collect()
}
/// `art`, the art symbol instance `n` places, with the visible geometry effects of `n`'s active
/// fill added after the effects of every fill in it that paints, and those of its active stroke
/// after the effects of every stroke in it that paints ([`active_fill`], [`active_stroke`]). Each
/// fill or stroke keeps its paint, and the others keep their shapes. Live objects in it that are
/// reached are evaluated first (with the symbols of `doc`), so the effects reshape what they draw.
/// Clip groups' clipping paths, guides and the art of opacity masks keep their shapes. A symbol
/// instance in the art takes the effects on its own active fill or stroke, or on a new fill or
/// stroke without paint when it has none, and passes them on to its own art in turn; an instance
/// of a symbol whose art (in `doc`) holds an instance of `n`'s symbol, at any depth, keeps its
/// appearance, so the effects never pass round a symbol that holds itself. Without `doc`, no
/// instance in the art takes them.
fn add_instance_row_effects(doc: Option<&Document>, n: &Node, art: &mut Node) {
let NodeKind::SymbolInstance { symbol, .. } = &n.kind else { return };
let fill = active_fill(n).map_or(vec![], |f| geometry_of(&f.effects));
let stroke = active_stroke(n).map_or(vec![], |s| geometry_of(&s.effects));
if !fill.is_empty() || !stroke.is_empty() {
add_row_effects(doc, symbol, art, &fill, &stroke);
}
}
/// Does the art of the symbol named `from` in `doc` hold an instance of the symbol named `to`, at
/// any depth of the symbols placed in it? `seen` holds the symbols already looked into.
fn places_symbol(doc: &Document, from: &str, to: &str, seen: &mut HashSet<String>) -> bool {
if !seen.insert(from.to_string()) {
return false;
}
let Some(sym) = doc.symbols.iter().find(|s| s.name == from) else { return false };
let mut names = vec![];
sym.art.walk(&mut |c| {
if let NodeKind::SymbolInstance { symbol, .. } = &c.kind {
names.push(symbol.clone());
}
});
names.iter().any(|name| name == to || places_symbol(doc, name, to, seen))
}
/// [`add_instance_row_effects`] of `fill` and `stroke` to `n` and the art in it, the art of an
/// instance of the symbol named `outer`.
fn add_row_effects(doc: Option<&Document>, outer: &str, n: &mut Node, fill: &[Effect], stroke: &[Effect]) {
match &n.kind {
NodeKind::Path { clipping: true, .. } | NodeKind::Path { guide: true, .. } | NodeKind::RulerGuide { .. } => return,
NodeKind::SymbolInstance { symbol, .. } => {
if let Some(d) = doc
&& !places_symbol(d, symbol, outer, &mut HashSet::new())
{
add_to_instance(n, fill, stroke);
}
return;
}
_ if vectorcraft_doc::live::is_live(n) => *n = evaluated(doc, n),
_ => {}
}
let takes = |i: &AppearanceItem| {
crate::paints(i) && !matches!(i, AppearanceItem::Fill(_) if fill.is_empty()) && !matches!(i, AppearanceItem::Stroke(_) if stroke.is_empty())
};
if n.appearance.items.iter().any(takes) {
for item in n.appearance.items.iter_mut().filter(|i| takes(i)) {
let fx = if matches!(item, AppearanceItem::Fill(_)) { fill } else { stroke };
item.effects_mut().extend_from_slice(fx);
}
}
// A compound path paints its members with its own fills and strokes.
if matches!(n.kind, NodeKind::Compound { .. }) {
return;
}
// A clip group's first member is its clipping path, whatever its kind.
let clips = n.clips();
for (i, c) in n.children_mut().into_iter().flatten().enumerate() {
if !(clips && i == 0) {
add_row_effects(doc, outer, Arc::make_mut(c), fill, stroke);
}
}
}
/// Add `fill` to the effects of symbol instance `n`'s active fill and `stroke` to those of its
/// active stroke ([`active_fill`], [`active_stroke`]); without one, to a new topmost fill or stroke
/// without paint, which neither stains nor strokes its art.
fn add_to_instance(n: &mut Node, fill: &[Effect], stroke: &[Effect]) {
if !fill.is_empty() {
match n.appearance.fill_mut().filter(|f| f.visible && f.opacity > 0.0) {
Some(f) => f.effects.extend_from_slice(fill),
None => {
let mut f = vectorcraft_doc::FillLayer::new(vectorcraft_doc::color::Paint::None);
f.effects = fill.to_vec();
n.appearance.items.push(AppearanceItem::Fill(f));
}
}
}
if !stroke.is_empty() {
match n.appearance.stroke_mut().filter(|s| s.visible && s.opacity > 0.0) {
Some(s) => s.effects.extend_from_slice(stroke),
None => {
let mut s = vectorcraft_doc::StrokeLayer::new(vectorcraft_doc::color::Paint::None, 0.0);
s.effects = stroke.to_vec();
n.appearance.items.push(AppearanceItem::Stroke(s));
}
}
}
}
/// Does effect `e` give an image's fill or stroke a shape to paint (a visible Convert to Shape or
/// Outline Object)?
fn gives_shape(e: &Effect) -> bool {
@@ -302,7 +425,9 @@ fn with_id(art: &Node, id: NodeId) -> Node {
/// `n` as plain art that geometry effects can reshape, without its transparency and object
/// effects: type outlined ([`outline_text`]), live objects evaluated (type inside them outlined),
/// a symbol instance as `symbol_art` (the symbol's art, stained as the caller draws it) placed by
/// the instance's transform (through the symbol's 9-slice guides in `doc`, when it has them).
/// the instance's transform (through the symbol's 9-slice guides in `doc`, when it has them), its
/// fills and strokes carrying the geometry effects of the instance's active fill and stroke
/// ([`add_instance_row_effects`]).
/// `None` for other kinds (images, paths, containers), and for an instance whose art placed
/// through the guides could hold more than [`vectorcraft_doc::MAX_SYMBOL_NODES`] objects. A Repeat counts the Repeats in the art of
/// `doc`'s symbols that its instances show toward its instance budget.
@@ -351,6 +476,9 @@ fn outline_art_with(
_ => return None,
};
outline_nested_text(paint_doc, &mut art, false);
if matches!(n.kind, NodeKind::SymbolInstance { .. }) {
add_instance_row_effects(symbols, n, &mut art);
}
art.opacity = 1.0;
art.blend = Default::default();
art.isolate = false;
@@ -497,8 +625,10 @@ pub fn reshape(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>) -> O
/// `n` (see [`needs_outline`], or a path with [`has_transform_art`]) as plain art for Expand
/// Appearance: [`reshape`]d when it has
/// geometry effects, else type with fills or strokes of its own outlined, or an image with fills
/// or strokes of its own that paint a shape, in the same form. `None` when it has none of these.
/// geometry effects, else type with fills or strokes of its own outlined, an image with fills or
/// strokes of its own that paint a shape, or a symbol instance's art reshaped by the geometry
/// effects of its active fill or stroke ([`has_item_geometry`]), in the same form. `None` when it
/// has none of these.
pub fn expand_outlined(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
// A path whose Transform effect copies it, or scales its strokes or moves its patterns: each
// copy an object of its own with the path's fills and strokes, the copies of each Transform
+76 -13
View File
@@ -8,7 +8,7 @@ use std::borrow::Cow;
use std::sync::Arc;
use vectorcraft_doc::{Appearance, AppearanceItem, Document, FillLayer, Node, NodeKind};
use vectorcraft_geom::{FillRule, PathData, Rect};
use vectorcraft_geom::{FillRule, PathData, Point, Rect, SubPath};
use vectorcraft_pathops as po;
use super::{MAX_MEMBERS, Scope, geometry, is_container, members, profile, region, reshape_members, solid_paints};
@@ -24,6 +24,19 @@ const MAX_DEPTH: usize = 64;
/// many steps.
pub(super) const MAX_WORK: usize = 1 << 18;
/// The most times resolving one 3D effect's art may cut an image's frame to a clipping path to
/// learn whether the clip shows any of it ([`Resolver::hidden`]).
pub(super) const MAX_IMAGE_CUTS: usize = 1024;
/// The area, in square points, under which what a clip shows of an image's frame counts as nothing:
/// the sliver left where the frame only touches the clipping path.
const SHOWN_AREA: f64 = 1e-9;
/// The precision an image's frame is cut to a clipping path with: the cut drops pieces narrower
/// than this, so it is far below [`po::DEFAULT_PRECISION`], and a strip of the image much
/// narrower than a hundredth of a point still counts as shown.
const CUT_PRECISION: f64 = 1e-6;
/// The error for an image a 3D effect is asked to build from.
pub(crate) fn image_refusal(label: &str) -> String {
format!("{label} can't build a solid from an image: trace it first with Object › Image Trace › Make.")
@@ -66,7 +79,7 @@ pub(crate) fn resolve_for<'a>(doc: Option<&Document>, n: &'a Node, label: &str,
if !needs(n) {
return Ok(Cow::Borrowed(n));
}
let mut r = Resolver { doc, label, every_fill, leaves: 0, work: 0, shown: None };
let mut r = Resolver { doc, label, every_fill, leaves: 0, work: 0, cuts: 0, shown: None };
let children = r.content(n, 0);
#[cfg(test)]
WORK.with(|w| w.set(r.work));
@@ -90,8 +103,18 @@ struct Resolver<'a> {
leaves: usize,
/// The objects visited or made so far, counted against [`MAX_WORK`].
work: usize,
/// Inside clipping groups, the box of what their clipping paths show together.
shown: Option<Rect>,
/// The image frames cut to clipping paths so far, counted against [`MAX_IMAGE_CUTS`].
cuts: usize,
/// Inside clipping groups, what their clipping paths show together.
shown: Option<Shown>,
}
/// The clipping paths around the art being listed, outermost first, with the box of what they
/// show together.
#[derive(Clone)]
struct Shown {
bounds: Rect,
clips: Vec<Arc<(PathData, FillRule)>>,
}
impl Resolver<'_> {
@@ -111,15 +134,52 @@ impl Resolver<'_> {
/// `f` run on the art of `c`. The effects of an object or group can move its art into a clip,
/// so inside one with effects no image is left out for lying outside the clip ([`Self::shown`]).
fn inside<T>(&mut self, c: &Node, f: impl FnOnce(&mut Self) -> Result<T, String>) -> Result<T, String> {
let outer = self.shown;
if has_effects(c) {
self.shown = None;
if !has_effects(c) {
return f(self);
}
let outer = self.shown.take();
let out = f(self);
self.shown = outer;
out
}
/// Does the plain image `c` lie wholly outside what the clips around it show together, each
/// clipping path with its own fill rule? Its frame, the pixel rectangle as its transform places
/// it, is cut to each clip in turn until nothing is left. An error when a cut fails or the cuts
/// pass [`MAX_IMAGE_CUTS`]: the image may show, so it can't be left out.
fn hidden(&mut self, c: &Node) -> Result<bool, String> {
let Some(shown) = &self.shown else { return Ok(false) };
let NodeKind::Image(im) = &c.kind else { return Ok(false) };
let Some(b) = c.visual_bounds() else { return Ok(true) };
if b.intersect(shown.bounds).area() <= 0.0 {
return Ok(true);
}
let (w, h) = (f64::from(im.width), f64::from(im.height));
let corners = [(0.0, 0.0), (w, 0.0), (w, h), (0.0, h)].map(|(x, y)| im.xf * Point::new(x, y));
if corners.iter().any(|p| !p.x.is_finite() || !p.y.is_finite()) {
return Err(image_refusal(self.label));
}
let clips = shown.clips.clone();
let mut left = PathData::single(SubPath::polyline(&corners, true));
for clip in clips {
let Some(here) = clip.0.bounds() else { return Ok(true) };
if left.bounds().is_none_or(|l| l.intersect(here).area() <= 0.0) {
return Ok(true);
}
self.cuts += 1;
if self.cuts > MAX_IMAGE_CUTS {
return Err(self.too_much_work());
}
self.spend(1)?;
left =
po::try_boolean(&left, FillRule::NonZero, &clip.0, clip.1, po::BoolOp::Intersect, CUT_PRECISION).map_err(|_| self.too_complex())?;
if left.subpaths.iter().all(|s| s.area().abs() <= SHOWN_AREA) {
return Ok(true);
}
}
Ok(false)
}
/// The art `n`, a symbol instance, a blend, a clipping group or layer, or a group holding one,
/// shows, as the members of a plain group.
fn content(&mut self, n: &Node, depth: usize) -> Result<Vec<Arc<Node>>, String> {
@@ -162,11 +222,15 @@ impl Resolver<'_> {
if clip_nested_past(first, MAX_DEPTH.saturating_sub(depth)) {
return Err(self.too_deep());
}
let clip = clip_area(first);
// An image wholly outside every clip around it shows nothing and is left out.
let outer = self.shown;
let clip = Arc::new(clip_area(first));
// An image wholly outside what the clips around it show together draws nothing and
// is left out.
let here = clip.0.bounds().unwrap_or(Rect::ZERO);
self.shown = Some(outer.map_or(here, |o| o.intersect(here)));
let outer = self.shown.take();
let mut inner_shown = outer.clone().unwrap_or(Shown { bounds: here, clips: vec![] });
inner_shown.bounds = inner_shown.bounds.intersect(here);
inner_shown.clips.push(clip.clone());
self.shown = Some(inner_shown);
let listed = self.list(rest, depth + 1);
self.shown = outer;
let (inner, _) = listed?;
@@ -198,8 +262,7 @@ impl Resolver<'_> {
// Only an image with no effects or fills and strokes of its own is measured by its
// frame; any other one may draw inside the clip.
let plain = !has_effects(c) && c.appearance.items.iter().all(|i| !i.visible());
let hidden = plain && self.shown.is_some_and(|k| c.visual_bounds().is_none_or(|b| b.intersect(k).area() <= 0.0));
if hidden {
if plain && self.hidden(c)? {
changed = true;
continue;
}
@@ -317,6 +317,86 @@ fn images_are_refused_alone_and_in_a_group_with_image_trace_named() {
}
}
/// A clipping group: `art` inside a clipping path of `path` filled under `rule`.
fn shaped_clip_group(id: u64, path: PathData, rule: FillRule, art: Vec<Node>) -> Node {
let mut clipping = Node::path(NodeId(id + 1), path, Appearance::default());
if let NodeKind::Path { clipping: c, rule: r, .. } = &mut clipping.kind {
*c = true;
*r = rule;
}
let children = std::iter::once(clipping).chain(art).map(Arc::new).collect();
Node::new(NodeId(id), NodeKind::Group { children, clip: true })
}
/// An image `width` by `height` pixels placed by `xf`.
fn placed(id: u64, width: u32, height: u32, xf: Affine) -> Node {
let mut img = image(id);
if let NodeKind::Image(i) = &mut img.kind {
i.width = width;
i.height = height;
i.xf = xf;
}
img
}
#[test]
fn an_image_inside_a_clips_box_but_outside_its_shape_is_left_out() {
let d = document();
let circle = || shapes::ellipse(Rect::new(100.0, 100.0, 200.0, 200.0));
let square = || shapes::rectangle(Rect::new(100.0, 100.0, 150.0, 150.0));
let at = |x: f64, y: f64| Affine::translate((x, y));
// Two circles inside each other's clips: they show together only the lens between them.
let lens = |art: Vec<Node>| {
let inner = shaped_clip_group(70, shapes::ellipse(Rect::new(120.0, 100.0, 220.0, 200.0)), FillRule::NonZero, art);
shaped_clip_group(80, shapes::ellipse(Rect::new(80.0, 100.0, 180.0, 200.0)), FillRule::NonZero, vec![inner])
};
let mut ring = shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0));
ring.subpaths.extend(shapes::rectangle(Rect::new(130.0, 130.0, 170.0, 170.0)).subpaths);
// Turned 45° about its center at (156, 156): its box reaches into the square's corner, and its
// frame does not.
let turned = at(156.0, 156.0) * Affine::rotate(std::f64::consts::FRAC_PI_4) * at(-5.0, -5.0);
// A shown rectangle and an image `w` by `h` at `xf` inside the clipping path `path`.
let clipped = |path: PathData, rule: FillRule, shown: Rect, (w, h, xf): (u32, u32, Affine)| {
shaped_clip_group(60, path, rule, vec![rect(62, shown.x0, shown.y0, shown.x1, shown.y1, 0.0), placed(63, w, h, xf)])
};
let middle = Rect::new(140.0, 140.0, 160.0, 160.0);
let band = Rect::new(100.0, 100.0, 110.0, 200.0);
let left_out = [
// In the corner of the circle's box, outside the circle.
("a circle's corner", clipped(circle(), FillRule::NonZero, middle, (10, 10, at(100.0, 100.0)))),
("a turned frame", clipped(square(), FillRule::NonZero, Rect::new(110.0, 110.0, 140.0, 140.0), (10, 10, turned))),
// Above the lens: inside each circle, outside both at once.
("two clips' shared area", lens(vec![rect(62, 145.0, 140.0, 155.0, 160.0, 0.0), placed(63, 8, 3, at(146.0, 100.0))])),
("an even-odd hole", clipped(ring.clone(), FillRule::EvenOdd, band, (10, 10, at(140.0, 140.0)))),
];
let shown = [
("part of the circle", clipped(circle(), FillRule::NonZero, middle, (10, 10, at(145.0, 145.0)))),
("a non-zero hole", clipped(ring, FillRule::NonZero, band, (10, 10, at(140.0, 140.0)))),
// A strip 0.005 pt wide inside the square still shows.
("a narrow strip", clipped(square(), FillRule::NonZero, Rect::new(110.0, 110.0, 140.0, 140.0), (10, 10, at(149.995, 120.0)))),
("the lens", lens(vec![rect(62, 145.0, 140.0, 155.0, 160.0, 0.0), placed(63, 8, 3, at(146.0, 108.0))])),
];
for id in TYPES {
for (what, g) in &left_out {
let n = with(g.clone(), id, json!({}));
assert_eq!(validate_three_d_in(Some(&d), &n, id, &json!({})), Ok(()), "{id}: {what}");
assert!(art(&d, &n, id).is_some(), "{id}: {what} draws");
assert!(three_d_meshes_in(Some(&d), &n).is_ok_and(|m| !m.is_empty()), "{id}: {what} exports");
}
for (what, g) in &shown {
let n = with(g.clone(), id, json!({}));
assert!(validate_three_d_in(Some(&d), &n, id, &json!({})).is_err_and(|e| e.contains("Image Trace")), "{id}: {what}");
assert!(art(&d, &n, id).is_none(), "{id}: {what}");
}
}
// Each frame cut to a clip counts: past the limit the art is refused, never left out unchecked.
let many: Vec<Node> = std::iter::once(rect(62, 140.0, 140.0, 160.0, 160.0, 0.0))
.chain((0..=source::MAX_IMAGE_CUTS as u64).map(|i| placed(100 + i, 10, 10, at(100.0, 100.0))))
.collect();
let n = with(shaped_clip_group(60, circle(), FillRule::NonZero, many), ROTATE, front());
assert!(validate_three_d_in(Some(&d), &n, ROTATE, &json!({})).is_err_and(|e| e.contains("too many objects")));
}
/// Symbols `L0` to `L{levels}`: `L0` holds `leaf`, each later one `fan` instances of the one
/// before it.
fn nested_symbols(levels: usize, fan: usize, leaf: Node) -> Document {
+1 -1
View File
@@ -23,7 +23,7 @@ pub fn specs() -> Vec<CommandSpec> {
"New…",
["File"],
None,
"{preset?: a name from file.newPresets (start from it; the other params change it), name?|title?: document name (default Untitled-N), width?: pt=612|\"210 mm\", height?: pt=792 (each more than 0 and at most 3,999,856 pt, as artboard.new takes them), units?: \"Pixels\"|\"Points\"|\"Picas\"|\"Inches\"|\"Millimeters\"|\"Centimeters\"|\"Feet\"|\"Yards\"|\"Meters\"|\"Feet & Inches\" (default: prefs unitsGeneral, as print presets; screen presets: Pixels), orientation?: \"portrait\"|\"landscape\" (swaps width and height to match), artboards?: n (1–1000), artboardLayout?: {layout?: \"gridByRow\"|\"gridByColumn\"|\"row\"|\"column\", columns?: n (default: all in one row), spacing?: pt=20, rightToLeft?: bool}, bleed?: pt|[top, bottom, left, right]|{top?, …} (0–72 pt), backgroundContents?: \"transparent\"|\"white\" (a white artboard background, not an object), colorMode?: \"rgb\"|\"cmyk\" (a CMYK document starts with CMYK swatches and stores the colours applied to it as CMYK), rasterEffectsPpi?: 72|150|300 (1–2400), previewMode?: \"default\"|\"pixel\"|\"overprint\" (overprint turns Overprint Preview on, pixel Pixel Preview in the app; default leaves both), alignNewObjectsToPixelGrid?: bool (Advanced Options › Align New Objects to Pixel Grid: paths drawn in the document are pixel-aligned; default on for the Mobile and Web presets, else off; document.setup changes it later), profile?: a category of file.newPresets|null (the New Document profile the document starts from, see file.newProfiles; default: a built-in preset's category, kept by the saved and Recent presets made from it; null or no preset: the defaults), created?: Unix seconds|null (File Info's created date; default now, recorded in the journal so a replay matches), size?: a preset's size label (a preset file.newPresets lists can be passed back; ignored)} → {index, previewMode, profile?: the profile applied, profileError?: {profile, reason} (its file could not be read, was larger than 64 MiB or is not a native document, or the request may not read the profiles folder: the document has the defaults)}; the size is remembered in file.newPresets' Recent",
"{preset?: a name from file.newPresets (start from it; the other params change it), name?|title?: document name (default Untitled-N), width?: pt=612|\"210 mm\", height?: pt=792 (each more than 0 and at most 3,999,856 pt, as artboard.new takes them), units?: \"Pixels\"|\"Points\"|\"Picas\"|\"Inches\"|\"Millimeters\"|\"Centimeters\"|\"Feet\"|\"Yards\"|\"Meters\"|\"Feet & Inches\" (default: prefs unitsGeneral, as print presets; screen presets: Pixels), orientation?: \"portrait\"|\"landscape\" (swaps width and height to match), artboards?: n (1–1000), artboardLayout?: {layout?: \"gridByRow\"|\"gridByColumn\"|\"row\"|\"column\", columns?: n (default: all in one row), spacing?: pt=20, rightToLeft?: bool}, bleed?: pt|[top, bottom, left, right]|{top?, …} (0–72 pt), backgroundContents?: \"transparent\"|\"white\" (a white artboard background, not an object), colorMode?: \"rgb\"|\"cmyk\" (a CMYK document starts with CMYK swatches and stores the colours applied to it as CMYK), rasterEffectsPpi?: 72|150|300 (1–2400), previewMode?: \"default\"|\"pixel\"|\"overprint\" (overprint turns Overprint Preview on, pixel Pixel Preview in the app; default leaves both), alignNewObjectsToPixelGrid?: bool (Advanced Options › Align New Objects to Pixel Grid: paths drawn in the document are pixel-aligned; default on for the Mobile and Web presets, else off; document.setup changes it later), profile?: a category of file.newPresets|null (the New Document profile the document starts from, see file.newProfiles; default: a built-in preset's category, kept by the saved and Recent presets made from it; null or no preset: the defaults), created?: Unix seconds|null (File Info's created date; default now, recorded in the journal so a replay matches), size?: a preset's size label (a preset file.newPresets lists can be passed back; ignored)} → {index, previewMode, profile?: the profile applied, warnings?: [text] (a color profile the profile's file carries that can't be used), profileError?: {profile, reason} (its file could not be read, was larger than 64 MiB or is not a native document, or the request may not read the profiles folder, a reason that leaves out the folder's path: the document has the defaults)}; the size is remembered in file.newPresets' Recent",
always,
super::newdoc::file_new
),
+39 -4
View File
@@ -94,7 +94,7 @@ fn effect_specs() -> Vec<CommandSpec> {
"Expand Appearance",
["Object"],
None,
"{ids?|id?: [..], target?} turn each object's appearance into objects: every fill and stroke becomes an object of its own (a stroke stays a stroke, with its weight, dashes, arrowheads and width profile, on its own copy of the path, which object.expand or object.path.outlineStroke outline; brushed strokes become their brush art) grouped in paint order under the object's id and transparency, geometry effects are baked, raster effects become an embedded image (shadows and outer glows below the art; blur, feather and inner glow replace it; an object whose only raster effect is its own Rasterize becomes the image object.rasterize makes with the effect's options), type with effects or fills of its own is outlined; Crop Marks become a group of the object and its marks; Vector Halftone (addon.color.halftone) becomes a group of its dot paths, one per ink; a group's or layer's own fills and strokes become objects among its members, which are expanded too. An outlined frame of threaded text leaves its thread with the text it shows, and a frame kept as type in a group stays in its thread. A dimension whose art it changes becomes a plain group of its art, except one selected with art it measures, directly or in groups without fills, strokes or effects of their own: its lines and label stay as they are, and if the new art no longer fits its link it stays where it was, no longer linked. Hidden fills, strokes and effects are dropped. In Safe Mode, an object with a visible plug-in effect (in it, or in a symbol it places) is an error and nothing changes. Enabled when a selected or targeted object's appearance isn't basic (`ids` then pick which objects) → {ids, warning? (an image in the art couldn't be decoded, as object.rasterize says)}",
"{ids?|id?: [..], target?} turn each object's appearance into objects: every fill and stroke becomes an object of its own (a stroke stays a stroke, with its weight, dashes, arrowheads and width profile, on its own copy of the path, which object.expand or object.path.outlineStroke outline; brushed strokes become their brush art) grouped in paint order under the object's id and transparency, geometry effects are baked, raster effects become an embedded image (shadows and outer glows below the art; blur, feather and inner glow replace it; an object whose only raster effect is its own Rasterize becomes the image object.rasterize makes with the effect's options), type with effects or fills of its own is outlined; Crop Marks become a group of the object and its marks; Vector Halftone (addon.color.halftone) becomes a group of its dot paths, one per ink; a group's or layer's own fills and strokes become objects among its members, which are expanded too; a symbol instance whose topmost fill or stroke is visible, has an opacity above 0 and has a geometry effect becomes a group of its art, that fill's effects reshaping the art's filled shapes and that stroke's effects reshaping its stroked shapes, as the canvas draws them. An outlined frame of threaded text leaves its thread with the text it shows, and a frame kept as type in a group stays in its thread. A dimension whose art it changes becomes a plain group of its art, except one selected with art it measures, directly or in groups without fills, strokes or effects of their own: its lines and label stay as they are, and if the new art no longer fits its link it stays where it was, no longer linked. Hidden fills, strokes and effects are dropped. In Safe Mode, an object with a visible plug-in effect (in it, or in a symbol it places) is an error and nothing changes. Enabled when a selected or targeted object's appearance isn't basic (`ids` then pick which objects) → {ids, warning? (an image in the art couldn't be decoded, as object.rasterize says)}",
can_expand,
expand_appearance
),
@@ -848,8 +848,9 @@ fn load_texture(params: &mut Value, cmd: &str) -> Result<()> {
/// Has `n` an appearance Expand Appearance turns into objects: a path that isn't basic (several
/// fills or strokes, effects, transparency of a fill or stroke, a brush), a group or layer with
/// fills, strokes or effects of its own or such members, other objects with effects, or type with
/// fills or strokes of its own?
/// fills, strokes or effects of its own or such members, other objects with effects, type with
/// fills or strokes of its own, or a symbol instance whose active fill or stroke has a geometry
/// effect ([`effects::has_item_geometry`])?
pub(crate) fn expandable(n: &Node) -> bool {
expandable_except(n, &[])
}
@@ -866,7 +867,7 @@ pub(crate) fn expandable_except(n: &Node, skip: &[NodeId]) -> bool {
!n.appearance.items.is_empty() || !n.appearance.effects.is_empty() || children.iter().any(|c| expandable_except(c, skip))
}
NodeKind::Text(_) => !n.appearance.items.is_empty() || !n.appearance.effects.is_empty(),
NodeKind::Image(_) => effects::has_item_geometry(n) || !n.appearance.effects.is_empty(),
NodeKind::Image(_) | NodeKind::SymbolInstance { .. } => effects::has_item_geometry(n) || !n.appearance.effects.is_empty(),
_ => !n.appearance.effects.is_empty(),
}
}
@@ -939,6 +940,38 @@ fn expand_pieces(
expanded
}
/// Replace each symbol instance in `m`, art an instance placed (`depth` instances deep), whose
/// active fill or stroke has a geometry effect ([`effects::has_item_geometry`]) by its art as
/// [`effects::expand_outlined`] makes it, and the instances in that art in turn, so
/// [`expand_pieces`] bakes their effects with the rest. An instance stays as it is when it is more
/// than [`vectorcraft_doc::depth::MAX_DEPTH`] instances deep, its symbol is missing, or its art
/// would take the objects placed so far past `budget`.
fn expand_nested_instances(d: &Document, m: &mut Node, depth: usize, budget: &mut u64) {
for c in m.children_mut().into_iter().flatten() {
if !matches!(c.kind, NodeKind::SymbolInstance { .. }) {
if c.children().is_some() {
expand_nested_instances(d, Arc::make_mut(c), depth, budget);
}
continue;
}
let NodeKind::SymbolInstance { symbol, .. } = &c.kind else { continue };
if depth >= vectorcraft_doc::depth::MAX_DEPTH || !effects::has_item_geometry(c) {
continue;
}
let Some(sym) = d.symbols.iter().find(|s| s.name == *symbol) else { continue };
let count = sym.art.count() as u64;
if count > *budget {
continue;
}
*budget -= count;
let art = vectorcraft_render::instance_art(&sym.art, c);
if let Some(mut a) = effects::expand_outlined(Some(d), c, Some(&art)) {
expand_nested_instances(d, &mut a, depth + 1, budget);
*c = Arc::new(a);
}
}
}
/// Does `n` hold an id of `seen`, or one id twice: does [`effects::fresh_ids`] give it new ids?
fn repeats_an_id(n: &Node, seen: &std::collections::HashSet<NodeId>) -> bool {
let mut ids = std::collections::HashSet::new();
@@ -1094,6 +1127,8 @@ fn expanded_node(d: &mut Document, mut n: Node, brushes: &[vectorcraft_brush::Br
_ => None,
};
effects::expand_outlined(Some(&*d), &v, symbol.as_ref()).map(|mut m| {
let mut budget = vectorcraft_doc::MAX_SYMBOL_NODES;
expand_nested_instances(d, &mut m, 1, &mut budget);
expand_pieces(d, &mut m, true, brushes, &mut stroke_art);
m
})
+17 -7
View File
@@ -115,7 +115,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Rasterize…",
["Object"],
None,
"{ppi?, background?: transparent|white|black|\"#rrggbb\" (#rrggbbaa and #rgba are refused), antiAlias?: none|art|type (text snapped to pixels)|bool, padding?|addAround?: pt (0–1000), colorModel?: \"rgb\"|\"cmyk\"|\"grayscale\"|\"bitmap\", clippingMask?: bool (a clip group with the art's outline)} replace the selection with an embedded image (PNG; in a CMYK document with its own colour model, a CMYK image of the art's inks, its transparency as its opacity mask); each defaults to document.rasterEffectsSettings; an image larger than 64 Mpx or 32,768 px a side is refused; a frame of threaded text that is rasterized leaves its thread with the text it shows → {id (the image, or its clip group), width, height, warning?: \"an image couldn't be decoded and is missing from the picture\" (as raster exports say)}",
"{ppi?, background?: transparent|white|black|\"#rrggbb\" (#rrggbbaa and #rgba are refused), antiAlias?: none|art|type (text snapped to pixels)|bool, padding?|addAround?: pt (0–1000), colorModel?: \"rgb\"|\"cmyk\"|\"grayscale\"|\"bitmap\", clippingMask?: bool (a clip group with the art's outline)} replace the selection with an embedded image (PNG; grayscale makes a gray PNG, and bitmap a 1-bit TIFF or, where it has transparency, a black-and-white gray PNG with alpha; in a CMYK document with its own colour model, a CMYK image of the art's inks, its transparency as its opacity mask); each defaults to document.rasterEffectsSettings; an image larger than 64 Mpx or 32,768 px a side is refused; a frame of threaded text that is rasterized leaves its thread with the text it shows → {id (the image, or its clip group), width, height, warning?: \"an image couldn't be decoded and is missing from the picture\" (as raster exports say)}",
has_selection,
rasterize
),
@@ -729,7 +729,9 @@ fn cmyk_raster(
/// The image Object › Rasterize makes of some art, before it goes in a document
/// ([`rasterize_art`]).
pub(crate) struct Rasterized {
/// A PNG, or a CMYK image of the art's inks ([`cmyk_raster`]).
/// A PNG (greyscale for the Grayscale colour model), a 1-bit TIFF or greyscale PNG for Bitmap
/// ([`vectorcraft_render::Rendered::to_bilevel`]), or a CMYK image of the art's inks
/// ([`cmyk_raster`]).
blob: vectorcraft_doc::ImageBlob,
/// A CMYK image's transparency, a greyscale PNG: it becomes the image's opacity mask.
alpha: Option<Vec<u8>>,
@@ -769,7 +771,8 @@ impl Rasterized {
/// Object › Rasterize's image of `nodes` (drawn also where hidden) with the params `p` of command
/// `cmd`, each defaulting to `doc`'s raster effects settings: over the bounds of what the art draws
/// ([`vectorcraft_render::drawn_bounds`]) and the room around them, in whole pixels. With `inks`,
/// a CMYK document's colour model makes a CMYK image ([`cmyk_raster`]); otherwise images are PNGs.
/// a CMYK document's colour model makes a CMYK image ([`cmyk_raster`]); otherwise images are PNGs,
/// greyscale for the Grayscale and Bitmap colour models (an opaque Bitmap image is a 1-bit TIFF).
pub(crate) fn rasterize_art(doc: &vectorcraft_doc::Document, nodes: Vec<Node>, p: &Value, cmd: &str, inks: bool) -> Result<Rasterized> {
check_numbers(p, &["ppi"], cmd)?;
let ppi = f64_or(p, "ppi", doc.raster_effects_ppi);
@@ -836,10 +839,17 @@ pub(crate) fn rasterize_art(doc: &vectorcraft_doc::Document, nodes: Vec<Node>, p
let tmp = isolated_doc(doc, nodes);
let opts = raster.render_options(RasterFormat::Png);
let (img, warning) = render_pixels(&tmp, region, scale, &opts, look.color_model)?;
let (blob, alpha) = if inks && doc.color_mode == vectorcraft_doc::ColorMode::Cmyk && look.color_model == RasterColorModel::Document {
cmyk_raster(&tmp, region, scale, &opts, &img)?
} else {
(vectorcraft_doc::ImageBlob::new("image/png", img.to_png().map_err(EngineError::Other)?), None)
let png =
|bytes: std::result::Result<Vec<u8>, String>| bytes.map(|b| vectorcraft_doc::ImageBlob::new("image/png", b)).map_err(EngineError::Other);
let (blob, alpha) = match look.color_model {
RasterColorModel::Document if inks && doc.color_mode == vectorcraft_doc::ColorMode::Cmyk => cmyk_raster(&tmp, region, scale, &opts, &img)?,
RasterColorModel::Document => (png(img.to_png())?, None),
// Grayscale images, as the colour models say (#6969).
RasterColorModel::Grayscale => (png(img.to_gray_png())?, None),
RasterColorModel::Bitmap => {
let (mime, bytes) = img.to_bilevel(ppi).map_err(EngineError::Other)?;
(vectorcraft_doc::ImageBlob::new(mime, bytes), None)
}
};
let xf = Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale);
Ok(Rasterized { blob, alpha, width: img.width, height: img.height, xf, clip, warning })
+35 -11
View File
@@ -798,9 +798,9 @@ pub(crate) fn file_new(s: &mut Session, p: &Value) -> Result<Value> {
None => (None, None),
};
let applied = profile.is_some();
let mut d = match profile {
Some(p) => settings.document_from_profile(p),
None => settings.document(),
let (mut d, links_from, warnings) = match profile {
Some(p) => (settings.document_from_profile(p.doc), Some(p.file), p.warnings),
None => (settings.document(), None, vec![]),
};
d.grid = s.prefs.grid();
d.title = match str_param(p, "name").or(str_param(p, "title")).map(str::trim).filter(|t| !t.is_empty()) {
@@ -811,6 +811,9 @@ pub(crate) fn file_new(s: &mut Session, p: &Value) -> Result<Value> {
};
d.metadata.created = clock_date(s, "created", date_param(p, "created", C)?);
let i = s.add_document(d, None);
// The profile's links keep their relative paths, which name places from the profile's file, as
// a new document from a history state keeps its source's.
s.doc_mut()?.links_from = links_from;
// Overprint Preview is the engine's (the UI turns Pixel Preview on from `previewMode`). Both
// are views of the whole app, so Default leaves them as they are.
if settings.preview_mode == PreviewMode::Overprint {
@@ -821,6 +824,9 @@ pub(crate) fn file_new(s: &mut Session, p: &Value) -> Result<Value> {
if applied {
out["profile"] = json!(settings.profile);
}
if !warnings.is_empty() {
out["warnings"] = json!(warnings);
}
if let Some(e) = profile_error {
out["profileError"] = json!({ "profile": settings.profile, "reason": e });
}
@@ -883,6 +889,19 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
/// larger one is reported and the document starts with the defaults.
pub const MAX_PROFILE_BYTES: u64 = 64 << 20;
/// `file.new`'s reason for a profile when the request being served may not read the profiles
/// folder: the folder's path is left out.
const PROFILE_DENIED: &str = "the request may not read the New Document profiles folder";
/// A profile's file as [`Session::read_profile`] read it.
struct ReadProfile {
doc: Document,
/// The file's path, which its links' relative paths name places from.
file: String,
/// A warning for each color profile the file carries that can't be used.
warnings: Vec<String>,
}
/// The New Document profiles' names: the built-in categories, in tab order.
pub fn profile_names() -> impl Iterator<Item = &'static str> {
CATEGORIES.iter().map(|c| c.0)
@@ -919,24 +938,29 @@ impl Session {
self.profile_path(name).is_some_and(|p| p.is_file())
}
/// Profile `name`'s document: `None` when it has no file (the shipped profile, the defaults),
/// Profile `name`'s document, its file's path and a warning for each color profile the file
/// carries that can't be used: `None` when it has no file (the shipped profile, the defaults),
/// the reason when the request being served may not read it, or its file can't be read, is
/// larger than [`MAX_PROFILE_BYTES`] or isn't a native document VectorCraft reads.
fn read_profile(&self, name: &str) -> std::result::Result<Option<Document>, String> {
fn read_profile(&self, name: &str) -> std::result::Result<Option<ReadProfile>, String> {
let Some(path) = self.profile_path(name) else { return Ok(None) };
let file = path.to_string_lossy().to_string();
// Checked before the file is looked for, so a request that may not read the folder learns
// nothing of its files.
crate::file_access::check_read(&path.to_string_lossy())?;
// nothing of its files, nor where the folder is.
let denied = |_| PROFILE_DENIED.to_string();
crate::file_access::check_read(&file).map_err(denied)?;
if std::fs::symlink_metadata(&path).is_err_and(|e| e.kind() == std::io::ErrorKind::NotFound) {
return Ok(None);
}
crate::file_access::check_read(&path.to_string_lossy())?;
crate::file_access::check_read(&file).map_err(denied)?;
let bytes = crate::store::read_capped(&path, MAX_PROFILE_BYTES)?;
let mut doc = vectorcraft_format::load(&bytes).map_err(|e| e.to_string())?;
// The color profiles the file carries are installed where missing, as opening it installs
// them, so the new document's assigned profiles draw and are saved with it.
let (mut doc, _, warnings) = super::fileio::native_file(&bytes).map_err(|e| e.to_string())?;
// Linked images in its symbols, patterns and brushes are read from their files, as placing
// a document reads them.
super::links::resolve(&mut doc, super::links::LinkBase::file(Some(&path.to_string_lossy())), false, false);
Ok(Some(doc))
super::links::resolve(&mut doc, super::links::LinkBase::file(Some(&file)), false, false);
Ok(Some(ReadProfile { doc, file, warnings }))
}
}
@@ -480,6 +480,7 @@ pub(super) const ALLOWED: &[&str] = &[
"select.allOnArtboard",
"select.anchors",
"select.anchorsMany",
"select.byNote",
"select.color",
"select.font",
"select.globalEdit",
+128
View File
@@ -11,6 +11,7 @@ use super::object::can_align;
use super::wand::{fill_of, stroke_of};
use super::{
CommandSpec, Result, Session, bad, bool_or, checked_id, checked_ids_param, cmd, color_value, has_doc, has_selection, index, ok, str_param,
switch_or, text_param,
};
use crate::DocState;
@@ -103,6 +104,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
select_color
),
cmd!(
"select.byNote",
"Select by Note",
[],
None,
"{note, caseSensitive?: bool, wholeWord?: bool} select the objects whose Attributes note (attributes.set) holds `note`, without its leading and trailing spaces (1 to 1,024 bytes): anywhere in the note and in any letter case by default; caseSensitive (default false) asks for the same letter case, and wholeWord (default false) for no letter, digit or _ right before or after it. It looks at the objects Select All takes and the objects inside them, leaving out hidden and locked objects and art on a locked artboard; an object whose note matches is selected without the objects inside it. The objects found replace the selection, in paint order. No object found is an error that leaves the selection as it was. Not an undo step; Select > Reselect repeats it. The Actions panel's Select Object step (ui.action.insertSelectObject) runs this → {count, ids} (resulting selection)",
has_doc,
by_note
),
cmd!(
"select.same.fillColor",
"Fill Color",
@@ -730,6 +740,124 @@ fn select_color(s: &mut Session, p: &Value) -> Result<Value> {
select_where(s, C, p, |d, n| paint_reported(d, n) && reads_as(fill_of(n), fill.as_deref()) && reads_as(stroke_of(n).0, stroke.as_deref()))
}
/// The longest note, in bytes, that Select by Note searches for.
const MAX_NOTE_QUERY: usize = 1024;
/// Select by Note: the objects whose Attributes note holds `note` ([`note_matches`]). An object
/// whose note matches is selected without the objects inside it. With none found, the selection
/// stays as it was.
fn by_note(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "select.byNote";
let query = text_param(p, "note", C)?.map(str::trim).unwrap_or_default();
if query.is_empty() {
return Err(bad(C, "the note is empty"));
}
if query.len() > MAX_NOTE_QUERY {
return Err(bad(C, "the note is longer than 1,024 bytes"));
}
let case_sensitive = switch_or(p, "caseSensitive", false, C)?;
let whole_word = switch_or(p, "wholeWord", false, C)?;
let not_found = || bad(C, format!("no object that can be selected has a note matching “{query}”"));
let st = s.doc()?;
let locks = st.doc.locked_artboards();
let mut ids = vec![];
walk_selectable(&st.doc, st.isolation, |n| {
let note = n.extras().attrs.as_deref().map_or("", |a| a.note.as_str());
if note_matches(note, query, case_sensitive, whole_word) && is_selectable(&st.doc, st.isolation, &locks, n.id) {
ids.push(n.id);
return false;
}
true
});
// The selection leaves out guides under Lock Guides and hidden slices, whatever selects them.
let mut found = st.selection.clone();
found.set(ids.iter().copied());
crate::deselect_hidden(&s.menu, &st.doc, &mut found);
if found.objects.is_empty() {
return Err(not_found());
}
let before = st.selection.objects.clone();
s.select(|_, sel| sel.set(ids.iter().copied()))?;
let st = s.doc_mut()?;
st.last_selection_cmd = Some((C.to_string(), p.clone(), before));
selection_result(s)
}
/// Whether `note` holds `query`: anywhere, and in any letter case unless `case_sensitive`. With
/// `whole_word`, the match starts and ends on whole characters of `note`, and the characters of
/// `note` right before and right after it are not a letter, a digit or `_`.
///
/// Letter case is compared one character at a time ([`fold_case`]), so a final Σ still matches σ,
/// and each folded character keeps the byte offset of the character of `note` it comes from.
fn note_matches(note: &str, query: &str, case_sensitive: bool, whole_word: bool) -> bool {
if note.is_empty() || query.is_empty() {
return false;
}
let fold = |text: &str| -> Vec<(char, usize)> {
let mut out = Vec::with_capacity(text.len());
for (at, c) in text.char_indices() {
if case_sensitive {
out.push((c, at));
} else {
out.extend(fold_case(c).map(|f| (f, at)));
}
}
out
};
let folded = fold(note);
let query: Vec<char> = fold(query).into_iter().map(|(c, _)| c).collect();
let word = |c: char| c.is_alphanumeric() || c == '_';
// Whether the match of `query` that starts at `folded[i]` counts.
let accept = |i: usize| {
if !whole_word {
return true;
}
let (start, last) = (folded[i].1, folded[i + query.len() - 1].1);
let starts_whole = i == 0 || folded[i - 1].1 != start;
let ends_whole = folded.get(i + query.len()).is_none_or(|(_, at)| *at != last);
if !starts_whole || !ends_whole {
return false;
}
let end = last + note[last..].chars().next().map_or(0, char::len_utf8);
!note[..start].chars().next_back().is_some_and(word) && !note[end..].chars().next().is_some_and(word)
};
// Knuth-Morris-Pratt, so a long note takes time in proportion to its length, and every place
// the query occurs is tried, also places that overlap. `fail[k]` is the length of the longest
// proper prefix of `query[..=k]` that is also its suffix.
let mut fail = vec![0; query.len()];
let mut k = 0;
for i in 1..query.len() {
while k > 0 && query[i] != query[k] {
k = fail[k - 1];
}
if query[i] == query[k] {
k += 1;
}
fail[i] = k;
}
let mut k = 0;
for (j, (c, _)) in folded.iter().enumerate() {
while k > 0 && *c != query[k] {
k = fail[k - 1];
}
if *c == query[k] {
k += 1;
}
if k == query.len() {
if accept(j + 1 - k) {
return true;
}
k = fail[k - 1];
}
}
false
}
/// `c` in lowercase for a comparison that ignores letter case, with the final sigma ς read as σ.
fn fold_case(c: char) -> impl Iterator<Item = char> {
c.to_lowercase().map(|l| if l == 'ς' { 'σ' } else { l })
}
/// Does `document.inspect` report a fill and stroke for `n`? A compound path's are its own, though
/// it holds its members, and its members report none.
fn paint_reported(d: &Document, n: &Node) -> bool {
+25 -1
View File
@@ -69,7 +69,7 @@ fn param_error(id: &str, e: &ParamError) -> EngineError {
Problem::Unknown { keys } => bad(id, format!("unknown parameter `{path}`; it takes: {keys}", keys = keys.join(", "))),
Problem::Missing => bad(id, format!("missing `{path}`")),
Problem::WrongType { expected, got } => bad(id, format!("`{path}` must be {expected}, not {got}")),
Problem::TooDeep => EngineError::Internal { cmd: id.into(), msg: e.to_string() },
Problem::TooDeep => bad(id, format!("`{path}` is nested too deeply")),
}
}
@@ -246,6 +246,30 @@ mod tests {
assert!(s.doc().unwrap().history.undo.is_empty());
}
/// A value nested past the limit of a sound schema is a parameter error that names it, not an
/// internal error: a color list at the nesting limit, where a color string still passes.
#[test]
fn a_value_nested_too_deeply_is_a_parameter_error() {
let levels = vectorcraft_apischema::MAX_DEPTH;
let ty = (0..levels).fold(Type::Color, |inner, _| Type::Array(Box::leak(Box::new(inner))));
let params: &'static [Param] = Box::leak(Box::new([Param::new("fills", ty)]));
let schema = ParamSchema::new(params, "");
assert_eq!(schema.definition_problem(), None);
let wrapped = |v: Value| (0..levels).fold(v, |inner, _| Value::Array(vec![inner]));
assert!(schema.validate(&json!({"fills": wrapped(json!("#ff0000"))})).is_ok());
let e = schema.validate(&json!({"fills": wrapped(json!([1, 0, 0]))})).unwrap_err();
let path = format!("fills{}", "[0]".repeat(levels));
match param_error("view.synthetic", &e) {
EngineError::BadParams { cmd, msg } => {
assert_eq!(cmd, "view.synthetic");
assert_eq!(msg, format!("`{path}` is nested too deeply"));
}
other => panic!("not a parameter error: {other}"),
}
let shown = param_error("view.synthetic", &e).to_string();
assert!(shown.starts_with("invalid parameters for `view.synthetic`") && !shown.contains("report this bug"), "{shown}");
}
/// Each command's JSON schema follows the 2020-12 rules, and a validator of those rules, which
/// shares no code with the writer, gives the same answer as the schema check. None of these
/// schemas has a lenient parameter.
+136 -1
View File
@@ -1,4 +1,5 @@
//! Window → Attributes (`attributes.set`, `attributes.info`, `path.setFillRule`, `path.reverse`),
//! Window → Attributes (`attributes.set`, `attributes.info`, `path.setFillRule`, `path.reverse`,
//! and `select.byNote`, which selects objects by their note),
//! the links SVG export writes for objects with a URL, and what objects keep for other software
//! (their `data-*` attributes and their attributes in other applications' namespaces).
@@ -216,6 +217,140 @@ fn object_data_is_set_saved_and_written_to_svg() {
assert_eq!(back.node(a).unwrap().extras().attrs, s.doc().unwrap().doc.node(a).unwrap().extras().attrs);
}
/// The selected objects after `select.byNote` with `p`, or its error.
fn by_note(s: &mut Session, p: Value) -> std::result::Result<Vec<u64>, String> {
s.execute("select.byNote", &p).map_err(|e| e.to_string())?;
Ok(s.doc().unwrap().selection.objects.iter().map(|id| id.0).collect())
}
fn note(s: &mut Session, id: NodeId, note: &str) {
run(s, "attributes.set", json!({"ids": [id.0], "note": note}));
}
/// Select by Note finds a note anywhere and in any letter case by default, replaces the selection
/// with every object it finds, in paint order, and is not an undo step. Case Sensitive and Whole
/// Word narrow it; Reselect repeats it.
#[test]
fn select_by_note_matches_part_of_a_note_in_any_case_unless_asked() {
let mut s = session();
let [a, b, c, d] = [0.0, 20.0, 40.0, 60.0].map(|x| rect(&mut s, x));
note(&mut s, a, "Logo front");
note(&mut s, b, "logo");
note(&mut s, c, "Logotype");
note(&mut s, d, "back");
run(&mut s, "select.set", json!({"ids": [d.0]}));
let undo = s.doc().unwrap().history.undo.len();
assert_eq!(by_note(&mut s, json!({"note": " LOGO "})), Ok(vec![a.0, b.0, c.0]), "outer spaces are left out");
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no undo step");
assert_eq!(by_note(&mut s, json!({"note": "logo", "caseSensitive": true})), Ok(vec![b.0]));
assert_eq!(by_note(&mut s, json!({"note": "logo", "wholeWord": true})), Ok(vec![a.0, b.0]));
assert_eq!(by_note(&mut s, json!({"note": "Logo", "caseSensitive": true, "wholeWord": true})), Ok(vec![a.0]));
assert_eq!(by_note(&mut s, json!({"note": "o f"})), Ok(vec![a.0]), "spaces inside the note count");
run(&mut s, "select.none", json!({}));
run(&mut s, "select.reselect", json!({}));
assert_eq!(s.doc().unwrap().selection.objects, [a]);
}
/// Whole Word looks at every place the text occurs, also places that overlap.
#[test]
fn select_by_note_whole_word_matches_overlapping_places_and_other_scripts() {
let mut s = session();
let a = rect(&mut s, 0.0);
let matches = |s: &mut Session, text: &str, p: Value| {
note(s, a, text);
by_note(s, p).is_ok()
};
assert!(matches(&mut s, "a aa a a", json!({"note": "a a", "wholeWord": true})));
assert!(!matches(&mut s, "aa_a", json!({"note": "a", "wholeWord": true})));
assert!(matches(&mut s, "x-ray, x", json!({"note": "x", "wholeWord": true})));
assert!(matches(&mut s, "ÉTIQUETTE", json!({"note": "étiquette"})));
assert!(!matches(&mut s, "ÉTIQUETTE", json!({"note": "étiquette", "caseSensitive": true})));
assert!(matches(&mut s, "标签 一", json!({"note": "一", "wholeWord": true})));
assert!(!matches(&mut s, "", json!({"note": "a"})));
}
/// Letter case is compared one character at a time: a final Σ, whose lowercase is ς, matches Σ
/// and σ. Whole Word looks at the characters of the note itself, so a letter whose lowercase
/// takes two characters (İ) neither ends a word early nor matches half of itself.
#[test]
fn select_by_note_compares_letter_case_per_character() {
let mut s = session();
let a = rect(&mut s, 0.0);
let matches = |s: &mut Session, text: &str, p: Value| {
note(s, a, text);
by_note(s, p).is_ok()
};
assert!(matches(&mut s, "ΟΣ", json!({"note": "Σ"})));
assert!(matches(&mut s, "ΟΣ", json!({"note": "σ"})));
assert!(matches(&mut s, "ΟΣ", json!({"note": "ος", "wholeWord": true})));
assert!(!matches(&mut s, "ΟΣ", json!({"note": "σ", "caseSensitive": true})));
assert!(!matches(&mut s, "İx", json!({"note": "i", "wholeWord": true})));
assert!(matches(&mut s, "İ x", json!({"note": "İ", "wholeWord": true})));
assert!(!matches(&mut s, "İ x", json!({"note": "i", "wholeWord": true})));
}
/// Select by Note leaves out hidden and locked objects, selects a group whose note matches without
/// its members and a member whose note matches on its own, keeps to the isolated group, and leaves
/// the selection as it was when it finds nothing. Bad params are errors that change nothing.
#[test]
fn select_by_note_takes_only_objects_that_can_be_selected() {
let mut s = session();
let [a, b, c, d, e] = [0.0, 10.0, 20.0, 30.0, 40.0].map(|x| rect(&mut s, x));
for id in [a, b, c, d, e] {
note(&mut s, id, "tag");
}
run(&mut s, "select.set", json!({"ids": [a.0]}));
run(&mut s, "object.hide", json!({}));
run(&mut s, "select.set", json!({"ids": [b.0]}));
run(&mut s, "object.lock", json!({}));
run(&mut s, "select.set", json!({"ids": [c.0, d.0]}));
let g = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap());
note(&mut s, g, "tag group");
assert_eq!(by_note(&mut s, json!({"note": "tag"})), Ok(vec![g.0, e.0]), "the group, not its members");
note(&mut s, g, "box");
assert_eq!(by_note(&mut s, json!({"note": "tag"})), Ok(vec![c.0, d.0, e.0]));
run(&mut s, "object.isolate", json!({"id": g.0}));
assert_eq!(by_note(&mut s, json!({"note": "tag"})), Ok(vec![c.0, d.0]), "inside the isolated group only");
run(&mut s, "select.set", json!({"ids": [c.0]}));
let missing = by_note(&mut s, json!({"note": "nothing"})).unwrap_err();
assert!(missing.contains("no object that can be selected has a note matching “nothing”"), "{missing}");
assert_eq!(s.doc().unwrap().selection.objects, [c], "the selection stays");
for bad in [
json!({}),
json!({"note": " "}),
json!({"note": 5}),
json!({"note": "x".repeat(1025)}),
json!({"note": "tag", "caseSensitive": "yes"}),
json!({"note": "tag", "wholeWord": 1}),
] {
assert!(s.execute("select.byNote", &bad).is_err(), "{bad}");
assert_eq!(s.doc().unwrap().selection.objects, [c], "{bad}");
}
assert!(by_note(&mut s, json!({"note": "x".repeat(1024)})).is_err_and(|e| e.contains("no object")), "1,024 bytes is allowed");
}
/// Under Lock Guides a guide whose note matches is not a match, and a search that finds only such
/// guides leaves the selection and the Puppet Warp pins on it as they were.
#[test]
fn select_by_note_leaves_locked_guides_out_before_changing_the_selection() {
let mut s = session();
let a = rect(&mut s, 10.0);
let g = rect(&mut s, 50.0);
note(&mut s, g, "edge");
run(&mut s, "select.set", json!({"ids": [g.0]}));
run(&mut s, "view.guides.make", json!({}));
run(&mut s, "view.guides.lock", json!({"locked": true}));
run(&mut s, "select.set", json!({"ids": [a.0]}));
run(&mut s, "object.puppetWarp", json!({"id": a.0, "rest": true, "pins": [[25, 25]], "moved": [[25, 25]]}));
run(&mut s, "select.set", json!({"ids": [a.0]}));
assert!(s.doc().unwrap().doc.puppet.is_some(), "a pin on the rectangle");
let missing = by_note(&mut s, json!({"note": "edge"})).unwrap_err();
assert!(missing.contains("no object that can be selected"), "{missing}");
let st = s.doc().unwrap();
assert_eq!(st.selection.objects, [a], "the selection stays");
assert!(st.doc.puppet.is_some(), "the pins stay");
}
/// An object's attributes in other applications' XML namespaces are its own, as its `data-*`
/// attributes are: a pasted copy and a duplicate keep them, deleting the object takes them with
/// it, and the native file and SVG export keep them.
+296
View File
@@ -126,6 +126,302 @@ fn a_symbol_instance_fill_with_no_opacity_keeps_swatch_links_when_expanded() {
assert!(linked > 0, "the expanded art keeps the Brand swatch link");
}
/// The square of [`square`] made a symbol, its instance selected, with a new fill (`fill`) or
/// stroke on top that has no paint: it neither stains nor strokes the art. Returns the instance and
/// the new row's index.
fn instance_with_row(s: &mut Session, name: &str, fill: bool) -> (NodeId, usize) {
square(s);
let id = NodeId(run(s, "symbol.new", json!({"name": name}))["id"].as_u64().unwrap());
run(s, if fill { "appearance.addFill" } else { "appearance.addStroke" }, json!({"ids": [id.0]}));
let row = node(s, id).appearance.items.len() - 1;
run(s, "appearance.setItem", json!({"ids": [id.0], "index": row, "none": true}));
(id, row)
}
fn offset_row(s: &mut Session, id: NodeId, row: usize, offset: f64) {
run(s, "effect.apply", json!({"ids": [id.0], "item": row, "effect": "path.offsetPath", "params": {"offset": offset}}));
}
fn near(p: [u8; 4], q: [u8; 4]) -> bool {
p.iter().zip(q.iter()).all(|(x, y)| x.abs_diff(*y) <= 24)
}
const WHITE: [u8; 4] = [255, 255, 255, 255];
const RED: [u8; 4] = [255, 0, 0, 255];
const BLUE: [u8; 4] = [0, 0, 255, 255];
/// No object in `n` keeps an effect of its own or on a fill or stroke.
fn no_effects_left(n: &Node) {
n.walk(&mut |part| {
assert!(part.appearance.effects.is_empty(), "{:?} keeps object effects", part.id);
assert!(part.appearance.items.iter().all(|i| i.effects().is_empty()), "{:?} keeps fill or stroke effects", part.id);
});
}
/// An effect on a symbol instance's fill reshapes the filled shapes of its art, keeping their
/// colors, and leaves their strokes where they were; Expand Appearance bakes the same look into
/// editable art in one undo step.
#[test]
fn a_symbol_instance_fill_effect_reshapes_its_filled_art_and_expands() {
let mut s = session();
let (id, row) = instance_with_row(&mut s, "Square", true);
assert!(!expandable(&s));
let plain = render(&s);
assert!(near(plain.pixel(44, 100), WHITE));
offset_row(&mut s, id, row, 10.0);
assert!(expandable(&s), "the fill's effect can be expanded");
let before = render(&s);
assert!(near(before.pixel(44, 100), RED), "the art's red fill grows by the offset: {:?}", before.pixel(44, 100));
assert!(near(before.pixel(50, 100), BLUE), "the blue stroke stays on the square: {:?}", before.pixel(50, 100));
run(&mut s, "effect.expandAppearance", json!({}));
let n = node(&s, id);
assert!(!matches!(n.kind, NodeKind::SymbolInstance { .. }));
no_effects_left(&n);
let mut paints = (0, 0);
n.walk(&mut |part| {
if part.appearance.fill().is_some_and(|f| f.paint.color().is_some_and(|c| c.to_hex() == "#ff0000")) {
paints.0 += 1;
}
if part.appearance.stroke().is_some_and(|st| st.paint.color().is_some_and(|c| c.to_hex() == "#0000ff")) {
paints.1 += 1;
}
});
assert!(paints.0 > 0 && paints.1 > 0, "the art keeps its red fill and blue stroke: {paints:?}");
let after = render(&s);
assert!(differing(&before, &after, 8) < 200, "expanding changed {} pixels", differing(&before, &after, 8));
run(&mut s, "edit.undo", json!({}));
let n = node(&s, id);
assert!(matches!(n.kind, NodeKind::SymbolInstance { .. }), "one undo gives the instance back");
assert!(n.appearance.items[row].effects().iter().any(|e| e.id == "path.offsetPath"));
}
/// Export bakes a symbol instance's fill effect into its art as the canvas draws it.
#[test]
fn export_bakes_a_symbol_instance_fill_effect() {
let mut s = session();
let (id, row) = instance_with_row(&mut s, "Square", true);
offset_row(&mut s, id, row, 10.0);
let doc = s.doc().unwrap().doc.clone();
let baked = vectorcraft_render::effects::bake_document(&doc).expect("the instance is baked");
let n = baked.node(id).expect("the baked art keeps the instance's id").clone();
assert!(!matches!(n.kind, NodeKind::SymbolInstance { .. }));
no_effects_left(&n);
let b = n.geometric_bounds().expect("the baked art has bounds");
assert!((b.x0 - 40.0).abs() < 1.0 && (b.x1 - 160.0).abs() < 1.0, "the fill grows by the offset: {b:?}");
}
/// An effect on a symbol instance's stroke reshapes the stroked shapes of its art and leaves
/// their fills where they were.
#[test]
fn a_symbol_instance_stroke_effect_reshapes_its_stroked_art_and_expands() {
let mut s = session();
let (id, row) = instance_with_row(&mut s, "Square", false);
assert!(near(render(&s).pixel(40, 100), WHITE));
offset_row(&mut s, id, row, 10.0);
assert!(expandable(&s));
let before = render(&s);
assert!(near(before.pixel(40, 100), BLUE), "the stroke moves out with the offset: {:?}", before.pixel(40, 100));
assert!(near(before.pixel(45, 100), WHITE), "the fill keeps its shape: {:?}", before.pixel(45, 100));
assert!(near(before.pixel(100, 100), RED));
run(&mut s, "effect.expandAppearance", json!({"ids": [id.0]}));
let n = node(&s, id);
assert!(!matches!(n.kind, NodeKind::SymbolInstance { .. }));
no_effects_left(&n);
let after = render(&s);
assert!(differing(&before, &after, 8) < 200, "expanding changed {} pixels", differing(&before, &after, 8));
}
/// A dynamic symbol's instance that paints a stroke of its own on art without one: the instance's
/// stroke effect reshapes that stroke, and its bounds take in where the stroke is drawn.
#[test]
fn a_stroke_effect_reshapes_a_stroke_a_dynamic_instance_adds() {
let mut s = session();
square(&mut s);
run(&mut s, "paint.setStroke", json!({"none": true}));
let id = NodeId(run(&mut s, "symbol.new", json!({"name": "Art", "type": "dynamic"}))["id"].as_u64().unwrap());
run(&mut s, "appearance.addStroke", json!({"ids": [id.0]}));
let row = node(&s, id).appearance.items.len() - 1;
run(&mut s, "appearance.setItem", json!({"ids": [id.0], "index": row, "none": true}));
let blue = vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 1.0));
s.edit("paint", |d, _| {
let o = vectorcraft_doc::symbol_overrides::SymbolOverride { path: vec![], stroke: Some(blue), ..Default::default() };
d.node_mut(id).ok_or(EngineError::NoNode(id))?.extras_mut().symbol_overrides.push(o);
Ok(())
})
.unwrap();
// The 1 pt stroke's center moves to x = 30.5, so it covers the pixel column from 30 to 31.
offset_row(&mut s, id, row, 19.5);
assert!(near(render(&s).pixel(30, 100), BLUE), "the added stroke moves out with the offset: {:?}", render(&s).pixel(30, 100));
let doc = s.doc().unwrap().doc.clone();
let b = vectorcraft_render::painted_bounds(&doc, doc.node(id).unwrap()).expect("the instance has bounds");
assert!(b.x0 <= 31.0 && b.x1 >= 169.0, "the bounds take in the offset stroke: {b:?}");
let drawn = vectorcraft_render::drawn_bounds(&doc, doc.node(id).unwrap()).expect("the instance is drawn");
assert!(drawn.x0 <= 31.0 && drawn.x1 >= 169.0, "the drawn bounds take in the offset stroke: {drawn:?}");
}
/// A group in the symbol's art with a fill of its own: the instance's fill effect reshapes what
/// that fill paints, and the instance's bounds take it in.
#[test]
fn a_fill_effect_reshapes_the_fill_of_a_group_in_the_art() {
let mut s = session();
square(&mut s);
run(&mut s, "paint.setFill", json!({"none": true}));
run(&mut s, "paint.setStroke", json!({"none": true}));
let g = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap());
run(&mut s, "appearance.addFill", json!({"ids": [g.0]}));
run(&mut s, "paint.setFill", json!({"ids": [g.0], "item": 0, "color": "#ff0000"}));
let id = NodeId(run(&mut s, "symbol.new", json!({"name": "Group"}))["id"].as_u64().unwrap());
run(&mut s, "appearance.addFill", json!({"ids": [id.0]}));
let row = node(&s, id).appearance.items.len() - 1;
run(&mut s, "appearance.setItem", json!({"ids": [id.0], "index": row, "none": true}));
offset_row(&mut s, id, row, 20.0);
assert!(near(render(&s).pixel(35, 100), RED), "the group's fill grows by the offset: {:?}", render(&s).pixel(35, 100));
let doc = s.doc().unwrap().doc.clone();
let b = vectorcraft_render::painted_bounds(&doc, doc.node(id).unwrap()).expect("the instance has bounds");
assert!(b.x0 <= 31.0 && b.x1 >= 169.0, "the bounds take in the group's grown fill: {b:?}");
let drawn = vectorcraft_render::drawn_bounds(&doc, doc.node(id).unwrap()).expect("the instance is drawn");
assert!(drawn.x0 <= 31.0 && drawn.x1 >= 169.0, "the drawn bounds take in the group's grown fill: {drawn:?}");
let before = render(&s);
run(&mut s, "effect.expandAppearance", json!({}));
no_effects_left(&node(&s, id));
let after = render(&s);
assert!(differing(&before, &after, 8) < 200, "expanding changed {} pixels", differing(&before, &after, 8));
}
/// Of several fills, only the topmost one's effects reach the art, and only while it is visible.
#[test]
fn only_the_topmost_visible_instance_fill_reshapes_the_art() {
let mut s = session();
let (id, lower) = instance_with_row(&mut s, "Square", true);
offset_row(&mut s, id, lower, 30.0);
run(&mut s, "appearance.addFill", json!({"ids": [id.0]}));
let top = node(&s, id).appearance.items.len() - 1;
run(&mut s, "appearance.setItem", json!({"ids": [id.0], "index": top, "none": true}));
assert!(!expandable(&s), "a lower fill's effect does not reach the art");
offset_row(&mut s, id, top, 10.0);
let both = render(&s);
assert!(near(both.pixel(44, 100), RED), "the top fill's offset shows");
assert!(near(both.pixel(25, 100), WHITE), "the lower fill's offset does not: {:?}", both.pixel(25, 100));
run(&mut s, "appearance.setItem", json!({"ids": [id.0], "index": top, "visible": false}));
assert!(near(render(&s).pixel(44, 100), WHITE), "a hidden top fill reshapes nothing");
assert!(!expandable(&s));
run(&mut s, "appearance.setItem", json!({"ids": [id.0], "index": top, "visible": true}));
run(&mut s, "effect.expandAppearance", json!({}));
let after = render(&s);
assert!(differing(&both, &after, 8) < 200, "expanding changed {} pixels", differing(&both, &after, 8));
let n = node(&s, id);
no_effects_left(&n);
}
/// An instance inside a symbol's art passes the outer instance's fill effect on to its own art.
#[test]
fn a_nested_symbol_instance_takes_the_outer_fill_effect() {
let mut s = session();
square(&mut s);
run(&mut s, "symbol.new", json!({"name": "Inner"}));
let id = NodeId(run(&mut s, "symbol.new", json!({"name": "Outer"}))["id"].as_u64().unwrap());
run(&mut s, "appearance.addFill", json!({"ids": [id.0]}));
let row = node(&s, id).appearance.items.len() - 1;
run(&mut s, "appearance.setItem", json!({"ids": [id.0], "index": row, "none": true}));
offset_row(&mut s, id, row, 40.0);
let before = render(&s);
assert!(near(before.pixel(20, 100), RED), "the inner art grows by the offset: {:?}", before.pixel(20, 100));
assert!(expandable(&s));
// A view that shows only the grown part, right of the square: the instance is still drawn.
let doc = s.doc().unwrap().doc.clone();
let opts = vectorcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let right = vectorcraft_render::Renderer::new().render(&doc, 200, 200, vectorcraft_geom::Affine::translate((-155.0, 0.0)), &opts);
assert!(near(right.pixel(20, 100), RED), "the grown art past the square's box is drawn: {:?}", right.pixel(20, 100));
let b = vectorcraft_render::painted_bounds(&doc, doc.node(id).unwrap()).expect("the instance has bounds");
assert!(b.x0 <= 11.0 && b.x1 >= 189.0, "the bounds take in the inner art's offset: {b:?}");
run(&mut s, "effect.expandAppearance", json!({}));
let n = node(&s, id);
assert!(!matches!(n.kind, NodeKind::SymbolInstance { .. }));
n.walk(&mut |part| assert!(!matches!(part.kind, NodeKind::SymbolInstance { .. }), "the inner instance is expanded too"));
no_effects_left(&n);
let after = render(&s);
assert!(differing(&before, &after, 8) < 200, "expanding changed {} pixels", differing(&before, &after, 8));
}
/// A symbol whose art holds an instance of itself draws, exports and expands without running away.
/// The engine keeps no such document after an edit, so Expand Appearance refuses it and the
/// instance stays as it was.
#[test]
fn a_fill_effect_on_an_instance_of_a_symbol_that_holds_itself_ends() {
let mut s = session();
let (id, row) = instance_with_row(&mut s, "Square", true);
offset_row(&mut s, id, row, 4.0);
{
let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
let inst = d.node(id).unwrap().clone();
let sym = d.symbols.iter_mut().find(|sy| sy.name == "Square").unwrap();
sym.art = std::sync::Arc::new(Node::group(NodeId(90_000), vec![sym.art.clone(), std::sync::Arc::new(inst)]));
}
let doc = s.doc().unwrap().doc.clone();
assert!(vectorcraft_render::effects::bake_document(&doc).is_some(), "export bakes the instance");
assert!(vectorcraft_render::painted_bounds(&doc, doc.node(id).unwrap()).is_some(), "the instance is measured");
render(&s);
assert!(expandable(&s));
let e = s.execute("effect.expandAppearance", &json!({})).expect_err("the symbol nests without end");
assert!(e.to_string().contains(crate::TOO_DEEP), "{e}");
assert!(matches!(node(&s, id).kind, NodeKind::SymbolInstance { .. }), "the instance stays as it was");
}
/// A clip group's clipping path in the art keeps its shape, a compound one included, while the art
/// it clips takes the instance's fill effect.
#[test]
fn a_compound_clipping_path_in_the_art_keeps_its_shape() {
let mut s = session();
let (id, row) = instance_with_row(&mut s, "Square", true);
offset_row(&mut s, id, row, 10.0);
let red = || {
let mut ap = vectorcraft_doc::Appearance::default();
ap.items.push(vectorcraft_doc::AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(vectorcraft_color::Paint::solid(
vectorcraft_color::Color::rgb(1.0, 0.0, 0.0),
))));
ap
};
let square = |id: u64, x: f64| {
let rect = vectorcraft_geom::Rect::new(x, 50.0, x + 100.0, 150.0);
std::sync::Arc::new(Node::path(NodeId(id), vectorcraft_geom::shapes::rectangle(rect), red()))
};
let mut clip = Node::new(NodeId(90_001), NodeKind::Compound { children: vec![square(90_002, 40.0)], rule: Default::default() });
clip.appearance = red().into();
let group = Node::new(NodeId(90_003), NodeKind::Group { children: vec![std::sync::Arc::new(clip), square(90_004, 50.0)], clip: true });
let doc = {
let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
let sym = d.symbols.iter_mut().find(|sy| sy.name == "Square").unwrap();
sym.art = std::sync::Arc::new(group);
d.clone()
};
let inst = doc.node(id).unwrap();
let art = doc.symbols.iter().find(|sy| sy.name == "Square").unwrap().art.clone();
let out = vectorcraft_render::effects::outline_art(Some(&doc), inst, Some(&art)).expect("the instance's art");
let mut seen = (false, false);
out.walk(&mut |part| {
let offset = part.appearance.items.iter().any(|i| i.effects().iter().any(|e| e.id == "path.offsetPath"));
match part.id.0 {
90_001 => {
seen.0 = true;
assert!(!offset, "the compound clipping path keeps its shape");
}
90_004 => {
seen.1 = true;
assert!(offset, "the clipped square takes the fill effect");
}
_ => {}
}
});
assert_eq!(seen, (true, true));
}
#[test]
fn two_fills_and_an_offset_stroke_become_three_paths_that_look_the_same() {
let mut s = session();
+56
View File
@@ -597,6 +597,10 @@ fn the_profile_follows_the_preset_access_and_reset() {
let roots = crate::file_access::AutomationRoots::new(&[elsewhere.as_path()], &[dir.as_path()]).unwrap().unwrap();
let r = crate::file_access::confine(Some(&roots), || s.execute("file.new", &json!({"preset": "A4"}))).unwrap();
assert!(r.get("profile").is_none() && r["profileError"]["profile"] == "Print", "{r}");
// The reason does not say where the folder is.
let reason = r["profileError"]["reason"].as_str().unwrap_or_default();
let folder = dir.file_name().unwrap().to_string_lossy().to_string();
assert!(!reason.is_empty() && !reason.contains(&folder), "{reason}");
assert!(!swatch_names(&s).contains(&"Brand Red".to_string()));
// Nothing tells such a request which profiles have files.
let r = crate::file_access::confine(Some(&roots), || s.execute("file.new", &json!({"preset": "Web 1920×1080"}))).unwrap();
@@ -627,6 +631,58 @@ fn the_profile_follows_the_preset_access_and_reset() {
let _ = std::fs::remove_dir_all(&dir);
}
/// The new document's links find their files from the profile's file, as a document opened from
/// that file does; a document without a profile has no such file.
#[test]
fn the_new_document_links_from_the_profile_file() {
let (mut s, dir) = with_profiles("links");
edit_print_profile(&mut s, &dir);
assert_eq!(s.execute("file.new", &json!({"preset": "A4"})).unwrap()["profile"], "Print");
let file = dir.join("Print.vectorcraft").to_string_lossy().to_string();
assert_eq!(s.doc().unwrap().links_from.as_deref(), Some(file.as_str()));
s.execute("file.new", &json!({"preset": "Web 1920×1080"})).unwrap();
assert_eq!(s.doc().unwrap().links_from, None);
let _ = std::fs::remove_dir_all(&dir);
}
/// A color profile the profile's file carries is installed when this machine lacks it, as opening
/// the file installs it, so the new document keeps it and a save carries it; one that can't be used
/// is a warning.
#[test]
fn a_profile_brings_the_color_profile_its_file_carries() {
use vectorcraft_color::cms;
let (mut s, dir) = with_profiles("icc");
let builtin = cms::profiles().into_iter().find(|p| p.kind == cms::ProfileKind::Rgb && p.builtin && p.name != cms::SRGB).unwrap();
let icc = cms::icc_bytes(&builtin.name).unwrap().to_vec();
let name = format!("Profile RGB {}", std::process::id());
cms::register_icc(&icc, Some(name.clone())).unwrap();
let bytes_of = |v: &Value| vectorcraft_format::base64_decode(v.as_str().expect("base64")).unwrap();
s.execute("file.new", &json!({"preset": "Web 1920×1080", "profile": null})).unwrap();
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).color_profiles.rgb = Some(name.clone());
let file = String::from_utf8(bytes_of(&s.execute("file.saveCopy", &json!({"compress": false})).unwrap()["dataBase64"])).unwrap();
cms::unregister_icc(&name);
// A profile made on another machine names a color profile this one lacks.
let elsewhere = format!("Profile RGB elsewhere {}", std::process::id());
assert!(cms::profile(&elsewhere).is_none());
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("Web.vectorcraft"), file.replace(&name, &elsewhere)).unwrap();
let r = s.execute("file.new", &json!({"preset": "Web 1920×1080"})).unwrap();
assert!(r["profile"] == "Web" && r.get("warnings").is_none(), "{r}");
assert_eq!(cms::profile(&elsewhere).map(|p| p.kind), Some(cms::ProfileKind::Rgb));
assert_eq!(s.doc().unwrap().doc.color_profiles.rgb.as_deref(), Some(elsewhere.as_str()));
let saved: Value = serde_json::from_slice(&bytes_of(&s.execute("file.saveCopy", &json!({"compress": false})).unwrap()["dataBase64"])).unwrap();
assert_eq!(vectorcraft_format::base64_decode(saved["profiles"][&elsewhere]["data"].as_str().unwrap()).unwrap(), icc);
cms::unregister_icc(&elsewhere);
// Damaged profile data starts the document with a warning.
let broken = format!("Profile RGB broken {}", std::process::id());
let mut v: Value = serde_json::from_str(&file.replace(&name, &broken)).unwrap();
v["profiles"][&broken]["data"] = json!("AAAA");
std::fs::write(dir.join("Web.vectorcraft"), serde_json::to_vec(&v).unwrap()).unwrap();
let r = s.execute("file.new", &json!({"preset": "Web 1920×1080"})).unwrap();
assert!(r["profile"] == "Web" && r["warnings"][0].as_str().unwrap_or_default().contains(&broken), "{r}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_damaged_or_oversized_profile_leaves_the_defaults_and_is_reported() {
let (mut s, dir) = with_profiles("damaged");
+47
View File
@@ -455,3 +455,50 @@ fn rasterize_to_cmyk_makes_a_cmyk_image() {
assert_eq!((alpha.get_pixel(1, 1).0, alpha.get_pixel(25, 25).0), ([0], [255]));
assert!(mask.clip);
}
/// Rasterize with the Grayscale or Bitmap colour model makes a grayscale image (#6969): the
/// image says Grayscale, its file holds one gray channel (one bit a pixel for an opaque Bitmap,
/// gray and alpha over a transparent background), in a CMYK document too, and a fill color
/// colorizes it.
#[test]
fn rasterize_to_grayscale_or_bitmap_makes_a_grayscale_image() {
for mode in ["rgb", "cmyk"] {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 100, "colorMode": mode})).unwrap();
let r = id(&s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 40})).unwrap());
s.execute("paint.setFill", &json!({"color": "#ff0000", "ids": [r.0]})).unwrap();
s.execute("paint.setStroke", &json!({"none": true, "ids": [r.0]})).unwrap();
let mut rasterize = |model: &str, background: &str| {
s.execute("select.set", &json!({"ids": [r.0]})).unwrap();
let p = json!({"ppi": 72, "colorModel": model, "background": background, "addAround": 5});
let img = id(&s.execute("object.rasterize", &p).unwrap());
let info = s.execute("image.info", &json!({"id": img.0})).unwrap();
let d = &s.doc().unwrap().doc;
let n = d.node(img).unwrap();
assert!(n.extras().mask.is_none(), "{mode} {model} {background}: no opacity mask");
let NodeKind::Image(im) = &n.kind else { panic!("an image") };
let blob = d.images[&im.key].clone();
let pixels = image::load_from_memory(&blob.bytes).unwrap().to_luma_alpha8();
assert_eq!(pixels.dimensions(), (50, 50));
let at = |x, y| pixels.get_pixel(x, y).0;
s.execute("edit.undo", &json!({})).unwrap();
(info["colorMode"].clone(), blob.gray_source(), at(25, 25), at(1, 1))
};
let gray = |one_bit, alpha| Some(vectorcraft_doc::GraySource { one_bit, alpha });
let (info, source, [level, alpha], corner) = rasterize("grayscale", "white");
assert_eq!((info, source, alpha, corner), (json!("Grayscale"), gray(false, false), 255, [255, 255]), "{mode}");
if mode == "rgb" {
assert_eq!(level, 54, "red's Rec. 709 luma");
} else {
assert!((1..=254).contains(&level), "the CMYK red's screen color as a gray: {level}");
}
assert_eq!(rasterize("grayscale", "transparent"), (json!("Grayscale"), gray(false, true), [level, 255], [255, 0]), "{mode}");
assert_eq!(rasterize("bitmap", "white"), (json!("Grayscale"), gray(true, false), [0, 255], [255, 255]), "{mode}");
assert_eq!(rasterize("bitmap", "transparent"), (json!("Grayscale"), gray(false, true), [0, 255], [255, 0]), "{mode}");
// A fill color colorizes the image, as it does any grayscale image.
s.execute("select.set", &json!({"ids": [r.0]})).unwrap();
let img = id(&s.execute("object.rasterize", &json!({"ppi": 72, "colorModel": "grayscale"})).unwrap());
s.execute("paint.setFill", &json!({"color": "#0000ff", "ids": [img.0]})).unwrap();
assert!(s.doc().unwrap().doc.node(img).unwrap().extras().colorize.is_some(), "{mode}: colorized");
}
}
+6
View File
@@ -1439,6 +1439,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"",
"{set?: index, action?: index, step?: index, command, params?: object} insert the command of a menu item as a step, without running it; the step plays as choosing the item runs it. command and params (default {}) must be those of a menu item that can be a step: one that runs an engine command, whose label does not end with \"…\", outside the VectorCraft, File, Window and Help menus, and that is not Undo, Redo, a Paste, a batch (command.batch), a preference (prefs.*) or action (action.*) command, an Object › Align item, Image Trace's Make or Make and Expand, Pattern's Make or Edit Pattern, a saved selection, saved view or recent font item, or a plug-in's item. The place is as ui.action.insertStop takes it: with set and action, after step, else after the action's last step; without them, the recording in progress. The new step becomes the panel's selected step, and its action and set become the selected ones. A command and params that no such menu item has, a param of another type, params that are not an object, no action and no recording, or a step that would give the action more than 10,000 steps or its set's file more than 8 MiB, counting the steps that the recording in progress adds to the set, is an error and changes nothing. The panel menu's Insert Menu Item… (dialog `actionInsertMenuItem` with find, command, params, set, action and step) runs this on OK → {set, action, step}, or {recording: true}",
),
(
"ui.action.insertSelectObject",
"Select Object",
"",
"{set?: index, action?: index, step?: index, note, caseSensitive?: bool, wholeWord?: bool} insert a Select Object step, without running it: playback runs select.byNote {note, caseSensitive, wholeWord}, which selects the objects whose Attributes note matches note, and a playback that finds no such object stops there with an error. note loses its leading and trailing spaces and must then be 1 to 1,024 bytes; caseSensitive and wholeWord default to false. The place is as ui.action.insertStop takes it: with set and action, after step, else after the action's last step; without them, the recording in progress. The new step becomes the panel's selected step, and its action and set become the selected ones. A missing or empty note, a param of another type, params that are not an object, no action and no recording, or a step that would give the action more than 10,000 steps or its set's file more than 8 MiB, counting the steps that the recording in progress adds to the set, is an error and changes nothing. The panel menu's Select Object… (dialog `actionSelectObject`, Set Selection, with note, caseSensitive, wholeWord, set, action and step) runs this on OK → {set, action, step}, or {recording: true}",
),
(
"ui.action.continue",
"Continue Action",
+15
View File
@@ -53,6 +53,21 @@ pub fn encode(rgba: &[u8], width: u32, height: u32, o: &PngOptions) -> Result<Ve
write(&pixels, width, height, Layout { channels, bits: 8, color_type }, &[], o)
}
/// Encode grey levels with alpha (two bytes a pixel, `width`×`height`, row-major) as a greyscale
/// PNG: grey and alpha (colour type 4), or grey alone (colour type 0) when every pixel is opaque.
pub fn encode_gray(gray_alpha: &[u8], width: u32, height: u32, o: &PngOptions) -> Result<Vec<u8>, String> {
check_size(width, height)?;
if gray_alpha.len() as u64 != u64::from(width) * u64::from(height) * 2 {
return Err("PNG encoding failed: pixel buffer doesn't match the image size".into());
}
let px = gray_alpha.as_chunks::<2>().0;
if px.iter().all(|p| p[1] == 255) {
let levels: Vec<u8> = px.iter().map(|p| p[0]).collect();
return write(&levels, width, height, Layout { channels: 1, bits: 8, color_type: 0 }, &[], o);
}
write(gray_alpha, width, height, Layout { channels: 2, bits: 8, color_type: 4 }, &[], o)
}
/// Encode a palette image as an indexed PNG (colour type 3: `PLTE`, and `tRNS` for its
/// transparent entry) at 1, 2, 4 or 8 bits per pixel, as few as the palette needs.
pub fn encode_indexed(img: &Indexed, o: &PngOptions) -> Result<Vec<u8>, String> {
+35
View File
@@ -67,6 +67,41 @@ fn png_refuses_a_buffer_of_the_wrong_size() {
assert!(encode(&[], 0, 0, &PngOptions::default()).is_err());
}
/// Object › Rasterize's Grayscale and Bitmap images (#6969): a grey PNG of one channel when
/// opaque and grey and alpha otherwise; Bitmap's black and white as a 1-bit TIFF when opaque,
/// else as a grey PNG with alpha. Odd widths keep each row on a byte.
#[test]
fn gray_and_bilevel_images_store_gray() {
let header = |file: &[u8]| (file[24], file[25]);
let gray = |file: &[u8]| image::load_from_memory(file).unwrap().to_luma_alpha8().into_raw();
// 3 × 2, premultiplied: black, red, light grey; white, mid grey, almost black.
let mut img = Rendered {
width: 3,
height: 2,
pixels: [[0, 0, 0], [255, 0, 0], [200; 3], [255; 3], [128; 3], [127; 3]].iter().flat_map(|&[r, g, b]| [r, g, b, 255]).collect(),
};
let levels: Vec<u8> = [0u8, 54, 200, 255, 128, 127].iter().flat_map(|&g| [g, 255]).collect();
let file = img.to_gray_png().unwrap();
assert_eq!(header(&file), (8, 0), "8-bit grey");
assert_eq!(gray(&file), levels);
let (mime, file) = img.to_bilevel(150.0).unwrap();
assert_eq!(mime, "image/tiff");
let mut d = ::tiff::decoder::Decoder::new(std::io::Cursor::new(&file)).unwrap();
assert_eq!(d.colortype().unwrap(), ::tiff::ColorType::Gray(1));
let bw: Vec<u8> = [0u8, 0, 255, 255, 255, 0].iter().flat_map(|&g| [g, 255]).collect();
assert_eq!(gray(&file), bw, "from 128 up is white");
// Half-covered white over the light grey; a clear pixel is white.
img.pixels[8..12].copy_from_slice(&[100, 100, 100, 100]);
img.pixels[20..24].copy_from_slice(&[0; 4]);
let file = img.to_gray_png().unwrap();
assert_eq!(header(&file), (8, 4), "grey and alpha");
assert_eq!(gray(&file), [0, 255, 54, 255, 255, 100, 255, 255, 128, 255, 255, 0]);
let (mime, file) = img.to_bilevel(150.0).unwrap();
assert_eq!((mime, header(&file)), ("image/png", (8, 4)), "no 1-bit grey with alpha");
assert_eq!(gray(&file), [0, 255, 0, 255, 255, 100, 255, 255, 255, 255, 255, 0], "still black and white");
assert!(super::png::encode_gray(&levels[..10], 3, 2, &PngOptions::default()).is_err());
}
/// A 40×30 pt document with a black circle off the pixel grid.
fn circle_doc() -> Document {
let mut d = Document::new(40.0, 30.0);
+60 -4
View File
@@ -4,6 +4,7 @@ use super::{
Affine, AppearanceItem, Arc, BezPath, Document, Effect, FillRule, GeomContext, Node, NodeKind, PathEl, Rect, Renderer, Shape, SymbolBoxes,
effects,
};
use vectorcraft_doc::symbol_overrides::dynamic_art;
fn node_bezpath(n: &Node) -> Option<BezPath> {
match &n.kind {
@@ -203,11 +204,14 @@ fn shape_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
// effects in a symbol's art and a live object's content paint past it.
_ if has_object_fx(n) => {
let symbol = match &n.kind {
NodeKind::SymbolInstance { symbol, .. } => symbols.document().symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone()),
NodeKind::SymbolInstance { symbol, .. } => {
symbols.document().symbols.iter().find(|s| s.name == *symbol).map(|s| dynamic_art(&s.art, n))
}
_ => None,
};
let reshaped = effects::reshape(Some(symbols.document()), n, symbol.as_deref());
let reshaped = effects::reshape(Some(symbols.document()), n, symbol.as_ref());
let b = match &reshaped {
Some(r) if matches!(n.kind, NodeKind::SymbolInstance { .. }) && effects::has_item_geometry(n) => instance_piece_bounds(symbols, r),
Some(r) if matches!(n.kind, NodeKind::Text(_)) || (matches!(n.kind, NodeKind::Image(_)) && !r.clips()) => {
r.children().into_iter().flatten().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, painted_reach(symbols, c)))
}
@@ -230,6 +234,55 @@ fn shape_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
}
}
/// The bounds of `art`, the art [`effects::reshape`] made of a symbol instance whose active fill or
/// stroke has geometry effects ([`effects::has_item_geometry`]): its paths measured with the
/// geometry effects of their fills and strokes ([`visual_bounds`]), the symbol instances in it that
/// took those effects by their own reshaped art in turn, its groups and layers with their own fills
/// and strokes by the art those paint ([`effects::evaluate_container`]), its clip groups by their
/// clipping paths and other objects as [`Node::visual_bounds_with`] measures them. Hidden objects are left out.
/// An instance more than [`vectorcraft_doc::depth::MAX_DEPTH`] instances deep, or whose art would
/// take the objects measured so far past [`effects::SYMBOL_ART_MAX_NODES`], is measured as
/// [`Node::visual_bounds_with`] measures it.
fn instance_piece_bounds(symbols: &SymbolBoxes, art: &Node) -> Option<Rect> {
let mut budget = effects::SYMBOL_ART_MAX_NODES;
pieces_within(symbols, art, 1, &mut budget)
}
/// [`instance_piece_bounds`] of `art`, `depth` instances deep, with `budget` objects left to measure.
fn pieces_within(symbols: &SymbolBoxes, art: &Node, depth: usize, budget: &mut u64) -> Option<Rect> {
if !art.visible {
return None;
}
match &art.kind {
NodeKind::Group { clip: false, .. } | NodeKind::Layer { clip: false, .. } => {
// A group's or layer's own fills and strokes as the art among its members that they paint.
let evaluated = effects::has_container_appearance(art).then(|| effects::evaluate_container(Some(symbols.document()), art)).flatten();
let g = evaluated.as_ref().unwrap_or(art);
if g.clips() {
return g.visual_bounds_with(symbols);
}
g.children().into_iter().flatten().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, pieces_within(symbols, c, depth, budget)))
}
NodeKind::Path { .. } | NodeKind::Compound { .. } if has_fx(art) => visual_bounds(symbols, art),
NodeKind::SymbolInstance { symbol, .. } if depth < vectorcraft_doc::depth::MAX_DEPTH && effects::has_item_geometry(art) => {
let doc = symbols.document();
let reshaped = doc.symbols.iter().find(|s| s.name == *symbol).and_then(|s| {
let count = s.art.count() as u64;
if count > *budget {
return None;
}
*budget -= count;
effects::reshape(Some(doc), art, Some(&dynamic_art(&s.art, art)))
});
match reshaped {
Some(r) => pieces_within(symbols, &r, depth + 1, budget),
None => art.visual_bounds_with(symbols),
}
}
_ => art.visual_bounds_with(symbols),
}
}
/// [`drawn_bounds`] of `n` grown by how far its own raster effects paint past them. With Crop
/// Marks, the [`drawn_bounds`] of its art: the marks and the object, which paints its own raster
/// effects inside the art.
@@ -406,11 +459,14 @@ fn cull_bounds_in(symbols: &SymbolBoxes, n: &Node, after_reshape: bool) -> Optio
// symbol instance's art paint past that art.
_ if has_object_fx(n) => {
let symbol = match &n.kind {
NodeKind::SymbolInstance { symbol, .. } => symbols.document().symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone()),
NodeKind::SymbolInstance { symbol, .. } => {
symbols.document().symbols.iter().find(|s| s.name == *symbol).map(|s| dynamic_art(&s.art, n))
}
_ => None,
};
let reshaped = effects::reshape(Some(symbols.document()), n, symbol.as_deref());
let reshaped = effects::reshape(Some(symbols.document()), n, symbol.as_ref());
let b = match &reshaped {
Some(r) if matches!(n.kind, NodeKind::SymbolInstance { .. }) && effects::has_item_geometry(n) => instance_piece_bounds(symbols, r),
Some(r) if matches!(n.kind, NodeKind::Text(_)) || (matches!(n.kind, NodeKind::Image(_)) && !r.clips()) => {
r.children().into_iter().flatten().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, inner(c)))
}
+52
View File
@@ -356,6 +356,58 @@ impl Rendered {
img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png).map_err(|e| format!("PNG encoding failed: {e}"))?;
Ok(buf)
}
/// Each pixel's grey level and alpha: the Rec. 709 luma of its straight colour, with `bilevel`
/// black below 128 and white from there up. A clear pixel is white: no ink where a fill
/// colour colorizes the image.
fn gray_alpha(&self, bilevel: bool) -> Vec<u8> {
let straight = self.to_straight();
let px = straight.as_chunks::<4>().0;
px.iter()
.flat_map(|&[r, g, b, a]| {
let y = (0.2126 * f32::from(r) + 0.7152 * f32::from(g) + 0.0722 * f32::from(b)).round().clamp(0.0, 255.0) as u8;
let y = if a == 0 || (bilevel && y >= 128) {
255
} else if bilevel {
0
} else {
y
};
[y, a]
})
.collect()
}
/// Encode as a greyscale PNG without metadata (Object › Rasterize's Grayscale colour model):
/// grey levels ([`Self::gray_alpha`]), with alpha only when some pixel isn't opaque.
pub fn to_gray_png(&self) -> Result<Vec<u8>, String> {
encode::png::encode_gray(&self.gray_alpha(false), self.width, self.height, &encode::png::PngOptions::default())
}
/// Encode in black and white (Object › Rasterize's Bitmap colour model): a 1-bit TIFF at `ppi`
/// when every pixel is opaque, else a grey PNG with alpha of only black and white (one-bit grey
/// with alpha is no format readers take). Returns the media type and the file.
pub fn to_bilevel(&self, ppi: f64) -> Result<(&'static str, Vec<u8>), String> {
let gray_alpha = self.gray_alpha(true);
let px = gray_alpha.as_chunks::<2>().0;
if !px.iter().all(|p| p[1] == 255) {
return Ok(("image/png", encode::png::encode_gray(&gray_alpha, self.width, self.height, &encode::png::PngOptions::default())?));
}
// WhiteIsZero: a set bit is black. Each row starts on a byte.
let mut bits = Vec::with_capacity(px.len() / 8 + self.height as usize);
for row in px.chunks(self.width.max(1) as usize) {
bits.extend(row.chunks(8).map(|byte| byte.iter().enumerate().fold(0u8, |b, (i, p)| b | u8::from(p[0] == 0) << (7 - i))));
}
let image = encode::tiff::Image {
width: self.width,
height: self.height,
data: &bits,
photometric: encode::tiff::Photometric::WhiteIsZero,
samples: 1,
bits: 1,
alpha: false,
};
let layout =
encode::tiff::Layout { byte_order: encode::tiff::ByteOrder::Little, compression: encode::tiff::Compression::PackBits, ppi, icc: None };
Ok(("image/tiff", encode::tiff::write(&image, &layout)?))
}
/// Encode as JPEG (flattened on white) at `quality` 1..=100.
pub fn to_jpeg(&self, quality: u8) -> Result<Vec<u8>, String> {
encode::jpeg(self, quality, None)
+1 -1
View File
@@ -232,7 +232,7 @@ id = "Curvature"
group = "Pen"
shown_when = [
"The Curvature tool over empty canvas, or anywhere while drawing a path away from its points.",
"Elsewhere the Curvature tool borrows Pen-family cursors: PenAdd over a segment, Move over an anchor, PenContinue at an open end, PenConvert with Alt, PenClose over the first point of the path being drawn.",
"Elsewhere the Curvature tool borrows Pen-family cursors: PenAdd over a segment, Move over an anchor, PenContinue at an open end of a selected path (Alt held or not), PenConvert with Alt over any other anchor or a point of the path being drawn, PenClose over the first point of the path being drawn.",
]
hotspot = "The nib's tip."
look = "The Pen nib with a curve badge."
+25 -15
View File
@@ -1,6 +1,7 @@
//! The Curvature tool (Shift+~).
//!
//! Each click adds a point and the path curves smoothly through all points. Alt-click or
//! Each click adds a point and the path curves smoothly through all points; an Alt-click adds a
//! corner point, the first point included. Alt-click or
//! double-click a point toggles it between smooth and corner; drag a point to move it; click the
//! first point to close; Backspace/Delete removes the last touched point; Esc/Enter ends the path.
//! A rubber band shows the curve to the cursor (Enable Rubber Band for Curvature Tool). Each point
@@ -8,8 +9,10 @@
//!
//! It edits any selected path too, whatever drew it, keeping its shape except where edited
//! (#798). A click on one of its points makes that the current point (direct-selected); a drag
//! moves it, re-curving only the two segments at it ([`curvature_move`]); Alt-click or
//! double-click toggles it between smooth and corner and Backspace/Delete removes it, the curve
//! moves it, re-curving only the two segments at it ([`curvature_move`]); double-click, or
//! Alt-click on any point but an end of an open path, toggles it between smooth and corner. An
//! Alt-click on an end of an open path makes it the current point without changing it, so the
//! next click goes on from it. Backspace/Delete removes the current point, the curve
//! where it was then following the remaining points ([`curvature_remove`]). A click on
//! a segment adds a smooth point there without changing the shape ([`curvature_insert`]), and a
//! drag moves the new point at once. While the current point is an end of an open path, clicks go
@@ -429,12 +432,16 @@ fn delete_point((id, si, ai): (NodeId, usize, usize)) -> Action {
fn toggle(cx: &ToolContext, (id, si, ai): (NodeId, usize, usize)) -> Option<Action> {
let sp = subpath(cx, id, si)?;
let a = sp.anchors.get(ai)?;
let open_end = !sp.closed && (ai == 0 || ai + 1 == sp.anchors.len());
let smooth = if open_end { a.has_in() || a.has_out() } else { a.kind == AnchorKind::Smooth };
let smooth = if open_end(sp, ai) { a.has_in() || a.has_out() } else { a.kind == AnchorKind::Smooth };
let to = if smooth { "corner" } else { "smooth" };
Some(Action::Exec("path.convertAnchor".into(), json!({"id": id.0, "subpath": si, "anchor": ai, "to": to})))
}
/// Whether anchor `ai` is an end of the open subpath `sp`.
fn open_end(sp: &SubPath, ai: usize) -> bool {
!sp.closed && (ai == 0 || ai + 1 == sp.anchors.len())
}
/// Snap targets for dragging anchors `anchors` of subpath `si` of `id` (the rest of the path
/// pulls).
fn hold_targets(snap: &mut DrawSnap, cx: &ToolContext, id: NodeId, si: usize, anchors: &[usize]) {
@@ -507,12 +514,13 @@ impl CurvatureTool {
}
/// A press on a selected path the tool isn't drawing: on the other end of the open path going
/// on (it closes), on a point, anywhere while an end is current (the path goes on), or on a
/// on (without Alt it closes), on a point, anywhere while an end is current (the path goes on), or on a
/// segment. None elsewhere: a new path starts.
fn press_selected(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Option<Vec<Action>> {
let (p, tol) = (ev.pos, point_tol(cx));
let end = current_end(cx);
if let Some(e) = end.filter(|e| e.anchors >= 3 && p.distance(e.other) <= tol) {
// An Alt-click on the other end makes it current instead (below).
if let Some(e) = end.filter(|e| !ev.mods.alt && e.anchors >= 3 && p.distance(e.other) <= tol) {
let how = self.how(&e);
self.placed = None;
let v = json!({"id": e.id.0, "subpath": e.si, "op": "close", "end": e.name(), "from": how.name()});
@@ -520,11 +528,14 @@ impl CurvatureTool {
}
if let Some(hit) = anchor_in(cx, editable_paths(cx), p, tol) {
let mut out = vec![select_point(hit)];
let (id, si, ai) = hit;
if ev.mods.alt {
out.extend(toggle(cx, hit));
// On an end of an open path the path goes on from it; any other point toggles.
if !subpath(cx, id, si).is_some_and(|sp| open_end(sp, ai)) {
out.extend(toggle(cx, hit));
}
return Some(out);
}
let (id, si, ai) = hit;
hold_targets(&mut self.snap, cx, id, si, &[ai]);
self.drag = Some((Grab::Anchor(id, si, ai), p, false));
return Some(out);
@@ -596,7 +607,7 @@ impl Tool for CurvatureTool {
// Start a new path.
self.reset();
let p = self.snap.press(cx, p, &[], None);
self.pts.push((p, false));
self.pts.push((p, ev.mods.alt));
self.current = Some(0);
vec![Action::Exec("path.curvature".into(), self.params()), Action::Notify("created".into())]
}
@@ -762,15 +773,14 @@ impl Tool for CurvatureTool {
}
let tol = point_tol(cx);
let end = current_end(cx);
if end.is_some_and(|e| e.anchors >= 3 && p.distance(e.other) <= tol) {
if !m.alt && end.is_some_and(|e| e.anchors >= 3 && p.distance(e.other) <= tol) {
return Cursor::PenClose;
}
if let Some((id, si, ai)) = anchor_in(cx, editable_paths(cx), p, tol) {
let open_end = subpath(cx, id, si).is_some_and(|sp| !sp.closed && (ai == 0 || ai + 1 == sp.anchors.len()));
return if m.alt {
Cursor::PenConvert
} else if open_end {
return if subpath(cx, id, si).is_some_and(|sp| open_end(sp, ai)) {
Cursor::PenContinue
} else if m.alt {
Cursor::PenConvert
} else {
Cursor::Move
};
+55 -3
View File
@@ -300,17 +300,69 @@ fn curvature_remove_keeps_a_corner_neighbors_handle_direction() {
assert_eq!((sp.anchors[1].h_in, sp.anchors[1].h_out), (corner.h_in, corner.p));
}
/// A curved end counts as smooth for the toggle: Alt-click turns it into a corner.
/// A curved end counts as smooth for the toggle: a double-click turns it into a corner.
#[test]
fn alt_click_on_a_curved_end_makes_it_a_corner() {
fn double_click_on_a_curved_end_makes_it_a_corner() {
let sp = curvature_spline(&[(pt(100.0, 300.0), false), (pt(200.0, 200.0), false), (pt(300.0, 300.0), false)], false);
let (d, id) = doc_with(sp);
let (s, p) = (selected(id, None), paint());
let cx = quiet(&d, &s, &p);
let a = press(&mut CurvatureTool::default(), &cx, 100.0, 300.0, true);
let a = CurvatureTool::default().pointer(&cx, &PointerEvent::new(PointerKind::DoubleClick, 100.0, 300.0));
assert!(a.iter().any(|a| matches!(a, Action::Exec(c, v) if c == "path.convertAnchor" && v["anchor"] == 0 && v["to"] == "corner")), "{a:?}");
}
/// An Alt-click on either end of a selected open path makes it the current point without
/// converting it, and the next click goes on from it.
#[test]
fn alt_click_on_an_open_end_goes_on_from_it() {
let (d, id) = doc_with(wave());
let (s, p) = (selected(id, None), paint());
let cx = quiet(&d, &s, &p);
let mut t = CurvatureTool::default();
let alt = Mods { alt: true, ..Mods::default() };
for (x, y, ai) in [(450.0, 60.0, 4), (0.0, 100.0, 0)] {
assert_eq!(t.cursor(&cx, pt(x, y), alt), Cursor::PenContinue);
let a = press(&mut t, &cx, x, y, true);
assert_eq!(a, vec![select_point((id, 0, ai))]);
assert!(!t.busy(), "an Alt-click does not grab the point");
}
// As the engine does it: the Alt-clicked end is current, and the next click goes on from it.
let s = selected(id, Some(0));
let a = press(&mut t, &quiet(&d, &s, &p), -50.0, 150.0, false);
assert!(matches!(edit_op(&a[0]), Some(("extend", v)) if v["end"] == "start" && v["from"] == "keep"), "{a:?}");
// With the start current, an Alt-click on the other end makes that end current; a plain click
// there closes the path.
let cx = quiet(&d, &s, &p);
assert_eq!(t.cursor(&cx, pt(450.0, 60.0), alt), Cursor::PenContinue);
assert_eq!(press(&mut t, &cx, 450.0, 60.0, true), vec![select_point((id, 0, 4))]);
assert_eq!(t.cursor(&cx, pt(450.0, 60.0), Mods::default()), Cursor::PenClose);
assert!(matches!(edit_op(&press(&mut t, &cx, 450.0, 60.0, false)[0]), Some(("close", v)) if v["end"] == "start"));
// A closed path has no ends: an Alt-click on its first point converts it.
let mut closed = wave();
closed.closed = true;
let (d, id) = doc_with(closed);
let s = selected(id, None);
let cx = quiet(&d, &s, &p);
assert_eq!(t.cursor(&cx, pt(0.0, 100.0), alt), Cursor::PenConvert);
let a = press(&mut t, &cx, 0.0, 100.0, true);
assert!(a.iter().any(|a| matches!(a, Action::Exec(c, v) if c == "path.convertAnchor" && v["anchor"] == 0)), "{a:?}");
}
/// An Alt-click that starts a path places a corner point, as later Alt-clicks do; a plain click
/// places a smooth one.
#[test]
fn alt_click_starts_a_path_with_a_corner() {
let d = Document::new(800.0, 600.0);
let (s, p) = (Selection::default(), paint());
let cx = quiet(&d, &s, &p);
for alt in [true, false] {
let mut t = CurvatureTool::default();
let a = press(&mut t, &cx, 100.0, 120.0, alt);
assert!(matches!(&a[0], Action::Exec(c, v) if c == "path.curvature" && v["points"][0]["corner"] == alt), "{a:?}");
assert_eq!(t.pts, vec![(pt(100.0, 120.0), alt)]);
}
}
/// A document with one path, `sp`.
fn doc_with(sp: SubPath) -> (Document, NodeId) {
let mut d = Document::new(800.0, 600.0);
+2
View File
@@ -11,6 +11,7 @@ pub mod action_batch;
pub mod action_insert_menu_item;
pub mod action_new;
pub mod action_playback;
pub mod action_select_object;
pub mod action_set_options;
pub mod action_stop;
pub mod artboard_options;
@@ -398,6 +399,7 @@ registry! {
ActionStopRecord: [action_stop::RECORD_KIND] => action_stop::RECORD_SPEC,
ActionStopMessage: [action_stop::MESSAGE_KIND] => action_stop::MESSAGE_SPEC,
ActionInsertMenuItem: [action_insert_menu_item::KIND] => action_insert_menu_item::SPEC,
ActionSelectObject: [action_select_object::KIND] => action_select_object::SPEC,
SliceOptions: [slices::OPTIONS] => slices::OPTIONS_SPEC,
DivideSlices: [slices::DIVIDE] => slices::DIVIDE_SPEC,
EpsOptions: [eps_options::KIND] => eps_options::SPEC,
@@ -0,0 +1,63 @@
//! Actions panel menu › Select Object… (`ui.action.insertSelectObject`): Set Selection, which adds a
//! step that selects the objects whose Attributes note matches the note typed.
//!
//! Its fields: `note`, `caseSensitive`, `wholeWord`, and `set`, `action` and `step`, the place of the
//! new step (no `set` and `action`: the recording in progress). OK is off while the note is empty.
//! An invalid note or place is an error, and the dialog stays open.
use serde_json::{Value, json};
use super::DialogSpec;
use crate::state::Dialog;
use crate::{VectorcraftApp, widgets};
/// The dialog kind of Set Selection.
pub const KIND: &str = "actionSelectObject";
/// The width of the Note label column.
const LABEL_W: f32 = 70.0;
pub(super) const SPEC: DialogSpec = DialogSpec {
heading: |_| tl!("Set Selection").into(),
body,
confirm,
ok_enabled: Some(|_, d| !d.str("note").trim().is_empty()),
min_width: 340.0,
..DialogSpec::FORM
};
/// Opens Set Selection for action `target` as (set, action), after `step`, else for the recording
/// in progress.
pub fn open(app: &mut VectorcraftApp, target: Option<(usize, usize)>, step: Option<usize>) {
let mut fields = json!({"note": "", "caseSensitive": false, "wholeWord": false});
if let Some((set, action)) = target {
fields["set"] = json!(set);
fields["action"] = json!(action);
if let Some(step) = step {
fields["step"] = json!(step);
}
}
app.ui.dialog = Some(Dialog::new(KIND, fields));
}
fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
widgets::label_row(ui, "Note:", LABEL_W, |ui| {
super::form::text(ui, d, "note", 240.0);
});
super::form::check(ui, d, "caseSensitive", "Case Sensitive");
super::form::check(ui, d, "wholeWord", "Whole Word");
false
}
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
// The fields go as they are, so a field of another type is an error rather than a default.
let mut params = json!({});
for key in ["note", "caseSensitive", "wholeWord", "set", "action", "step"] {
if let Some(value) = d.fields.get(key) {
params[key] = value.clone();
}
}
let r = app.run(crate::panels::actions::INSERT_SELECT_OBJECT, params)?;
app.ui.dialog = None;
Ok(r)
}
+13 -1
View File
@@ -151,11 +151,14 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
r
}
/// Say in the status bar when `file.new`'s result `r` reports a profile it could not read.
/// Say in the status bar when `file.new`'s result `r` reports a profile it could not read, or
/// color profiles its file carries that can't be used.
fn report_profile(app: &mut VectorcraftApp, r: &Value) {
if let Some(e) = r.get("profileError") {
let (name, reason) = (e["profile"].as_str().unwrap_or_default(), e["reason"].as_str().unwrap_or_default());
app.status(format!("The {name} profile could not be read, so the new document has the default settings: {reason}"));
} else if let Some(w) = r["warnings"].as_array().filter(|w| !w.is_empty()) {
app.status(w.iter().filter_map(Value::as_str).collect::<Vec<_>>().join("; "));
}
}
@@ -1592,6 +1595,15 @@ mod tests {
}
}
/// A color profile in the profile's file that can't be used, which `file.new` reports, shows in
/// the status bar.
#[test]
fn profile_warnings_show_in_the_status_bar() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
super::report_profile(&mut app, &json!({"index": 0, "warnings": ["the color profile X can't be used"]}));
assert_eq!(app.ui.status, "the color profile X can't be used");
}
/// Manage Profiles lists a row per profile; Reset is off until a profile has been edited, Escape
/// closes only the pop-up, and Edit opens the profile as a document and closes New Document.
#[test]
@@ -1,4 +1,5 @@
//! The Artboard tool's canvas menu › Rename: a name for the selected artboards. OK runs
//! The Artboard tool's canvas menu › Rename, and the Artboards panel's row menu › Rename: a name
//! for the selected artboards. OK runs
//! `artboard.rename` as one undo step; with several artboards the name is numbered.
//!
//! Fields: `name` (the artboard's name with one artboard, empty with several) and `indices` (the
+10
View File
@@ -5982,6 +5982,16 @@ axis Y Axis Y ос
@msg Batch cannot play an action with a stop Пакет не може да изпълни действие със спиране
Insert Menu Item… Вмъкване на елемент от меню…
Insert Menu Item Вмъкване на елемент от меню
Select Object… Избиране на обект…
Select Object Избиране на обект
Set Selection Задаване на селекция
Case Sensitive Различаване на главни и малки букви
Whole Word Цяла дума
Select Object: {note} Избиране на обект: {note}
Select by Note Избиране по забележка
@msg the note is empty забележката е празна
@msg the note is longer than 1,024 bytes забележката е по-дълга от 1024 байта
@msg no object that can be selected has a note matching “{query}” няма обект, който може да бъде избран, със забележка, съответстваща на „{query}“
Menu Item: Елемент от меню:
No menu item matches. Няма съвпадащ елемент от меню.
Several menu items match. Choose one from the list. Съвпадат няколко елемента от меню. Изберете един от списъка.
Can't render this file because it contains an unexpected character in line 2 and column 73.
+10
View File
@@ -6024,6 +6024,16 @@ axis Y Axis Y osa
@msg Batch cannot play an action with a stop Paketna obrada ne može reprodukovati akciju sa zaustavljanjem
Insert Menu Item… Umetni stavku menija…
Insert Menu Item Umetni stavku menija
Select Object… Odaberi objekt…
Select Object Odaberi objekt
Set Selection Postavi odabir
Case Sensitive Razlikuj velika i mala slova
Whole Word Cijela riječ
Select Object: {note} Odaberi objekt: {note}
Select by Note Odaberi po napomeni
@msg the note is empty napomena je prazna
@msg the note is longer than 1,024 bytes napomena je duža od 1.024 bajta
@msg no object that can be selected has a note matching “{query}” nijedan objekt koji se može odabrati nema napomenu koja odgovara „{query}”
Menu Item: Stavka menija:
No menu item matches. Nijedna stavka menija ne odgovara.
Several menu items match. Choose one from the list. Odgovara više stavki menija. Izaberite jednu sa liste.
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -4037,6 +4037,16 @@ digits Malayalam Malaiàlam
@msg Batch cannot play an action with a stop Lot no pot reproduir una acció amb una aturada
Insert Menu Item… Insereix un element de menú…
Insert Menu Item Insereix un element de menú
Select Object… Selecciona un objecte…
Select Object Selecciona un objecte
Set Selection Defineix la selecció
Case Sensitive Distingeix majúscules i minúscules
Whole Word Paraula sencera
Select Object: {note} Selecciona un objecte: {note}
Select by Note Selecciona per nota
@msg the note is empty la nota és buida
@msg the note is longer than 1,024 bytes la nota té més de 1.024 bytes
@msg no object that can be selected has a note matching “{query}” cap objecte que es pugui seleccionar té una nota que coincideixi amb “{query}”
Menu Item: Element de menú:
No menu item matches. Cap element de menú no coincideix.
Several menu items match. Choose one from the list. Coincideixen diversos elements de menú. Trieu-ne un de la llista.
@@ -6187,6 +6197,7 @@ digits Malayalam Malaiàlam
@msg unknown parameter `{path}`; it takes: {keys} paràmetre desconegut `{path}`; admet: {keys}
@msg missing `{path}` falta `{path}`
@msg `{path}` must be {expected}, not {got} `{path}` ha de ser {expected}, no {got}
@msg `{path}` is nested too deeply `{path}` està imbricat massa profundament
@msg unknown preference `{key}` preferència desconeguda `{key}`
@msg Preferences imported. S’han importat les preferències.
@msg not a preferences file: {e} no és un fitxer de preferències: {e}
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -5878,6 +5878,7 @@ mark-style Japanese Japonské
@msg unknown parameter `{path}`; it takes: {keys} neznámý parametr `{path}`; přijímá: {keys}
@msg missing `{path}` chybí `{path}`
@msg `{path}` must be {expected}, not {got} `{path}` musí být {expected}, nikoli {got}
@msg `{path}` is nested too deeply `{path}` je vnořený příliš hluboko
Load EPS into Clipboard Načíst EPS do schránky
@msg an image of {w} × {h} pixels is too large to draw obraz o velikosti {w} × {h} obrazových bodů je příliš velký na vykreslení
@msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: není obraz PNG, JPEG, WebP, TIFF ani BMP
@@ -6423,6 +6424,16 @@ axis Y Axis osa Y
@msg Batch cannot play an action with a stop Dávka nemůže přehrát akci se zastavením
Insert Menu Item… Vložit položku nabídky…
Insert Menu Item Vložit položku nabídky
Select Object… Vybrat objekt…
Select Object Vybrat objekt
Set Selection Nastavit výběr
Case Sensitive Rozlišovat velká a malá písmena
Whole Word Celé slovo
Select Object: {note} Vybrat objekt: {note}
Select by Note Vybrat podle poznámky
@msg the note is empty poznámka je prázdná
@msg the note is longer than 1,024 bytes poznámka je delší než 1 024 bajtů
@msg no object that can be selected has a note matching “{query}” žádný objekt, který lze vybrat, nemá poznámku odpovídající „{query}“
Menu Item: Položka nabídky:
No menu item matches. Žádná položka nabídky neodpovídá.
Several menu items match. Choose one from the list. Odpovídá několik položek nabídky. Vyberte jednu ze seznamu.
Can't render this file because it contains an unexpected character in line 2 and column 73.
+10
View File
@@ -5999,6 +5999,16 @@ axis Y Axis Y-akse
@msg Batch cannot play an action with a stop Batch kan ikke afspille en handling med et stop
Insert Menu Item… Indsæt menupunkt…
Insert Menu Item Indsæt menupunkt
Select Object… Markér objekt…
Select Object Markér objekt
Set Selection Angiv markering
Case Sensitive Forskel på store og små bogstaver
Whole Word Helt ord
Select Object: {note} Markér objekt: {note}
Select by Note Markér efter note
@msg the note is empty noten er tom
@msg the note is longer than 1,024 bytes noten er længere end 1.024 byte
@msg no object that can be selected has a note matching “{query}” intet objekt, der kan markeres, har en note, der matcher »{query}«
Menu Item: Menupunkt:
No menu item matches. Intet menupunkt passer.
Several menu items match. Choose one from the list. Flere menupunkter passer. Vælg et fra listen.
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -5453,6 +5453,7 @@ lighting Light Licht
@msg unknown parameter `{path}`; it takes: {keys} unbekannter Parameter `{path}`; akzeptiert werden: {keys}
@msg missing `{path}` `{path}` fehlt
@msg `{path}` must be {expected}, not {got} `{path}` muss {expected} sein, nicht {got}
@msg `{path}` is nested too deeply `{path}` ist zu tief verschachtelt
Load EPS into Clipboard EPS in die Zwischenablage laden
@msg an image of {w} × {h} pixels is too large to draw ein Bild mit {w} × {h} Pixeln ist zu groß, um gezeichnet zu werden
@msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: kein PNG-, JPEG-, WebP-, TIFF- oder BMP-Bild
@@ -6014,6 +6015,16 @@ axis Y Axis Y-Achse
@msg Batch cannot play an action with a stop Die Stapelverarbeitung kann keine Aktion mit einem Stopp abspielen
Insert Menu Item… Menübefehl einfügen…
Insert Menu Item Menübefehl einfügen
Select Object… Objekt auswählen…
Select Object Objekt auswählen
Set Selection Auswahl festlegen
Case Sensitive Groß-/Kleinschreibung beachten
Whole Word Ganzes Wort
Select Object: {note} Objekt auswählen: {note}
Select by Note Nach Notiz auswählen
@msg the note is empty die Notiz ist leer
@msg the note is longer than 1,024 bytes die Notiz ist länger als 1.024 Byte
@msg no object that can be selected has a note matching “{query}” kein auswählbares Objekt hat eine Notiz, die „{query}“ entspricht
Menu Item: Menübefehl:
No menu item matches. Kein Menübefehl passt.
Several menu items match. Choose one from the list. Mehrere Menübefehle passen. Wählen Sie einen aus der Liste.
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -5237,6 +5237,7 @@ digits Malayalam Μαλαγιάλαμ
@msg unknown parameter `{path}`; it takes: {keys} άγνωστη παράμετρος `{path}`· δέχεται: {keys}
@msg missing `{path}` λείπει το `{path}`
@msg `{path}` must be {expected}, not {got} Το `{path}` πρέπει να είναι {expected}, όχι {got}
@msg `{path}` is nested too deeply Το `{path}` έχει υπερβολικά βαθιά ένθεση
1 Layer 1 επίπεδο
1 bit 1 bit
16 Colors 16 χρώματα
@@ -6374,6 +6375,16 @@ axis Y Axis Άξονας Y
@msg Batch cannot play an action with a stop Η μαζική επεξεργασία δεν μπορεί να αναπαράγει ενέργεια με διακοπή
Insert Menu Item… Εισαγωγή στοιχείου μενού…
Insert Menu Item Εισαγωγή στοιχείου μενού
Select Object… Επιλογή αντικειμένου…
Select Object Επιλογή αντικειμένου
Set Selection Ορισμός επιλογής
Case Sensitive Διάκριση πεζών-κεφαλαίων
Whole Word Ολόκληρη λέξη
Select Object: {note} Επιλογή αντικειμένου: {note}
Select by Note Επιλογή βάσει σημείωσης
@msg the note is empty η σημείωση είναι κενή
@msg the note is longer than 1,024 bytes η σημείωση υπερβαίνει τα 1.024 byte
@msg no object that can be selected has a note matching “{query}” κανένα αντικείμενο που μπορεί να επιλεγεί δεν έχει σημείωση που να ταιριάζει με «{query}»
Menu Item: Στοιχείο μενού:
No menu item matches. Κανένα στοιχείο μενού δεν ταιριάζει.
Several menu items match. Choose one from the list. Ταιριάζουν πολλά στοιχεία μενού. Επιλέξτε ένα από τη λίστα.
Can't render this file because it is too large.
+11
View File
@@ -5819,6 +5819,7 @@ mark-style Japanese Japonesas
@msg unknown parameter `{path}`; it takes: {keys} parámetro desconocido `{path}`; acepta: {keys}
@msg missing `{path}` falta `{path}`
@msg `{path}` must be {expected}, not {got} `{path}` debe ser {expected}, no {got}
@msg `{path}` is nested too deeply `{path}` está anidado a demasiada profundidad
Load EPS into Clipboard Cargar EPS en el portapapeles
@msg an image of {w} × {h} pixels is too large to draw una imagen de {w} × {h} píxeles es demasiado grande para dibujarse
@msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: no es una imagen PNG, JPEG, WebP, TIFF ni BMP
@@ -6365,6 +6366,16 @@ axis Y Axis Eje Y
@msg Batch cannot play an action with a stop Lote no puede reproducir una acción con una parada
Insert Menu Item… Insertar elemento de menú…
Insert Menu Item Insertar elemento de menú
Select Object… Seleccionar objeto…
Select Object Seleccionar objeto
Set Selection Definir selección
Case Sensitive Distinguir mayúsculas y minúsculas
Whole Word Palabra completa
Select Object: {note} Seleccionar objeto: {note}
Select by Note Seleccionar por nota
@msg the note is empty la nota está vacía
@msg the note is longer than 1,024 bytes la nota ocupa más de 1024 bytes
@msg no object that can be selected has a note matching “{query}” ningún objeto que se pueda seleccionar tiene una nota que coincida con “{query}”
Menu Item: Elemento de menú:
No menu item matches. Ningún elemento de menú coincide.
Several menu items match. Choose one from the list. Coinciden varios elementos de menú. Elija uno de la lista.
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -5760,6 +5760,7 @@ mark-style Japanese Japanilainen
@msg unknown parameter `{path}`; it takes: {keys} tuntematon parametri `{path}`; parametrit: {keys}
@msg missing `{path}` `{path}` puuttuu
@msg `{path}` must be {expected}, not {got} `{path}` on oltava {expected}, ei {got}
@msg `{path}` is nested too deeply `{path}` on liian syvällä sisäkkäin
Load EPS into Clipboard Lataa EPS leikepöydälle
@msg an image of {w} × {h} pixels is too large to draw kuva, jonka koko on {w} × {h} pikseliä, on liian suuri piirrettäväksi
@msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: ei ole PNG-, JPEG-, WebP-, TIFF- tai BMP-kuva
@@ -6453,6 +6454,16 @@ axis Y Axis Y-akseli
@msg Batch cannot play an action with a stop Erä ei voi toistaa toimintoa, jossa on pysäytys
Insert Menu Item… Lisää valikkokohta…
Insert Menu Item Lisää valikkokohta
Select Object… Valitse objekti…
Select Object Valitse objekti
Set Selection Määritä valinta
Case Sensitive Kirjainkoko merkitsevä
Whole Word Koko sana
Select Object: {note} Valitse objekti: {note}
Select by Note Valitse huomautuksen mukaan
@msg the note is empty huomautus on tyhjä
@msg the note is longer than 1,024 bytes huomautus on pidempi kuin 1 024 tavua
@msg no object that can be selected has a note matching “{query}” millään valittavissa olevalla objektilla ei ole huomautusta, joka vastaa hakua ”{query}”
Menu Item: Valikkokohta:
No menu item matches. Mikään valikkokohta ei vastaa hakua.
Several menu items match. Choose one from the list. Useampi valikkokohta vastaa hakua. Valitse yksi luettelosta.
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -5448,6 +5448,7 @@ lighting Light Lumière
@msg unknown parameter `{path}`; it takes: {keys} paramètre inconnu `{path}` ; il accepte : {keys}
@msg missing `{path}` `{path}` manquant
@msg `{path}` must be {expected}, not {got} `{path}` doit être {expected}, pas {got}
@msg `{path}` is nested too deeply `{path}` est imbriqué trop profondément
1 Layer 1 calque
1 bit 1 bit
16 Colors 16 couleurs
@@ -6418,6 +6419,16 @@ axis Y Axis Axe Y
@msg Batch cannot play an action with a stop Le traitement par lots ne peut pas exécuter un script comportant un arrêt
Insert Menu Item… Insérer une commande de menu…
Insert Menu Item Insérer une commande de menu
Select Object… Sélectionner un objet…
Select Object Sélectionner un objet
Set Selection Définir la sélection
Case Sensitive Respecter la casse
Whole Word Mot entier
Select Object: {note} Sélectionner un objet : {note}
Select by Note Sélectionner par remarque
@msg the note is empty la remarque est vide
@msg the note is longer than 1,024 bytes la remarque dépasse 1 024 octets
@msg no object that can be selected has a note matching “{query}” aucun objet sélectionnable n’a de remarque correspondant à « {query} »
Menu Item: Commande de menu :
No menu item matches. Aucune commande de menu ne correspond.
Several menu items match. Choose one from the list. Plusieurs commandes de menu correspondent. Choisissez-en une dans la liste.
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -3466,6 +3466,16 @@ antiAlias Sharp शार्प
@msg Batch cannot play an action with a stop बैच स्टॉप वाला एक्शन नहीं चला सकता
Insert Menu Item… मेनू आइटम जोड़ें…
Insert Menu Item मेनू आइटम जोड़ें
Select Object… ऑब्जेक्ट सिलेक्ट करें…
Select Object ऑब्जेक्ट सिलेक्ट करें
Set Selection सिलेक्शन सेट करें
Case Sensitive केस सेंसिटिव
Whole Word पूरा शब्द
Select Object: {note} ऑब्जेक्ट सिलेक्ट करें: {note}
Select by Note नोट द्वारा चुनें
@msg the note is empty नोट खाली है
@msg the note is longer than 1,024 bytes नोट 1,024 बाइट से लंबा है
@msg no object that can be selected has a note matching “{query}” सिलेक्ट किए जा सकने वाले किसी भी ऑब्जेक्ट का नोट “{query}” से मेल नहीं खाता
Menu Item: मेनू आइटम:
No menu item matches. कोई मेनू आइटम मेल नहीं खाता।
Several menu items match. Choose one from the list. कई मेनू आइटम मेल खाते हैं। सूची से एक चुनें।
@@ -8228,6 +8238,7 @@ antiAlias Sharp शार्प
@msg unknown parameter `{path}`; it takes: {keys} अज्ञात पैरामीटर `{path}`; यह लेता है: {keys}
@msg missing `{path}` लापता `{path}`
@msg `{path}` must be {expected}, not {got} `{path}` {expected} होना चाहिए, {got} नहीं
@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}` (one, मानक या wedges)
@msg unknown piePosition `{v}` (even, ratio or stacked) अज्ञात piePosition `{v}` (यहां तक कि अनुपात या स्टैक्ड)
Can't render this file because it is too large.
+11
View File
@@ -5861,6 +5861,7 @@ imageTrace Outline Körvonal
@msg unknown parameter `{path}`; it takes: {keys} ismeretlen paraméter: `{path}`; ezeket fogadja el: {keys}
@msg missing `{path}` hiányzik: `{path}`
@msg `{path}` must be {expected}, not {got} `{path}`: {expected} kell, nem ez: {got}
@msg `{path}` is nested too deeply `{path}` túl mélyen van egymásba ágyazva
Load EPS into Clipboard EPS betöltése a vágólapra
@msg an image of {w} × {h} pixels is too large to draw egy {w} × {h} pixeles kép túl nagy a kirajzoláshoz
@msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: nem PNG, JPEG, WebP, TIFF vagy BMP kép
@@ -6406,6 +6407,16 @@ axis Y Axis Y tengely
@msg Batch cannot play an action with a stop A kötegelt feldolgozás nem játszhat le megállást tartalmazó műveletet
Insert Menu Item… Menüelem beszúrása…
Insert Menu Item Menüelem beszúrása
Select Object… Objektum kijelölése…
Select Object Objektum kijelölése
Set Selection Kijelölés beállítása
Case Sensitive Kis- és nagybetűk megkülönböztetése
Whole Word Teljes szó
Select Object: {note} Objektum kijelölése: {note}
Select by Note Kijelölés megjegyzés szerint
@msg the note is empty a megjegyzés üres
@msg the note is longer than 1,024 bytes a megjegyzés hosszabb 1024 bájtnál
@msg no object that can be selected has a note matching “{query}” egyetlen kijelölhető objektum megjegyzése sem egyezik ezzel: „{query}”
Menu Item: Menüelem:
No menu item matches. Egy menüelem sem egyezik.
Several menu items match. Choose one from the list. Több menüelem is egyezik. Válasszon egyet a listából.
Can't render this file because it contains an unexpected character in line 2 and column 73.
+10
View File
@@ -5999,6 +5999,16 @@ axis Y Axis Sumbu Y
@msg Batch cannot play an action with a stop Batch tidak dapat memutar tindakan dengan titik henti
Insert Menu Item… Sisipkan item menu…
Insert Menu Item Sisipkan item menu
Select Object… Pilih objek…
Select Object Pilih objek
Set Selection Atur seleksi
Case Sensitive Peka huruf besar/kecil
Whole Word Seluruh kata
Select Object: {note} Pilih objek: {note}
Select by Note Pilih berdasarkan catatan
@msg the note is empty catatan kosong
@msg the note is longer than 1,024 bytes catatan lebih panjang dari 1.024 byte
@msg no object that can be selected has a note matching “{query}” tidak ada objek yang dapat dipilih dengan catatan yang cocok dengan “{query}”
Menu Item: Item menu:
No menu item matches. Tidak ada item menu yang cocok.
Several menu items match. Choose one from the list. Beberapa item menu cocok. Pilih salah satu dari daftar.
Can't render this file because it contains an unexpected character in line 2406 and column 5.
+11
View File
@@ -5495,6 +5495,7 @@ lighting Light Luce
@msg unknown parameter `{path}`; it takes: {keys} parametro sconosciuto `{path}`; accetta: {keys}
@msg missing `{path}` manca `{path}`
@msg `{path}` must be {expected}, not {got} `{path}` deve essere {expected}, non {got}
@msg `{path}` is nested too deeply `{path}` è annidato troppo in profondità
Load EPS into Clipboard Carica EPS negli appunti
@msg an image of {w} × {h} pixels is too large to draw un'immagine di {w} × {h} pixel è troppo grande per essere disegnata
@msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: non è un'immagine PNG, JPEG, WebP, TIFF o BMP
@@ -6048,6 +6049,16 @@ axis Y Axis Asse Y
@msg Batch cannot play an action with a stop L'elaborazione batch non può eseguire un'azione con un'interruzione
Insert Menu Item… Inserisci voce di menu…
Insert Menu Item Inserisci voce di menu
Select Object… Seleziona oggetto…
Select Object Seleziona oggetto
Set Selection Imposta selezione
Case Sensitive Maiuscole/minuscole
Whole Word Parola intera
Select Object: {note} Seleziona oggetto: {note}
Select by Note Seleziona per nota
@msg the note is empty la nota è vuota
@msg the note is longer than 1,024 bytes la nota supera 1.024 byte
@msg no object that can be selected has a note matching “{query}” nessun oggetto selezionabile ha una nota corrispondente a “{query}”
Menu Item: Voce di menu:
No menu item matches. Nessuna voce di menu corrisponde.
Several menu items match. Choose one from the list. Corrispondono più voci di menu. Sceglierne una dall’elenco.
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -5537,6 +5537,7 @@ lighting Light ライト
@msg unknown parameter `{path}`; it takes: {keys} `{path}` は不明なパラメーターです。使えるパラメーター: {keys}
@msg missing `{path}` `{path}` が指定されていません
@msg `{path}` must be {expected}, not {got} `{path}` は {expected} にしてください ({got} は使えません)
@msg `{path}` is nested too deeply `{path}` の入れ子が深すぎます
Load EPS into Clipboard EPS をクリップボードに読み込む
@msg an image of {w} × {h} pixels is too large to draw {w} × {h} ピクセルの画像は大きすぎて描画できません
@msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: PNG、JPEG、WebP、TIFF、BMP のいずれの画像でもありません
@@ -6113,6 +6114,16 @@ axis Y Axis Y 軸
@msg Batch cannot play an action with a stop バッチでは停止を含むアクションを再生できません
Insert Menu Item… メニュー項目を挿入…
Insert Menu Item メニュー項目を挿入
Select Object… オブジェクトを選択…
Select Object オブジェクトを選択
Set Selection 選択範囲を設定
Case Sensitive 大文字と小文字を区別
Whole Word 単語単位
Select Object: {note} オブジェクトを選択: {note}
Select by Note メモで選択
@msg the note is empty メモが空です
@msg the note is longer than 1,024 bytes メモが 1,024 バイトを超えています
@msg no object that can be selected has a note matching “{query}” 「{query}」に一致するメモを持つ選択可能なオブジェクトはありません
Menu Item: メニュー項目:
No menu item matches. 一致するメニュー項目がありません。
Several menu items match. Choose one from the list. 複数のメニュー項目が一致します。リストから 1 つ選択してください。
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -5166,6 +5166,7 @@ font-technology Variable ცვლადი
@msg unknown parameter `{path}`; it takes: {keys} უცნობი პარამეტრი `{path}`; იღებს: {keys}
@msg missing `{path}` აკლია `{path}`
@msg `{path}` must be {expected}, not {got} `{path}` უნდა იყოს {expected} და არა {got}
@msg `{path}` is nested too deeply `{path}` ზედმეტად ღრმადაა ჩადგმული
file-filter All readable files ყველა წაკითხვადი ფაილი
file-filter All placeable files ყველა განთავსებადი ფაილი
file-filter Swatch libraries ფერის ნიმუშების ბიბლიოთეკები
@@ -6374,6 +6375,16 @@ axis Y Axis Y ღერძი
@msg Batch cannot play an action with a stop პაკეტურ დამუშავებას არ შეუძლია გაჩერების მქონე მოქმედების შესრულება
Insert Menu Item… მენიუს ელემენტის ჩასმა…
Insert Menu Item მენიუს ელემენტის ჩასმა
Select Object… ობიექტის მონიშვნა…
Select Object ობიექტის მონიშვნა
Set Selection მონიშვნის დაყენება
Case Sensitive რეგისტრის გათვალისწინება
Whole Word მთელი სიტყვა
Select Object: {note} ობიექტის მონიშვნა: {note}
Select by Note მონიშვნა შენიშვნის მიხედვით
@msg the note is empty შენიშვნა ცარიელია
@msg the note is longer than 1,024 bytes შენიშვნა 1024 ბაიტზე გრძელია
@msg no object that can be selected has a note matching “{query}” არცერთ მოსანიშნ ობიექტს არ აქვს შენიშვნა, რომელიც ემთხვევა „{query}“-ს
Menu Item: მენიუს ელემენტი:
No menu item matches. მენიუს არცერთი ელემენტი არ ემთხვევა.
Several menu items match. Choose one from the list. ემთხვევა მენიუს რამდენიმე ელემენტი. აირჩიეთ ერთი სიიდან.
Can't render this file because it is too large.
+11
View File
@@ -5456,6 +5456,7 @@ lighting Light 광원
@msg unknown parameter `{path}`; it takes: {keys} 알 수 없는 매개변수 `{path}`: 받는 매개변수: {keys}
@msg missing `{path}` `{path}`이(가) 없습니다
@msg `{path}` must be {expected}, not {got} `{path}`은(는) {expected}이어야 합니다({got} 사용 불가)
@msg `{path}` is nested too deeply `{path}`이(가) 너무 깊게 중첩되어 있습니다
Load EPS into Clipboard EPS를 클립보드로 불러오기
@msg an image of {w} × {h} pixels is too large to draw {w} × {h}픽셀 이미지는 너무 커서 그릴 수 없습니다
@msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: PNG, JPEG, WebP, TIFF 또는 BMP 이미지가 아닙니다
@@ -6009,6 +6010,16 @@ axis Y Axis Y축
@msg Batch cannot play an action with a stop 일괄 처리에서는 중지가 있는 액션을 재생할 수 없습니다
Insert Menu Item… 메뉴 항목 삽입…
Insert Menu Item 메뉴 항목 삽입
Select Object… 개체 선택…
Select Object 개체 선택
Set Selection 선택 영역 설정
Case Sensitive 대/소문자 구분
Whole Word 전체 단어
Select Object: {note} 개체 선택: {note}
Select by Note 참고로 선택
@msg the note is empty 참고가 비어 있습니다
@msg the note is longer than 1,024 bytes 참고가 1,024바이트보다 깁니다
@msg no object that can be selected has a note matching “{query}” 선택할 수 있는 개체 중 참고가 “{query}”와(과) 일치하는 개체가 없습니다
Menu Item: 메뉴 항목:
No menu item matches. 일치하는 메뉴 항목이 없습니다.
Several menu items match. Choose one from the list. 여러 메뉴 항목이 일치합니다. 목록에서 하나를 선택하세요.
Can't render this file because it contains an unexpected character in line 2 and column 73.
+10
View File
@@ -6050,6 +6050,16 @@ axis Y Axis Y-as
@msg Batch cannot play an action with a stop Batch kan geen actie met een stop afspelen
Insert Menu Item… Menu-item invoegen…
Insert Menu Item Menu-item invoegen
Select Object… Object selecteren…
Select Object Object selecteren
Set Selection Selectie instellen
Case Sensitive Hoofdlettergevoelig
Whole Word Heel woord
Select Object: {note} Object selecteren: {note}
Select by Note Selecteren op notitie
@msg the note is empty de notitie is leeg
@msg the note is longer than 1,024 bytes de notitie is langer dan 1024 bytes
@msg no object that can be selected has a note matching “{query}” geen selecteerbaar object heeft een notitie die overeenkomt met „{query}”
Menu Item: Menu-item:
No menu item matches. Geen menu-item komt overeen.
Several menu items match. Choose one from the list. Meerdere menu-items komen overeen. Kies er een uit de lijst.
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -5717,6 +5717,7 @@ mark-style Japanese Japońskie
@msg unknown parameter `{path}`; it takes: {keys} nieznany parametr `{path}`; przyjmuje: {keys}
@msg missing `{path}` brak `{path}`
@msg `{path}` must be {expected}, not {got} `{path}` musi być {expected}, a nie {got}
@msg `{path}` is nested too deeply `{path}` jest zagnieżdżone zbyt głęboko
Load EPS into Clipboard Wczytaj EPS do schowka
@msg an image of {w} × {h} pixels is too large to draw obraz o wymiarach {w} × {h} pikseli jest zbyt duży, aby go narysować
@msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: to nie jest obraz PNG, JPEG, WebP, TIFF ani BMP
@@ -6277,6 +6278,16 @@ axis Y Axis Oś Y
@msg Batch cannot play an action with a stop Przetwarzanie wsadowe nie może odtworzyć operacji z zatrzymaniem
Insert Menu Item… Wstaw polecenie menu…
Insert Menu Item Wstaw polecenie menu
Select Object… Zaznacz obiekt…
Select Object Zaznacz obiekt
Set Selection Ustaw zaznaczenie
Case Sensitive Uwzględniaj wielkość liter
Whole Word Całe słowo
Select Object: {note} Zaznacz obiekt: {note}
Select by Note Zaznacz według notatki
@msg the note is empty notatka jest pusta
@msg the note is longer than 1,024 bytes notatka jest dłuższa niż 1024 bajty
@msg no object that can be selected has a note matching “{query}” żaden obiekt, który można zaznaczyć, nie ma notatki pasującej do „{query}”
Menu Item: Polecenie menu:
No menu item matches. Żadne polecenie menu nie pasuje.
Several menu items match. Choose one from the list. Pasuje kilka poleceń menu. Wybierz jedno z listy.
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -4672,6 +4672,7 @@ axis Both Ambos
@msg unknown parameter `{path}`; it takes: {keys} parâmetro desconhecido `{path}`; aceita: {keys}
@msg missing `{path}` falta `{path}`
@msg `{path}` must be {expected}, not {got} `{path}` deve ser {expected}, não {got}
@msg `{path}` is nested too deeply `{path}` está aninhado com profundidade excessiva
Load EPS into Clipboard Carregar EPS na Área de Transferência
Drawer menu Menu da gaveta
Toolbar: Show Controls Barra de ferramentas: Mostrar controles
@@ -6338,6 +6339,16 @@ axis Y Axis Eixo Y
@msg Batch cannot play an action with a stop Lote não pode reproduzir uma ação com uma parada
Insert Menu Item… Inserir Item de Menu…
Insert Menu Item Inserir Item de Menu
Select Object… Selecionar Objeto…
Select Object Selecionar Objeto
Set Selection Definir Seleção
Case Sensitive Diferenciar Maiúsculas e Minúsculas
Whole Word Palavra Inteira
Select Object: {note} Selecionar Objeto: {note}
Select by Note Selecionar por Nota
@msg the note is empty a nota está vazia
@msg the note is longer than 1,024 bytes a nota tem mais de 1.024 bytes
@msg no object that can be selected has a note matching “{query}” nenhum objeto que pode ser selecionado tem uma nota correspondente a “{query}”
Menu Item: Item de menu:
No menu item matches. Nenhum item de menu corresponde.
Several menu items match. Choose one from the list. Vários itens de menu correspondem. Escolha um da lista.
Can't render this file because it contains an unexpected character in line 6 and column 47.
+11
View File
@@ -5481,6 +5481,7 @@ lighting Light Свет
@msg unknown parameter `{path}`; it takes: {keys} неизвестный параметр `{path}`; принимает: {keys}
@msg missing `{path}` отсутствует `{path}`
@msg `{path}` must be {expected}, not {got} `{path}` должно быть {expected}, а не {got}
@msg `{path}` is nested too deeply `{path}` вложено слишком глубоко
Load EPS into Clipboard Загрузить EPS в буфер обмена
@msg an image of {w} × {h} pixels is too large to draw изображение размером {w} × {h} пикселей слишком велико для отрисовки
@msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: это не изображение PNG, JPEG, WebP, TIFF или BMP
@@ -6034,6 +6035,16 @@ axis Y Axis Ось Y
@msg Batch cannot play an action with a stop Пакет не может воспроизвести действие с остановкой
Insert Menu Item… Вставить пункт меню…
Insert Menu Item Вставить пункт меню
Select Object… Выделить объект…
Select Object Выделить объект
Set Selection Задать выделение
Case Sensitive С учетом регистра
Whole Word Слово целиком
Select Object: {note} Выделить объект: {note}
Select by Note Выделить по примечанию
@msg the note is empty примечание пустое
@msg the note is longer than 1,024 bytes примечание длиннее 1024 байт
@msg no object that can be selected has a note matching “{query}” ни у одного объекта, который можно выделить, нет примечания, совпадающего с «{query}»
Menu Item: Пункт меню:
No menu item matches. Нет подходящих пунктов меню.
Several menu items match. Choose one from the list. Подходит несколько пунктов меню. Выберите один из списка.
Can't render this file because it is too large.
+11
View File
@@ -5349,6 +5349,7 @@ lighting Light Ljus
@msg unknown parameter `{path}`; it takes: {keys} okänd parameter `{path}`; den tar: {keys}
@msg missing `{path}` `{path}` saknas
@msg `{path}` must be {expected}, not {got} `{path}` måste vara {expected}, inte {got}
@msg `{path}` is nested too deeply `{path}` är för djupt kapslat
1 Layer 1 lager
1 bit 1 bit
16 Colors 16 färger
@@ -6404,6 +6405,16 @@ axis Y Axis Y-axel
@msg Batch cannot play an action with a stop Batch kan inte spela upp en åtgärd med ett stopp
Insert Menu Item… Infoga menyalternativ…
Insert Menu Item Infoga menyalternativ
Select Object… Markera objekt…
Select Object Markera objekt
Set Selection Ange markering
Case Sensitive Skiftlägeskänslig
Whole Word Helt ord
Select Object: {note} Markera objekt: {note}
Select by Note Markera efter anteckning
@msg the note is empty anteckningen är tom
@msg the note is longer than 1,024 bytes anteckningen är längre än 1 024 byte
@msg no object that can be selected has a note matching “{query}” inget objekt som kan markeras har en anteckning som matchar ”{query}”
Menu Item: Menyalternativ:
No menu item matches. Inget menyalternativ matchar.
Several menu items match. Choose one from the list. Flera menyalternativ matchar. Välj ett i listan.
Can't render this file because it contains an unexpected character in line 5 and column 25.
+11
View File
@@ -5144,6 +5144,7 @@ lighting Light Işık
@msg unknown parameter `{path}`; it takes: {keys} bilinmeyen parametre `{path}`; şunları alır: {keys}
@msg missing `{path}` `{path}` eksik
@msg `{path}` must be {expected}, not {got} `{path}` {expected} olmalıdır, {got} değil
@msg `{path}` is nested too deeply `{path}` çok derin iç içe geçmiş
1 Layer 1 Katman
1 bit 1 bit
16 Colors 16 Renk
@@ -6379,6 +6380,16 @@ axis Y Axis Y ekseni
@msg Batch cannot play an action with a stop Toplu işlem durak içeren bir eylemi oynatamaz
Insert Menu Item… Menü Öğesi Ekle…
Insert Menu Item Menü Öğesi Ekle
Select Object… Nesne Seç…
Select Object Nesne Seç
Set Selection Seçimi Ayarla
Case Sensitive Büyük/Küçük Harfe Duyarlı
Whole Word Tam Sözcük
Select Object: {note} Nesne Seç: {note}
Select by Note Nota Göre Seç
@msg the note is empty not boş
@msg the note is longer than 1,024 bytes not 1.024 bayttan uzun
@msg no object that can be selected has a note matching “{query}” seçilebilen hiçbir nesnenin notu “{query}” ile eşleşmiyor
Menu Item: Menü öğesi:
No menu item matches. Eşleşen menü öğesi yok.
Several menu items match. Choose one from the list. Birden çok menü öğesi eşleşiyor. Listeden birini seçin.
Can't render this file because it contains an unexpected character in line 5 and column 25.
+11
View File
@@ -5475,6 +5475,7 @@ lighting Light Світло
@msg unknown parameter `{path}`; it takes: {keys} невідомий параметр `{path}`; приймає: {keys}
@msg missing `{path}` відсутнє `{path}`
@msg `{path}` must be {expected}, not {got} `{path}` має бути {expected}, а не {got}
@msg `{path}` is nested too deeply `{path}` вкладено надто глибоко
Load EPS into Clipboard Завантажити EPS у буфер обміну
@msg an image of {w} × {h} pixels is too large to draw зображення розміром {w} × {h} пікселів завелике для відображення
@msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: це не зображення PNG, JPEG, WebP, TIFF чи BMP
@@ -6028,6 +6029,16 @@ axis Y Axis Вісь Y
@msg Batch cannot play an action with a stop Пакет не може відтворити дію із зупинкою
Insert Menu Item… Вставити пункт меню…
Insert Menu Item Вставити пункт меню
Select Object… Виділити об’єкт…
Select Object Виділити об’єкт
Set Selection Задати виділення
Case Sensitive З урахуванням регістру
Whole Word Ціле слово
Select Object: {note} Виділити об’єкт: {note}
Select by Note Виділити за приміткою
@msg the note is empty примітка порожня
@msg the note is longer than 1,024 bytes примітка довша за 1024 байти
@msg no object that can be selected has a note matching “{query}” жоден об’єкт, який можна виділити, не має примітки, що збігається з «{query}»
Menu Item: Пункт меню:
No menu item matches. Немає відповідних пунктів меню.
Several menu items match. Choose one from the list. Відповідає кілька пунктів меню. Виберіть один зі списку.
Can't render this file because it is too large.
+11
View File
@@ -5389,6 +5389,7 @@ theme Appearance Giao diện
@msg unknown parameter `{path}`; it takes: {keys} tham số không xác định `{path}`; nhận: {keys}
@msg missing `{path}` thiếu `{path}`
@msg `{path}` must be {expected}, not {got} `{path}` phải là {expected}, không phải {got}
@msg `{path}` is nested too deeply `{path}` lồng nhau quá sâu
@msg unknown preference `{key}` tùy chọn không xác định `{key}`
@msg Preferences imported. Đã nhập tùy chọn.
@msg not a preferences file: {e} không phải tệp tùy chọn: {e}
@@ -6029,6 +6030,16 @@ axis Y Axis Trục Y
@msg Batch cannot play an action with a stop Hàng loạt không thể phát hành động có điểm dừng
Insert Menu Item… Chèn mục menu…
Insert Menu Item Chèn mục menu
Select Object… Chọn đối tượng…
Select Object Chọn đối tượng
Set Selection Đặt vùng chọn
Case Sensitive Phân biệt chữ hoa chữ thường
Whole Word Toàn bộ từ
Select Object: {note} Chọn đối tượng: {note}
Select by Note Chọn theo ghi chú
@msg the note is empty ghi chú trống
@msg the note is longer than 1,024 bytes ghi chú dài hơn 1.024 byte
@msg no object that can be selected has a note matching “{query}” không có đối tượng nào có thể chọn có ghi chú khớp với “{query}”
Menu Item: Mục menu:
No menu item matches. Không có mục menu nào khớp.
Several menu items match. Choose one from the list. Có nhiều mục menu khớp. Hãy chọn một mục trong danh sách.
Can't render this file because it contains an unexpected character in line 204 and column 5.
+11
View File
@@ -5751,6 +5751,7 @@ lighting Light 光源
@msg unknown parameter `{path}`; it takes: {keys} 未知参数 `{path}`;它接受:{keys}
@msg missing `{path}` 缺少 `{path}`
@msg `{path}` must be {expected}, not {got} `{path}` 必须是 {expected},而不是 {got}
@msg `{path}` is nested too deeply `{path}` 嵌套过深
Load EPS into Clipboard 将 EPS 载入剪贴板
@msg an image of {w} × {h} pixels is too large to draw {w} × {h} 像素的图像过大,无法绘制
@msg the JPEG XL file is cut short JPEG XL 文件不完整
@@ -6261,6 +6262,16 @@ axis Y Axis Y 轴
@msg Batch cannot play an action with a stop 批处理无法播放含有停止的动作
Insert Menu Item… 插入菜单项…
Insert Menu Item 插入菜单项
Select Object… 选择对象…
Select Object 选择对象
Set Selection 设置选区
Case Sensitive 区分大小写
Whole Word 全字匹配
Select Object: {note} 选择对象:{note}
Select by Note 按注释选择
@msg the note is empty 注释为空
@msg the note is longer than 1,024 bytes 注释超过 1,024 字节
@msg no object that can be selected has a note matching “{query}” 没有可选对象的注释与“{query}”匹配
Menu Item: 菜单项:
No menu item matches. 没有匹配的菜单项。
Several menu items match. Choose one from the list. 有多个菜单项匹配。请从列表中选择一个。
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -5583,6 +5583,7 @@ lighting Light 光源
@msg unknown parameter `{path}`; it takes: {keys} 未知的參數 `{path}`;它接受:{keys}
@msg missing `{path}` 未提供 `{path}`
@msg `{path}` must be {expected}, not {got} `{path}` 必須是 {expected},而不是 {got}
@msg `{path}` is nested too deeply `{path}` 巢狀層級過深
Load EPS into Clipboard 將 EPS 載入剪貼簿
@msg an image of {w} × {h} pixels is too large to draw {w} × {h} 像素的影像過大,無法繪製
@msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}:不是 PNG、JPEG、WebP、TIFF 或 BMP 影像
@@ -6066,6 +6067,16 @@ axis Y Axis Y 軸
@msg Batch cannot play an action with a stop 批次無法播放含有停止的動作
Insert Menu Item… 插入選單項目…
Insert Menu Item 插入選單項目
Select Object… 選取物件…
Select Object 選取物件
Set Selection 設定選取範圍
Case Sensitive 區分大小寫
Whole Word 全字拼寫須相符
Select Object: {note} 選取物件:{note}
Select by Note 依註解選取
@msg the note is empty 註解是空的
@msg the note is longer than 1,024 bytes 註解超過 1,024 位元組
@msg no object that can be selected has a note matching “{query}” 沒有可選取物件的註解符合「{query}」
Menu Item: 選單項目:
No menu item matches. 沒有相符的選單項目。
Several menu items match. Choose one from the list. 有多個選單項目相符。請從清單中選擇一個。
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -979,6 +979,7 @@ fn run_ui_part11(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result
crate::panels::actions::STOP_RECORDING => crate::panels::actions::stop(app, p),
crate::panels::actions::INSERT_STOP => crate::panels::actions::insert_stop(app, p),
crate::panels::actions::INSERT_MENU_ITEM => crate::panels::actions::insert_menu_item(app, p),
crate::panels::actions::INSERT_SELECT_OBJECT => crate::panels::actions::insert_select_object(app, p),
crate::panels::actions::CONTINUE_PLAYBACK => crate::panels::actions::continue_playback(app, p),
crate::panels::actions::DELETE_STEP => crate::panels::actions::delete_step(app, p),
"ui.batchDialog" => {
+6
View File
@@ -1440,6 +1440,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"",
"{set?: index, action?: index, step?: index, command, params?: object} insert the command of a menu item as a step, without running it; the step plays as choosing the item runs it. command and params (default {}) must be those of a menu item that can be a step: one that runs an engine command, whose label does not end with \"…\", outside the VectorCraft, File, Window and Help menus, and that is not Undo, Redo, a Paste, a batch (command.batch), a preference (prefs.*) or action (action.*) command, an Object › Align item, Image Trace's Make or Make and Expand, Pattern's Make or Edit Pattern, a saved selection, saved view or recent font item, or a plug-in's item. The place is as ui.action.insertStop takes it: with set and action, after step, else after the action's last step; without them, the recording in progress. The new step becomes the panel's selected step, and its action and set become the selected ones. A command and params that no such menu item has, a param of another type, params that are not an object, no action and no recording, or a step that would give the action more than 10,000 steps or its set's file more than 8 MiB, counting the steps that the recording in progress adds to the set, is an error and changes nothing. The panel menu's Insert Menu Item… (dialog `actionInsertMenuItem` with find, command, params, set, action and step) runs this on OK → {set, action, step}, or {recording: true}",
),
(
crate::panels::actions::INSERT_SELECT_OBJECT,
"Select Object",
"",
"{set?: index, action?: index, step?: index, note, caseSensitive?: bool, wholeWord?: bool} insert a Select Object step, without running it: playback runs select.byNote {note, caseSensitive, wholeWord}, which selects the objects whose Attributes note matches note, and a playback that finds no such object stops there with an error. note loses its leading and trailing spaces and must then be 1 to 1,024 bytes; caseSensitive and wholeWord default to false. The place is as ui.action.insertStop takes it: with set and action, after step, else after the action's last step; without them, the recording in progress. The new step becomes the panel's selected step, and its action and set become the selected ones. A missing or empty note, a param of another type, params that are not an object, no action and no recording, or a step that would give the action more than 10,000 steps or its set's file more than 8 MiB, counting the steps that the recording in progress adds to the set, is an error and changes nothing. The panel menu's Select Object… (dialog `actionSelectObject`, Set Selection, with note, caseSensitive, wholeWord, set, action and step) runs this on OK → {set, action, step}, or {recording: true}",
),
(
crate::panels::actions::CONTINUE_PLAYBACK,
"Continue Action",
+246 -9
View File
@@ -948,6 +948,55 @@ pub fn insert_menu_item(app: &mut VectorcraftApp, p: &Value) -> Result<Value, St
insert(app, p, (command.to_string(), params))
}
/// The command a Select Object step runs, with {note, caseSensitive, wholeWord} as its params: it
/// selects the objects whose Attributes note matches.
pub const SELECT_OBJECT_STEP: &str = "select.byNote";
/// The longest note a Select Object step searches for, in bytes, as `select.byNote` takes it.
const MAX_SELECT_NOTE: usize = 1024;
/// The note, Case Sensitive and Whole Word in a Select Object step's params, the note without its
/// leading and trailing spaces. Case Sensitive and Whole Word may be left out. No note, an empty
/// note or one longer than 1,024 bytes, a value of another type, or params that are not an object
/// is an error.
pub(crate) fn select_object_params(p: &Value) -> Result<(String, bool, bool), String> {
if !p.is_object() {
return Err("`params` must be an object".into());
}
let key = "note";
let note = match p.get(key) {
None | Some(Value::Null) => "",
Some(v) => v.as_str().ok_or_else(|| format!("`{key}` must be a string"))?,
}
.trim();
if note.is_empty() {
return Err("the note is empty".into());
}
if note.len() > MAX_SELECT_NOTE {
return Err("the note is longer than 1,024 bytes".into());
}
let flag = |key: &str| match p.get(key) {
None | Some(Value::Null) => Ok(false),
Some(v) => v.as_bool().ok_or_else(|| format!("`{key}` must be true or false")),
};
Ok((note.to_string(), flag("caseSensitive")?, flag("wholeWord")?))
}
/// The command that inserts a Select Object step ([`insert_select_object`]).
pub const INSERT_SELECT_OBJECT: &str = "ui.action.insertSelectObject";
/// `ui.action.insertSelectObject {set?, action?, step?, note, caseSensitive?, wholeWord?}`: inserts
/// a Select Object step, without running it. Playback selects the objects whose Attributes note
/// matches `note` ([`SELECT_OBJECT_STEP`]); a playback that finds none stops there with an error.
/// The note and options are as [`select_object_params`] reads them. The place is as [`insert_stop`]
/// takes it, and the step is selected as a stop is. Bad params, or a place [`insert_stop`]
/// refuses, is an error, and then nothing changes. Set Selection's OK runs this. → {set, action,
/// step}, or {recording: true}
pub fn insert_select_object(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
let (note, case_sensitive, whole_word) = select_object_params(p)?;
insert(app, p, (SELECT_OBJECT_STEP.to_string(), json!({"note": note, "caseSensitive": case_sensitive, "wholeWord": whole_word})))
}
/// The journal entry the recording in progress records from.
fn recording_start(app: &VectorcraftApp) -> Option<usize> {
let (_, _, start) = app.ui.recording.as_ref()?;
@@ -1256,7 +1305,7 @@ pub const DELETE_STEP: &str = "ui.action.deleteStep";
/// `ui.action.deleteStep {set, action, step}`: deletes step `step`, a stop or a command, of the
/// action at index `action` of set `set`. The steps after it keep whether they are excluded, and
/// no step of that action stays selected. A Record Stop or Insert Menu Item dialog open on that step
/// no step of that action stays selected. A Record Stop, Insert Menu Item or Set Selection dialog open on that step
/// closes, and one open on a later step of the action follows it up. A missing or invalid index is an error, and
/// then nothing changes. Undo does not bring the step back. Delete Selection runs this for the
/// selected step. → {set, action, step}
@@ -1567,10 +1616,12 @@ fn holds_set(d: &crate::state::Dialog) -> bool {
|| (d.kind == crate::dialogs::action_set_options::KIND && d.fields.contains_key("set"))
}
/// Whether dialog `d` is a Record Stop or Insert Menu Item dialog, which holds the place of a new
/// step in its `set`, `action` and `step` fields.
/// Whether dialog `d` is a Record Stop, Insert Menu Item or Set Selection dialog, which holds the
/// place of a new step in its `set`, `action` and `step` fields.
fn holds_step(d: &crate::state::Dialog) -> bool {
d.kind == crate::dialogs::action_stop::RECORD_KIND || d.kind == crate::dialogs::action_insert_menu_item::KIND
d.kind == crate::dialogs::action_stop::RECORD_KIND
|| d.kind == crate::dialogs::action_insert_menu_item::KIND
|| d.kind == crate::dialogs::action_select_object::KIND
}
/// The command that duplicates an action or an action set ([`duplicate`]).
@@ -1624,8 +1675,8 @@ pub fn duplicate(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
edited.actions.insert(a + 1, copy);
edit_keeps_set_loadable(app, Some(set), &edited)?;
app.ui.action_sets[set] = edited;
// A Batch, Record Stop or Insert Menu Item dialog open on a later action of the set follows it
// down.
// A Batch, Record Stop, Insert Menu Item or Set Selection dialog open on a later action of the
// set follows it down.
if let Some(d) = app.ui.dialog.as_mut().filter(|d| d.kind == crate::dialogs::action_batch::KIND || holds_step(d))
&& d.fields.get("set").and_then(index) == Some(set)
&& let Some(da) = d.fields.get("action").and_then(index).filter(|&da| da > a)
@@ -1706,6 +1757,10 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut egui::Ui) {
let step = selected_step(app, ui.ctx(), selected);
crate::dialogs::action_insert_menu_item::open(app, if recording { None } else { selected }, step);
}
if widgets::menu_item(ui, tl!("Select Object…"), recording || has, false) {
let step = selected_step(app, ui.ctx(), selected);
crate::dialogs::action_select_object::open(app, if recording { None } else { selected }, step);
}
if widgets::menu_item(ui, tl!("Action Options…"), has, false)
&& let Some((s, a)) = selected
{
@@ -1892,10 +1947,14 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) {
current = Some(si);
sets_selected = vec![si];
}
// A stop's row shows its message; other steps show their command.
// A stop's row shows its message, and a Select Object step's its note; other
// steps show their command.
let shown = if command == STOP_STEP {
let message = params.get("message").and_then(Value::as_str).and_then(|m| m.lines().next()).unwrap_or_default();
crate::i18n::fmt(tl!("Stop: {message}"), &[("message", message)])
} else if command == SELECT_OBJECT_STEP {
let note = params.get("note").and_then(Value::as_str).and_then(|m| m.lines().next()).unwrap_or_default();
crate::i18n::fmt(tl!("Select Object: {note}"), &[("note", note)])
} else {
command.clone()
};
@@ -3087,7 +3146,7 @@ mod tests {
(json!(long.to_string()), "an action in the action set file has more than 10,000 steps"),
(json!(" ".repeat(files::MAX_SET_FILE + 1)), "the action set file is larger than 8 MiB"),
(
json!("{\"format\": \"vcactions\", \"version\": 4, \"name\": \"S\", \"actions\": []}"),
json!("{\"format\": \"vcactions\", \"version\": 5, \"name\": \"S\", \"actions\": []}"),
"the action set file needs a later version of VectorCraft",
),
] {
@@ -5520,7 +5579,7 @@ mod tests {
json!({"format": "vcactions", "version": version, "name": "Old", "actions": [{"name": "A", "steps": [{"command": "object.move"}]}]});
assert_eq!(files::read_set(&old.to_string(), "Test").unwrap().actions[0].steps.len(), 1, "version {version}");
}
let later = json!({"format": "vcactions", "version": 4, "name": "New", "actions": []});
let later = json!({"format": "vcactions", "version": 5, "name": "New", "actions": []});
assert_eq!(files::read_set(&later.to_string(), "Test").unwrap_err(), "the action set file needs a later version of VectorCraft");
for params in [json!({"message": 5}), json!({"allowContinue": "yes"}), json!(42)] {
let stop = json!({"command": "action.stop", "params": params});
@@ -5664,4 +5723,182 @@ mod tests {
app.run(DELETE_SETS, json!({"sets": [1]})).unwrap();
assert_eq!(open_kind(&app), None, "deleting its set closes it");
}
/// [`app_with_three_moves`] with a second rectangle whose Attributes note is "Badge front" →
/// (the app, the first rectangle's x offset, the second rectangle's x offset).
#[allow(clippy::type_complexity)]
fn app_with_a_noted_rect() -> (VectorcraftApp, Box<dyn Fn(&VectorcraftApp) -> f64>, Box<dyn Fn(&VectorcraftApp) -> f64>) {
let (mut app, x) = app_with_three_moves();
let first = app.session.active().unwrap().selection.objects.clone();
let id = vectorcraft_doc::NodeId(
app.run("shape.rectangle", json!({"x": 100, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(),
);
app.run("attributes.set", json!({"ids": [id.0], "note": "Badge front"})).unwrap();
app.run("select.set", json!({"ids": first.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
let noted = move |app: &VectorcraftApp| app.session.active().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().x0 - 100.0;
(app, Box::new(x), Box::new(noted))
}
/// A Select Object step goes after the given step, or at the action's end, runs nothing when it
/// goes in, and plays as `select.byNote`: the steps after it act on the objects it selects.
#[test]
fn insert_select_object_goes_after_the_given_step_and_plays_as_a_selection_by_note() {
let (mut app, x, noted) = app_with_a_noted_rect();
let selection = app.session.active().unwrap().selection.clone();
let r = app.run(INSERT_SELECT_OBJECT, json!({"set": 0, "action": 0, "step": 0, "note": " badge "})).unwrap();
assert_eq!(r, json!({"set": 0, "action": 0, "step": 1}));
assert_eq!(app.session.active().unwrap().selection, selection, "inserting runs nothing");
let action = &app.ui.action_sets[0].actions[0];
assert_eq!(action.steps[1], (SELECT_OBJECT_STEP.to_string(), json!({"note": "badge", "caseSensitive": false, "wholeWord": false})));
app.run(INSERT_SELECT_OBJECT, json!({"set": 0, "action": 0, "note": "Badge", "caseSensitive": true, "wholeWord": true})).unwrap();
assert_eq!(app.ui.action_sets[0].actions[0].steps[4].1, json!({"note": "Badge", "caseSensitive": true, "wholeWord": true}));
play(&mut app, 0, 0).unwrap();
assert_eq!((x(&app), noted(&app)), (1.0, 6.0), "the moves after the step move the object with the note");
let ids = &app.session.active().unwrap().selection.objects;
assert_eq!(ids.len(), 1);
}
/// A playback whose Select Object step finds no object stops there with the step's error, at
/// every speed, and runs none of the steps after it.
#[test]
fn a_select_object_step_that_finds_nothing_stops_the_playback() {
for speed in ["accelerated", "step"] {
let (mut app, x, noted) = app_with_a_noted_rect();
app.run(PLAYBACK_OPTIONS, json!({"speed": speed})).unwrap();
app.ui.action_sets[0].actions[0].insert_step(0, (SELECT_OBJECT_STEP.into(), json!({"note": "missing"})));
let selection = app.session.active().unwrap().selection.clone();
let e = play(&mut app, 0, 0).unwrap_err();
assert!(e.contains("no object that can be selected has a note matching “missing”"), "{speed}: {e}");
play_out(&mut app);
assert_eq!((x(&app), noted(&app)), (0.0, 0.0), "{speed}: nothing after it runs");
assert_eq!(app.session.active().unwrap().selection, selection, "{speed}: the selection stays");
assert!(app.ui.action_playing.is_none(), "{speed}");
}
}
#[test]
fn insert_select_object_refuses_bad_params_and_changes_nothing() {
let (mut app, _, _) = app_with_a_noted_rect();
let place = |extra: Value| {
let mut p = json!({"set": 0, "action": 0});
if let (Some(p), Some(extra)) = (p.as_object_mut(), extra.as_object()) {
p.extend(extra.clone());
}
p
};
for (params, want) in [
(place(json!({})), "the note is empty"),
(place(json!({"note": " \t "})), "the note is empty"),
(place(json!({"note": 5})), "`note` must be a string"),
(place(json!({"note": "x".repeat(1025)})), "the note is longer than 1,024 bytes"),
(place(json!({"note": "x", "caseSensitive": "yes"})), "`caseSensitive` must be true or false"),
(place(json!({"note": "x", "wholeWord": 0})), "`wholeWord` must be true or false"),
(place(json!({"note": "x", "action": 5})), "no such action"),
(place(json!({"note": "x", "step": 9})), "no such action step"),
(json!({"note": "x"}), "no such action"),
(json!(["note"]), "`params` must be an object"),
] {
assert_eq!(app.run(INSERT_SELECT_OBJECT, params.clone()).unwrap_err(), want, "{params}");
assert_eq!(app.ui.action_sets[0].actions[0].steps.len(), 3, "{params}");
}
assert!(app.run(INSERT_SELECT_OBJECT, place(json!({"note": "x".repeat(1024)}))).is_ok(), "1,024 bytes goes in");
}
/// A saved Select Object step loads back as it was, in a version 4 file that older builds refuse;
/// a file whose Select Object step has bad params is refused, also inside a recorded batch.
#[test]
fn a_saved_select_object_step_survives_and_bad_ones_are_refused() {
let (mut app, _, _) = app_with_a_noted_rect();
app.run(INSERT_SELECT_OBJECT, json!({"set": 0, "action": 0, "note": "Badge", "wholeWord": true})).unwrap();
let saved = files::write_set(&app.ui.action_sets[0]);
let file: Value = serde_json::from_str(&saved).unwrap();
assert_eq!(file["version"], json!(4), "older builds must refuse a Select Object step");
assert_eq!(
file["actions"][0]["steps"][3],
json!({"command": "select.byNote", "params": {"note": "Badge", "caseSensitive": false, "wholeWord": true}})
);
let loaded = files::read_set(&saved, "Test").unwrap();
assert_eq!(loaded.actions[0].steps, app.ui.action_sets[0].actions[0].steps);
app.ui.action_sets[0].actions[0].steps.push(("action.stop".into(), json!({"message": ""})));
let file: Value = serde_json::from_str(&files::write_set(&app.ui.action_sets[0])).unwrap();
assert_eq!(file["version"], json!(4), "with a stop too");
for params in [json!({}), json!({"note": " "}), json!({"note": 5}), json!({"note": "a", "caseSensitive": 1}), json!(null), json!(42)] {
let step = json!({"command": "select.byNote", "params": params});
for step in [step.clone(), json!({"command": "command.batch", "params": {"commands": [step]}})] {
let bad = json!({"format": "vcactions", "version": 4, "name": "Bad", "actions": [{"name": "A", "steps": [step]}]});
assert_eq!(files::read_set(&bad.to_string(), "Test").unwrap_err(), "not an action set file", "{step}");
}
}
let nested = json!({"commands": [{"command": "select.byNote", "params": {"note": "Inner"}}]});
let set = ActionSet::new("Nested", vec![Action::new("A", vec![("command.batch".into(), nested)])]);
assert_eq!(serde_json::from_str::<Value>(&files::write_set(&set)).unwrap()["version"], json!(4), "a step in a recorded batch counts");
}
/// The panel menu's Select Object… opens Set Selection on the selected step's place, with an
/// empty note and both options off; Cancel inserts nothing, and OK inserts the step after the
/// selected step, whose row shows the note. While recording, the step goes into the recording.
/// With nothing selected and nothing recording, the item is off.
#[test]
fn select_object_in_the_menu_opens_set_selection_and_inserts_on_ok() {
use super::super::tests_appearance::{click, frame_events};
let (mut app, _, _) = app_with_a_noted_rect();
app.ui.action_set = Some(0);
let ctx = egui::Context::default();
app.ui_ctx = Some(ctx.clone());
let open = |ctx: &egui::Context, app: &mut VectorcraftApp| {
let texts = frame_events(ctx, app, vec![], menu);
let item = texts.iter().find(|(s, _)| s.trim() == "Select Object…").map(|(_, r)| *r).expect("Select Object… in the menu");
click(ctx, app, item.center(), menu);
};
open(&ctx, &mut app);
assert_eq!(open_kind(&app), None, "nothing selected and nothing recording: the item is off");
click_first_step(&ctx, &mut app);
open(&ctx, &mut app);
let d = app.ui.dialog.as_ref().expect("Select Object… opens Set Selection");
assert_eq!(d.kind, crate::dialogs::action_select_object::KIND);
assert_eq!((&d.fields["set"], &d.fields["action"], &d.fields["step"]), (&json!(0), &json!(0), &json!(0)));
assert_eq!((d.str("note"), d.bool("caseSensitive"), d.bool("wholeWord")), (String::new(), false, false));
crate::dialogs::cancel(&mut app);
assert_eq!(app.ui.action_sets[0].actions[0].steps.len(), 3, "Cancel inserts nothing");
open(&ctx, &mut app);
// A field of another type (set through ui.dialog.set) is an error, and the dialog stays.
for (key, value) in [("note", json!(5)), ("caseSensitive", json!("yes")), ("wholeWord", json!(1))] {
if let Some(d) = app.ui.dialog.as_mut() {
d.fields.insert("note".into(), json!("Badge"));
d.fields.insert("caseSensitive".into(), json!(false));
d.fields.insert("wholeWord".into(), json!(false));
d.fields.insert(key.into(), value.clone());
}
assert!(crate::dialogs::confirm(&mut app).is_err(), "`{key}`: {value} is an error");
assert_eq!(open_kind(&app), Some(crate::dialogs::action_select_object::KIND), "`{key}`: the dialog stays open");
assert_eq!(app.ui.action_sets[0].actions[0].steps.len(), 3, "`{key}`: nothing is inserted");
}
if let Some(d) = app.ui.dialog.as_mut() {
d.fields.insert("note".into(), json!("Badge"));
d.fields.insert("caseSensitive".into(), json!(true));
d.fields.insert("wholeWord".into(), json!(false));
}
crate::dialogs::confirm(&mut app).unwrap();
assert_eq!(open_kind(&app), None, "OK closes Set Selection");
let action = &app.ui.action_sets[0].actions[0];
assert_eq!(action.steps[1], (SELECT_OBJECT_STEP.to_string(), json!({"note": "Badge", "caseSensitive": true, "wholeWord": false})));
let texts = frame_events(&ctx, &mut app, vec![], show);
assert!(texts.iter().any(|(s, _)| s == "Select Object: Badge"), "the step's row shows the note: {texts:?}");
app.run(START_RECORDING, json!({})).unwrap();
open(&ctx, &mut app);
assert!(!app.ui.dialog.as_ref().is_some_and(|d| d.fields.contains_key("set")), "while recording it goes into the recording");
if let Some(d) = app.ui.dialog.as_mut() {
d.fields.insert("note".into(), json!("While recording"));
}
crate::dialogs::confirm(&mut app).unwrap();
app.run(STOP_RECORDING, json!({})).unwrap();
let recorded = app.ui.action_sets[0].actions.last().unwrap();
assert_eq!(
recorded.steps,
[(SELECT_OBJECT_STEP.to_string(), json!({"note": "While recording", "caseSensitive": false, "wholeWord": false}))]
);
}
}
+32 -12
View File
@@ -1,14 +1,16 @@
//! Action set files: the Actions panel menu's Save Actions… and Load Actions….
//!
//! An action set file (`.vcactions`) is JSON: `{"format": "vcactions", "version": 1, 2 or 3, "name",
//! An action set file (`.vcactions`) is JSON: `{"format": "vcactions", "version": 1 to 4, "name",
//! "actions": [{"name", "steps": [{"command", "params", "excluded"?}], "key"?, "color"?}]}`. Each step is a
//! command id and its params, as `command.batch` takes them, or a stop: the command `action.stop`
//! with `{"message", "allowContinue"}`. `key` is the action's function key
//! with `{"message", "allowContinue"}`. A Select Object step is the command `select.byNote` with
//! `{"note", "caseSensitive", "wholeWord"}`. `key` is the action's function key
//! (`{"key": 1 to 15, "cmd", "shift"}`) and `color` its Button Mode color; an action without them,
//! or with a value that is not one, has none. `excluded: true` keeps a step but skips it during
//! playback. Sets with exclusions use version 2 so older builds refuse them rather than replay
//! excluded steps, and sets with stops use version 3 so older builds refuse them rather than fail
//! at a stop. Other sets use version 1, and earlier version 1 and 2 files still work.
//! excluded steps, sets with stops use version 3 so older builds refuse them rather than fail at a
//! stop, and sets with Select Object steps use version 4 so older builds refuse them rather than
//! fail at that step. Other sets use version 1, and earlier version 1 to 3 files still work.
//! Load Actions… also reads the action sets another app saves as text (`.aia`, through
//! `actionSet.read`).
@@ -122,14 +124,25 @@ pub(super) fn step_bytes(command: &str, params: &Value) -> usize {
/// The params of the stops in a step: a stop's own, or those of the stops a `command.batch` step
/// holds, which playback runs as its own steps.
fn stops_in<'a>(command: &str, params: &'a Value) -> Vec<&'a Value> {
if command == super::STOP_STEP {
steps_in(super::STOP_STEP, command, params)
}
/// The params of the Select Object steps in a step, as [`stops_in`] finds stops.
fn select_objects_in<'a>(command: &str, params: &'a Value) -> Vec<&'a Value> {
steps_in(super::SELECT_OBJECT_STEP, command, params)
}
/// The params of the steps that run command `kind` in a step: the step's own, or those of the
/// steps a `command.batch` step holds, which playback runs as its own steps.
fn steps_in<'a>(kind: &str, command: &str, params: &'a Value) -> Vec<&'a Value> {
if command == kind {
return vec![params];
}
if command != "command.batch" {
return vec![];
}
let steps = params.get("commands").and_then(Value::as_array).into_iter().flatten();
steps.filter(|step| super::is_stop(step)).map(|step| step.get("params").unwrap_or(&Value::Null)).collect()
steps.filter(|step| step.get("command").and_then(Value::as_str) == Some(kind)).map(|step| step.get("params").unwrap_or(&Value::Null)).collect()
}
/// `set` as the text of an action set file.
@@ -149,11 +162,14 @@ pub fn write_set(set: &ActionSet) -> String {
color: a.color.clone(),
})
.collect();
let has_stop = set.actions.iter().flat_map(|a| &a.steps).any(|(command, params)| !stops_in(command, params).is_empty());
let version = match (has_stop, set.actions.iter().any(|a| !a.excluded_steps.is_empty())) {
(true, _) => 3,
(false, true) => 2,
(false, false) => 1,
let steps = || set.actions.iter().flat_map(|a| &a.steps);
let has_select_object = steps().any(|(command, params)| !select_objects_in(command, params).is_empty());
let has_stop = steps().any(|(command, params)| !stops_in(command, params).is_empty());
let version = match (has_select_object, has_stop, set.actions.iter().any(|a| !a.excluded_steps.is_empty())) {
(true, ..) => 4,
(false, true, _) => 3,
(false, false, true) => 2,
(false, false, false) => 1,
};
let file = SetFile { format: ACTIONS_EXT.into(), version, name: set.name.clone(), actions };
serde_json::to_string_pretty(&file).unwrap_or_default()
@@ -168,7 +184,7 @@ pub fn read_set(text: &str, name: &str) -> Result<ActionSet, String> {
if f.format != ACTIONS_EXT {
return Err("not an action set file".into());
}
if !matches!(f.version, 1..=3) {
if !matches!(f.version, 1..=4) {
return Err("the action set file needs a later version of VectorCraft".into());
}
if f.actions.len() > MAX_SET_ACTIONS {
@@ -181,6 +197,10 @@ pub fn read_set(text: &str, name: &str) -> Result<ActionSet, String> {
if stops.any(|p| super::stop_params(p).is_err()) {
return Err("not an action set file".into());
}
let mut select_objects = f.actions.iter().flat_map(|a| &a.steps).flat_map(|s| select_objects_in(&s.command, &s.params));
if select_objects.any(|p| super::select_object_params(p).is_err()) {
return Err("not an action set file".into());
}
let actions = f
.actions
.into_iter()
+20 -2
View File
@@ -1,5 +1,6 @@
//! Artboards panel: numbered list with inline rename (double-click the name, or the panel menu's
//! Rename; while several artboards are selected, the name typed goes to all of them, numbered),
//! Rename; while several artboards are selected, the name typed goes to all of them, numbered), a
//! row menu (right-click) whose Rename opens the Rename dialog for the selected artboards,
//! move up / down (or drag a row above or below another), new
//! (Alt-click: Artboard Options for the new artboard first), delete; a row dragged onto New
//! Artboard duplicates its artboard (as Duplicate Artboards does, with
@@ -541,7 +542,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let scrolled = scroll.show(ui, |ui| {
for (i, name) in abs.iter().enumerate() {
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click_and_drag());
if resp.drag_started() {
// Only the primary button drags a row; a right-button drag reorders nothing.
if resp.drag_started_by(egui::PointerButton::Primary) {
egui::DragAndDrop::set_payload(ui.ctx(), RowDrag(i));
}
if all.contains(&i) {
@@ -648,6 +650,14 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
} else if resp.double_clicked() {
start_rename(ui.ctx(), app, i);
}
// A right-click on a row that isn't selected makes its artboard the active one and
// the only selected one ([`select`] sets the Artboard tool's `active` option, which
// clears the rest of its selection); on a selected row the selection stays. The row
// menu then acts on the selection.
if resp.secondary_clicked() && !picked(app, abs.len()).contains(&i) {
select(app, i);
}
resp.context_menu(|ui| row_menu(app, ui));
// Another row dragged over this one lands above or below it by the pointer's half,
// a line showing where.
if let Some(from) = resp.dnd_hover_payload::<RowDrag>().map(|d| d.0).filter(|from| *from != i) {
@@ -707,6 +717,14 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
});
}
/// A row's menu: Rename opens the Rename dialog for the selected artboards ([`picked`]), whose OK
/// runs `artboard.rename` in one undo step, numbering the name when there are several.
fn row_menu(app: &mut VectorcraftApp, ui: &mut Ui) {
if menu_item(ui, tl!("Rename"), true, false) {
crate::menus::invoke(app, "artboard.rename", json!({}));
}
}
/// Open Rearrange All Artboards (#681).
pub(crate) fn open_rearrange(app: &mut VectorcraftApp) {
app.run("ui.menuDialog", json!({ "command": "artboard.rearrange" })).ok();
@@ -1122,6 +1122,132 @@ fn dragging_a_row_onto_another_row_reorders_the_artboards() {
assert_eq!(names(&app), ["Artboard 1", "Artboard 2", "Artboard 3"]);
}
/// The panel drawn in a 240 × 400 pt window, for the row menu's tests.
struct RowMenuWindow {
ctx: egui::Context,
time: f64,
}
impl RowMenuWindow {
fn new() -> Self {
RowMenuWindow { ctx: egui::Context::default(), time: 0.0 }
}
/// One frame of the panel with `events`; the texts drawn and where.
fn frame(&mut self, app: &mut VectorcraftApp, events: Vec<egui::Event>) -> Vec<(String, egui::Rect)> {
self.time += 0.5;
let screen = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(240.0, 400.0)));
let raw = egui::RawInput { events, time: Some(self.time), screen_rect: screen, ..Default::default() };
let mut out = self.ctx.run_ui(raw, |ui| show(app, ui));
out.textures_delta.clear();
out.shapes
.iter()
.filter_map(|c| match &c.shape {
egui::Shape::Text(t) => Some((t.galley.text().trim_start().to_string(), t.visual_bounding_rect())),
_ => None,
})
.collect()
}
/// Where the text `label` is drawn now, if it is.
fn find(&mut self, app: &mut VectorcraftApp, label: &str) -> Option<egui::Pos2> {
self.frame(app, vec![]).into_iter().find(|(t, _)| t == label).map(|(_, r)| r.center())
}
/// A click with `button` at `at`.
fn click(&mut self, app: &mut VectorcraftApp, at: egui::Pos2, button: egui::PointerButton) {
let b = |pressed| egui::Event::PointerButton { pos: at, button, pressed, modifiers: Default::default() };
self.frame(app, vec![egui::Event::PointerMoved(at), b(true), b(false)]);
self.frame(app, vec![]);
}
/// A right-click on the row named `row`, then a click on its menu's Rename.
fn rename_from_row_menu(&mut self, app: &mut VectorcraftApp, row: &str) {
let at = self.find(app, row).expect("the row");
self.click(app, at, egui::PointerButton::Secondary);
let item = self.find(app, "Rename").expect("the row menu's Rename");
self.click(app, item, egui::PointerButton::Primary);
}
}
/// A right-click on an Artboards panel row opens a menu with Rename, which opens the Rename dialog
/// and renames nothing until OK: on a row of the selection, the dialog takes every selected
/// artboard and OK numbers the name in one undo step; on a row that isn't selected, that row's
/// artboard becomes the only selected one and the dialog takes it alone, and Cancel keeps its name.
#[test]
fn the_row_menus_rename_opens_the_rename_dialog_for_the_selected_artboards() {
let mut app = menu_app(4);
select(&mut app, 2);
app.session.set_tool_option("selected", &json!([2, 1]));
let mut window = RowMenuWindow::new();
let steps = undo_steps(&app);
window.rename_from_row_menu(&mut app, "Artboard 2");
let d = app.ui.dialog.as_ref().expect("the Rename dialog");
assert_eq!(d.kind, crate::dialogs::rename_artboards::KIND);
assert_eq!((d.str("name").as_str(), &d.fields["indices"]), ("", &json!([1, 2])), "the dialog takes both selected artboards");
assert_eq!(picked(&app, 4), [1, 2], "the selection stays");
assert_eq!(board_names(&app), ["Artboard 1", "Artboard 2", "Artboard 3", "Artboard 4"], "nothing is renamed before OK");
assert_eq!(undo_steps(&app), steps);
app.ui.dialog.as_mut().unwrap().fields.insert("name".into(), json!("Page"));
crate::dialogs::confirm(&mut app).unwrap();
assert_eq!(board_names(&app), ["Artboard 1", "Page-1", "Page-2", "Artboard 4"]);
assert_eq!(undo_steps(&app), steps + 1, "one undo step");
window.rename_from_row_menu(&mut app, "Artboard 4");
let d = app.ui.dialog.as_ref().expect("the Rename dialog");
assert_eq!((d.str("name").as_str(), &d.fields["indices"]), ("Artboard 4", &json!([3])), "the dialog takes the row's artboard alone");
assert_eq!((picked(&app, 4), selected(&app, 4)), (vec![3], 3), "the row's artboard is the only one selected");
crate::dialogs::cancel(&mut app);
assert!(app.ui.dialog.is_none());
assert_eq!(board_names(&app), ["Artboard 1", "Page-1", "Page-2", "Artboard 4"]);
assert_eq!(undo_steps(&app), steps + 1, "Cancel records no undo step");
}
/// Without the Artboard tool the row menu's Rename takes the right-clicked row's artboard, which
/// becomes the active one; a locked artboard can be renamed too. A menu closed without a choice
/// opens no dialog.
#[test]
fn the_row_menu_renames_the_right_clicked_artboard_with_any_tool() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 100, "height": 50, "units": "Points", "artboards": 3})).unwrap();
app.session.execute("artboard.setProps", &json!({"index": 2, "locked": true})).unwrap();
select(&mut app, 0);
let mut window = RowMenuWindow::new();
let at = window.find(&mut app, "Artboard 2").expect("the row");
window.click(&mut app, at, egui::PointerButton::Secondary);
assert!(window.find(&mut app, "Rename").is_some(), "the row menu is open");
let escape = egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() };
window.frame(&mut app, vec![escape]);
window.frame(&mut app, vec![]);
assert!(window.find(&mut app, "Rename").is_none(), "Escape closes the menu");
assert!(app.ui.dialog.is_none(), "and opens no dialog");
assert_eq!(selected(&app, 3), 1, "the right-clicked artboard is active");
window.rename_from_row_menu(&mut app, "Artboard 3");
let d = app.ui.dialog.as_ref().expect("the Rename dialog");
assert_eq!((d.str("name").as_str(), &d.fields["indices"]), ("Artboard 3", &json!([2])));
app.ui.dialog.as_mut().unwrap().fields.insert("name".into(), json!("Back"));
crate::dialogs::confirm(&mut app).unwrap();
assert_eq!(board_names(&app), ["Artboard 1", "Artboard 2", "Back"], "the locked artboard is renamed");
}
/// A row dragged with the right button reorders nothing; the primary button still drags rows.
#[test]
fn a_right_button_drag_does_not_move_a_row() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 100, "height": 100, "artboards": 3})).unwrap();
let mut window = RowMenuWindow::new();
let (first, last) = (window.find(&mut app, "Artboard 1").unwrap(), window.find(&mut app, "Artboard 3").unwrap());
let below = last + vec2(0.0, 8.0);
let button = |at, pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed, modifiers: Default::default() };
window.frame(&mut app, vec![egui::Event::PointerMoved(first), button(first, true)]);
window.frame(&mut app, vec![egui::Event::PointerMoved(first + vec2(0.0, 12.0))]);
window.frame(&mut app, vec![egui::Event::PointerMoved(below)]);
window.frame(&mut app, vec![button(below, false)]);
window.frame(&mut app, vec![]);
assert_eq!(board_names(&app), ["Artboard 1", "Artboard 2", "Artboard 3"]);
}
/// A click in a row's lock column locks its artboard and shows the lock next to the name, a
/// second click unlocks it, and each click is one undo step. Delete Artboard leaves a locked
/// artboard.
+46 -11
View File
@@ -1795,6 +1795,11 @@ path to a value inside a list or an object, and the document and its undo histor
→ invalid parameters for `view.saved.edit`: `edits[0].delete` must be true or false, not 1
```
A schema nests at most 16 lists and objects. A value that would go past that depth is refused with the same kind of
parameter error, ``invalid parameters for `<id>`: `<path>` is nested too deeply``, where `<path>` is the path to the
value. A schema that is wrong in itself, such as one that nests past 16 levels or puts a list of ids or a point
inside 16 lists, is an internal error in the command, and the command does not run.
An omitted value with a `default` takes it (`rotation` 0 in `view.saved.new`); the journal keeps the params as they
were given. Saved steps played from a journal or an action keep a key the schema does not name, unless its value nests
more than 16 levels deep, and the values of the parameters it names are checked as in a new call. The schemas of built-in commands belong to API level 3
@@ -2982,13 +2987,14 @@ already walks Image Trace. Short agent entry points:
the file at `path`, else a picked one, or the file's text `data`, adds its set after the other sets and returns
`{set, name, actions}`. A set named like one already in the panel gets the next free number (`Print 2`), and the
other sets and the current set stay as they are. An action set file (`.vcactions`) is JSON:
`{"format": "vcactions", "version": 1, 2 or 3, "name", "actions": [{"name", "steps": [{"command", "params", "excluded"?}]}]}`, each step
`{"format": "vcactions", "version": 1 to 4, "name", "actions": [{"name", "steps": [{"command", "params", "excluded"?}]}]}`, each step
a command id and its params as `command.batch` takes them, or a stop: the command `action.stop` with
`{message, allowContinue}`. `excluded: true` preserves a step but omits it from playback;
an absent or false flag includes the step. Files with stops use version 3, and files with excluded steps and no stops
use version 2, so older builds refuse them
instead of playing the excluded steps or failing at a stop; other files use version 1. A file with another `format` or a `version` other than
1, 2 or 3, a stop whose message is not text or whose `allowContinue` is not true or false, a file larger than 8 MiB, one with more than 1,000 actions and one with an action of more than 10,000 steps are
`{message, allowContinue}`. A Select Object step is the command `select.byNote` with `{note, caseSensitive,
wholeWord}`. `excluded: true` preserves a step but omits it from playback;
an absent or false flag includes the step. Files with Select Object steps use version 4, files with stops and no
Select Object step use version 3, and files with excluded steps and neither use version 2, so older builds refuse them
instead of playing the excluded steps or failing at a stop or a Select Object step; other files use version 1. A file with another `format` or a `version` other than
1 to 4, a stop whose message is not text or whose `allowContinue` is not true or false, a Select Object step whose params `ui.action.insertSelectObject` would refuse, a file larger than 8 MiB, one with more than 1,000 actions and one with an action of more than 10,000 steps are
refused, and no set is added. So is a file whose set `ui.actionSet.save` would write larger than 8 MiB (a file with
less white space, a set name that gets a number, or an `.aia` set), with the error `the action set would be larger
than 8 MiB`. The Actions panel menu's Save Actions… and Load Actions… run these; Load Actions… is
@@ -3081,6 +3087,28 @@ already walks Image Trace. Short agent entry points:
`actionInsertMenuItem` dialog (`find`, `command`, `params`, `set`, `action`, `step`): typing in Find and pressing
Find picks the one item whose menu path, shown name or command id holds every word typed, or lists the matches, and
OK runs this.
- Actions: `ui.action.insertSelectObject {set?, action?, step?, note, caseSensitive?, wholeWord?}` inserts a Select
Object step without running it. The step is `select.byNote {note, caseSensitive, wholeWord}`: during playback it
selects the objects whose Attributes note matches `note`, and the steps after it act on them. `note` loses its
leading and trailing spaces and must then be 1 to 1,024 bytes; `caseSensitive` and `wholeWord` default to false. The
place, the selected step and the result are as `ui.action.insertStop` has them. A missing or empty note, a param of
another type, params that are not an object, an index past the last set, action or step, no action and no
recording, or a step that would give the action more than 10,000 steps or its set's file more than 8 MiB is an
error, and then nothing changes. When the step finds no object, the playback stops there with that error: at
Accelerated the whole batch is undone as for any failing step, and at Step by Step and Pause For the steps already
played stay. The panel's row for the step reads `Select Object: <note>`. The panel menu's Select Object… opens the
`actionSelectObject` dialog, Set Selection (`note`, `caseSensitive`, `wholeWord`, `set`, `action`, `step`), whose OK
runs this; OK is off while the note is empty. The two options, the 1,024-byte limit and stopping when nothing is
found are VectorCraft's own rules.
- Select: `select.byNote {note, caseSensitive?, wholeWord?}` selects the objects whose Attributes note
(`attributes.set {note}`) holds `note` without its leading and trailing spaces, and returns `{count, ids}`. By default
the text may be anywhere in the note and in any letter case. `caseSensitive: true` asks for the same letter case, and
`wholeWord: true` for no letter, digit or `_` right before or after the text. It looks at the objects Select All
takes and the objects inside them, and leaves out hidden and locked objects and art on a locked artboard; in
isolation mode it looks inside the isolated group only. An object whose note matches is selected without the objects
inside it, and a member whose note matches is selected on its own. The objects found replace the selection, in paint
order. The command makes no undo step, and Select › Reselect repeats it. An empty note, a note longer than 1,024
bytes, a param of another type, or no object found is an error, and then the selection stays as it was.
- Actions: `ui.action.continue {}` plays on from the stop the playing action waits at, on the document active now, and
closes the stop's message box (`actionStopMessage`). The next step runs at once, or at Accelerated the steps up to
the next stop as one undo step. It returns `{results, pending, atStop?}`. With no action waiting at a stop it is an
@@ -4293,7 +4321,10 @@ document's raster effects resolution (shadows and outer glows under the art; a b
the object with the image). Type with a geometry effect (Outline Object included) or with fills and strokes of its own
is outlined; type that has no fills or strokes of its own and whose only effects are shadows or outer glows stays
type, with the image under it. A group's or layer's own fills and strokes become objects among its members, and its
members are expanded too. The result is `{ids}`; when an image in the art can't be decoded, the images made from
members are expanded too. A symbol instance whose topmost fill or topmost stroke is visible, has an opacity above 0
and has a geometry effect becomes a group of its art: that fill's effects reshape the filled shapes of the art and that
stroke's effects reshape its stroked shapes, which keep their colors, as the canvas draws them (VectorCraft's own
rule); the instance's other fills and strokes are dropped. The result is `{ids}`; when an image in the art can't be decoded, the images made from
raster effects do not show it, and the result also carries the `warning` that Flatten Transparency gives.
A path's strokes stay strokes, as the
[Expand objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/expand-objects.html) help
@@ -4817,8 +4848,9 @@ the curve at an end), and the neighbours' handles keep their directions (their l
moved there: the tool's click-and-drag on a segment). `op: "extend"` with `x`, `y` goes on from the `end` (default)
or the `start` of an open subpath (the new end curves into the path unless `corner` is true), and `op: "close"` closes it from that end; `from` says how the old end bends into
the new segment: `"keep"` (default) keeps its curve, `"smooth"` curves it through its neighbours (a point the tool
placed), `"corner"` leaves it without handles. Alt-click or double-click toggles a point with `path.convertAnchor`, and
Backspace/Delete removes the current point with `path.removeAnchor`.
placed), `"corner"` leaves it without handles. Double-click, or Alt-click on any point but an end of an open subpath,
toggles a point with `path.convertAnchor`; an Alt-click on an end of an open subpath only makes it the current point
(`select.anchors`), so the next click extends from it. Backspace/Delete removes the current point with `path.removeAnchor`.
```json
{"name":"run_command","arguments":{"command":"path.curvatureEdit","params":{"id":12,"op":"move","anchor":2,"x":260,"y":140}}}
@@ -5883,8 +5915,11 @@ The profile is read on every `file.new`, so a replayed journal or a recorded act
runs, as `document.open` reads its file when it runs. A file larger than 64 MiB (VectorCraft's own limit) is not read.
When the file cannot be read, is too large or is not a native document, or the request's read roots leave out the
folder (whether or not the file exists), the document starts with the defaults and
`file.new`'s result carries `profileError: {profile, reason}`; the desktop app shows the reason in the status bar. In
the New Document dialog, Manage Profiles lists the profiles with Edit and Reset buttons. Edit is available only when
`file.new`'s result carries `profileError: {profile, reason}`; the desktop app shows the reason in the status bar. The
reason given when the read roots leave out the folder does not name the folder's path. Color profiles the profile's
file carries are installed when they are missing, as `document.open` installs them, and each one that can't be used
adds a line to `file.new`'s `warnings`. The new document's links find their files through relative paths from the
profile's file until the document is saved. In the New Document dialog, Manage Profiles lists the profiles with Edit and Reset buttons. Edit is available only when
there is a profiles folder, and Reset only for a profile that has a file. An unknown profile name is an error that
lists the names.
File diff suppressed because one or more lines are too long
+7 -3
View File
@@ -177,9 +177,13 @@ solids are ([Groups](#groups)), turned about one shared axis.
- The objects stay live: editing the clipping path, the blend's keys or the symbol reshapes the solid.
- An image is refused, alone or in a group: trace it first with Object › Image Trace › Make. The help page routes
images to 3D through Image Trace ([Create 3D vector artwork](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-vector-artwork.html)).
A hidden image in a group is left out like any hidden member, and so is an image in a clipping group whose bounds
lie wholly outside the clipping path's bounds, when the image has no visible fills or strokes of its own and
neither the image nor a group holding it inside the clipping group has effects.
A hidden image in a group is left out like any hidden member. An image in a clipping group is left out when its
frame, as its transform places it, lies wholly outside the area that every clipping path around it fills together
(holes of an even-odd or compound clipping path included), when the image has no visible fills or strokes of its
own and neither the image nor a group holding it inside the clipping group has effects. A clipping path hides
everything outside its shape ([the help page](https://helpx.adobe.com/illustrator/using/clipping-masks.html)). An
image that the clips show any part of is refused. Resolving one effect's art cuts image frames to clipping paths at
most 1,024 times (VectorCraft's own limit); past that, the art is refused.
## Rotate
+1 -1
View File
@@ -231,7 +231,7 @@ tools, never checked side by side) isn't captured row by row.
| "intersect" label and snapping to path intersections | partial | #1658 (part of #1602): a point drawn with the Pen, the shape tools or the other drawing tools and an anchor dragged with Direct Selection snap to where paths and ruler guides cross; a moved selection's own anchors and the Width tool don't |
| Construction guides at preference angles while drawing | done | #506 |
| Line extension: the dragged end of a straight segment snaps to the segment's own direction at any angle, past its other end or short of it, with the guide along it | done | #2120; Direct Selection and Curvature, one anchor dragged (with several, the move has no single direction); nearer the horizontal or vertical through the other end, the point lines up with it ("align"), so a nearly level segment can still be straightened |
| Measurement labels while drawing, moving and resizing | partial | sizes and offsets; angle and distance readouts while drawing missing |
| Measurement labels while drawing, moving and resizing | done | sizes and offsets; the segment being drawn is labeled with its distance, angle and offsets by the Line Segment tool, and by the Pen, Curvature and Smart Pen tools with Enable Rubber Band on (#2983) |
| Spacing guides (equal distances) | partial | for moves and bounding-box resizes (#394); not while drawing |
| Distance Guides: Alt/Option over another object, or an empty part of an artboard, shows the selection's distances to it (Smart Guides › Invoke Distance Guides) | done | Selection tool hover with Smart Guides on: the gap between facing edges, else the offsets of matching edges, to another object; the left, right, top and bottom distances to the artboard; labelled in the General unit. The selection is measured as a whole. Nothing while moving or nudging, which the public pages don't describe. [Use Distance Guides](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/use-distance-guides-for-accurate-placement.html), [Smart Guides options](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/smart-guides-options.html) |
| Snapping tolerance preference | done | |