Merge main into fix/plugin-stale-grant

This commit is contained in:
dotCooCoo
2026-11-08 18:59:23 -08:00
117 changed files with 5093 additions and 545 deletions
Generated
+190
View File
@@ -2844,6 +2844,182 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "jxl-bitstream"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b480e752277e29eb4054f69546887a9b84656fe78c08f54ba5850ced98a378fe"
dependencies = [
"tracing",
]
[[package]]
name = "jxl-coding"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd972bcd125e776f1eb241ac50e39f956095a1c2770c64736c968f8946bd9a3c"
dependencies = [
"jxl-bitstream",
"tracing",
]
[[package]]
name = "jxl-color"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f316b1358c1711755b3ee8e8cb5c4a1dad12e796233088a7a513440782de80b2"
dependencies = [
"jxl-bitstream",
"jxl-coding",
"jxl-grid",
"jxl-image",
"jxl-oxide-common",
"jxl-threadpool",
"tracing",
]
[[package]]
name = "jxl-frame"
version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d967c6fd669c7c01060b5022d8835fa82fd46b06ffc98b549f17600a097c2b3"
dependencies = [
"jxl-bitstream",
"jxl-coding",
"jxl-grid",
"jxl-image",
"jxl-modular",
"jxl-oxide-common",
"jxl-threadpool",
"jxl-vardct",
"tracing",
]
[[package]]
name = "jxl-grid"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01671307879a033bfa52e6e8784b941aca770b3f3a7d33830b455b6844f793fb"
dependencies = [
"tracing",
]
[[package]]
name = "jxl-image"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5f752d62577c702a94dbbce4045caf08cb58639e8a4d56464b40ecf33ffe565"
dependencies = [
"jxl-bitstream",
"jxl-grid",
"jxl-oxide-common",
"tracing",
]
[[package]]
name = "jxl-jbr"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e35d032bcec660647828527ff42c6f5776d2fd44b8357f9f6d9ac6dc07218e46"
dependencies = [
"brotli-decompressor",
"jxl-bitstream",
"jxl-frame",
"jxl-grid",
"jxl-image",
"jxl-modular",
"jxl-oxide-common",
"jxl-threadpool",
"jxl-vardct",
"tracing",
]
[[package]]
name = "jxl-modular"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a2f045b24c738dd91d482be385512b512721ae08a671bd4b27bf1c47f215235"
dependencies = [
"jxl-bitstream",
"jxl-coding",
"jxl-grid",
"jxl-oxide-common",
"jxl-threadpool",
"tracing",
]
[[package]]
name = "jxl-oxide"
version = "0.12.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d36c662923f47586880211f3bc7c0d83fb3a9b410d278c7bde93450748abeef3"
dependencies = [
"brotli-decompressor",
"jxl-bitstream",
"jxl-color",
"jxl-frame",
"jxl-grid",
"jxl-image",
"jxl-jbr",
"jxl-oxide-common",
"jxl-render",
"jxl-threadpool",
"tracing",
]
[[package]]
name = "jxl-oxide-common"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b62394c5021b3a9e7e0dbb2d639d555d019090c9946c39f6d3b09d390db4157b"
dependencies = [
"jxl-bitstream",
]
[[package]]
name = "jxl-render"
version = "0.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d34386bfdb6a19b5a30cc9beb4d475d537422c31ae8c39bb69640fcce3fcaf19"
dependencies = [
"bytemuck",
"jxl-bitstream",
"jxl-coding",
"jxl-color",
"jxl-frame",
"jxl-grid",
"jxl-image",
"jxl-modular",
"jxl-oxide-common",
"jxl-threadpool",
"jxl-vardct",
"tracing",
]
[[package]]
name = "jxl-threadpool"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25f15eb830aa77a7f21148d72e153562a26bfe570139bd4922eab1908dd499d3"
dependencies = [
"tracing",
]
[[package]]
name = "jxl-vardct"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce72a18c6d3a47172ab6c479be2bdb56f22066b5d7092663f03b4490820b4511"
dependencies = [
"jxl-bitstream",
"jxl-coding",
"jxl-grid",
"jxl-modular",
"jxl-oxide-common",
"jxl-threadpool",
"tracing",
]
[[package]]
name = "keyboard-types"
version = "0.8.3"
@@ -6292,6 +6468,9 @@ dependencies = [
"hayro-jpeg2000",
"heic-rs",
"image",
"jxl-color",
"jxl-grid",
"jxl-oxide",
"kurbo 0.13.1",
"log",
"proptest",
@@ -6323,6 +6502,8 @@ dependencies = [
"vectorcraft-tools",
"vectorcraft-trace",
"wat",
"zune-core 0.5.3",
"zune-jpegxl",
]
[[package]]
@@ -8097,6 +8278,15 @@ dependencies = [
"zune-core 0.5.3",
]
[[package]]
name = "zune-jpegxl"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cba11ecd07cc23351500e840ae03841b7e4997923ec8e4832ee3ae199ea0b6b4"
dependencies = [
"zune-core 0.5.3",
]
[[package]]
name = "zvariant"
version = "5.15.1"
+10
View File
@@ -105,6 +105,16 @@ gif = { version = "0.14", default-features = false, features = ["std", "raii_no_
# decoders, read directly (the image crate converts CMYK to RGB).
tiff = "0.11"
zune-jpeg = "0.5"
# JPEG XL decoder (pure Rust, MIT OR Apache-2.0): File › Open and Place read `.jxl` images. It is
# built without its default `rayon` feature and decodes on one thread.
jxl-oxide = { version = "0.12.6", default-features = false }
# jxl-oxide's color conversions, run on colors after their alpha is divided out.
jxl-color = "0.11.0"
# jxl-oxide's buffer allocation error, told apart from other decoding errors.
jxl-grid = "0.6.2"
# Tests: a lossless JPEG XL encoder (pure Rust, MIT OR Apache-2.0 OR Zlib) for the `.jxl` test files.
zune-jpegxl = { version = "0.5.2", default-features = false, features = ["std"] }
zune-core = "0.5"
# ui
egui = "0.37"
+1 -1
View File
@@ -237,7 +237,7 @@ and what's next; [`docs/target-app-parity.md`](docs/target-app-parity.md) the fu
(92.5% of menu items do something), feature depth is ~75%, and we estimate ~65% ready for real work for a mainstream illustrator (~62% across all tracked features): everyday
vector illustration is close to usable: drawing and path tools,
Pathfinder and Shape Builder, paint, gradients, appearance and transparency, type with styles, threading and Hebrew/Arabic bidirectional layout, and
files (SVG, PDF and PDF-compatible `.ai` with PDF/X, legacy version 7 `.ai` keeping layers, groups and type, EPS, DXF, EMF/WMF, raster formats, PSD out and PSD/PSB in as their merged image, Print, Package).
files (SVG, PDF and PDF-compatible `.ai` with PDF/X, legacy version 7 `.ai` keeping layers, groups and type, EPS, DXF, EMF/WMF, raster formats, PSD out and PSD/PSB in as their merged image, JPEG XL in, Print, Package).
Affinity documents (`.af` from Affinity 3, `.afdesign`, `.afpub` and, by their content, `.afphoto` from Affinity 1 and 2) open
and place natively: layers, groups, artboards and pages, curves and shapes, fills, gradients and strokes, clipping
and masks, text with each paragraph's indents, spacing and leading, tables (as text frames and lines), master
+47 -5
View File
@@ -395,6 +395,15 @@ impl Source {
}
}
/// The centers of a `cols` × `rows` grid of cells over `b`, row by row.
fn cell_centers(b: Rect, cols: u16, rows: u16) -> impl Iterator<Item = Point> {
let (cw, ch) = (b.width() / cols as f64, b.height() / rows as f64);
(0..rows as usize * cols as usize).map(move |k| {
let (i, j) = (k % cols as usize, k / cols as usize);
Point::new(b.x0 + (i as f64 + 0.5) * cw, b.y0 + (j as f64 + 0.5) * ch)
})
}
/// A freeform gradient ready to sample ([`Freeform::field`]).
#[derive(Clone, Debug)]
pub struct Field {
@@ -429,11 +438,7 @@ impl Field {
/// Samples at the centres of a `cols` × `rows` grid of cells over `b`, row by row: RGB and
/// opacity (as [`Self::sample`]).
pub fn grid(&self, b: Rect, cols: u16, rows: u16) -> impl Iterator<Item = ([f32; 3], f32)> + '_ {
let (cw, ch) = (b.width() / cols as f64, b.height() / rows as f64);
(0..rows as usize * cols as usize).map(move |k| {
let (i, j) = (k % cols as usize, k / cols as usize);
self.sample(Point::new(b.x0 + (i as f64 + 0.5) * cw, b.y0 + (j as f64 + 0.5) * ch))
})
cell_centers(b, cols, rows).map(move |p| self.sample(p))
}
/// Display RGB and opacity at `p`.
@@ -503,6 +508,27 @@ impl ModelField {
};
(c, a)
}
/// [`Self::sample`] at the centers of a `cols` × `rows` grid of cells over `b`, row by row.
pub fn grid(&self, b: Rect, cols: u16, rows: u16) -> impl Iterator<Item = (Color, f32)> + '_ {
cell_centers(b, cols, rows).map(move |p| self.sample(p))
}
/// Display RGB and opacity at `p`: the display color of [`Self::sample`], or the display RGB
/// the points mix in when their models differ.
pub fn display_sample(&self, p: Point) -> ([f32; 3], f32) {
if self.model.is_none() {
return self.first.sample(p);
}
let (c, a) = self.sample(p);
(c.to_rgb(), a)
}
/// [`Self::display_sample`] at the centers of a `cols` × `rows` grid of cells over `b`, row by
/// row.
pub fn display_grid(&self, b: Rect, cols: u16, rows: u16) -> impl Iterator<Item = ([f32; 3], f32)> + '_ {
cell_centers(b, cols, rows).map(move |p| self.display_sample(p))
}
}
#[cfg(test)]
@@ -530,6 +556,22 @@ mod tests {
a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-4)
}
#[test]
fn the_model_field_grid_and_display_samples_match_its_sample() {
let mut f = two(0.0);
(f.points[0].color, f.points[1].color) = (Color::cmyk(1.0, 0.0, 0.0, 0.0), Color::cmyk(0.0, 0.0, 0.0, 1.0));
let field = f.model_field(50.0);
let b = Rect::new(0.0, 0.0, 100.0, 20.0);
let (cells, shown): (Vec<_>, Vec<_>) = (field.grid(b, 5, 1).collect(), field.display_grid(b, 5, 1).collect());
let (mid, a) = field.sample(Point::new(50.0, 10.0));
assert_eq!(cells[2], (mid, a));
assert_eq!(shown[2], (mid.to_rgb(), a));
// Points in different models give their display mix.
(f.points[0].color, f.points[1].color) = (red(), Color::gray(0.5));
let (rgb, _) = f.model_field(50.0).display_sample(Point::new(50.0, 0.0));
assert!(close(rgb, [0.75, 0.25, 0.25]), "{rgb:?}");
}
#[test]
fn the_sample_at_a_point_is_its_colour() {
let f = two(0.0);
+6 -5
View File
@@ -84,8 +84,9 @@ impl Default for HitOptions {
/// The shapes that an image's own fills and strokes paint, each with its fill rule and the fill or
/// stroke that paints it (none for fills and strokes that paint nothing). Effects are evaluated
/// above this crate, so the engine registers one with [`set_image_paint`].
pub type ImagePaint = fn(&Node) -> Vec<(BezPath, FillRule, AppearanceItem)>;
/// above this crate, so the engine registers one with [`set_image_paint`]. It looks up swatches and
/// patterns in the document it is given.
pub type ImagePaint = fn(Option<&Document>, &Node) -> Vec<(BezPath, FillRule, AppearanceItem)>;
static IMAGE_PAINT: OnceLock<ImagePaint> = OnceLock::new();
@@ -98,8 +99,8 @@ pub fn set_image_paint(f: ImagePaint) {
/// The shapes that the image `n`'s own fills and strokes paint ([`set_image_paint`]), none while
/// nothing is registered.
pub(crate) fn image_paint(n: &Node) -> Vec<(BezPath, FillRule, AppearanceItem)> {
IMAGE_PAINT.get().map_or_else(Vec::new, |f| f(n))
pub(crate) fn image_paint(doc: Option<&Document>, n: &Node) -> Vec<(BezPath, FillRule, AppearanceItem)> {
IMAGE_PAINT.get().map_or_else(Vec::new, |f| f(doc, n))
}
/// One level of evaluation of a live object in a document, as the canvas draws it: type in an
@@ -871,7 +872,7 @@ fn hit_leaf(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Optio
if opt.outline {
return None;
}
image_paint(n).iter().rev().find_map(|(bp, rule, item)| match item {
image_paint(Some(symbols.document()), n).iter().rev().find_map(|(bp, rule, item)| match item {
AppearanceItem::Stroke(s) => s.hits(bp, *rule, p, opt.tol).then_some(HitKind::Stroke),
AppearanceItem::Fill(_) => (!opt.path_only && fill_contains(bp, *rule, p)).then_some(HitKind::Fill),
})
+6 -4
View File
@@ -239,10 +239,12 @@ impl Node {
NodeKind::Intertwine { children } => children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.reach(symbols))),
// The frame and each shape the image's own fills and strokes paint, a stroke's grown by
// the farthest it can reach from its shape.
NodeKind::Image(_) => crate::hit::image_paint(self).iter().fold(self.geometric(symbols), |acc, (bp, _, item)| {
let o = if let crate::AppearanceItem::Stroke(s) = item { s.reach() } else { 0.0 };
vectorcraft_geom::union_opt(acc, (!bp.elements().is_empty()).then(|| bp.bounding_box().inflate(o, o)))
}),
NodeKind::Image(_) => {
crate::hit::image_paint(symbols.map(SymbolBoxes::document), self).iter().fold(self.geometric(symbols), |acc, (bp, _, item)| {
let o = if let crate::AppearanceItem::Stroke(s) = item { s.reach() } else { 0.0 };
vectorcraft_geom::union_opt(acc, (!bp.elements().is_empty()).then(|| bp.bounding_box().inflate(o, o)))
})
}
// Keys and steps placed on a spine, turned to it with Align to Path, can reach past the
// keys and the spine.
NodeKind::Blend { children, spec } => {
+26 -23
View File
@@ -95,8 +95,9 @@ pub struct DataSet {
impl DataSet {
/// Whether the art, as [`Document::captured_values`] read it, still holds what this row
/// says. A value the art can't report (its object is locked or gone) isn't drift: the row
/// keeps it, and applying it again would skip it too.
/// says. A value the art can't report (its object is gone, or is locked or in a locked layer
/// or group and the variable isn't a visibility variable) isn't drift: the row keeps it, and
/// applying it again would skip it too.
pub fn matches(&self, current: &BTreeMap<String, DataValue>) -> bool {
self.values.iter().all(|(k, v)| current.get(k).is_none_or(|c| c == v))
}
@@ -193,33 +194,35 @@ impl Document {
self.variables.bindings = bindings;
}
/// Whether a variable may be bound to the object `id`: it is in the document, neither it nor
/// anything above it is locked, nothing above it is hidden, and it is not on a locked artboard
/// ([`Document::on_locked_artboard`]). Its own visibility doesn't count, as that is what a
/// visibility variable drives.
pub fn variable_target(&self, id: NodeId) -> bool {
self.variable_target_with(&self.locked_artboards(), id)
/// Whether a variable of `kind` may be bound to the object `id`: it is in the document,
/// nothing above it is hidden, it is not on a locked artboard ([`Document::on_locked_artboard`]),
/// and, unless `kind` is visibility, neither it nor anything above it is locked. Its own
/// visibility doesn't count, as that is what a visibility variable drives.
pub fn variable_target(&self, id: NodeId, kind: VariableKind) -> bool {
self.variable_target_with(&self.locked_artboards(), id, kind)
}
/// [`Document::variable_target`] with the locked artboards `locks` of this document, measured
/// once for many ids.
pub fn variable_target_with(&self, locks: &LockedArtboards, id: NodeId) -> bool {
self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| !n.locked && (n.visible || *i == id))))
pub fn variable_target_with(&self, locks: &LockedArtboards, id: NodeId, kind: VariableKind) -> bool {
let locking = kind != VariableKind::Visibility;
self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| !(locking && n.locked) && (n.visible || *i == id))))
&& !locks.holds_id(id)
}
/// Whether a data set reads and rewrites the bound object `id`: it is in the document, neither
/// it nor anything above it is locked, and it is not on a locked artboard
/// ([`Document::on_locked_artboard`]). Hidden objects count, and so do objects in a hidden
/// layer or group.
pub fn dataset_target(&self, id: NodeId) -> bool {
self.dataset_target_with(&self.locked_artboards(), id)
/// Whether a data set reads and rewrites the bound object `id` for a variable of `kind`: it
/// is in the document, it is not on a locked artboard ([`Document::on_locked_artboard`]) and,
/// unless `kind` is visibility, neither it nor anything above it is locked. Hidden objects
/// count, and so do objects in a hidden layer or group.
pub fn dataset_target(&self, id: NodeId, kind: VariableKind) -> bool {
self.dataset_target_with(&self.locked_artboards(), id, kind)
}
/// [`Document::dataset_target`] with the locked artboards `locks` of this document, measured
/// once for many ids.
pub fn dataset_target_with(&self, locks: &LockedArtboards, id: NodeId) -> bool {
self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| !n.locked))) && !locks.holds_id(id)
pub fn dataset_target_with(&self, locks: &LockedArtboards, id: NodeId, kind: VariableKind) -> bool {
let locking = kind != VariableKind::Visibility;
self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| !(locking && n.locked)))) && !locks.holds_id(id)
}
/// The type objects of the story that the type object `id` belongs to, in thread order: the
@@ -253,21 +256,21 @@ impl Document {
/// [`Document::story_target`] with the locked artboards `locks` of this document, measured
/// once for many ids.
pub fn story_target_with(&self, locks: &LockedArtboards, id: NodeId) -> bool {
self.story_of(id).into_iter().all(|f| self.dataset_target_with(locks, f))
self.story_of(id).into_iter().all(|f| self.dataset_target_with(locks, f, VariableKind::Text))
}
/// What the object `id` holds for the variable of `kind`: its characters (the whole story
/// for a frame of threaded text), whether it shows, or a linked image's path. `None` when the
/// object is gone, is not of that kind, has no link path yet (an embedded image), or is locked,
/// in a locked layer or group, or on a locked artboard, or for text when a frame of its story
/// is (the document's value is whatever it was left at).
/// object is gone, is not of that kind, has no link path yet (an embedded image), or, unless
/// `kind` is visibility, is locked or in a locked layer or group, or is on a locked artboard,
/// or for text when a frame of its story is (the document's value is whatever it was left at).
pub fn variable_value(&self, id: NodeId, kind: VariableKind) -> Option<DataValue> {
self.variable_value_with(&self.locked_artboards(), id, kind)
}
/// [`Document::variable_value`] with the locked artboards `locks` of this document.
fn variable_value_with(&self, locks: &LockedArtboards, id: NodeId, kind: VariableKind) -> Option<DataValue> {
if !self.dataset_target_with(locks, id) {
if !self.dataset_target_with(locks, id, kind) {
return None;
}
let node = self.node(id)?;
+15 -14
View File
@@ -348,16 +348,16 @@ fn moved_type_group(n: &Node) -> Option<Node> {
/// outlined as [`evaluate_container`] outlines it, and the other members become what it makes of
/// them. `None` when no type stays type.
fn container_keeping_type(d: &Document, n: &Node) -> Option<Node> {
fn keep(n: &mut Node, moves: &[serde_json::Value], clipping: bool, kept: &mut bool) {
fn keep(d: &Document, n: &mut Node, moves: &[serde_json::Value], clipping: bool, kept: &mut bool) {
let clips = n.clips();
let Some(ch) = n.children_mut() else { return };
for (i, c) in ch.iter_mut().enumerate() {
let clipping = clipping || (clips && i == 0);
if !matches!(c.kind, NodeKind::Text(_)) {
if has_pathfinder(c) || has_own_paint(c) || !geometry_effects(c).is_empty() {
outline_nested_text(Arc::make_mut(c), clipping);
outline_nested_text(Some(d), Arc::make_mut(c), clipping);
} else if c.children().is_some() {
keep(Arc::make_mut(c), moves, clipping, kept);
keep(d, Arc::make_mut(c), moves, clipping, kept);
}
continue;
}
@@ -372,7 +372,7 @@ fn container_keeping_type(d: &Document, n: &Node) -> Option<Node> {
}
}
None if clipping => outline_text(c).map(|o| Node { visible: c.visible, ..o }),
None => as_art(None, c, None),
None => as_art(Some(d), c, None),
};
if let Some(o) = art {
*c = Arc::new(o);
@@ -391,11 +391,11 @@ fn container_keeping_type(d: &Document, n: &Node) -> Option<Node> {
}
let mut m = crate::reshape::evaluated(Some(d), n);
let mut kept = false;
keep(&mut m, &moves, false, &mut kept);
keep(d, &mut m, &moves, false, &mut kept);
if !kept {
return None;
}
reshape_leaves(&mut m, &fx);
reshape_leaves(&mut m, &fx, Some(d));
m.appearance.effects.retain(|e| !is_pathfinder(&e.id) && !is_geometry(&e.id));
m.appearance.items.clear();
m.appearance.contents_index = None;
@@ -547,9 +547,10 @@ pub fn expand_art(d: &mut Document, n: &mut Node, stroke_art: StrokeArt) {
/// its gradient meshes clipped to the outline; a brushed stroke stays a stroke), in paint order, as
/// the members of a group keeping `n`'s id, transparency, opacity mask and raster effects. A path
/// with [`has_transform_art`](crate::has_transform_art) is first made into the art [`reshape`]
/// makes of it, and each copy is baked this way. `None` when it has neither a stroke to outline nor
/// a geometry effect, so bending first changes nothing.
pub fn bake_appearance(n: &Node) -> Option<Node> {
/// makes of it, and each copy is baked this way; the swatches and patterns that live-effect plug-ins
/// painting their pieces name are looked up in `doc`. `None` when it has neither a stroke to
/// outline nor a geometry effect, so bending first changes nothing.
pub fn bake_appearance(doc: Option<&Document>, n: &Node) -> Option<Node> {
if let Some(m) = crate::revolve_art(n).or_else(|| crate::solid_art(n)) {
return Some(m);
}
@@ -560,9 +561,9 @@ pub fn bake_appearance(n: &Node) -> Option<Node> {
// patterns: the art Expand Appearance makes of it (each copy an object with its own strokes,
// patterns and gradients), each piece baked in turn.
if crate::has_transform_art(n)
&& let Some(mut m) = reshape(None, n, None)
&& let Some(mut m) = reshape(doc, n, None)
{
bake_appearance_pieces(&mut m);
bake_appearance_pieces(doc, &mut m);
return Some(m);
}
let outlined = |i: &AppearanceItem| paints(i) && matches!(i, AppearanceItem::Stroke(s) if s.brush.is_none());
@@ -617,15 +618,15 @@ pub fn bake_appearance(n: &Node) -> Option<Node> {
}
/// [`bake_appearance`] every path and compound path in `n` that it changes, in place.
fn bake_appearance_pieces(n: &mut Node) {
fn bake_appearance_pieces(doc: Option<&Document>, n: &mut Node) {
if matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) {
if let Some(m) = bake_appearance(n) {
if let Some(m) = bake_appearance(doc, n) {
*n = m;
}
return;
}
for c in n.children_mut().into_iter().flatten() {
bake_appearance_pieces(Arc::make_mut(c));
bake_appearance_pieces(doc, Arc::make_mut(c));
}
}
+6 -6
View File
@@ -536,7 +536,7 @@ fn a_baked_stroke_keeps_its_raster_effects() {
s.effects.push(fx("path.offsetPath", json!({"offset": 5.0})));
s.effects.push(fx("stylize.dropShadow", json!({})));
}
let m = bake_appearance(&n).unwrap();
let m = bake_appearance(None, &n).unwrap();
let ch = m.children().unwrap();
let Some(AppearanceItem::Fill(f)) = ch[1].appearance.items.first() else { panic!("the outline is filled") };
assert_eq!(f.effects.iter().map(|e| e.id.as_str()).collect::<Vec<_>>(), ["stylize.dropShadow"]);
@@ -551,7 +551,7 @@ fn a_baked_stroke_leaves_dither_behind() {
g.gradient.dither = true;
s.paint = Paint::Gradient(Box::new(g));
}
let m = bake_appearance(&n).unwrap();
let m = bake_appearance(None, &n).unwrap();
let ch = m.children().unwrap();
let Some(AppearanceItem::Fill(f)) = ch[1].appearance.items.first() else { panic!("the outline is filled") };
let Paint::Gradient(g) = &f.paint else { panic!("a gradient fill") };
@@ -567,7 +567,7 @@ fn a_baked_stroke_with_a_gradient_along_it_becomes_meshes_clipped_to_its_outline
s.gradient_mode = vectorcraft_doc::StrokeGradientMode::Along;
s.opacity = 0.5;
}
let m = bake_appearance(&n).unwrap();
let m = bake_appearance(None, &n).unwrap();
let ch = m.children().unwrap();
let NodeKind::Group { children, clip: true } = &ch[1].kind else { panic!("a clip group: {:?}", ch[1].kind) };
assert!(matches!(children[0].kind, NodeKind::Path { clipping: true, .. }));
@@ -600,7 +600,7 @@ fn a_baked_transform_copy_keeps_its_scaled_stroke() {
// Distort Appearance: the 200% copy's stroke is 4 pt, as outside the envelope, so its outline
// is 204 pt wide.
let n = transformed(Paint::None, Paint::solid(Color::BLACK), 2.0, json!({"scaleH": 200, "scaleV": 200, "copies": 1, "strokes": true}));
let m = bake_appearance(&n).unwrap();
let m = bake_appearance(None, &n).unwrap();
let w = widths(&m);
assert_eq!(w.len(), 2, "{w:?}");
assert!((w[0] - 102.0).abs() < 0.5 && (w[1] - 204.0).abs() < 0.5, "{w:?}");
@@ -611,7 +611,7 @@ fn a_baked_transform_copy_keeps_its_scaled_stroke() {
fn a_baked_transform_copy_keeps_its_moved_pattern() {
let paint = Paint::Pattern { pattern: "p".into(), xf: vectorcraft_geom::Affine::IDENTITY };
let n = transformed(paint, Paint::None, 0.0, json!({"moveH": 30, "copies": 1, "patterns": true}));
let m = bake_appearance(&n).unwrap();
let m = bake_appearance(None, &n).unwrap();
let xs: Vec<f64> = baked_paths(&m)
.iter()
.map(|p| match p.appearance.fill_paint() {
@@ -626,7 +626,7 @@ fn a_baked_transform_copy_keeps_its_moved_pattern() {
fn a_baked_transform_without_its_options_outlines_the_copies_together() {
// Without Scale Strokes & Effects the copy keeps the 2 pt stroke: one outline of both squares.
let n = transformed(Paint::None, Paint::solid(Color::BLACK), 2.0, json!({"scaleH": 200, "scaleV": 200, "copies": 1}));
let m = bake_appearance(&n).unwrap();
let m = bake_appearance(None, &n).unwrap();
let w = widths(&m);
assert_eq!(w.len(), 1, "{w:?}");
assert!((w[0] - 202.0).abs() < 0.5, "{w:?}");
+6 -2
View File
@@ -3,6 +3,7 @@ use super::{
Appearance, AppearanceItem, BezPath, Effect, FillRule, Node, NodeKind, PathData, Rect, StrokeLayer, Value, WarpStyle, distort, stroke, stylize,
util, warp,
};
use vectorcraft_doc::Document;
/// Context the geometry effects may need from the object's appearance.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
@@ -22,6 +23,9 @@ pub struct GeomContext<'a> {
/// The index in `appearance.items` of the fill or stroke whose own effects these are; `None`:
/// the object's effects.
pub item: Option<usize>,
/// The document that the swatches and patterns named by a live-effect plug-in that paints its
/// pieces are looked up in. Without one, a result that names any is refused.
pub doc: Option<&'a Document>,
}
impl<'a> GeomContext<'a> {
@@ -32,7 +36,7 @@ impl<'a> GeomContext<'a> {
_ => FillRule::NonZero,
};
let stroke = n.appearance.stroke().filter(|s| !s.paint.is_none() && s.width > 0.0);
Self { stroke, rule, seed: n.id.0, appearance: Some(&n.appearance), item: None }
Self { stroke, rule, seed: n.id.0, appearance: Some(&n.appearance), item: None, doc: None }
}
/// The context of the effects on `item`, one of the same object's appearance items: a
@@ -374,7 +378,7 @@ fn apply_unchecked(id: &str, p: &Value, path: &PathData, b: Rect, ctx: &GeomCont
use util::{join, num};
if let Some(plugin) = vectorcraft_plugins::effect::plugin_id(id) {
let paints = vectorcraft_plugins::effect::Paints { appearance: ctx.appearance, item: ctx.item };
return vectorcraft_plugins::effect::apply(plugin, p, path, b, ctx.rule, paints).unwrap_or_else(|| path.clone());
return vectorcraft_plugins::effect::apply(plugin, p, path, b, ctx.rule, paints, ctx.doc).unwrap_or_else(|| path.clone());
}
match id {
"distort.freeDistort" => distort::free_distort(path, b, p, max),
+60 -46
View File
@@ -67,25 +67,28 @@ pub type OutlineHook<'a> = &'a dyn Fn(&Node) -> Option<Node>;
/// back to front: a compound shape as its area, a blend, an envelope or an object with a warp as
/// the art it draws, and type and symbol instances as their art through `hook`. Past
/// [`SYMBOL_ART_MAX_NODES`](crate::SYMBOL_ART_MAX_NODES) objects of symbol art, further
/// instances add nothing.
pub fn member_leaves(n: &Node, hook: Option<OutlineHook>) -> Vec<Node> {
/// instances add nothing. The swatches and patterns that live-effect plug-ins painting their
/// pieces name are looked up in `doc` ([`shape_art`]).
pub fn member_leaves(doc: Option<&Document>, n: &Node, hook: Option<OutlineHook>) -> Vec<Node> {
let mut out = vec![];
leaves(n, hook, &SymbolNodes::default(), &mut out);
leaves(doc, n, hook, &SymbolNodes::default(), &mut out);
out
}
/// [`member_leaves`], with the symbol art given so far counted in `nodes`.
fn leaves(n: &Node, hook: Option<OutlineHook>, nodes: &SymbolNodes, out: &mut Vec<Node>) {
fn leaves(doc: Option<&Document>, n: &Node, hook: Option<OutlineHook>, nodes: &SymbolNodes, out: &mut Vec<Node>) {
if !n.visible {
return;
}
// A blend, an envelope or an object with a warp takes part as the art it draws.
if let Some(art) = shape_art_in(n, hook, nodes) {
if let Some(art) = shape_art_in(doc, n, hook, nodes) {
let first = out.len();
leaves(&art, hook, nodes, out);
// Every path of a live object's, type's or symbol instance's art is drawn inside the
// object's opacity and blend mode.
if crate::needs_outline(n) {
leaves(doc, &art, hook, nodes, out);
// Every path of a live object's, type's or symbol instance's art, and every piece of a path
// whose live-effect plug-in paints its pieces, is drawn inside the object's opacity and
// blend mode.
let pieces = matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) && matches!(art.kind, NodeKind::Group { .. });
if crate::needs_outline(n) || pieces {
for l in out.iter_mut().skip(first) {
within(l, n);
}
@@ -95,11 +98,11 @@ fn leaves(n: &Node, hook: Option<OutlineHook>, nodes: &SymbolNodes, out: &mut Ve
match &n.kind {
NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => out.push(n.clone()),
// A compound shape takes part as its area, styled like it.
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => match compound_shape_art_in(n, hook, nodes) {
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => match compound_shape_art_in(doc, n, hook, nodes) {
Some(art) => out.push(art),
None => {
for c in children {
leaves(c, hook, nodes, out);
leaves(doc, c, hook, nodes, out);
}
}
},
@@ -114,7 +117,7 @@ fn leaves(n: &Node, hook: Option<OutlineHook>, nodes: &SymbolNodes, out: &mut Ve
let art = match &n.kind {
NodeKind::SymbolInstance { symbol, .. } if nodes.charge(&art) => {
let mut art = crate::reshape::symbol_art_with(&art, h, nodes, symbol);
crate::reshape::outline_nested_text(&mut art, false);
crate::reshape::outline_nested_text(doc, &mut art, false);
transparency_into_members(&mut art);
art
}
@@ -122,7 +125,7 @@ fn leaves(n: &Node, hook: Option<OutlineHook>, nodes: &SymbolNodes, out: &mut Ve
_ => art,
};
let first = out.len();
leaves(&art, None, nodes, out);
leaves(doc, &art, None, nodes, out);
for l in out.iter_mut().skip(first) {
within(l, n);
}
@@ -176,29 +179,37 @@ fn transparency_into_members(n: &mut Node) {
/// [`outline_object`](crate::outline_object) gives a symbol instance its symbol's art. The art
/// keeps the effects of `n` other than its geometry effects, and the groups inside it pass their
/// opacity and blend mode to the objects they hold. `None` when `n` is none of these, for images,
/// for symbol instances without geometry effects, and for one without art from `hook`.
pub fn shape_art(n: &Node, hook: Option<OutlineHook>) -> Option<Node> {
shape_art_in(n, hook, &SymbolNodes::default())
/// for symbol instances without geometry effects, and for one without art from `hook`. A
/// live-effect plug-in that paints its pieces makes a path, and each path of a group's or symbol's
/// art, a group of its pieces, whose swatches and patterns must be in `doc` (without one, pieces
/// that name one are refused and the path stays as it is).
pub fn shape_art(doc: Option<&Document>, n: &Node, hook: Option<OutlineHook>) -> Option<Node> {
shape_art_in(doc, n, hook, &SymbolNodes::default())
}
/// [`shape_art`], with the symbol art given so far counted in `nodes`.
fn shape_art_in(n: &Node, hook: Option<OutlineHook>, nodes: &SymbolNodes) -> Option<Node> {
let mut art = reshaped_art(n, hook, nodes)?;
fn shape_art_in(doc: Option<&Document>, n: &Node, hook: Option<OutlineHook>, nodes: &SymbolNodes) -> Option<Node> {
let mut art = reshaped_art(doc, n, hook, nodes)?;
transparency_into_members(&mut art);
Some(art)
}
/// [`shape_art_in`] before the groups inside the art pass on their transparency.
fn reshaped_art(n: &Node, hook: Option<OutlineHook>, nodes: &SymbolNodes) -> Option<Node> {
fn reshaped_art(doc: Option<&Document>, n: &Node, hook: Option<OutlineHook>, nodes: &SymbolNodes) -> Option<Node> {
let fx = crate::reshape::geometry_effects(n);
let outline = hook.unwrap_or(&crate::outline_text);
match &n.kind {
NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } if !fx.is_empty() => Some(reshaped(n, n)),
NodeKind::Group { .. } | NodeKind::Layer { .. } if !fx.is_empty() => match compound_shape_art_in(n, hook, nodes) {
Some(area) => Some(reshaped(&area, n)),
// A path with a live-effect plug-in that paints its pieces: the group of its pieces, each
// with its own paints, as the renderer draws it.
NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } if !fx.is_empty() => {
let painted = crate::reshape::has_painted_effects(n).then(|| crate::reshape::reshape(doc, n, None)).flatten();
Some(painted.unwrap_or_else(|| reshaped(n, n, doc)))
}
NodeKind::Group { .. } | NodeKind::Layer { .. } if !fx.is_empty() => match compound_shape_art_in(doc, n, hook, nodes) {
Some(area) => Some(reshaped(&area, n, doc)),
None => {
let mut m = crate::reshape::outlined_members_with(None, n, outline, nodes);
let carry = crate::reshape::reshape_leaves_carrying(&mut m, &fx);
let mut m = crate::reshape::outlined_members_with(None, doc, n, outline, nodes);
let carry = crate::reshape::reshape_leaves_carrying(&mut m, &fx, doc);
// The group's own fills and strokes follow the effects as its members do.
if carry.moves(&m.appearance) {
carry.apply(&mut m.appearance);
@@ -213,8 +224,8 @@ fn reshaped_art(n: &Node, hook: Option<OutlineHook>, nodes: &SymbolNodes) -> Opt
let h = hook?;
let art = h(n).filter(|art| nodes.charge(art))?;
let mut art = crate::reshape::symbol_art_with(&art, h, nodes, symbol);
crate::reshape::outline_nested_text(&mut art, false);
let carry = crate::reshape::reshape_leaves_carrying(&mut art, &fx);
crate::reshape::outline_nested_text(doc, &mut art, false);
let carry = crate::reshape::reshape_leaves_carrying(&mut art, &fx, doc);
// The art's own fills and strokes follow the effects as its members do.
if carry.moves(&art.appearance) {
carry.apply(&mut art.appearance);
@@ -226,19 +237,21 @@ fn reshaped_art(n: &Node, hook: Option<OutlineHook>, nodes: &SymbolNodes) -> Opt
}
NodeKind::Image(_) | NodeKind::SymbolInstance { .. } => None,
_ if crate::needs_outline(n) && (vectorcraft_doc::live::is_live(n) || !fx.is_empty()) => {
crate::reshape::as_art_with(None, n, None, outline, nodes)
crate::reshape::as_art_with(None, doc, n, None, outline, nodes)
}
_ => None,
}
}
/// `src`, a path, compound path or compound shape's area, as one path reshaped by the visible
/// geometry effects of `n`, without those effects.
fn reshaped(src: &Node, n: &Node) -> Node {
/// geometry effects of `n`, without those effects. The swatches and patterns a live-effect plug-in
/// that paints its pieces names are looked up in `doc`.
fn reshaped(src: &Node, n: &Node, doc: Option<&Document>) -> Node {
let mut m = src.clone();
if let Some((path, rule)) = geometry(src) {
let bounds = vectorcraft_geom::Shape::bounding_box(&path.to_bezpath());
let (g, carry) = crate::apply_geometry_carrying(&n.appearance.effects, &path, bounds, &crate::GeomContext::of(n));
let (g, carry) =
crate::apply_geometry_carrying(&n.appearance.effects, &path, bounds, &crate::GeomContext { doc, ..crate::GeomContext::of(n) });
m.kind = NodeKind::Path { path: g, rule, live: None, clipping: false, guide: false };
// Its gradients move with the outline, as the canvas paints them.
if carry.moves(&m.appearance) {
@@ -282,12 +295,12 @@ fn shape_op(mode: ShapeMode) -> po::BoolOp {
/// it by the member's shape mode; the backmost member starts the area. `None` when `n` is not a
/// compound shape, or its members are past a live Pathfinder's budget ([`PATHFINDER_MAX_SEGMENTS`],
/// [`PATHFINDER_MAX_OVERLAPS`], [`SYMBOL_ART_MAX_NODES`](crate::SYMBOL_ART_MAX_NODES)).
pub fn compound_shape_area(n: &Node, hook: Option<OutlineHook>) -> Option<PathData> {
compound_shape_area_in(n, hook, &SymbolNodes::default())
pub fn compound_shape_area(doc: Option<&Document>, n: &Node, hook: Option<OutlineHook>) -> Option<PathData> {
compound_shape_area_in(doc, n, hook, &SymbolNodes::default())
}
/// [`compound_shape_area`], with the symbol art given so far counted in `nodes`.
fn compound_shape_area_in(n: &Node, hook: Option<OutlineHook>, nodes: &SymbolNodes) -> Option<PathData> {
fn compound_shape_area_in(doc: Option<&Document>, n: &Node, hook: Option<OutlineHook>, nodes: &SymbolNodes) -> Option<PathData> {
if !n.is_compound_shape() {
return None;
}
@@ -296,7 +309,7 @@ fn compound_shape_area_in(n: &Node, hook: Option<OutlineHook>, nodes: &SymbolNod
let mut members: Vec<(po::BoolOp, usize)> = vec![];
for c in n.children()? {
let before = all.len();
leaves(c, hook, nodes, &mut all);
leaves(doc, c, hook, nodes, &mut all);
if all.len() > before {
members.push((shape_op(c.extras().shape_mode), all.len() - before));
}
@@ -323,13 +336,13 @@ fn compound_shape_area_in(n: &Node, hook: Option<OutlineHook>, nodes: &SymbolNod
/// Compound shape `n` as one path of its area ([`compound_shape_area`]) with its appearance,
/// opacity and blend mode: how it takes part in a Pathfinder operation.
pub fn compound_shape_art(n: &Node, hook: Option<OutlineHook>) -> Option<Node> {
compound_shape_art_in(n, hook, &SymbolNodes::default())
pub fn compound_shape_art(doc: Option<&Document>, n: &Node, hook: Option<OutlineHook>) -> Option<Node> {
compound_shape_art_in(doc, n, hook, &SymbolNodes::default())
}
/// [`compound_shape_art`], with the symbol art given so far counted in `nodes`.
fn compound_shape_art_in(n: &Node, hook: Option<OutlineHook>, nodes: &SymbolNodes) -> Option<Node> {
let area = compound_shape_area_in(n, hook, nodes)?;
fn compound_shape_art_in(doc: Option<&Document>, n: &Node, hook: Option<OutlineHook>, nodes: &SymbolNodes) -> Option<Node> {
let area = compound_shape_area_in(doc, n, hook, nodes)?;
let mut art = Node::path(n.id, area, Appearance { items: n.appearance.items.clone(), ..Default::default() });
(art.opacity, art.blend) = (n.opacity, n.blend);
Some(art)
@@ -435,15 +448,16 @@ fn within_budget<'a>(leaves: impl IntoIterator<Item = &'a Node>) -> bool {
/// Evaluate the visible Pathfinder effects of `group` in order: the resulting child objects.
/// `None` when the group has no Pathfinder effect. Past the budget ([`PATHFINDER_MAX_SEGMENTS`],
/// [`PATHFINDER_MAX_OVERLAPS`], [`SYMBOL_ART_MAX_NODES`](crate::SYMBOL_ART_MAX_NODES) for
/// each effect) it returns the children as they are.
pub fn pathfinder_children(group: &Node, hook: Option<OutlineHook>) -> Option<Vec<Arc<Node>>> {
/// each effect) it returns the children as they are. The swatches and patterns that live-effect
/// plug-ins painting their pieces name are looked up in `doc` ([`shape_art`]).
pub fn pathfinder_children(doc: Option<&Document>, group: &Node, hook: Option<OutlineHook>) -> Option<Vec<Arc<Node>>> {
if !has_pathfinder(group) {
return None;
}
let source: &[Arc<Node>] = group.children()?;
let mut stack: Vec<Arc<Node>> = if group.is_compound_shape() {
// The group's own appearance paints the area ([`evaluate_container`]), so the art has none.
match compound_shape_area(group, hook) {
match compound_shape_area(doc, group, hook) {
Some(area) if area.is_empty() => vec![],
Some(area) => vec![Arc::new(Node::path(group.id, area, Appearance::default()))],
None => return Some(source.to_vec()),
@@ -455,7 +469,7 @@ pub fn pathfinder_children(group: &Node, hook: Option<OutlineHook>) -> Option<Ve
if e.id == crate::trap::TRAP {
let (mut lv, nodes) = (vec![], SymbolNodes::default());
for c in &stack {
leaves(c, hook, &nodes, &mut lv);
leaves(doc, c, hook, &nodes, &mut lv);
}
if nodes.spent() || !within_budget(&lv) {
return Some(source.to_vec());
@@ -479,7 +493,7 @@ pub fn pathfinder_children(group: &Node, hook: Option<OutlineHook>) -> Option<Ve
.iter()
.map(|c| {
let mut lv = vec![];
leaves(c, hook, &nodes, &mut lv);
leaves(doc, c, hook, &nodes, &mut lv);
lv
})
.collect();
@@ -633,7 +647,7 @@ pub fn member_shapes(doc: Option<&Document>, n: &Node) -> Vec<(PathData, FillRul
NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => out.extend(crate::reshape::leaf_geometry(n)),
NodeKind::Group { .. } if n.is_compound_shape() => {
let hook = |c: &Node| crate::outline_object(doc, c);
match compound_shape_area(n, Some(&hook)) {
match compound_shape_area(doc, n, Some(&hook)) {
Some(area) => out.push((area, FillRule::NonZero)),
None => members(doc, n, out),
}
@@ -715,7 +729,7 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option<Node> {
let mut m = n.clone();
if pathfinder {
let hook = |c: &Node| crate::outline_object(doc, c);
let result = pathfinder_children(n, Some(&hook)).unwrap_or_default();
let result = pathfinder_children(doc, n, Some(&hook)).unwrap_or_default();
if let Some(ch) = m.children_mut() {
*ch = result;
}
@@ -724,7 +738,7 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option<Node> {
}
if !fx.is_empty() {
m = crate::reshape::outlined_members(doc, &m);
let carry = crate::reshape::reshape_leaves_carrying(&mut m, &fx);
let carry = crate::reshape::reshape_leaves_carrying(&mut m, &fx, doc);
// The group's own fills and strokes paint the members as a whole: their gradients follow
// the effects as the members' do.
if paint && carry.moves(&m.appearance) {
+25 -25
View File
@@ -27,7 +27,7 @@ fn area(n: &Node) -> f64 {
}
fn overlap_cmyk(g: &Node) -> [f32; 4] {
let faces = pathfinder_children(g, None).unwrap();
let faces = pathfinder_children(None, g, None).unwrap();
assert_eq!(faces.len(), 3);
let overlap = faces
.iter()
@@ -61,7 +61,7 @@ fn trap_effect_adds_an_overprinting_strip_above_the_members() {
let mut g = Node::group(NodeId(10), vec![sq(1, 0.0, yellow), sq(2, 10.0, blue)]);
let params = serde_json::json!({"thickness": 1.0, "tintReduction": 100});
g.appearance.effects.push(Effect { id: crate::trap::TRAP.into(), params, visible: true });
let out = pathfinder_children(&g, None).unwrap();
let out = pathfinder_children(None, &g, None).unwrap();
assert_eq!(out.len(), 3, "both squares and one trap");
assert_eq!((out[0].id, out[1].id), (NodeId(1), NodeId(2)));
let b = out[2].geometric_bounds().unwrap();
@@ -89,17 +89,17 @@ fn soft_mix_blends_cmyk_bottom_to_top_and_drops_strokes() {
#[test]
fn shape_modes_follow_the_pathfinder_panel() {
let add = pathfinder_children(&group_with("pathfinder.add"), None).unwrap();
let add = pathfinder_children(None, &group_with("pathfinder.add"), None).unwrap();
assert_eq!(add.len(), 1);
assert!((area(&add[0]) - 150.0).abs() < 1e-3);
// Add keeps the front object's paint, Subtract the back one's.
assert_eq!(add[0].id, NodeId(2));
let sub = pathfinder_children(&group_with("pathfinder.subtract"), None).unwrap();
let sub = pathfinder_children(None, &group_with("pathfinder.subtract"), None).unwrap();
assert_eq!(sub[0].id, NodeId(1));
assert!((area(&sub[0]) - 50.0).abs() < 1e-3);
let int = pathfinder_children(&group_with("pathfinder.intersect"), None).unwrap();
let int = pathfinder_children(None, &group_with("pathfinder.intersect"), None).unwrap();
assert!((area(&int[0]) - 50.0).abs() < 1e-3);
let div = pathfinder_children(&group_with("pathfinder.divide"), None).unwrap();
let div = pathfinder_children(None, &group_with("pathfinder.divide"), None).unwrap();
assert_eq!(div.len(), 3);
}
@@ -112,7 +112,7 @@ fn divide_and_outline_draw_nothing_for_unpainted_members() {
for effect in ["pathfinder.divide", "pathfinder.outline"] {
let mut g = Node::group(NodeId(10), vec![sq(1, 0.0, Color::rgb(1.0, 0.0, 0.0)), unpainted.clone()]);
g.appearance.effects.push(Effect { id: effect.into(), params: serde_json::json!({}), visible: true });
let out = pathfinder_children(&g, None).unwrap();
let out = pathfinder_children(None, &g, None).unwrap();
assert!(out.iter().any(|n| n.id == NodeId(1)) && out.iter().any(|n| n.id == NodeId(2)), "{effect}: {out:#?}");
for n in out.iter().filter(|n| n.id == NodeId(2)) {
assert!(n.appearance.items.iter().all(|i| i.paint().is_none()), "{effect}: {n:#?}");
@@ -138,7 +138,7 @@ fn square_and(front: Option<Node>, effect: &str) -> Node {
/// Returns the Pathfinder results of `g`, with type outlined as the renderer outlines it.
fn results(g: &Node) -> Vec<Arc<Node>> {
pathfinder_children(g, crate::text_outliner()).unwrap()
pathfinder_children(None, g, crate::text_outliner()).unwrap()
}
#[test]
@@ -167,7 +167,7 @@ fn type_changes_the_result_of_every_pathfinder_effect() {
assert!((with_type("pathfinder.intersect") + with_type("pathfinder.minusBack") - glyph_area).abs() < 0.5, "{glyph_area}");
// Outline art nested one group deeper takes part as well.
let nested: OutlineHook = &|n: &Node| crate::outline_text(n).map(|g| Node::group(n.id, vec![Arc::new(g)]));
let sub = pathfinder_children(&square_and(Some(type_h()), "pathfinder.subtract"), Some(nested)).unwrap();
let sub = pathfinder_children(None, &square_and(Some(type_h()), "pathfinder.subtract"), Some(nested)).unwrap();
assert!((sub.iter().map(|n| area(n)).sum::<f64>() - with_type("pathfinder.subtract")).abs() < 1e-6);
}
@@ -243,7 +243,7 @@ fn type_in_a_group_keeps_its_look_when_the_groups_geometry_effects_outline_it()
fn hidden_effect_or_plain_group_is_untouched() {
let mut g = group_with("pathfinder.add");
g.appearance.effects[0].visible = false;
assert!(pathfinder_children(&g, None).is_none());
assert!(pathfinder_children(None, &g, None).is_none());
assert!(!has_pathfinder(&sq(1, 0.0, Color::BLACK)));
}
@@ -320,12 +320,12 @@ fn shape_art_moves_gradients_with_a_transform_effect() {
};
let mut path = Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(placed.clone(), Paint::None, 0.0));
path.appearance.effects.push(moved());
let got = starts(&shape_art(&path, None).unwrap());
let got = starts(&shape_art(None, &path, None).unwrap());
assert!(got.len() == 1 && (got[0] - 40.0).abs() < 1e-6, "the path's gradient: {got:?}");
let mut g = Node::group(NodeId(10), vec![sq(2, 0.0, Color::BLACK)]);
g.appearance.items.push(AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(placed)));
g.appearance.effects.push(moved());
let got = starts(&shape_art(&g, None).unwrap());
let got = starts(&shape_art(None, &g, None).unwrap());
assert!(!got.is_empty() && got.iter().all(|s| (s - 40.0).abs() < 1e-6), "the group's own fill: {got:?}");
}
@@ -343,7 +343,7 @@ fn shape_art_moves_the_gradient_of_a_symbol_instances_art_with_its_transform() {
let hook = move |_: &Node| Some(art.clone());
let mut instance = Node::new(NodeId(1), NodeKind::SymbolInstance { symbol: "s".into(), xf: vectorcraft_geom::Affine::IDENTITY });
instance.appearance.effects.push(Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 30}), visible: true });
let out = shape_art(&instance, Some(&hook)).unwrap();
let out = shape_art(None, &instance, Some(&hook)).unwrap();
let Paint::Gradient(g) = out.appearance.fill_paint() else { panic!("a gradient: {:?}", out.appearance) };
assert!((g.geom.unwrap().start.x - 30.0).abs() < 1e-6, "{:?}", g.geom);
assert!((out.geometric_bounds().unwrap().x0 - 30.0).abs() < 1e-6, "the art moves");
@@ -377,11 +377,11 @@ fn shape_art_moves_the_gradients_of_a_symbol_instances_art_and_a_compound_shape_
let hook = move |_: &Node| Some(art.clone());
let mut instance = Node::new(NodeId(1), NodeKind::SymbolInstance { symbol: "s".into(), xf: vectorcraft_geom::Affine::IDENTITY });
instance.appearance.effects.push(warp());
check(&shape_art(&instance, Some(&hook)).unwrap(), Rect::new(0.0, 0.0, 10.0, 10.0), &format!("warp.{style} on a symbol instance"));
check(&shape_art(None, &instance, Some(&hook)).unwrap(), Rect::new(0.0, 0.0, 10.0, 10.0), &format!("warp.{style} on a symbol instance"));
let mut shape = compound_shape(ShapeMode::Add);
shape.appearance = Appearance::basic(placed.clone(), Paint::None, 0.0).into();
shape.appearance.effects.push(warp());
check(&shape_art(&shape, None).unwrap(), Rect::new(0.0, 0.0, 15.0, 10.0), &format!("warp.{style} on a compound shape"));
check(&shape_art(None, &shape, None).unwrap(), Rect::new(0.0, 0.0, 15.0, 10.0), &format!("warp.{style} on a compound shape"));
}
}
@@ -714,7 +714,7 @@ fn pathfinder_past_its_budget_draws_the_source_art() {
// n² crossings past the cap: no evaluation (#3591), the source art as it is.
for n in [130, 800] {
let g = crossing_bars(n, "pathfinder.divide");
let out = pathfinder_children(&g, None).unwrap();
let out = pathfinder_children(None, &g, None).unwrap();
assert_eq!(out.len(), 2 * n, "{n} x {n} bars");
let ids: Vec<NodeId> = out.iter().map(|c| c.id).collect();
let src: Vec<NodeId> = g.children().unwrap().iter().map(|c| c.id).collect();
@@ -729,7 +729,7 @@ fn pathfinder_within_its_budget_still_divides() {
// n x n bars divide into 3·n² pieces, and 128 x 128 bars, which overlap in exactly
// `PATHFINDER_MAX_OVERLAPS` pairs, must still divide.
for n in [8, 128] {
let out = pathfinder_children(&crossing_bars(n, "pathfinder.divide"), None).unwrap();
let out = pathfinder_children(None, &crossing_bars(n, "pathfinder.divide"), None).unwrap();
assert_eq!(out.len(), 3 * n * n, "{n} x {n} bars");
}
}
@@ -747,7 +747,7 @@ fn pathfinder_on_many_stacked_bars_still_runs() {
.collect();
let mut g = Node::group(NodeId(10), children);
g.appearance.effects.push(Effect { id: "pathfinder.add".into(), params: serde_json::json!({}), visible: true });
let out = pathfinder_children(&g, None).unwrap();
let out = pathfinder_children(None, &g, None).unwrap();
assert_eq!(out.len(), 1);
}
@@ -768,9 +768,9 @@ fn compound_shapes_draw_the_area_their_shape_modes_make() {
for (mode, want) in [(ShapeMode::Add, 150.0), (ShapeMode::Subtract, 50.0), (ShapeMode::Intersect, 50.0), (ShapeMode::Exclude, 100.0)] {
let g = compound_shape(mode);
assert!(has_pathfinder(&g), "{mode:?}");
let area_path = compound_shape_area(&g, None).unwrap();
let area_path = compound_shape_area(None, &g, None).unwrap();
assert!((po::area(&area_path, FillRule::NonZero).abs() - want).abs() < 0.01, "{mode:?}");
let out = pathfinder_children(&g, None).unwrap();
let out = pathfinder_children(None, &g, None).unwrap();
assert_eq!(out.len(), 1, "{mode:?}");
assert!(out[0].appearance.items.is_empty(), "the group's own appearance paints it");
let art = evaluate_container(None, &g).unwrap();
@@ -787,7 +787,7 @@ fn compound_shapes_draw_the_area_their_shape_modes_make() {
back.extras_mut().shape_mode = ShapeMode::Subtract;
ch[0] = Arc::new(back);
}
assert!((po::area(&compound_shape_area(&g, None).unwrap(), FillRule::NonZero).abs() - 150.0).abs() < 0.01);
assert!((po::area(&compound_shape_area(None, &g, None).unwrap(), FillRule::NonZero).abs() - 150.0).abs() < 0.01);
// Hidden members take no part; the mark on a clip group is ignored.
let mut g = compound_shape(ShapeMode::Subtract);
if let Some(ch) = g.children_mut() {
@@ -795,10 +795,10 @@ fn compound_shapes_draw_the_area_their_shape_modes_make() {
front.visible = false;
ch[1] = Arc::new(front);
}
assert!((po::area(&compound_shape_area(&g, None).unwrap(), FillRule::NonZero).abs() - 100.0).abs() < 0.01);
assert!((po::area(&compound_shape_area(None, &g, None).unwrap(), FillRule::NonZero).abs() - 100.0).abs() < 0.01);
let mut clip = compound_shape(ShapeMode::Add);
clip.kind = NodeKind::Group { children: clip.children().unwrap().clone(), clip: true };
assert!(!clip.is_compound_shape() && compound_shape_area(&clip, None).is_none());
assert!(!clip.is_compound_shape() && compound_shape_area(None, &clip, None).is_none());
}
/// A compound shape inside another (and inside an Effect › Pathfinder group) takes part as its area,
@@ -811,10 +811,10 @@ fn nested_compound_shapes_take_part_as_their_area() {
let mut outer = Node::group(NodeId(20), vec![Arc::new(inner.clone()), Arc::new(cutter)]);
outer.extras_mut().compound_shape = true;
// (0–5 of the inner area) ∩ (2–12) = 2–5 by 10 high.
assert!((po::area(&compound_shape_area(&outer, None).unwrap(), FillRule::NonZero).abs() - 30.0).abs() < 0.01);
assert!((po::area(&compound_shape_area(None, &outer, None).unwrap(), FillRule::NonZero).abs() - 30.0).abs() < 0.01);
let mut effect = Node::group(NodeId(30), vec![Arc::new(inner)]);
effect.appearance.effects.push(Effect { id: "pathfinder.add".into(), params: serde_json::json!({}), visible: true });
let out = pathfinder_children(&effect, None).unwrap();
let out = pathfinder_children(None, &effect, None).unwrap();
assert_eq!(out.len(), 1);
assert!((area(&out[0]) - 50.0).abs() < 0.01);
assert_eq!(out[0].appearance.fill_paint().color(), Some(Color::rgb(0.0, 1.0, 0.0)), "styled like the compound shape");
+4 -3
View File
@@ -80,8 +80,8 @@ pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks};
pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace};
pub use raster::{RASTERIZE, RasterFx, Rasterize, color_param, outset, raster_effects};
pub use reshape::{
SYMBOL_ART_MAX_NODES, expand_outlined, gives_image_shape, has_gradient_copies, has_item_geometry, has_transform_art, has_transform_copies,
has_transform_options, image_paint, needs_outline, outline_art, outline_text, reshape,
SYMBOL_ART_MAX_NODES, expand_outlined, gives_image_shape, has_gradient_copies, has_item_geometry, has_painted_effects, has_transform_art,
has_transform_copies, has_transform_options, image_paint, needs_outline, outline_art, outline_text, reshape,
};
pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_options, validate_revolve};
pub use solids::{
@@ -107,5 +107,6 @@ pub use geometry::{
#[cfg(test)]
pub(crate) use geometry::{MAX_EFFECT_LINES, apply_geometry_within};
pub use params::{
default_params, has_geometry, has_plugin_effect, is_geometry, is_raster, merged_params, new_effect, plugin_revision, scale_effect, upgrade_params,
any_plugin_paints_output, default_params, has_geometry, has_painted_plugin_effect, has_plugin_effect, is_geometry, is_raster, merged_params,
new_effect, plugin_revision, scale_effect, upgrade_params,
};
+2 -1
View File
@@ -43,7 +43,8 @@ fn with_hooks<R>(doc: Option<&Document>, f: impl FnOnce(Hooks) -> R) -> R {
vectorcraft_text::register_envelope_ink();
let outline = |n: &Node| outline_object(doc, n);
let tiles = |name: &str, xf: Affine, region: Rect| Some(doc?.pattern(name)?.instances_in(xf, region));
f(Hooks { outline: Some(&outline), appearance: Some(&bake_appearance), pattern: Some(&tiles), symbols: doc })
let appearance = |n: &Node| bake_appearance(doc, n);
f(Hooks { outline: Some(&outline), appearance: Some(&appearance), pattern: Some(&tiles), symbols: doc })
}
/// One level of evaluation of live object `n` ([`live::expand_live_hooks`]).
+13
View File
@@ -148,6 +148,19 @@ pub fn has_plugin_effect(n: &Node) -> bool {
n.appearance.effects.iter().any(plugin) || n.appearance.items.iter().any(|i| i.effects().iter().any(plugin))
}
/// Does `n` have a visible live-effect plug-in effect, its own or a fill's or stroke's, whose
/// plug-in paints the pieces it returns? What such an effect draws also depends on the document's
/// swatches and patterns, which its pieces may name.
pub fn has_painted_plugin_effect(n: &Node) -> bool {
let painted = |e: &Effect| e.visible && vectorcraft_plugins::effect::plugin_id(&e.id).is_some_and(vectorcraft_plugins::effect::paints_output);
n.appearance.effects.iter().any(painted) || n.appearance.items.iter().any(|i| i.effects().iter().any(painted))
}
/// Is a live-effect plug-in that paints the pieces it returns installed?
pub fn any_plugin_paints_output() -> bool {
vectorcraft_plugins::registry::list().iter().any(|p| vectorcraft_plugins::effect::paints_output(&p.manifest().id))
}
/// The plug-in registry's revision, which changes whenever a plug-in is installed or removed.
pub fn plugin_revision() -> u64 {
vectorcraft_plugins::registry::revision()
+263 -55
View File
@@ -103,10 +103,27 @@ fn reaches_appearance(e: &Effect) -> bool {
}
/// Is `n` a path or compound path that its Transform effects make into art of separate objects
/// ([`has_gradient_copies`], [`has_layered_copies`], [`has_transform_options`])? It is drawn, baked and expanded through
/// that art ([`reshape`]).
/// ([`has_gradient_copies`], [`has_layered_copies`], [`has_transform_options`]), or whose
/// live-effect plug-ins paint what they return ([`has_painted_effects`])? It is drawn, baked and
/// expanded through that art ([`reshape`]).
pub fn has_transform_art(n: &Node) -> bool {
has_gradient_copies(n) || has_layered_copies(n) || has_transform_options(n)
has_gradient_copies(n) || has_layered_copies(n) || has_transform_options(n) || has_painted_effects(n)
}
/// Is `e` a visible live-effect plug-in whose manifest declares `paintOutput`: its id's plug-in id,
/// else `None`.
fn painted_plugin(e: &Effect) -> Option<&str> {
let id = vectorcraft_plugins::effect::plugin_id(&e.id).filter(|_| e.visible)?;
vectorcraft_plugins::effect::paints_output(id).then_some(id)
}
/// Is `n` a path or compound path with a live-effect plug-in that paints what it returns
/// ([`painted_plugin`]), its own or on a fill or stroke that paints? Each piece the plug-in returns
/// is then an object of its own with its own paints ([`paint_leaf`], [`transform_item_leaf`]).
pub fn has_painted_effects(n: &Node) -> bool {
matches!(n.kind, NodeKind::Path { guide: false, clipping: false, .. } | NodeKind::Compound { .. })
&& (n.appearance.effects.iter().any(|e| painted_plugin(e).is_some())
|| n.appearance.items.iter().any(|i| crate::paints(i) && i.effects().iter().any(|e| painted_plugin(e).is_some())))
}
/// Is `n` type without inline graphics that has a fill or stroke of its own that paints and has a
@@ -163,17 +180,18 @@ fn image_rows(items: &[AppearanceItem]) -> Vec<AppearanceItem> {
/// The shapes that the image `n`'s own fills and strokes paint ([`image_rows`]), each with its fill
/// rule and the fill or stroke that paints it: the paths (or compound paths) along its frame that
/// [`reshape`] makes, with each fill's or stroke's geometry effects applied as the renderer applies
/// them. Empty for anything but an image with such fills or strokes.
pub fn image_paint(n: &Node) -> Vec<(BezPath, FillRule, AppearanceItem)> {
/// them. Empty for anything but an image with such fills or strokes. The swatches and patterns that
/// live-effect plug-ins painting their pieces name are looked up in `doc`.
pub fn image_paint(doc: Option<&Document>, n: &Node) -> Vec<(BezPath, FillRule, AppearanceItem)> {
if !matches!(n.kind, NodeKind::Image(_)) || !has_item_geometry(n) {
return vec![];
}
let Some(art) = reshape(None, n, None) else { return vec![] };
let Some(art) = reshape(doc, n, None) else { return vec![] };
let mut out = vec![];
art.walk(&mut |c| {
let (NodeKind::Path { rule, .. } | NodeKind::Compound { rule, .. }) = &c.kind else { return };
let Some(g) = c.stroke_path() else { return };
let ctx = GeomContext::of(c);
let ctx = GeomContext { doc, ..GeomContext::of(c) };
for item in c.appearance.items.iter().filter(|i| crate::paints(i)) {
let fx = item.effects();
let shape = if crate::has_geometry(fx) && !g.elements().is_empty() {
@@ -286,14 +304,17 @@ fn with_id(art: &Node, id: NodeId) -> Node {
/// 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.
pub fn outline_art(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
outline_art_with(doc, n, symbol_art, &outline_text, &SymbolNodes::default(), false)
outline_art_with(doc, doc, n, symbol_art, &outline_text, &SymbolNodes::default(), false)
}
/// [`outline_art`] with live objects evaluated through `outline` ([`evaluated_with`]); with
/// `apart`, also a path or compound path whose Transform effect makes copies
/// ([`has_transform_copies`]), as the one piece the copies are made from.
/// ([`has_transform_copies`]), as the one piece the copies are made from. `symbols` is the document
/// the symbols of the art come from; `paint_doc` is the one the swatches and patterns of painted
/// pieces are looked up in.
fn outline_art_with(
doc: Option<&Document>,
symbols: Option<&Document>,
paint_doc: Option<&Document>,
n: &Node,
symbol_art: Option<&Node>,
outline: OutlineHook,
@@ -303,7 +324,7 @@ fn outline_art_with(
let mut art = match &n.kind {
NodeKind::Text(_) => outline_text(n)?,
NodeKind::SymbolInstance { symbol, xf } => {
let a = match doc {
let a = match symbols {
Some(d) => d.place_symbol_art(symbol, symbol_art?, *xf)?,
None => {
let mut a = symbol_art?.clone();
@@ -323,10 +344,10 @@ fn outline_art_with(
one.appearance.effects.clear();
Node::group(n.id, vec![Arc::new(one)])
}
_ if vectorcraft_doc::live::is_live(n) => evaluated_with(doc, n, outline, nodes),
_ if vectorcraft_doc::live::is_live(n) => evaluated_with(symbols, n, outline, nodes),
_ => return None,
};
outline_nested_text(&mut art, false);
outline_nested_text(paint_doc, &mut art, false);
art.opacity = 1.0;
art.blend = Default::default();
art.isolate = false;
@@ -433,21 +454,22 @@ fn instances_as_art(doc: Option<&Document>, n: &mut Node, outline: OutlineHook,
/// Replace type anywhere inside `n` by its outlines, as art that keeps the type's look
/// ([`as_art`]). Type in a clip group's clipping path (all of `n` when `clipping` is set) becomes
/// its plain outlines ([`outline_text`]) with its visibility, which is what
/// [`crate::clip_outline`] clips by.
pub(crate) fn outline_nested_text(n: &mut Node, clipping: bool) {
/// [`crate::clip_outline`] clips by. The swatches and patterns that live-effect plug-ins painting
/// their pieces name are looked up in `doc`.
pub(crate) fn outline_nested_text(doc: Option<&Document>, n: &mut Node, clipping: bool) {
let clips = n.clips();
let Some(ch) = n.children_mut() else { return };
for (i, c) in ch.iter_mut().enumerate() {
let clipping = clipping || (clips && i == 0);
let art = match &c.kind {
NodeKind::Text(_) if clipping => outline_text(c).map(|o| Node { visible: c.visible, ..o }),
NodeKind::Text(_) => as_art(None, c, None),
NodeKind::Text(_) => as_art(doc, c, None),
_ => None,
};
if let Some(o) = art {
*c = Arc::new(o);
} else if c.children().is_some() {
outline_nested_text(Arc::make_mut(c), clipping);
outline_nested_text(doc, Arc::make_mut(c), clipping);
}
}
}
@@ -480,7 +502,7 @@ pub fn expand_outlined(doc: Option<&Document>, n: &Node, symbol_art: Option<&Nod
// effect in a group of their own (copies of copies nest), as the reference app expands them
// (#5612).
if has_transform_art(n) || has_transform_copies(n) {
return as_art_opts(doc, n, symbol_art, &outline_text, &SymbolNodes::default(), true);
return as_art_opts(doc, doc, n, symbol_art, &outline_text, &SymbolNodes::default(), true);
}
let own_paint = match n.kind {
NodeKind::Text(_) => !n.appearance.items.is_empty(),
@@ -494,17 +516,34 @@ pub fn expand_outlined(doc: Option<&Document>, n: &Node, symbol_art: Option<&Nod
/// [`reshape`] without its checks (no geometry effects: just the art).
pub(crate) fn as_art(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
as_art_with(doc, n, symbol_art, &outline_text, &SymbolNodes::default())
as_art_with(doc, doc, n, symbol_art, &outline_text, &SymbolNodes::default())
}
/// [`as_art`] with live objects evaluated through `outline` ([`evaluated_with`]).
pub(crate) fn as_art_with(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>, outline: OutlineHook, nodes: &SymbolNodes) -> Option<Node> {
as_art_opts(doc, n, symbol_art, outline, nodes, false)
/// [`as_art`] with live objects evaluated through `outline` ([`evaluated_with`]). `symbols` is the
/// document the symbols of the art come from; `paint_doc` is the one the swatches and patterns of
/// painted pieces are looked up in.
pub(crate) fn as_art_with(
symbols: Option<&Document>,
paint_doc: Option<&Document>,
n: &Node,
symbol_art: Option<&Node>,
outline: OutlineHook,
nodes: &SymbolNodes,
) -> Option<Node> {
as_art_opts(symbols, paint_doc, n, symbol_art, outline, nodes, false)
}
/// [`as_art_with`]; with `apart`, a path's or compound path's Transform effects make each copy an
/// object of its own ([`copy_leaf`]) rather than one path of every copy.
fn as_art_opts(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>, outline: OutlineHook, nodes: &SymbolNodes, apart: bool) -> Option<Node> {
fn as_art_opts(
symbols: Option<&Document>,
paint_doc: Option<&Document>,
n: &Node,
symbol_art: Option<&Node>,
outline: OutlineHook,
nodes: &SymbolNodes,
apart: bool,
) -> Option<Node> {
let fx = geometry_effects(n);
let mut art = match &n.kind {
NodeKind::Image(im) => {
@@ -532,13 +571,13 @@ fn as_art_opts(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>, outl
Node::group(n.id, rows(below).into_iter().chain([Arc::new(image)]).chain(rows(above)).collect())
}
}
_ => outline_art_with(doc, n, symbol_art, outline, nodes, apart)?,
_ => outline_art_with(symbols, paint_doc, n, symbol_art, outline, nodes, apart)?,
};
reshape_leaves_apart(&mut art, &fx, Budget::EFFECTS, apart);
reshape_leaves_apart(&mut art, &fx, Budget::EFFECTS, apart, paint_doc);
if apart {
hoist_copies(&mut art);
}
transform_items(&mut art);
transform_items(&mut art, paint_doc);
// Type and symbols in perspective: the art projected onto their plane.
if let Some(h) = n.projection() {
project_leaves(&mut art, &h);
@@ -596,20 +635,22 @@ pub(crate) fn geometry_effects(n: &Node) -> Vec<&Effect> {
/// symbol instances follow a Transform effect the same way ([`transform_placed`]); other geometry
/// effects leave them as they are (a raster follows a non-affine map only as a mesh warp). The
/// paths keep to one [`Budget::EFFECTS`] together (or to what they cost, when that is more): a path
/// whose effect would pass what the others leave of it keeps its shape.
pub(crate) fn reshape_leaves(art: &mut Node, fx: &[&Effect]) {
reshape_leaves_within(art, fx, Budget::EFFECTS);
/// whose effect would pass what the others leave of it keeps its shape. The swatches and patterns
/// that live-effect plug-ins painting their pieces name are looked up in `doc`
/// ([`reshape_leaves_apart`]).
pub(crate) fn reshape_leaves(art: &mut Node, fx: &[&Effect], doc: Option<&Document>) {
reshape_leaves_within(art, fx, Budget::EFFECTS, doc);
}
/// [`reshape_leaves`], and how gradients that paint the art as a whole (a group's own fills and
/// strokes) follow the effects ([`crate::Carry`]).
pub(crate) fn reshape_leaves_carrying(art: &mut Node, fx: &[&Effect]) -> crate::Carry {
reshape_leaves_within(art, fx, Budget::EFFECTS)
pub(crate) fn reshape_leaves_carrying(art: &mut Node, fx: &[&Effect], doc: Option<&Document>) -> crate::Carry {
reshape_leaves_within(art, fx, Budget::EFFECTS, doc)
}
/// [`reshape_leaves_carrying`] whose paths keep to `budget` together, or to what they cost.
pub(crate) fn reshape_leaves_within(art: &mut Node, fx: &[&Effect], budget: Budget) -> crate::Carry {
reshape_leaves_apart(art, fx, budget, false)
pub(crate) fn reshape_leaves_within(art: &mut Node, fx: &[&Effect], budget: Budget, doc: Option<&Document>) -> crate::Carry {
reshape_leaves_apart(art, fx, budget, false, doc)
}
/// `art`, the group of the one piece a path's copies are made from ([`outline_art_with`]), holding
@@ -629,9 +670,11 @@ fn hoist_copies(art: &mut Node) {
}
/// [`reshape_leaves_within`]; with `apart`, a Transform effect's copies of each path are objects
/// of their own ([`copy_leaf`]) whatever paints them, as Expand Appearance makes them. Returns how
/// gradients that paint the art as a whole follow the effects.
fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: bool) -> crate::Carry {
/// of their own ([`copy_leaf`]) whatever paints them, as Expand Appearance makes them. A live-effect
/// plug-in that paints what it returns makes each path a group of its pieces ([`paint_leaf`]),
/// whose swatches and patterns must be in `doc` (without one, pieces that name one are refused).
/// Returns how gradients that paint the art as a whole follow the effects.
fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: bool, doc: Option<&Document>) -> crate::Carry {
let mut whole = crate::Carry::default();
if fx.is_empty() {
return whole;
@@ -658,6 +701,17 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b
for e in fx {
let Some(bounds) = art.geometric_bounds() else { break };
let params = merged_params(&e.id, &e.params);
if let Some(plugin) = painted_plugin(e) {
for_each_leaf(art, &mut |leaf| {
let Some((path, _)) = leaf_geometry(leaf) else { return };
let before = Budget::of(&path);
let room = max.minus(used.minus(before));
if let Some(made) = paint_leaf(leaf, plugin, &params, bounds, room, doc) {
used = used.minus(before).plus(made);
}
});
continue;
}
// Placed gradients follow a Transform effect, Free Distort and the Warp effects
// ([`crate::Carry`]): under Free Distort and Warp (`boxes`) each path's move and scale from
// its bounds before the effect to its bounds after it. Under those and the other effects,
@@ -843,15 +897,118 @@ fn copy_leaf(leaf: &mut Node, path: &PathData, m: Affine, copies: usize, scaling
made
}
/// Live-effect plug-in `plugin` (one that paints what it returns) with `params` run on `leaf`, a
/// path or compound path, as one of its object's effects with reference box `bounds`: `leaf`
/// becomes a group with its id and name of one object for each piece returned, in order, each with
/// the piece's geometry and the leaf's appearance with the piece's fills and strokes in place of
/// its own ([`vectorcraft_plugins::painted::appearance`]). The swatches and patterns the pieces
/// name must be in `doc`, and without one the pieces may name none. Returns what the pieces cost
/// ([`piece_cost`]); `None` (the leaf stays as it is) when the plug-in fails, the pieces are
/// refused, or they would pass `budget`.
fn paint_leaf(leaf: &mut Node, plugin: &str, params: &serde_json::Value, bounds: Rect, budget: Budget, doc: Option<&Document>) -> Option<Budget> {
let (path, rule) = leaf_geometry(leaf)?;
let paints = vectorcraft_plugins::effect::Paints { appearance: Some(&leaf.appearance), item: None };
let pieces = vectorcraft_plugins::effect::apply_painted(plugin, params, &path, bounds, rule, paints, doc)?;
let made = pieces.iter().map(|p| piece_cost(&p.path)).fold(Budget { anchors: 0, lines: 0 }, Budget::plus);
if !budget.fits(made) {
return None;
}
let children = pieces
.iter()
.map(|p| {
let mut one = leaf.clone();
one.name = None;
one.appearance = vectorcraft_plugins::painted::appearance(&leaf.appearance, p).into();
set_geometry(&mut one, p.path.clone(), p.rule);
Arc::new(one)
})
.collect();
let mut g = Node::group(leaf.id, children);
g.name = leaf.name.clone();
*leaf = g;
Some(made)
}
/// What a painted piece with geometry `path` costs: its anchors and lines, plus a share of the
/// effects' budget for the object it becomes, so that a budget of [`Budget::EFFECTS`] holds at most
/// [`MAX_OBJECTS`](vectorcraft_plugins::objects::MAX_OBJECTS) pieces however small their paths.
fn piece_cost(path: &PathData) -> Budget {
let max = vectorcraft_plugins::objects::MAX_OBJECTS;
Budget::of(path).plus(Budget { anchors: Budget::EFFECTS.anchors / max, lines: Budget::EFFECTS.lines / max })
}
/// Live-effect plug-in `plugin` (one that paints what it returns) with `params`, a later effect of
/// `item`, run on `leaf`, a path of [`transform_piece`] painted by copies of `item`, with reference
/// box `bounds`: `leaf` becomes a group of one path for each piece returned, in order. A piece that
/// gives fills (on a fill) or strokes (on a stroke) is painted by copies of `item`, the leaf's first
/// fill or stroke, with those paints ([`vectorcraft_plugins::painted::items`]), under the leaf's
/// other fills and strokes; one that gives none keeps the leaf's. Returns
/// what the paths cost ([`piece_cost`]); `None` (the leaf stays as it is) when the plug-in fails,
/// the pieces are refused (a swatch or pattern not in `doc`, or any without one), or they would pass
/// `budget`.
fn paint_piece(
leaf: &mut Node,
item: &AppearanceItem,
plugin: &str,
params: &serde_json::Value,
bounds: Rect,
budget: Budget,
doc: Option<&Document>,
) -> Option<Budget> {
let (path, rule) = leaf_geometry(leaf)?;
let paints = vectorcraft_plugins::effect::Paints { appearance: Some(&leaf.appearance), item: Some(0) };
let pieces = vectorcraft_plugins::effect::apply_painted(plugin, params, &path, bounds, rule, paints, doc)?;
let made = pieces.iter().map(|p| piece_cost(&p.path)).fold(Budget { anchors: 0, lines: 0 }, Budget::plus);
if !budget.fits(made) {
return None;
}
let mut children = vec![];
for p in pieces.iter() {
let gives = match item {
AppearanceItem::Fill(_) => p.fills.is_some(),
AppearanceItem::Stroke(_) => p.strokes.is_some(),
};
let items = if gives {
let mut items = vectorcraft_plugins::painted::items(item, p);
items.extend_from_slice(leaf.appearance.items.get(1..).unwrap_or_default());
items
} else {
leaf.appearance.items.clone()
};
if items.is_empty() {
continue;
}
let mut one = leaf.clone();
one.appearance.items = items;
set_geometry(&mut one, p.path.clone(), p.rule);
children.push(Arc::new(one));
}
*leaf = Node::group(leaf.id, children);
Some(made)
}
/// Every path and compound path under `art` (other than clipping paths) with a fill or stroke whose
/// own Transform effect has Transform Patterns or Scale Strokes & Effects on
/// ([`item_transform_options`]), made into a group of objects of its own ([`transform_item_leaf`]).
/// The new paths keep to one [`Budget::EFFECTS`] together (or to what `art` costs, when that is
/// more).
fn transform_items(art: &mut Node) {
/// ([`item_transform_options`]), or whose own live-effect plug-in paints what it returns
/// ([`painted_plugin`]), made into a group of objects of its own ([`transform_item_leaf`]). The
/// new paths keep to one [`Budget::EFFECTS`] together (or to what `art` costs, when that is more).
/// The swatches and patterns painted pieces name must be in `doc`; without one they may name none.
fn transform_items(art: &mut Node, doc: Option<&Document>) {
let mut used = art_cost(art);
let max = Budget::EFFECTS.at_least(used);
for_each_leaf(art, &mut |leaf| transform_item_leaf(leaf, max, &mut used));
for_each_leaf(art, &mut |leaf| transform_item_leaf(leaf, max, &mut used, doc));
}
/// Is `e` an effect that makes a fill's or stroke's piece of a path an object of its own: a
/// Transform effect that [`reaches_appearance`], or a live-effect plug-in that paints what it
/// returns ([`painted_plugin`])?
fn makes_piece(e: &Effect) -> bool {
reaches_appearance(e) || painted_plugin(e).is_some()
}
/// Does `item` paint and carry an effect that [`makes_piece`]?
fn item_makes_piece(item: &AppearanceItem) -> bool {
crate::paints(item) && item.effects().iter().any(makes_piece)
}
/// `leaf`, a path or compound path with a fill or stroke whose own Transform effect has Transform
@@ -866,8 +1023,17 @@ fn transform_items(art: &mut Node) {
/// effects stay on each of them. The other fills and strokes stay together on paths of the leaf's
/// geometry between them, with their own effects. `max` is the budget of the whole art and `used`
/// what it has spent.
fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget) {
if matches!(leaf.kind, NodeKind::Path { clipping: true, .. }) || !leaf.appearance.items.iter().any(item_transform_options) {
///
/// A fill or stroke whose first such effect is a live-effect plug-in that paints what it returns
/// ([`painted_plugin`]) becomes, in place of a transformed path, one path for each piece the
/// plug-in returns, painted by copies of the fill or stroke with the piece's paints
/// ([`vectorcraft_plugins::painted::items`]); the plug-in is given the geometry before it and told
/// which fill or stroke it is on. When it fails (or names a swatch or pattern not in `doc`, or any
/// without a document), the
/// fill or stroke paints that geometry as it is. The geometry effects after it then apply to all
/// the pieces as one ([`transform_piece`]).
fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Option<&Document>) {
if matches!(leaf.kind, NodeKind::Path { clipping: true, .. }) || !leaf.appearance.items.iter().any(item_makes_piece) {
return;
}
let Some((path, rule)) = leaf_geometry(leaf) else { return };
@@ -895,12 +1061,12 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget) {
let mut pieces: Vec<Arc<Node>> = vec![];
let mut plain: Vec<AppearanceItem> = vec![];
// Which items become a transformed piece of their own; every other run of items is one plain piece.
let transformed: Vec<bool> = original.appearance.items.iter().map(|i| crate::paints(i) && i.effects().iter().any(reaches_appearance)).collect();
let transformed: Vec<bool> = original.appearance.items.iter().map(item_makes_piece).collect();
// The number of pieces after item `i` in paint order: each costs at least the leaf's geometry.
let later = |i: usize| (i + 1..transformed.len()).filter(|&j| transformed[j] || j == i + 1 || transformed[j - 1]).count();
for (i, item) in original.appearance.items.iter().enumerate() {
let fx = item.effects();
let Some(at) = fx.iter().position(reaches_appearance).filter(|_| crate::paints(item)) else {
let Some(at) = fx.iter().position(makes_piece).filter(|_| crate::paints(item)) else {
plain.push(item.clone());
continue;
};
@@ -923,6 +1089,33 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget) {
};
let Some(tb) = g.bounds() else { continue };
let params = merged_params(&fx[at].id, &fx[at].params);
if let Some(plugin) = painted_plugin(&fx[at]) {
let mut it = item.clone();
let rest: Vec<Effect> = before.iter().filter(|e| !is_geometry(&e.id)).chain(&fx[at + 1..]).cloned().collect();
*it.effects_mut() = rest;
let mut one = Appearance { items: vec![it.clone()], ..Default::default() };
if carry.moves(&one) {
carry.apply(&mut one);
}
let it = one.items.pop().unwrap_or(it);
let paints = vectorcraft_plugins::effect::Paints { appearance: Some(&original.appearance), item: Some(i) };
let painted = vectorcraft_plugins::effect::apply_painted(plugin, &params, &g, tb, rule, paints, doc)
.map(|pieces| pieces.iter().map(|p| (p.path.clone(), p.rule, vectorcraft_plugins::painted::items(&it, p))).collect::<Vec<_>>())
.filter(|pieces| room.fits(pieces.iter().map(|(p, ..)| piece_cost(p)).fold(Budget { anchors: 0, lines: 0 }, Budget::plus)));
// A failed run draws the fill or stroke on the geometry it was given.
let returned = painted.unwrap_or_else(|| vec![(g.clone(), rule, vec![it])]);
let mut group: Vec<Arc<Node>> = vec![];
for (path, rule, items) in returned.into_iter().filter(|(_, _, items)| !items.is_empty()) {
*used = used.plus(piece_cost(&path));
let mut p = piece(items);
set_geometry(&mut p, path, rule);
group.push(Arc::new(p));
}
let mut p = Node::group(leaf.id, group);
transform_piece(&mut p, ctx.seed, limit, used, doc);
pieces.push(Arc::new(p));
continue;
}
let (_, copies) = crate::distort::transform_map(tb, &params, ctx.seed);
let options = crate::distort::TransformOptions::of(&params);
let map = crate::distort::transform_affine(tb, &params, ctx.seed);
@@ -936,7 +1129,7 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget) {
set_geometry(&mut p, g.clone(), rule);
let (made, _) = transform_leaf(&mut p, &g, map, copies, options, room);
*used = used.plus(made);
transform_piece(&mut p, ctx.seed, limit, used);
transform_piece(&mut p, ctx.seed, limit, used, doc);
pieces.push(Arc::new(p));
}
if !plain.is_empty() {
@@ -955,9 +1148,11 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget) {
/// own effects reshape all its paths ([`reshape_leaves_within`]). Each path applies its own copy of
/// the effect, whose distances its Scale Strokes & Effects scaled. A Transform effect with
/// Transform Patterns or Scale Strokes & Effects on transforms each path as [`transform_leaf`] does.
/// The geometry effects then leave the fills and strokes; their other effects stay. The paths keep
/// to `max` together with the rest of the art (`used`).
fn transform_piece(p: &mut Node, seed: u64, max: Budget, used: &mut Budget) {
/// A live-effect plug-in that paints what it returns ([`painted_plugin`]) makes each path one path
/// for each piece it returns ([`paint_piece`]). The geometry effects then leave the fills and
/// strokes; their other effects stay. The paths keep to `max` together with the rest of the art
/// (`used`), and the swatches and patterns painted pieces name must be in `doc`.
fn transform_piece(p: &mut Node, seed: u64, max: Budget, used: &mut Budget, doc: Option<&Document>) {
let mut count = 0;
for_each_leaf(p, &mut |l| {
if let Some(item) = l.appearance.items.first() {
@@ -973,6 +1168,12 @@ fn transform_piece(p: &mut Node, seed: u64, max: Budget, used: &mut Budget) {
// What the other paths leave of the budget.
let before = Budget::of(&path);
let room = max.minus(used.minus(before));
if let Some(plugin) = painted_plugin(&e) {
if let Some(made) = paint_piece(leaf, &item, plugin, &merged_params(&e.id, &e.params), bounds, room, doc) {
*used = used.minus(before).plus(made);
}
return;
}
if reaches_appearance(&e) {
let params = merged_params(&e.id, &e.params);
let (_, copies) = crate::distort::transform_map(bounds, &params, seed);
@@ -987,7 +1188,7 @@ fn transform_piece(p: &mut Node, seed: u64, max: Budget, used: &mut Budget) {
AppearanceItem::Stroke(s) => Some(s),
AppearanceItem::Fill(_) => None,
};
let ctx = GeomContext { stroke, rule, seed, appearance: Some(&appearance), item: Some(0) };
let ctx = GeomContext { stroke, rule, seed, appearance: Some(&appearance), item: Some(0), doc };
let mut carry = crate::Carry::default();
let out = crate::geometry::apply_geometry_carrying_within(std::slice::from_ref(&e), &path, bounds, &ctx, room, Some(&mut carry));
*used = used.minus(before).plus(Budget::of(&out));
@@ -1276,14 +1477,21 @@ fn placed_count(n: &Node) -> usize {
/// `n` with type anywhere inside it outlined and live objects evaluated, so geometry effects on
/// it reach every member.
pub(crate) fn outlined_members(doc: Option<&Document>, n: &Node) -> Node {
outlined_members_with(doc, n, &outline_text, &SymbolNodes::default())
outlined_members_with(doc, doc, n, &outline_text, &SymbolNodes::default())
}
/// [`outlined_members`] with live objects and symbol instances given as art through `outline`
/// ([`evaluated_with`]).
pub(crate) fn outlined_members_with(doc: Option<&Document>, n: &Node, outline: OutlineHook, nodes: &SymbolNodes) -> Node {
let mut art = evaluated_with(doc, n, outline, nodes);
outline_nested_text(&mut art, false);
/// ([`evaluated_with`]), with the symbols of the art from `symbols` and the swatches and patterns
/// of painted pieces looked up in `paint_doc`.
pub(crate) fn outlined_members_with(
symbols: Option<&Document>,
paint_doc: Option<&Document>,
n: &Node,
outline: OutlineHook,
nodes: &SymbolNodes,
) -> Node {
let mut art = evaluated_with(symbols, n, outline, nodes);
outline_nested_text(paint_doc, &mut art, false);
art
}
+1 -1
View File
@@ -482,7 +482,7 @@ mod tests {
let expanded = expand_revolve_art(&n).unwrap();
let count = |n: &Node| n.children().unwrap()[0].children().unwrap().len();
assert!(count(&expanded) < count(&live) * 3 / 4, "hidden back surfaces are removed");
assert_eq!(crate::bake_appearance(&n), Some(live.clone()), "other baking retains the full surface");
assert_eq!(crate::bake_appearance(None, &n), Some(live.clone()), "other baking retains the full surface");
n.appearance.effects[0].params["expandVisibleOnly"] = json!(false);
assert_eq!(revolve_art(&n), Some(live.clone()), "the live view ignores the expansion setting");
assert_eq!(expand_revolve_art(&n), Some(live));
+2 -2
View File
@@ -330,7 +330,7 @@ fn export_and_expand_bake_faces_with_their_own_ids() {
let mut n = square_node();
n.id = d.alloc_id();
n.appearance.effects.push(fx(id, json!({"bevel": "rounded"})));
assert_eq!(crate::bake_appearance(&n), solid_art(&n), "{id}");
assert_eq!(crate::bake_appearance(None, &n), solid_art(&n), "{id}");
let mut stroke = |_: &mut Document, _: &PathData, _: FillRule, _: &StrokeLayer| None;
let art = crate::expand_leaf(&mut d, &n, &mut stroke).unwrap();
let mut ids = std::collections::HashSet::new();
@@ -356,7 +356,7 @@ fn a_twisted_tapered_hollow_inflate_exports_and_expands() {
let params = json!({"twist": 120, "taper": 50, "cap": false, "bothSides": true, "rotationX": -20, "rotationY": 35});
n.appearance.effects.push(fx(INFLATE, params));
let art = solid_art(&n).unwrap();
assert_eq!(crate::bake_appearance(&n), Some(art.clone()));
assert_eq!(crate::bake_appearance(None, &n), Some(art.clone()));
assert!(faces(&art).len() > 20, "{} faces", faces(&art).len());
let mut stroke = |_: &mut Document, _: &PathData, _: FillRule, _: &StrokeLayer| None;
let expanded = crate::expand_leaf(&mut d, &n, &mut stroke).unwrap();
+11 -11
View File
@@ -412,7 +412,7 @@ fn stacked_effects_on_art_stay_within_the_anchor_budget() {
let leaf = |x: f64| Arc::new(Node::path(id, shapes::rectangle(Rect::new(x, 0.0, x + 100.0, 100.0)), Appearance::default()));
let mut art = Node::group(id, (0..10).map(|k| leaf(k as f64 * 120.0)).collect());
let rough = fx("distort.roughen", json!({"size": 50, "detail": 100}));
crate::reshape::reshape_leaves_within(&mut art, &[&rough, &rough, &rough], Budget { anchors: 5_000, lines: usize::MAX });
crate::reshape::reshape_leaves_within(&mut art, &[&rough, &rough, &rough], Budget { anchors: 5_000, lines: usize::MAX }, None);
let mut total = 0;
let mut reshaped = 0;
for c in art.children().into_iter().flatten() {
@@ -451,12 +451,12 @@ fn art_past_the_budget_keeps_its_own_gradients_in_place() {
for params in [json!({"scaleH": 1000, "scaleV": 1000}), json!({"scaleH": 1000, "scaleV": 1000, "strokes": true})] {
let grow = fx("distort.transform", params.clone());
let mut art = Node::group(id, vec![circle()]);
let carry = crate::reshape::reshape_leaves_within(&mut art, &[&grow], Budget::EFFECTS);
let carry = crate::reshape::reshape_leaves_within(&mut art, &[&grow], Budget::EFFECTS, None);
let end = own_end(&carry).unwrap();
assert!((end - 550.0).abs() < 1e-6, "{params}: within the budget the group's gradient grows with the circle: {end}");
// No room beyond what the circle costs: scaled up, it would be drawn with more lines.
let mut art = Node::group(id, vec![circle()]);
let carry = crate::reshape::reshape_leaves_within(&mut art, &[&grow], Budget { anchors: usize::MAX, lines: 0 });
let carry = crate::reshape::reshape_leaves_within(&mut art, &[&grow], Budget { anchors: usize::MAX, lines: 0 }, None);
let b = art.geometric_bounds().unwrap();
assert!(b.width() < 101.0 && b.height() < 101.0, "{params}: the circle keeps its shape: {b:?}");
assert_eq!(own_end(&carry), Some(100.0), "{params}: the group's gradient stays where it was placed");
@@ -465,7 +465,7 @@ fn art_past_the_budget_keeps_its_own_gradients_in_place() {
let mut inner = Node::group(NodeId(2), vec![square]);
inner.appearance.items.push(AppearanceItem::Fill(FillLayer::new(placed.clone())));
let mut art = Node::group(id, vec![circle(), Arc::new(inner)]);
let carry = crate::reshape::reshape_leaves_within(&mut art, &[&grow], Budget { anchors: usize::MAX, lines: 0 });
let carry = crate::reshape::reshape_leaves_within(&mut art, &[&grow], Budget { anchors: usize::MAX, lines: 0 }, None);
assert_eq!(own_end(&carry), Some(100.0), "{params}: the art's own gradient stays");
let inner = &art.children().unwrap()[1];
let Paint::Gradient(g) = inner.appearance.fill_paint() else { panic!("a gradient") };
@@ -509,7 +509,7 @@ fn a_hidden_path_past_the_budget_leaves_a_layers_gradients_free_to_move() {
let corners = json!({"corners": [[-4.5, -4.5], [5.5, -4.5], [5.5, 5.5], [-4.5, 5.5]]});
for grow in [fx("distort.transform", json!({"scaleH": 1000, "scaleV": 1000})), fx("distort.freeDistort", corners)] {
let mut art = layer();
let carry = crate::reshape::reshape_leaves_within(&mut art, &[&grow], budget);
let carry = crate::reshape::reshape_leaves_within(&mut art, &[&grow], budget, None);
let circle = art.children().unwrap()[1].geometric_bounds().unwrap();
assert!(circle.width() < 101.0, "{}: the hidden circle keeps its shape: {circle:?}", grow.id);
let square = art.children().unwrap()[0].geometric_bounds().unwrap();
@@ -518,7 +518,7 @@ fn a_hidden_path_past_the_budget_leaves_a_layers_gradients_free_to_move() {
carry.apply(&mut own);
assert_eq!(end(&own), Some(550.0), "{}: the layer's gradient, as the art", grow.id);
let mut art = Node::group(NodeId(9), vec![Arc::new(layer())]);
crate::reshape::reshape_leaves_within(&mut art, &[&grow], budget);
crate::reshape::reshape_leaves_within(&mut art, &[&grow], budget, None);
assert_eq!(end(&art.children().unwrap()[0].appearance), Some(550.0), "{}: the layer's gradient, inside the art", grow.id);
}
}
@@ -549,7 +549,7 @@ fn edited_freeform_points_follow_a_transform_effect_and_free_distort() {
inner.appearance.items.push(AppearanceItem::Fill(FillLayer::new(freeform.clone())));
let mut art = Node::group(NodeId(9), vec![square(Appearance::basic(freeform.clone(), Paint::None, 0.0)), Arc::new(inner)]);
art.appearance.items.push(AppearanceItem::Fill(FillLayer::new(freeform.clone())));
let carry = crate::reshape::reshape_leaves_within(&mut art, &[&e], Budget::EFFECTS);
let carry = crate::reshape::reshape_leaves_within(&mut art, &[&e], Budget::EFFECTS, None);
let members = art.children().unwrap();
assert_eq!(xs(&members[0].appearance), Some(vec![30.0, 130.0]), "{}: the path's points", e.id);
assert_eq!(xs(&members[1].appearance), Some(vec![30.0, 130.0]), "{}: the inner group's points", e.id);
@@ -625,7 +625,7 @@ fn reshape_leaves_within_optimizes_transform_before_terminal_offset_path() {
let offset = fx("path.offsetPath", json!({"offset": 4.0, "joins": "round"}));
let start = std::time::Instant::now();
crate::reshape::reshape_leaves_within(&mut art, &[&transform, &offset], Budget::EFFECTS);
crate::reshape::reshape_leaves_within(&mut art, &[&transform, &offset], Budget::EFFECTS, None);
let elapsed = start.elapsed();
assert!(elapsed < std::time::Duration::from_secs(2));
@@ -652,7 +652,7 @@ fn reshape_leaves_within_preserves_order_when_not_terminal_or_not_style_safe() {
let mut art_evenodd = Node::group(id, vec![Arc::new(leaf)]);
let transform = fx("distort.transform", json!({"moveH": 2.0, "moveV": 2.0, "copies": 2}));
let offset = fx("path.offsetPath", json!({"offset": 4.0}));
crate::reshape::reshape_leaves_within(&mut art_evenodd, &[&transform, &offset], Budget::EFFECTS);
crate::reshape::reshape_leaves_within(&mut art_evenodd, &[&transform, &offset], Budget::EFFECTS, None);
let p = art_evenodd.children().unwrap()[0].path_data().unwrap();
assert_eq!(p.subpaths.len(), 1, "EvenOdd does not swap; copies union into 1 outer subpath");
@@ -660,7 +660,7 @@ fn reshape_leaves_within_preserves_order_when_not_terminal_or_not_style_safe() {
let leaf_round = Arc::new(Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), Appearance::default()));
let mut art_round = Node::group(id, vec![leaf_round]);
let round_corners = fx("stylize.roundCorners", json!({"radius": 2.0}));
crate::reshape::reshape_leaves_within(&mut art_round, &[&transform, &offset, &round_corners], Budget::EFFECTS);
crate::reshape::reshape_leaves_within(&mut art_round, &[&transform, &offset, &round_corners], Budget::EFFECTS, None);
let p = art_round.children().unwrap()[0].path_data().unwrap();
assert_eq!(p.subpaths.len(), 1, "Non-terminal Offset Path does not swap; copies union into 1 outer subpath");
@@ -668,7 +668,7 @@ fn reshape_leaves_within_preserves_order_when_not_terminal_or_not_style_safe() {
let leaf_miter = Arc::new(Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), Appearance::default()));
let mut art_miter = Node::group(id, vec![leaf_miter]);
let offset_miter = fx("path.offsetPath", json!({"offset": 4.0, "joins": "miter"}));
crate::reshape::reshape_leaves_within(&mut art_miter, &[&transform, &offset_miter], Budget::EFFECTS);
crate::reshape::reshape_leaves_within(&mut art_miter, &[&transform, &offset_miter], Budget::EFFECTS, None);
let p = art_miter.children().unwrap()[0].path_data().unwrap();
assert_eq!(p.subpaths.len(), 1, "Miter join does not swap; copies union into 1 outer subpath");
}
+6
View File
@@ -40,6 +40,9 @@ vectorcraft-plugins = { workspace = true }
vectorcraft-affinity = { workspace = true }
vectorcraft-scriptcheck = { workspace = true }
image = { workspace = true }
jxl-oxide = { workspace = true }
jxl-color = { workspace = true }
jxl-grid = { workspace = true }
hayro-jpeg2000 = { version = "=0.3.5", default-features = false, features = ["std", "simd"] }
# Pure Rust, no unsafe; pinned at the version photocraft-heif uses.
heic-rs = { version = "=0.1.4", default-features = false, features = ["std"], optional = true }
@@ -77,5 +80,8 @@ vectorcraft-pdf = { workspace = true, features = ["read-count"] }
wat = { workspace = true }
# Zstandard streams for the Illustrator editing data fuzz tests.
ruzstd = { workspace = true }
# JPEG XL files for the `.jxl` open and place tests.
zune-jpegxl = { workspace = true }
zune-core = { workspace = true }
# The JavaScript parser's lexer, compared with the script checker's on the same scripts.
vectorcraft-js = { workspace = true }
+1 -1
View File
@@ -182,7 +182,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Load Image into Clipboard",
[],
None,
"{dataBase64 (PNG, JPEG, GIF, WebP, TIFF or BMP bytes), mime?: the type the system clipboard gave (an image/… type; the format is read from the bytes), center?: [x, y]} replace the clipboard with the image, embedded at 100% of its physical size (its resolution, else 72 ppi), centred on `center` (default: the first artboard) → {count, width, height (pt), pixelWidth, pixelHeight}; then run edit.paste. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error",
"{dataBase64 (PNG, JPEG, GIF, WebP, TIFF, BMP, Targa, PCX, PSD, PSB or JPEG XL bytes), mime?: the type the system clipboard gave (an image/… type; the format is read from the bytes), center?: [x, y]} replace the clipboard with the image, embedded at 100% of its physical size (its resolution, else 72 ppi), centered on `center` (default: the first artboard) → {count, width, height (pt), pixelWidth, pixelHeight}; then run edit.paste. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error",
has_doc,
import_image
),
+166 -13
View File
@@ -30,7 +30,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Invert Colors",
["Edit", "Edit Colors"],
None,
"{fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?} replace each colour by its RGB inverse (keeps the colour model) in fills, strokes, text, gradient meshes (with fills), colorized image fills (their pixels stay unchanged), other embedded images (a recoloured copy) and pattern tiles (a new pattern swatch; the original stays), as one undo step → {changed}",
"{fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?} replace each colour by its RGB inverse (keeps the colour model) in fills, strokes, text, gradient meshes (with fills), colorized image fills (their pixels stay unchanged), other embedded images (a recoloured copy) and pattern tiles (a new pattern swatch; the original stays), as one undo step → {changed, notChanged: {locked, hidden} (when the selection is everything select.all takes: the locked and hidden objects it left out whose colors would have changed; an object both hidden and locked counts as hidden), warning?: set when notChanged counts any object}",
has_selection,
|s, p| recolor(s, p, "edit.colors.invert", "Invert Colors", &invert)
),
@@ -39,7 +39,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Convert to CMYK",
["Edit", "Edit Colors"],
None,
"{fill?, stroke?, includeImages?, includePatterns?, ids?} (as edit.colors.invert) → {changed}",
"{fill?, stroke?, includeImages?, includePatterns?, ids?} (as edit.colors.invert) → {changed, notChanged, warning?}",
has_selection,
|s, p| recolor(s, p, "edit.colors.toCMYK", "Convert to CMYK", &to_cmyk)
),
@@ -48,7 +48,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Convert to Grayscale",
["Edit", "Edit Colors"],
None,
"{fill?, stroke?, includeImages?, includePatterns?, ids?} (as edit.colors.invert) → {changed}",
"{fill?, stroke?, includeImages?, includePatterns?, ids?} (as edit.colors.invert) → {changed, notChanged, warning?}",
has_selection,
|s, p| recolor(s, p, "edit.colors.toGrayscale", "Convert to Grayscale", &to_gray)
),
@@ -57,7 +57,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Convert to RGB",
["Edit", "Edit Colors"],
None,
"{fill?, stroke?, includeImages?, includePatterns?, ids?} (as edit.colors.invert) → {changed}",
"{fill?, stroke?, includeImages?, includePatterns?, ids?} (as edit.colors.invert) → {changed, notChanged, warning?}",
has_selection,
|s, p| {
recolor(s, p, "edit.colors.toRGB", "Convert to RGB", &|c| {
@@ -71,7 +71,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Saturate…",
["Edit", "Edit Colors"],
None,
"{intensity: -100..100 (%; a number outside the range counts as its nearest end), fill?, stroke?, includeImages?, includePatterns?, ids?} scale saturation by (1 + intensity/100), reaching what edit.colors.invert does → {changed}",
"{intensity: -100..100 (%; a number outside the range counts as its nearest end), fill?, stroke?, includeImages?, includePatterns?, ids?} scale saturation by (1 + intensity/100), reaching what edit.colors.invert does → {changed, notChanged, warning?} (as edit.colors.invert)",
has_selection,
saturate
),
@@ -80,7 +80,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Adjust Color Balance…",
["Edit", "Edit Colors"],
None,
"{mode?: \"rgb\"|\"cmyk\"|\"gray\"|\"global\" (default: from the channels given), r?, g?, b? | c?, m?, y?, k? | gray? | tint?: -100..100 (% added per channel, default 0; `tint` is global mode: it shifts the tints of colours and gradient stops linked to global and spot swatches, which stay linked, and leaves other colours and images alone), convert?: false (true: the results stay in the adjusted model; false: each colour keeps its own), fill?, stroke?, includeImages?, includePatterns?, ids?} reaching what edit.colors.invert does → {changed}",
"{mode?: \"rgb\"|\"cmyk\"|\"gray\"|\"global\" (default: from the channels given), r?, g?, b? | c?, m?, y?, k? | gray? | tint?: -100..100 (% added per channel, default 0; `tint` is global mode: it shifts the tints of colours and gradient stops linked to global and spot swatches, which stay linked, and leaves other colours and images alone), convert?: false (true: the results stay in the adjusted model; false: each colour keeps its own), fill?, stroke?, includeImages?, includePatterns?, ids?} reaching what edit.colors.invert does → {changed, notChanged, warning?} (as edit.colors.invert)",
has_selection,
adjust_balance
),
@@ -213,6 +213,48 @@ impl<'a> Recolor<'a> {
Self { links: Some(links), ..Self::new(scope, f) }
}
/// A pass that maps colors as this one does, with nothing recolored yet.
fn again(&self) -> Self {
Self { links: self.links, ..Self::new(self.scope, self.f) }
}
/// The entries of `objects` (ids, each with a flag) whose object this pass would change, other
/// than by swatch links alone, its patterns' tiles included. The document stays as it is.
pub(crate) fn would_change<T: Copy>(mut self, d: &Document, objects: &[(NodeId, T)]) -> Vec<(NodeId, T)> {
let tried: Vec<(&Node, Node, T)> =
objects.iter().filter_map(|&(id, flag)| d.node(id).and_then(|n| Some((n, self.subtree(d, n)?, flag)))).collect();
let same = self.link_only_copies(d);
tried.into_iter().filter_map(|(n, m, flag)| (without_links(m, &same) != without_links(n.clone(), &same)).then_some((n.id, flag))).collect()
}
/// The patterns this pass has copied whose copy differs from the original only by swatch
/// links: the copy's name → the original's. A copy whose tile uses other copies counts when
/// those copies do.
fn link_only_copies(&self, d: &Document) -> HashMap<String, String> {
let art = |v: &[Arc<Node>], same: &HashMap<String, String>| v.iter().map(|n| without_links((**n).clone(), same)).collect::<Vec<_>>();
let copies: HashMap<&str, &PatternDef> = self.new_patterns.iter().map(|p| (p.name.as_str(), p)).collect();
let pairs: Vec<(&String, &String, &PatternDef, &PatternDef)> = self
.patterns
.iter()
.filter_map(|(old, new)| {
let new = new.as_ref()?;
Some((new, old, d.pattern(old)?, *copies.get(new.as_str())?))
})
.collect();
let mut same = HashMap::new();
loop {
let found: Vec<(String, String)> = pairs
.iter()
.filter(|(new, _, original, copy)| !same.contains_key(*new) && art(&original.art, &same) == art(&copy.art, &same))
.map(|(new, old, _, _)| ((*new).clone(), (*old).clone()))
.collect();
if found.is_empty() {
return same;
}
same.extend(found);
}
}
/// Recolour the objects `ids` and everything inside them (each once), add the new images and
/// pattern swatches, and return the number of paints, meshes and images changed.
pub(crate) fn run(mut self, d: &mut Document, ids: &[NodeId]) -> usize {
@@ -313,6 +355,29 @@ impl<'a> Recolor<'a> {
}
}
/// `n` with the swatch links of its paints and its children's removed, each color kept as it
/// shows, and each pattern named in `same` (a copy → its original) by its original's name.
fn without_links(mut n: Node, same: &HashMap<String, String>) -> Node {
for p in vectorcraft_doc::swatches::node_paints_mut(&mut n) {
map_links(p, &|_, link, _| link.take().is_some());
match p {
Paint::Gradient(g) => g.swatch = None,
Paint::Pattern { pattern, .. } => {
if let Some(original) = same.get(pattern.as_str()) {
*pattern = original.clone();
}
}
_ => {}
}
}
if let Some(children) = n.children_mut() {
for c in children.iter_mut() {
*c = Arc::new(without_links((**c).clone(), same));
}
}
n
}
/// What a pass over document `d` does to each colour the shared visitor meets.
struct Visit<'p, 'a> {
pass: &'p mut Recolor<'a>,
@@ -342,19 +407,107 @@ pub(crate) fn recolor_links(s: &mut Session, p: &Value, cmd: &str, label: &str,
}
/// Run `pass` on the `ids` param's objects (default: the selection) as one undo step named `label`;
/// errors name the command `cmd`.
/// errors name the command `cmd`. The reply's `notChanged` counts the locked and hidden objects the
/// selection left out ([`left_out`]) that the pass would have changed.
fn run_pass(s: &mut Session, p: &Value, cmd: &str, label: &str, pass: Recolor) -> Result<Value> {
let ids = match ids_param(p, "ids", cmd)? {
Some(v) => v,
None => selected_roots(s)?,
let (ids, left) = match ids_param(p, "ids", cmd)? {
Some(v) => (v, vec![]),
None => {
let roots = selected_roots(s)?;
let st = s.doc()?;
let left = left_out(&st.doc, st.isolation, &roots);
(roots, left)
}
};
let colorized_ok = super::paint::check_colorized(&s.doc()?.doc).is_ok();
let n = s.edit(label, |d, _| {
let (n, not_changed) = s.edit(label, |d, _| {
let not_changed = NotChanged::of(pass.again().would_change(d, &left).into_iter().map(|(_, hidden)| hidden));
let n = pass.run(d, &ids);
super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(cmd, e))?;
Ok(n)
Ok((n, not_changed))
})?;
Ok(json!({ "changed": n }))
let mut out = json!({ "changed": n });
not_changed.report(&mut out);
Ok(out)
}
/// The objects a recolor of `roots` leaves out of the art the user meant to recolor, each with
/// whether it is hidden (otherwise it is locked). When `roots` are exactly the objects Select All
/// takes (in isolation mode, those of the isolated group), these are the objects beside them that
/// Select All skips: hidden or locked objects, the objects of hidden or locked layers and
/// sublayers, and, outside isolation mode, objects on locked artboards (counted as locked). The art
/// of template layers and the objects inside a group are not listed. With any other `roots`, none.
pub(crate) fn left_out(d: &Document, iso: Option<NodeId>, roots: &[NodeId]) -> Vec<(NodeId, bool)> {
fn visit(n: &Node, hidden: bool, locked: bool, all: &HashSet<NodeId>, board_locked: &dyn Fn(&Node) -> bool, out: &mut Vec<(NodeId, bool)>) {
for c in n.children().into_iter().flatten() {
if c.is_template() {
continue;
}
let (hidden, locked) = (hidden || !c.visible, locked || c.locked);
if c.is_layer() {
visit(c, hidden, locked, all, board_locked, out);
} else if !all.contains(&c.id) && (hidden || locked || board_locked(c)) {
out.push((c.id, hidden));
}
}
}
let all: HashSet<NodeId> = super::select::selectable(d, iso).into_iter().collect();
let picked: HashSet<NodeId> = roots.iter().copied().collect();
if picked.is_empty() || picked != all {
return vec![];
}
let mut out = vec![];
match iso.and_then(|i| d.node(i)) {
Some(c) => visit(c, false, false, &all, &|_| false, &mut out),
None => {
let boards = d.locked_artboards();
let board_locked = |n: &Node| !boards.is_empty() && boards.holds(n);
for l in d.layers.iter().filter(|l| !l.is_template()) {
visit(l, !l.visible, l.locked, &all, &board_locked, &mut out);
}
}
}
out
}
/// How many locked and hidden objects a recolor left out ([`left_out`]) although it would have
/// changed their colors. An object both hidden and locked counts as hidden.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) struct NotChanged {
pub locked: usize,
pub hidden: usize,
}
impl NotChanged {
/// The counts of objects given by whether each is hidden.
pub(crate) fn of(hidden: impl IntoIterator<Item = bool>) -> Self {
hidden.into_iter().fold(Self::default(), |n, h| if h { Self { hidden: n.hidden + 1, ..n } } else { Self { locked: n.locked + 1, ..n } })
}
/// Add `notChanged` to reply `out`, and a `warning`, which the app shows in its status bar,
/// when any object is counted.
pub(crate) fn report(self, out: &mut Value) {
out["notChanged"] = json!({ "locked": self.locked, "hidden": self.hidden });
if let Some(w) = self.warning() {
out["warning"] = json!(w);
}
}
fn warning(self) -> Option<String> {
let part = |n: usize, kind: &str| match n {
0 => None,
1 => Some(format!("1 {kind} object")),
n => Some(format!("{n} {kind} objects")),
};
match (part(self.locked, "locked"), part(self.hidden, "hidden")) {
(Some(locked), Some(hidden)) => Some(format!("Recolor completed; {locked} and {hidden} were left unchanged")),
(Some(objects), None) | (None, Some(objects)) if self.locked + self.hidden == 1 => {
Some(format!("Recolor completed; {objects} was left unchanged"))
}
(Some(objects), None) | (None, Some(objects)) => Some(format!("Recolor completed; {objects} were left unchanged")),
(None, None) => None,
}
}
}
fn saturate(s: &mut Session, p: &Value) -> Result<Value> {
+3 -3
View File
@@ -189,7 +189,7 @@ fn make(s: &mut Session, p: &Value) -> Result<Value> {
/// that has a shape. A shape taken from inside a group, or from the art of a blend, an envelope or
/// a warped group, also takes that object's opacity and blend mode.
fn top_style(d: &Document, n: &Node, hook: effects::OutlineHook) -> Option<Style> {
if let Some(art) = effects::shape_art(n, Some(hook)) {
if let Some(art) = effects::shape_art(Some(d), n, Some(hook)) {
let mut lv = vec![];
pf_leaves(d, &art, &mut lv);
let inside = matches!(art.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) && !art.is_compound_shape();
@@ -199,7 +199,7 @@ fn top_style(d: &Document, n: &Node, hook: effects::OutlineHook) -> Option<Style
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } if !n.is_compound_shape() => {
children.iter().rev().find_map(|c| top_style(d, c, hook)).map(|s| s.within(n))
}
NodeKind::SymbolInstance { .. } => effects::member_leaves(n, Some(hook)).last().map(style_of),
NodeKind::SymbolInstance { .. } => effects::member_leaves(Some(d), n, Some(hook)).last().map(style_of),
_ => {
let mut lv = vec![];
pf_leaves(d, n, &mut lv);
@@ -267,7 +267,7 @@ fn expand(s: &mut Session, _: &Value) -> Result<Value> {
for id in &shapes {
let shape = d.node(*id).cloned().ok_or(EngineError::NoNode(*id))?;
let hook = |c: &Node| effects::outline_object(Some(d), c);
let area = effects::compound_shape_area(&shape, Some(&hook))
let area = effects::compound_shape_area(Some(d), &shape, Some(&hook))
.ok_or_else(|| EngineError::Other("the compound shape is too complex to expand".into()))?;
if area.is_empty() {
return Err(EngineError::Other(format!("{LABEL} produced an empty result")));
+3 -2
View File
@@ -283,6 +283,7 @@ fn painted_runs<'a>(d: &Document, n: &'a Node) -> Option<(&'a vectorcraft_doc::T
/// it) or of type whose layout draws nothing (`painted_runs`). An image's fills and strokes paint
/// only where an effect gives them a shape.
fn own_patterns<'a>(
d: &Document,
n: &'a Node,
members: &HashSet<*const Node>,
painted_runs: Option<&(&vectorcraft_doc::TextObject, BTreeSet<usize>)>,
@@ -296,7 +297,7 @@ fn own_patterns<'a>(
// The patterns an image's shaped fills and strokes paint (their effects can transform the
// pattern, so they are matched by name).
let shaped: Option<Vec<String>> = matches!(n.kind, NodeKind::Image(_)).then(|| {
vectorcraft_render::effects::image_paint(n)
vectorcraft_render::effects::image_paint(Some(d), n)
.into_iter()
.filter_map(|(_, _, item)| match item.paint() {
Paint::Pattern { pattern, .. } => Some(pattern.clone()),
@@ -415,7 +416,7 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
// The patterns this object's fills and strokes use, each once.
let mut used_patterns: Vec<&str> = vec![];
let painted_runs = painted_runs(d, n);
for pattern in own_patterns(n, &members, painted_runs.as_ref()) {
for pattern in own_patterns(d, n, &members, painted_runs.as_ref()) {
patterns.insert(pattern.to_string());
used_patterns.push(pattern);
}
+94 -26
View File
@@ -393,12 +393,21 @@ fn expand_gradient_fills(s: &mut Session, to: GradientTo) -> Result<()> {
/// Path `l` with each gradient fill turned into art, in appearance order: the other items stay
/// on plain copies of the path. The path's geometry effects ([`reshaped`]) and each fill's own
/// ([`fill_shape`]) shape the outline the art fills first, as on the canvas. `None` when no
/// gradient fill can be expanded.
/// gradient fill can be expanded. A path that live-effect plug-ins paint is expanded piece by piece
/// ([`expand_pieces`]).
fn expand_fills(d: &mut Document, l: &Node, to: GradientTo) -> Option<Node> {
if vectorcraft_render::effects::has_painted_effects(l) {
return expand_pieces(d, l, usize::MAX, |d, piece, _| expand_piece_fills(d, piece, to));
}
expand_piece_fills(d, l, to)
}
/// [`expand_fills`] of a path with no live-effect plug-in that paints what it returns.
fn expand_piece_fills(d: &mut Document, l: &Node, to: GradientTo) -> Option<Node> {
if !matches!(l.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) || !l.appearance.items.iter().any(|i| gradient_fill(i).is_some()) {
return None;
}
let reshaped = reshaped(l);
let reshaped = reshaped(d, l);
let l = reshaped.as_ref().unwrap_or(l);
let (path, _) = node_path(l)?;
let bounds = path.bounds()?;
@@ -408,7 +417,7 @@ fn expand_fills(d: &mut Document, l: &Node, to: GradientTo) -> Option<Node> {
for item in &l.appearance.items {
// Items that paint nothing go; a gradient that can't be expanded stays.
let art = gradient_fill(item).and_then(|_| {
let (fl, path, bounds, moved) = fill_shape(l, item, &path, bounds)?;
let (fl, path, bounds, moved) = fill_shape(d, l, item, &path, bounds)?;
let (f, g) = gradient_fill(&moved)?;
gradient_art(d, &fl, &path, bounds, f, g, to)
});
@@ -467,16 +476,57 @@ fn gradient_art(d: &mut Document, l: &Node, path: &PathData, bounds: Rect, f: &F
Some(art)
}
/// Path `l`, which live-effect plug-ins paint, as the art the canvas draws
/// ([`reshape`](vectorcraft_render::effects::reshape)) with ids of its own, each piece of the art
/// replaced by what `expand` makes of it (`expand` gets the piece and how many objects it may become;
/// `None` keeps the piece with its own paints). `None` when the art would hold more than `budget`
/// objects or no piece expands.
fn expand_pieces(d: &mut Document, l: &Node, budget: usize, mut expand: impl FnMut(&mut Document, &Node, usize) -> Option<Node>) -> Option<Node> {
let art = vectorcraft_render::effects::reshape(Some(d), l, None)?;
let mut art = d.reid(&art);
art.locked = l.locked;
let mut left = budget.checked_sub(art.count_with_masks())?;
let mut expanded = false;
expand_piece_leaves(d, &mut art, &mut left, &mut expanded, &mut expand);
expanded.then_some(art)
}
/// Replace each path and compound path under `n` (`n` included) by what `expand` makes of it, in
/// paint order. `left` is how many more objects the art may hold, and `expanded` is set when a path
/// is replaced.
fn expand_piece_leaves(
d: &mut Document,
n: &mut Node,
left: &mut usize,
expanded: &mut bool,
expand: &mut impl FnMut(&mut Document, &Node, usize) -> Option<Node>,
) {
if matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) {
let room = *left + n.count_with_masks();
if let Some(new) = expand(d, n, room) {
*left = room.saturating_sub(new.count_with_masks());
*n = new;
*expanded = true;
}
return;
}
for c in n.children_mut().into_iter().flatten() {
expand_piece_leaves(d, Arc::make_mut(c), left, expanded, expand);
}
}
/// Path `l` as the canvas paints its fills and strokes: its visible geometry effects applied to its
/// path (a plain path then), and every geometry effect of its own removed. `None` when it has no
/// visible geometry effect.
pub(crate) fn reshaped(l: &Node) -> Option<Node> {
/// visible geometry effect. The swatches and patterns that live-effect plug-ins painting their
/// pieces name are looked up in `doc`. This merges the pieces of such a plug-in into one path, so a
/// path that one paints goes through [`expand_pieces`] instead.
pub(crate) fn reshaped(doc: &Document, l: &Node) -> Option<Node> {
use vectorcraft_render::effects::{GeomContext, apply_geometry_carrying, has_geometry, is_geometry};
if !has_geometry(&l.appearance.effects) {
return None;
}
let (path, _) = node_path(l)?;
let (g, carry) = apply_geometry_carrying(&l.appearance.effects, &path, path.bounds()?, &GeomContext::of(l));
let (g, carry) = apply_geometry_carrying(&l.appearance.effects, &path, path.bounds()?, &GeomContext { doc: Some(doc), ..GeomContext::of(l) });
let mut m = with_path(l, g);
// Its gradients move with the outline, as the canvas paints them.
if carry.moves(&m.appearance) {
@@ -490,13 +540,19 @@ pub(crate) fn reshaped(l: &Node) -> Option<Node> {
/// outline, box and fill: when the fill has visible geometry effects, `l` with the outline they
/// make of `path` as a plain path, that outline's box, and the fill with its gradient moved along
/// with the outline.
pub(crate) fn fill_shape(l: &Node, item: &AppearanceItem, path: &PathData, bounds: Rect) -> Option<(Node, PathData, Rect, AppearanceItem)> {
pub(crate) fn fill_shape(
doc: &Document,
l: &Node,
item: &AppearanceItem,
path: &PathData,
bounds: Rect,
) -> Option<(Node, PathData, Rect, AppearanceItem)> {
use vectorcraft_render::effects::{GeomContext, apply_geometry_carrying, has_geometry};
if !has_geometry(item.effects()) {
return Some((l.clone(), path.clone(), bounds, item.clone()));
}
// In the fill's context, as drawing runs them: a plug-in effect is told which fill it is on.
let (outline, carry) = apply_geometry_carrying(item.effects(), path, bounds, &GeomContext::of(l).item(item));
let (outline, carry) = apply_geometry_carrying(item.effects(), path, bounds, &GeomContext { doc: Some(doc), ..GeomContext::of(l) }.item(item));
let b = outline.bounds()?;
let mut one = Appearance { items: vec![item.clone()], ..Default::default() };
if carry.moves(&one) {
@@ -698,7 +754,7 @@ fn expand_live_with_transparent_image_patterns(
let Some(n) = d.node(*id) else { continue };
// Random Transform effects take their seed from the ids: bake them first ([`bake_seeded`]),
// and leave the object live when that can't be done.
let Some(mut g) = bake_seeded(&vectorcraft_render::effects::expand_live_deep(Some(d), n)) else { continue };
let Some(mut g) = bake_seeded(d, &vectorcraft_render::effects::expand_live_deep(Some(d), n)) else { continue };
// An object whose art would take the expanded art past the budget stays live.
let size = g.count_with_masks();
if size > budget - made.get() {
@@ -781,14 +837,23 @@ fn expand_patterns(d: &mut Document, l: &Node) -> Option<Node> {
}
/// [`expand_patterns`] for only the pattern fills `which` picks: the others stay like the other
/// items, as does a pattern fill whose tiles would take the tiles of `l` past `budget` objects.
/// items, as does a pattern fill whose tiles would take the tiles of `l` past `budget` objects. A
/// path that live-effect plug-ins paint is expanded piece by piece ([`expand_pieces`]).
fn expand_patterns_where(d: &mut Document, l: &Node, which: impl Fn(&Document, &AppearanceItem) -> bool, budget: usize) -> Option<Node> {
if vectorcraft_render::effects::has_painted_effects(l) {
return expand_pieces(d, l, budget, |d, piece, room| expand_piece_patterns(d, piece, &which, room));
}
expand_piece_patterns(d, l, which, budget)
}
/// [`expand_patterns_where`] of a path with no live-effect plug-in that paints what it returns.
fn expand_piece_patterns(d: &mut Document, l: &Node, which: impl Fn(&Document, &AppearanceItem) -> bool, budget: usize) -> Option<Node> {
if !matches!(l.kind, NodeKind::Path { .. } | NodeKind::Compound { .. })
|| !l.appearance.items.iter().any(|i| pattern_fill(d, i).is_some() && which(d, i))
{
return None;
}
let reshaped = reshaped(l);
let reshaped = reshaped(d, l);
let l = reshaped.as_ref().unwrap_or(l);
let (path, _) = node_path(l)?;
let bounds = path.bounds()?;
@@ -820,7 +885,7 @@ fn expand_patterns_where(d: &mut Document, l: &Node, which: impl Fn(&Document, &
later -= usize::from(fill.is_some());
let room = if later > 0 { left.saturating_sub(l.count() + 1) } else { left };
let tiles = fill.filter(|_| !full).and_then(|(f, def, xf)| {
let (fl, _, bounds, _) = fill_shape(l, item, &path, bounds)?;
let (fl, _, bounds, _) = fill_shape(d, l, item, &path, bounds)?;
let Some(t) = pattern_tiles(d, &def, xf, bounds, room) else {
full = true;
return None;
@@ -867,7 +932,7 @@ fn pattern_tiles(d: &mut Document, def: &PatternDef, xf: Affine, bounds: Rect, b
if !moves_only(xf) && def.art.iter().any(|a| has_instances(a)) {
return Some(vec![]);
}
let Some(def) = seeds_baked(def) else {
let Some(def) = seeds_baked(d, def) else {
return Some(vec![]);
};
let brushes = vectorcraft_brush::library(d);
@@ -900,13 +965,14 @@ fn pattern_tiles(d: &mut Document, def: &PatternDef, xf: Affine, bounds: Rect, b
/// effect baked into filled art ([`bake_appearance`](vectorcraft_render::effects::bake_appearance))
/// while it keeps its own id: the effect takes its seed from the id, and the canvas draws the art
/// with those ids for every tile, while the tiles get ids of their own. `None` when something
/// other than a path has such an effect.
fn seeds_baked(def: &PatternDef) -> Option<std::borrow::Cow<'_, PatternDef>> {
/// other than a path has such an effect. The swatches and patterns that live-effect plug-ins
/// painting their pieces name are looked up in `d`.
fn seeds_baked<'a>(d: &Document, def: &'a PatternDef) -> Option<std::borrow::Cow<'a, PatternDef>> {
if !def.art.iter().any(|a| has_seeded(a)) {
return Some(std::borrow::Cow::Borrowed(def));
}
let mut def = def.clone();
def.art = def.art.iter().map(|a| bake_seeded(a).map(Arc::new)).collect::<Option<_>>()?;
def.art = def.art.iter().map(|a| bake_seeded(d, a).map(Arc::new)).collect::<Option<_>>()?;
Some(std::borrow::Cow::Owned(def))
}
@@ -1015,8 +1081,9 @@ pub(crate) fn has_seeded(n: &Node) -> bool {
}
/// `n` with the paths in it that have a random Transform effect baked ([`seeds_baked`]). `None`
/// when something else has one.
fn bake_seeded(n: &Node) -> Option<Node> {
/// when something else has one. The swatches and patterns that live-effect plug-ins painting their
/// pieces name are looked up in `d`.
fn bake_seeded(d: &Document, n: &Node) -> Option<Node> {
if !has_seeded(n) {
return Some(n.clone());
}
@@ -1024,17 +1091,17 @@ fn bake_seeded(n: &Node) -> Option<Node> {
if !matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) {
return None;
}
vectorcraft_render::effects::bake_appearance(n)?
vectorcraft_render::effects::bake_appearance(Some(d), n)?
} else {
n.clone()
};
if !matches!(n.kind, NodeKind::Compound { .. }) {
for c in n.children_mut().into_iter().flatten() {
*c = Arc::new(bake_seeded(c)?);
*c = Arc::new(bake_seeded(d, c)?);
}
}
if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) {
m.art = Arc::new(bake_seeded(&m.art)?);
m.art = Arc::new(bake_seeded(d, &m.art)?);
}
Some(n)
}
@@ -1055,7 +1122,7 @@ fn has_unbakeable_effects(n: &Node) -> bool {
/// paths baked ([`baked`], [`outline_brushed`]) and its live shapes turned into plain paths (a
/// stretch would rebuild their corners round), in its opacity masks too.
fn prepared(d: &mut Document, brushes: &[vectorcraft_brush::Brush], n: &Node) -> Node {
let mut t = baked(n);
let mut t = baked(d, n);
outline_brushed(d, brushes, &mut t);
drop_live(&mut t);
prepare_masks(d, brushes, &mut t);
@@ -1096,14 +1163,15 @@ fn scales_evenly(a: Affine) -> bool {
}
/// `n` with the strokes and geometry effects of each path in it baked into filled art
/// ([`bake_appearance`](vectorcraft_render::effects::bake_appearance)).
fn baked(n: &Node) -> Node {
if let Some(b) = vectorcraft_render::effects::bake_appearance(n) {
/// ([`bake_appearance`](vectorcraft_render::effects::bake_appearance)), with the swatches and
/// patterns that live-effect plug-ins painting their pieces name looked up in `d`.
fn baked(d: &Document, n: &Node) -> Node {
if let Some(b) = vectorcraft_render::effects::bake_appearance(Some(d), n) {
return b;
}
let mut n = n.clone();
for c in n.children_mut().into_iter().flatten() {
*c = Arc::new(baked(c));
*c = Arc::new(baked(d, c));
}
n
}
+3
View File
@@ -34,6 +34,7 @@ pub mod eps;
mod export;
pub(crate) mod imagemap;
mod importer;
pub mod jxlread;
mod legacy_ai;
mod load;
mod mesh3d;
@@ -207,6 +208,8 @@ mod tests_pdfoutput;
#[cfg(test)]
mod tests_tiffbmp;
#[cfg(test)]
mod tests_jxlread;
#[cfg(test)]
mod tests_pdfx;
#[cfg(test)]
+6 -2
View File
@@ -505,6 +505,8 @@ pub const FORMATS: &[Format] = &[
reader("jpeg2000", "JPEG 2000", &["jpf", "jpx", "jp2", "j2k", "j2c", "jpc"], "image/jp2", true),
// HEVC-coded HEIF still images, decoded to PNG; image sequences are not read.
reader("heif", "HEIC/HEIF", &["heic", "heif"], "image/heic", true),
// jxl-oxide reads a JPEG XL file's first frame, which is stored as PNG. Exports don't write it.
reader("jxl", "JPEG XL", &["jxl"], "image/jxl", true),
];
/// Every extension `document.open` reads (the "All readable files" filter of open dialogs).
@@ -544,6 +546,7 @@ pub const OPEN_EXTS: &[&str] = &[
"psd",
"pdd",
"psb",
"jxl",
"vctemplate",
"dxf",
"emf",
@@ -558,12 +561,12 @@ pub const OPEN_EXTS: &[&str] = &[
/// Extensions in [`OPEN_EXTS`] the packaging declares no type for: Photoshop documents belong to
/// raster editors, so Finder and file managers don't offer a vector app for them, and there is no
/// `.epsf`, `.ps`, Targa, PCX, Pixar, JPEG 2000 or HEIC/HEIF type, nor one for the `.pns`, `.dib` and `.rle` names of
/// `.epsf`, `.ps`, Targa, PCX, Pixar, JPEG 2000, HEIC/HEIF or JPEG XL type, nor one for the `.pns`, `.dib` and `.rle` names of
/// PNG and BMP files (File › Open and Place still read them all). On Linux the desktop entry's EPS
/// type (shared-mime-info's) also covers `.epsf`, so file managers offer VectorCraft for it.
pub const UNASSOCIATED_EXTS: &[&str] = &[
"psd", "pdd", "psb", "epsf", "ps", "pns", "dib", "rle", "tga", "vda", "icb", "vst", "pcx", "pxr", "jpf", "jpx", "jp2", "j2k", "j2c", "jpc",
"heic", "heif",
"heic", "heif", "jxl",
];
/// The extension that picks each writable format when exporting (the format's first; PNG-8 shares
@@ -632,6 +635,7 @@ pub const PLACE_EXTS: &[&str] = &[
"psd",
"pdd",
"psb",
"jxl",
"vctemplate",
"dxf",
"emf",
+273
View File
@@ -0,0 +1,273 @@
//! Reading JPEG XL images (`.jxl`, ISO/IEC 18181) for File › Open and File › Place, with the
//! jxl-oxide decoder: the bare codestream and the ISOBMFF container. The first frame of an
//! animation is read, with the orientation its header gives applied.
//!
//! Colors come back as sRGB whenever the decoder converts them without a color management
//! system: XYB-encoded images, every color space a JPEG XL header names, and embedded ICC
//! profiles that describe one of them. Any other image with an embedded RGB ICC profile comes
//! back with its samples as stored, as PNG and JPEG files with an embedded profile are drawn, and
//! with the profile. Gray images, and images whose profile is not an RGB one, are refused when the
//! decoder cannot convert their ICC profile. 16-bit and floating-point samples become 8 bits, alpha comes back straight,
//! and CMYK images are refused. The file is untrusted: the decoder is given only a container's
//! image boxes, the image's size is checked from its header before any pixels are decoded, and the
//! decoder's image buffers are capped.
use jxl_color::ColorTransform;
use jxl_oxide::color::{ColourEncoding, TransferFunction};
use jxl_oxide::{AllocTracker, ColorEncodingWithProfile, EnumColourEncoding, HdrType, InitializeResult, JxlImage, NullCms, RenderingIntent};
/// A bare JPEG XL codestream starts with these bytes.
const CODESTREAM: [u8; 2] = [0xff, 0x0a];
/// The signature box an ISOBMFF JPEG XL container starts with.
const CONTAINER: [u8; 12] = [0, 0, 0, 0x0c, b'J', b'X', b'L', b' ', 0x0d, 0x0a, 0x87, 0x0a];
/// How many bytes the decoder is given first to read the header. Each later try gives it twice as
/// many bytes, and each try decodes the embedded ICC profile from its start again.
const HEAD: usize = 4096;
/// The most the decoder's buffers may take while it reads the header. Its `AllocTracker` counts the
/// image buffers only: an embedded ICC profile is decoded into buffers of its own, which jxl-color
/// limits to 256 MiB.
const HEADER_BUFFERS: usize = 16 << 20;
/// The most the decoder's buffers may take for the pixels: this many times the image's size as
/// RGBA, plus [`HEADER_BUFFERS`], and never more than the `max_alloc` of [`decode`], the limit the
/// `image` crate's decoders get in `raster_image`. The decoder keeps each channel as 32-bit
/// samples: a lossless 16-bit RGBA image of 1024 × 768 pixels took 12 times its size as RGBA.
const DECODER_BUFFERS: u64 = 16;
const TRUNCATED: &str = "the JPEG XL file is cut short";
/// Is `bytes` a JPEG XL image (a bare codestream or a container)?
pub fn is_jxl(bytes: &[u8]) -> bool {
bytes.starts_with(&CODESTREAM) || bytes.starts_with(&CONTAINER)
}
/// Resolution after the decoder applies the image header's orientation.
pub(crate) fn oriented_resolution(ppi: Option<(f64, f64)>, orientation: u32) -> Option<(f64, f64)> {
ppi.map(|(x, y)| if (5..=8).contains(&orientation) { (y, x) } else { (x, y) })
}
/// The first frame of a JPEG XL image as straight RGBA, with the embedded ICC profile its colors
/// are in when they are not converted to sRGB ([`output_profile`]), and the header orientation.
/// Fails, without decoding the
/// pixels, when the image would take more than `max_alloc` bytes as RGBA. The image is also too
/// large to draw when the decoder's buffers for its pixels need more than they are allowed. A
/// container whose selected boxes exceed `max_alloc` or [`HEADER_BUFFERS`], whichever is greater,
/// is refused with "the JPEG XL container is too large to read".
pub fn decode(bytes: &[u8], max_alloc: u64) -> Result<(image::RgbaImage, Option<Vec<u8>>, u32), String> {
let pixel_boxes = pixel_boxes(bytes, max_alloc.max(HEADER_BUFFERS as u64))?;
let bytes = pixel_boxes.as_deref().unwrap_or(bytes);
// The decoder reads the header first, with buffers for the header alone.
let (head, _) = header(bytes, HEADER_BUFFERS, None)?;
let (w, h) = (head.width(), head.height());
let rgba = u64::from(w) * u64::from(h) * 4;
if rgba > max_alloc {
return Err(too_large(w, h));
}
if head.pixel_format().has_black() {
return Err("CMYK JPEG XL images can't be read".into());
}
// Then it reads the file again until the first frame is loaded, with buffers for the pixels.
let budget = rgba.saturating_mul(DECODER_BUFFERS).saturating_add(HEADER_BUFFERS as u64).min(max_alloc);
let budget = usize::try_from(budget).unwrap_or(usize::MAX);
let size = Some((w, h));
let (mut image, mut at) = header(bytes, budget, size)?;
let mut end = at;
while image.num_loaded_keyframes() == 0 {
if end == bytes.len() {
return Err(TRUNCATED.into());
}
end = end.saturating_mul(2).max(HEAD).min(bytes.len());
at += image.feed_bytes(&bytes[at..end]).map_err(|e| failed(e, size))?;
}
let metadata = &image.image_header().metadata;
let associated = metadata.alpha().and_then(|i| metadata.ec_info.get(i)).and_then(|ec| ec.alpha_associated()) == Some(true);
let intent = RenderingIntent::Relative;
let srgb = if metadata.grayscale() { EnumColourEncoding::gray_srgb(intent) } else { EnumColourEncoding::srgb(intent) };
let profile = output_profile(metadata.xyb_encoded, metadata.grayscale(), image.original_icc())?;
let orientation = metadata.orientation;
// Premultiplied colors are divided by alpha in the encoding they are stored in, and converted
// to sRGB after that. XYB-encoded images come from the decoder in linear sRGB (tone mapped when
// they are HDR), which the sRGB transfer curve alone turns into sRGB.
let (request, convert) = match (to_srgb_without_cms(metadata.xyb_encoded, image.original_icc()), associated) {
(false, _) => (None, None),
(true, false) => (Some(srgb), None),
(true, true) if metadata.xyb_encoded => (Some(EnumColourEncoding { tf: TransferFunction::Linear, ..srgb }), Some(ToSrgb::Curve)),
(true, true) => {
let stored = match (&metadata.colour_encoding, image.original_icc()) {
(ColourEncoding::Enum(e), _) => ColorEncodingWithProfile::new(e.clone()),
(_, Some(icc)) => ColorEncodingWithProfile::with_icc(icc).map_err(unreadable)?,
(_, None) => return Err(unreadable("no color encoding")),
};
let mut builder = ColorTransform::builder();
builder.from_pq(image.hdr_type() == Some(HdrType::Pq));
let to = ColorEncodingWithProfile::new(srgb);
let t = builder.build(&stored, &to, &metadata.opsin_inverse_matrix, &metadata.tone_mapping, &NullCms).map_err(unreadable)?;
(None, Some(ToSrgb::Transform(t)))
}
};
if let Some(encoding) = request {
image.request_color_encoding(encoding);
}
let render = image.render_frame(0).map_err(|e| failed(e, size))?;
let mut stream = render.stream();
let channels = stream.channels() as usize;
if !(1..=4).contains(&channels) {
return Err(unreadable(format!("{channels} channels")));
}
let mut out = image::RgbaImage::new(stream.width(), stream.height());
let width = out.width() as usize;
let mut row = vec![0f32; width * channels];
let mut colors = [vec![0f32; width], vec![0f32; width], vec![0f32; width]];
for line in out.chunks_exact_mut((width * 4).max(4)) {
if stream.write_to_buffer(&mut row) != row.len() {
return Err(TRUNCATED.into());
}
to_rgba(&row, channels, associated, convert.as_ref(), &mut colors, line)?;
}
Ok((out, profile, orientation))
}
/// How colors whose alpha was divided out become sRGB.
pub(crate) enum ToSrgb {
/// A conversion from the encoding the colors are stored in.
Transform(ColorTransform),
/// The sRGB transfer curve, for colors in linear sRGB.
Curve,
}
/// One row of samples, `channels` to a pixel (gray or RGB, then alpha when there are 2 or 4), as
/// straight RGBA bytes in `out`: colors divided by alpha when it is `associated`, then converted
/// with `convert`. `colors` holds a row of red, green and blue; a gray sample goes into all three,
/// as jxl-render's `clone_gray` does before its own conversions.
pub(crate) fn to_rgba(
row: &[f32],
channels: usize,
associated: bool,
convert: Option<&ToSrgb>,
colors: &mut [Vec<f32>; 3],
out: &mut [u8],
) -> Result<(), String> {
let alpha = |s: &[f32]| if matches!(channels, 2 | 4) { s.last().map_or(1.0, |a| a.clamp(0.0, 1.0)) } else { 1.0 };
let gray = channels < 3;
for (x, s) in row.chunks_exact(channels).enumerate() {
let a = alpha(s);
for (i, plane) in colors.iter_mut().enumerate() {
let v = s.get(if gray { 0 } else { i }).copied().unwrap_or(0.0);
if let Some(c) = plane.get_mut(x) {
*c = if associated && a > 0.0 { v / a } else { v };
}
}
}
// The conversion leaves gray in the first row when it gives one channel.
let gray = match convert {
Some(ToSrgb::Transform(t)) => {
let mut planes: Vec<&mut [f32]> = colors.iter_mut().map(Vec::as_mut_slice).collect();
t.run(&mut planes).map_err(unreadable)? < 3
}
Some(ToSrgb::Curve) => {
colors.iter_mut().flatten().for_each(|v| *v = if *v <= 0.003_130_8 { *v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 });
gray
}
None => gray,
};
for (x, (px, s)) in out.as_chunks_mut::<4>().0.iter_mut().zip(row.chunks_exact(channels)).enumerate() {
let c = |i: usize| colors.get(if gray { 0 } else { i }).and_then(|p| p.get(x)).copied().unwrap_or(0.0);
*px = [byte(c(0)), byte(c(1)), byte(c(2)), byte(alpha(s))];
}
Ok(())
}
/// A container's signature and the boxes the decoder reads pixels from (`ftyp`, `jxll`, `jxlc` and
/// `jxlp`). Every other box is left out: Exif, XMP, JPEG reconstruction data and Brotli-compressed
/// `brob` boxes, which the decoder would otherwise store and unpack without a size limit. A box
/// cut short ends the file. Selected boxes exceeding `max_copy` are refused with "the JPEG XL
/// container is too large to read". `None` for a bare codestream.
pub(super) fn pixel_boxes(bytes: &[u8], max_copy: u64) -> Result<Option<Vec<u8>>, String> {
let Some(mut rest) = bytes.strip_prefix(&CONTAINER) else { return Ok(None) };
let mut out = CONTAINER.to_vec();
while let (Some(size), Some(ty)) = (rest.get(..4).and_then(|s| <[u8; 4]>::try_from(s).ok()), rest.get(4..8)) {
let (head, len) = match u32::from_be_bytes(size) {
0 => (8, rest.len() as u64),
1 => match rest.get(8..16).and_then(|l| l.try_into().ok()) {
Some(l) => (16, u64::from_be_bytes(l)),
None => break,
},
n => (8, u64::from(n)),
};
if len < head {
break;
}
let len = usize::try_from(len).unwrap_or(usize::MAX).min(rest.len());
let (b, after) = rest.split_at(len);
if matches!(ty, b"ftyp" | b"jxll" | b"jxlc" | b"jxlp") {
if (out.len() as u64).saturating_add(b.len() as u64) > max_copy {
return Err("the JPEG XL container is too large to read".into());
}
out.try_reserve_exact(b.len()).map_err(|_| "the JPEG XL container is too large to read")?;
out.extend_from_slice(b);
}
rest = after;
}
Ok(Some(out))
}
/// A decoder whose buffers may take `buffers` bytes, given as few of `bytes` as it needs to read
/// the header → (the decoder, how many bytes it took). `size` is the image's size when it is
/// known, for the error when the buffers need more ([`failed`]).
fn header(bytes: &[u8], buffers: usize, size: Option<(u32, u32)>) -> Result<(JxlImage, usize), String> {
let mut uninit = JxlImage::builder().alloc_tracker(AllocTracker::with_limit(buffers)).build_uninit();
let (mut at, mut end) = (0, 0usize);
loop {
end = end.saturating_mul(2).max(HEAD).min(bytes.len());
at += uninit.feed_bytes(&bytes[at..end]).map_err(|e| failed(e, size))?;
match uninit.try_init().map_err(|e| failed(e, size))? {
InitializeResult::Initialized(image) => return Ok((image, at)),
InitializeResult::NeedMoreData(_) if end == bytes.len() => return Err(TRUNCATED.into()),
InitializeResult::NeedMoreData(more) => uninit = more,
}
}
}
/// Does the decoder convert to sRGB without a color management system? It does for XYB-encoded
/// images and the color spaces a header names; for an embedded ICC profile, only when the profile
/// describes one of those.
fn to_srgb_without_cms(xyb: bool, icc: Option<&[u8]>) -> bool {
xyb || icc.is_none_or(|icc| ColorEncodingWithProfile::with_icc(icc).is_ok_and(|p| p.icc_profile().is_empty()))
}
/// The embedded RGB profile when the decoder leaves colors in it. Gray colors without a
/// conversion to sRGB cannot be stored as RGB without changing their profile, and neither can
/// colors left in a profile that is not an RGB one.
pub(crate) fn output_profile(xyb: bool, grayscale: bool, icc: Option<&[u8]>) -> Result<Option<Vec<u8>>, String> {
let converts = to_srgb_without_cms(xyb, icc);
if grayscale && !converts {
return Err(unreadable("unsupported grayscale ICC profile"));
}
if !converts && icc.and_then(|icc| icc.get(16..20)) != Some(b"RGB ") {
return Err(unreadable("unsupported ICC profile color space"));
}
Ok(icc.filter(|_| !converts).map(<[u8]>::to_vec))
}
/// The decoder's error `e` as a message. When the decoder's buffers needed more than they were
/// allowed and the image's `size` is known, the image is too large to draw. Otherwise the file
/// can't be read.
fn failed(e: Box<dyn std::error::Error + Send + Sync>, size: Option<(u32, u32)>) -> String {
let first: &(dyn std::error::Error + 'static) = &*e;
let out_of_memory = std::iter::successors(Some(first), |c| c.source()).any(|c| c.is::<jxl_grid::OutOfMemory>());
match size {
Some((w, h)) if out_of_memory => too_large(w, h),
_ => unreadable(e),
}
}
fn too_large(w: u32, h: u32) -> String {
format!("an image of {w} × {h} pixels is too large to draw")
}
fn unreadable(e: impl std::fmt::Display) -> String {
format!("the JPEG XL file can't be read: {e}")
}
fn byte(v: f32) -> u8 {
(v.clamp(0.0, 1.0) * 255.0).round() as u8
}
+25 -11
View File
@@ -9,7 +9,7 @@ use vectorcraft_render::effects::pixel::LoadedTexture;
use super::super::{Result, Session, bad, bool_or, clipboard, parse_range, str_param};
use super::pdfimport::LoadOptions;
use super::{Format, SAVE_FORMATS, absolute_path, file_stamp, format, psdread, read_file, readable_format_for_name};
use super::{Format, SAVE_FORMATS, absolute_path, file_stamp, format, jxlread, psdread, read_file, readable_format_for_name};
use crate::cmd::links::Resolved;
use crate::{EngineError, Prefs};
@@ -102,6 +102,9 @@ pub fn detect(name: &str, bytes: &[u8]) -> Option<&'static Format> {
if super::pxrread::is_pxr(bytes) {
return format("pxr");
}
if jxlread::is_jxl(bytes) {
return format("jxl");
}
if super::jp2read::is_jpeg2000(bytes) {
return format("jpeg2000");
}
@@ -759,7 +762,8 @@ pub(crate) fn size_note(bytes: &[u8]) -> Option<String> {
/// ([`super::tgaread::fix_alpha`]). An image stored as PNG whose decoded samples, or whose pixels
/// as RGBA, need more than [`MAX_RASTER_ALLOC`] is refused as too large to draw.
/// A TIFF with more strips or tiles than the decoder can count
/// ([`vectorcraft_doc::cmyk::oversized_tiff`]) is refused as too large to draw.
/// ([`vectorcraft_doc::cmyk::oversized_tiff`]) is refused as too large to draw. JPEG XL images are
/// read with [`jxlread`] and stored as PNG, with the ICC profile their colors are left in.
pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
let ppi = super::ppi::resolution(bytes);
if let Some((w, h)) = vectorcraft_doc::cmyk::oversized_tiff(bytes) {
@@ -767,25 +771,29 @@ pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
}
if psdread::is_psd(bytes) {
let cms = vectorcraft_color::cms::active();
return as_png(psdread::decode(bytes, MAX_RASTER_ALLOC, |c| cms.cmyk_to_srgb(c, false)).map_err(err)?, ppi);
return as_png(psdread::decode(bytes, MAX_RASTER_ALLOC, |c| cms.cmyk_to_srgb(c, false)).map_err(err)?, None, ppi);
}
if jxlread::is_jxl(bytes) {
let (img, icc, orientation) = jxlread::decode(bytes, MAX_RASTER_ALLOC).map_err(err)?;
return as_png(img, icc, jxlread::oriented_resolution(ppi, orientation));
}
if super::jp2read::is_jpeg2000(bytes) {
return as_png(super::jp2read::decode(bytes, MAX_RASTER_ALLOC).map_err(err)?, ppi);
return as_png(super::jp2read::decode(bytes, MAX_RASTER_ALLOC).map_err(err)?, None, ppi);
}
if super::heifread::is_heif(bytes) {
// The EXIF resolution describes the stored pixels; a quarter turn swaps its axes.
let ppi = ppi.map(|(x, y)| if super::heifread::quarter_turned(bytes) { (y, x) } else { (x, y) });
return as_png(super::heifread::decode(bytes, MAX_RASTER_ALLOC).map_err(err)?, ppi);
return as_png(super::heifread::decode(bytes, MAX_RASTER_ALLOC).map_err(err)?, None, ppi);
}
if super::pxrread::is_pxr(bytes) {
return as_png(super::pxrread::decode(bytes, MAX_RASTER_ALLOC).map_err(err)?, ppi);
return as_png(super::pxrread::decode(bytes, MAX_RASTER_ALLOC).map_err(err)?, None, ppi);
}
let mut reader = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().map_err(err)?;
if reader.format().is_none() && super::tgaread::is_targa(bytes) {
reader.set_format(image::ImageFormat::Tga);
}
if reader.format().is_none() && super::pcxread::is_pcx(bytes) {
return as_png(super::pcxread::decode(bytes, MAX_RASTER_ALLOC).map_err(err)?, ppi);
return as_png(super::pcxread::decode(bytes, MAX_RASTER_ALLOC).map_err(err)?, None, ppi);
}
let mut limits = image::Limits::default();
limits.max_alloc = Some(MAX_RASTER_ALLOC);
@@ -831,7 +839,7 @@ pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
if f.id == "tga" {
super::tgaread::fix_alpha(bytes, &mut img);
}
as_png(img, ppi)
as_png(img, None, ppi)
}
/// The EXIF orientation of encoded image `bytes` when it turns or flips the pixels; `None` when
@@ -842,11 +850,17 @@ pub(crate) fn exif_orientation(bytes: &[u8]) -> Option<image::metadata::Orientat
decoder.orientation().ok().filter(|o| *o != image::metadata::Orientation::NoTransforms)
}
/// `img` stored as a PNG that keeps the file's resolution.
fn as_png(img: image::RgbaImage, ppi: Option<(f64, f64)>) -> Result<RasterImage> {
/// `img` stored as a PNG that keeps the file's resolution and carries `icc`, the ICC profile its
/// colors are in.
pub(super) fn as_png(img: image::RgbaImage, icc: Option<Vec<u8>>, ppi: Option<(f64, f64)>) -> Result<RasterImage> {
use image::ImageEncoder as _;
let size = img.dimensions();
let mut png = Vec::new();
img.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).map_err(err)?;
let mut encoder = image::codecs::png::PngEncoder::new(&mut png);
if let Some(icc) = icc {
encoder.set_icc_profile(icc).map_err(err)?;
}
img.write_with_encoder(encoder).map_err(err)?;
let png = match ppi {
Some(r) => super::ppi::with_png_resolution(&png, r),
None => png,
+29 -1
View File
@@ -1,5 +1,5 @@
//! Image resolution metadata (pixels per inch): PNG `pHYs`, JPEG JFIF density or EXIF, TIFF and
//! WebP EXIF resolution tags, BMP pixels per metre, a Photoshop document's ResolutionInfo, a PCX
//! WebP and JPEG XL EXIF resolution tags, BMP pixels per metre, a Photoshop document's ResolutionInfo, a PCX
//! header's HDpi and VDpi, JP2 capture and display resolution boxes, or a HEIF image's EXIF
//! resolution tags. Place
//! sizes an image by it (72 ppi when the file has none); images converted to PNG on import keep it
@@ -17,6 +17,8 @@ pub fn resolution(bytes: &[u8]) -> Option<(f64, f64)> {
jpeg(bytes)
} else if bytes.starts_with(b"II*\0") || bytes.starts_with(b"MM\0*") {
tiff(bytes)
} else if super::jxlread::is_jxl(bytes) {
jxl(bytes)
} else if bytes.len() >= 12 && &bytes[..4] == b"RIFF" && &bytes[8..12] == b"WEBP" {
webp(bytes)
} else if bytes.starts_with(b"BM") {
@@ -196,6 +198,32 @@ fn webp(b: &[u8]) -> Option<(f64, f64)> {
None
}
fn jxl(b: &[u8]) -> Option<(f64, f64)> {
if !b.starts_with(&[0, 0, 0, 12, b'J', b'X', b'L', b' ', 13, 10, 135, 10]) {
return None;
}
let mut at = 12;
while at < b.len() {
let size = be32(b, at)?;
let ty = slice(b, at.checked_add(4)?, 4)?;
let (header, len) = match size {
0 => (8, b.len().checked_sub(at)?),
1 => (16, usize::try_from(u64::from_be_bytes(bytes(b, at.checked_add(8)?)?)).ok()?),
_ => (8, size as usize),
};
if len < header {
return None;
}
let payload = slice(b, at.checked_add(header)?, len - header)?;
if ty == b"Exif" {
let offset = usize::try_from(be32(payload, 0)?).ok()?;
return tiff(payload.get(4usize.checked_add(offset)?..)?);
}
at = at.checked_add(len)?;
}
None
}
fn bmp(b: &[u8]) -> Option<(f64, f64)> {
// BITMAPINFOHEADER (40 bytes or longer) at 14: pixels per metre at +24 and +28.
if le32(b, 14)? < 40 {
@@ -0,0 +1,333 @@
//! Reading JPEG XL images: the bare codestream and the container, alpha, 16-bit samples, the size
//! check before decoding, and damaged files refused without a panic.
use serde_json::json;
use zune_core::bit_depth::BitDepth;
use zune_core::colorspace::ColorSpace;
use zune_core::options::EncoderOptions;
use super::jxlread::{ToSrgb, decode, is_jxl, oriented_resolution, output_profile, pixel_boxes, to_rgba};
use super::*;
/// A lossless JPEG XL codestream of `w` × `h` pixels: `samples` in `space`, 8-bit, or 16-bit in
/// native byte order.
fn jxl(w: usize, h: usize, space: ColorSpace, depth: BitDepth, samples: &[u8]) -> Vec<u8> {
let mut out = vec![];
zune_jpegxl::JxlSimpleEncoder::new(samples, EncoderOptions::new(w, h, space, depth)).encode(&mut out).unwrap();
out
}
/// `codestream` in the ISOBMFF container: the signature box, `ftyp` and a `jxlc` box holding it.
fn container(codestream: &[u8]) -> Vec<u8> {
let mut f = vec![0, 0, 0, 0x0c, b'J', b'X', b'L', b' ', 0x0d, 0x0a, 0x87, 0x0a];
f.extend(20u32.to_be_bytes());
f.extend(b"ftypjxl \0\0\0\0jxl ");
f.extend((8 + codestream.len() as u32).to_be_bytes());
f.extend(b"jxlc");
f.extend(codestream);
f
}
/// A 3 × 2 RGBA image: opaque red, green and blue, then half-transparent, clear and opaque gray.
const RGBA: [[u8; 4]; 6] = [[255, 0, 0, 255], [0, 255, 0, 255], [0, 0, 255, 255], [200, 100, 50, 128], [9, 9, 9, 0], [77, 77, 77, 255]];
fn rgba8() -> Vec<u8> {
jxl(3, 2, ColorSpace::RGBA, BitDepth::Eight, RGBA.as_flattened())
}
fn b64(bytes: &[u8]) -> String {
vectorcraft_format::base64_encode(bytes)
}
/// The pixels of the one image the active document holds.
fn stored_pixels(s: &Session) -> Vec<[u8; 4]> {
let doc = &s.doc().unwrap().doc;
assert_eq!(doc.images.len(), 1);
let blob = doc.images.values().next().unwrap();
assert_eq!(blob.mime, "image/png", "stored as PNG");
image::load_from_memory(&blob.bytes).unwrap().to_rgba8().pixels().map(|p| p.0).collect()
}
#[test]
fn jxl_images_open_and_place_with_their_pixels_and_alpha() {
let bare = rgba8();
let boxed = container(&bare);
assert!(is_jxl(&bare) && is_jxl(&boxed) && !is_jxl(b"\xff\xd8\xff"));
for (name, bytes) in [("bare.jxl", &bare), ("boxed.jxl", &boxed), ("named-wrong.png", &boxed)] {
let mut s = Session::new();
let r = s.execute("document.open", &json!({"name": name, "dataBase64": b64(bytes)})).unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!(r["format"], "jxl", "{name}: told by its content");
let ab = s.doc().unwrap().doc.artboards[0].rect;
assert_eq!((ab.width(), ab.height()), (3.0, 2.0), "{name}: 72 ppi, one point a pixel");
assert_eq!(stored_pixels(&s), RGBA, "{name}");
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
let r = s.execute("file.place", &json!({"name": name, "dataBase64": b64(bytes)})).unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!((r["width"].as_f64(), r["height"].as_f64()), (Some(3.0), Some(2.0)), "{name}: {r}");
assert_eq!(stored_pixels(&s), RGBA, "{name}: placed");
}
assert!(OPEN_EXTS.contains(&"jxl") && PLACE_EXTS.contains(&"jxl"));
assert!(open_filters().any(|f| f == ("JPEG XL", &["jxl"][..])) && place_filters().any(|f| f == ("JPEG XL", &["jxl"][..])));
}
#[test]
fn container_copy_stays_within_the_input_allowance() {
let f = container(&[0; 128]);
assert!(pixel_boxes(&f, 64).unwrap_err().contains("too large"));
assert_eq!(pixel_boxes(&f, f.len() as u64).unwrap().unwrap(), f);
}
#[test]
fn container_exif_resolution_sizes_open_and_place() {
let bare = rgba8();
let mut f = container(&[]);
f.truncate(32);
let mut exif = Vec::new();
exif.extend(4u32.to_be_bytes());
exif.extend(b"Exif");
exif.extend(b"II*\0");
exif.extend(8u32.to_le_bytes());
exif.extend(3u16.to_le_bytes());
for (tag, value) in [(282u16, 50u32), (283, 58), (296, 2)] {
exif.extend(tag.to_le_bytes());
exif.extend((if tag == 296 { 3u16 } else { 5 }).to_le_bytes());
exif.extend(1u32.to_le_bytes());
exif.extend(value.to_le_bytes());
}
exif.extend(0u32.to_le_bytes());
for value in [144u32, 1, 72, 1] {
exif.extend(value.to_le_bytes());
}
f.extend((8 + exif.len() as u32).to_be_bytes());
f.extend(b"Exif");
f.extend(&exif);
f.extend((8 + bare.len() as u32).to_be_bytes());
f.extend(b"jxlc");
f.extend(&bare);
assert_eq!(super::ppi::resolution(&f), Some((144.0, 72.0)));
assert_eq!(super::ppi::resolution(&bare), None);
let mut s = Session::new();
s.execute("document.open", &json!({"name": "resolved.jxl", "dataBase64": b64(&f)})).unwrap();
let rect = s.doc().unwrap().doc.artboards[0].rect;
assert_eq!((rect.width(), rect.height()), (1.5, 2.0));
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
let r = s.execute("file.place", &json!({"name": "resolved.jxl", "dataBase64": b64(&f)})).unwrap();
assert_eq!((r["width"].as_f64(), r["height"].as_f64()), (Some(1.5), Some(2.0)));
assert_eq!(super::ppi::resolution(&s.doc().unwrap().doc.images.values().next().unwrap().bytes), Some((144.0, 72.0)));
}
#[test]
fn jxl_axis_exchanging_orientations_swap_resolution() {
let ppi = Some((144.0, 72.0));
for orientation in 1..=8 {
let expected = if orientation >= 5 { Some((72.0, 144.0)) } else { ppi };
assert_eq!(oriented_resolution(ppi, orientation), expected, "orientation {orientation}");
}
assert_eq!(oriented_resolution(None, 6), None);
}
/// A `.jxl` file copied in a file manager pastes as its image.
#[test]
fn a_copied_jxl_file_pastes_as_its_image() {
use crate::cmd::clipboard::{FILE_HEAD, PASTE_ORDER, file_flavour};
let f = container(&rgba8());
let mime = file_flavour("copied.jxl", &f[..f.len().min(FILE_HEAD)], &PASTE_ORDER);
assert_eq!(mime, Some("image/jxl"));
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
let r = s.execute("clipboard.importImage", &json!({"dataBase64": b64(&f), "mime": mime})).unwrap();
assert_eq!((r["pixelWidth"].as_u64(), r["pixelHeight"].as_u64()), (Some(3), Some(2)), "{r}");
s.execute("edit.paste", &json!({})).unwrap();
assert_eq!(stored_pixels(&s), RGBA);
}
#[test]
fn sixteen_bit_samples_become_eight_bits() {
let rgb: [[u16; 3]; 4] = [[0x0000, 0x8080, 0xffff], [0x1234, 0x80ff, 0x7f00], [0xff00, 0x00ff, 0x4000], [0xc000, 0x0101, 0xfefe]];
let samples: Vec<u8> = rgb.as_flattened().iter().flat_map(|v| v.to_ne_bytes()).collect();
let f = jxl(2, 2, ColorSpace::RGB, BitDepth::Sixteen, &samples);
let to8 = |v: u16| (f64::from(v) / 65535.0 * 255.0).round() as u8;
let want: Vec<[u8; 4]> = rgb.iter().map(|p| [to8(p[0]), to8(p[1]), to8(p[2]), 255]).collect();
assert_eq!(decode(&f, 1 << 30).unwrap().0.pixels().map(|p| p.0).collect::<Vec<_>>(), want);
// Gray, 16 bits, in the container, opened as a document.
let gray: Vec<u8> = [0xffffu16, 0x8080, 0x0000, 0x4040].iter().flat_map(|v| v.to_ne_bytes()).collect();
let f = container(&jxl(2, 2, ColorSpace::Luma, BitDepth::Sixteen, &gray));
let mut s = Session::new();
s.execute("document.open", &json!({"name": "gray.jxl", "dataBase64": b64(&f)})).unwrap();
assert_eq!(stored_pixels(&s), [[255, 255, 255, 255], [128, 128, 128, 255], [0, 0, 0, 255], [64, 64, 64, 255]]);
}
/// A bare codestream that is only a header: `w` × `h` pixels and default metadata.
fn header_only(w: u32, h: u32) -> Vec<u8> {
// Each field's bits, read from the lowest bit of each byte up: `div8`, the height as a U32
// (selector 3: 1 + 30 bits), `ratio` 0, the width likewise, then `all_default` for the
// metadata and `default_m` for the transform data.
let fields = [(0, 1), (3, 2), (h - 1, 30), (0, 3), (3, 2), (w - 1, 30), (1, 1), (1, 1)];
let (mut out, mut acc, mut n) = (vec![0xff, 0x0a], 0u64, 0);
for (v, bits) in fields {
acc |= u64::from(v) << n;
n += bits;
while n >= 8 {
out.push(acc as u8);
(acc, n) = (acc >> 8, n - 8);
}
}
out.push(acc as u8);
out
}
#[test]
fn an_image_too_large_is_refused_from_its_header() {
// The header of a 100000 × 100000 image (40 GB as RGBA), with no pixel data after it.
let huge = header_only(100_000, 100_000);
assert_eq!(decode(&huge, MAX_RASTER_ALLOC).unwrap_err(), "an image of 100000 × 100000 pixels is too large to draw");
let mut s = Session::new();
let e = s.execute("document.open", &json!({"name": "huge.jxl", "dataBase64": b64(&huge)})).unwrap_err().to_string();
assert!(e.contains("too large to draw"), "{e}");
// The allowance counts RGBA bytes: 3 × 2 pixels take 24.
assert!(decode(&rgba8(), 23).unwrap_err().contains("too large"));
assert!(decode(&rgba8(), 1 << 20).is_ok());
}
/// The decoder's buffers take more than the image as RGBA. When they need more than the allowance,
/// the image is too large to draw, as when its size alone is.
#[test]
fn an_image_whose_decoder_buffers_pass_the_allowance_is_too_large() {
assert_eq!(decode(&rgba8(), 24).unwrap_err(), "an image of 3 × 2 pixels is too large to draw");
let gray: Vec<u8> = (0..64 * 64).map(|i| (i % 251) as u8).collect();
let f = container(&jxl(64, 64, ColorSpace::Luma, BitDepth::Eight, &gray));
assert_eq!(decode(&f, 64 * 64 * 4).unwrap_err(), "an image of 64 × 64 pixels is too large to draw");
assert!(decode(&f, 1 << 20).is_ok());
// A file cut short is still reported as cut short or unreadable.
let e = decode(&f[..f.len() - 3], 1 << 20).unwrap_err();
assert!(!e.contains("too large"), "{e}");
}
/// Premultiplied colors are divided by their alpha before they are converted to sRGB: a linear
/// 0.25 at alpha 0.5 is a straight linear 0.5, which sRGB stores as 0.735 (188 of 255).
#[test]
fn associated_alpha_is_divided_out_before_the_color_conversion() {
use jxl_oxide::{ColorEncodingWithProfile, EnumColourEncoding, NullCms, RenderingIntent};
let srgb = EnumColourEncoding::srgb(RenderingIntent::Relative);
let linear = EnumColourEncoding { tf: jxl_oxide::color::TransferFunction::Linear, ..srgb.clone() };
let header = jxl_oxide::JxlImage::builder().read(&rgba8()[..]).unwrap();
let m = &header.image_header().metadata;
let to_srgb = ToSrgb::Transform(
jxl_color::ColorTransform::new(
&ColorEncodingWithProfile::new(linear),
&ColorEncodingWithProfile::new(srgb),
&m.opsin_inverse_matrix,
&m.tone_mapping,
&NullCms,
)
.unwrap(),
);
let mut colors = [vec![0.0; 2], vec![0.0; 2], vec![0.0; 2]];
let mut out = [0u8; 8];
for convert in [&to_srgb, &ToSrgb::Curve] {
to_rgba(&[0.25, 0.25, 0.25, 0.5, 0.0, 0.0, 0.0, 0.0], 4, true, Some(convert), &mut colors, &mut out).unwrap();
assert_eq!(out, [188, 188, 188, 128, 0, 0, 0, 0]);
// Straight alpha is left as it is.
to_rgba(&[0.25, 0.25, 0.25, 0.5, 1.0, 1.0, 1.0, 1.0], 4, false, Some(convert), &mut colors, &mut out).unwrap();
assert_eq!(out, [137, 137, 137, 128, 255, 255, 255, 255]);
}
// Gray with another white point converts through three channels and comes back as gray.
let gray_e = EnumColourEncoding {
white_point: jxl_oxide::color::WhitePoint::E,
tf: jxl_oxide::color::TransferFunction::Linear,
..EnumColourEncoding::gray_srgb(RenderingIntent::Relative)
};
let gray_srgb = ColorEncodingWithProfile::new(EnumColourEncoding::gray_srgb(RenderingIntent::Relative));
let to_gray =
jxl_color::ColorTransform::new(&ColorEncodingWithProfile::new(gray_e), &gray_srgb, &m.opsin_inverse_matrix, &m.tone_mapping, &NullCms)
.unwrap();
to_rgba(&[0.25, 0.5, 1.0, 1.0], 2, true, Some(&ToSrgb::Transform(to_gray)), &mut colors, &mut out).unwrap();
assert_eq!(out, [188, 188, 188, 128, 255, 255, 255, 255]);
}
/// A container's Exif and Brotli-compressed boxes are left out: an unreadable `brob` box before the
/// codestream doesn't stop the image from opening.
#[test]
fn metadata_boxes_are_not_read() {
let mut f = container(&[]);
f.truncate(32); // the signature and `ftyp`
let mut boxed = |ty: &[u8], body: &[u8]| {
f.extend((8 + body.len() as u32).to_be_bytes());
f.extend(ty);
f.extend(body);
};
boxed(b"Exif", &[0, 0, 0, 0, b'M', b'M', 0, 42]);
boxed(b"brob", &[b'x', b'm', b'l', b' ', 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]);
boxed(b"jxlc", &rgba8());
assert_eq!(decode(&f, 1 << 30).unwrap().0.pixels().map(|p| p.0).collect::<Vec<_>>(), RGBA);
}
/// Colors left in their embedded RGB profile keep it: the PNG they are stored in carries the
/// profile. Colors converted to sRGB, and a gray image's colors, which come back as RGB, are stored
/// without one.
#[test]
fn colors_left_in_their_icc_profile_keep_it() {
use image::ImageDecoder as _;
use jxl_oxide::{EnumColourEncoding, RenderingIntent};
// A profile jxl-color can't turn into a JPEG XL color space, so the decoder leaves the colors
// in it: the sRGB profile with the tag of its red curve renamed.
let unnamed = |encoding: &EnumColourEncoding, curve: &[u8; 4]| {
let mut icc = jxl_color::icc::colour_encoding_to_icc(encoding);
let count = u32::from_be_bytes(icc[128..132].try_into().unwrap()) as usize;
for at in (0..count).map(|i| 132 + 12 * i) {
if icc[at..at + 4] == curve[..] {
icc[at..at + 4].copy_from_slice(b"xTRC");
}
}
icc
};
let srgb = EnumColourEncoding::srgb(RenderingIntent::Relative);
let rgb = unnamed(&srgb, b"rTRC");
assert_ne!(rgb, jxl_color::icc::colour_encoding_to_icc(&srgb), "a tag renamed");
assert_eq!(output_profile(false, false, Some(&rgb)).unwrap(), Some(rgb.clone()));
assert_eq!(output_profile(true, false, Some(&rgb)).unwrap(), None, "XYB-encoded colors become sRGB");
assert_eq!(output_profile(false, false, Some(&jxl_color::icc::colour_encoding_to_icc(&srgb))).unwrap(), None, "converted to sRGB");
let gray = unnamed(&EnumColourEncoding::gray_srgb(RenderingIntent::Relative), b"kTRC");
assert!(!jxl_oxide::ColorEncodingWithProfile::with_icc(&gray).unwrap().icc_profile().is_empty());
assert_eq!(output_profile(false, true, Some(&gray)).unwrap_err(), "the JPEG XL file can't be read: unsupported grayscale ICC profile");
assert_eq!(
output_profile(false, true, Some(&jxl_color::icc::colour_encoding_to_icc(&EnumColourEncoding::gray_srgb(RenderingIntent::Relative))))
.unwrap(),
None
);
assert_eq!(output_profile(false, false, None).unwrap(), None);
// A color image left in a profile that is not an RGB one can't be stored as RGB.
let mut lab = rgb.clone();
lab[16..20].copy_from_slice(b"Lab ");
assert_eq!(output_profile(false, false, Some(&lab)).unwrap_err(), "the JPEG XL file can't be read: unsupported ICC profile color space");
assert_eq!(output_profile(true, false, Some(&lab)).unwrap(), None, "XYB-encoded colors become sRGB");
// The PNG carries the profile, the pixels and the resolution.
let img = image::RgbaImage::from_raw(2, 1, vec![200, 100, 50, 255, 9, 9, 9, 0]).unwrap();
for icc in [Some(rgb), None] {
let RasterImage { blob, width, height, .. } = super::load::as_png(img.clone(), icc.clone(), Some((144.0, 144.0))).unwrap();
assert_eq!((width, height), (2, 1));
let mut png = image::codecs::png::PngDecoder::new(std::io::Cursor::new(&blob.bytes[..])).unwrap();
assert_eq!(png.icc_profile().unwrap(), icc);
assert_eq!(image::DynamicImage::from_decoder(png).unwrap().to_rgba8(), img);
assert_eq!(super::ppi::resolution(&blob.bytes).map(|(x, y)| (x.round(), y.round())), Some((144.0, 144.0)));
}
}
#[test]
fn damaged_files_are_refused_without_a_panic() {
let good = rgba8();
for n in 0..good.len() {
assert!(decode(&good[..n], 1 << 30).is_err(), "cut at {n}");
}
// Every byte changed in turn: an error or an image, never a panic.
for i in 0..good.len() {
for v in [0, 1, 0x7f, 0x80, 0xff] {
let mut f = good.clone();
f[i] = v;
let _ = decode(&f, 1 << 30);
}
}
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let cut = &good[..good.len() - 3];
assert!(s.execute("file.place", &json!({"name": "cut.jxl", "dataBase64": b64(cut)})).is_err());
}
+3 -3
View File
@@ -308,7 +308,7 @@ pub(crate) fn pf_leaves(d: &Document, n: &Node, out: &mut Vec<Node>) {
match &n.kind {
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => {
let hook = |c: &Node| vectorcraft_render::effects::outline_object(Some(d), c);
match vectorcraft_render::effects::compound_shape_art(n, Some(&hook)) {
match vectorcraft_render::effects::compound_shape_art(Some(d), n, Some(&hook)) {
Some(art) => out.push(art),
None => children.iter().for_each(|c| pf_leaves(d, c, out)),
}
@@ -912,7 +912,7 @@ pub(crate) fn outline_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Br
outline_member_strokes(d, brushes, &mut art);
return Some(d.reid(&art));
}
let reshaped = reshaped(l);
let reshaped = reshaped(d, l);
let l = reshaped.as_ref().unwrap_or(l);
let (path, rule) = node_path(l)?;
let ctx = GeomContext::of(l);
@@ -923,7 +923,7 @@ pub(crate) fn outline_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Br
for item in &l.appearance.items {
match item {
AppearanceItem::Fill(f) if f.visible && !f.paint.is_none() => {
let shaped = f.effects.iter().any(seeded).then(|| path.bounds().and_then(|b| fill_shape(l, item, &path, b))).flatten();
let shaped = f.effects.iter().any(seeded).then(|| path.bounds().and_then(|b| fill_shape(d, l, item, &path, b))).flatten();
let Some((shape, _, _, moved)) = shaped else {
fills.push(item.clone());
continue;
+5 -2
View File
@@ -75,6 +75,9 @@ pub fn describe(p: &Plugin) -> Value {
if m.budget == Budget::Heavy {
v["budget"] = json!("heavy");
}
if m.paint_output {
v["paintOutput"] = json!(true);
}
if !m.commands.is_empty() {
let commands: Vec<Value> = m
.commands
@@ -890,7 +893,7 @@ pub fn specs() -> Vec<CommandSpec> {
"List Plug-ins",
[],
None,
"{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\"|\"importer\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.<id>), format? and extensions? (an exporter's or importer's format id, plugin.<id>, for document.export or document.open's importer, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies; progress: it may report its progress, see Cancel and write log lines; settings: it may keep settings of its own; cache: it may keep a cache of its own), hash (the module's SHA-256), api (the plug-in API level it was built for, 1 or 2), cacheBytes (the bytes its cache holds; see plugin.clearCache), commands?: [{id, command (its command id, plugin.<id>.<command id>, which runs it), label, params (doc), paramsSchema}] (the menu commands a filter or live effect adds)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null, safeMode: bool (no plug-in loads or runs, see plugin.safeMode), disabled: [{id, file, name, label}] (turned off for the next launch, see plugin.setEnabled)}",
"{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\"|\"importer\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.<id>), format? and extensions? (an exporter's or importer's format id, plugin.<id>, for document.export or document.open's importer, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies; progress: it may report its progress, see Cancel and write log lines; settings: it may keep settings of its own; cache: it may keep a cache of its own), hash (the module's SHA-256), api (the plug-in API level it was built for, 1 or 2), paintOutput?: true (a live effect whose pieces have paints of their own, see docs/plugins.md), cacheBytes (the bytes its cache holds; see plugin.clearCache), commands?: [{id, command (its command id, plugin.<id>.<command id>, which runs it), label, params (doc), paramsSchema}] (the menu commands a filter or live effect adds)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null, safeMode: bool (no plug-in loads or runs, see plugin.safeMode), disabled: [{id, file, name, label}] (turned off for the next launch, see plugin.setEnabled)}",
always,
list
),
@@ -899,7 +902,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Plug-in Info",
[],
None,
"{id} → plugin.list's entry plus {manifest, defaults, lastError (a live effect's last failure, null)}",
"{id} → plugin.list's entry plus {manifest, defaults, lastError (a live effect's last failure, null; for a live effect with paintOutput, its name and the refused field, such as objects[1].strokes[0].width)}",
always,
info
),
+55 -4
View File
@@ -12,7 +12,7 @@ use vectorcraft_color::{Color, Paint, Swatch};
use vectorcraft_doc::swatches::{node_colors, node_paints, override_paints};
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
use super::colorcmds::{Recolor, Scope};
use super::colorcmds::{NotChanged, Recolor, Scope, left_out};
use super::{
CommandSpec, Result, Session, always, bad, check_switches, checked_ids_param, cmd, color_value, has_doc, index, str_param, switch_or, text_param,
};
@@ -33,7 +33,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Recolor Artwork",
["Edit", "Edit Colors"],
None,
"{map: rows [{from: [colour keys (recolor.colors) or \"#rrggbb\" (every colour shown as that hex)], to: colour (hex, key, {c,m,y,k} or {l,a,b}) | null (in from, to and groupColors, #rrggbbaa and #rgba are refused), exclude?: false (the row's colours are kept), tint?: 100 (percent; with limitTo, art linked to the global or spot swatch the new color is takes this share of its tint)}] | {\"key or #rrggbb\": colour, …} (one row per colour), method?: \"exact\" (default)|\"preserveTints\"|\"scaleTints\"|\"tintsShades\"|\"hueShift\" (how a row's colours take its new colour, relative to the row's darkest, average or most saturated colour), limitTo?: swatch library id or name; or \"document\" (the document's swatches): new colours snap to its nearest colour, group?: colour group to rewrite, groupColors?: [colour] (what `group` is rewritten with; default: the rows' new colours, in order, excluded rows left out), rename?: the group's new name, includeImages?: true, includePatterns?: true, globalAdjust?: {saturation?: 0, brightness?: 0, temperature?: 0, luminosity?: 0} (Global Adjust: percent from -100 to 100, 0 changes nothing; every colour a row that isn't excluded recolours, and the group's new colours, take the same change after the row's new colour: saturation scales HSB saturation by 1 + saturation/100, brightness moves HSB brightness that share of the way to full or to black, temperature adds 0.2 × temperature/100 to red and takes it from blue, luminosity moves Lab lightness that share of the way to 100 or 0 keeping a and b; with limitTo each row's new colour takes the change before it snaps to the library)} recolour the selection (gradients, text, meshes, image pixels and pattern tiles included; each new colour keeps the model of the one it replaces (model?: \"keep\" default | \"new\" (the new colour's own model, so a CMYK colour replacing RGB art gives CMYK art) | \"document\" (the document's colour mode)), except a library swatch's with limitTo: art takes the swatch itself, linked to a global or spot colour at its tints, and the swatch joins the document's swatches; art linked to a global swatch of `group` that is rewritten with its row's new color stays linked to it, at its tint, unless the row converts the color into the art's model; other recolored art, and art in a row linked to a global swatch of `group` that is rewritten with another color, keeps its row's new color and loses its swatch link) and the group, as one undo step → {changed, swatches: [names added to the document's swatches], relinked?: with group, the other paints linked to its global swatches that took their new colors, note?: set when a colour kept a model other than its new colour's (see model)}",
"{map: rows [{from: [colour keys (recolor.colors) or \"#rrggbb\" (every colour shown as that hex)], to: colour (hex, key, {c,m,y,k} or {l,a,b}) | null (in from, to and groupColors, #rrggbbaa and #rgba are refused), exclude?: false (the row's colours are kept), tint?: 100 (percent; with limitTo, art linked to the global or spot swatch the new color is takes this share of its tint)}] | {\"key or #rrggbb\": colour, …} (one row per colour), method?: \"exact\" (default)|\"preserveTints\"|\"scaleTints\"|\"tintsShades\"|\"hueShift\" (how a row's colours take its new colour, relative to the row's darkest, average or most saturated colour), limitTo?: swatch library id or name; or \"document\" (the document's swatches): new colours snap to its nearest colour, group?: colour group to rewrite, groupColors?: [colour] (what `group` is rewritten with; default: the rows' new colours, in order, excluded rows left out), rename?: the group's new name, includeImages?: true, includePatterns?: true, globalAdjust?: {saturation?: 0, brightness?: 0, temperature?: 0, luminosity?: 0} (Global Adjust: percent from -100 to 100, 0 changes nothing; every colour a row that isn't excluded recolours, and the group's new colours, take the same change after the row's new colour: saturation scales HSB saturation by 1 + saturation/100, brightness moves HSB brightness that share of the way to full or to black, temperature adds 0.2 × temperature/100 to red and takes it from blue, luminosity moves Lab lightness that share of the way to 100 or 0 keeping a and b; with limitTo each row's new colour takes the change before it snaps to the library)} recolour the selection (gradients, text, meshes, image pixels and pattern tiles included; each new colour keeps the model of the one it replaces (model?: \"keep\" default | \"new\" (the new colour's own model, so a CMYK colour replacing RGB art gives CMYK art) | \"document\" (the document's colour mode)), except a library swatch's with limitTo: art takes the swatch itself, linked to a global or spot colour at its tints, and the swatch joins the document's swatches; art linked to a global swatch of `group` that is rewritten with its row's new color stays linked to it, at its tint, unless the row converts the color into the art's model; other recolored art, and art in a row linked to a global swatch of `group` that is rewritten with another color, keeps its row's new color and loses its swatch link) and the group, as one undo step → {changed, swatches: [names added to the document's swatches], relinked?: with group, the other paints linked to its global swatches that took their new colors, note?: set when a colour kept a model other than its new colour's (see model), notChanged: {locked, hidden} (as edit.colors.invert), warning?: set when notChanged counts any object}",
has_doc,
apply
),
@@ -118,6 +118,38 @@ fn used_colors(d: &Document, ids: &[NodeId], only: Option<&BTreeSet<NodeId>>) ->
used.into_iter().map(|(_, u)| u).collect()
}
/// The colors `n` shows, in paint order ([`node_colors`]), then the colors of the tiles of the
/// patterns it uses as fills or strokes, and of the patterns those tiles use (each pattern once).
fn shown_colors<'a>(d: &'a Document, n: &'a Node) -> Vec<Color> {
let mut colors = vec![];
node_colors(n, &mut |c, _| colors.push(*c));
let mut patterns: Vec<&str> = vec![];
pattern_names(n, &mut patterns);
let mut next = 0;
while let Some(&name) = patterns.get(next) {
next += 1;
for a in d.pattern(name).map(|def| def.art.as_slice()).unwrap_or_default() {
node_colors(a, &mut |c, _| colors.push(*c));
pattern_names(a, &mut patterns);
}
}
colors
}
/// Adds the names of the patterns `n` and the objects inside it use as fills or strokes to `names`,
/// leaving out names already there.
fn pattern_names<'a>(n: &'a Node, names: &mut Vec<&'a str>) {
n.walk(&mut |m| {
for p in node_paints(m) {
if let Paint::Pattern { pattern, .. } = p
&& !names.contains(&pattern.as_str())
{
names.push(pattern);
}
}
});
}
/// The objects whose colors `recolor.colors` and `recolor.reduce` read, for [`used_colors`]: `ids`
/// (each checked) or else the selection, with everything they hold; with nothing selected, the
/// objects Select All takes and the objects inside them, leaving out every hidden or locked object
@@ -526,6 +558,10 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
// `selected_roots` refuses it.
super::edit::refuse_symbol_part(s)?;
let ids = s.doc()?.selection.objects.clone();
let left = {
let st = s.doc()?;
left_out(&st.doc, st.isolation, &super::edit::roots_of(&st.doc, ids.clone()))
};
let group = text_param(p, "group", C)?;
if ids.is_empty() && group.is_none() {
return Err(bad(C, "select artwork to recolour, or give a colour `group`"));
@@ -563,7 +599,7 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
};
let rename = text_param(p, "rename", C)?;
let colorized_ok = super::paint::check_colorized(&s.doc()?.doc).is_ok();
let (changed, edit, added, converted) = s.edit(label, |d, _| {
let (changed, edit, added, converted, not_changed) = s.edit(label, |d, _| {
// The rows' library swatches join the document (one it has with that name and colour is
// used as it is).
let mut added = vec![];
@@ -601,12 +637,27 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
let map = ColorMap::new(&rows, method, model, &links, kept, mapped_adjust);
let f = |c: Color| map.map(c);
let lf = |c: &mut Color, link: &mut Option<String>, tint: &mut f32| map.map_link(c, link, tint);
// The objects left out whose colors the pass would change, with the colors they show now.
// The trial pass does not count towards the note on color models.
let before: Vec<(NodeId, Vec<Color>, bool)> = Recolor::with_links(scope, &f, &lf)
.would_change(d, &left)
.into_iter()
.filter_map(|(id, hidden)| Some((id, shown_colors(d, d.node(id)?), hidden)))
.collect();
map.converted.set(false);
let changed = Recolor::with_links(scope, &f, &lf).run(d, &ids);
let edit = group.map(|g| super::swatch::rewrite_group(d, g, &new, rename, C)).transpose()?;
super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(C, e))?;
Ok((changed, edit, added, map.converted.get()))
// Rewriting the group's swatches can change the colors of art linked to them, left out or
// not, and of the patterns it uses. Art that only loses its link keeps its colors and is
// counted.
let not_changed = NotChanged::of(
before.iter().filter(|(id, colors, _)| d.node(*id).is_some_and(|now| shown_colors(d, now) == *colors)).map(|(_, _, hidden)| *hidden),
);
Ok((changed, edit, added, map.converted.get(), not_changed))
})?;
let mut out = json!({ "changed": changed, "swatches": added });
not_changed.report(&mut out);
if let Some(e) = edit {
e.defaults(s);
out["relinked"] = json!(e.relinked());
+31 -24
View File
@@ -138,9 +138,10 @@ fn names_param(p: &Value, cmd: &str, fallback: Option<String>, nothing: &str) ->
}
}
/// Refuse what a variable of `kind` can't be bound to: art that is locked, on a hidden or locked
/// layer, or on a locked artboard; a text variable only drives type; an image variable only drives
/// an image object; a variable of unknown kind drives nothing.
/// Refuse what a variable of `kind` can't be bound to: art on a hidden layer or in a hidden group,
/// art on a locked artboard, and, unless `kind` is visibility, art that is locked or on a locked
/// layer or in a locked group; a text variable only drives type; an image variable only drives an
/// image object; a variable of unknown kind drives nothing.
fn check_bindable(doc: &vectorcraft_doc::Document, ids: &[NodeId], kind: VariableKind, cmd: &str) -> Result<()> {
if kind == VariableKind::Unknown {
return Err(bad(cmd, "a variable of unknown kind can't be bound to objects"));
@@ -149,7 +150,7 @@ fn check_bindable(doc: &vectorcraft_doc::Document, ids: &[NodeId], kind: Variabl
for id in ids {
match doc.node(*id) {
None => return Err(EngineError::NoNode(*id)),
Some(_) if !doc.variable_target_with(&locks, *id) => return Err(bad(cmd, not_a_target(doc, *id))),
Some(_) if !doc.variable_target_with(&locks, *id, kind) => return Err(bad(cmd, not_a_target(doc, *id, kind))),
Some(n) if kind == VariableKind::Text && !matches!(n.kind, NodeKind::Text(_)) => {
return Err(bad(cmd, format!("node {} is not type", id.0)));
}
@@ -165,14 +166,16 @@ fn check_bindable(doc: &vectorcraft_doc::Document, ids: &[NodeId], kind: Variabl
Ok(())
}
/// Why `variable_target` refuses `id`: the object is locked, a group or layer that holds it is
/// locked, one that holds it is hidden, or it is on a locked artboard, checked in that order.
fn not_a_target(doc: &vectorcraft_doc::Document, id: NodeId) -> String {
/// Why `variable_target` refuses `id` for a variable of `kind`: the object is locked, a group or
/// layer that holds it is locked, one that holds it is hidden, or it is on a locked artboard,
/// checked in that order. For a visibility variable, the object and group lock checks are skipped.
fn not_a_target(doc: &vectorcraft_doc::Document, id: NodeId, kind: VariableKind) -> String {
let holders: Vec<&vectorcraft_doc::Node> =
doc.ancestry(id).unwrap_or_default().into_iter().filter(|i| *i != id).filter_map(|i| doc.node(i)).collect();
if doc.node(id).is_some_and(|n| n.locked) {
let locks = kind != VariableKind::Visibility;
if locks && doc.node(id).is_some_and(|n| n.locked) {
format!("node {} is locked", id.0)
} else if holders.iter().any(|n| n.locked) {
} else if locks && holders.iter().any(|n| n.locked) {
format!("node {} is in a locked group or layer", id.0)
} else if holders.iter().any(|n| !n.visible) {
format!("node {} is in a hidden group or layer", id.0)
@@ -511,8 +514,9 @@ fn dataset_capture(s: &mut Session, p: &Value) -> Result<Value> {
}
/// Update Data Set: write what the bound objects hold now into the row that is active, for each
/// variable the row holds. A variable whose objects report no value (locked, inside a locked
/// layer or group, on a locked artboard, or gone) keeps the row's value. With `all`, every other variable the
/// variable the row holds. A variable whose objects report no value (gone, or, for a variable
/// other than a visibility variable, locked or inside a locked layer or group, or, for any
/// variable, on a locked artboard) keeps the row's value. With `all`, every other variable the
/// objects report a value for is added to the row. Without an active row there is nothing to
/// update, so say so rather than picking one. With every bound object locked, no value is
/// written; an update that writes no value adds no undo step.
@@ -588,14 +592,16 @@ fn apply_dataset(s: &mut Session, name: &str) -> Result<Value> {
}
// Which bound objects the row may rewrite, measured once before any of them changes, so
// text that grows onto a locked artboard part way through is still rewritten. Each id is
// keyed by whether a text value is meant for it, which checks every frame of its story.
let targets: BTreeSet<(NodeId, bool)> = {
// keyed by whether a text value is meant for it, which checks every frame of its story,
// and by whether a visibility value is, which ignores locks.
let targets: BTreeSet<(NodeId, bool, bool)> = {
let locks = d.locked_artboards();
let mut out = BTreeSet::new();
for (id, var) in &pairs {
let text = d.variables.variable(var).is_some_and(|v| v.kind == VariableKind::Text);
if if text { d.story_target_with(&locks, *id) } else { d.dataset_target_with(&locks, *id) } {
out.insert((*id, text));
let kind = d.variables.variable(var).map_or(VariableKind::Unknown, |v| v.kind);
let text = kind == VariableKind::Text;
if if text { d.story_target_with(&locks, *id) } else { d.dataset_target_with(&locks, *id, kind) } {
out.insert((*id, text, kind == VariableKind::Visibility));
}
}
out
@@ -617,12 +623,13 @@ fn apply_dataset(s: &mut Session, name: &str) -> Result<Value> {
continue;
}
};
// Locked art, art in a locked layer or group or on a locked artboard, and text whose
// object is gone, can't be rewritten: skipped and counted, like the rest. Hidden art
// is rewritten, in a hidden layer or group too, and stays hidden unless a visibility
// value shows it. A text value rewrites the whole story of threaded text, so it skips
// when any frame of it is locked or on a locked artboard.
if !targets.contains(&(id, kind == VariableKind::Text)) {
// Art that is gone, art on a locked artboard, and art that is locked or in a locked
// layer or group, is skipped and counted, like the rest, except that a visibility value
// shows and hides locked art off a locked artboard. Hidden art is rewritten, in a hidden layer or group too,
// and stays hidden unless a visibility value shows it. A text value rewrites the whole
// story of threaded text, so it skips when any frame of it is locked or on a locked
// artboard.
if !targets.contains(&(id, kind == VariableKind::Text, kind == VariableKind::Visibility)) {
skipped += 1;
continue;
}
@@ -759,7 +766,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Bind Variable",
["Window", "Variables"],
None,
"{variable, ids?} bind objects (default: the selection) to a variable; text needs type, image needs an image object, graph needs a graph root, locked art is refused. A type object holds one text variable: binding it to another text variable is refused, and the error names the one it has. Variables of other kinds can share an object, and binding an object to a variable it already has changes nothing → {bound, already}",
"{variable, ids?} bind objects (default: the selection) to a variable; text needs type, image needs an image object, graph needs a graph root, art on a locked artboard is refused, and so are locked art and art in a locked layer or group except by a visibility variable. A type object holds one text variable: binding it to another text variable is refused, and the error names the one it has. Variables of other kinds can share an object, and binding an object to a variable it already has changes nothing → {bound, already}",
has_doc,
bind
),
@@ -815,7 +822,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Update Data Set",
["Window", "Variables"],
None,
"{all?: bool} write what the bound objects hold now into the active dataset, for each variable the dataset holds; a variable whose objects report no value (locked, inside a locked layer or group, on a locked artboard, or gone) keeps the dataset's value. `all: true` also adds every other variable the objects report a value for; `left` names the variables it didn't write → {values, left}",
"{all?: bool} write what the bound objects hold now into the active dataset, for each variable the dataset holds; a variable whose objects report no value (gone, on a locked artboard, or, for a variable other than a visibility variable, locked or inside a locked layer or group) keeps the dataset's value. `all: true` also adds every other variable the objects report a value for; `left` names the variables it didn't write → {values, left}",
has_doc,
dataset_update
),
+4 -3
View File
@@ -253,9 +253,10 @@ pub struct Session {
/// Where folder searches may go; `None`: this computer's rules ([`cmd::findfiles::Rules`]).
/// Tests set it.
pub search_rules: Option<cmd::findfiles::Rules>,
/// Canvas feedback cached for the pending deferred preview ([`Session::overlays`]); cleared
/// when the preview params change or the interaction ends.
pub(crate) deferred_overlay_cache: Option<(String, Value, Vec<vectorcraft_tools::Overlay>)>,
/// Canvas feedback cached for the pending deferred preview ([`Session::overlays`],
/// [`Session::deferred_transform_box`]); cleared when the preview params change or the
/// interaction ends.
pub(crate) deferred_overlay_cache: Option<tooling::DeferredFeedback>,
/// The walker threads a folder search starts; `None`: [`cmd::findfiles::threads`]. Tests set
/// it.
pub search_threads: Option<usize>,
+14 -8
View File
@@ -179,7 +179,7 @@ fn fill_proxy_invert_recolors_a_colorized_gray_image_and_undo_restores_it() {
let id = image(&mut s, "gray", gray(false));
s.execute("paint.setFill", &json!({"color": "#336699"})).unwrap();
let before = colorize(&s, id).unwrap();
assert_eq!(s.execute("paint.invert", &json!({})).unwrap(), json!({"changed": 1}));
assert_eq!(s.execute("paint.invert", &json!({})).unwrap(), json!({"changed": 1, "notChanged": {"locked": 0, "hidden": 0}}));
assert_eq!(colorize(&s, id).and_then(|p| p.color()).map(|c| c.to_hex()), Some("#cc9966".into()));
assert_eq!(s.proxy_paints().0.color().map(|c| c.to_hex()), Some("#cc9966".into()));
s.execute("edit.undo", &json!({})).unwrap();
@@ -193,7 +193,7 @@ fn fill_proxy_complement_recolors_a_colorized_gray_image_and_undo_restores_it()
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
let before = colorize(&s, id).unwrap();
let expected = Paint::solid(Color::rgb8(0, 255, 255));
assert_eq!(s.execute("paint.complement", &json!({})).unwrap(), json!({"changed": 1}));
assert_eq!(s.execute("paint.complement", &json!({})).unwrap(), json!({"changed": 1, "notChanged": {"locked": 0, "hidden": 0}}));
assert_eq!(colorize(&s, id), Some(expected.clone()));
assert_eq!(s.proxy_paints().0, expected);
s.execute("edit.undo", &json!({})).unwrap();
@@ -209,7 +209,7 @@ fn stroke_proxy_invert_leaves_a_colorized_image_fill_unchanged() {
let key = image_key(&s, id);
s.execute("paint.setStroke", &json!({"color": "#ff0000"})).unwrap();
assert_eq!(s.doc().unwrap().doc.node(id).unwrap().appearance.stroke().unwrap().paint.color(), Some(Color::rgb8(255, 0, 0)));
assert_eq!(s.execute("paint.invert", &json!({"stroke": true})).unwrap(), json!({"changed": 1}));
assert_eq!(s.execute("paint.invert", &json!({"stroke": true})).unwrap(), json!({"changed": 1, "notChanged": {"locked": 0, "hidden": 0}}));
assert_eq!(s.doc().unwrap().doc.node(id).unwrap().appearance.stroke().unwrap().paint.color(), Some(Color::rgb8(0, 255, 255)));
assert_eq!(colorize(&s, id), before);
assert_eq!(image_key(&s, id), key);
@@ -223,7 +223,10 @@ fn edit_colors_invert_without_fill_recolors_a_colorized_images_stroke() {
s.execute("paint.setStroke", &json!({"color": "#ff0000"})).unwrap();
let before = colorize(&s, id);
let key = image_key(&s, id);
assert_eq!(s.execute("edit.colors.invert", &json!({"fill": false, "stroke": true})).unwrap(), json!({"changed": 1}));
assert_eq!(
s.execute("edit.colors.invert", &json!({"fill": false, "stroke": true})).unwrap(),
json!({"changed": 1, "notChanged": {"locked": 0, "hidden": 0}})
);
assert_eq!(s.doc().unwrap().doc.node(id).unwrap().appearance.stroke().unwrap().paint.color(), Some(Color::rgb8(0, 255, 255)));
assert_eq!(colorize(&s, id), before);
assert_eq!(image_key(&s, id), key);
@@ -251,7 +254,10 @@ fn edit_colors_recolors_an_image_colorize_with_pixels_excluded() {
let id = image(&mut s, "gray", gray(false));
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
let before = colorize(&s, id);
assert_eq!(s.execute("edit.colors.invert", &json!({"includeImages": false})).unwrap(), json!({"changed": 1}));
assert_eq!(
s.execute("edit.colors.invert", &json!({"includeImages": false})).unwrap(),
json!({"changed": 1, "notChanged": {"locked": 0, "hidden": 0}})
);
assert_eq!(colorize(&s, id), Some(Paint::solid(Color::rgb8(0, 255, 255))));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(colorize(&s, id), before);
@@ -264,7 +270,7 @@ fn edit_colors_invert_recolors_colorize_without_changing_pixels_and_undo_restore
s.execute("paint.setFill", &json!({"color": "#336699"})).unwrap();
let before = colorize(&s, id);
let key = image_key(&s, id);
assert_eq!(s.execute("edit.colors.invert", &json!({})).unwrap(), json!({"changed": 1}));
assert_eq!(s.execute("edit.colors.invert", &json!({})).unwrap(), json!({"changed": 1, "notChanged": {"locked": 0, "hidden": 0}}));
assert_eq!(colorize(&s, id).and_then(|p| p.color()).map(|c| c.to_hex()), Some("#cc9966".into()));
assert_eq!(image_key(&s, id), key);
s.execute("edit.undo", &json!({})).unwrap();
@@ -292,7 +298,7 @@ fn edit_colors_invert_without_fill_leaves_colorized_image_unchanged() {
s.execute("paint.setFill", &json!({"color": "#336699"})).unwrap();
let before = colorize(&s, id);
let key = image_key(&s, id);
assert_eq!(s.execute("edit.colors.invert", &json!({"fill": false})).unwrap(), json!({"changed": 0}));
assert_eq!(s.execute("edit.colors.invert", &json!({"fill": false})).unwrap(), json!({"changed": 0, "notChanged": {"locked": 0, "hidden": 0}}));
assert_eq!(colorize(&s, id), before);
assert_eq!(image_key(&s, id), key);
}
@@ -326,7 +332,7 @@ fn proxy_invert_recolors_a_colorized_image_and_another_fill_in_one_undo_step() {
s.execute("paint.setFill", &json!({"color": "#0000ff"})).unwrap();
s.execute("select.set", &json!({"ids": [id.0, shape.0]})).unwrap();
let undo = s.doc().unwrap().history.undo.len();
assert_eq!(s.execute("paint.invert", &json!({})).unwrap(), json!({"changed": 2}));
assert_eq!(s.execute("paint.invert", &json!({})).unwrap(), json!({"changed": 2, "notChanged": {"locked": 0, "hidden": 0}}));
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
assert_eq!(colorize(&s, id), Some(Paint::solid(Color::rgb8(0, 255, 255))));
assert_eq!(s.doc().unwrap().doc.node(shape).unwrap().appearance.fill().unwrap().paint.color(), Some(Color::rgb8(255, 255, 0)));
+8 -8
View File
@@ -68,7 +68,7 @@ fn make_and_expand_each_shape_mode() {
assert_eq!(members, vec![back, front], "the members, in their stacking order");
assert!(shape.children().unwrap().iter().all(|m| m.extras().shape_mode.name() == mode));
assert_eq!(shape.appearance.fill_paint().color(), Some(fill), "{mode}");
let drawn = effects::compound_shape_area(&shape, None).unwrap();
let drawn = effects::compound_shape_area(None, &shape, None).unwrap();
assert!((area(&drawn) - want).abs() < 0.5, "{mode}: {}", area(&drawn));
// Expand: the path, or compound path, of the area, painted as the compound shape.
let made = run(&mut s, "object.compoundShape.expand", json!({}));
@@ -118,7 +118,7 @@ fn a_selected_member_takes_a_shape_mode() {
assert_eq!(cmd::selected_member_modes(&s), vec![ShapeMode::Add]);
run(&mut s, "object.compoundShape.setMode", json!({"mode": "intersect"}));
assert_eq!(cmd::selected_member_modes(&s), vec![ShapeMode::Intersect]);
assert!((area(&effects::compound_shape_area(&node(&s, id), None).unwrap()) - 5_000.0).abs() < 0.5);
assert!((area(&effects::compound_shape_area(None, &node(&s, id), None).unwrap()) - 5_000.0).abs() < 0.5);
// The same mode again is no edit.
let undo = s.doc().unwrap().history.undo.len();
run(&mut s, "object.compoundShape.setMode", json!({"mode": "intersect"}));
@@ -208,7 +208,7 @@ fn make(s: &mut Session, mode: &str) -> NodeId {
fn drawn(s: &Session, id: NodeId) -> f64 {
let d = &s.doc().unwrap().doc;
let hook = |c: &Node| effects::outline_object(Some(d), c);
area(&effects::compound_shape_area(&node(s, id), Some(&hook)).unwrap())
area(&effects::compound_shape_area(None, &node(s, id), Some(&hook)).unwrap())
}
/// A bar (0–100 across, 10–30 down) behind a blend of two 20 pt squares at 0 and 80 (0–20 down)
@@ -243,7 +243,7 @@ fn a_blend_is_a_member_as_its_steps() {
let doc = s.doc().unwrap().doc.clone();
let back = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap();
assert_eq!(back.node(id), doc.node(id));
assert!((area(&effects::compound_shape_area(back.node(id).unwrap(), None).unwrap()) - more).abs() < 0.5);
assert!((area(&effects::compound_shape_area(None, back.node(id).unwrap(), None).unwrap()) - more).abs() < 0.5);
run(&mut s, "select.set", json!({"ids": [id.0]}));
let made = run(&mut s, "object.compoundShape.expand", json!({}));
assert!((node_area(&node(&s, NodeId(made["ids"][0].as_u64().unwrap()))) - more).abs() < 0.5, "{mode}");
@@ -472,8 +472,8 @@ fn branching_symbol_art_stops_at_its_budget() {
}
let d = &s.doc().unwrap().doc;
let hook = |c: &Node| effects::outline_object(Some(d), c);
assert!(effects::compound_shape_area(&node(&s, id), Some(&hook)).is_none(), "past the budget");
let leaves = effects::member_leaves(&node(&s, group), Some(&hook));
assert!(effects::compound_shape_area(None, &node(&s, id), Some(&hook)).is_none(), "past the budget");
let leaves = effects::member_leaves(None, &node(&s, group), Some(&hook));
assert!(!leaves.is_empty() && leaves.len() as u64 <= effects::SYMBOL_ART_MAX_NODES, "{} shapes", leaves.len());
}
@@ -563,7 +563,7 @@ fn a_warped_symbol_instance_is_a_member_as_it_draws() {
assert!((warped - 400.0).abs() > 10.0, "the warp changes the area: {warped}");
assert!((drawn(&s, id) - (warped + 100.0)).abs() < 1.0, "{} vs {}", drawn(&s, id), warped + 100.0);
let hook = |c: &Node| effects::outline_object(Some(d), c);
let alone: f64 = effects::member_leaves(&node(&s, a), Some(&hook)).iter().map(node_area).sum();
let alone: f64 = effects::member_leaves(None, &node(&s, a), Some(&hook)).iter().map(node_area).sum();
assert!((alone - warped).abs() < 1.0, "the instance alone: {alone} vs {warped}");
}
@@ -616,7 +616,7 @@ fn type_in_symbol_art_counts_as_its_outlines() {
let unwarped = {
let d = &s.doc().unwrap().doc;
let hook = |c: &Node| effects::outline_object(Some(d), c);
effects::member_leaves(&node(&s, a), Some(&hook)).iter().map(node_area).sum::<f64>()
effects::member_leaves(None, &node(&s, a), Some(&hook)).iter().map(node_area).sum::<f64>()
};
assert!(unwarped > 200.0, "the glyphs: {unwarped}");
run(&mut s, "select.set", json!({"ids": [a.0]}));
+44
View File
@@ -430,3 +430,47 @@ fn recolor_writes_a_new_pattern_swatch() {
s.execute("edit.colors.invert", &json!({})).unwrap();
assert_eq!((fill_pattern(&s, r), tile_fill(&s, "Dots 2")), ("Dots 2".to_string(), "#00ffff".to_string()));
}
#[test]
fn edit_colors_of_everything_count_the_hidden_objects_left_out() {
let mut s = session();
let shown = rect(&mut s, json!("#ff0000"));
let hidden = rect(&mut s, json!("#ff0000"));
// A hidden object with no paint would not change.
let unpainted = rect(&mut s, json!("#ff0000"));
s.execute("paint.setFill", &json!({"ids": [unpainted.0], "none": true})).unwrap();
s.execute("paint.setStroke", &json!({"ids": [unpainted.0], "none": true})).unwrap();
s.execute("layer.setProps", &json!({"ids": [hidden.0, unpainted.0], "visible": false})).unwrap();
// A locked member of a selected group is recolored with it.
let member = rect(&mut s, json!("#ff0000"));
let locked_member = rect(&mut s, json!("#ff0000"));
s.execute("select.set", &json!({"ids": [member.0, locked_member.0]})).unwrap();
s.execute("object.group", &json!({})).unwrap();
s.execute("layer.setProps", &json!({"ids": [locked_member.0], "locked": true})).unwrap();
s.execute("select.all", &json!({})).unwrap();
let r = s.execute("edit.colors.invert", &json!({})).unwrap();
assert_eq!(r["notChanged"], json!({"locked": 0, "hidden": 1}), "{r}");
assert_eq!(r["warning"], "Recolor completed; 1 hidden object was left unchanged");
assert_eq!((fill_of(&s, shown).to_hex(), fill_of(&s, hidden).to_hex()), ("#00ffff".to_string(), "#ff0000".to_string()));
assert_eq!(fill_of(&s, locked_member).to_hex(), "#00ffff");
// Objects given by `ids` are the whole scope.
let r = s.execute("edit.colors.toGrayscale", &json!({"ids": [shown.0]})).unwrap();
assert_eq!(r["notChanged"], json!({"locked": 0, "hidden": 0}), "{r}");
assert!(r.get("warning").is_none(), "{r}");
}
#[test]
fn edit_colors_in_isolation_mode_count_the_locked_members_left_out() {
let mut s = session();
let a = rect(&mut s, json!("#ff0000"));
let b = rect(&mut s, json!("#ff0000"));
let c = rect(&mut s, json!("#ff0000"));
s.execute("select.set", &json!({"ids": [a.0, b.0, c.0]})).unwrap();
let g = id_of(&s.execute("object.group", &json!({})).unwrap());
s.execute("layer.setProps", &json!({"ids": [b.0, c.0], "locked": true})).unwrap();
s.execute("object.isolate", &json!({"id": g.0})).unwrap();
s.execute("select.all", &json!({})).unwrap();
let r = s.execute("edit.colors.saturate", &json!({"intensity": -50})).unwrap();
assert_eq!(r["notChanged"], json!({"locked": 2, "hidden": 0}), "{r}");
assert_eq!(r["warning"], "Recolor completed; 2 locked objects were left unchanged");
}
+41 -22
View File
@@ -974,19 +974,48 @@ fn expanded_colours_keep_the_stops_colour_model() {
assert!((c - 0.625).abs() < 1e-5 && (m - 0.375).abs() < 1e-5, "interpolated in CMYK: {c} {m}");
}
/// A new 200 × 200 CMYK document.
fn cmyk_session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 200, "colorMode": "cmyk"})).unwrap();
s
}
/// The selection's freeform gradient expanded: the mesh's colors, after checking that it draws
/// within `most` (mean channel difference) of the gradient.
fn expanded(s: &mut Session, most: f64) -> Vec<Color> {
let before = render(s);
run(s, "object.expand", json!({}));
let diff = mean_diff(&before, &render(s));
assert!(diff < most, "looks the same: {diff}");
let NodeKind::Mesh(m) = selected(s).kind else { panic!("a mesh: {:?}", selected(s).kind) };
m.points.into_iter().map(|p| p.color).collect()
}
/// Cyan and black points mix as inks: cyan and black only, adding up to one. The gradient and the
/// mesh both mix inks, so the two draw alike.
fn cyan_and_black_points_expand_as_they_draw(mut s: Session) {
let (cyan, black) = (json!({"c": 1.0, "m": 0.0, "y": 0.0, "k": 0.0}), json!({"c": 0.0, "m": 0.0, "y": 0.0, "k": 1.0}));
freeform_rect(&mut s, Rect::new(20.0, 20.0, 180.0, 180.0), [cyan.clone(), cyan, black.clone(), black]);
for c in expanded(&mut s, 2.5) {
let [c, m, y, k] = inks(c);
assert!(m.abs() < 1e-5 && y.abs() < 1e-5 && (c + k - 1.0).abs() < 1e-5, "C{c} M{m} Y{y} K{k}");
}
}
#[test]
fn cyan_and_black_freeform_points_expand_as_they_draw_in_an_rgb_document() {
cyan_and_black_points_expand_as_they_draw(session());
}
#[test]
fn cyan_and_black_freeform_points_expand_as_they_draw_in_a_cmyk_document() {
cyan_and_black_points_expand_as_they_draw(cmyk_session());
}
#[test]
fn a_freeform_gradient_expands_with_the_inks_of_its_points() {
let ink = |c: f32, m: f32, y: f32, k: f32| json!({"c": c, "m": m, "y": y, "k": k});
// The mesh's colors, after checking that it draws within `most` (mean channel difference) of
// the gradient.
let expanded = |s: &mut Session, most: f64| {
let before = render(s);
run(s, "object.expand", json!({}));
let diff = mean_diff(&before, &render(s));
assert!(diff < most, "looks the same: {diff}");
let NodeKind::Mesh(m) = selected(s).kind else { panic!("a mesh: {:?}", selected(s).kind) };
m.points.into_iter().map(|p| p.color).collect::<Vec<_>>()
};
// Points of one ink set give every mesh point that ink set.
let mut s = session();
let one = ink(0.5, 0.2, 0.0, 0.1);
@@ -994,19 +1023,9 @@ fn a_freeform_gradient_expands_with_the_inks_of_its_points() {
for c in expanded(&mut s, 1.0) {
assert!(close_inks(inks(c), [0.5, 0.2, 0.0, 0.1]), "{c:?}");
}
// Cyan and black points mix as inks: cyan and black only, adding up to one. The gradient blends
// the points' display colors and the mesh blends their inks, so the two draw a little apart in between.
let mut s = session();
let (cyan, black) = (ink(1.0, 0.0, 0.0, 0.0), ink(0.0, 0.0, 0.0, 1.0));
freeform_rect(&mut s, Rect::new(20.0, 20.0, 180.0, 180.0), [cyan.clone(), cyan.clone(), black.clone(), black]);
for c in expanded(&mut s, 7.5) {
let [c, m, y, k] = inks(c);
assert!(m.abs() < 1e-5 && y.abs() < 1e-5 && (c + k - 1.0).abs() < 1e-5, "C{c} M{m} Y{y} K{k}");
}
// Points in different models mix in display RGB, stored in a CMYK document as CMYK.
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 200, "colorMode": "cmyk"})).unwrap();
let gray = json!({"gray": 0.5});
let (cyan, gray) = (ink(1.0, 0.0, 0.0, 0.0), json!({"gray": 0.5}));
let mut s = cmyk_session();
freeform_rect(&mut s, Rect::new(20.0, 20.0, 180.0, 180.0), [cyan.clone(), cyan, gray.clone(), gray]);
for c in expanded(&mut s, 2.5) {
inks(c);
+620
View File
@@ -316,6 +316,626 @@ fn expanding_a_fill_gives_its_plug_in_effect_the_fill_it_sits_on() {
assert_eq!(clip.path_data().unwrap().bounds(), triangle().bounds(), "the triangle the canvas fills");
}
/// The manifest of a test live effect `id` that declares `paintOutput` (at `api` 2), or that
/// doesn't (`paints` off).
fn painting(id: &str, paints: bool) -> String {
let declared = if paints { r#","api":2,"paintOutput":true"# } else { "" };
format!(r#"{{"id":"{id}","name":"Test {id}","version":"1","kind":"effect"{declared},"params":{{}}}}"#)
}
/// A 20 × 20 square at `x`, 40.
fn square_at(x: f64) -> PathData {
PathData::single(SubPath::polyline(&[Point::new(x, 40.0), Point::new(x + 20.0, 40.0), Point::new(x + 20.0, 60.0), Point::new(x, 60.0)], true))
}
fn solid(hex: &str) -> Value {
serde_json::to_value(Paint::solid(Color::from_hex(hex).unwrap())).unwrap()
}
/// The 200 × 200 top left of the document, drawn on white.
fn drawn(s: &Session) -> vectorcraft_render::Rendered {
let opts = vectorcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
vectorcraft_render::Renderer::new().render(&s.doc().unwrap().doc, 200, 200, vectorcraft_geom::Affine::IDENTITY, &opts)
}
const WHITE: [u8; 4] = [255, 255, 255, 255];
const RED: [u8; 4] = [255, 0, 0, 255];
const GREEN: [u8; 4] = [0, 255, 0, 255];
const BLUE: [u8; 4] = [0, 0, 255, 255];
/// A live effect that declares `paintOutput` draws each piece it returns by itself, with the paints
/// it gives (a piece without `fills` keeps the object's), in place of the object; the same output
/// from an effect that doesn't declare it is one path painted by the object's fill. Drawing and
/// export leave the document alone, and Expand Appearance writes the pieces as ordinary paths in
/// one undo step.
#[test]
fn a_painted_live_effect_draws_its_pieces_with_their_own_paints() {
let mut s = session();
let id = rect(&mut s, 10.0, "#ff0000");
let out = json!({"objects": [{"path": square_at(40.0), "fills": [solid("#00ff00")]}, {"path": square_at(80.0)}]}).to_string();
install_wat(&mut s, &tpl::returning(&painting("test.engine.paints", true), &out));
install_wat(&mut s, &tpl::returning(&painting("test.engine.geometry", false), &out));
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.geometry"}));
let img = drawn(&s);
assert_eq!((img.pixel(50, 50), img.pixel(90, 50), img.pixel(15, 15)), (RED, RED, WHITE), "without paintOutput the paint is ignored");
run(&mut s, "effect.remove", json!({"ids": [id.0], "index": 0}));
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.paints"}));
let before = s.doc().unwrap().doc.clone();
let undo = s.doc().unwrap().history.undo.len();
let img = drawn(&s);
assert_eq!((img.pixel(50, 50), img.pixel(90, 50), img.pixel(15, 15)), (GREEN, RED, WHITE), "each piece with its own paint");
// An upstream paint edit reaches the piece that keeps the object's fill; Undo brings it back.
run(&mut s, "paint.setFill", json!({"ids": [id.0], "color": "#0000ff"}));
assert_eq!((drawn(&s).pixel(50, 50), drawn(&s).pixel(90, 50)), (GREEN, BLUE));
run(&mut s, "edit.undo", json!({}));
assert_eq!(drawn(&s).pixel(90, 50), RED);
run(&mut s, "edit.redo", json!({}));
assert_eq!(drawn(&s).pixel(90, 50), BLUE);
run(&mut s, "edit.undo", json!({}));
let svg = s.execute("document.export", &json!({"format": "svg"})).unwrap();
let svg = String::from_utf8(vectorcraft_format::base64_decode(svg["dataBase64"].as_str().unwrap()).unwrap()).unwrap();
assert!(svg.contains("#00ff00") && svg.contains("#ff0000"), "{svg}");
assert_eq!(s.doc().unwrap().doc, before, "drawing and export don't change the document");
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
// Expanded: two ordinary paths, green and red, and no live effect, as one undo step.
run(&mut s, "select.set", json!({"ids": [id.0]}));
run(&mut s, "effect.expandAppearance", json!({}));
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
let mut paths = vec![];
s.doc().unwrap().doc.walk(|n| {
if let Some(p) = n.path_data() {
assert!(n.appearance.effects.is_empty(), "no live effect left");
paths.push((p.bounds(), n.appearance.fill().map(|f| f.paint.clone())));
}
});
let fill = |hex: &str| Some(Paint::solid(Color::from_hex(hex).unwrap()));
assert_eq!(paths, [(square_at(40.0).bounds(), fill("#00ff00")), (square_at(80.0).bounds(), fill("#ff0000"))]);
assert_eq!(drawn(&s).pixel(50, 50), GREEN);
run(&mut s, "edit.undo", json!({}));
assert_eq!(node(&s, id).appearance.effects[0].id, "plugin.test.engine.paints");
}
/// On a fill, a painted effect returns only fills: they replace that fill on each piece. A result
/// that breaks a rule (strokes from an effect on a fill, a swatch the document doesn't have) is
/// refused whole: the object is drawn as if the effect weren't there, and `plugin.info` names the
/// plug-in and the field.
#[test]
fn a_painted_effect_on_a_fill_and_refused_results() {
let mut s = session();
let id = rect(&mut s, 10.0, "#ff0000");
let fills = json!({"objects": [{"path": square_at(40.0), "fills": [solid("#00ff00")]}]}).to_string();
let strokes = json!({"objects": [{"path": square_at(40.0), "strokes": [{"paint": solid("#00ff00"), "width": 2}]}]}).to_string();
let mut gone = solid("#00ff00");
gone["swatch"] = json!("Not a swatch");
let unknown = json!({"objects": [{"path": square_at(40.0), "fills": [gone]}]}).to_string();
install_wat(&mut s, &tpl::returning(&painting("test.engine.fillpaint", true), &fills));
install_wat(&mut s, &tpl::returning(&painting("test.engine.strokepaint", true), &strokes));
install_wat(&mut s, &tpl::returning(&painting("test.engine.swatchpaint", true), &unknown));
let fill = node(&s, id).appearance.items.iter().position(|i| i.is_fill()).unwrap();
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.fillpaint", "item": fill}));
let img = drawn(&s);
assert_eq!((img.pixel(50, 50), img.pixel(15, 15)), (GREEN, WHITE), "the fill's piece, green");
assert_eq!(run(&mut s, "plugin.info", json!({"id": "test.engine.fillpaint"}))["lastError"], Value::Null);
// A second painted effect on the same fill paints what it returns too.
let blue = json!({"objects": [{"path": square_at(80.0), "fills": [solid("#0000ff")]}]}).to_string();
install_wat(&mut s, &tpl::returning(&painting("test.engine.bluepaint", true), &blue));
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.bluepaint", "item": fill}));
let img = drawn(&s);
assert_eq!((img.pixel(90, 50), img.pixel(50, 50), img.pixel(15, 15)), (BLUE, WHITE, WHITE), "the second effect's piece, blue");
run(&mut s, "effect.remove", json!({"ids": [id.0], "index": 1, "item": fill}));
run(&mut s, "effect.remove", json!({"ids": [id.0], "index": 0, "item": fill}));
for (effect, item, field) in
[("test.engine.strokepaint", json!(fill), "objects[0].strokes"), ("test.engine.swatchpaint", Value::Null, "objects[0].fills[0].swatch")]
{
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": format!("plugin.{effect}"), "item": item}));
let img = drawn(&s);
assert_eq!((img.pixel(50, 50), img.pixel(15, 15)), (WHITE, RED), "{effect}: drawn as it is");
let e = run(&mut s, "plugin.info", json!({"id": effect}))["lastError"].as_str().unwrap_or_default().to_string();
assert!(e.starts_with(&format!("Test {effect}: ")) && e.contains(field), "{effect}: {e}");
let n = node(&s, id);
let kept = if item.is_null() { n.appearance.effects.clone() } else { n.appearance.items[fill].effects().to_vec() };
assert_eq!(kept.iter().map(|e| e.id.as_str()).collect::<Vec<_>>(), [format!("plugin.{effect}")], "the effect record stays");
run(&mut s, "effect.remove", json!({"ids": [id.0], "index": 0, "item": item}));
}
}
/// A painted effect on a group may link its pieces to the document's swatches.
#[test]
fn a_painted_group_effect_links_to_the_document_s_swatches() {
let mut s = session();
rect(&mut s, 10.0, "#ff0000");
run(&mut s, "swatch.new", json!({"name": "Leaf", "color": "#00ff00", "global": true}));
run(&mut s, "select.all", json!({}));
let g = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap());
let mut leaf = solid("#00ff00");
leaf["swatch"] = json!("Leaf");
let out = json!({"objects": [{"path": square_at(40.0), "fills": [leaf]}]}).to_string();
install_wat(&mut s, &tpl::returning(&painting("test.engine.grouppaint", true), &out));
run(&mut s, "effect.apply", json!({"ids": [g.0], "effect": "plugin.test.engine.grouppaint"}));
let img = drawn(&s);
assert_eq!((img.pixel(50, 50), img.pixel(15, 15)), (GREEN, WHITE), "the group's piece, green");
}
/// The output of a painted effect that returns one green square linked to swatch `Leaf`.
fn leaf_square() -> String {
let mut leaf = solid("#00ff00");
leaf["swatch"] = json!("Leaf");
json!({"objects": [{"path": square_at(40.0), "fills": [leaf]}]}).to_string()
}
/// A Pathfinder operation and baked appearance take a painted effect's pieces, swatch links
/// included, as the canvas draws them: a group with a painted effect inside a group with a live
/// Pathfinder effect draws the green piece, and baking a path's appearance gives the piece linked
/// to the swatch.
#[test]
fn pathfinder_and_baked_appearance_take_painted_pieces_with_swatch_links() {
let mut s = session();
let id = rect(&mut s, 10.0, "#ff0000");
run(&mut s, "swatch.new", json!({"name": "Leaf", "color": "#00ff00"}));
install_wat(&mut s, &tpl::returning(&painting("test.engine.leafpaint", true), &leaf_square()));
run(&mut s, "select.set", json!({"ids": [id.0]}));
let inner = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap());
run(&mut s, "effect.apply", json!({"ids": [inner.0], "effect": "plugin.test.engine.leafpaint"}));
let outer = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap());
run(&mut s, "effect.apply", json!({"ids": [outer.0], "effect": "pathfinder.add"}));
let img = drawn(&s);
assert_eq!((img.pixel(50, 50), img.pixel(15, 15)), (GREEN, WHITE), "the Pathfinder operation takes the green piece");
let id = rect(&mut s, 120.0, "#ff0000");
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.leafpaint"}));
let st = s.doc().unwrap();
let baked = vectorcraft_render::effects::bake_appearance(Some(&st.doc), st.doc.node(id).unwrap()).unwrap();
let mut fills = vec![];
baked.walk(&mut |n| {
if let Some(p) = n.path_data() {
fills.push((p.bounds(), n.appearance.fill().map(|f| f.paint.clone())));
}
});
let mut leaf = Paint::solid(Color::from_hex("#00ff00").unwrap());
if let Paint::Solid { swatch, .. } = &mut leaf {
*swatch = Some("Leaf".into());
}
assert_eq!(fills, [(square_at(40.0).bounds(), Some(leaf))]);
}
/// A swatch-linked piece on screen is drawn when the path it comes from is off screen: the
/// renderer's bounds of the path take in the piece.
#[test]
fn a_swatch_linked_piece_is_not_culled_with_its_off_screen_path() {
let mut s = session();
let id = rect(&mut s, 500.0, "#ff0000");
run(&mut s, "swatch.new", json!({"name": "Leaf", "color": "#00ff00"}));
install_wat(&mut s, &tpl::returning(&painting("test.engine.culled", true), &leaf_square()));
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.culled"}));
assert_eq!(drawn(&s).pixel(50, 50), GREEN);
}
/// The renderer measures a painted piece with its own strokes: a piece off screen whose wide stroke
/// reaches the view is drawn, and the bounds raster effects work over take in that stroke, on a
/// path with no stroke of its own and with a blur on the stroke the effect is on.
#[test]
fn a_piece_s_own_stroke_counts_in_the_bounds_of_what_is_drawn() {
let mut s = session();
let wide = json!({"objects": [{"path": square_at(-120.0), "strokes": [{"paint": solid("#00ff00"), "width": 300}]}]}).to_string();
install_wat(&mut s, &tpl::returning(&painting("test.engine.widestroke", true), &wide));
let id = rect(&mut s, -300.0, "#ff0000");
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.widestroke"}));
assert_eq!(drawn(&s).pixel(30, 50), GREEN, "only the piece's stroke is on screen");
let bounds = |s: &Session, id: NodeId| vectorcraft_render::drawn_bounds(&s.doc().unwrap().doc, &node(s, id)).unwrap();
assert!(bounds(&s, id).x1 >= 50.0, "{:?}", bounds(&s, id));
let blurred = rect(&mut s, -300.0, "#ff0000");
run(&mut s, "paint.setStroke", json!({"ids": [blurred.0], "color": "#0000ff", "width": 1}));
let stroke = node(&s, blurred).appearance.items.iter().position(|i| !i.is_fill()).unwrap();
run(&mut s, "effect.apply", json!({"ids": [blurred.0], "effect": "plugin.test.engine.widestroke", "item": stroke}));
run(&mut s, "effect.apply", json!({"ids": [blurred.0], "effect": "blur.gaussian", "params": {"radius": 10}, "item": stroke}));
assert!(bounds(&s, blurred).x1 > 55.0, "the blur spreads the piece's stroke: {:?}", bounds(&s, blurred));
}
/// A retained renderer's kept bounds follow the document's swatches: a swatch-linked piece on
/// screen appears when its swatch is added, though the path it comes from is off screen.
#[test]
fn a_retained_renderer_shows_a_piece_once_its_swatch_exists() {
let mut s = session();
let id = rect(&mut s, 500.0, "#ff0000");
install_wat(&mut s, &tpl::returning(&painting("test.engine.lateleaf", true), &leaf_square()));
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.lateleaf"}));
let opts = vectorcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let mut r = vectorcraft_render::Renderer::new();
let at_piece = |r: &mut vectorcraft_render::Renderer, s: &Session| {
r.render(&s.doc().unwrap().doc, 200, 200, vectorcraft_geom::Affine::IDENTITY, &opts).pixel(50, 50)
};
assert_eq!(at_piece(&mut r, &s), WHITE, "the piece is refused");
run(&mut s, "select.none", json!({}));
run(&mut s, "swatch.new", json!({"name": "Leaf", "color": "#00ff00"}));
assert_eq!(at_piece(&mut r, &s), GREEN, "the piece is drawn with its swatch");
}
/// Object › Expand of a gradient fill works piece by piece, for a painted effect on the object and
/// on the fill: a piece that keeps the gradient has it expanded, and a piece with a paint of its own,
/// here linked to the document's swatch, keeps that paint on its own path.
#[test]
fn expanding_a_gradient_fill_keeps_each_piece_s_own_paint() {
for on_fill in [false, true] {
let mut s = session();
let id = rect(&mut s, 10.0, "#ff0000");
run(&mut s, "paint.setFill", json!({"ids": [id.0], "gradient": {"kind": "linear"}}));
run(&mut s, "swatch.new", json!({"name": "Leaf", "color": "#00ff00"}));
let mut leaf = solid("#00ff00");
leaf["swatch"] = json!("Leaf");
let out = json!({"objects": [{"path": square_at(40.0), "fills": [leaf]}, {"path": square_at(100.0)}]}).to_string();
install_wat(&mut s, &tpl::returning(&painting("test.engine.expandref", true), &out));
let item = if on_fill { json!(node(&s, id).appearance.items.iter().position(|i| i.is_fill()).unwrap()) } else { Value::Null };
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.expandref", "item": item}));
run(&mut s, "select.set", json!({"ids": [id.0]}));
run(&mut s, "object.expand", json!({"object": false, "fill": true, "stroke": false}));
let img = drawn(&s);
assert_eq!((img.pixel(15, 15), img.pixel(50, 50)), (WHITE, GREEN), "on_fill {on_fill}: the input is gone and the piece keeps its paint");
assert!(![WHITE, GREEN].contains(&img.pixel(110, 50)), "on_fill {on_fill}: the other piece shows the gradient");
let mut own = Paint::solid(Color::from_hex("#00ff00").unwrap());
if let Paint::Solid { swatch, .. } = &mut own {
*swatch = Some("Leaf".into());
}
let mut kept = vec![];
let mut plugins = 0;
s.doc().unwrap().doc.walk(|n| {
plugins += n
.appearance
.effects
.iter()
.chain(n.appearance.items.iter().flat_map(|i| i.effects()))
.filter(|e| e.id.starts_with("plugin."))
.count();
if n.path_data().is_some_and(|p| p.bounds() == square_at(40.0).bounds()) {
kept.push(n.appearance.fill().map(|f| f.paint.clone()));
}
});
assert_eq!(kept, [Some(own)], "on_fill {on_fill}");
assert_eq!(plugins, 0, "on_fill {on_fill}: no live effect is left");
}
}
/// Object › Expand of a pattern fill works piece by piece: a piece that keeps the pattern has its
/// tiles expanded, and a piece with a paint of its own keeps that paint.
#[test]
fn expanding_a_pattern_fill_keeps_each_piece_s_own_paint() {
let mut s = session();
let id = rect(&mut s, 10.0, "#ff0000");
let tile = rect(&mut s, 300.0, "#00ff00");
run(&mut s, "object.pattern.make", json!({"ids": [tile.0], "name": "Tiles", "edit": false}));
run(&mut s, "paint.setFill", json!({"ids": [id.0], "swatch": "Tiles"}));
let out = json!({"objects": [{"path": square_at(40.0), "fills": [solid("#0000ff")]}, {"path": square_at(100.0)}]}).to_string();
install_wat(&mut s, &tpl::returning(&painting("test.engine.expandtiles", true), &out));
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.expandtiles"}));
run(&mut s, "select.set", json!({"ids": [id.0]}));
run(&mut s, "object.expand", json!({"object": false, "fill": true, "stroke": false}));
let img = drawn(&s);
assert_eq!((img.pixel(15, 15), img.pixel(50, 50)), (WHITE, BLUE), "the input is gone and the piece keeps its paint");
assert!(near(img.pixel(110, 50), GREEN), "the other piece shows the tiles");
let mut patterns = 0;
s.doc().unwrap().doc.walk(|n| patterns += n.appearance.items.iter().filter(|i| matches!(i.paint(), Paint::Pattern { .. })).count());
assert_eq!(patterns, 0, "the tiles are objects");
}
/// Two painted effects on one fill, the first returning a piece with two fills: the second replaces
/// the first of them and the other fill stays over it.
#[test]
fn a_second_painted_effect_on_a_fill_keeps_the_piece_s_other_fills() {
let mut s = session();
let id = rect(&mut s, 10.0, "#ff0000");
let two = json!({"objects": [{"path": square_at(40.0), "fills": [solid("#ff0000"), solid("#00ff00")]}]}).to_string();
let one = json!({"objects": [{"path": square_at(40.0), "fills": [solid("#0000ff")]}]}).to_string();
install_wat(&mut s, &tpl::returning(&painting("test.engine.twofills", true), &two));
install_wat(&mut s, &tpl::returning(&painting("test.engine.onefill", true), &one));
let fill = node(&s, id).appearance.items.iter().position(|i| i.is_fill()).unwrap();
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.twofills", "item": fill}));
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.onefill", "item": fill}));
assert_eq!(drawn(&s).pixel(50, 50), GREEN, "the other fill paints over the replaced one");
let st = s.doc().unwrap();
let art = vectorcraft_render::effects::reshape(Some(&st.doc), st.doc.node(id).unwrap(), None).unwrap();
let mut paints = vec![];
art.walk(&mut |n| {
if n.path_data().is_some() {
paints.push(n.appearance.items.iter().filter(|i| i.is_fill()).map(|i| i.paint().clone()).collect::<Vec<_>>());
}
});
let fill = |hex: &str| Paint::solid(Color::from_hex(hex).unwrap());
assert!(paints.contains(&vec![fill("#0000ff"), fill("#00ff00")]), "{paints:?}");
}
/// A Transform effect before a painted effect on a fill moves the fill's placed gradient: a piece
/// that keeps the fill, and the fill drawn on the moved shape after a refused result, both have the
/// gradient at its moved position.
#[test]
fn a_painted_effect_on_a_fill_keeps_a_placed_gradient_with_its_moved_shape() {
let mut gone = solid("#00ff00");
gone["swatch"] = json!("Not a swatch");
for (name, out, want) in [
("keeps", json!({"objects": [{"path": square_at(40.0)}]}).to_string(), square_at(40.0).bounds()),
("refused", json!({"objects": [{"path": square_at(40.0), "fills": [gone]}]}).to_string(), square_at(110.0).bounds()),
] {
let mut s = session();
let id = NodeId(run(&mut s, "shape.rectangle", json!({"x": 10, "y": 40, "width": 20, "height": 20}))["id"].as_u64().unwrap());
run(&mut s, "paint.setStroke", json!({"ids": [id.0], "none": true}));
let stops = json!([{"offset": 0, "color": "#ffffff"}, {"offset": 1, "color": "#000000"}]);
run(&mut s, "paint.setFill", json!({"ids": [id.0], "gradient": {"kind": "linear", "stops": stops}}));
run(&mut s, "select.set", json!({"ids": [id.0]}));
run(&mut s, "paint.setGradientGeom", json!({"start": [14, 50], "end": [26, 50], "stroke": false}));
let plugin = format!("test.engine.carry{name}");
install_wat(&mut s, &tpl::returning(&painting(&plugin, true), &out));
let fill = node(&s, id).appearance.items.iter().position(|i| i.is_fill()).unwrap();
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "distort.transform", "params": {"moveH": 100}, "item": fill}));
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": format!("plugin.{plugin}"), "item": fill}));
let st = s.doc().unwrap();
let art = vectorcraft_render::effects::reshape(Some(&st.doc), st.doc.node(id).unwrap(), None).unwrap();
let mut seen = vec![];
art.walk(&mut |n| {
if let (Some(p), Some(Paint::Gradient(g))) = (n.path_data(), n.appearance.items.iter().find(|i| i.is_fill()).map(|i| i.paint())) {
seen.push((p.bounds(), g.geom.map(|g| (g.start.x, g.end.x))));
}
});
assert_eq!(seen, [(want, Some((114.0, 126.0)))], "{name}");
}
}
/// The geometry of a painted result that names a swatch counts only when the document has that
/// swatch: without a document, and with one that lacks it, the effect keeps its input.
#[test]
fn painted_geometry_is_used_only_when_its_references_are_in_the_document() {
use vectorcraft_render::effects::{GeomContext, apply_geometry_with};
let mut s = session();
let id = rect(&mut s, 10.0, "#ff0000");
install_wat(&mut s, &tpl::returning(&painting("test.engine.geomref", true), &leaf_square()));
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.geomref"}));
let n = node(&s, id);
let path = n.path_data().unwrap().clone();
let input = path.bounds().unwrap();
let geometry = |doc: Option<&vectorcraft_doc::Document>| {
apply_geometry_with(&n.appearance.effects, &path, input, &GeomContext { doc, ..GeomContext::of(&n) }).bounds()
};
assert_eq!(geometry(None), Some(input), "no document");
assert_eq!(geometry(Some(&s.doc().unwrap().doc)), Some(input), "no such swatch");
run(&mut s, "swatch.new", json!({"name": "Leaf", "color": "#00ff00"}));
assert_eq!(geometry(Some(&s.doc().unwrap().doc)), square_at(40.0).bounds(), "the piece's geometry");
}
/// Type with a painted effect, in a group with a live Pathfinder effect: the operation takes the
/// piece linked to the document's swatch.
#[test]
fn pathfinder_takes_the_swatch_linked_pieces_of_type() {
let mut s = session();
let t = NodeId(run(&mut s, "text.create", json!({"x": 10, "y": 150, "text": "Hi", "size": 20}))["id"].as_u64().unwrap());
run(&mut s, "swatch.new", json!({"name": "Leaf", "color": "#00ff00"}));
install_wat(&mut s, &tpl::returning(&painting("test.engine.typepaint", true), &leaf_square()));
run(&mut s, "effect.apply", json!({"ids": [t.0], "effect": "plugin.test.engine.typepaint"}));
run(&mut s, "select.set", json!({"ids": [t.0]}));
let g = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap());
run(&mut s, "effect.apply", json!({"ids": [g.0], "effect": "pathfinder.divide"}));
assert_eq!(drawn(&s).pixel(50, 50), GREEN);
}
/// Type whose fill has a painted effect, in a group with a geometry effect inside a live Pathfinder
/// effect: the operation takes the piece linked to the document's swatch.
#[test]
fn pathfinder_takes_swatch_linked_pieces_of_type_in_a_reshaped_group() {
let mut s = session();
let t = NodeId(run(&mut s, "text.create", json!({"x": 10, "y": 150, "text": "Hi", "size": 20}))["id"].as_u64().unwrap());
run(&mut s, "swatch.new", json!({"name": "Leaf", "color": "#00ff00"}));
install_wat(&mut s, &tpl::returning(&painting("test.engine.memberpaint", true), &leaf_square()));
run(&mut s, "select.set", json!({"ids": [t.0]}));
run(&mut s, "appearance.addFill", json!({}));
run(&mut s, "paint.setFill", json!({"color": "#ff0000", "item": 0}));
run(&mut s, "effect.apply", json!({"effect": "plugin.test.engine.memberpaint", "item": 0}));
let inner = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap());
run(&mut s, "effect.apply", json!({"ids": [inner.0], "effect": "path.offsetPath", "params": {"offset": 0}}));
let outer = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap());
run(&mut s, "effect.apply", json!({"ids": [outer.0], "effect": "pathfinder.divide"}));
assert_eq!(drawn(&s).pixel(50, 50), GREEN);
}
/// A painted effect on an image's own fill that links its piece to a swatch: hit tests and bounds
/// use the piece the canvas draws.
#[test]
fn an_image_s_painted_fill_is_hit_and_measured_as_drawn() {
let mut s = session();
let mut png = vec![];
image::RgbaImage::from_pixel(30, 30, image::Rgba([255, 0, 0, 255]))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
let r = run(&mut s, "file.place", json!({"name": "red.png", "dataBase64": vectorcraft_format::base64_encode(&png), "at": [65, 65]}));
let im = NodeId(r["ids"][0].as_u64().unwrap());
run(&mut s, "swatch.new", json!({"name": "Leaf", "color": "#00ff00"}));
let mut leaf = solid("#00ff00");
leaf["swatch"] = json!("Leaf");
let out = json!({"objects": [{"path": square_at(120.0), "fills": [leaf]}]}).to_string();
install_wat(&mut s, &tpl::returning(&painting("test.engine.imagepaint", true), &out));
run(&mut s, "select.set", json!({"ids": [im.0]}));
run(&mut s, "appearance.addFill", json!({}));
run(&mut s, "paint.setFill", json!({"color": "#ff0000", "item": 0}));
run(&mut s, "effect.apply", json!({"effect": "convertToShape.rectangle", "item": 0}));
run(&mut s, "effect.apply", json!({"effect": "plugin.test.engine.imagepaint", "item": 0}));
assert_eq!(drawn(&s).pixel(130, 50), GREEN, "the canvas draws the piece");
let doc = &s.doc().unwrap().doc;
assert!(vectorcraft_doc::hit::hit_test(doc, Point::new(130.0, 50.0), Default::default()).is_some(), "a click on the piece hits the image");
let boxes = doc.symbol_boxes();
assert!(doc.node(im).unwrap().reach_bounds_with(&boxes).unwrap().x1 >= 140.0, "the bounds take in the piece");
}
/// A path with a painted effect takes part in a Pathfinder operation as its pieces, each with its
/// own paint and the path's opacity: Divide on a group holding it makes one face per piece,
/// painted as the piece is.
#[test]
fn a_path_with_a_painted_effect_takes_part_in_pathfinder_as_its_pieces() {
let mut s = session();
let id = rect(&mut s, 10.0, "#ff0000");
let out = json!({"objects": [{"path": square_at(40.0), "fills": [solid("#00ff00")]}, {"path": square_at(80.0), "fills": [solid("#0000ff")]}]})
.to_string();
install_wat(&mut s, &tpl::returning(&painting("test.engine.twopaint", true), &out));
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.twopaint"}));
run(&mut s, "select.set", json!({"ids": [id.0]}));
let g = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap());
run(&mut s, "effect.apply", json!({"ids": [g.0], "effect": "pathfinder.divide"}));
let img = drawn(&s);
assert_eq!((img.pixel(50, 50), img.pixel(90, 50), img.pixel(15, 15)), (GREEN, BLUE, WHITE), "one face per piece");
run(&mut s, "select.set", json!({"ids": [id.0]}));
run(&mut s, "transparency.set", json!({"item": null, "opacity": 50}));
let st = s.doc().unwrap();
let hook = |c: &vectorcraft_doc::Node| vectorcraft_render::effects::outline_object(Some(&st.doc), c);
let faces = vectorcraft_render::effects::pathfinder_children(Some(&st.doc), st.doc.node(g).unwrap(), Some(&hook)).unwrap();
let faces: Vec<_> = faces.iter().map(|f| (f.appearance.fill().map(|f| f.paint.clone()), f.opacity)).collect();
let fill = |hex: &str| Some(Paint::solid(Color::from_hex(hex).unwrap()));
assert_eq!(faces, [(fill("#0000ff"), 0.5), (fill("#00ff00"), 0.5)]);
}
/// Art a renderer keeps for an object with a painted effect is made again when a swatch its pieces
/// name leaves the document (the object is then drawn as if the effect weren't there) and when
/// Undo brings the swatch back.
#[test]
fn kept_art_follows_the_swatches_painted_pieces_name() {
let mut s = session();
let id = rect(&mut s, 10.0, "#ff0000");
run(&mut s, "swatch.new", json!({"name": "Leaf", "color": "#00ff00"}));
install_wat(&mut s, &tpl::returning(&painting("test.engine.keptpaint", true), &leaf_square()));
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.keptpaint"}));
let mut r = vectorcraft_render::Renderer::new();
let opts = vectorcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let mut draw = |s: &Session| {
let img = r.render(&s.doc().unwrap().doc, 200, 200, vectorcraft_geom::Affine::IDENTITY, &opts);
(img.pixel(50, 50), img.pixel(15, 15))
};
assert_eq!(draw(&s), (GREEN, WHITE), "the piece, green");
run(&mut s, "swatch.delete", json!({"name": "Leaf"}));
assert_eq!(draw(&s), (WHITE, RED), "without the swatch, the object as it is");
run(&mut s, "edit.undo", json!({}));
assert_eq!(draw(&s), (GREEN, WHITE), "the swatch back, the piece again");
}
/// A piece linked to a global swatch shows the swatch's edited color, in art a renderer keeps and
/// in a kept raster of a blur over it, and its earlier color after Undo.
#[test]
fn a_piece_linked_to_a_global_swatch_takes_its_edited_color() {
for blur in [false, true] {
let mut s = session();
let id = rect(&mut s, 10.0, "#ff0000");
install_wat(&mut s, &tpl::returning(&painting("test.engine.globalleaf", true), &leaf_square()));
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.globalleaf"}));
if blur {
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "blur.gaussian", "params": {"radius": 1}}));
}
run(&mut s, "select.none", json!({}));
run(&mut s, "swatch.new", json!({"name": "Leaf", "color": "#00ff00", "global": true}));
let mut r = vectorcraft_render::Renderer::new();
let opts = vectorcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let mut at_piece = |s: &Session| r.render(&s.doc().unwrap().doc, 200, 200, vectorcraft_geom::Affine::IDENTITY, &opts).pixel(50, 50);
assert!(near(at_piece(&s), GREEN), "the piece, in the swatch's color (blur {blur})");
run(&mut s, "swatch.edit", json!({"name": "Leaf", "color": "#0000ff"}));
let px = at_piece(&s);
assert!(near(px, BLUE), "the swatch's new color (blur {blur}): {px:?}");
run(&mut s, "edit.undo", json!({}));
assert!(near(at_piece(&s), GREEN), "the swatch's color after Undo (blur {blur})");
}
}
/// Art a renderer keeps for an object whose plug-in starts to paint its pieces when it is replaced
/// follows the swatches those pieces name from then on.
#[test]
fn kept_art_follows_the_swatches_once_a_replaced_plugin_paints() {
let mut s = session();
let id = rect(&mut s, 10.0, "#ff0000");
run(&mut s, "swatch.new", json!({"name": "Leaf", "color": "#00ff00"}));
install_wat(&mut s, &tpl::returning(&painting("test.engine.laterpaint", false), &leaf_square()));
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.laterpaint"}));
let mut r = vectorcraft_render::Renderer::new();
let opts = vectorcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let mut draw = |s: &Session| {
let img = r.render(&s.doc().unwrap().doc, 200, 200, vectorcraft_geom::Affine::IDENTITY, &opts);
(img.pixel(50, 50), img.pixel(15, 15))
};
assert_eq!(draw(&s), (RED, WHITE), "the square in the object's paint");
install_wat(&mut s, &tpl::returning(&painting("test.engine.laterpaint", true), &leaf_square()));
assert_eq!(draw(&s), (GREEN, WHITE), "the piece, green");
run(&mut s, "swatch.delete", json!({"name": "Leaf"}));
assert_eq!(draw(&s), (WHITE, RED), "without the swatch, the object as it is");
}
/// Whether `px` is within 40 levels of `want` on every channel. A blur leaves the middle of a
/// shape close to its color.
fn near(px: [u8; 4], want: [u8; 4]) -> bool {
px.iter().zip(want).all(|(a, b)| a.abs_diff(b) <= 40)
}
/// A kept raster of a blur over a painted effect is made again when a swatch its piece names
/// leaves the document (the object is then drawn as if the effect weren't there) and when Undo
/// brings the swatch back.
#[test]
fn a_kept_raster_follows_the_swatches_painted_pieces_name() {
let mut s = session();
let id = rect(&mut s, 10.0, "#ff0000");
run(&mut s, "swatch.new", json!({"name": "Leaf", "color": "#00ff00"}));
install_wat(&mut s, &tpl::returning(&painting("test.engine.blurpaint", true), &leaf_square()));
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.blurpaint"}));
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "blur.gaussian", "params": {"radius": 1}}));
let mut r = vectorcraft_render::Renderer::new();
let opts = vectorcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let mut draw = |s: &Session| r.render(&s.doc().unwrap().doc, 200, 200, vectorcraft_geom::Affine::IDENTITY, &opts);
let img = draw(&s);
assert!(near(img.pixel(50, 50), GREEN) && near(img.pixel(15, 15), WHITE), "the piece, green and blurred");
run(&mut s, "swatch.delete", json!({"name": "Leaf"}));
let img = draw(&s);
assert!(near(img.pixel(50, 50), WHITE) && near(img.pixel(15, 15), RED), "without the swatch, the object as it is: {:?}", img.pixel(50, 50));
run(&mut s, "edit.undo", json!({}));
let img = draw(&s);
assert!(near(img.pixel(50, 50), GREEN) && near(img.pixel(15, 15), WHITE), "the swatch back, the piece again");
}
/// A kept raster of a blur over a painted effect is made again when the tile of a pattern its
/// piece names changes and the pattern keeps its name.
#[test]
fn a_kept_raster_follows_the_tiles_of_patterns_painted_pieces_name() {
let mut s = session();
let id = rect(&mut s, 10.0, "#ff0000");
let green = rect(&mut s, 300.0, "#00ff00");
let blue = rect(&mut s, 320.0, "#0000ff");
let tile = |r: f64, art: vectorcraft_doc::Node| vectorcraft_doc::pattern::PatternDef {
tile: vectorcraft_geom::Rect::new(r, 10.0, r + 10.0, 20.0),
..vectorcraft_doc::pattern::PatternDef::new("Tiles", vec![std::sync::Arc::new(art)])
};
let (first, second) = (tile(300.0, node(&s, green)), tile(320.0, node(&s, blue)));
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).patterns.push(first);
let paint = serde_json::to_value(Paint::Pattern { pattern: "Tiles".into(), xf: vectorcraft_geom::Affine::IDENTITY }).unwrap();
let out = json!({"objects": [{"path": square_at(40.0), "fills": [paint]}]}).to_string();
install_wat(&mut s, &tpl::returning(&painting("test.engine.tilepaint", true), &out));
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.tilepaint"}));
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "blur.gaussian", "params": {"radius": 1}}));
let mut r = vectorcraft_render::Renderer::new();
let opts = vectorcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let mut draw = |s: &Session| r.render(&s.doc().unwrap().doc, 200, 200, vectorcraft_geom::Affine::IDENTITY, &opts).pixel(50, 50);
assert!(near(draw(&s), GREEN), "the piece in the green tile");
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).patterns[0] = second;
let px = draw(&s);
assert!(near(px, BLUE), "the piece in the new tile: {px:?}");
}
/// Every painted piece counts against the effects' budget, however small its path: two painted
/// effects in a stack that each return 350 tiny triangles make no more than 100,000 objects, not
/// 350 × 350 of them.
#[test]
fn painted_pieces_keep_to_the_effects_budget() {
let mut s = session();
let id = rect(&mut s, 10.0, "#ff0000");
let tiny = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(0.1, 0.0), Point::new(0.0, 0.1)], true));
let out = json!({"objects": vec![json!({"path": tiny}); 350]}).to_string();
install_wat(&mut s, &tpl::returning(&painting("test.engine.many", true), &out));
for _ in 0..2 {
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.many"}));
}
let st = s.doc().unwrap();
let art = vectorcraft_render::effects::reshape(Some(&st.doc), st.doc.node(id).unwrap(), None).unwrap();
let mut paths = 0;
art.walk(&mut |n| paths += usize::from(matches!(n.kind, vectorcraft_doc::NodeKind::Path { .. })));
assert!((350..=100_000).contains(&paths), "{paths} paths");
}
/// `CRAFT_PLUGIN_PAINTS_WASM=<folder>` saves a test live effect to try in the app
/// (`stroke-width-test-effect.wasm`): it draws a triangle over the area 100,80–300,230 while its
/// object's stroke is 5 pt wide, and hands the geometry back otherwise.
+209
View File
@@ -630,6 +630,215 @@ fn model_param_must_be_a_string_when_given() {
assert_eq!(fill(&s, art).to_hex(), "#1a1a1a", "nothing changed");
}
/// Sets the Layers panel's lock and visibility of `ids` (objects or layers).
fn set_props(s: &mut Session, ids: &[NodeId], props: Value) {
let mut p = props;
p["ids"] = json!(ids.iter().map(|i| i.0).collect::<Vec<_>>());
run(s, "layer.setProps", p);
}
#[test]
fn recolor_of_everything_counts_the_locked_and_hidden_objects_it_left_out() {
let mut s = session();
let open = rect(&mut s, json!("#ff0000"));
let locked = rect(&mut s, json!("#ff0000"));
let hidden = rect(&mut s, json!("#ff0000"));
let both = rect(&mut s, json!("#ff0000"));
// A locked object the map leaves as it is was not left out of anything.
let other = rect(&mut s, json!("#00ff00"));
set_props(&mut s, &[locked, other], json!({"locked": true}));
set_props(&mut s, &[hidden], json!({"visible": false}));
set_props(&mut s, &[both], json!({"visible": false, "locked": true}));
// The objects of a locked layer count as locked; a template layer's art is not counted.
let layer = NodeId(run(&mut s, "layer.new", json!({}))["id"].as_u64().unwrap());
let on_locked_layer = rect(&mut s, json!("#ff0000"));
set_props(&mut s, &[layer], json!({"locked": true}));
let template = NodeId(run(&mut s, "layer.new", json!({}))["id"].as_u64().unwrap());
rect(&mut s, json!("#ff0000"));
set_props(&mut s, &[template], json!({"template": true}));
select_all(&mut s);
let r = run(&mut s, "recolor.apply", json!({"map": {"#ff0000": "#0000ff"}}));
assert_eq!(r["notChanged"], json!({"locked": 2, "hidden": 2}), "{r}");
assert_eq!(r["warning"], "Recolor completed; 2 locked objects and 2 hidden objects were left unchanged");
assert_eq!(fill(&s, open).to_hex(), "#0000ff");
for id in [locked, hidden, both, on_locked_layer] {
assert_eq!(fill(&s, id).to_hex(), "#ff0000", "locked and hidden art keeps its colors");
}
}
#[test]
fn recolor_of_everything_counts_locked_art_that_only_loses_its_group_swatch_link() {
let mut s = session();
run(&mut s, "swatch.newGroup", json!({"name": "Brand", "colors": ["#2878f0", "#ff0000"]}));
let names: Vec<String> =
s.doc().unwrap().doc.swatch_groups.iter().find(|g| g.name == "Brand").unwrap().swatches.iter().map(|w| w.name.clone()).collect();
for name in &names {
run(&mut s, "swatch.edit", json!({"name": name, "global": true}));
}
let open = rect(&mut s, json!("#000000"));
let locked = rect(&mut s, json!("#000000"));
run(&mut s, "paint.setFill", json!({"ids": [open.0, locked.0], "swatch": names[1]}));
set_props(&mut s, &[locked], json!({"locked": true}));
select_all(&mut s);
// The group is rewritten with one color, so its second swatch goes and the locked art only
// loses its link to it.
let r = run(&mut s, "recolor.apply", json!({"map": {"#ff0000": "#00ff00"}, "group": "Brand", "groupColors": ["#123456"]}));
assert_eq!(fill(&s, open).to_hex(), "#00ff00");
assert_eq!(fill(&s, locked).to_hex(), "#ff0000", "the locked art keeps its color");
assert_eq!(r["notChanged"], json!({"locked": 1, "hidden": 0}), "{r}");
}
#[test]
fn recolor_of_everything_does_not_count_locked_art_whose_pattern_the_group_rewrite_recolors() {
let mut s = session();
run(&mut s, "swatch.newGroup", json!({"name": "Brand", "colors": ["#2878f0", "#ff0000"]}));
let names: Vec<String> =
s.doc().unwrap().doc.swatch_groups.iter().find(|g| g.name == "Brand").unwrap().swatches.iter().map(|w| w.name.clone()).collect();
for name in &names {
run(&mut s, "swatch.edit", json!({"name": name, "global": true}));
}
// "Dots": a tile linked to the group's second swatch.
let tile = rect(&mut s, json!("#000000"));
run(&mut s, "paint.setFill", json!({"ids": [tile.0], "swatch": names[1]}));
run(&mut s, "select.set", json!({"ids": [tile.0]}));
run(&mut s, "object.pattern.make", json!({"name": "Dots", "width": 20, "height": 20}));
run(&mut s, "object.pattern.done", json!({}));
let tile_hex = |s: &Session| s.doc().unwrap().doc.pattern("Dots").unwrap().art[0].appearance.fill_paint().color().unwrap().to_hex();
assert_eq!(tile_hex(&s), "#ff0000");
let locked = rect(&mut s, json!("#000000"));
run(&mut s, "paint.setFill", json!({"ids": [locked.0], "swatch": "Dots"}));
set_props(&mut s, &[locked], json!({"locked": true}));
let open = rect(&mut s, json!("#000000"));
run(&mut s, "paint.setFill", json!({"ids": [open.0], "swatch": names[1]}));
select_all(&mut s);
// The group's second swatch is rewritten with the row's new color, and the tile follows it.
let r = run(&mut s, "recolor.apply", json!({"map": {"#ff0000": "#00ff00"}, "group": "Brand", "groupColors": ["#2878f0", "#00ff00"]}));
assert_eq!(tile_hex(&s), "#00ff00", "{r}");
assert_eq!(r["notChanged"], json!({"locked": 0, "hidden": 0}), "{r}");
}
#[test]
fn recolor_of_everything_does_not_count_locked_art_whose_nested_pattern_the_group_rewrite_recolors() {
let mut s = session();
run(&mut s, "swatch.newGroup", json!({"name": "Brand", "colors": ["#2878f0", "#ff0000"]}));
let names: Vec<String> =
s.doc().unwrap().doc.swatch_groups.iter().find(|g| g.name == "Brand").unwrap().swatches.iter().map(|w| w.name.clone()).collect();
for name in &names {
run(&mut s, "swatch.edit", json!({"name": name, "global": true}));
}
// "Dots": a tile linked to the group's second swatch; "Rings": a tile filled with "Dots".
let tile = rect(&mut s, json!("#000000"));
run(&mut s, "paint.setFill", json!({"ids": [tile.0], "swatch": names[1]}));
run(&mut s, "select.set", json!({"ids": [tile.0]}));
run(&mut s, "object.pattern.make", json!({"name": "Dots", "width": 20, "height": 20}));
run(&mut s, "object.pattern.done", json!({}));
let outer = rect(&mut s, json!("#000000"));
run(&mut s, "paint.setFill", json!({"ids": [outer.0], "swatch": "Dots"}));
run(&mut s, "select.set", json!({"ids": [outer.0]}));
run(&mut s, "object.pattern.make", json!({"name": "Rings", "width": 40, "height": 40}));
run(&mut s, "object.pattern.done", json!({}));
let tile_hex = |s: &Session| s.doc().unwrap().doc.pattern("Dots").unwrap().art[0].appearance.fill_paint().color().unwrap().to_hex();
let locked = rect(&mut s, json!("#000000"));
run(&mut s, "paint.setFill", json!({"ids": [locked.0], "swatch": "Rings"}));
set_props(&mut s, &[locked], json!({"locked": true}));
let open = rect(&mut s, json!("#000000"));
run(&mut s, "paint.setFill", json!({"ids": [open.0], "swatch": names[1]}));
select_all(&mut s);
// The rewrite recolors the inner tile, which the locked art shows through "Rings".
let r = run(&mut s, "recolor.apply", json!({"map": {"#ff0000": "#00ff00"}, "group": "Brand", "groupColors": ["#2878f0", "#00ff00"]}));
assert_eq!(tile_hex(&s), "#00ff00", "{r}");
assert_eq!(r["notChanged"], json!({"locked": 0, "hidden": 0}), "{r}");
}
#[test]
fn recolor_of_everything_does_not_count_locked_art_that_would_only_take_a_swatch_link() {
let mut s = session();
run(&mut s, "swatch.new", json!({"name": "Red", "color": "#ff0000", "global": true}));
// "Dots": an unlinked red tile.
let tile = rect(&mut s, json!("#ff0000"));
run(&mut s, "select.set", json!({"ids": [tile.0]}));
run(&mut s, "object.pattern.make", json!({"name": "Dots", "width": 20, "height": 20}));
run(&mut s, "object.pattern.done", json!({}));
let open = rect(&mut s, json!("#ff0000"));
let locked = rect(&mut s, json!("#ff0000"));
let dots = rect(&mut s, json!("#000000"));
run(&mut s, "paint.setFill", json!({"ids": [dots.0], "swatch": "Dots"}));
set_props(&mut s, &[locked, dots], json!({"locked": true}));
select_all(&mut s);
// The row keeps the color and snaps it to the document's global swatch of the same color, so
// the open art only takes the link.
let r = run(&mut s, "recolor.apply", json!({"map": {"#ff0000": "#ff0000"}, "limitTo": "document"}));
let link = |s: &Session, id: NodeId| match s.doc().unwrap().doc.node(id).unwrap().appearance.fill_paint() {
vectorcraft_color::Paint::Solid { swatch, .. } => swatch,
p => panic!("{p:?}"),
};
assert!(link(&s, open).is_some(), "{r}");
assert_eq!(fill(&s, locked).to_hex(), "#ff0000");
assert_eq!(r["notChanged"], json!({"locked": 0, "hidden": 0}), "{r}");
assert!(r.get("warning").is_none(), "{r}");
}
#[test]
fn recolor_of_everything_does_not_count_locked_art_whose_nested_pattern_would_only_take_a_swatch_link() {
let mut s = session();
run(&mut s, "swatch.new", json!({"name": "Red", "color": "#ff0000", "global": true}));
// "Dots": an unlinked red tile; "Rings": a tile filled with "Dots".
let tile = rect(&mut s, json!("#ff0000"));
run(&mut s, "select.set", json!({"ids": [tile.0]}));
run(&mut s, "object.pattern.make", json!({"name": "Dots", "width": 20, "height": 20}));
run(&mut s, "object.pattern.done", json!({}));
let outer = rect(&mut s, json!("#000000"));
run(&mut s, "paint.setFill", json!({"ids": [outer.0], "swatch": "Dots"}));
run(&mut s, "select.set", json!({"ids": [outer.0]}));
run(&mut s, "object.pattern.make", json!({"name": "Rings", "width": 40, "height": 40}));
run(&mut s, "object.pattern.done", json!({}));
rect(&mut s, json!("#ff0000"));
let rings = rect(&mut s, json!("#000000"));
run(&mut s, "paint.setFill", json!({"ids": [rings.0], "swatch": "Rings"}));
set_props(&mut s, &[rings], json!({"locked": true}));
select_all(&mut s);
// The inner tile would only take the link to the document's red swatch.
let r = run(&mut s, "recolor.apply", json!({"map": {"#ff0000": "#ff0000"}, "limitTo": "document"}));
assert_eq!(r["notChanged"], json!({"locked": 0, "hidden": 0}), "{r}");
assert!(r.get("warning").is_none(), "{r}");
}
#[test]
fn locked_and_hidden_members_of_a_selected_group_are_recolored_and_not_counted() {
let mut s = session();
let ids = [rect(&mut s, json!("#ff0000")), rect(&mut s, json!("#ff0000")), rect(&mut s, json!("#ff0000"))];
select_all(&mut s);
run(&mut s, "object.group", json!({}));
set_props(&mut s, &ids[1..2], json!({"locked": true}));
set_props(&mut s, &ids[2..3], json!({"visible": false}));
let node = |s: &Session, id: NodeId| s.doc().unwrap().doc.node(id).unwrap().clone();
assert!(node(&s, ids[1]).locked && !node(&s, ids[2]).visible);
select_all(&mut s);
let r = run(&mut s, "recolor.apply", json!({"map": {"#ff0000": "#0000ff"}}));
assert_eq!(r["notChanged"], json!({"locked": 0, "hidden": 0}), "{r}");
assert!(r.get("warning").is_none(), "{r}");
assert!(ids.iter().all(|id| fill(&s, *id).to_hex() == "#0000ff"));
}
#[test]
fn recolor_of_part_of_the_art_counts_nothing_left_out() {
let mut s = session();
let picked = rect(&mut s, json!("#ff0000"));
let locked = rect(&mut s, json!("#ff0000"));
rect(&mut s, json!("#ff0000"));
set_props(&mut s, &[locked], json!({"locked": true}));
run(&mut s, "select.set", json!({"ids": [picked.0]}));
let r = run(&mut s, "recolor.apply", json!({"map": {"#ff0000": "#0000ff"}}));
assert_eq!(r["notChanged"], json!({"locked": 0, "hidden": 0}), "{r}");
assert!(r.get("warning").is_none(), "{r}");
// Everything selected, with nothing locked or hidden.
run(&mut s, "object.unlockAll", json!({}));
select_all(&mut s);
let r = run(&mut s, "recolor.apply", json!({"map": {"#ff0000": "#00ff00"}}));
assert_eq!(r["notChanged"], json!({"locked": 0, "hidden": 0}), "{r}");
assert!(r.get("warning").is_none(), "{r}");
}
/// A row's own `model` takes the place of the command's for that row's colors; a bad one fails
/// before editing; `recolor.randomize` moves it with its new color.
#[test]
+27
View File
@@ -241,6 +241,33 @@ fn options_are_saved_with_the_preferences() {
assert_eq!(t.prefs.tool_settings["polygon"]["sides"], json!(9));
}
/// Escape on a polygon or rounded rectangle drag puts back the side count or corner radius its
/// arrows changed, in the saved options and the Corner Radius preference too.
#[test]
fn escape_on_a_shape_drag_saves_the_option_it_put_back() {
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
let mut s = session();
let v = ViewInfo { smart_guides: false, ..Default::default() };
let drag = |s: &mut Session| {
s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 100.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 200.0, 180.0), v).unwrap();
s.tool_key(ToolKey::Up, Mods::default(), v).unwrap();
// Saves every option, the one the arrow changed included.
s.set_tool_option("length", &json!(50));
};
select(&mut s, "polygon");
drag(&mut s);
assert_eq!(s.prefs.tool_settings["polygon"]["sides"], json!(7));
s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap();
assert_eq!(s.prefs.tool_settings["polygon"]["sides"], json!(6));
select(&mut s, "roundedRectangle");
let radius = s.prefs.corner_radius;
drag(&mut s);
assert_eq!(s.prefs.corner_radius, radius + 1.0);
s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap();
assert_eq!(s.prefs.corner_radius, radius);
}
/// The Fidelity an Alt smoothing drag uses on a selected path.
fn alt_smooth_fidelity(s: &mut Session) -> Option<f64> {
use vectorcraft_tools::{Action, Mods, PointerEvent, PointerKind};
+91 -7
View File
@@ -516,7 +516,7 @@ fn unbind_reports_what_it_actually_removed() {
assert!(v["variables"].get("bindings").is_none_or(|v| v == &json!({})), "{v}");
}
/// Locked art is the document's to rewrite only while it isn't locked.
/// Locked type takes no new text variable binding, and a row leaves its text alone.
#[test]
fn locked_art_is_neither_bound_nor_rewritten() {
let mut s = session();
@@ -634,6 +634,90 @@ fn binding_errors_say_whether_a_layer_or_group_is_hidden_or_locked() {
assert!(e.contains(&format!("node {} is locked", t.0)), "{e}");
}
/// A visibility variable binds a locked layer and a locked object inside a locked group, and a row
/// hides and shows them in one undo step without unlocking them. List, Capture and Update Data Set
/// read whether they show.
#[test]
fn a_visibility_variable_shows_and_hides_locked_art() {
let mut s = session();
let badge = rect(&mut s);
let other = rect(&mut s);
let g = group(&mut s, &[badge, other]);
let layer = NodeId(s.execute("layer.new", &json!({})).unwrap()["id"].as_u64().unwrap());
rect(&mut s);
s.execute("object.setProps", &json!({"ids": [badge.0], "locked": true})).unwrap();
s.execute("object.setProps", &json!({"ids": [g.0], "locked": true})).unwrap();
s.execute("layer.setProps", &json!({"id": layer.0, "locked": true})).unwrap();
s.execute("variable.define", &json!({"name": "Layer", "kind": "visibility"})).unwrap();
s.execute("variable.define", &json!({"name": "Badge", "kind": "visibility"})).unwrap();
s.execute("variable.bind", &json!({"variable": "Layer", "ids": [layer.0]})).unwrap();
s.execute("variable.bind", &json!({"variable": "Badge", "ids": [badge.0]})).unwrap();
s.execute("dataset.new", &json!({"name": "Shown", "values": {"Layer": true, "Badge": true}})).unwrap();
s.execute("dataset.new", &json!({"name": "Hidden", "values": {"Layer": false, "Badge": false}})).unwrap();
let shows = |s: &Session, id: NodeId| s.doc().unwrap().doc.node(id).unwrap().visible;
let locked = |s: &Session| [layer, g, badge].iter().all(|id| s.doc().unwrap().doc.node(*id).unwrap().locked);
let v = s.execute("dataset.select", &json!({"name": "Hidden"})).unwrap();
assert_eq!((v["applied"].as_u64(), v["skipped"].as_u64()), (Some(2), Some(0)), "{v}");
assert!(!shows(&s, layer) && !shows(&s, badge), "the row hides the locked layer and the locked object");
assert!(locked(&s), "the row leaves the locks as they were");
let sets = s.execute("dataset.list", &json!({})).unwrap();
let matching: Vec<&Value> = sets["datasets"].as_array().unwrap().iter().filter(|d| d["matches"] == json!(true)).map(|d| &d["name"]).collect();
assert_eq!(matching, [&json!("Hidden")], "List reads the locked art: {sets}");
assert_eq!(s.execute("dataset.capture", &json!({"name": "Captured"})).unwrap()["values"], 2);
let sets = s.execute("dataset.list", &json!({})).unwrap();
assert_eq!(sets["datasets"][2]["values"], json!({"Badge": {"visible": false}, "Layer": {"visible": false}}), "{sets}");
// Undo puts back what the row hid.
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert!(shows(&s, layer) && shows(&s, badge), "undo shows them again");
let v = s.execute("dataset.select", &json!({"name": "Hidden"})).unwrap();
assert_eq!(v["applied"], 2, "{v}");
let v = s.execute("dataset.select", &json!({"name": "Shown"})).unwrap();
assert_eq!((v["applied"].as_u64(), v["skipped"].as_u64()), (Some(2), Some(0)), "{v}");
assert!(shows(&s, layer) && shows(&s, badge), "the row shows them");
assert!(locked(&s));
// Update writes the locked layer's visibility into the active row.
s.execute("layer.setProps", &json!({"id": layer.0, "visible": false})).unwrap();
let v = s.execute("dataset.update", &json!({})).unwrap();
assert_eq!((v["values"].clone(), v["left"].clone()), (json!(2), json!([])), "{v}");
let sets = s.execute("dataset.list", &json!({})).unwrap();
assert_eq!(sets["datasets"][0]["values"], json!({"Badge": {"visible": true}, "Layer": {"visible": false}}), "{sets}");
}
/// Text and graph variables refuse locked art and art in a locked layer. A visibility variable is
/// refused for art on a hidden layer that is also locked, and the error says the art is in a hidden
/// group or layer.
#[test]
fn locked_art_refuses_text_and_graph_variables_and_a_hidden_layer_refuses_visibility() {
let mut s = session();
let layer = NodeId(s.execute("layer.new", &json!({})).unwrap()["id"].as_u64().unwrap());
let t = text(&mut s, "x");
let r = rect(&mut s);
s.execute("object.setProps", &json!({"ids": [r.0], "locked": true})).unwrap();
s.execute("layer.setProps", &json!({"id": layer.0, "locked": true})).unwrap();
s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap();
s.execute("variable.define", &json!({"name": "Chart", "kind": "graph"})).unwrap();
s.execute("variable.define", &json!({"name": "Show", "kind": "visibility"})).unwrap();
let bind = |s: &mut Session, var: &str, id: NodeId| s.execute("variable.bind", &json!({"variable": var, "ids": [id.0]}));
let e = bind(&mut s, "Name", t).unwrap_err().to_string();
assert!(e.contains(&format!("node {} is in a locked group or layer", t.0)), "{e}");
let e = bind(&mut s, "Chart", r).unwrap_err().to_string();
assert!(e.contains(&format!("node {} is locked", r.0)), "{e}");
bind(&mut s, "Show", t).unwrap();
assert!(s.doc().unwrap().doc.variables.objects_of("Name").is_empty());
s.execute("layer.setProps", &json!({"id": layer.0, "visible": false})).unwrap();
let e = bind(&mut s, "Show", r).unwrap_err().to_string();
assert!(e.contains(&format!("node {} is in a hidden group or layer", r.0)), "{e}");
// The hidden layer's own visibility is what the variable drives.
bind(&mut s, "Show", layer).unwrap();
}
/// Make Text Dynamic / Make Visibility Dynamic: what the panel's buttons run. One step from a
/// selection to a bound variable, named after what it drives.
#[test]
@@ -848,7 +932,7 @@ fn updating_writes_only_the_variables_the_row_holds() {
assert_eq!(sets["datasets"][0]["values"]["Client"], json!({"text": "Typed by hand"}), "{sets}");
}
/// A variable whose object is locked reports no value, so Update keeps the row's value for it
/// A text variable whose object is locked reports no value, so Update keeps the row's value for it
/// rather than dropping it from the row.
#[test]
fn updating_keeps_a_value_the_art_does_not_report() {
@@ -1201,8 +1285,8 @@ fn a_hidden_object_comes_back_and_is_captured_hidden() {
}
/// A row selected while a layer is hidden rewrites the type on it and applies its other values,
/// and leaves the layer hidden: showing the layer shows that row's text. A layer that is locked
/// as well as hidden is still left alone.
/// and leaves the layer hidden: showing the layer shows that row's text. On a layer that is locked
/// as well as hidden, the type keeps its text and the row's visibility value applies.
#[test]
fn a_row_rewrites_type_on_a_hidden_layer_but_not_on_a_locked_one() {
let mut s = session();
@@ -1230,12 +1314,12 @@ fn a_row_rewrites_type_on_a_hidden_layer_but_not_on_a_locked_one() {
s.execute("layer.setProps", &json!({"id": layer.0, "visible": true})).unwrap();
assert_eq!(plain(&s, t), "Bob", "showing the layer shows the row's text");
// Locked and hidden: the row skips both objects and the type keeps its text.
// Locked and hidden: the row skips the type, which keeps its text, and shows the rectangle.
s.execute("layer.setProps", &json!({"id": layer.0, "visible": false, "locked": true})).unwrap();
let v = s.execute("dataset.select", &json!({"name": "A"})).unwrap();
assert_eq!((v["applied"].as_u64(), v["skipped"].as_u64()), (Some(0), Some(2)), "{v}");
assert_eq!((v["applied"].as_u64(), v["skipped"].as_u64()), (Some(1), Some(1)), "{v}");
assert_eq!(plain(&s, t), "Bob", "the type on the locked layer keeps its text");
assert!(!s.doc().unwrap().doc.node(r).unwrap().visible);
assert!(s.doc().unwrap().doc.node(r).unwrap().visible, "the row's visibility value applies on the locked layer");
}
/// Type inside a hidden group is rewritten too, and List and Capture Data Set read it.
+48 -13
View File
@@ -766,6 +766,11 @@ impl Session {
self.tool.transforming()
}
/// Is the active tool resizing the selection by a bounding-box handle?
pub fn tool_resizing(&self) -> bool {
self.tool.resizing()
}
/// With Real-time Drawing off, how far a Selection-tool move drag has carried the selection
/// so far: the document moves on release, and the bounding box shows at this offset meanwhile.
pub fn deferred_move(&self) -> Option<vectorcraft_geom::Vec2> {
@@ -1087,25 +1092,48 @@ impl Session {
Ok(ui)
}
/// Canvas feedback while Real-time Drawing is off: run the pending preview command once, take
/// outlines from the objects it touched, then put the document back at the interaction snapshot.
fn deferred_preview_overlays_from_command(&mut self, cmd: &str, p: &Value) -> Vec<Overlay> {
if self.deferred_overlay_cache.as_ref().is_some_and(|(c, params, _)| c == cmd && params == p) {
return self.deferred_overlay_cache.as_ref().map(|(_, _, o)| o.clone()).unwrap_or_default();
/// With Real-time Drawing off, the selection's transform box ([`Session::transform_box`]) as the
/// pending preview of a handle resize leaves it, measured once per preview step; `None` when no
/// such preview is pending, or when the preview fails.
pub fn deferred_transform_box(&mut self) -> Option<vectorcraft_doc::OrientedBox> {
if !self.tool.resizing() {
return None;
}
let (cmd, p) = self.unapplied_interaction_preview()?;
self.deferred_feedback(&cmd, &p)?.transform_box
}
fn deferred_preview_overlays_from_command(&mut self, cmd: &str, p: &Value) -> Vec<Overlay> {
self.deferred_feedback(cmd, p).map(|f| f.overlays.clone()).unwrap_or_default()
}
/// Canvas feedback while Real-time Drawing is off: run the pending preview command once, take
/// outlines from the objects it touched (and, during a handle resize, their transform box), then
/// put the document back at the interaction snapshot.
fn deferred_feedback(&mut self, cmd: &str, p: &Value) -> Option<&DeferredFeedback> {
if !self.deferred_overlay_cache.as_ref().is_some_and(|f| f.cmd == cmd && f.params == *p) {
let feedback = self.measure_deferred_preview(cmd, p)?;
self.deferred_overlay_cache = Some(feedback);
}
self.deferred_overlay_cache.as_ref()
}
fn measure_deferred_preview(&mut self, cmd: &str, p: &Value) -> Option<DeferredFeedback> {
let (snapshot, selection, revision, paint, interaction_fields) = {
let Some(st) = self.active() else { return vec![] };
let Some(it) = st.interaction.as_ref() else { return vec![] };
let st = self.active()?;
let it = st.interaction.as_ref()?;
if !std::sync::Arc::ptr_eq(&it.doc, &st.doc) {
return vec![];
return None;
}
let interaction_fields = (it.preview.clone(), it.copies.clone(), it.perspective_again.clone());
(st.doc.clone(), st.selection.clone(), st.revision, self.pending_paint.clone(), interaction_fields)
};
let overlays = if self.preview(cmd, p).is_ok() {
self.active().map(|st| outlines_for_selection(&st.doc, &st.selection)).unwrap_or_default()
let (overlays, transform_box) = if self.preview(cmd, p).is_ok() {
let overlays = self.active().map(|st| outlines_for_selection(&st.doc, &st.selection)).unwrap_or_default();
let objects = self.active().map(|st| st.selection.objects.clone()).unwrap_or_default();
(overlays, if self.tool.resizing() { self.transform_box(&objects) } else { None })
} else {
vec![]
(vec![], None)
};
if let Ok(st) = self.doc_mut() {
st.doc = snapshot;
@@ -1118,11 +1146,18 @@ impl Session {
}
}
self.pending_paint = paint;
self.deferred_overlay_cache = Some((cmd.to_string(), p.clone(), overlays.clone()));
overlays
Some(DeferredFeedback { cmd: cmd.to_string(), params: p.clone(), overlays, transform_box })
}
}
/// What the canvas shows for a deferred preview: see [`Session::deferred_transform_box`].
pub(crate) struct DeferredFeedback {
cmd: String,
params: Value,
overlays: Vec<Overlay>,
transform_box: Option<vectorcraft_doc::OrientedBox>,
}
/// The most points the outlines of [`outlines_for_selection`] are drawn for, the same number as the
/// canvas's limit on the anchor points it outlines (`OVERLAY_MAX_ANCHORS` in
/// `crates/ui-egui/src/canvas/outline.rs`). Past it the selection's bounds are drawn instead.
+36
View File
@@ -347,6 +347,42 @@ fn a_freeform_gradient_expands_with_its_rgb_values_in_the_working_rgb_space() {
}
}
/// With a working RGB space other than sRGB, RGB freeform points mix in that space on the canvas
/// and in a writer, as Object > Expand mixes them.
#[test]
fn freeform_rgb_points_mix_in_the_working_rgb_space() {
use vectorcraft_color::freeform::spread_scale;
use vectorcraft_color::{Color, Freeform, FreeformPoint, Gradient, GradientKind, GradientPaint, Paint};
use vectorcraft_doc::{Appearance, Document, Node};
use vectorcraft_geom::{Point, Rect, shapes};
let _g = settings();
let old = cms::active_settings();
cms::set_active(&cms::ColorSettings { rgb: cms::WIDE_GAMUT_RGB.into(), ..old.clone() }).unwrap();
let box_ = Rect::new(0.0, 0.0, 100.0, 100.0);
let (a, b) = (Color::rgb(0.9, 0.1, 0.1), Color::rgb(0.1, 0.2, 0.9));
let p = |x, c| FreeformPoint::new(Point::new(x, 50.5), c);
let f = Freeform { points: vec![p(20.5, a), p(80.5, b)], ..Default::default() };
let mut g = GradientPaint::new(Gradient { kind: GradientKind::Freeform, ..Default::default() });
g.freeform = Some(f.clone());
let mut d = Document::new(100.0, 100.0);
let id = d.alloc_id();
d.insert(Some(d.layers[0].id), 0, Node::path(id, shapes::rectangle(box_), Appearance::basic(Paint::Gradient(Box::new(g)), Paint::None, 0.0)))
.unwrap();
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let canvas = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &opts).pixel(50, 50);
let svg = vectorcraft_svg::export_full(&d, &vectorcraft_svg::ExportOptions::default(), None).svg;
let data = svg.split("data:image/png;base64,").nth(1).unwrap().split('"').next().unwrap();
let png = image::load_from_memory(&vectorcraft_testkit::format::base64_decode(data).unwrap()).unwrap().to_rgba8();
let written = png.get_pixel(png.width() / 2, png.height() / 2).0;
let want = Color::rgb(0.5, 0.15, 0.5).to_rgba8(1.0);
let display = f.field(spread_scale(box_)).sample(Point::new(50.5, 50.5)).0.map(|v| (v * 255.0).round() as u8);
cms::set_active(&old).unwrap();
let near = |got: [u8; 4]| got.iter().zip(want).take(3).all(|(g, w)| g.abs_diff(w) <= 3);
assert!(near(canvas), "canvas: {canvas:?} vs {want:?}");
assert!(near(written), "SVG: {written:?} vs {want:?}");
assert!(display.iter().zip(want).any(|(g, w)| g.abs_diff(w) > 8), "the display mix differs: {display:?} vs {want:?}");
}
/// Loading a new file of the working RGB profile (named after its description, as before) takes
/// effect at once: CMYK colors convert into the new file and the canvas redraws, without choosing
/// the working spaces again.
+17 -1
View File
@@ -1,5 +1,5 @@
//! Untrusted files never crash the app: garbage, truncated, mutated and hostile SVG, PDF and DXF input,
//! mutated raster images (Photoshop documents among them) placed with File → Place, and swatch (`.vcswatches`, `.gpl`, `.ase`), graphic style (`.vcstyles`), flattener preset
//! mutated raster images (Photoshop documents and JPEG XL images among them) placed with File → Place, and swatch (`.vcswatches`, `.gpl`, `.ase`), graphic style (`.vcstyles`), flattener preset
//! (`.vcflattener`) and export preset (`.vcexportpresets`) libraries, shortcut sets and action sets other apps save as text (`.kys`, `.aia`), and native files (compressed, damaged, saved for older versions),
//! must load as an error or as a document that then renders and exports, without a panic; nor may
//! bitmaps, PDF and text pasted from other apps, nor EMF and WMF pictures (damaged files, records
@@ -1743,6 +1743,22 @@ fn raster_samples() -> Vec<(&'static str, Vec<u8>)> {
psb.extend((0..8).flat_map(|_| 2u32.to_be_bytes()));
psb.extend((0..8u8).flat_map(|v| [0xfe, v * 30]));
out.push(("a.psb", psb));
// JPEG XL: an 8-bit RGBA codestream, and a 16-bit gray one in the container.
use zune_core::{bit_depth::BitDepth, colorspace::ColorSpace, options::EncoderOptions};
let jxl = |space, depth, samples: &[u8]| {
let mut b = vec![];
zune_jpegxl::JxlSimpleEncoder::new(samples, EncoderOptions::new(5, 3, space, depth)).encode(&mut b).unwrap();
b
};
out.push(("a.jxl", jxl(ColorSpace::RGBA, BitDepth::Eight, &(0..60).map(|i| (i * 17) as u8).collect::<Vec<_>>())));
let gray = jxl(ColorSpace::Luma, BitDepth::Sixteen, &(0..30).map(|i| (i * 41) as u8).collect::<Vec<_>>());
let mut boxed = vec![0, 0, 0, 0x0c, b'J', b'X', b'L', b' ', 0x0d, 0x0a, 0x87, 0x0a];
boxed.extend(20u32.to_be_bytes());
boxed.extend(b"ftypjxl \0\0\0\0jxl ");
boxed.extend((8 + gray.len() as u32).to_be_bytes());
boxed.extend(b"jxlc");
boxed.extend(gray);
out.push(("b.jxl", boxed));
out
}
+26
View File
@@ -535,6 +535,32 @@ proptest! {
renders(&s, &out)?;
}
/// A live effect that declares `paintOutput` returning hostile pieces, on objects or on a fill:
/// drawing, export and Expand Appearance never crash.
#[test]
fn hostile_painted_effect_output_never_crashes(
objects in prop::collection::vec(arb_object(), 0..5),
junk in prop::option::weighted(0.1, arb_junk()),
on_fill in any::<bool>(),
) {
let mut s = rich_session();
s.execute("select.all", &json!({})).unwrap();
let out = output(&objects, &[], junk);
let manifest = r#"{"id":"fuzz.painted","name":"Fuzz","version":"1","kind":"effect","api":2,"paintOutput":true,"params":{}}"#;
install(&mut s, &wat::parse_str(tpl::returning(manifest, &out)).unwrap()).unwrap();
let item = if on_fill { json!(0) } else { Value::Null };
let r = catch_quiet(|| s.execute("effect.apply", &json!({"effect": "plugin.fuzz.painted", "item": item})).map(|_| ()));
if let Err(msg) = r {
return Err(TestCaseError::fail(format!("panicked on {out}: {msg}")));
}
renders(&s, &out)?;
let r = catch_quiet(|| s.execute("effect.expandAppearance", &json!({})).map(|_| ()));
if let Err(msg) = r {
return Err(TestCaseError::fail(format!("expanding panicked on {out}: {msg}")));
}
renders(&s, &out)?;
}
/// An importer's hostile output (`document.open`) either opens a document that renders and
/// exports, or is an error that opens nothing.
#[test]
+1 -1
View File
@@ -6426,7 +6426,7 @@ fn each_pathfinder_operation_on_a_group_draws_as_vectorcraft_draws_it() {
// Each result's box and paints; Outline's lines start where each square's path starts, so
// the results are compared in a sorted order.
let faces = |g: &Node| -> Vec<String> {
let mut faces: Vec<String> = vectorcraft_effects::pathfinder_children(g, None)
let mut faces: Vec<String> = vectorcraft_effects::pathfinder_children(None, g, None)
.unwrap()
.iter()
.map(|n| {
+57 -19
View File
@@ -1098,6 +1098,7 @@ impl<'a> Scene<'a> {
/// Paint all of `area` with an image of freeform gradient `g` placed on `bounds`, sampled at
/// the document's raster effects resolution (at most [`MAX_FIELD_PX`] a side).
fn field_image(&mut self, g: &GradientPaint, bounds: Rect, area: Rect) {
use vectorcraft_color::cms::Model;
use vectorcraft_color::freeform::{painted_box, spread_scale};
let Some(b) = painted_box(bounds) else { return };
let area = area.abs();
@@ -1108,11 +1109,24 @@ impl<'a> Scene<'a> {
let k = (self.doc.raster_effects_ppi / 72.0).min(MAX_FIELD_PX / long);
let along = |len: f64| (len * k).ceil().clamp(1.0, MAX_FIELD_PX) as u16;
let (cols, rows) = (along(area.width()), along(area.height()));
let field = g.freeform_on(b).field_with(spread_scale(b), &|c| c.to_rgb());
let field = g.freeform_on(b).model_field(spread_scale(b));
let q = |v: f32| (unit(v) * 255.0).round() as u8;
let rgba: Vec<u8> = field.grid(area, cols, rows).flat_map(|(c, a)| [q(c[0]), q(c[1]), q(c[2]), q(a)]).collect();
let (w, h) = (u32::from(cols), u32::from(rows));
let image = if field.model() == Some(Model::Cmyk) || (self.cmyk && field.model() == Some(Model::Gray)) {
let inks: Vec<u8> = field
.grid(area, cols, rows)
.flat_map(|(color, a)| {
let [c, m, y, k] = color.to_cmyk();
[q(c), q(m), q(y), q(k), q(a)]
})
.collect();
self.encode_inks(&inks, w, h)
} else {
let rgba: Vec<u8> = field.display_grid(area, cols, rows).flat_map(|(c, a)| [q(c[0]), q(c[1]), q(c[2]), q(a)]).collect();
self.encode_image(&rgba, w, h)
};
let place = Affine::translate(area.origin().to_vec2()) * Affine::scale_non_uniform(area.width(), area.height());
if let Some(image) = self.encode_image(&rgba, u32::from(cols), u32::from(rows)) {
if let Some(image) = image {
self.place_image(&image, place);
}
}
@@ -1313,41 +1327,65 @@ impl<'a> Scene<'a> {
/// Level 3 (a key colour no opaque pixel has), white at Level 2; partly transparent ones over
/// white. `None` for an empty image.
fn encode_image(&mut self, rgba: &[u8], w: u32, h: u32) -> Option<String> {
let space = if self.cmyk { Space::Cmyk } else { Space::Rgb };
let mut cache: HashMap<[u8; 3], [u8; 4]> = HashMap::new();
let cms = self.cms.clone();
let intent = cms.settings().intent;
self.encode_samples(rgba.as_chunks::<4>().0, w, h, space, |p: &[u8; 4], al| {
let rgb = [0, 1, 2].map(|i| ((u32::from(p[i]) * al + 255 * (255 - al) + 127) / 255) as u8);
match space {
Space::Cmyk => *cache
.entry(rgb)
.or_insert_with(|| cms.srgb_to_cmyk(rgb.map(|x| f32::from(x) / 255.0), intent).map(|x| (unit(x) * 255.0).round() as u8)),
_ => [rgb[0], rgb[1], rgb[2], 0],
}
})
}
/// Straight CMYK pixels with opacity (five bytes a pixel, `w` × `h`, rows from the top), drawn
/// as [`Self::encode_image`] draws its pixels and keeping their ink amounts: partly
/// transparent pixels lay down that share of each ink.
fn encode_inks(&mut self, cmyka: &[u8], w: u32, h: u32) -> Option<String> {
self.encode_samples(cmyka.as_chunks::<5>().0, w, h, Space::Cmyk, |p: &[u8; 5], al| {
[0, 1, 2, 3].map(|i| ((u32::from(p[i]) * al + 127) / 255) as u8)
})
}
/// Pixels (`w` × `h`, rows from the top, each ending in its opacity) as the operators and data
/// that draw them in `space` on the unit square. `sample` gives the samples of a pixel that
/// isn't clear, from the pixel and its opacity.
fn encode_samples<const N: usize>(
&mut self,
px: &[[u8; N]],
w: u32,
h: u32,
space: Space,
mut sample: impl FnMut(&[u8; N], u32) -> [u8; 4],
) -> Option<String> {
if w == 0 || h == 0 {
return None;
}
let px = rgba.as_chunks::<4>().0;
let clear = px.iter().any(|p| p[3] == 0);
if px.iter().any(|p| p[3] > 0 && p[3] < 255) {
let opacity = |p: &[u8; N]| p[N - 1];
let clear = px.iter().any(|p| opacity(p) == 0);
if px.iter().any(|p| opacity(p) > 0 && opacity(p) < 255) {
self.warn(IMAGE_PARTLY);
}
let keyed = clear && self.o.level == Level::Three;
if clear && !keyed {
self.warn(IMAGE_ALPHA_L2);
}
let space = if self.cmyk { Space::Cmyk } else { Space::Rgb };
let n = space.n();
// The key colour, which opaque pixels that happen to have it are nudged off.
let key: [u8; 4] = [255, 0, 255, 0];
// White: no ink, or full light.
let white: [u8; 4] = if space == Space::Cmyk { [0; 4] } else { [255; 4] };
let mut cache: HashMap<[u8; 3], [u8; 4]> = HashMap::new();
let cms = self.cms.clone();
let intent = cms.settings().intent;
let mut data = Vec::with_capacity(px.len() * n);
for p in px {
if p[3] == 0 {
if opacity(p) == 0 {
data.extend(if keyed { &key } else { &white }.iter().take(n));
continue;
}
let al = u32::from(p[3]);
let rgb = [0, 1, 2].map(|i| ((u32::from(p[i]) * al + 255 * (255 - al) + 127) / 255) as u8);
let mut v = match space {
Space::Cmyk => *cache
.entry(rgb)
.or_insert_with(|| cms.srgb_to_cmyk(rgb.map(|x| f32::from(x) / 255.0), intent).map(|x| (unit(x) * 255.0).round() as u8)),
_ => [rgb[0], rgb[1], rgb[2], 0],
};
let mut v = sample(p, u32::from(opacity(p)));
if keyed && v.iter().take(n).eq(key.iter().take(n)) {
v[1] = 1;
}
+55 -1
View File
@@ -3,7 +3,7 @@
use std::sync::Arc;
use vectorcraft_color::{Color, Gradient, GradientKind, GradientMethod, GradientPaint, GradientStop, Paint, Swatch};
use vectorcraft_color::{Color, Freeform, FreeformPoint, Gradient, GradientKind, GradientMethod, GradientPaint, GradientStop, Paint, Swatch};
use vectorcraft_doc::{Appearance, AppearanceItem, Dash, Document, ImageBlob, ImageObject, LineCap, LineJoin, Node, NodeKind};
use vectorcraft_geom::{Affine, PathData, Point, Rect, shapes};
@@ -574,6 +574,60 @@ fn image_data(page: &str) -> Vec<u8> {
ps::ascii85_decode(&page[at..end]).unwrap()
}
/// A square filled with a freeform gradient of `a` on the left and `b` on the right, with those
/// points at `opacity`, exported: (the page, the middle pixel's samples, the warnings).
fn freeform_mid(a: Color, b: Color, opacity: f32, n: usize) -> (String, Vec<u8>, Vec<String>) {
let p = |x, c| FreeformPoint { opacity, ..FreeformPoint::new(Point::new(x, 100.0), c) };
let mut g = GradientPaint::new(Gradient { kind: GradientKind::Freeform, ..Default::default() });
g.freeform = Some(Freeform { points: vec![p(40.0, a), p(160.0, b)], ..Default::default() });
let d = doc_with(|d| vec![path(d, shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 200.0)), Paint::Gradient(Box::new(g)), Paint::None, 0.0)]);
let out = export(&d, &opts(&d), None, None).unwrap();
let page = text(&out);
let size = |key: &str| {
let at = page.find(key).unwrap() + key.len();
page[at..].split(' ').next().unwrap().parse::<usize>().unwrap()
};
let (w, h) = (size("/Width "), size("/Height "));
let pixels = ps::inflate(&image_data(&page), 1 << 28).unwrap();
let at = (h / 2 * w + w / 2) * n;
(page, pixels[at..at + n].to_vec(), out.warnings)
}
#[test]
fn freeform_gradients_keep_the_inks_of_cmyk_points_and_mix_in_the_points_model() {
let (cyan, black) = (Color::cmyk(1.0, 0.0, 0.0, 0.0), Color::cmyk(0.0, 0.0, 0.0, 1.0));
let (page, mid, _) = freeform_mid(cyan, black, 1.0, 4);
assert!(page.contains("/DeviceCMYK setcolorspace"), "{page}");
assert!(mid.iter().zip([128, 0, 0, 128]).all(|(g, w): (&u8, i32)| (i32::from(*g) - w).abs() <= 6), "{mid:?}");
// A partly transparent point lays down that share of each ink.
let (_, mid, warnings) = freeform_mid(cyan, black, 0.5, 4);
assert!(mid.iter().zip([64, 0, 0, 64]).all(|(g, w): (&u8, i32)| (i32::from(*g) - w).abs() <= 6), "{mid:?}");
assert!(warnings.iter().any(|w| w.contains("partly transparent")), "{warnings:?}");
// Lab points go out as RGB, mixed first in Lab.
let (page, mid, _) = freeform_mid(Color::lab(90.0, -60.0, 40.0), Color::lab(30.0, 60.0, -40.0), 1.0, 3);
assert!(page.contains("/DeviceRGB setcolorspace"), "{page}");
let want = Color::lab(60.0, 0.0, 0.0).to_rgba8(1.0);
assert!(mid.iter().zip(want).all(|(g, w)| g.abs_diff(w) <= 6), "{mid:?} vs {want:?}");
}
#[test]
fn gray_freeform_points_are_written_as_black_ink_in_a_cmyk_document() {
let p = |x, c| FreeformPoint::new(Point::new(x, 100.0), c);
let mut g = GradientPaint::new(Gradient { kind: GradientKind::Freeform, ..Default::default() });
g.freeform = Some(Freeform { points: vec![p(40.0, Color::gray(0.2)), p(160.0, Color::gray(0.8))], ..Default::default() });
let mut d = doc_with(|d| vec![path(d, shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 200.0)), Paint::Gradient(Box::new(g)), Paint::None, 0.0)]);
d.color_mode = vectorcraft_doc::ColorMode::Cmyk;
let page = text(&export(&d, &opts(&d), None, None).unwrap());
let size = |key: &str| {
let at = page.find(key).unwrap() + key.len();
page[at..].split(' ').next().unwrap().parse::<usize>().unwrap()
};
let (w, h) = (size("/Width "), size("/Height "));
let pixels = ps::inflate(&image_data(&page), 1 << 28).unwrap();
let at = (h / 2 * w + w / 2) * 4;
assert!(pixels[at..at + 3] == [0, 0, 0] && pixels[at + 3].abs_diff(128) <= 6, "{:?}", &pixels[at..at + 4]);
}
#[test]
fn images_are_flate_with_masked_pixels_at_level_3_and_run_length_at_level_2() {
let d = png_doc();
+2 -2
View File
@@ -408,8 +408,8 @@ impl<'a> Scene<'a> {
let (cols, rows) = (along(area.width()), along(area.height()));
let pixels: Vec<u8> = if g.gradient.kind == GradientKind::Freeform {
let Some(b) = painted_box(bounds) else { return };
let field = g.freeform_on(b).field_with(spread_scale(b), &|c| c.to_rgb());
field.grid(area, cols, rows).flat_map(|(c, a)| [q(c[0]), q(c[1]), q(c[2]), q(a)]).collect()
let field = g.freeform_on(b).model_field(spread_scale(b));
field.display_grid(area, cols, rows).flat_map(|(c, a)| [q(c[0]), q(c[1]), q(c[2]), q(a)]).collect()
} else {
let geom = g.resolve(bounds);
let sample = g.gradient.sampler();
+29
View File
@@ -85,6 +85,35 @@ fn wmf_output_round_trips() {
assert!(near(ellipse.geometric_bounds().unwrap(), Rect::new(100.0, 20.0, 180.0, 80.0), 0.1));
}
#[test]
fn freeform_points_that_share_a_model_are_written_as_their_mix() {
use vectorcraft_color::{Color, Freeform, FreeformPoint, GradientKind};
let mid = |a: Color, b: Color| {
let p = |x, c| FreeformPoint::new(Point::new(x, 50.0), c);
let mut g = GradientPaint::new(Gradient { kind: GradientKind::Freeform, ..Default::default() });
g.freeform = Some(Freeform { points: vec![p(20.0, a), p(80.0, b)], ..Default::default() });
let d =
doc_with(100.0, 100.0, vec![path(shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Paint::Gradient(Box::new(g)), Paint::None, 0.0)]);
let back = import(&emf(&d).bytes).unwrap();
let im = all(&back.document)
.iter()
.find_map(|n| match &n.kind {
NodeKind::Image(im) => Some(im.clone()),
_ => None,
})
.unwrap();
let px = image::load_from_memory(&back.document.images[&im.key].bytes).unwrap().to_rgba8();
px.get_pixel(px.width() / 2, px.height() / 2).0
};
let near = |got: [u8; 4], want: [u8; 4]| got.iter().zip(want).all(|(g, w)| g.abs_diff(w) <= 6);
let got = mid(Color::cmyk(1.0, 0.0, 0.0, 0.0), Color::cmyk(0.0, 0.0, 0.0, 1.0));
let want = Color::cmyk(0.5, 0.0, 0.0, 0.5).to_rgba8(1.0);
assert!(near(got, want), "CMYK: {got:?} vs {want:?}");
let got = mid(Color::lab(90.0, -60.0, 40.0), Color::lab(30.0, 60.0, -40.0));
let want = Color::lab(60.0, 0.0, 0.0).to_rgba8(1.0);
assert!(near(got, want), "Lab: {got:?} vs {want:?}");
}
#[test]
fn dashes_gradients_and_images_round_trip_through_emf() {
let mut line = path(shapes::line((10.0, 90.0).into(), (190.0, 90.0).into()), Paint::None, red(), 2.0);
+26 -11
View File
@@ -450,6 +450,7 @@ impl Exporter<'_> {
/// [`MAX_FIELD_PX`] a side), or a linear or radial gradient (at most [`MAX_GRADIENT_PX`] a side),
/// with dither's noise when it paints a fill (`fill`).
pub(super) fn field_image(&mut self, s: &mut Surface, g: &vectorcraft_color::GradientPaint, bounds: Rect, area: Rect, fill: bool) {
use vectorcraft_color::cms::Model;
use vectorcraft_color::freeform::{painted_box, spread_scale};
let Some(b) = painted_box(bounds) else { return };
let area = area.abs();
@@ -466,18 +467,32 @@ impl Exporter<'_> {
let k = (self.doc.raster_effects_ppi / 72.0).min(max / long);
let along = |len: f64| (len * k).ceil().clamp(1.0, max) as u16;
let (cols, rows) = (along(w), along(h));
let rgba: Vec<u8> = if freeform {
let field = g.freeform_on(b).field_with(spread_scale(b), &|c| c.to_rgb());
field.grid(area, cols, rows).flat_map(|(c, a)| [q(c[0]), q(c[1]), q(c[2]), q(a)]).collect()
} else {
self.warn("linear and radial gradients drawn as pixels are exported as images");
g.pixels(b, area, cols, rows, fill, &|c| c.to_rgb()).flatten().collect()
let field = freeform.then(|| g.freeform_on(b).model_field(spread_scale(b)));
let cmyk_doc = self.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk;
let img = match &field {
Some(field) if field.model() == Some(Model::Cmyk) || (cmyk_doc && field.model() == Some(Model::Gray)) => {
let (mut inks, mut alpha) = (Vec::new(), Vec::new());
for (color, a) in field.grid(area, cols, rows) {
let [c, m, y, k] = color.to_cmyk();
inks.extend([q(c), q(m), q(y), q(k)]);
alpha.push(q(a));
}
let convert = if self.convert { self.out.cmyk_pixels() } else { None };
crate::images::from_inks(inks, alpha, cols.into(), rows.into(), self.interpolate, convert)
}
_ => {
let rgba: Vec<u8> = match &field {
Some(field) => field.display_grid(area, cols, rows).flat_map(|(c, a)| [q(c[0]), q(c[1]), q(c[2]), q(a)]).collect(),
None => {
self.warn("linear and radial gradients drawn as pixels are exported as images");
g.pixels(b, area, cols, rows, fill, &|c| c.to_rgb()).flatten().collect()
}
};
let pixels = if self.convert { self.out.pixels() } else { None };
crate::images::from_rgba_in(rgba, cols.into(), rows.into(), self.interpolate, pixels)
}
};
let pixels = if self.convert { self.out.pixels() } else { None };
let (Some(img), Some(size)) = (
crate::images::from_rgba_in(rgba, cols.into(), rows.into(), self.interpolate, pixels),
Size::from_wh(area.width() as f32, area.height() as f32),
) else {
let (Some(img), Some(size)) = (img, Size::from_wh(area.width() as f32, area.height() as f32)) else {
return;
};
s.push_transform(&xf(Affine::translate(area.origin().to_vec2())));
+16
View File
@@ -101,6 +101,22 @@ pub(crate) fn from_rgba_in(mut rgba: Vec<u8>, width: u32, height: u32, interpola
from_rgba(&rgba, width, height, interpolate, pixels)
}
/// A `width` × `height` image of CMYK ink amounts `inks` (four bytes a pixel), converted by
/// `convert` (when given), with straight `alpha` (one byte a pixel) carried only when some pixel
/// isn't opaque. `None` when the buffers don't hold that many pixels.
pub(crate) fn from_inks(inks: Vec<u8>, alpha: Vec<u8>, width: u32, height: u32, interpolate: bool, convert: Option<&CmykPixels>) -> Option<Image> {
let pixels = u64::from(width) * u64::from(height);
if pixels == 0 || inks.len() as u64 != pixels * 4 || alpha.len() as u64 != pixels {
return None;
}
let alpha = alpha.iter().any(|a| *a < 255).then_some(alpha);
let (samples, space) = match convert {
Some(p) => p.convert(&inks),
None => (inks, ImageColorspace::Cmyk),
};
lossless(samples, alpha, space, width, height, interpolate)
}
/// The kinds of images the Compression section has settings for.
#[derive(Clone, Copy, Debug, PartialEq)]
enum Kind {
+72 -2
View File
@@ -8,9 +8,10 @@ use craft_pdf::syntax::Pdf;
use craft_pdf::syntax::object::{Array, Name, Object, Stream};
use serde_json::json;
use vectorcraft_color::cms::{self, DEVICE_CMYK, GENERIC_CMYK, SRGB};
use vectorcraft_color::{Color, Freeform, FreeformPoint, Gradient, GradientKind, GradientPaint, Paint};
use vectorcraft_doc::cmyk::Inks;
use vectorcraft_doc::{ColorMode, Document, ImageBlob, ImageObject, Node, NodeKind};
use vectorcraft_geom::Affine;
use vectorcraft_doc::{Appearance, ColorMode, Document, ImageBlob, ImageObject, Node, NodeKind};
use vectorcraft_geom::{Affine, Point, Rect, shapes};
use crate::*;
@@ -471,6 +472,75 @@ fn a_pixelated_cmyk_image_with_its_grid_mask_asks_viewers_not_to_smooth_it() {
}
}
/// A 100 × 100 pt document of `mode` whose square has a freeform fill of `a` on the left and `b`
/// on the right, with those points at `opacity`.
fn freeform_doc(mode: ColorMode, a: Color, b: Color, opacity: f32) -> Document {
let p = |x, c| FreeformPoint { opacity, ..FreeformPoint::new(Point::new(x, 50.0), c) };
let mut g = GradientPaint::new(Gradient { kind: GradientKind::Freeform, ..Default::default() });
g.freeform = Some(Freeform { points: vec![p(20.0, a), p(80.0, b)], ..Default::default() });
let mut d = Document::new_with_mode(100.0, 100.0, mode);
let square = Node::path(
d.alloc_id(),
shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)),
Appearance::basic(Paint::Gradient(Box::new(g)), Paint::None, 0.0),
);
let l = d.layers[0].id;
d.insert(Some(l), 0, square).unwrap();
d
}
#[test]
fn freeform_gradients_of_cmyk_points_are_written_as_cmyk_images() {
let (cyan, black) = (Color::cmyk(1.0, 0.0, 0.0, 0.0), Color::cmyk(0.0, 0.0, 0.0, 1.0));
for mode in [ColorMode::Cmyk, ColorMode::Rgb] {
let d = freeform_doc(mode, cyan, black, 1.0);
let out = export_with_report(&d, &opts(json!({}))).unwrap();
let w = written(&out.bytes);
assert_eq!(w.len(), 1, "{mode:?}: opaque, no soft mask: {w:?}");
assert_eq!(w[0].space, "DeviceCMYK", "{mode:?}");
let back = import(&out.bytes).unwrap();
let inks = all_images(&back)[0].cmyk().unwrap();
let at = (inks.height as usize / 2 * inks.width as usize + inks.width as usize / 2) * 4;
assert!(close(&inks.data[at..at + 4], &[128, 0, 0, 128], 6), "{mode:?}: {:?}", &inks.data[at..at + 4]);
}
// Opacity goes in a soft mask, and the inks stay.
let w = written(&export_with_report(&freeform_doc(ColorMode::Cmyk, cyan, black, 0.5), &opts(json!({}))).unwrap().bytes);
assert_eq!(w.len(), 2, "the image and its soft mask: {w:?}");
assert!(w.iter().any(|i| i.space == "DeviceCMYK") && w.iter().any(|i| i.space == "DeviceGray"), "{w:?}");
// The Output settings convert the inks like any CMYK image's.
let d = freeform_doc(ColorMode::Cmyk, cyan, black, 1.0);
let w = written(&export_with_report(&d, &opts(json!({"output": {"conversion": "destination", "destination": SRGB}}))).unwrap().bytes);
assert_eq!(w.len(), 1, "{w:?}");
assert_ne!(w[0].space, "DeviceCMYK", "converted to RGB");
}
#[test]
fn freeform_gradients_of_lab_points_are_written_as_their_lab_mix() {
let d = freeform_doc(ColorMode::Rgb, Color::lab(90.0, -60.0, 40.0), Color::lab(30.0, 60.0, -40.0), 1.0);
let out = export_with_report(&d, &opts(json!({}))).unwrap();
assert_eq!(written(&out.bytes)[0].space, "DeviceRGB");
let back = import(&out.bytes).unwrap();
let px = image::load_from_memory(&all_images(&back)[0].bytes).unwrap().to_rgba8();
let got = px.get_pixel(px.width() / 2, px.height() / 2).0;
let want = Color::lab(60.0, 0.0, 0.0).to_rgba8(1.0);
assert!(close(&got, &want, 6), "{got:?} vs {want:?}");
}
#[test]
fn gray_freeform_points_are_written_as_black_ink_in_a_cmyk_document() {
let (a, b) = (Color::gray(0.2), Color::gray(0.8));
let out = export_with_report(&freeform_doc(ColorMode::Cmyk, a, b, 1.0), &opts(json!({}))).unwrap();
let w = written(&out.bytes);
assert_eq!(w[0].space, "DeviceCMYK", "{w:?}");
let back = import(&out.bytes).unwrap();
let inks = all_images(&back)[0].cmyk().unwrap();
let at = (inks.height as usize / 2 * inks.width as usize + inks.width as usize / 2) * 4;
assert!(close(&inks.data[at..at + 4], &[0, 0, 0, 128], 6), "{:?}", &inks.data[at..at + 4]);
// An RGB document writes them as an image in RGB.
let w = written(&export_with_report(&freeform_doc(ColorMode::Rgb, a, b, 1.0), &opts(json!({}))).unwrap().bytes);
assert_ne!(w[0].space, "DeviceCMYK", "{w:?}");
}
/// #2950: Press Quality converts to the document's profile with its numbers kept, so in a CMYK
/// document an RGB image comes out CMYK, as the press needs; with no conversion (the default
/// preset) it stays RGB, as before.
+135 -25
View File
@@ -5,20 +5,25 @@
//! sees them ([`Paints`]): an effect can draw as wide as the stroke it sits on, or tell its fills
//! apart, without asking for them again as parameters.
//!
//! An effect whose manifest declares `"paintOutput": true` returns painted pieces instead
//! ([`apply_painted`], [`crate::painted`]): each is drawn by itself with paints of its own.
//!
//! Drawing evaluates effects often (every frame, hit tests, bounds), so results are cached by
//! plug-in installation, parameters, input geometry and paints: the plug-in runs again only when
//! one of them changes. Failures are cached too (the geometry is then drawn unchanged) and
//! reported by [`last_error`].
//! reported by [`last_error`]. The swatches and patterns a painted result names are checked
//! against the document each time it is used.
use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use std::sync::Mutex;
use std::sync::{Arc, Mutex};
use serde_json::{Value, json};
use vectorcraft_doc::{Appearance, AppearanceItem};
use vectorcraft_doc::{Appearance, AppearanceItem, Document};
use vectorcraft_geom::{FillRule, PathData, Rect};
use crate::objects::{decode, output_geometry, paint_lists};
use crate::painted::{self, Piece, Scope};
use crate::{Error, Kind, Limits, Result, registry};
/// The effect id prefix of live-effect plug-ins: `plugin.<plug-in id>`.
@@ -88,6 +93,15 @@ impl Paints<'_> {
};
Given { fills, strokes, item }
}
/// Where the effect sits: what the pieces of an effect that declares `paintOutput` may paint.
fn scope(&self) -> Scope {
match self.appearance.zip(self.item).and_then(|(ap, i)| ap.items.get(i)) {
Some(AppearanceItem::Fill(_)) => Scope::Fill,
Some(AppearanceItem::Stroke(_)) => Scope::Stroke,
None => Scope::Object,
}
}
}
#[derive(PartialEq)]
@@ -117,9 +131,27 @@ impl Key {
}
}
/// What one run of a live effect gave: geometry, or the painted pieces of an effect that declares
/// `paintOutput` ([`crate::painted`]).
#[derive(Clone, Debug, PartialEq)]
enum Out {
Path(PathData),
Pieces(Arc<Vec<Piece>>),
}
impl Out {
/// The geometry: the path, or every piece's path in order.
fn geometry(&self) -> PathData {
match self {
Out::Path(p) => p.clone(),
Out::Pieces(pieces) => PathData::new(pieces.iter().flat_map(|p| p.path.subpaths.iter().cloned()).collect()),
}
}
}
#[derive(Default)]
struct Cache {
entries: HashMap<u64, (Key, Option<PathData>)>,
entries: HashMap<u64, (Key, Option<Out>)>,
/// The anchors of the entries' inputs and results ([`CACHE_ANCHORS`]).
anchors: usize,
}
@@ -131,10 +163,16 @@ fn anchors(p: &PathData) -> usize {
p.subpaths.iter().map(|sp| sp.anchors.len() + 1).sum()
}
/// Cache units an entry takes: its input and result paths, and the serialized parameters and
/// paints its key owns (one unit per [`CACHE_ANCHORS`]'s 50 bytes an anchor stands for).
fn weight(key: &Key, out: Option<&PathData>) -> usize {
anchors(&key.path) + out.map_or(0, anchors) + (key.params.len() + key.paints.len()) / 50 + 1
/// Cache units an entry takes: its input and result paths, the serialized parameters and paints its
/// key owns (one unit per [`CACHE_ANCHORS`]'s 50 bytes an anchor stands for), and one for each
/// paint, gradient stop and freeform point a painted result holds.
fn weight(key: &Key, out: Option<&Out>) -> usize {
let out = match out {
None => 0,
Some(Out::Path(p)) => anchors(p),
Some(Out::Pieces(pieces)) => pieces.iter().map(|p| anchors(&p.path) + 1).sum::<usize>() + painted::paint_count(pieces),
};
anchors(&key.path) + out + (key.params.len() + key.paints.len()) / 50 + 1
}
static ERRORS: Mutex<Option<HashMap<String, String>>> = Mutex::new(None);
@@ -194,21 +232,77 @@ pub fn last_error(id: &str) -> Option<String> {
ERRORS.lock().unwrap_or_else(|e| e.into_inner()).as_ref()?.get(id).cloned()
}
fn note(id: &str, r: &Result<PathData>) {
/// Records how a run of plug-in `id` went: a failure as its last error (a painted effect's named
/// after the plug-in, [`painted::named`]), a success clearing it.
fn note(id: &str, plugin: &crate::Plugin, r: std::result::Result<(), &Error>) {
let mut errors = ERRORS.lock().unwrap_or_else(|e| e.into_inner());
let errors = errors.get_or_insert_with(HashMap::new);
match r {
Ok(_) => errors.remove(id),
Ok(()) => errors.remove(id),
Err(e) if plugin.manifest().paint_output => errors.insert(id.to_string(), painted::named(&plugin.manifest().name, e)),
Err(e) => errors.insert(id.to_string(), e.to_string()),
};
}
/// Does live-effect plug-in `id` return painted pieces (its manifest declares `paintOutput`)?
pub fn paints_output(id: &str) -> bool {
registry::get(id).is_some_and(|p| p.manifest().kind == Kind::Effect && p.manifest().paint_output)
}
/// Runs live-effect plug-in `id` on `path` (whose reference box is `bounds`), of an object painted
/// with `paints`: the new geometry, or `None` when the plug-in isn't installed, isn't an effect,
/// fails, or Safe Mode is on (the caller then keeps the geometry, and the object keeps its effect).
/// The launch marker names the plug-in during its first run in this process, and until every run
/// that started while it ran has ended too ([`crate::safe_mode::loading`]).
pub fn apply(id: &str, params: &Value, path: &PathData, bounds: Rect, rule: FillRule, paints: Paints) -> Option<PathData> {
/// For an effect that returns painted pieces, the geometry is every piece's path in order, and
/// `None` when the swatches and patterns they name are refused as [`apply_painted`] refuses them.
pub fn apply(id: &str, params: &Value, path: &PathData, bounds: Rect, rule: FillRule, paints: Paints, doc: Option<&Document>) -> Option<PathData> {
let (plugin, out) = evaluate(id, params, path, bounds, rule, paints)?;
if let Out::Pieces(pieces) = &out {
references_found(id, &plugin, pieces, doc)?;
}
Some(out.geometry())
}
/// Runs live-effect plug-in `id`, which declares `paintOutput`, as [`apply`] does: its painted
/// pieces, each to draw by itself in order with the paints it gives ([`painted::appearance`],
/// [`painted::items`]). Every swatch and pattern the pieces name must be in `doc`; without a
/// document, pieces that name one are refused ([`painted::check_references`]). A paint linked to a
/// global swatch has that swatch's current color. `None` when the
/// plug-in isn't installed, doesn't declare `paintOutput`, fails, or Safe Mode is on; the caller then
/// draws its input unchanged, and [`last_error`] says why a run failed (a refusal for want of a
/// document is not recorded).
pub fn apply_painted(
id: &str,
params: &Value,
path: &PathData,
bounds: Rect,
rule: FillRule,
paints: Paints,
doc: Option<&Document>,
) -> Option<Arc<Vec<Piece>>> {
let (plugin, out) = evaluate(id, params, path, bounds, rule, paints)?;
let Out::Pieces(pieces) = out else { return None };
references_found(id, &plugin, &pieces, doc)?;
Some(match doc {
Some(d) => painted::with_swatch_colors(pieces, d),
None => pieces,
})
}
/// `Some` when every swatch and pattern `pieces` name is in `doc` ([`painted::check_references`]),
/// recording the outcome for [`last_error`] when there is a document. The document's swatches and
/// patterns can change while a result stays cached, so each use checks them.
fn references_found(id: &str, plugin: &crate::Plugin, pieces: &[Piece], doc: Option<&Document>) -> Option<()> {
let checked = painted::check_references(pieces, doc);
if doc.is_some() {
note(id, plugin, checked.as_ref().map(|_| ()));
}
checked.ok()
}
/// Runs plug-in `id` as [`apply`] says, or takes its result from the cache: the plug-in and what
/// it gave. The launch marker names the plug-in during its first run in this process, and until
/// every run that started while it ran has ended too ([`crate::safe_mode::loading`]).
fn evaluate(id: &str, params: &Value, path: &PathData, bounds: Rect, rule: FillRule, paints: Paints) -> Option<(Arc<crate::Plugin>, Out)> {
if crate::safe_mode::active() {
return None;
}
@@ -227,10 +321,11 @@ pub fn apply(id: &str, params: &Value, path: &PathData, bounds: Rect, rule: Fill
if let Some((k, out)) = CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref().and_then(|c| c.entries.get(&h))
&& *k == key
{
return out.clone();
return out.clone().map(|o| (plugin, o));
}
let scope = paints.scope();
let guarded = begin_run(&plugin);
let r = run(&plugin, resolved, path, bounds, rule, given);
let r = run(&plugin, resolved, path, bounds, rule, given, scope);
let cancelled = matches!(r, Err(Error::Cancelled));
if guarded {
end_run(plugin.serial(), cancelled);
@@ -239,11 +334,11 @@ pub fn apply(id: &str, params: &Value, path: &PathData, bounds: Rect, rule: Fill
if cancelled {
return None;
}
note(id, &r);
note(id, &plugin, r.as_ref().map(|_| ()));
let out = r.ok();
let size = weight(&key, out.as_ref());
if size > CACHE_ANCHORS / 4 {
return out;
return out.map(|o| (plugin, o));
}
let mut cache = CACHE.lock().unwrap_or_else(|e| e.into_inner());
let cache = cache.get_or_insert_with(Cache::default);
@@ -254,22 +349,28 @@ pub fn apply(id: &str, params: &Value, path: &PathData, bounds: Rect, rule: Fill
cache.anchors = cache.anchors.saturating_sub(weight(&old, old_out.as_ref()));
}
cache.anchors += size;
out
out.map(|o| (plugin, o))
}
/// One run of `plugin` on `path`, with the object's paints as `given`.
fn run(plugin: &crate::Plugin, mut params: Value, path: &PathData, bounds: Rect, rule: FillRule, given: Given) -> Result<PathData> {
/// One run of `plugin` on `path`, with the object's paints as `given`: geometry, or for an effect
/// that declares `paintOutput` its pieces, checked for an effect in `scope`.
fn run(plugin: &crate::Plugin, mut params: Value, path: &PathData, bounds: Rect, rule: FillRule, given: Given, scope: Scope) -> Result<Out> {
let b = json!([bounds.x0, bounds.y0, bounds.x1, bounds.y1]);
params["_context"] = json!({"mode": "effect", "bounds": b, "item": given.item});
let rule = match rule {
let rule_name = match rule {
FillRule::NonZero => "nonzero",
FillRule::EvenOdd => "evenodd",
};
let input = json!({"objects": [{"type": "path", "path": path, "fillRule": rule, "bounds": b, "fills": given.fills, "strokes": given.strokes}]});
let input =
json!({"objects": [{"type": "path", "path": path, "fillRule": rule_name, "bounds": b, "fills": given.fills, "strokes": given.strokes}]});
let (input, params) = (serde_json::to_vec(&input), serde_json::to_vec(&params));
let (Ok(input), Ok(params)) = (input, params) else { return Err(Error::Params("can't encode the input".into())) };
let limits = Limits { wall_time_ms: WALL_TIME_MS, ..plugin.limits().clone() };
Ok(output_geometry(decode(&plugin.run_limited(&input, &params, &limits)?)?))
let output = plugin.run_limited(&input, &params, &limits)?;
if plugin.manifest().paint_output {
return Ok(Out::Pieces(Arc::new(painted::decode(&output, scope, rule)?)));
}
Ok(Out::Path(output_geometry(decode(&output)?)))
}
#[cfg(test)]
@@ -309,8 +410,17 @@ mod tests {
let plugin = registry::install(crate::Plugin::load(&wat::parse_str(&src).unwrap(), Limits::default()).unwrap());
let ran_first =
|| RUNS.lock().unwrap_or_else(|e| e.into_inner()).as_ref().is_some_and(|r| matches!(r.get(&plugin.serial()), Some(Runs::Done)));
let draw =
|| apply("test.effect.cancelled", &json!({}), &PathData::default(), Rect::new(0.0, 0.0, 1.0, 1.0), FillRule::NonZero, Paints::default());
let draw = || {
apply(
"test.effect.cancelled",
&json!({}),
&PathData::default(),
Rect::new(0.0, 0.0, 1.0, 1.0),
FillRule::NonZero,
Paints::default(),
None,
)
};
let token = vectorcraft_doc::cancel::CancelToken::new();
token.cancel();
assert_eq!(vectorcraft_doc::cancel::scope(&token, None, draw), None);
+3 -1
View File
@@ -21,7 +21,8 @@
//! their geometry, fills, strokes (gradients included) and opacity. An **object filter** returns
//! the objects to keep, change or add, applied to the document as one edit
//! ([`objects::apply_output`]); a **live effect** ([`effect`]) rewrites one object's geometry each
//! time it is drawn, from an appearance stack; an **exporter** ([`export`]) writes the whole
//! time it is drawn, from an appearance stack, and at `api` 2 may paint what it returns
//! ([`painted`]); an **exporter** ([`export`]) writes the whole
//! document in a file format of its own; an **importer** ([`import`]) reads a file in a format of
//! its own into a new document. The process-wide [`registry`] holds the installed
//! plug-ins; in [`safe_mode`] none of them loads or runs. A plug-in's parameters ([`manifest`]) come with what the host's dialogs need to show
@@ -38,6 +39,7 @@ pub mod host;
pub mod import;
pub mod manifest;
pub mod objects;
pub mod painted;
pub mod permissions;
pub mod registry;
mod runtime;
+51
View File
@@ -292,6 +292,10 @@ pub struct Manifest {
/// How much a run may compute: only a filter may be [`Budget::Heavy`].
#[serde(default, skip_serializing_if = "Budget::is_light")]
pub budget: Budget,
/// A live effect at `api` 2 that returns painted pieces ([`crate::painted`]): `true` when the
/// manifest has `"paintOutput": true`, the only value the key may hold.
#[serde(default, rename = "paintOutput", skip_serializing_if = "std::ops::Not::not")]
pub paint_output: bool,
/// The menu commands it adds, in menu order (a filter or a live effect only).
#[serde(default, deserialize_with = "ordered_commands", skip_serializing_if = "Vec::is_empty")]
pub commands: Vec<Command>,
@@ -304,6 +308,9 @@ impl Manifest {
return Err(Error::Manifest(format!("manifest is {} bytes (limit {MAX_MANIFEST_BYTES})", bytes.len())));
}
let text = std::str::from_utf8(bytes).map_err(|_| Error::Manifest("manifest is not UTF-8".into()))?;
if serde_json::from_str::<Value>(text).is_ok_and(|v| v.get("paintOutput").is_some_and(|p| *p != Value::Bool(true))) {
return Err(Error::Manifest("`paintOutput` may only be true".into()));
}
let man: Manifest = serde_json::from_str(text).map_err(|e| Error::Manifest(format!("manifest: {e}")))?;
man.validate()?;
Ok(man)
@@ -323,6 +330,7 @@ impl Manifest {
params: Vec::new(),
extensions: Vec::new(),
budget: Budget::Light,
paint_output: false,
commands: Vec::new(),
}
}
@@ -362,6 +370,9 @@ impl Manifest {
let permission = host.name();
return Err(Error::Manifest(format!("permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API}")));
}
if self.paint_output && (self.kind != Kind::Effect || self.api < 2) {
return Err(Error::Manifest("`paintOutput` is only for a live effect (`kind` \"effect\") at `api` 2".into()));
}
self.validate_extensions()?;
match (self.budget, self.kind) {
(Budget::Light, _) | (Budget::Heavy, Kind::Filter) => {}
@@ -929,6 +940,46 @@ mod tests {
}
}
/// A live effect at `api` 2 may declare `"paintOutput": true`, and only that value; any other
/// kind or level, and `false`, `null` or another type, is refused naming the key. Without the
/// key, at any level, the effect returns geometry only.
#[test]
fn only_a_level_2_live_effect_declares_paint_output() {
let painted = Manifest::parse(br#"{"id":"org.example.tone","name":"Tone","kind":"effect","api":2,"paintOutput":true}"#).unwrap();
assert!(painted.paint_output);
assert_eq!(serde_json::to_value(&painted).unwrap()["paintOutput"], json!(true));
let listed =
Manifest::parse(br#"{"id":"a","name":"x","kind":"effect","api":2,"paintOutput":true,"permissions":["doc.read","effect.render"]}"#)
.unwrap();
assert!(listed.paint_output);
for plain in [r#"{"id":"a","name":"x","kind":"effect","api":2}"#, r#"{"id":"a","name":"x","kind":"effect"}"#] {
let m = Manifest::parse(plain.as_bytes()).unwrap();
assert!(!m.paint_output, "{plain}");
assert!(serde_json::to_value(&m).unwrap().get("paintOutput").is_none(), "{plain}");
}
for bad in [
r#"{"id":"a","name":"x","kind":"effect","api":2,"paintOutput":false}"#,
r#"{"id":"a","name":"x","kind":"effect","api":2,"paintOutput":null}"#,
r#"{"id":"a","name":"x","kind":"effect","api":2,"paintOutput":1}"#,
r#"{"id":"a","name":"x","kind":"effect","api":2,"paintOutput":"true"}"#,
r#"{"id":"a","name":"x","kind":"effect","paintOutput":true}"#,
r#"{"id":"a","name":"x","kind":"effect","api":1,"paintOutput":true}"#,
r#"{"id":"a","name":"x","kind":"filter","api":2,"paintOutput":true}"#,
r#"{"id":"a","name":"x","kind":"exporter","api":2,"extensions":["plt"],"paintOutput":true}"#,
r#"{"id":"a","name":"x","kind":"effect","api":2,"paintOutput":true,"permissions":["doc.read"]}"#,
] {
let e = Manifest::parse(bad.as_bytes()).unwrap_err().to_string();
assert!(e.contains("paintOutput") || e.contains("effect.render"), "{bad}: {e}");
}
}
#[test]
fn commands_and_paint_output_may_both_be_declared_on_a_live_effect() {
let m = Manifest::parse(br#"{"id":"a","name":"x","kind":"effect","api":2,"paintOutput":true,"commands":[{"id":"c","label":"C"}]}"#).unwrap();
assert!(m.paint_output);
assert_eq!(m.commands.len(), 1);
}
/// A filter or a live effect lists menu commands with their own parameters; a `menu` key is
/// ignored. A malformed entry, a bad or repeated id, a label that isn't printable and too many
/// commands are refused, and the error names the command.
+5 -5
View File
@@ -162,11 +162,11 @@ pub fn encode_input(doc: &Document, ids: &[NodeId]) -> Vec<u8> {
serde_json::to_vec(&json!({ "objects": objects })).unwrap_or_default()
}
fn out_err(at: &str, msg: impl std::fmt::Display) -> Error {
pub(crate) fn out_err(at: &str, msg: impl std::fmt::Display) -> Error {
Error::Output(format!("{at}: {msg}"))
}
fn check_point(p: Point, at: &str) -> Result<()> {
pub(crate) fn check_point(p: Point, at: &str) -> Result<()> {
if p.x.is_finite() && p.y.is_finite() && p.x.abs() <= MAX_COORD && p.y.abs() <= MAX_COORD {
Ok(())
} else {
@@ -176,7 +176,7 @@ fn check_point(p: Point, at: &str) -> Result<()> {
/// A path from plug-in output: native `PathData` JSON, every coordinate finite and in range, empty
/// subpaths dropped; `anchors` counts towards [`MAX_ANCHORS`].
fn decode_path(v: &Value, at: &str, anchors: &mut usize) -> Result<PathData> {
pub(crate) fn decode_path(v: &Value, at: &str, anchors: &mut usize) -> Result<PathData> {
let mut path: PathData = serde_json::from_value(v.clone()).map_err(|e| out_err(at, e))?;
path.subpaths.retain(|sp| !sp.anchors.is_empty());
*anchors = anchors.saturating_add(path.anchor_count());
@@ -486,7 +486,7 @@ fn admit_paint(doc: &Document, p: Paint, was: Option<&Paint>, at: &str) -> Resul
/// Sets the paints of `ap`'s fills (`fill`) or strokes to `new` (a fill keeps everything but its
/// paint), in paint order: extra items are removed (from the top), missing ones added above the
/// last item of their kind (fills at the bottom, strokes on top when the object has none).
fn set_items(ap: &mut Appearance, fill: bool, new: Vec<OutStroke>) {
pub(crate) fn set_items(ap: &mut Appearance, fill: bool, new: Vec<OutStroke>) {
let idx: Vec<usize> = ap.items.iter().enumerate().filter(|(_, i)| i.is_fill() == fill).map(|(k, _)| k).collect();
for &i in idx.iter().skip(new.len()).rev() {
ap.remove_item(i);
@@ -513,7 +513,7 @@ fn set_items(ap: &mut Appearance, fill: bool, new: Vec<OutStroke>) {
}
/// Gives stroke `s` the paint of `o` and each of its weight, join, cap and miter limit it has.
fn style(s: &mut StrokeLayer, o: OutStroke) {
pub(crate) fn style(s: &mut StrokeLayer, o: OutStroke) {
s.paint = o.paint;
s.width = o.width.unwrap_or(s.width);
s.join = o.join.unwrap_or(s.join);
+447
View File
@@ -0,0 +1,447 @@
//! What a live effect that declares `"paintOutput": true` (at `api` 2) returns: painted pieces.
//!
//! Such an effect returns `{"objects": [...]}` as any live effect does, but each entry is a piece
//! drawn by itself, in the order returned, with paints of its own: `{"path", "fillRule"?,
//! "fills"?, "strokes"?}`. Fills and strokes take the forms a filter returns ([`crate::objects`]).
//! A piece without `fills` (or `strokes`) keeps the ones it was given; an empty list paints none.
//! An effect on a fill may return only `fills`, one on a stroke only `strokes`.
//!
//! The result is checked whole before any of it is used ([`decode`]): every number finite and in
//! range (nothing is clamped), every key known, and every swatch and pattern a paint names in the
//! document ([`check_references`]). One fault refuses the whole result, naming the field.
use serde_json::Value;
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, Document, LineCap, LineJoin};
use vectorcraft_geom::{FillRule, PathData};
use crate::objects::{MAX_COORD, MAX_ITEMS, MAX_OBJECTS, MAX_STROKE_WIDTH, MITER_LIMIT, OutStroke, check_point, out_err};
use crate::{Error, Result};
/// The keys a piece may have. `type` and `bounds` are read back from the input as it was given and
/// say nothing about the piece.
const PIECE_KEYS: [&str; 6] = ["type", "path", "fillRule", "bounds", "fills", "strokes"];
/// The keys a stroke of a piece may have.
const STROKE_KEYS: [&str; 5] = ["paint", "width", "join", "cap", "miterLimit"];
/// One piece a live effect returned: its geometry and fill rule, and its fills and strokes in paint
/// order (`None`: the ones it was given).
#[derive(Clone, Debug, PartialEq)]
pub struct Piece {
pub path: PathData,
pub rule: FillRule,
pub fills: Option<Vec<Paint>>,
pub strokes: Option<Vec<OutStroke>>,
}
/// Where in an appearance the effect sits, which says what its pieces may paint.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Scope {
/// One of the object's effects: a piece may return fills and strokes.
Object,
/// One of a fill's own effects: a piece may return only `fills`.
Fill,
/// One of a stroke's own effects: a piece may return only `strokes`.
Stroke,
}
/// Parses and checks a painted result: `{"objects": [...]}`, or `{"error": "message"}` when the
/// plug-in declined. `rule` is the fill rule a piece without `fillRule` takes. Any other top-level
/// key, an unknown key in a piece, stroke or paint, a list or count past its limit, a number out of
/// range or a paint of the wrong form refuses the whole result, naming the field.
pub fn decode(bytes: &[u8], scope: Scope, rule: FillRule) -> Result<Vec<Piece>> {
let v: Value = serde_json::from_slice(bytes).map_err(|e| out_err("output", format!("not JSON: {e}")))?;
if let Some(e) = crate::objects::declined(&v) {
return Err(e);
}
let top = v.as_object().ok_or_else(|| out_err("output", "expected {\"objects\": [...]}"))?;
if let Some(k) = top.keys().find(|k| !matches!(k.as_str(), "objects" | "error")) {
return Err(out_err(k, "a painted live effect returns only `objects`"));
}
let objects = top.get("objects").and_then(Value::as_array).ok_or_else(|| out_err("objects", "expected {\"objects\": [...]}"))?;
if objects.len() > MAX_OBJECTS {
return Err(out_err("objects", format!("{} objects (limit {MAX_OBJECTS})", objects.len())));
}
let mut anchors = 0;
objects.iter().enumerate().map(|(i, o)| piece(o, &format!("objects[{i}]"), scope, rule, &mut anchors)).collect()
}
fn piece(v: &Value, at: &str, scope: Scope, rule: FillRule, anchors: &mut usize) -> Result<Piece> {
let o = v.as_object().ok_or_else(|| out_err(at, "an object must be a JSON object"))?;
if let Some(k) = o.keys().find(|k| !PIECE_KEYS.contains(&k.as_str())) {
return Err(out_err(&format!("{at}.{k}"), "a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes`"));
}
if let Some(t) = o.get("type")
&& !matches!(t.as_str(), Some("path" | "compound"))
{
return Err(out_err(&format!("{at}.type"), "`type` must be \"path\" or \"compound\""));
}
let path = o.get("path").ok_or_else(|| out_err(&format!("{at}.path"), "each piece needs a `path`"))?;
// At most MAX_ANCHORS over all pieces.
let path = crate::objects::decode_path(path, &format!("{at}.path"), anchors)?;
let rule = match o.get("fillRule") {
None => rule,
Some(r) => match r.as_str() {
Some("nonzero") => FillRule::NonZero,
Some("evenodd") => FillRule::EvenOdd,
_ => return Err(out_err(&format!("{at}.fillRule"), "`fillRule` must be \"nonzero\" or \"evenodd\"")),
},
};
let list = |key: &str| -> Result<Option<&Vec<Value>>> {
match o.get(key) {
None => Ok(None),
Some(Value::Array(a)) if a.len() <= MAX_ITEMS => Ok(Some(a)),
Some(_) => Err(out_err(&format!("{at}.{key}"), format!("`{key}` must be an array of at most {MAX_ITEMS} entries"))),
}
};
let (fills, strokes) = (list("fills")?, list("strokes")?);
match scope {
Scope::Fill if strokes.is_some() => return Err(out_err(&format!("{at}.strokes"), "an effect on a fill returns only `fills`")),
Scope::Stroke if fills.is_some() => return Err(out_err(&format!("{at}.fills"), "an effect on a stroke returns only `strokes`")),
_ => {}
}
let fills = fills.map(|f| f.iter().enumerate().map(|(i, p)| paint(p, &format!("{at}.fills[{i}]"))).collect::<Result<Vec<_>>>()).transpose()?;
let strokes =
strokes.map(|s| s.iter().enumerate().map(|(i, s)| stroke(s, &format!("{at}.strokes[{i}]"))).collect::<Result<Vec<_>>>()).transpose()?;
Ok(Piece { path, rule, fills, strokes })
}
/// A stroke `{paint, width?, join?, cap?, miterLimit?}`: a width of 0–[`MAX_STROKE_WIDTH`] and a
/// miter limit in [`MITER_LIMIT`].
fn stroke(v: &Value, at: &str) -> Result<OutStroke> {
let s = v.as_object().ok_or_else(|| out_err(at, "a stroke must be {\"paint\", \"width\"?, \"join\"?, \"cap\"?, \"miterLimit\"?}"))?;
if let Some(k) = s.keys().find(|k| !STROKE_KEYS.contains(&k.as_str())) {
return Err(out_err(&format!("{at}.{k}"), "a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit`"));
}
let number = |key: &str, range: std::ops::RangeInclusive<f64>| -> Result<Option<f64>> {
let Some(v) = s.get(key) else { return Ok(None) };
match v.as_f64().filter(|x| x.is_finite() && range.contains(x)) {
Some(x) => Ok(Some(x)),
None => Err(out_err(&format!("{at}.{key}"), format!("`{key}` must be a number from {} to {}", range.start(), range.end()))),
}
};
let join = match s.get("join").map(Value::as_str) {
None => None,
Some(Some("miter")) => Some(LineJoin::Miter),
Some(Some("round")) => Some(LineJoin::Round),
Some(Some("bevel")) => Some(LineJoin::Bevel),
Some(_) => return Err(out_err(&format!("{at}.join"), "`join` must be \"miter\", \"round\" or \"bevel\"")),
};
let cap = match s.get("cap").map(Value::as_str) {
None => None,
Some(Some("butt")) => Some(LineCap::Butt),
Some(Some("round")) => Some(LineCap::Round),
Some(Some("square")) => Some(LineCap::Square),
Some(_) => return Err(out_err(&format!("{at}.cap"), "`cap` must be \"butt\", \"round\" or \"square\"")),
};
let paint = paint(s.get("paint").unwrap_or(&Value::Null), &format!("{at}.paint"))?;
Ok(OutStroke { paint, width: number("width", 0.0..=MAX_STROKE_WIDTH)?, join, cap, miter_limit: number("miterLimit", MITER_LIMIT)? })
}
/// Is `x` finite and in `lo..=hi`?
fn within(x: f64, lo: f64, hi: f64) -> bool {
x.is_finite() && (lo..=hi).contains(&x)
}
fn in_range(x: f32, lo: f32, hi: f32, at: &str, what: &str) -> Result<()> {
if within(f64::from(x), f64::from(lo), f64::from(hi)) { Ok(()) } else { Err(out_err(at, format!("{what} must be from {lo} to {hi}"))) }
}
/// A color with every component in range: 0–1, or Lab's L 0–100 and a, b −128–127.
fn color(c: &Color, at: &str) -> Result<()> {
let unit = |x: f32| in_range(x, 0.0, 1.0, at, "a color component");
match *c {
Color::Rgb { r, g, b } => [r, g, b].into_iter().try_for_each(unit),
Color::Cmyk { c, m, y, k } => [c, m, y, k].into_iter().try_for_each(unit),
Color::Gray { k } => unit(k),
Color::Lab { l, a, b } => {
in_range(l, 0.0, 100.0, at, "Lab's L")?;
in_range(a, -128.0, 127.0, at, "Lab's a")?;
in_range(b, -128.0, 127.0, at, "Lab's b")
}
}
}
/// Refuses a key of object `v` that is not in `keys`, naming it after `at`. A value that is not an
/// object passes, and deserializing it reports the fault.
fn only_keys(v: &Value, at: &str, keys: &[&str]) -> Result<()> {
match v.as_object().and_then(|o| o.keys().find(|k| !keys.contains(&k.as_str()))) {
Some(k) => Err(out_err(&format!("{at}.{k}"), format!("unknown key `{k}`; the keys here are {}", keys.join(", ")))),
None => Ok(()),
}
}
/// [`only_keys`] for a color, whose keys follow its `model`. An unknown model passes, and
/// deserializing it reports the fault.
fn color_keys(v: &Value, at: &str) -> Result<()> {
let keys: &[&str] = match v.get("model").and_then(Value::as_str) {
Some("rgb") => &["model", "r", "g", "b"],
Some("cmyk") => &["model", "c", "m", "y", "k"],
Some("gray") => &["model", "k"],
Some("lab") => &["model", "l", "a", "b"],
_ => return Ok(()),
};
only_keys(v, at, keys)
}
/// [`only_keys`] for a point `{x, y}`.
fn point_keys(v: Option<&Value>, at: &str) -> Result<()> {
v.map_or(Ok(()), |v| only_keys(v, at, &["x", "y"]))
}
/// Calls `f` on each entry of the array at `v`, named `at[i]`.
fn each(v: Option<&Value>, at: &str, f: impl Fn(&Value, &str) -> Result<()>) -> Result<()> {
v.and_then(Value::as_array).into_iter().flatten().enumerate().try_for_each(|(i, e)| f(e, &format!("{at}[{i}]")))
}
/// Refuses an unknown key at any level of paint `v`: the paint itself, its colors, a gradient's
/// stops, placement and freeform points. Deserializing a paint drops unknown keys, so a key such
/// as `opacity` on a solid paint would otherwise be accepted and do nothing.
fn paint_keys(v: &Value, at: &str) -> Result<()> {
match v.get("type").and_then(Value::as_str) {
Some("none") => only_keys(v, at, &["type"]),
Some("solid") => {
only_keys(v, at, &["type", "color", "swatch", "tint"])?;
v.get("color").map_or(Ok(()), |c| color_keys(c, &format!("{at}.color")))
}
Some("pattern") => only_keys(v, at, &["type", "pattern", "xf"]),
Some("gradient") => {
only_keys(v, at, &["type", "gradient", "geom", "angle", "swatch", "freeform"])?;
if let Some(g) = v.get("gradient") {
let at = format!("{at}.gradient");
only_keys(g, &at, &["kind", "stops", "dither", "method"])?;
each(g.get("stops"), &format!("{at}.stops"), |s, at| {
only_keys(s, at, &["offset", "color", "opacity", "midpoint", "swatch", "tint", "plates"])?;
s.get("color").map_or(Ok(()), |c| color_keys(c, &format!("{at}.color")))
})?;
}
if let Some(geom) = v.get("geom") {
let at = format!("{at}.geom");
only_keys(geom, &at, &["start", "end", "aspect", "focal"])?;
["start", "end", "focal"].into_iter().try_for_each(|k| point_keys(geom.get(k), &format!("{at}.{k}")))?;
}
if let Some(ff) = v.get("freeform") {
let at = format!("{at}.freeform");
only_keys(ff, &at, &["points", "lines", "mode"])?;
each(ff.get("points"), &format!("{at}.points"), |p, at| {
only_keys(p, at, &["at", "color", "opacity", "spread"])?;
point_keys(p.get("at"), &format!("{at}.at"))?;
p.get("color").map_or(Ok(()), |c| color_keys(c, &format!("{at}.color")))
})?;
}
Ok(())
}
_ => Ok(()),
}
}
/// A paint as the filter form has it (`null`: none), checked rather than clamped: colors, tints,
/// stops, opacities, midpoints and placements in range.
fn paint(v: &Value, at: &str) -> Result<Paint> {
if v.is_null() {
return Ok(Paint::None);
}
paint_keys(v, at)?;
let p: Paint = serde_json::from_value(v.clone()).map_err(|e| out_err(at, e))?;
let unit = |x: f32, at: &str, what: &str| in_range(x, 0.0, 1.0, at, what);
match &p {
Paint::None => {}
Paint::Solid { color: c, tint, .. } => {
color(c, &format!("{at}.color"))?;
unit(*tint, &format!("{at}.tint"), "`tint`")?;
}
Paint::Gradient(g) => {
let stops = &g.gradient.stops;
if stops.is_empty() || stops.len() > MAX_ITEMS {
return Err(out_err(&format!("{at}.gradient.stops"), format!("a gradient needs 1-{MAX_ITEMS} stops")));
}
for (i, s) in stops.iter().enumerate() {
let at = format!("{at}.gradient.stops[{i}]");
unit(s.offset, &format!("{at}.offset"), "`offset`")?;
unit(s.opacity, &format!("{at}.opacity"), "`opacity`")?;
in_range(s.midpoint, 0.13, 0.87, &format!("{at}.midpoint"), "`midpoint`")?;
unit(s.tint, &format!("{at}.tint"), "`tint`")?;
color(&s.color, &format!("{at}.color"))?;
for x in s.plates.iter().flatten() {
unit(*x, &format!("{at}.plates"), "a plate")?;
}
}
if stops.windows(2).any(|w| w[1].offset < w[0].offset) {
return Err(out_err(&format!("{at}.gradient.stops"), "stops must be in order of `offset`"));
}
if !g.angle.is_finite() {
return Err(out_err(&format!("{at}.angle"), "`angle` must be a number"));
}
if let Some(geom) = &g.geom {
check_point(geom.start, &format!("{at}.geom.start"))?;
check_point(geom.end, &format!("{at}.geom.end"))?;
if let Some(f) = geom.focal {
check_point(f, &format!("{at}.geom.focal"))?;
}
if !within(geom.aspect, 0.01, 100.0) {
return Err(out_err(&format!("{at}.geom.aspect"), "`aspect` must be from 0.01 to 100"));
}
}
if let Some(ff) = &g.freeform {
if ff.points.len() > MAX_ITEMS || ff.lines.len() > MAX_ITEMS || ff.lines.iter().any(|l| l.len() > MAX_ITEMS) {
return Err(out_err(&format!("{at}.freeform"), format!("a freeform gradient holds at most {MAX_ITEMS} points and lines")));
}
if ff.lines.iter().flatten().any(|i| *i >= ff.points.len()) {
return Err(out_err(&format!("{at}.freeform.lines"), "a freeform gradient line names a point it doesn't have"));
}
for (i, pt) in ff.points.iter().enumerate() {
let at = format!("{at}.freeform.points[{i}]");
check_point(pt.at, &format!("{at}.at"))?;
color(&pt.color, &format!("{at}.color"))?;
unit(pt.opacity, &format!("{at}.opacity"), "`opacity`")?;
unit(pt.spread, &format!("{at}.spread"), "`spread`")?;
}
}
}
Paint::Pattern { xf, .. } => {
if !xf.as_coeffs().iter().all(|c| within(*c, -MAX_COORD, MAX_COORD)) {
return Err(out_err(&format!("{at}.xf"), "pattern transform out of range"));
}
}
}
Ok(p)
}
/// Checks that every swatch a solid paint, gradient or gradient stop of `pieces` links to is a
/// swatch of `doc`, and every pattern a pattern paint names is one of its patterns. Without a
/// document (`None`), any swatch or pattern a piece names is refused.
pub fn check_references(pieces: &[Piece], doc: Option<&Document>) -> Result<()> {
let swatch = |name: &Option<String>, at: &str| match name {
Some(n) if doc.and_then(|d| d.swatch(n)).is_none() => Err(out_err(at, format!("the document has no swatch {n:?}"))),
_ => Ok(()),
};
let check = |p: &Paint, at: &str| -> Result<()> {
match p {
Paint::None => Ok(()),
Paint::Solid { swatch: s, .. } => swatch(s, &format!("{at}.swatch")),
Paint::Gradient(g) => {
swatch(&g.swatch, &format!("{at}.swatch"))?;
g.gradient.stops.iter().enumerate().try_for_each(|(i, s)| swatch(&s.swatch, &format!("{at}.gradient.stops[{i}].swatch")))
}
Paint::Pattern { pattern, .. } if doc.and_then(|d| d.pattern(pattern)).is_none() => {
Err(out_err(&format!("{at}.pattern"), format!("the document has no pattern {pattern:?}")))
}
Paint::Pattern { .. } => Ok(()),
}
};
for (i, p) in pieces.iter().enumerate() {
for (k, f) in p.fills.iter().flatten().enumerate() {
check(f, &format!("objects[{i}].fills[{k}]"))?;
}
for (k, s) in p.strokes.iter().flatten().enumerate() {
check(&s.paint, &format!("objects[{i}].strokes[{k}].paint"))?;
}
}
Ok(())
}
/// The current color of a paint or stop linked to global swatch `name` at `tint`, when it differs
/// from `color`.
fn newer_color(color: Color, name: Option<&str>, tint: f32, doc: &Document) -> Option<Color> {
doc.global_color(name?).map(|c| c.tinted(tint)).filter(|c| *c != color)
}
/// Does `p` hold a color older than the global swatch it is linked to?
fn behind_swatches(p: &Paint, doc: &Document) -> bool {
match p {
Paint::Solid { color, swatch, tint } => newer_color(*color, swatch.as_deref(), *tint, doc).is_some(),
Paint::Gradient(g) => g.gradient.stops.iter().any(|s| newer_color(s.color, s.swatch.as_deref(), s.tint, doc).is_some()),
Paint::None | Paint::Pattern { .. } => false,
}
}
/// Gives a solid paint or gradient stop of `p` linked to a global swatch that swatch's current
/// color at its own tint.
fn take_swatch_colors(p: &mut Paint, doc: &Document) {
match p {
Paint::Solid { color, swatch, tint } => {
if let Some(c) = newer_color(*color, swatch.as_deref(), *tint, doc) {
*color = c;
}
}
Paint::Gradient(g) => {
for s in &mut g.gradient.stops {
if let Some(c) = newer_color(s.color, s.swatch.as_deref(), s.tint, doc) {
s.color = c;
}
}
}
Paint::None | Paint::Pattern { .. } => {}
}
}
/// `pieces` with every paint linked to a global swatch of `doc` in that swatch's current color, as
/// a global swatch edit changes linked art. A paint linked to a swatch that isn't global keeps the
/// color the plug-in gave.
pub(crate) fn with_swatch_colors(mut pieces: std::sync::Arc<Vec<Piece>>, doc: &Document) -> std::sync::Arc<Vec<Piece>> {
let behind =
|p: &Piece| p.fills.iter().flatten().any(|f| behind_swatches(f, doc)) || p.strokes.iter().flatten().any(|s| behind_swatches(&s.paint, doc));
if pieces.iter().any(behind) {
for p in std::sync::Arc::make_mut(&mut pieces) {
p.fills.iter_mut().flatten().for_each(|f| take_swatch_colors(f, doc));
p.strokes.iter_mut().flatten().for_each(|s| take_swatch_colors(&mut s.paint, doc));
}
}
pieces
}
/// How many paints, gradient stops and freeform points `pieces` hold: what a cache counts them as
/// besides their anchors.
pub(crate) fn paint_count(pieces: &[Piece]) -> usize {
let one = |p: &Paint| match p {
Paint::Gradient(g) => 1 + g.gradient.stops.len() + g.freeform.as_ref().map_or(0, |f| f.points.len()),
_ => 1,
};
pieces.iter().map(|p| p.fills.iter().flatten().map(one).sum::<usize>() + p.strokes.iter().flatten().map(|s| one(&s.paint)).sum::<usize>()).sum()
}
/// The appearance piece `p` of an object-level effect is drawn with: `base` (the object's) with
/// its fills, then its strokes, replaced by the piece's where it has them, as a filter's output
/// sets them (an existing item keeps everything but what the piece gives; new ones take the
/// defaults).
pub fn appearance(base: &Appearance, p: &Piece) -> Appearance {
let mut ap = base.clone();
if let Some(fills) = &p.fills {
crate::objects::set_items(&mut ap, true, fills.iter().cloned().map(OutStroke::paint).collect());
}
if let Some(strokes) = &p.strokes {
crate::objects::set_items(&mut ap, false, strokes.clone());
}
ap
}
/// The items piece `p` of an effect on `item` (a fill or a stroke) is drawn with, in paint order:
/// `item` itself when the piece keeps it, else one copy of it for each paint or stroke the piece
/// gives, with that paint (and that stroke's weight, join, cap and miter limit where given).
pub fn items(item: &AppearanceItem, p: &Piece) -> Vec<AppearanceItem> {
match item {
AppearanceItem::Fill(f) => match &p.fills {
None => vec![item.clone()],
Some(fills) => fills.iter().map(|paint| AppearanceItem::Fill(vectorcraft_doc::FillLayer { paint: paint.clone(), ..f.clone() })).collect(),
},
AppearanceItem::Stroke(s) => match &p.strokes {
None => vec![item.clone()],
Some(strokes) => strokes
.iter()
.map(|o| {
let mut s = s.clone();
crate::objects::style(&mut s, o.clone());
AppearanceItem::Stroke(s)
})
.collect(),
},
}
}
/// An error naming `name`, the plug-in, before what went wrong (`plugin.info`'s `lastError`).
pub(crate) fn named(name: &str, e: &Error) -> String {
format!("{name}: {e}")
}
#[cfg(test)]
mod tests;
+241
View File
@@ -0,0 +1,241 @@
use serde_json::json;
use vectorcraft_color::{Color, Paint, Swatch};
use vectorcraft_doc::{Appearance, AppearanceItem, Document, FillLayer, StrokeLayer};
use super::*;
const SQUARE: &str = r#"{"subpaths":[{"closed":true,"anchors":[{"p":[0,0]},{"p":[10,0]},{"p":[10,10]},{"p":[0,10]}]}]}"#;
fn red() -> Value {
serde_json::to_value(Paint::solid(Color::rgb(1.0, 0.0, 0.0))).unwrap()
}
fn out(pieces: Value) -> Vec<u8> {
serde_json::to_vec(&json!({ "objects": pieces })).unwrap()
}
fn square() -> Value {
serde_json::from_str(SQUARE).unwrap()
}
/// Each piece keeps its own paints in the order returned; a piece without `fills` or `strokes`
/// keeps the ones it was given (`None`) and an empty list paints none.
#[test]
fn pieces_keep_their_own_paints_in_order() {
let blue = serde_json::to_value(Paint::solid(Color::rgb(0.0, 0.0, 1.0))).unwrap();
let pieces = decode(
&out(json!([
{"path": square(), "fills": [red()], "strokes": [{"paint": blue, "width": 4, "join": "round", "cap": "square", "miterLimit": 2}]},
{"type": "path", "path": square(), "fillRule": "evenodd", "fills": [], "bounds": [0, 0, 10, 10]},
{"path": square()},
])),
Scope::Object,
FillRule::NonZero,
)
.unwrap();
assert_eq!(pieces.len(), 3);
assert_eq!(pieces[0].fills, Some(vec![Paint::solid(Color::rgb(1.0, 0.0, 0.0))]));
let s = &pieces[0].strokes.as_ref().unwrap()[0];
assert_eq!((s.width, s.join, s.cap, s.miter_limit), (Some(4.0), Some(LineJoin::Round), Some(LineCap::Square), Some(2.0)));
assert_eq!((pieces[1].rule, pieces[1].fills.as_deref(), &pieces[1].strokes), (FillRule::EvenOdd, Some(&[][..]), &None));
assert_eq!((pieces[2].rule, &pieces[2].fills, &pieces[2].strokes), (FillRule::NonZero, &None, &None));
}
/// A solid paint or gradient stop linked to a global swatch takes the swatch's color at its own
/// tint. A link to a swatch that isn't global and a paint without a link keep the color given.
#[test]
fn linked_paints_take_the_global_swatch_color() {
let brand = Color::rgb(0.2, 0.4, 0.6);
let mut doc = Document::new(100.0, 100.0);
doc.swatches.push(Swatch { name: "Brand".into(), paint: Paint::solid(brand), global: true, spot: false });
doc.swatches.push(Swatch { name: "Plain".into(), paint: Paint::solid(brand), global: false, spot: false });
let given = json!({"model": "rgb", "r": 1, "g": 1, "b": 0});
let solid = |extra: Value| {
let mut p = json!({"type": "solid", "color": given});
p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
p
};
let stop = json!({"offset": 0, "color": given, "swatch": "Brand", "tint": 0.5});
let gradient = json!({"type": "gradient", "gradient": {"kind": "Linear", "stops": [stop]}});
let fills = json!([solid(json!({"swatch": "Brand", "tint": 0.5})), solid(json!({"swatch": "Plain"})), solid(json!({})), gradient]);
let strokes = json!([{"paint": solid(json!({"swatch": "Brand"}))}]);
let pieces = decode(&out(json!([{"path": square(), "fills": fills, "strokes": strokes}])), Scope::Object, FillRule::NonZero).unwrap();
let pieces = with_swatch_colors(std::sync::Arc::new(pieces), &doc);
let color = |p: &Paint| match p {
Paint::Solid { color, .. } => *color,
Paint::Gradient(g) => g.gradient.stops[0].color,
_ => panic!("{p:?}"),
};
let fills = pieces[0].fills.as_ref().unwrap();
let old = Color::rgb(1.0, 1.0, 0.0);
assert_eq!([color(&fills[0]), color(&fills[1]), color(&fills[2]), color(&fills[3])], [brand.tinted(0.5), old, old, brand.tinted(0.5)]);
assert_eq!(color(&pieces[0].strokes.as_ref().unwrap()[0].paint), brand);
}
/// What a piece is drawn with: the object's appearance with the paints the piece gives in place of
/// its own (kept, cleared or replaced), and for an effect on one fill or stroke, copies of that item.
#[test]
fn a_piece_s_appearance_inherits_or_replaces() {
let base = Appearance {
items: vec![
AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::WHITE))),
AppearanceItem::Stroke(StrokeLayer::new(Paint::solid(Color::BLACK), 3.0)),
],
..Appearance::default()
};
let red = Paint::solid(Color::rgb(1.0, 0.0, 0.0));
let piece =
|fills: Option<Vec<Paint>>, strokes: Option<Vec<OutStroke>>| Piece { path: PathData::default(), rule: FillRule::NonZero, fills, strokes };
assert_eq!(appearance(&base, &piece(None, None)), base, "inherited");
let ap = appearance(&base, &piece(Some(vec![red.clone()]), Some(vec![])));
assert_eq!(ap.items.len(), 1);
assert_eq!(ap.items[0].paint(), &red);
let ap = appearance(&base, &piece(None, Some(vec![OutStroke { width: Some(8.0), ..OutStroke::paint(red.clone()) }])));
let AppearanceItem::Stroke(s) = &ap.items[1] else { panic!("a stroke") };
assert_eq!((s.width, &s.paint), (8.0, &red));
let two = items(&base.items[0], &piece(Some(vec![red.clone(), Paint::None]), None));
assert_eq!(two.iter().map(|i| i.paint().clone()).collect::<Vec<_>>(), [red.clone(), Paint::None]);
assert_eq!(items(&base.items[1], &piece(None, None)), vec![base.items[1].clone()]);
let none = items(&base.items[1], &piece(None, Some(vec![])));
assert!(none.is_empty());
}
/// An effect on a fill returns only fills, one on a stroke only strokes.
#[test]
fn an_item_s_effect_paints_only_its_kind() {
let fills = out(json!([{"path": square(), "fills": [red()]}]));
let strokes = out(json!([{"path": square(), "strokes": [{"paint": red()}]}]));
assert!(decode(&fills, Scope::Fill, FillRule::NonZero).is_ok());
assert!(decode(&strokes, Scope::Stroke, FillRule::NonZero).is_ok());
let e = decode(&strokes, Scope::Fill, FillRule::NonZero).unwrap_err().to_string();
assert!(e.contains("objects[0].strokes"), "{e}");
let e = decode(&fills, Scope::Stroke, FillRule::NonZero).unwrap_err().to_string();
assert!(e.contains("objects[0].fills"), "{e}");
}
/// Every fault refuses the whole result and names its field; nothing is clamped.
#[test]
fn faults_refuse_the_whole_result_by_field() {
let ok = json!({"path": square(), "fills": [red()]});
let stop = |offset: f64| json!({"type": "gradient", "gradient": {"kind": "Linear", "stops": [{"offset": offset, "color": {"model": "rgb", "r": 0, "g": 0, "b": 0}}]}});
let many: Vec<Value> = (0..=MAX_ITEMS).map(|_| red()).collect();
let stops: Vec<Value> = (0..=MAX_ITEMS).map(|_| json!({"offset": 0, "color": {"model": "gray", "k": 0}})).collect();
for (piece, at) in [
(json!({"path": square(), "fills": [{"type": "plaid"}]}), "objects[1].fills[0]"),
(json!({"path": square(), "fills": [{"type": "solid", "color": {"model": "rgb", "r": 2, "g": 0, "b": 0}}]}), "objects[1].fills[0].color"),
(json!({"path": square(), "fills": [{"type": "solid", "color": {"model": "rgb", "r": 1e300, "g": 0, "b": 0}}]}), "objects[1].fills[0].color"),
(json!({"path": square(), "fills": [{"type": "solid", "color": {"model": "gray", "k": 0.5}, "tint": 1.5}]}), "objects[1].fills[0].tint"),
(json!({"path": square(), "strokes": [{"paint": red(), "width": 1001}]}), "objects[1].strokes[0].width"),
(json!({"path": square(), "strokes": [{"paint": red(), "width": -1}]}), "objects[1].strokes[0].width"),
(json!({"path": square(), "strokes": [{"paint": red(), "miterLimit": 0.5}]}), "objects[1].strokes[0].miterLimit"),
(json!({"path": square(), "strokes": [{"paint": red(), "join": "wobbly"}]}), "objects[1].strokes[0].join"),
(json!({"path": square(), "strokes": [{"paint": red(), "dashes": [1, 2]}]}), "objects[1].strokes[0].dashes"),
(json!({"path": square(), "fills": [stop(1.5)]}), "objects[1].fills[0].gradient.stops[0].offset"),
(
json!({"path": square(), "fills": [{"type": "gradient", "gradient": {"kind": "Linear", "stops": stops}}]}),
"objects[1].fills[0].gradient.stops",
),
(json!({"path": square(), "fills": many}), "objects[1].fills"),
(json!({"path": square(), "fills": [red()], "opacity": 0.5}), "objects[1].opacity"),
(json!({"path": square(), "effects": []}), "objects[1].effects"),
(json!({"path": square(), "guide": true}), "objects[1].guide"),
(json!({"path": square(), "locked": true}), "objects[1].locked"),
(json!({"fills": [red()]}), "objects[1].path"),
(json!({"path": square(), "fillRule": "inside"}), "objects[1].fillRule"),
(json!({"path": {"subpaths": [{"closed": true, "anchors": [{"p": [1e9, 0]}]}]}}), "objects[1].path"),
] {
let e = decode(&out(json!([ok, piece])), Scope::Object, FillRule::NonZero).unwrap_err();
let e = e.to_string();
assert!(e.contains(&format!("{at}:")), "{at}: {e}");
}
let e = decode(&serde_json::to_vec(&json!({"objects": [ok], "documentData": {}})).unwrap(), Scope::Object, FillRule::NonZero).unwrap_err();
assert!(e.to_string().contains("documentData"), "{e}");
let declined = decode(br#"{"error":"nothing to do"}"#, Scope::Object, FillRule::NonZero).unwrap_err();
assert_eq!(declined, Error::Failed("nothing to do".into()));
}
/// A key a paint does not have, at any level of it, refuses the result and is named; it is not
/// dropped.
#[test]
fn unknown_keys_inside_a_paint_are_refused_by_field() {
let gray = json!({"model": "gray", "k": 0});
let stop = |extra: Value| {
let mut s = json!({"offset": 0, "color": gray});
s.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
json!({"type": "gradient", "gradient": {"kind": "Linear", "stops": [s]}})
};
let point = json!({"x": 0, "y": 0});
for (fill, at) in [
(json!({"type": "solid", "color": {"model": "rgb", "r": 1, "g": 0, "b": 0}, "opacity": 0.5}), "objects[0].fills[0].opacity"),
(json!({"type": "solid", "color": {"model": "gray", "k": 0, "alpha": 0.5}}), "objects[0].fills[0].color.alpha"),
(json!({"type": "none", "color": gray}), "objects[0].fills[0].color"),
(json!({"type": "pattern", "pattern": "Dots", "scale": 2}), "objects[0].fills[0].scale"),
(stop(json!({"blend": "multiply"})), "objects[0].fills[0].gradient.stops[0].blend"),
(stop(json!({"color": {"model": "lab", "l": 50, "a": 0, "b": 0, "c": 1}})), "objects[0].fills[0].gradient.stops[0].color.c"),
(
json!({"type": "gradient", "gradient": {"kind": "Linear", "stops": [{"offset": 0, "color": gray}], "opacity": 0.5}}),
"objects[0].fills[0].gradient.opacity",
),
(
json!({"type": "gradient", "gradient": {"kind": "Linear", "stops": [{"offset": 0, "color": gray}]}, "opacity": 0.5}),
"objects[0].fills[0].opacity",
),
(
json!({"type": "gradient", "gradient": {"kind": "Radial", "stops": [{"offset": 0, "color": gray}]},
"geom": {"start": point, "end": {"x": 1, "y": 0, "z": 0}}}),
"objects[0].fills[0].geom.end.z",
),
(
json!({"type": "gradient", "gradient": {"kind": "Freeform", "stops": [{"offset": 0, "color": gray}]},
"freeform": {"points": [{"at": point, "color": gray, "glow": 1}]}}),
"objects[0].fills[0].freeform.points[0].glow",
),
] {
let e = decode(&out(json!([{"path": square(), "fills": [fill]}])), Scope::Object, FillRule::NonZero).unwrap_err().to_string();
assert!(e.contains(&format!("{at}:")), "{at}: {e}");
}
let e = decode(
&out(json!([{"path": square(), "strokes": [{"paint": {"type": "solid", "color": gray, "opacity": 0.5}}]}])),
Scope::Object,
FillRule::NonZero,
)
.unwrap_err()
.to_string();
assert!(e.contains("objects[0].strokes[0].paint.opacity:"), "{e}");
// Every key a paint does have is still taken.
let full = json!({"type": "gradient", "angle": 30, "swatch": "Ramp",
"gradient": {"kind": "Freeform", "dither": true, "method": "Perceptual",
"stops": [{"offset": 0, "color": gray, "opacity": 1, "midpoint": 0.5, "swatch": "Ink", "tint": 0.5, "plates": [0, 0, 0, 0]}]},
"geom": {"start": point, "end": {"x": 1, "y": 0}, "aspect": 1, "focal": point},
"freeform": {"points": [{"at": point, "color": gray, "opacity": 1, "spread": 0.5}], "lines": [[0]], "mode": "lines"}});
let solid = json!({"type": "solid", "color": {"model": "cmyk", "c": 0, "m": 0, "y": 0, "k": 1}, "swatch": "Ink", "tint": 0.5});
let pattern = json!({"type": "pattern", "pattern": "Dots", "xf": [1, 0, 0, 1, 0, 0]});
assert!(
decode(&out(json!([{"path": square(), "fills": [full, solid, pattern, {"type": "none"}, null]}])), Scope::Object, FillRule::NonZero).is_ok()
);
}
/// The swatches and patterns a piece's paints name must be in the document.
#[test]
fn named_swatches_and_patterns_must_exist() {
let mut doc = Document::new(100.0, 100.0);
doc.swatches.push(Swatch { name: "Brand".into(), paint: Paint::solid(Color::rgb(0.2, 0.4, 0.6)), global: true, spot: false });
let linked = |name: &str| json!({"type": "solid", "color": {"model": "rgb", "r": 0.2, "g": 0.4, "b": 0.6}, "swatch": name});
let pieces = |pieces: Value| decode(&out(pieces), Scope::Object, FillRule::NonZero).unwrap();
let check = |p: Value| check_references(&pieces(p), Some(&doc));
assert!(check(json!([{"path": square(), "fills": [linked("Brand")]}])).is_ok());
let e = check(json!([{"path": square()}, {"path": square(), "strokes": [{"paint": linked("Gone")}]}])).unwrap_err().to_string();
assert!(e.contains("objects[1].strokes[0].paint.swatch"), "{e}");
let stop =
json!({"type": "gradient", "gradient": {"kind": "Linear", "stops": [{"offset": 0, "color": {"model": "gray", "k": 0}, "swatch": "Gone"}]}});
let e = check(json!([{"path": square(), "fills": [stop]}])).unwrap_err().to_string();
assert!(e.contains("objects[0].fills[0].gradient.stops[0].swatch"), "{e}");
let e = check(json!([{"path": square(), "fills": [{"type": "pattern", "pattern": "Dots"}]}])).unwrap_err().to_string();
assert!(e.contains("objects[0].fills[0].pattern"), "{e}");
// Without a document to look them up in, no swatch or pattern may be named.
assert!(check_references(&pieces(json!([{"path": square(), "fills": [red()]}])), None).is_ok());
let e = check_references(&pieces(json!([{"path": square(), "fills": [linked("Brand")]}])), None).unwrap_err().to_string();
assert!(e.contains("objects[0].fills[0].swatch"), "{e}");
let e = check_references(&pieces(json!([{"path": square(), "fills": [{"type": "pattern", "pattern": "Dots"}]}])), None).unwrap_err().to_string();
assert!(e.contains("objects[0].fills[0].pattern"), "{e}");
}
+2 -1
View File
@@ -14,7 +14,8 @@ use serde::{Deserialize, Serialize};
use crate::{Error, Kind, Result};
/// The plug-in API level this host implements. A manifest's `api` (1 when left out) may be at most
/// this.
/// this. Level 2 adds the host imports ([`PROGRESS_API`]) and a live effect's `paintOutput`
/// ([`crate::painted`]).
pub const API_LEVEL: u32 = 2;
/// The API level that adds the host imports ([`crate::host::IMPORTS`]) and the `progress`,
+2 -1
View File
@@ -71,7 +71,8 @@ fn in_safe_mode_nothing_loads_or_runs() {
assert_eq!(registry::load_folder(&dir), Err(Error::SafeMode));
assert!(registry::get("test.safe.folder").is_none());
let path = PathData::default();
let drawn = effect::apply("test.safe.effect", &json!({}), &path, Rect::new(0.0, 0.0, 1.0, 1.0), FillRule::NonZero, effect::Paints::default());
let drawn =
effect::apply("test.safe.effect", &json!({}), &path, Rect::new(0.0, 0.0, 1.0, 1.0), FillRule::NonZero, effect::Paints::default(), None);
assert_eq!(drawn, None, "the geometry is drawn unchanged");
assert_eq!(effect::last_error("test.safe.effect"), None, "not a failure of the plug-in");
assert_eq!(Error::SafeMode.to_string(), "Safe Mode is on: plug-ins don't load or run until VectorCraft starts normally");
+31 -11
View File
@@ -10,6 +10,7 @@ use std::cell::RefCell;
use std::hash::{Hash, Hasher};
use std::sync::Arc;
use vectorcraft_color::cms::Model;
use vectorcraft_color::freeform::{grid_size, painted_box, spread_scale};
use vectorcraft_color::{Freeform, GradientPaint};
use vectorcraft_geom::{Affine, Rect};
@@ -24,6 +25,10 @@ thread_local! {
static GRIDS: RefCell<FrameCache<u64, Arc<Pixmap>>> = RefCell::new(FrameCache::new(lru::FREEFORM_BUDGET, usize::MAX));
}
/// Most cells along a side of a grid of Lab points on an ink plane, where each cell separates its
/// color through the profile; the paint smooths the grid.
const MAX_LAB_PLANE_CELLS: u16 = 64;
/// Cache key of the grid of `f` on box `b` at `cols` × `rows` painted with `ink` (and the active
/// colour settings, which turn colours into display RGB or inks).
fn key(f: &Freeform, b: Rect, cols: u16, rows: u16, ink: Ink) -> u64 {
@@ -38,18 +43,33 @@ fn key(f: &Freeform, b: Rect, cols: u16, rows: u16, ink: Ink) -> u64 {
h.finish()
}
/// The grid size of `f` on box `b` drawn `device` pixels long with `ink`.
pub(crate) fn cells(f: &Freeform, b: Rect, device: f64, ink: Ink) -> (u16, u16) {
let (cols, rows) = grid_size(b, device);
if f.shared_model() == Some(Model::Lab) && matches!(ink.base(), Ink::Cmy | Ink::K) {
return (cols.min(MAX_LAB_PLANE_CELLS), rows.min(MAX_LAB_PLANE_CELLS));
}
(cols, rows)
}
/// Sample `f` at the centres of a `cols` × `rows` grid over `b` (premultiplied, colours as `ink`
/// paints them).
/// paints them). Points that share a color model mix in that model ([`Freeform::model_field`]);
/// points in different models, and points that `ink` paints as RGB black, mix as `ink` paints each
/// of them.
pub(crate) fn rasterize(f: &Freeform, b: Rect, cols: u16, rows: u16, ink: Ink) -> Pixmap {
let field = f.field_with(spread_scale(b), &|c| ink.rgb(c));
let data = field
.grid(b, cols, rows)
.map(|([r, g, bl], a)| {
let a = a.clamp(0.0, 1.0);
let q = |v: f32| (v.clamp(0.0, 1.0) * a * 255.0).round() as u8;
vello_cpu::color::PremulRgba8 { r: q(r), g: q(g), b: q(bl), a: (a * 255.0).round() as u8 }
})
.collect();
let adjusted = ink.freeform(f);
let (f, ink) = adjusted.as_ref().map_or((f, ink), |a| (a, ink.base()));
let cell = |[r, g, bl]: [f32; 3], a: f32| {
let a = a.clamp(0.0, 1.0);
let q = |v: f32| (v.clamp(0.0, 1.0) * a * 255.0).round() as u8;
vello_cpu::color::PremulRgba8 { r: q(r), g: q(g), b: q(bl), a: (a * 255.0).round() as u8 }
};
let scale = spread_scale(b);
let data = if f.shared_model().is_some() && !ink.blacks_a_point(f) {
f.model_field(scale).grid(b, cols, rows).map(|(c, a)| cell(ink.rgb_sampled(&c), a)).collect()
} else {
f.field_with(scale, &|c| ink.rgb(c)).grid(b, cols, rows).map(|(rgb, a)| cell(rgb, a)).collect()
};
Pixmap::from_parts(data, cols, rows)
}
@@ -58,8 +78,8 @@ pub(crate) fn rasterize(f: &Freeform, b: Rect, cols: u16, rows: u16, ink: Ink) -
pub(crate) fn set_freeform_paint(ctx: &mut RenderContext, g: &GradientPaint, bounds: Rect, ink: Ink) -> bool {
let Some(b) = painted_box(bounds) else { return false };
let device = b.width().max(b.height()) * ctx.transform().determinant().abs().sqrt();
let (cols, rows) = grid_size(b, device);
let f = g.freeform_on(b);
let (cols, rows) = cells(&f, b, device, ink);
let k = key(&f, b, cols, rows, ink);
let pm = GRIDS
.with(|c| {
+1
View File
@@ -74,6 +74,7 @@ pub(crate) use bounds::{cull_bounds, drawn_outline, drawn_outlines, painted_reac
#[cfg(test)]
pub(crate) use entries::Drawn;
pub(crate) use entries::{DocInputs, FxGeom, Inputs, Reads, ShadowEntry};
pub(crate) use pixels::paint_names;
pub(crate) use predicates::{has_fx, has_object_fx};
#[cfg(test)]
pub(crate) use raster::RASTERIZED;
+44 -23
View File
@@ -28,9 +28,10 @@ pub(super) fn apply(effects: &[Effect], bp: &BezPath, ctx: &GeomContext) -> BezP
effects::apply_geometry_bez(effects, bp, bp.bounding_box(), ctx)
}
/// The object-level effected geometry of `n` (base path `bp`).
pub(crate) fn effected_path(n: &Node, bp: &BezPath) -> BezPath {
apply(&n.appearance.effects, bp, &GeomContext::of(n))
/// The object-level effected geometry of `n` (base path `bp`), with the swatches and patterns that
/// live-effect plug-ins painting their pieces name looked up in `doc`.
pub(crate) fn effected_path(doc: &Document, n: &Node, bp: &BezPath) -> BezPath {
apply(&n.appearance.effects, bp, &GeomContext { doc: Some(doc), ..GeomContext::of(n) })
}
/// [`apply`], and how gradients follow the effects ([`effects::Carry`]).
@@ -42,8 +43,8 @@ pub(super) fn apply_carrying(effects: &[Effect], bp: &BezPath, ctx: &GeomContext
}
/// [`effected_path`], and how `n`'s gradients follow its effects.
pub(crate) fn effected_path_carrying(n: &Node, bp: &BezPath) -> (BezPath, effects::Carry) {
apply_carrying(&n.appearance.effects, bp, &GeomContext::of(n))
pub(crate) fn effected_path_carrying(doc: &Document, n: &Node, bp: &BezPath) -> (BezPath, effects::Carry) {
apply_carrying(&n.appearance.effects, bp, &GeomContext { doc: Some(doc), ..GeomContext::of(n) })
}
pub(super) fn item_effects(item: &AppearanceItem) -> &[Effect] {
@@ -62,9 +63,12 @@ pub(crate) fn visual_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
b.inflate(o, o)
});
}
if let Some(art) = painted_art(symbols, n) {
return art.visual_bounds_with(symbols).map(|r| with_raster_reach(symbols, n, r));
}
let bp = node_bezpath(n)?;
let ctx = GeomContext::of(n);
let g = effected_path(n, &bp);
let ctx = GeomContext { doc: Some(symbols.document()), ..GeomContext::of(n) };
let g = effected_path(symbols.document(), n, &bp);
let mut r: Option<Rect> = (!g.elements().is_empty()).then(|| g.bounding_box());
for item in &n.appearance.items {
let fx = item_effects(item);
@@ -77,13 +81,21 @@ pub(crate) fn visual_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
}
}
let s = n.appearance.outset();
r.map(|r| {
let r = r.inflate(s, s);
// The fills' and strokes' own raster effects, then the object's.
let r = n.appearance.items.iter().filter(|i| paints(i)).map(|i| spread(symbols, item_effects(i), r)).fold(r, |acc, b| acc.union(b));
let o = outset(symbols, &n.appearance.effects, r);
r.inflate(o, o)
})
r.map(|r| with_raster_reach(symbols, n, r.inflate(s, s)))
}
/// `r`, the art of `n` with its strokes, grown by how far the raster effects of `n`'s fills and
/// strokes and then `n`'s own paint past it.
fn with_raster_reach(symbols: &SymbolBoxes, n: &Node, r: Rect) -> Rect {
let r = n.appearance.items.iter().filter(|i| paints(i)).map(|i| spread(symbols, item_effects(i), r)).fold(r, |acc, b| acc.union(b));
let o = outset(symbols, &n.appearance.effects, r);
r.inflate(o, o)
}
/// The art of `n` with each piece a live-effect plug-in returns and the paints of that piece
/// ([`effects::has_painted_effects`]); `None` for any other object.
fn painted_art(symbols: &SymbolBoxes, n: &Node) -> Option<Node> {
effects::has_painted_effects(n).then(|| effects::reshape(Some(symbols.document()), n, None)).flatten()
}
/// Does `item` paint (as [`Renderer::paint_items`] draws it)?
@@ -99,21 +111,27 @@ fn paints(item: &AppearanceItem) -> bool {
pub(crate) fn drawn_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
let b = art_bounds(symbols, n)?;
let items = n.appearance.items.iter().filter(|i| paints(i) && !effects::raster_effects(item_effects(i)).is_empty());
Some(items.map(|i| spread(symbols, item_effects(i), item_bounds(n, i).unwrap_or(b))).fold(b, |acc, r| acc.union(r)))
Some(items.map(|i| spread(symbols, item_effects(i), item_bounds(symbols.document(), n, i).unwrap_or(b))).fold(b, |acc, r| acc.union(r)))
}
/// The bounds of what `item`, a fill or stroke of the path or compound path `n`, paints: the path
/// with `n`'s and `item`'s geometry effects, and for a stroke its width. `None` for other objects,
/// 3D art, a brushed stroke and an item that draws no path.
fn item_bounds(n: &Node, item: &AppearanceItem) -> Option<Rect> {
/// 3D art, a path whose live-effect plug-ins paint their pieces, a brushed stroke and an item that
/// draws no path.
fn item_bounds(doc: &Document, n: &Node, item: &AppearanceItem) -> Option<Rect> {
let stroke = matches!(item, AppearanceItem::Stroke(_));
if !has_fx(n) || effects::has_revolve(n) || effects::has_solid(n) || (stroke && vectorcraft_brush::has_brush(n)) {
if !has_fx(n)
|| effects::has_revolve(n)
|| effects::has_solid(n)
|| effects::has_painted_effects(n)
|| (stroke && vectorcraft_brush::has_brush(n))
{
return None;
}
let g = effected_path(n, &node_bezpath(n)?);
let g = effected_path(doc, n, &node_bezpath(n)?);
let fx = item_effects(item);
if effects::has_geometry(fx) {
let ig = apply(fx, &g, &GeomContext::of(n).item(item));
let ig = apply(fx, &g, &GeomContext { doc: Some(doc), ..GeomContext::of(n) }.item(item));
let o = if let AppearanceItem::Stroke(s) = item { s.reach() } else { 0.0 };
return (!ig.elements().is_empty()).then(|| ig.bounding_box().inflate(o, o));
}
@@ -168,14 +186,17 @@ fn shape_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
if let Some(art) = effects::revolve_art(n).or_else(|| effects::solid_art(n)) {
return drawn_bounds(symbols, &art);
}
let g = effected_path(n, &node_bezpath(n)?);
if let Some(art) = painted_art(symbols, n) {
return art.visual_bounds_with(symbols);
}
let g = effected_path(symbols.document(), n, &node_bezpath(n)?);
// The path as the fills and strokes without geometry effects of their own paint it
// (as every fill and stroke would when none paints).
let mut plain = n.appearance.clone();
plain.items.retain(|i| !(paints(i) && effects::has_geometry(item_effects(i))));
let on_path = !n.appearance.items.iter().any(paints) || plain.items.iter().any(paints);
let mut r = (on_path && !g.elements().is_empty()).then(|| plain.stroked_bounds(g.bounding_box(), || g.clone()));
let ctx = GeomContext::of(n);
let ctx = GeomContext { doc: Some(symbols.document()), ..GeomContext::of(n) };
for item in n.appearance.items.iter().filter(|i| paints(i) && effects::has_geometry(item_effects(i))) {
let ig = apply(item_effects(item), &g, &ctx.item(item));
let o = if let AppearanceItem::Stroke(s) = item { s.reach() } else { 0.0 };
@@ -240,7 +261,7 @@ pub(crate) fn drawn_outline(doc: &Document, n: &Node) -> Option<(BezPath, FillRu
}
match &n.kind {
NodeKind::Path { rule, .. } | NodeKind::Compound { rule, .. } if effects::has_geometry(&n.appearance.effects) => {
Some((effected_path(n, &node_bezpath(n)?), *rule))
Some((effected_path(doc, n, &node_bezpath(n)?), *rule))
}
_ => effects::clip_outline(n),
}
+11 -7
View File
@@ -314,17 +314,21 @@ impl Inputs {
/// What raster contents read from the document by name or key, and the color settings, as a frame
/// finds them ([`Renderer::doc_inputs`]): hashes of the patterns' tilings
/// ([`crate::pattern::hash_tiling`]), of the symbols' names and art and of the images' keys and
/// pixels, the address of the color settings, and whether the patterns' and symbols' art has a
/// plug-in effect. Art, pixels and previews are hashed by the addresses of their shared values,
/// and names, keys, tiles, tile types and overlaps are hashed by value. The live cache keeps these
/// inputs with each evaluated live object, with each mesh's quads and with what each walk of a
/// live object or of effect art found when it found no placed file (`crate::live`), and
/// [`Renderer::fx_art`] keeps them with art that can be made from evaluated live objects.
/// ([`crate::pattern::hash_tiling`]), of the symbols' names and art, of the images' keys and
/// pixels and of the swatches' and patterns' names, the address of the color settings, and whether
/// the patterns' and symbols' art has a plug-in effect. Art, pixels and previews are hashed by the
/// addresses of their shared values, and names, keys, tiles, tile types and overlaps are hashed by
/// value. The live cache keeps these inputs with each evaluated live object, with each mesh's quads
/// and with what each walk of a live object or of effect art found when it found no placed file
/// (`crate::live`), and [`Renderer::fx_art`] keeps them with art that can be made from evaluated
/// live objects.
pub(crate) struct DocInputs {
pub(super) patterns: u64,
pub(super) symbols: u64,
pub(super) images: u64,
/// The swatches' and patterns' names, which the pieces of live-effect plug-ins that paint them
/// may name (`crate::live`).
pub(crate) names: u64,
pub(crate) cms: usize,
/// Whether an object in a pattern's or a symbol's art, or in an opacity mask there, has a
/// visible plug-in effect.
+25 -6
View File
@@ -11,6 +11,22 @@ use super::{
RasterFx, Rect, RenderContext, Renderer, Shape, blend_mode, effects, fill_rule, paint, peniko,
};
/// A hash of the names of `doc`'s swatches and patterns, of the paints and spot flags of its global
/// swatches, and of the Spot Colors option those colors depend on.
pub(crate) fn paint_names(doc: &Document) -> u64 {
let mut h = DefaultHasher::new();
doc.spot_use_lab.hash(&mut h);
doc.swatches_iter().for_each(|s| {
s.name.hash(&mut h);
if s.global {
s.spot.hash(&mut h);
format!("{:?}", s.paint).hash(&mut h);
}
});
doc.patterns.iter().for_each(|p| p.name.hash(&mut h));
h.finish()
}
impl Renderer {
/// Paint `content` through the content-changing effects of `rfx` (Gaussian Blur, Feather, the
/// Photoshop-style filters and Rasterize) in stack order on its pixels: rendered offscreen over
@@ -398,8 +414,10 @@ impl Renderer {
/// [`crate::RenderOptions::kept_images_only`], also which pixels and undecodable notes the
/// image store holds); for type the fonts, whether overprinting shows
/// ([`crate::proof::overprints`]) and Document Setup's substitution highlights; and the
/// plug-ins installed, when an object in it has a plug-in effect, and when it shows symbol or
/// pattern art and an object in the document's symbol or pattern art has one.
/// plug-ins installed, with the names of the document's swatches and patterns and its pattern
/// definitions (a plug-in's painted pieces can name them), when an object in it has a plug-in
/// effect, and when it shows symbol or pattern art and an object in the document's symbol or
/// pattern art has one.
pub(crate) fn raster_inputs(&mut self, f: &Frame, n: &Node, reads: Reads) -> Inputs {
let doc = self.doc_inputs(f.doc);
let mut h = DefaultHasher::new();
@@ -442,7 +460,7 @@ impl Renderer {
(vectorcraft_text::FontDb::global().generation(), crate::proof::overprints(f.opts), highlights).hash(&mut h);
}
if reads.plugins || (doc.plugins && (reads.symbols || reads.patterns)) {
effects::plugin_revision().hash(&mut h);
(effects::plugin_revision(), doc.names, doc.patterns).hash(&mut h);
}
Inputs { reads, key: h.finish(), _doc: doc }
}
@@ -470,15 +488,16 @@ impl Renderer {
(key, Arc::as_ptr(&blob.bytes) as usize, blob.proxy.as_ref().map(|p| Arc::as_ptr(p) as usize)).hash(h);
}
});
let found = (patterns, symbols, images, Arc::as_ptr(&cms) as usize);
let names = paint_names(doc);
let found = (patterns, symbols, images, names, Arc::as_ptr(&cms) as usize);
let d = match &self.doc_inputs {
Some((_, d)) if (d.patterns, d.symbols, d.images, d.cms) == found => d.clone(),
Some((_, d)) if (d.patterns, d.symbols, d.images, d.names, d.cms) == found => d.clone(),
_ => {
let doc_art = || doc.patterns.iter().flat_map(|p| &p.art).chain(doc.symbols.iter().map(|s| &s.art));
let art = doc_art().map(Arc::downgrade).collect();
let pixels = doc.images.values().flat_map(|b| std::iter::once(&b.bytes).chain(&b.proxy)).map(Arc::downgrade).collect();
let plugins = doc_art().any(|a| crate::holds_plugin_effect(a));
Arc::new(DocInputs { patterns, symbols, images, cms: found.3, plugins, _held: (art, pixels, Arc::downgrade(&cms)) })
Arc::new(DocInputs { patterns, symbols, images, names, cms: found.4, plugins, _held: (art, pixels, Arc::downgrade(&cms)) })
}
};
self.doc_inputs = Some((at, d.clone()));
+5 -3
View File
@@ -53,9 +53,10 @@ pub(super) fn evaluates_live(n: &Node) -> bool {
/// Can the art [`Renderer::fx_art`] makes for `n` change while `n` stays the same, other than
/// through live objects it evaluates? It can when `n` holds type (laid out in the fonts installed),
/// a symbol instance (made from its symbol's art), an object with a 3D effect or a color
/// adjustment (shaded or recolored through the color settings), or one with Vector Halftone (its
/// dots sampled from everything it draws), in it or in the opacity masks of
/// the objects in it.
/// adjustment (shaded or recolored through the color settings), one with Vector Halftone (its
/// dots sampled from everything it draws), or one with a live-effect plug-in that paints its pieces
/// (drawn only while the swatches and patterns they name are in the document), in it or in the
/// opacity masks of the objects in it.
pub(super) fn reads_outside(n: &Node) -> bool {
let mut reads = false;
n.walk(&mut |c| {
@@ -64,6 +65,7 @@ pub(super) fn reads_outside(n: &Node) -> bool {
|| effects::has_solid(c)
|| effects::has_adjustment(c)
|| effects::addons::halftone::has_halftone(c)
|| effects::has_painted_plugin_effect(c)
|| c.extras().mask.as_ref().is_some_and(|m| reads_outside(&m.art))
});
reads
+3 -3
View File
@@ -340,7 +340,7 @@ impl Renderer {
let e = match self.fx_geom.remove(&key) {
Some(e) if Arc::ptr_eq(&e.node, &o) => e,
_ => {
let (g, carry) = effected_path_carrying(n, bp);
let (g, carry) = effected_path_carrying(f.symbols.document(), n, bp);
FxGeom { g: Arc::new(g), carried: carried(n, &carry), items: vec![None; n.appearance.items.len()], node: o, stamp }
}
};
@@ -349,7 +349,7 @@ impl Renderer {
let (g, carried) = match &cached {
Some((_, e)) => (e.g.clone(), e.carried.clone()),
None => {
let (g, carry) = effected_path_carrying(n, bp);
let (g, carry) = effected_path_carrying(f.symbols.document(), n, bp);
(Arc::new(g), carried(n, &carry))
}
};
@@ -380,7 +380,7 @@ impl Renderer {
self.hairline(ctx, f, g, [0, 0, 0, 255]);
return;
}
let gctx = GeomContext::of(paints);
let gctx = GeomContext { doc: Some(f.symbols.document()), ..GeomContext::of(paints) };
let rfx = effects::raster_effects(&n.appearance.effects);
// Document-space reach of the painted geometry (strokes, arrowheads…).
let outset = n.appearance.outset();
+24
View File
@@ -7,6 +7,30 @@ use super::{DRAG_PIXEL_BUDGET, Drawn, PIXEL_BUDGET, drag_scale, filter_region, f
use crate::tests_fontchange::{fonts_steady, with_fonts_unchanged};
use crate::{RenderOptions, Rendered, Renderer};
/// The hash renderers compare to redraw art linked to swatches changes with a global swatch's
/// color and spot flag and with the Spot Colors option.
#[test]
fn paint_names_follow_global_swatch_colors_and_spot_flags() {
let mut d = Document::new(100.0, 100.0);
d.swatches.push(vectorcraft_doc::color::Swatch {
name: "Brand".into(),
paint: Paint::solid(Color::rgb(1.0, 0.0, 0.0)),
global: true,
spot: false,
});
let last = d.swatches.len() - 1;
let first = super::paint_names(&d);
d.swatches[last].spot = true;
assert_ne!(super::paint_names(&d), first, "spot flag");
d.swatches[last].spot = false;
assert_eq!(super::paint_names(&d), first, "nothing changed");
d.swatches[last].paint = Paint::solid(Color::rgb(0.0, 0.0, 1.0));
assert_ne!(super::paint_names(&d), first, "color");
d.swatches[last].paint = Paint::solid(Color::rgb(1.0, 0.0, 0.0));
d.spot_use_lab = !d.spot_use_lab;
assert_ne!(super::paint_names(&d), first, "Spot Colors option");
}
/// #1706: the raster a shadow or glow is filtered in stays within the pixel budget however
/// far the blur spreads at the zoom (it was up to 65535 pixels a side: 16 GB).
#[test]
+27 -1
View File
@@ -20,7 +20,7 @@ use std::sync::{Arc, Mutex};
use vectorcraft_color::blend::{cmyk_planes, planes_cmyk};
use vectorcraft_color::cms::{self, Cms, CmykLut, ProofLut, lab};
use vectorcraft_color::{Color, Gradient};
use vectorcraft_color::{Color, Freeform, Gradient};
use vectorcraft_doc::cmyk::Inks;
use vectorcraft_doc::{ColorMode, Document, ImageBlob};
use vello_cpu::Pixmap;
@@ -126,6 +126,23 @@ impl Ink {
Some(g)
}
/// Transform original freeform points before their colors mix, as [`Self::gradient`] does for
/// stops.
pub(crate) fn freeform(self, f: &Freeform) -> Option<Freeform> {
let amount = self.darkness()?;
let mut f = f.clone();
for point in &mut f.points {
point.color = Self::darkened_color(&point.color, amount, self.black_endpoint());
}
Some(f)
}
/// Whether this ink paints a point of `f` as RGB black: Appearance of Black's Rich setting and
/// a point of pure CMYK black.
pub(crate) fn blacks_a_point(self, f: &Freeform) -> bool {
self.base() == Ink::RichBlack && f.points.iter().any(|p| matches!(p.color, Color::Cmyk { c, m, y, k } if is_pure_black([c, m, y, k])))
}
/// The three values painted for colour `c`.
pub(crate) fn rgb(self, c: &Color) -> [f32; 3] {
if let Some(amount) = self.darkness() {
@@ -138,6 +155,15 @@ impl Ink {
}
}
/// [`Self::rgb`] for colors sampled densely (a freeform gradient's grid): on an ink plane, RGB
/// colors go through the separation tables placed images use.
pub(crate) fn rgb_sampled(self, c: &Color) -> [f32; 3] {
match (self, c) {
(Ink::Cmy | Ink::K, Color::Rgb { .. }) => separation().0[(self == Ink::K) as usize].apply(c.to_rgb()),
_ => self.rgb(c),
}
}
/// [`Self::rgb`] as a paint, at `alpha`.
pub(crate) fn color(self, c: &Color, alpha: f32) -> peniko::Color {
let [r, g, b] = self.rgb(c);
+6
View File
@@ -76,6 +76,12 @@ pub struct Renderer {
/// The plug-in revision ([`effects::plugin_revision`]) that `geom`, `fx_geom` and `fx_arts` were
/// last checked against (see [`Self::check_plugins`]).
plugin_revision: u64,
/// Is a live-effect plug-in that paints its pieces installed (found when `plugin_revision`
/// changes)?
painting_plugins: bool,
/// The hash of the document's swatch and pattern names at the last check, kept while
/// `painting_plugins` holds (see [`Self::check_plugins`]).
paint_names: u64,
/// Expanded stroke outlines keyed by ([`Self::stroke_owner`], stroke layer address, width bits,
/// tolerance level): panning re-renders reuse them instead of re-expanding every stroke.
strokes: PtrMap<StrokeKey, StrokeEntry>,
+21 -16
View File
@@ -99,17 +99,19 @@ pub fn expand_live(n: &Node) -> Vec<Node> {
/// the fonts, the plug-ins and the color settings, hashed: the art of the symbols, the tilings of
/// the patterns and the reads of the placed files it shows ([`hash_resources`]), the font
/// generation ([`vectorcraft_text::FontDb::generation`]) when it shows type or a placed file, the
/// plug-in revision ([`crate::effects::plugin_revision`]) when it shows an object with a plug-in
/// effect, and the color settings (`cms`, their address). Art is hashed by the addresses of its
/// `Arc`s, which copies of the document share: a copy of the document has the same key. It also
/// returns weak references to the reads found, and what the walk found ([`Walked`]) when it found
/// no placed file.
fn resources_key(doc: &Document, cms: usize, n: &Node) -> (u64, Vec<Weak<ReadDocument>>, Option<Walked>) {
/// plug-in revision ([`crate::effects::plugin_revision`]) and `names` (the hash of the document's
/// swatch and pattern names, [`DocInputs`]) when it shows an object with a plug-in effect, since a
/// plug-in that paints its pieces ([`crate::effects::has_painted_plugin_effect`]) draws them only
/// while the swatches and patterns they name are in the document, and the color settings (`cms`,
/// their address). Art is hashed by the addresses of its `Arc`s, which copies of the document
/// share: a copy of the document has the same key. It also returns weak references to the reads
/// found, and what the walk found ([`Walked`]) when it found no placed file.
fn resources_key(doc: &Document, cms: usize, names: u64, n: &Node) -> (u64, Vec<Weak<ReadDocument>>, Option<Walked>) {
let mut h = DefaultHasher::new();
let mut shown = Shown::default();
hash_resources(doc, n, &mut shown, &mut h);
let walked = Walked { hash: h.finish(), text: shown.text, plugins: shown.plugins };
(walked.key(cms), shown.reads, (!shown.placed).then_some(walked))
(walked.key(cms, names), shown.reads, (!shown.placed).then_some(walked))
}
/// What [`hash_resources`] found in a node: the hash of what it walked, whether it found type or
@@ -123,9 +125,11 @@ struct Walked {
impl Walked {
/// The [`resources_key`] of the node walked: the walk's hash, the font generation when it found
/// type or a placed file, the plug-in revision when it found a plug-in effect, and the color
/// settings `cms`.
fn key(self, cms: usize) -> u64 {
/// type or a placed file, the plug-in revision and the hash of the document's swatch and pattern
/// names (`names`) when it found a plug-in effect, and the color settings `cms`. Whether a
/// plug-in paints its pieces depends on the plug-ins installed, so the names count for every
/// plug-in effect.
fn key(self, cms: usize, names: u64) -> u64 {
let mut h = DefaultHasher::new();
self.hash.hash(&mut h);
if self.text {
@@ -133,6 +137,7 @@ impl Walked {
}
if self.plugins {
crate::effects::plugin_revision().hash(&mut h);
names.hash(&mut h);
}
cms.hash(&mut h);
h.finish()
@@ -445,13 +450,13 @@ impl Renderer {
&& std::ptr::eq(node.as_ptr(), Arc::as_ptr(n))
&& read.same_art(&inputs)
{
return (walked.key(inputs.cms), inputs, vec![]);
return (walked.key(inputs.cms, inputs.names), inputs, vec![]);
}
#[cfg(test)]
{
self.live.walked += 1;
}
let (key, reads, walked) = resources_key(doc, inputs.cms, n);
let (key, reads, walked) = resources_key(doc, inputs.cms, inputs.names, n);
if let Some(walked) = walked {
// The bytes count the key and the entry, and the node's allocation that the weak
// reference keeps.
@@ -478,7 +483,7 @@ impl Renderer {
self.live.evaluated += 1;
}
let hook = |c: &Node| crate::effects::outline_object(Some(doc), c);
let v: Expanded = match crate::effects::pathfinder_children(a, Some(&hook)) {
let v: Expanded = match crate::effects::pathfinder_children(Some(doc), a, Some(&hook)) {
Some(children) => Arc::new(children),
None => Arc::new(crate::effects::expand_live(Some(doc), a).into_iter().map(Arc::new).collect()),
};
@@ -788,7 +793,7 @@ mod tests {
let envelope = Node::new(d.alloc_id(), envelope);
let key = |d: &Document| {
std::thread::scope(|s| {
std::thread::Builder::new().stack_size(256 << 10).spawn_scoped(s, || resources_key(d, 0, &envelope).0).unwrap().join().unwrap()
std::thread::Builder::new().stack_size(256 << 10).spawn_scoped(s, || resources_key(d, 0, 0, &envelope).0).unwrap().join().unwrap()
})
};
let before = key(&d);
@@ -820,8 +825,8 @@ mod tests {
frame: Box::new(Affine::IDENTITY),
};
let envelope = Node::new(d.alloc_id(), envelope);
let before = resources_key(&d, 0, &envelope).0;
let before = resources_key(&d, 0, 0, &envelope).0;
d.symbols[2].art = y.clone();
assert_ne!(resources_key(&d, 0, &envelope).0, before, "the art of every instance is hashed");
assert_ne!(resources_key(&d, 0, 0, &envelope).0, before, "the art of every instance is hashed");
}
}
+1 -1
View File
@@ -185,7 +185,7 @@ fn rasterize(
let opts = RenderOptions { mark_undecoded_images, ..Default::default() };
r.image_left_out = Some(false);
let pattern_images_left_out = images_left_out();
r.check_plugins();
r.check_plugins(doc);
r.check_symbols(doc);
let symbols = doc.symbol_boxes().quick();
r.stamp += 1;
+5 -3
View File
@@ -45,6 +45,8 @@ impl Renderer {
kept: PtrMap::default(),
symbols: vec![],
plugin_revision: 0,
painting_plugins: false,
paint_names: 0,
strokes: PtrMap::default(),
moved_strokes: PtrMap::default(),
char_strokes: FrameCache::new(lru::CHAR_STROKE_BUDGET, 0),
@@ -119,7 +121,7 @@ impl Renderer {
return Rendered { width: w, height: h, pixels };
}
}
self.check_plugins();
self.check_plugins(doc);
// The document's own symbols: proofing rewrites the colors and blend modes of their art.
self.check_symbols(doc);
let prepared = proof::prepare(doc, opts, &mut self.proof_art);
@@ -342,7 +344,7 @@ impl Renderer {
/// [`Self::render_node`] with `opts` (e.g. [`RenderOptions::mark_undecoded_images`]).
pub fn render_node_with(&mut self, doc: &Document, n: &Arc<Node>, w: u16, h: u16, view: Affine, opts: &RenderOptions) -> Option<Rendered> {
self.check_plugins();
self.check_plugins(doc);
self.check_symbols(doc);
let inv = view.inverse();
let visible = inv.transform_rect_bbox(Rect::new(0.0, 0.0, w as f64, h as f64));
@@ -381,7 +383,7 @@ impl Renderer {
background: Option<[u8; 4]>,
opts: &RenderOptions,
) -> Option<Rendered> {
self.check_plugins();
self.check_plugins(doc);
self.check_symbols(doc);
let b = brush_fx::cull_bounds(&doc.symbol_boxes(), n)?;
let w = size.clamp(1, MAX_CONTEXT_SIDE) as u16;
+20 -7
View File
@@ -98,15 +98,28 @@ impl Renderer {
/// objects with a plug-in effect. Rasters of raster effects and evaluations of live objects hold
/// the revision in their keys when what they draw has a plug-in effect (`fx::Inputs`,
/// `live::resources_key`).
pub(crate) fn check_plugins(&mut self) {
///
/// The same caches are dropped when a live-effect plug-in that paints its pieces is installed
/// and the names of `doc`'s swatches or patterns have changed since the last check: whether its
/// pieces are used depends on those names.
pub(crate) fn check_plugins(&mut self, doc: &Document) {
let revision = effects::plugin_revision();
if revision == self.plugin_revision {
return;
let mut stale = false;
if revision != self.plugin_revision {
self.plugin_revision = revision;
self.painting_plugins = effects::any_plugin_paints_output();
stale = true;
}
if self.painting_plugins {
let names = fx::paint_names(doc);
stale |= names != self.paint_names;
self.paint_names = names;
}
if stale {
self.fx_geom.retain(|_, e| !effects::has_plugin_effect(&e.node));
self.geom.retain(|_, e| !holds_plugin_effect(&e.node) && !holds_instance(&e.node));
self.fx_arts.retain(|(node, ..)| !effects::has_plugin_effect(node));
}
self.plugin_revision = revision;
self.fx_geom.retain(|_, e| !effects::has_plugin_effect(&e.node));
self.geom.retain(|_, e| !holds_plugin_effect(&e.node) && !holds_instance(&e.node));
self.fx_arts.retain(|(node, ..)| !effects::has_plugin_effect(node));
}
/// Cached region a clip group's clipping path `a` clips to ([`effects::clip_outline`]):
+108
View File
@@ -51,6 +51,114 @@ fn near(a: [u8; 4], b: [u8; 4], tol: i32) -> bool {
a.iter().zip(b).all(|(x, y)| (*x as i32 - y as i32).abs() <= tol)
}
/// `a` and `b` on either side of (50.5, 50.5): the point a pixel center and a grid cell share.
fn pair(a: Color, b: Color) -> Freeform {
let p = |x, c| FreeformPoint::new(Point::new(x, 50.5), c);
Freeform { points: vec![p(20.5, a), p(80.5, b)], ..Default::default() }
}
const CYAN: Color = Color::Cmyk { c: 1.0, m: 0.0, y: 0.0, k: 0.0 };
const BLACK: Color = Color::Cmyk { c: 0.0, m: 0.0, y: 0.0, k: 1.0 };
/// The middle pixel of a square painted with `pair(a, b)`.
fn middle(a: Color, b: Color) -> [u8; 4] {
render(&doc(shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), paint(Some(pair(a, b))), 1.0)).pixel(50, 50)
}
#[test]
fn points_that_share_a_model_mix_in_it() {
let want = Color::cmyk(0.5, 0.0, 0.0, 0.5).to_rgba8(1.0);
assert!(near(middle(CYAN, BLACK), want, 3), "CMYK: {:?} vs {want:?}", middle(CYAN, BLACK));
let (lab1, lab2) = (Color::lab(90.0, -60.0, 40.0), Color::lab(30.0, 60.0, -40.0));
let want = Color::lab(60.0, 0.0, 0.0).to_rgba8(1.0);
assert!(near(middle(lab1, lab2), want, 3), "Lab: {:?} vs {want:?}", middle(lab1, lab2));
let want = Color::gray(0.5).to_rgba8(1.0);
assert!(near(middle(Color::gray(0.2), Color::gray(0.8)), want, 3), "Gray");
}
#[test]
fn rgb_points_and_points_in_different_models_mix_in_display_rgb() {
let (red, blue) = (Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0));
let want = Color::rgb(0.5, 0.0, 0.5).to_rgba8(1.0);
assert!(near(middle(red, blue), want, 3), "RGB: {:?} vs {want:?}", middle(red, blue));
let gray = Color::gray(0.5);
let [r, g, b] = [red.to_rgb(), gray.to_rgb()].into_iter().fold([0.0; 3], |m, c| [m[0] + c[0] / 2.0, m[1] + c[1] / 2.0, m[2] + c[2] / 2.0]);
let want = Color::rgb(r, g, b).to_rgba8(1.0);
assert!(near(middle(red, gray), want, 3), "mixed models: {:?} vs {want:?}", middle(red, gray));
}
/// The middle grid cell of `pair(a, b)` rasterized on a 101-point box with `ink`, as RGB.
fn middle_cell(a: Color, b: Color, ink: crate::ink::Ink) -> [u8; 3] {
let cell = crate::freeform::rasterize(&pair(a, b), Rect::new(0.0, 0.0, 101.0, 101.0), 17, 17, ink).sample(8, 8);
[cell.r, cell.g, cell.b]
}
#[test]
fn ink_planes_separate_the_mix_of_lab_points() {
use crate::ink::Ink;
let (lab1, lab2) = (Color::lab(90.0, -60.0, 40.0), Color::lab(30.0, 60.0, -40.0));
let mid = Color::lab(60.0, 0.0, 0.0);
// A color far out of gamut stays as exact as a solid fill of it.
let far = Color::lab(20.0, -125.0, 125.0);
for ink in [Ink::Cmy, Ink::K] {
for (a, b, c) in [(lab1, lab2, mid), (far, far, far)] {
let want = ink.color(&c, 1.0).to_rgba8();
let got = middle_cell(a, b, ink);
assert!(near([got[0], got[1], got[2], 255], [want.r, want.g, want.b, 255], 2), "{ink:?} {c:?}: {got:?} vs {want:?}");
}
}
}
#[test]
fn ink_planes_separate_the_mix_of_rgb_points() {
use crate::ink::Ink;
let (red, blue) = (Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0));
let mid = Color::rgb(0.5, 0.0, 0.5);
for ink in [Ink::Cmy, Ink::K] {
let want = ink.color(&mid, 1.0).to_rgba8();
let got = middle_cell(red, blue, ink);
assert!(near([got[0], got[1], got[2], 255], [want.r, want.g, want.b, 255], 3), "{ink:?}: {got:?} vs {want:?}");
}
}
#[test]
fn lab_points_on_an_ink_plane_use_a_coarser_grid() {
use crate::ink::Ink;
let b = Rect::new(0.0, 0.0, 1000.0, 1000.0);
let lab = pair(Color::lab(90.0, -60.0, 40.0), Color::lab(30.0, 60.0, -40.0));
let (cols, rows) = crate::freeform::cells(&lab, b, 4000.0, Ink::Cmy);
assert_eq!((cols, rows), (64, 64));
assert_eq!(crate::freeform::cells(&lab, b, 4000.0, Ink::K), (64, 64));
let (wide, _) = crate::freeform::cells(&lab, b, 4000.0, Ink::Display);
assert!(wide > 64, "{wide}");
let (cmyk, _) = crate::freeform::cells(&pair(CYAN, BLACK), b, 4000.0, Ink::Cmy);
assert!(cmyk > 64, "{cmyk}");
}
#[test]
fn pure_black_points_fade_from_rgb_black_under_rich_black() {
use crate::ink::Ink;
let white = Color::cmyk(0.0, 0.0, 0.0, 0.0);
let [r, g, b] = white.to_rgb();
let want = [r, g, b].map(|v| (v * 0.5 * 255.0).round() as u8);
let got = middle_cell(BLACK, white, Ink::RichBlack);
assert!(near([got[0], got[1], got[2], 255], [want[0], want[1], want[2], 255], 2), "{got:?} vs {want:?}");
// Without a pure black point, the points mix in their model.
let (a, c) = (Color::cmyk(0.0, 0.0, 0.0, 0.9), white);
let want = Color::cmyk(0.0, 0.0, 0.0, 0.45).to_rgba8(1.0);
let got = middle_cell(a, c, Ink::RichBlack);
assert!(near([got[0], got[1], got[2], 255], want, 2), "{got:?} vs {want:?}");
}
#[test]
fn darkening_changes_the_points_before_they_mix() {
use crate::ink::Ink;
// Half black added to each point: K100 stays K100 and K0 becomes K50, so they meet at K75.
let got = middle_cell(BLACK, Color::cmyk(0.0, 0.0, 0.0, 0.0), Ink::Display.darkened(0.5));
let want = Color::cmyk(0.0, 0.0, 0.0, 0.75).to_rgba8(1.0);
assert!(near([got[0], got[1], got[2], 255], want, 2), "{got:?} vs {want:?}");
}
#[test]
fn the_rendered_grid_follows_the_colour_field() {
let f = freeform();
+2 -2
View File
@@ -440,9 +440,9 @@ impl Writer<'_> {
let freeform = g.gradient.kind == GradientKind::Freeform;
let (rgba, cols, rows): (Vec<u8>, u16, u16) = if freeform {
let (cols, rows) = grid_size(b, 2.0 * b.width().max(b.height()));
let field = g.freeform_on(b).field(spread_scale(b));
let field = g.freeform_on(b).model_field(spread_scale(b));
let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
(field.grid(b, cols, rows).flat_map(|([r, g, bl], a)| [q(r), q(g), q(bl), q(a)]).collect(), cols, rows)
(field.display_grid(b, cols, rows).flat_map(|([r, g, bl], a)| [q(r), q(g), q(bl), q(a)]).collect(), cols, rows)
} else {
let k = self.doc.raster_effects_ppi / 72.0;
let along = |len: f64| (len * k).ceil().clamp(1.0, MAX_GRADIENT_PX) as u16;
+23
View File
@@ -41,6 +41,29 @@ fn png_of(svg: &str) -> image::RgbaImage {
image::load_from_memory(&base64_decode(data).unwrap()).unwrap().to_rgba8()
}
#[test]
fn freeform_points_that_share_a_model_are_written_as_their_mix() {
let mid = |a: Color, b: Color| {
let p = |x, c| FreeformPoint::new(Point::new(x, 80.0), c);
let mut g = GradientPaint::new(Gradient { kind: GradientKind::Freeform, ..Default::default() });
g.freeform = Some(Freeform { points: vec![p(40.0, a), p(160.0, b)], ..Default::default() });
let shape = shapes::rectangle(Rect::new(20.0, 20.0, 180.0, 140.0));
let d = doc_with(Node::path(NodeId(0), shape, Appearance::basic(Paint::Gradient(Box::new(g)), Paint::None, 0.0)));
let png = png_of(&export_full(&d, &ExportOptions::default(), None).svg);
png.get_pixel(png.width() / 2, png.height() / 2).0
};
let near = |got: [u8; 4], want: [u8; 4]| got.iter().zip(want).all(|(g, w)| g.abs_diff(w) <= 3);
let got = mid(Color::cmyk(1.0, 0.0, 0.0, 0.0), Color::cmyk(0.0, 0.0, 0.0, 1.0));
let want = Color::cmyk(0.5, 0.0, 0.0, 0.5).to_rgba8(1.0);
assert!(near(got, want), "CMYK: {got:?} vs {want:?}");
let got = mid(Color::lab(90.0, -60.0, 40.0), Color::lab(30.0, 60.0, -40.0));
let want = Color::lab(60.0, 0.0, 0.0).to_rgba8(1.0);
assert!(near(got, want), "Lab: {got:?} vs {want:?}");
let got = mid(Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0));
let want = Color::rgb(0.5, 0.0, 0.5).to_rgba8(1.0);
assert!(near(got, want), "RGB: {got:?} vs {want:?}");
}
#[test]
fn a_freeform_fill_is_an_image_clipped_to_the_shape() {
let shape = shapes::ellipse(Rect::new(20.0, 20.0, 180.0, 140.0));
+6 -1
View File
@@ -148,10 +148,15 @@ pub trait Tool: Send {
false
}
/// Is a drag moving, scaling or rotating the selection (the canvas then hides the bounding box,
/// so only the art is seen going with the pointer)?
/// so only the art is seen going with the pointer, except during a handle resize)?
fn transforming(&self) -> bool {
false
}
/// Is the transforming drag a resize by a bounding-box handle? The canvas then keeps drawing
/// the bounding box at the changing bounds.
fn resizing(&self) -> bool {
false
}
/// Does the tool take `key` now, ahead of the command shortcuts bound to it (the Gradient tool
/// with a stop selected takes Delete and the arrows)?
fn claims_key(&self, _cx: &ToolContext, _key: ToolKey) -> bool {
+4
View File
@@ -822,6 +822,10 @@ impl Tool for SelectionTool {
self.alt_hover
}
fn resizing(&self) -> bool {
matches!(self.state, State::Scaling { .. })
}
fn transforming(&self) -> bool {
match &self.state {
State::Moving { began: true, .. } | State::Scaling { .. } | State::Rotating { .. } => true,
+42
View File
@@ -6,6 +6,7 @@
//! Ctrl/Cmd keeps the inner radius where it is while the outer one follows the pointer, and Alt
//! keeps the star's shoulders straight (#812). The start point and
//! the dragged corner snap to Smart Guides ([`DrawSnap`]), hovering before the press too.
//! Escape cancels the drag and puts back the options its keys and Ctrl/Cmd changed.
//!
//! The selection keeps its bounding box and its Live Shape widgets on show with the Rectangle,
//! Rounded Rectangle, Ellipse, Polygon and Star tools, so a shape just drawn can be adjusted at
@@ -71,6 +72,9 @@ pub struct ShapeTool {
pub line_angle: f64,
pub fill_line: bool,
snap: DrawSnap,
/// The options a drag's keys and Ctrl/Cmd change, as they were when the drag began: Escape
/// puts them back.
before_drag: Option<DragOptions>,
/// A widget of the selection is being dragged instead of a shape drawn.
widget: Option<WidgetDrag>,
/// The selection is being moved or turned instead of a shape drawn.
@@ -84,6 +88,15 @@ pub struct ShapeTool {
snapped: bool,
}
/// The options that keys and Ctrl/Cmd change while a shape is dragged out.
#[derive(Clone, Copy)]
struct DragOptions {
sides: u32,
points: u32,
corner_radius: f64,
star_ratio: f64,
}
/// The inner-to-outer radius ratio that keeps a star of `points` points' shoulders straight (each
/// point's sides in line with the next points' sides: a regular star polygon {n/2}); none for
/// fewer than five points, whose shoulders can't line up.
@@ -117,6 +130,7 @@ impl ShapeTool {
line_length: 100.0,
line_angle: 0.0,
fill_line: false,
before_drag: None,
widget: None,
selection: None,
guides: vec![],
@@ -125,6 +139,18 @@ impl ShapeTool {
}
}
fn drag_options(&self) -> DragOptions {
DragOptions { sides: self.sides, points: self.points, corner_radius: self.corner_radius, star_ratio: self.star_ratio }
}
/// Put back the options a canceled drag changed.
fn restore_drag_options(&mut self, o: DragOptions) {
self.sides = o.sides;
self.points = o.points;
self.corner_radius = o.corner_radius;
self.star_ratio = o.star_ratio;
}
/// A press on a corner or pie widget of the selection: the drag adjusts it.
fn press_widget(&mut self, cx: &ToolContext, ev: &PointerEvent) -> bool {
if !shows_shape_widgets(self.id) {
@@ -428,6 +454,7 @@ impl Tool for ShapeTool {
self.last = ev.pos;
self.began = false;
self.inner = None;
self.before_drag = None;
vec![]
}
PointerKind::Drag => {
@@ -451,6 +478,7 @@ impl Tool for ShapeTool {
return out;
}
self.began = true;
self.before_drag = Some(self.drag_options());
out.push(Action::Begin(self.label().into()));
}
let (c, v) = self.command_at(s, ev.pos, ev.mods, cx.constrain_angle);
@@ -461,6 +489,7 @@ impl Tool for ShapeTool {
let Some(s) = self.start.take() else { return vec![] };
self.snap.clear();
self.inner = None;
self.before_drag = None;
if self.began {
self.began = false;
vec![Action::Commit]
@@ -528,6 +557,9 @@ impl Tool for ShapeTool {
self.start = None;
self.began = false;
self.inner = None;
if let Some(o) = self.before_drag.take() {
self.restore_drag_options(o);
}
self.snap.clear();
vec![Action::Cancel]
} else {
@@ -659,6 +691,16 @@ impl Tool for ShapeTool {
"fillLine" => self.fill_line = v.as_bool().unwrap_or(self.fill_line),
_ => {}
}
// An option set while a shape is dragged out stays when Escape cancels the drag.
if let Some(b) = self.before_drag.as_mut() {
match key {
"sides" => b.sides = self.sides,
"points" => b.points = self.points,
"cornerRadius" => b.corner_radius = self.corner_radius,
"starRatio" => b.star_ratio = self.star_ratio,
_ => {}
}
}
}
}
+79 -3
View File
@@ -166,15 +166,16 @@ fn a_star_drag_holds_the_inner_radius_it_shows_when_ctrl_goes_down() {
close(radii(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 170.0, 50.0).with_mods(ctrl))), (120.0, 40.0 * straight));
// Escape with the key held, then a new press and the key before the first move: the next star
// holds what it shows, not the canceled radius.
// has the ratio from before the canceled drag and holds the inner radius it shows, not the
// canceled one.
let mut t = ShapeTool::new("star");
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 50.0));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 90.0, 50.0));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 130.0, 50.0).with_mods(ctrl));
assert_eq!(t.key(&cx, ToolKey::Escape, ctrl), vec![Action::Cancel]);
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 50.0));
close(radii(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 60.0, 50.0).with_mods(ctrl))), (10.0, 2.5));
close(radii(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 70.0, 50.0).with_mods(ctrl))), (20.0, 2.5));
close(radii(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 60.0, 50.0).with_mods(ctrl))), (10.0, 5.0));
close(radii(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 70.0, 50.0).with_mods(ctrl))), (20.0, 5.0));
// Ctrl/Cmd pressed inside the drag threshold, where no star is shown yet.
let mut t = ShapeTool::new("star");
@@ -301,6 +302,81 @@ fn rounded_rectangle_radius_is_clamped_to_its_range() {
assert_eq!(t.options()["cornerRadius"], json!(MAX_CORNER_RADIUS));
}
/// Escape during a polygon, star or rounded rectangle drag puts back the side count, point count
/// or corner radius its arrows changed, for the next drag and the tool's options; a drag that is
/// let go keeps them.
#[test]
fn escape_puts_back_the_options_a_drag_changed() {
let (d, _) = doc_with_rect();
let s = Selection::default();
let p = paint();
let mut cx = cx(&d, &s, &p);
cx.smart_guides = false;
for (id, field) in [("polygon", "sides"), ("star", "points"), ("roundedRectangle", "radius")] {
let mut t = ShapeTool::new(id);
let value = |a: Vec<Action>| match a.last() {
Some(Action::Preview(_, v)) => v[field].clone(),
_ => panic!("{id}: {a:?}"),
};
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0));
let before = value(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 60.0, 40.0)));
let options = t.options();
t.key(&cx, ToolKey::Up, Mods::default());
let changed = value(t.key(&cx, ToolKey::Up, Mods::default()));
assert_ne!(changed, before, "{id}: the arrows change the option");
assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Cancel], "{id}");
assert_eq!(t.options(), options, "{id}: the tool's options");
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0));
assert_eq!(value(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 60.0, 40.0))), before, "{id}: the next drag");
// A drag that is let go keeps what its arrows changed.
t.key(&cx, ToolKey::Up, Mods::default());
t.key(&cx, ToolKey::Up, Mods::default());
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 60.0, 40.0)), vec![Action::Commit]);
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0));
assert_eq!(value(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 60.0, 40.0))), changed, "{id}: a finished drag keeps it");
t.key(&cx, ToolKey::Down, Mods::default());
t.key(&cx, ToolKey::Escape, Mods::default());
let kept = t.options();
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0));
// Escape puts back only what this drag changed.
assert_eq!(value(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 60.0, 40.0))), changed, "{id}: only this drag");
assert_ne!(kept, options, "{id}");
}
}
/// Ctrl/Cmd changes a star's inner-to-outer ratio as it is dragged out; Escape puts it back, and
/// an option set during the drag stays.
#[test]
fn escape_puts_back_the_star_ratio_and_keeps_an_option_set_during_the_drag() {
let (d, _) = doc_with_rect();
let s = Selection::default();
let p = paint();
let mut cx = cx(&d, &s, &p);
cx.smart_guides = false;
let ctrl = Mods { cmd: true, ..Default::default() };
let mut t = ShapeTool::new("star");
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 50.0));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 90.0, 50.0));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 130.0, 50.0).with_mods(ctrl));
assert!((t.star_ratio - 0.25).abs() < 1e-9, "{}", t.star_ratio);
t.key(&cx, ToolKey::Escape, ctrl);
assert_eq!(t.star_ratio, 0.5);
// Options set with `tool.setOption` during the drag stay; the arrows' change goes back.
for (id, key, set) in [("polygon", "sides", json!(9)), ("star", "points", json!(8)), ("roundedRectangle", "cornerRadius", json!(30.0))] {
let mut t = ShapeTool::new(id);
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 60.0, 40.0));
t.key(&cx, ToolKey::Up, Mods::default());
t.set_option(key, &set);
t.set_option("starRatio", &json!(0.3));
t.key(&cx, ToolKey::Up, Mods::default());
t.key(&cx, ToolKey::Escape, Mods::default());
let o = t.options();
assert_eq!((o[key].clone(), o["starRatio"].clone()), (set, json!(0.3)), "{id}");
}
}
/// The start and the dragged corner land on another object's anchors (#506), hovering shows
/// it before the press, and the measurement label gives the size drawn.
#[test]
+12 -3
View File
@@ -110,9 +110,10 @@ pub(super) fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf:
// The Selection tool's bounding box (rotated with the objects after a rotation), also shown by
// the shape tools that keep a shape just drawn ready to adjust while they draw none (#5477). It
// hides while a drag moves or resizes the selection, so only the art is seen going with the
// pointer (#712).
// pointer (#712). A handle resize keeps it at the changing bounds (#3526).
// With Real-time Drawing off the art waits for the release of a move drag, and the box moves
// with the pointer instead.
let resizing = app.session.tool_resizing();
let moved = app.session.deferred_move();
let shape_tool = vectorcraft_tools::shape::shows_shape_widgets(app.session.tool_id());
// A shape tool drawing a shape shows none of this; adjusting a widget, it shows the widgets.
@@ -122,10 +123,18 @@ pub(super) fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf:
let held = super::drag::mods(p.ctx().input(|i| i.modifiers), false);
let lends_selection = !app.session.tool_busy() && app.session.lent_tool_id(&held) == Some("selection");
let show_box = (app.session.tool_id() == "selection" || lends_selection || (shape_tool && !app.session.tool_busy()))
&& (!app.session.tool_transforming() || moved.is_some())
&& (resizing || !app.session.tool_transforming() || moved.is_some())
&& app.ui.view.bounding_box
&& app.session.active().is_some_and(|st| !st.selection.is_empty() && st.selection.anchors.is_empty());
let bbox = if show_box { app.selection_box() } else { None };
// With Real-time Drawing off the document stays at the drag's snapshot, so a resize measures the
// box from the pending preview; otherwise each step's revision measures it once (`selection_box`).
let bbox = if !show_box {
None
} else if let Some(b) = resizing.then(|| app.session.deferred_transform_box()).flatten() {
Some(b)
} else {
app.selection_box()
};
let bbox = bbox.map(|b| match moved {
Some(d) => vectorcraft_doc::OrientedBox { rect: b.rect + b.to_local(d.to_point()).to_vec2(), ..b },
None => b,
+90
View File
@@ -3931,6 +3931,96 @@ fn hidden_corner_widgets_do_not_answer_the_pointer() {
assert!(radius(&app).is_some_and(|r| r > 0.0), "{:?}", radius(&app));
}
/// One headless canvas frame → the bounding box drawn: its outline's corners on screen (the
/// closed four-point line in the layer color; `None` when it is hidden) and the number of its
/// handles (white 6 px squares).
fn bounding_box_drawn(app: &mut VectorcraftApp, ctx: &egui::Context) -> (Option<Vec<Pos2>>, usize) {
let st = app.session.active().unwrap();
let layer = egui::epaint::ColorMode::Solid(c32(st.doc.layer_color(st.selection.objects[0])));
let drawn = shapes(app, ctx);
let outline = drawn.iter().find_map(|s| match s {
Shape::Path(ps) if ps.closed && ps.points.len() == 4 && ps.stroke.color == layer => Some(ps.points.clone()),
_ => None,
});
let handle = |s: &&Shape| matches!(s, Shape::Rect(r) if r.rect.size() == vec2(6.0, 6.0) && r.fill == Color32::WHITE);
(outline, drawn.iter().filter(handle).count())
}
/// The selection's bounding box corners on screen, as measured from the document.
fn box_corners(app: &mut VectorcraftApp) -> Vec<Pos2> {
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
app.selection_box().unwrap().corners().iter().map(|q| xf.to_screen(*q)).collect()
}
/// Do two outlines have the same corners, to 0.01 px?
fn same_corners(a: &[Pos2], b: &[Pos2]) -> bool {
a.len() == b.len() && a.iter().zip(b).all(|(p, q)| p.distance(*q) < 0.01)
}
/// #3526: a resize by a bounding-box handle keeps the bounding box and its handles drawn at the
/// bounds the drag gives, with Real-time Drawing on and off (off, the document stays at the drag's
/// snapshot until the release).
#[test]
fn a_resize_drag_keeps_the_bounding_box_at_the_new_bounds() {
for real_time in [true, false] {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
app.session.execute("shape.ellipse", &json!({"x": 100, "y": 100, "width": 100, "height": 60})).unwrap();
app.session.execute("prefs.set", &json!({"key": "realTimeDrawing", "value": real_time})).unwrap();
let ctx = egui::Context::default();
frame(&mut app, &ctx, vec![]);
app.select_tool("selection");
let before = box_corners(&mut app);
let (outline, handles) = bounding_box_drawn(&mut app, &ctx);
assert!(outline.is_some_and(|o| same_corners(&o, &before)) && handles == 8, "real time {real_time}: the box at rest");
let view = app.view_info();
app.session.pointer(&PointerEvent::new(PointerKind::Down, 200.0, 160.0), view).unwrap();
app.session.pointer(&PointerEvent::new(PointerKind::Drag, 263.0, 211.0), view).unwrap();
assert!(app.session.tool_resizing(), "real time {real_time}: the handle was hit");
let (during, handles) = bounding_box_drawn(&mut app, &ctx);
assert_eq!(handles, 8, "real time {real_time}: the handles show during the resize");
app.session.pointer(&PointerEvent::new(PointerKind::Up, 263.0, 211.0), view).unwrap();
let after = box_corners(&mut app);
assert!(!same_corners(&after, &before), "real time {real_time}: the drag resized the ellipse");
assert!(during.as_ref().is_some_and(|o| same_corners(o, &after)), "real time {real_time}: {during:?}, resized to {after:?}");
let (outline, handles) = bounding_box_drawn(&mut app, &ctx);
assert!(outline.is_some_and(|o| same_corners(&o, &after)) && handles == 8, "real time {real_time}: the box after the release");
}
}
/// A move, a rotation and a corner-radius drag keep hiding the bounding box and its handles until
/// the release (#712, #1229).
#[test]
fn a_move_rotate_or_corner_radius_drag_hides_the_bounding_box() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
app.session.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap();
let ctx = egui::Context::default();
frame(&mut app, &ctx, vec![]);
app.select_tool("selection");
let drag = |app: &mut VectorcraftApp, from: Point, to: Point| {
let view = app.view_info();
app.session.pointer(&PointerEvent::new(PointerKind::Down, from.x, from.y), view).unwrap();
app.session.pointer(&PointerEvent::new(PointerKind::Drag, to.x, to.y), view).unwrap();
assert!(app.session.tool_transforming() && !app.session.tool_resizing());
let during = bounding_box_drawn(app, &ctx);
app.session.pointer(&PointerEvent::new(PointerKind::Up, to.x, to.y), view).unwrap();
during
};
assert_eq!(bounding_box_drawn(&mut app, &ctx).1, 8);
assert_eq!(drag(&mut app, Point::new(150.0, 150.0), Point::new(160.0, 160.0)), (None, 0), "a move");
assert_eq!(bounding_box_drawn(&mut app, &ctx).1, 8, "back after the move");
let offset = 10.0 / app.view_info().zoom;
assert_eq!(drag(&mut app, Point::new(210.0 + offset, 210.0 + offset), Point::new(260.0, 160.0)), (None, 0), "a rotation");
assert_eq!(bounding_box_drawn(&mut app, &ctx).1, 8, "back after the rotation");
let corner = {
let st = app.session.active().unwrap();
vectorcraft_tools::corners::CornerWidgets::of(&st.doc, &st.selection, app.view_info().zoom, false).unwrap().visible().next().unwrap()
};
assert_eq!(drag(&mut app, corner, corner + vectorcraft_geom::Vec2::new(10.0, 10.0)), (None, 0), "a corner-radius drag");
assert_eq!(bounding_box_drawn(&mut app, &ctx).1, 8, "back after the corner-radius drag");
}
/// View › Hide Edges hides the bounding box and the type widget beside it, and the Selection
/// tool then answers neither: a double-click on the widget's place leaves point type as it is,
/// and a drag from a corner handle's place does not scale. With edges shown, both answer.
+1
View File
@@ -657,6 +657,7 @@ impl PluginIntField {
params: vec![("seed".into(), vectorcraft_plugins::ParamSpec::Int { min, max, default: 0 }.into())],
extensions: Vec::new(),
budget: Default::default(),
paint_output: false,
commands: Vec::new(),
};
let mut f = PluginIntField { ctx, d: Dialog::new(plugin::KIND, json!({ "seed": start })), m, pos: egui::Pos2::ZERO };
+13
View File
@@ -5627,6 +5627,13 @@ digits Malayalam Malaiàlam
@msg select an object in perspective selecciona un objecte en perspectiva
@msg select an Objects on Path group selecciona un grup d’objectes sobre un traçat
@msg select artwork (or a colour group) to recolor selecciona la il·lustració (o un grup de colors) per recolorejar
@msg Recolor completed; {locked} and {hidden} were left unchanged Recoloració completada; sense canvis: {locked} i {hidden}
@msg Recolor completed; {objects} were left unchanged Recoloració completada; sense canvis: {objects}
@msg Recolor completed; {objects} was left unchanged Recoloració completada; sense canvis: {objects}
@msg 1 locked object 1 objecte bloquejat
@msg {n} locked objects {n} objectes bloquejats
@msg 1 hidden object 1 objecte ocult
@msg {n} hidden objects {n} objectes ocults
@msg select at least two objects selecciona almenys dos objectes
@msg select objects, not layers selecciona objectes, no capes
@msg select one object selecciona un objecte
@@ -5935,6 +5942,8 @@ digits Malayalam Malaiàlam
@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` el mòdul importa `env.{name}`, que un complement només pot fer servir al nivell d’API de complements {PROGRESS_API} (`api` {PROGRESS_API}) i amb el permís `{permission}`
@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network el mòdul importa `{from}.{name}`, que l’amfitrió no ofereix: un complement només pot importar les funcions `env.vc_*` que enumera docs/plugins.md (nivell d’API de complements 2), i cap WASI, fitxer ni xarxa
@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` és {_1}; aquest amfitrió admet els nivells d’API de complements de l’1 al {API_LEVEL}
@msg `paintOutput` may only be true `paintOutput` només pot ser true
@msg `paintOutput` is only for a live effect (`kind` "effect") at `api` 2 `paintOutput` només és per a un efecte dinàmic (`kind` "effect") amb `api` 2
@msg `grant` must be a list of permission names `grant` ha de ser una llista de noms de permisos
@msg `grant` names an unknown permission `{shown}` `grant` esmenta un permís desconegut `{shown}`
@msg a plug-in of kind `{_1}` needs the permission `{_2}` un complement del tipus `{_1}` necessita el permís `{_2}`
@@ -6330,6 +6339,10 @@ breakText Glyphs Glifs
@msg `by` must be lines, paragraphs, words or glyphs, not {other} `by` ha de ser lines, paragraphs, words o glyphs, no {other}
@msg `kind` must be "line" or "paragraph", not {other} `kind` ha de ser "line" o "paragraph", no {other}
@msg `align` must be "left", "center" or "right", not {other} `align` ha de ser "left", "center" o "right", no {other}
@msg the JPEG XL file is cut short el fitxer JPEG XL està truncat
@msg the JPEG XL container is too large to read el contenidor JPEG XL és massa gran per llegir-lo
@msg CMYK JPEG XL images can't be read les imatges JPEG XL en CMYK no es poden llegir
@msg the JPEG XL file can't be read: {e} no es pot llegir el fitxer JPEG XL: {e}
File + Number Fitxer + número
File + Data Set Name Fitxer + nom del conjunt de dades
Data Set Name Nom del conjunt de dades
Can't render this file because it contains an unexpected character in line 2 and column 73.
+13
View File
@@ -4839,6 +4839,13 @@ imageTrace Outline Obrys
@msg select an image vyberte obraz
@msg select an object in perspective vyberte objekt v perspektivě
@msg select artwork (or a colour group) to recolor vyberte kresbu (nebo skupinu barev) k přebarvení
@msg Recolor completed; {locked} and {hidden} were left unchanged Přebarvení dokončeno; beze změny: {locked} a {hidden}
@msg Recolor completed; {objects} were left unchanged Přebarvení dokončeno; beze změny: {objects}
@msg Recolor completed; {objects} was left unchanged Přebarvení dokončeno; beze změny: {objects}
@msg 1 locked object zamčený objekt (1)
@msg {n} locked objects zamčené objekty ({n})
@msg 1 hidden object skrytý objekt (1)
@msg {n} hidden objects skryté objekty ({n})
@msg select at least two objects vyberte alespoň dva objekty
@msg exit isolation mode first nejprve ukončete izolovaný režim
@msg select objects, not layers vyberte objekty, ne vrstvy
@@ -5153,6 +5160,10 @@ imageTrace Outline Obrys
@msg the PSD file is cut short soubor PSD je useknutý
@msg the PSD file's header is damaged hlavička souboru PSD je poškozená
@msg the PSD file's image data can't be read: {e} obrazová data souboru PSD nelze přečíst: {e}
@msg the JPEG XL file is cut short soubor JPEG XL je useknutý
@msg the JPEG XL container is too large to read kontejner JPEG XL je příliš velký pro načtení
@msg CMYK JPEG XL images can't be read obrázky JPEG XL v režimu CMYK nelze přečíst
@msg the JPEG XL file can't be read: {e} soubor JPEG XL nelze přečíst: {e}
@msg Imported library {name} Importována knihovna {name}
@msg Invalid panel layout; restored the default dock Neplatné rozložení panelů; obnoveno výchozí ukotvení
@msg action is required action je povinné
@@ -5922,6 +5933,8 @@ axis Y Axis osa Y
@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` modul importuje `env.{name}`, který zásuvný modul smí používat jen na úrovni API zásuvných modulů {PROGRESS_API} (`api` {PROGRESS_API}) a s oprávněním `{permission}`
@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network modul importuje `{from}.{name}`, který hostitel nenabízí: zásuvný modul smí importovat jen funkce `env.vc_*`, které uvádí docs/plugins.md (úroveň API zásuvných modulů 2), a žádné WASI, soubory ani síť
@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` je {_1}; tento hostitel podporuje úrovně API zásuvných modulů 1 až {API_LEVEL}
@msg `paintOutput` may only be true `paintOutput` může být jen true
@msg `paintOutput` is only for a live effect (`kind` "effect") at `api` 2 `paintOutput` je jen pro živý efekt (`kind` "effect") s `api` 2
@msg `grant` must be a list of permission names `grant` musí být seznam názvů oprávnění
@msg `grant` names an unknown permission `{shown}` `grant` uvádí neznámé oprávnění `{shown}`
@msg a plug-in of kind `{_1}` needs the permission `{_2}` zásuvný modul druhu `{_1}` potřebuje oprávnění `{_2}`
Can't render this file because it contains an unexpected character in line 2 and column 73.
+13
View File
@@ -4446,6 +4446,13 @@ theme Appearance Erscheinungsbild
@msg select an image wählen Sie ein Bild aus
@msg select an object in perspective wählen Sie ein Objekt in der Perspektive aus
@msg select artwork (or a colour group) to recolor wählen Sie eine Grafik (oder eine Farbgruppe) zum Neufärben aus
@msg Recolor completed; {locked} and {hidden} were left unchanged Neufärben abgeschlossen; unverändert: {locked} und {hidden}
@msg Recolor completed; {objects} were left unchanged Neufärben abgeschlossen; unverändert: {objects}
@msg Recolor completed; {objects} was left unchanged Neufärben abgeschlossen; unverändert: {objects}
@msg 1 locked object 1 gesperrtes Objekt
@msg {n} locked objects {n} gesperrte Objekte
@msg 1 hidden object 1 ausgeblendetes Objekt
@msg {n} hidden objects {n} ausgeblendete Objekte
@msg select at least two objects wählen Sie mindestens zwei Objekte aus
@msg exit isolation mode first beenden Sie zuerst den Isolationsmodus
@msg select objects, not layers wählen Sie Objekte aus, keine Ebenen
@@ -4891,6 +4898,10 @@ theme Appearance Erscheinungsbild
@msg the PSD image is too large to read ({_1} × {_2} pixels) das PSD-Bild ist zu groß zum Lesen ({_1} × {_2} Pixel)
@msg zipped 32-bit PSD image data with prediction can't be read ZIP-komprimierte 32-Bit-PSD-Bilddaten mit Vorhersage können nicht gelesen werden
@msg {depth}-bit PSD files in this colour mode can't be read {depth}-Bit-PSD-Dateien in diesem Farbmodus können nicht gelesen werden
@msg the JPEG XL file is cut short die JPEG-XL-Datei ist unvollständig
@msg the JPEG XL container is too large to read der JPEG-XL-Container ist zu groß zum Lesen
@msg CMYK JPEG XL images can't be read JPEG-XL-Bilder im CMYK-Modus können nicht gelesen werden
@msg the JPEG XL file can't be read: {e} die JPEG-XL-Datei kann nicht gelesen werden: {e}
Value Axis Werteachse
On Left Side Links
On Right Side Rechts
@@ -5504,6 +5515,8 @@ axis Y Axis Y-Achse
@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` das Modul importiert `env.{name}`, das ein Plug-in nur auf der Plug-in-API-Stufe {PROGRESS_API} (`api` {PROGRESS_API}) und mit der Berechtigung `{permission}` verwenden darf
@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network das Modul importiert `{from}.{name}`, das der Host nicht anbietet: Ein Plug-in darf nur die `env.vc_*`-Funktionen importieren, die docs/plugins.md aufführt (Plug-in-API-Stufe 2), aber kein WASI, keine Dateien und kein Netzwerk
@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` ist {_1}; dieser Host unterstützt die Plug-in-API-Stufen 1 bis {API_LEVEL}
@msg `paintOutput` may only be true `paintOutput` darf nur true sein
@msg `paintOutput` is only for a live effect (`kind` "effect") at `api` 2 `paintOutput` gilt nur für einen Live-Effekt (`kind` "effect") mit `api` 2
@msg `grant` must be a list of permission names `grant` muss eine Liste von Berechtigungsnamen sein
@msg `grant` names an unknown permission `{shown}` `grant` nennt eine unbekannte Berechtigung `{shown}`
@msg a plug-in of kind `{_1}` needs the permission `{_2}` ein Plug-in der Art `{_1}` braucht die Berechtigung `{_2}`
Can't render this file because it contains an unexpected character in line 2 and column 73.
+13
View File
@@ -4479,6 +4479,13 @@ digits Malayalam Μαλαγιάλαμ
@msg select an image επιλέξτε εικόνα
@msg select an object in perspective επιλέξτε αντικείμενο σε προοπτική
@msg select artwork (or a colour group) to recolor επιλέξτε σχέδιο (ή ομάδα χρωμάτων) για επαναχρωματισμό
@msg Recolor completed; {locked} and {hidden} were left unchanged Ο επαναχρωματισμός ολοκληρώθηκε· χωρίς αλλαγή: {locked} και {hidden}
@msg Recolor completed; {objects} were left unchanged Ο επαναχρωματισμός ολοκληρώθηκε· χωρίς αλλαγή: {objects}
@msg Recolor completed; {objects} was left unchanged Ο επαναχρωματισμός ολοκληρώθηκε· χωρίς αλλαγή: {objects}
@msg 1 locked object 1 κλειδωμένο αντικείμενο
@msg {n} locked objects {n} κλειδωμένα αντικείμενα
@msg 1 hidden object 1 κρυφό αντικείμενο
@msg {n} hidden objects {n} κρυφά αντικείμενα
@msg select at least two objects επιλέξτε τουλάχιστον δύο αντικείμενα
@msg exit isolation mode first βγείτε πρώτα από τη λειτουργία απομόνωσης
@msg select objects, not layers επιλέξτε αντικείμενα, όχι επίπεδα
@@ -4831,6 +4838,10 @@ digits Malayalam Μαλαγιάλαμ
@msg the PSD file is cut short το αρχείο PSD είναι περικομμένο
@msg the PSD file's header is damaged η κεφαλίδα του αρχείου PSD είναι κατεστραμμένη
@msg the PSD file's image data can't be read: {e} τα δεδομένα εικόνας PSD δεν μπορούν να διαβαστούν: {e}
@msg the JPEG XL file is cut short το αρχείο JPEG XL είναι περικομμένο
@msg the JPEG XL container is too large to read το κοντέινερ JPEG XL είναι πολύ μεγάλο για ανάγνωση
@msg CMYK JPEG XL images can't be read οι εικόνες JPEG XL σε CMYK δεν διαβάζονται
@msg the JPEG XL file can't be read: {e} το αρχείο JPEG XL δεν μπορεί να διαβαστεί: {e}
Create New Fields by Δημιουργία νέων πεδίων κατά
@msg Imported library {name} Εισήχθη η βιβλιοθήκη {name}
RGB: RGB:
@@ -5873,6 +5884,8 @@ axis Y Axis Άξονας Y
@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` η λειτουργική μονάδα εισάγει το `env.{name}`, το οποίο ένα πρόσθετο μπορεί να χρησιμοποιεί μόνο στο επίπεδο API προσθέτων {PROGRESS_API} (`api` {PROGRESS_API}) και με το δικαίωμα `{permission}`
@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network η λειτουργική μονάδα εισάγει το `{from}.{name}`, το οποίο δεν προσφέρει ο κεντρικός υπολογιστής: ένα πρόσθετο μπορεί να εισάγει μόνο τις συναρτήσεις `env.vc_*` που απαριθμεί το docs/plugins.md (επίπεδο API προσθέτων 2) και κανένα WASI, αρχεία ή δίκτυο
@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} το `api` είναι {_1}· αυτός ο κεντρικός υπολογιστής υποστηρίζει τα επίπεδα API προσθέτων 1 έως {API_LEVEL}
@msg `paintOutput` may only be true Το `paintOutput` μπορεί να είναι μόνο true
@msg `paintOutput` is only for a live effect (`kind` "effect") at `api` 2 το `paintOutput` ισχύει μόνο για ζωντανό εφέ (`kind` "effect") με `api` 2
@msg `grant` must be a list of permission names το `grant` πρέπει να είναι λίστα ονομάτων δικαιωμάτων
@msg `grant` names an unknown permission `{shown}` το `grant` αναφέρει άγνωστο δικαίωμα `{shown}`
@msg a plug-in of kind `{_1}` needs the permission `{_2}` ένα πρόσθετο τύπου `{_1}` χρειάζεται το δικαίωμα `{_2}`
Can't render this file because it is too large.
+13
View File
@@ -4530,6 +4530,13 @@ axis Both Ambos
@msg select an image selecciona una imagen
@msg select an object in perspective selecciona un objeto en perspectiva
@msg select artwork (or a colour group) to recolor selecciona la ilustración (o un grupo de colores) que quieres volver a colorear
@msg Recolor completed; {locked} and {hidden} were left unchanged Recoloreado completado; sin cambios: {locked} y {hidden}
@msg Recolor completed; {objects} were left unchanged Recoloreado completado; sin cambios: {objects}
@msg Recolor completed; {objects} was left unchanged Recoloreado completado; sin cambios: {objects}
@msg 1 locked object 1 objeto bloqueado
@msg {n} locked objects {n} objetos bloqueados
@msg 1 hidden object 1 objeto oculto
@msg {n} hidden objects {n} objetos ocultos
@msg select at least two objects selecciona al menos dos objetos
@msg exit isolation mode first sal primero del modo de aislamiento
@msg select objects, not layers selecciona objetos, no capas
@@ -4870,6 +4877,10 @@ axis Both Ambos
@msg the PSD file is cut short el archivo PSD está incompleto
@msg the PSD file's header is damaged el encabezado del archivo PSD está dañado
@msg the PSD file's image data can't be read: {e} no se pueden leer los datos de imagen del archivo PSD: {e}
@msg the JPEG XL file is cut short el archivo JPEG XL está incompleto
@msg the JPEG XL container is too large to read el contenedor JPEG XL es demasiado grande para leerlo
@msg CMYK JPEG XL images can't be read las imágenes JPEG XL en CMYK no se pueden leer
@msg the JPEG XL file can't be read: {e} no se puede leer el archivo JPEG XL: {e}
Video Video
De-Interlace… Desentrelazar…
De-Interlace Desentrelazar
@@ -5863,6 +5874,8 @@ axis Y Axis Eje Y
@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` el módulo importa `env.{name}`, que un plugin solo puede usar en el nivel de API de plugins {PROGRESS_API} (`api` {PROGRESS_API}) y con el permiso `{permission}`
@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network el módulo importa `{from}.{name}`, que el host no ofrece: un plugin solo puede importar las funciones `env.vc_*` que enumera docs/plugins.md (nivel de API de plugins 2), y ningún WASI, archivo ni red
@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` es {_1}; este host admite los niveles de API de plugins de 1 a {API_LEVEL}
@msg `paintOutput` may only be true `paintOutput` solo puede ser true
@msg `paintOutput` is only for a live effect (`kind` "effect") at `api` 2 `paintOutput` solo es para un efecto dinámico (`kind` "effect") con `api` 2
@msg `grant` must be a list of permission names `grant` debe ser una lista de nombres de permisos
@msg `grant` names an unknown permission `{shown}` `grant` nombra un permiso desconocido `{shown}`
@msg a plug-in of kind `{_1}` needs the permission `{_2}` un plugin de tipo `{_1}` necesita el permiso `{_2}`
Can't render this file because it contains an unexpected character in line 2 and column 73.
+13
View File
@@ -4741,6 +4741,13 @@ imageTrace Outline Ääriviiva
@msg select an image valitse kuva
@msg select an object in perspective valitse perspektiivissä oleva objekti
@msg select artwork (or a colour group) to recolor valitse uudelleenväritettävä kuvitus (tai väriryhmä)
@msg Recolor completed; {locked} and {hidden} were left unchanged Uudelleenväritys valmis; muuttamatta jäi: {locked} ja {hidden}
@msg Recolor completed; {objects} were left unchanged Uudelleenväritys valmis; muuttamatta jäi: {objects}
@msg Recolor completed; {objects} was left unchanged Uudelleenväritys valmis; muuttamatta jäi: {objects}
@msg 1 locked object 1 lukittu objekti
@msg {n} locked objects {n} lukittua objektia
@msg 1 hidden object 1 piilotettu objekti
@msg {n} hidden objects {n} piilotettua objektia
@msg select at least two objects valitse vähintään kaksi objektia
@msg exit isolation mode first poistu ensin eristystilasta
@msg select objects, not layers valitse objekteja, ei tasoja
@@ -5055,6 +5062,10 @@ imageTrace Outline Ääriviiva
@msg the PSD file is cut short tämä PSD-tiedosto on katkennut
@msg the PSD file's header is damaged tämän PSD-tiedoston otsake on vaurioitunut
@msg the PSD file's image data can't be read: {e} tämän PSD-tiedoston kuvadataa ei voi lukea: {e}
@msg the JPEG XL file is cut short tämä JPEG XL -tiedosto on katkennut
@msg the JPEG XL container is too large to read JPEG XL -säiliö on liian suuri luettavaksi
@msg CMYK JPEG XL images can't be read CMYK-tilassa olevia JPEG XL -kuvia ei voi lukea
@msg the JPEG XL file can't be read: {e} tätä JPEG XL -tiedostoa ei voi lukea: {e}
@msg Imported library {name} Tuotiin kirjasto {name}
@msg Invalid panel layout; restored the default dock Virheellinen paneeliasettelu; oletustelakka palautettiin
@msg action is required action vaaditaan
@@ -5952,6 +5963,8 @@ axis Y Axis Y-akseli
@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` moduuli tuo kohteen `env.{name}`, jota laajennus saa käyttää vain laajennus-API:n tasolla {PROGRESS_API} (`api` {PROGRESS_API}) ja käyttöoikeudella `{permission}`
@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network moduuli tuo kohteen `{from}.{name}`, jota isäntä ei tarjoa: laajennus saa tuoda vain docs/plugins.md-tiedostossa luetellut `env.vc_*`-funktiot (laajennus-API:n taso 2) eikä mitään WASIa, tiedostoja tai verkkoa
@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` on {_1}; tämä isäntä tukee laajennus-API:n tasoja 1 ja {API_LEVEL} välillä
@msg `paintOutput` may only be true `paintOutput` voi olla vain true
@msg `paintOutput` is only for a live effect (`kind` "effect") at `api` 2 `paintOutput` on vain reaaliaikaiselle tehosteelle (`kind` "effect") tasolla `api` 2
@msg `grant` must be a list of permission names `grant` on oltava luettelo käyttöoikeuksien nimistä
@msg `grant` names an unknown permission `{shown}` `grant` nimeää tuntemattoman käyttöoikeuden `{shown}`
@msg a plug-in of kind `{_1}` needs the permission `{_2}` lajin `{_1}` laajennus tarvitsee käyttöoikeuden `{_2}`
Can't render this file because it contains an unexpected character in line 2 and column 73.
+13
View File
@@ -4520,6 +4520,13 @@ axis Both Les deux
@msg select an image sélectionnez une image
@msg select an object in perspective sélectionnez un objet en perspective
@msg select artwork (or a colour group) to recolor sélectionnez l’illustration (ou un groupe de couleurs) dont redéfinir les couleurs
@msg Recolor completed; {locked} and {hidden} were left unchanged Recoloration terminée ; sans changement : {locked} et {hidden}
@msg Recolor completed; {objects} were left unchanged Recoloration terminée ; sans changement : {objects}
@msg Recolor completed; {objects} was left unchanged Recoloration terminée ; sans changement : {objects}
@msg 1 locked object 1 objet verrouillé
@msg {n} locked objects {n} objets verrouillés
@msg 1 hidden object 1 objet masqué
@msg {n} hidden objects {n} objets masqués
@msg select at least two objects sélectionnez au moins deux objets
@msg exit isolation mode first quittez d’abord le mode Isolation
@msg select objects, not layers sélectionnez des objets, pas des calques
@@ -4878,6 +4885,10 @@ axis Both Les deux
@msg the PSD file is cut short le fichier PSD est tronqué
@msg the PSD file's header is damaged l'en-tête du fichier PSD est endommagé
@msg the PSD file's image data can't be read: {e} les données d'image du fichier PSD ne peuvent pas être lues : {e}
@msg the JPEG XL file is cut short le fichier JPEG XL est tronqué
@msg the JPEG XL container is too large to read le conteneur JPEG XL est trop volumineux pour être lu
@msg CMYK JPEG XL images can't be read les images JPEG XL en CMJN ne peuvent pas être lues
@msg the JPEG XL file can't be read: {e} le fichier JPEG XL ne peut pas être lu : {e}
Video Vidéo
De-Interlace… Désentrelacement…
De-Interlace Désentrelacement
@@ -5917,6 +5928,8 @@ axis Y Axis Axe Y
@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` le module importe `env.{name}`, qu’un module externe ne peut utiliser qu’au niveau d’API de module externe {PROGRESS_API} (`api` {PROGRESS_API}) et avec l’autorisation `{permission}`
@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network le module importe `{from}.{name}`, que l’hôte ne propose pas : un module externe ne peut importer que les fonctions `env.vc_*` que liste docs/plugins.md (niveau d’API de module externe 2), et aucun WASI, fichier ni réseau
@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` vaut {_1} ; cet hôte prend en charge les niveaux d’API de module externe 1 à {API_LEVEL}
@msg `paintOutput` may only be true `paintOutput` ne peut valoir que true
@msg `paintOutput` is only for a live effect (`kind` "effect") at `api` 2 `paintOutput` est réservé à un effet dynamique (`kind` "effect") avec `api` 2
@msg `grant` must be a list of permission names `grant` doit être une liste de noms d’autorisations
@msg `grant` names an unknown permission `{shown}` `grant` nomme une autorisation inconnue `{shown}`
@msg a plug-in of kind `{_1}` needs the permission `{_2}` un module externe de type `{_1}` a besoin de l’autorisation `{_2}`
Can't render this file because it contains an unexpected character in line 2 and column 73.
+9
View File
@@ -5273,6 +5273,8 @@ antiAlias Sharp शार्प
@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` मॉड्यूल `env.{name}` आयात करता है, जिसे प्लग-इन केवल प्लग-इन API स्तर {PROGRESS_API} (`api` {PROGRESS_API}) पर और अनुमति `{permission}` के साथ उपयोग कर सकता है
@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network मॉड्यूल `{from}.{name}` आयात करता है, जिसे होस्ट उपलब्ध नहीं कराता: प्लग-इन केवल वे `env.vc_*` फ़ंक्शन आयात कर सकता है जो docs/plugins.md में सूचीबद्ध हैं (प्लग-इन API स्तर 2), और कोई WASI, फ़ाइल या नेटवर्क नहीं
@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` {_1} है; यह होस्ट प्लग-इन API स्तर 1 से {API_LEVEL} तक का समर्थन करता है
@msg `paintOutput` may only be true `paintOutput` केवल true हो सकता है
@msg `paintOutput` is only for a live effect (`kind` "effect") at `api` 2 `paintOutput` केवल `api` 2 पर लाइव इफ़ेक्ट (`kind` "effect") के लिए है
@msg `grant` must be a list of permission names `grant` अनुमति नामों की सूची होनी चाहिए
@msg `grant` names an unknown permission `{shown}` `grant` एक अज्ञात अनुमति `{shown}` बताता है
@msg a plug-in of kind `{_1}` needs the permission `{_2}` `{_1}` प्रकार के प्लग-इन को `{_2}` अनुमति चाहिए
@@ -7226,6 +7228,13 @@ antiAlias Sharp शार्प
@msg select area type and closed paths (at least one area type object) क्षेत्र प्रकार और बंद पाथ (कम से कम एक क्षेत्र प्रकार ऑब्जेक्ट) सिलेक्ट करें
@msg select area type whose first line can fit its frame क्षेत्र के प्रकार सिलेक्ट करें जिनकी पहली पंक्ति अपने फ्रेम को फिट कर सकती है
@msg select artwork (or a colour group) to recolor फिर से रंग करने के लिए आर्टवर्क (या एक रंग समूह) सिलेक्ट करें
@msg Recolor completed; {locked} and {hidden} were left unchanged रीकलर पूरा हुआ; ये अपरिवर्तित रहे: {locked} और {hidden}
@msg Recolor completed; {objects} were left unchanged रीकलर पूरा हुआ; ये अपरिवर्तित रहे: {objects}
@msg Recolor completed; {objects} was left unchanged रीकलर पूरा हुआ; यह अपरिवर्तित रहा: {objects}
@msg 1 locked object 1 लॉक किया गया ऑब्जेक्ट
@msg {n} locked objects {n} लॉक किए गए ऑब्जेक्ट
@msg 1 hidden object 1 छिपा हुआ ऑब्जेक्ट
@msg {n} hidden objects {n} छिपे हुए ऑब्जेक्ट
@msg select artwork to add its colours अपने रंगों को जोड़ने के लिए आर्टवर्क सिलेक्ट करें
@msg select artwork to make a group of its colours अपने रंगों का एक समूह बनाने के लिए आर्टवर्क सिलेक्ट करें
@msg select artwork to recolour, or give a colour `group` रंग बदलने के लिए आर्टवर्क चुनें या रंग `group` दें
Can't render this file because it is too large.
+13
View File
@@ -5277,6 +5277,13 @@ imageTrace Outline Körvonal
@msg select an Image Trace object jelölj ki egy képvektorizálási objektumot
@msg select an object in perspective jelölj ki egy perspektivikus objektumot
@msg select artwork (or a colour group) to recolor jelölj ki átszínezendő grafikát (vagy színcsoportot)
@msg Recolor completed; {locked} and {hidden} were left unchanged Átszínezés kész; változatlan maradt: {locked} és {hidden}
@msg Recolor completed; {objects} were left unchanged Átszínezés kész; változatlan maradt: {objects}
@msg Recolor completed; {objects} was left unchanged Átszínezés kész; változatlan maradt: {objects}
@msg 1 locked object 1 zárolt objektum
@msg {n} locked objects {n} zárolt objektum
@msg 1 hidden object 1 rejtett objektum
@msg {n} hidden objects {n} rejtett objektum
@msg select at least two objects jelölj ki legalább két objektumot
@msg exit isolation mode first előbb lépj ki az elkülönítési módból
@msg select objects, not layers objektumokat jelölj ki, rétegeket ne
@@ -5379,6 +5386,10 @@ imageTrace Outline Körvonal
@msg the PSD file is cut short a PSD-fájl csonka
@msg the PSD file's header is damaged a PSD-fájl fejléce sérült
@msg the PSD file's image data can't be read: {e} a PSD-fájl képadatai nem olvashatók: {e}
@msg the JPEG XL file is cut short a JPEG XL-fájl csonka
@msg the JPEG XL container is too large to read a JPEG XL-konténer túl nagy az olvasáshoz
@msg CMYK JPEG XL images can't be read CMYK módú JPEG XL-képek nem olvashatók
@msg the JPEG XL file can't be read: {e} a JPEG XL-fájl nem olvasható: {e}
@msg the PSD file's image data uses an unknown compression ({m}) a PSD-fájl képadatai ismeretlen tömörítést használnak ({m})
@msg the PCX file is cut short a PCX-fájl csonka
@msg the PCX file's header is damaged a PCX-fájl fejléce sérült
@@ -5905,6 +5916,8 @@ axis Y Axis Y tengely
@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` a modul importálja az `env.{name}` elemet, amelyet bővítmény csak {PROGRESS_API}. szintű bővítmény-API-val (`api` {PROGRESS_API}) és a(z) `{permission}` engedéllyel használhat
@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network a modul importálja a(z) `{from}.{name}` elemet, amelyet a gazdaprogram nem kínál: bővítmény csak a docs/plugins.md által felsorolt `env.vc_*` függvényeket importálhatja (2. szintű bővítmény-API), WASI-t, fájlokat és hálózatot nem
@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} az `api` értéke {_1}; ez a gazdaprogram az 1 és {API_LEVEL} közötti bővítmény-API-szinteket támogatja
@msg `paintOutput` may only be true A `paintOutput` értéke csak true lehet
@msg `paintOutput` is only for a live effect (`kind` "effect") at `api` 2 a `paintOutput` csak élő effektushoz (`kind` "effect") használható, `api` 2 mellett
@msg `grant` must be a list of permission names a `grant` értékének engedélynevek listájának kell lennie
@msg `grant` names an unknown permission `{shown}` a `grant` ismeretlen engedélyt nevez meg: `{shown}`
@msg a plug-in of kind `{_1}` needs the permission `{_2}` egy `{_1}` fajtájú bővítménynek szüksége van a(z) `{_2}` engedélyre
Can't render this file because it contains an unexpected character in line 2 and column 73.
+13
View File
@@ -4572,6 +4572,13 @@ axis Both Entrambi
@msg select an image seleziona un'immagine
@msg select an object in perspective seleziona un oggetto in prospettiva
@msg select artwork (or a colour group) to recolor seleziona la grafica (o un gruppo colori) da ricolorare
@msg Recolor completed; {locked} and {hidden} were left unchanged Ricolorazione completata; senza modifiche: {locked} e {hidden}
@msg Recolor completed; {objects} were left unchanged Ricolorazione completata; senza modifiche: {objects}
@msg Recolor completed; {objects} was left unchanged Ricolorazione completata; senza modifiche: {objects}
@msg 1 locked object 1 oggetto bloccato
@msg {n} locked objects {n} oggetti bloccati
@msg 1 hidden object 1 oggetto nascosto
@msg {n} hidden objects {n} oggetti nascosti
@msg select at least two objects seleziona almeno due oggetti
@msg exit isolation mode first esci prima dalla modalità isolamento
@msg select objects, not layers seleziona oggetti, non livelli
@@ -4912,6 +4919,10 @@ axis Both Entrambi
@msg the PSD file is cut short il file PSD è troncato
@msg the PSD file's header is damaged l'intestazione del file PSD è danneggiata
@msg the PSD file's image data can't be read: {e} i dati immagine del file PSD non possono essere letti: {e}
@msg the JPEG XL file is cut short il file JPEG XL è troncato
@msg the JPEG XL container is too large to read il contenitore JPEG XL è troppo grande per essere letto
@msg CMYK JPEG XL images can't be read le immagini JPEG XL in CMYK non possono essere lette
@msg the JPEG XL file can't be read: {e} il file JPEG XL non può essere letto: {e}
Video Video
De-Interlace… Deinterlaccia…
De-Interlace Deinterlaccia
@@ -5538,6 +5549,8 @@ axis Y Axis Asse Y
@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` il modulo importa `env.{name}`, che un plug-in può usare solo al livello API dei plug-in {PROGRESS_API} (`api` {PROGRESS_API}) e con l’autorizzazione `{permission}`
@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network il modulo importa `{from}.{name}`, che l’host non offre: un plug-in può importare solo le funzioni `env.vc_*` elencate in docs/plugins.md (livello API dei plug-in 2), e nessun WASI, file o rete
@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` è {_1}; questo host supporta i livelli API dei plug-in da 1 a {API_LEVEL}
@msg `paintOutput` may only be true `paintOutput` può essere solo true
@msg `paintOutput` is only for a live effect (`kind` "effect") at `api` 2 `paintOutput` vale solo per un effetto dinamico (`kind` "effect") con `api` 2
@msg `grant` must be a list of permission names `grant` deve essere un elenco di nomi di autorizzazioni
@msg `grant` names an unknown permission `{shown}` `grant` indica un’autorizzazione sconosciuta `{shown}`
@msg a plug-in of kind `{_1}` needs the permission `{_2}` un plug-in di tipo `{_1}` richiede l’autorizzazione `{_2}`
Can't render this file because it contains an unexpected character in line 2 and column 73.
+13
View File
@@ -4696,6 +4696,13 @@ swatch Bright Violet ビビッドバイオレット
@msg print settings are not an object プリント設定がオブジェクト形式ではありません
@msg no colour group `{g}` カラーグループ `{g}` が見つかりません
@msg select artwork (or a colour group) to recolor 再配色するアートワーク (またはカラーグループ) を選択してください
@msg Recolor completed; {locked} and {hidden} were left unchanged 再配色が完了しました。変更されなかったもの: {locked}、{hidden}
@msg Recolor completed; {objects} were left unchanged 再配色が完了しました。変更されなかったもの: {objects}
@msg Recolor completed; {objects} was left unchanged 再配色が完了しました。変更されなかったもの: {objects}
@msg 1 locked object ロックされたオブジェクト 1 個
@msg {n} locked objects ロックされたオブジェクト {n} 個
@msg 1 hidden object 非表示のオブジェクト 1 個
@msg {n} hidden objects 非表示のオブジェクト {n} 個
@msg Preview in Browser needs the desktop app (the web downloads files instead) 「ブラウザーでプレビュー」にはデスクトップアプリが必要です (Web 版では代わりにファイルをダウンロードします)
@msg no file system here この環境にはファイルシステムがありません
@msg nothing to preview プレビューするものがありません
@@ -4953,6 +4960,10 @@ swatch Bright Violet ビビッドバイオレット
@msg the PSD file is cut short PSD ファイルが途中で切れています
@msg the PSD file's header is damaged PSD ファイルのヘッダーが破損しています
@msg the PSD file's image data can't be read: {e} PSD ファイルの画像データを読み込めません:{e}
@msg the JPEG XL file is cut short JPEG XL ファイルが途中で切れています
@msg the JPEG XL container is too large to read JPEG XL コンテナが大きすぎて読み込めません
@msg CMYK JPEG XL images can't be read CMYK の JPEG XL 画像は読み込めません
@msg the JPEG XL file can't be read: {e} JPEG XL ファイルを読み込めません:{e}
Video ビデオ
De-Interlace… インターレース解除…
De-Interlace インターレース解除
@@ -5603,6 +5614,8 @@ axis Y Axis Y 軸
@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` モジュールが `env.{name}` をインポートしていますが、プラグインがこれを使用できるのはプラグイン API レベル {PROGRESS_API} (`api` {PROGRESS_API}) で権限 `{permission}` がある場合だけです
@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network モジュールがホストの提供しない `{from}.{name}` をインポートしています。プラグインがインポートできるのは docs/plugins.md に記載された `env.vc_*` 関数 (プラグイン API レベル 2) だけで、WASI、ファイル、ネットワークは使用できません
@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` は {_1} です。このホストが対応するプラグイン API レベルは 1 から {API_LEVEL} です
@msg `paintOutput` may only be true `paintOutput` には true のみ指定できます
@msg `paintOutput` is only for a live effect (`kind` "effect") at `api` 2 `paintOutput` は `api` 2 のライブ効果 (`kind` "effect") 専用です
@msg `grant` must be a list of permission names `grant` は権限名のリストにしてください
@msg `grant` names an unknown permission `{shown}` `grant` に不明な権限 `{shown}` が含まれています
@msg a plug-in of kind `{_1}` needs the permission `{_2}` 種類 `{_1}` のプラグインには権限 `{_2}` が必要です
Can't render this file because it contains an unexpected character in line 2 and column 73.
+13
View File
@@ -4281,6 +4281,13 @@ font-technology Variable ცვლადი
@msg select an image მონიშნეთ გამოსახულება
@msg select an object in perspective მონიშნეთ ობიექტი პერსპექტივაში
@msg select artwork (or a colour group) to recolor მონიშნეთ ხელახლა შესაფერადებელი ნახატი (ან ფერთა ჯგუფი)
@msg Recolor completed; {locked} and {hidden} were left unchanged ხელახლა შეფერადება დასრულდა; უცვლელი დარჩა: {locked} და {hidden}
@msg Recolor completed; {objects} were left unchanged ხელახლა შეფერადება დასრულდა; უცვლელი დარჩა: {objects}
@msg Recolor completed; {objects} was left unchanged ხელახლა შეფერადება დასრულდა; უცვლელი დარჩა: {objects}
@msg 1 locked object 1 ჩაკეტილი ობიექტი
@msg {n} locked objects {n} ჩაკეტილი ობიექტი
@msg 1 hidden object 1 დამალული ობიექტი
@msg {n} hidden objects {n} დამალული ობიექტი
@msg select at least two objects მონიშნეთ ორი ობიექტი მაინც
@msg exit isolation mode first ჯერ გამოდით იზოლაციის რეჟიმიდან
@msg select objects, not layers მონიშნეთ ობიექტები და არა შრეები
@@ -4572,6 +4579,10 @@ font-technology Variable ცვლადი
@msg the PSD file is cut short PSD ფაილი შეკვეცილია
@msg the PSD file's header is damaged PSD ფაილის სათაური დაზიანებულია
@msg the PSD file's image data can't be read: {e} PSD ფაილის გამოსახულების მონაცემების წაკითხვა ვერ ხერხდება: {e}
@msg the JPEG XL file is cut short JPEG XL ფაილი შეკვეცილია
@msg the JPEG XL container is too large to read JPEG XL კონტეინერი წასაკითხად ზედმეტად დიდია
@msg CMYK JPEG XL images can't be read CMYK JPEG XL გამოსახულებების წაკითხვა ვერ ხერხდება
@msg the JPEG XL file can't be read: {e} JPEG XL ფაილის წაკითხვა ვერ ხერხდება: {e}
@msg Imported library {name} შემოტანილია ბიბლიოთეკა {name}
@msg Invalid panel layout; restored the default dock პანელების განლაგება არასწორია; აღდგენილია ნაგულისხმევი დოკი
@msg action is required action აუცილებელია
@@ -5846,6 +5857,8 @@ axis Y Axis Y ღერძი
@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` მოდულს შემოაქვს `env.{name}`, რომლის გამოყენებაც პლაგინს მხოლოდ პლაგინის API-ის დონეზე {PROGRESS_API} (`api` {PROGRESS_API}) და ნებართვით `{permission}` შეუძლია
@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network მოდულს შემოაქვს `{from}.{name}`, რომელსაც ჰოსტი არ გვთავაზობს: პლაგინს შეუძლია შემოიტანოს მხოლოდ docs/plugins.md-ში ჩამოთვლილი `env.vc_*` ფუნქციები (პლაგინის API-ის დონე 2) და არა WASI, ფაილები ან ქსელი
@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` არის {_1}; ეს ჰოსტი პლაგინის API-ის დონეებს 1-დან {API_LEVEL}-მდე უჭერს მხარს
@msg `paintOutput` may only be true `paintOutput` შეიძლება იყოს მხოლოდ true
@msg `paintOutput` is only for a live effect (`kind` "effect") at `api` 2 `paintOutput` მხოლოდ ცოცხალი ეფექტისთვისაა (`kind` "effect") `api` 2-ზე
@msg `grant` must be a list of permission names `grant` ნებართვების სახელების სია უნდა იყოს
@msg `grant` names an unknown permission `{shown}` `grant` უცნობ ნებართვას ასახელებს: `{shown}`
@msg a plug-in of kind `{_1}` needs the permission `{_2}` `{_1}` სახის პლაგინს სჭირდება ნებართვა `{_2}`
Can't render this file because it is too large.

Some files were not shown because too many files have changed in this diff Show More