Merge main into fix/tiled-documents-own-profiles

This commit is contained in:
dotCooCoo
2026-11-14 04:58:21 -08:00
235 changed files with 21951 additions and 1390 deletions
+18
View File
@@ -440,6 +440,15 @@ pub(crate) fn f64_req(p: &Value, key: &str, cmd: &str) -> Result<f64> {
Some(v) => v.as_f64().ok_or_else(|| bad(cmd, format!("`{key}` must be a number, not {v}"))),
}
}
/// The whole number of 0 or more that `p` gives for `key`. A missing key or null is "missing integer
/// `{key}`"; any other value, such as `"3"`, `2.5`, `-1` or `true`, is "`{key}` must be a whole
/// number", as [`object_param`] words it.
pub(crate) fn u64_req(p: &Value, key: &str, cmd: &str) -> Result<u64> {
match p.get(key) {
None | Some(Value::Null) => Err(bad(cmd, format!("missing integer `{key}`"))),
Some(v) => v.as_u64().ok_or_else(|| bad(cmd, format!("`{key}` must be a whole number"))),
}
}
/// The number `p` gives for `key`, or `default` when `p` leaves it out or gives null. Any other
/// value, such as `"40"`, is an error: "`{key}` must be a number, not {v}".
pub(crate) fn number_or(p: &Value, key: &str, default: f64, cmd: &str) -> Result<f64> {
@@ -550,6 +559,15 @@ pub(crate) fn given_object_ids(p: &Value, command: &str) -> Result<Option<Vec<No
pub(crate) fn index(v: u64) -> usize {
usize::try_from(v).unwrap_or(usize::MAX)
}
/// The whole number of 0 or more that `p` gives for `key`, read with [`index`], or `None` when `p`
/// leaves it out or gives null. Any other value, such as `"3"`, `2.5`, `-1` or `true`, is an error
/// under `cmd`: "`{key}` must be a whole number".
pub(crate) fn index_opt(p: &Value, key: &str, cmd: &str) -> Result<Option<usize>> {
match p.get(key) {
None | Some(Value::Null) => Ok(None),
Some(v) => v.as_u64().map(|n| Some(index(n))).ok_or_else(|| bad(cmd, format!("`{key}` must be a whole number"))),
}
}
/// Validate all explicit targets before a command changes the document or selection.
pub(crate) fn checked_ids_param(s: &Session, p: &Value, key: &str, command: &str) -> Result<Vec<NodeId>> {
checked_nodes_param(s, p, key, command, false)
+179 -79
View File
@@ -9,6 +9,8 @@
#[cfg(test)]
mod tests;
use std::ops::RangeInclusive;
use serde_json::{Value, json};
use vectorcraft_doc::{LiveShape, NodeId, NodeKind};
use vectorcraft_geom::{Affine, FillRule, Point, Rect, shapes};
@@ -16,9 +18,25 @@ use vectorcraft_tools::addons::box3d::CustomShape;
use super::super::create::insert_look;
use super::super::{
CommandSpec, Result, Session, bool_or, check_numbers, check_switches, cmd, f64_or, f64_req, has_doc, has_selection, str_param, targets,
CommandSpec, Result, Session, bad, bool_or, check_numbers, check_switches, cmd, f64_or, f64_req, has_doc, has_selection, number_or, str_param,
targets,
};
/// The sides of a polygon and the points of a star: whole numbers in this range.
const COUNTS: RangeInclusive<f64> = 3.0..=1000.0;
/// `rounded` and `randomized` of a polygon or star.
const ROUNDING: RangeInclusive<f64> = -10.0..=10.0;
/// A star's inner radius as a fraction of its outer radius.
const SPOKE_RATIO: RangeInclusive<f64> = 0.01..=1.0;
/// A spiral's `turns`.
const TURNS: RangeInclusive<f64> = 0.01..=1024.0;
/// A spiral's `divergence`.
const DIVERGENCE: RangeInclusive<f64> = 0.0..=1000.0;
/// A spiral's `innerRadius`, as a fraction of its radius.
const INNER_RADIUS: RangeInclusive<f64> = 0.0..=0.999;
/// The `arcMode` names; `slice` and `open` are also read as `pie` and `arc`.
const ARC_MODES: [&str; 4] = ["whole", "pie", "chord", "arc"];
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
@@ -26,7 +44,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Rectangle",
[],
None,
"{x, y, width, height, radius?: pt, radii?: [tl,tr,br,bl], startAngle?, endAngle?: degrees (the part of the outline kept, clockwise from its first anchor, the top-left corner; each side an equal share of 360; default 0 and 360), arcMode?: whole|pie|chord|arc, rotation?: deg clockwise about the centre (0: upright)} → {id}",
"{x, y, width, height: pt (0 or more), radius?: pt (0 or more), radii?: [tl,tr,br,bl] (each 0 or more), startAngle?, endAngle?: degrees (the part of the outline kept, clockwise from its first anchor, the top-left corner; each side an equal share of 360; default 0 and 360), arcMode?: whole|pie|chord|arc, rotation?: deg clockwise about the centre (0: upright)} → {id}",
has_doc,
rectangle
),
@@ -35,7 +53,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Ellipse",
[],
None,
"{x, y, width, height, startAngle?, endAngle?: degrees (counter-clockwise from 3 o'clock, as object.setLiveShape's pieStart and pieEnd; default 0 and 360), arcMode?: whole|pie|chord|arc, rotation?: deg clockwise about the centre (0: upright)} → {id}",
"{x, y, width, height: pt (0 or more), startAngle?, endAngle?: degrees (counter-clockwise from 3 o'clock, as object.setLiveShape's pieStart and pieEnd; default 0 and 360), arcMode?: whole|pie|chord|arc, rotation?: deg clockwise about the centre (0: upright)} → {id}",
has_doc,
ellipse
),
@@ -44,7 +62,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Polygon",
[],
None,
"{cx, cy, radius, sides=6, rounded=0, randomized=0, startAngle?, endAngle?: degrees (the part of the outline kept, clockwise from its first anchor, the top corner; each side an equal share of 360; default 0 and 360), arcMode?: whole|pie|chord|arc, rotation?: deg clockwise (0: a corner straight up)} → {id}",
"{cx, cy, radius: pt (0 or more), sides=6: a whole number 3 to 1000, rounded=0, randomized=0: -10 to 10, startAngle?, endAngle?: degrees (the part of the outline kept, clockwise from its first anchor, the top corner; each side an equal share of 360; default 0 and 360), arcMode?: whole|pie|chord|arc, rotation?: deg clockwise (0: a corner straight up)} → {id}",
has_doc,
polygon
),
@@ -53,7 +71,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Star",
[],
None,
"{cx, cy, radius1, radius2?, points=5, rounded=0, randomized=0, startAngle?, endAngle?: degrees (the part of the outline kept, clockwise from its first anchor, the top point; each side an equal share of 360; default 0 and 360), arcMode?: whole|pie|chord|arc, rotation?: deg clockwise (0: a point straight up)} → {id}",
"{cx, cy, radius1: pt (0 or more), radius2?: pt (0.01 to 1 times radius1; default half of it), points=5: a whole number 3 to 1000, rounded=0, randomized=0: -10 to 10, startAngle?, endAngle?: degrees (the part of the outline kept, clockwise from its first anchor, the top point; each side an equal share of 360; default 0 and 360), arcMode?: whole|pie|chord|arc, rotation?: deg clockwise (0: a point straight up)} → {id}",
has_doc,
star
),
@@ -62,7 +80,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Spiral",
[],
None,
"{cx, cy, radius, turns=3, divergence=1, innerRadius=0, clockwise=true} → {id}",
"{cx, cy, radius: pt (0 or more), turns=3: 0.01 to 1024, divergence=1: 0 to 1000, innerRadius=0: 0 to 0.999 of radius, clockwise=true} → {id}",
has_doc,
spiral
),
@@ -71,7 +89,7 @@ pub fn specs() -> Vec<CommandSpec> {
"3D Box",
[],
None,
"{x, y, width, height, angleX=30 (degrees about the level axis: positive tips the top away from you), angleY=30 (degrees about the upright axis: positive turns the right side away from you), angleZ=0 (degrees clockwise in the page), parallelX=false, parallelY=true, parallelZ=false} → {id}",
"{x, y, width, height: pt (0 or more), angleX=30 (degrees about the level axis: positive tips the top away from you), angleY=30 (degrees about the upright axis: positive turns the right side away from you), angleZ=0 (degrees clockwise in the page), parallelX=false, parallelY=true, parallelZ=false} → {id}",
has_doc,
box3d
),
@@ -80,33 +98,96 @@ pub fn specs() -> Vec<CommandSpec> {
"Configure Shape",
[],
None,
"{ids?, radius?, radii?: [tl,tr,br,bl], startAngle?, endAngle?: degrees (as addon.shapes.rectangle, ellipse, polygon and star take them: an ellipse's counter-clockwise from 3 o'clock, the others' clockwise along the outline), arcMode?: whole|pie|chord|arc, sides?, isStar?, spokeRatio?, rounded?, randomized?, turns?, divergence?, innerRadius?, angleX?, angleY?: degrees (as addon.shapes.box3d: positive tips the top, or turns the right side, away from you), angleZ?: degrees clockwise in the page, parallelX?, parallelY?, parallelZ?, reset?: bool} → {updated}",
"{ids?, radius?: pt (0 or more; fitted to half each rectangle's shorter side), radii?: [tl,tr,br,bl] (each 0 or more), startAngle?, endAngle?: degrees (as addon.shapes.rectangle, ellipse, polygon and star take them: an ellipse's counter-clockwise from 3 o'clock, the others' clockwise along the outline), arcMode?: whole|pie|chord|arc, sides?: a whole number 3 to 1000, isStar?, spokeRatio?: 0.01 to 1, rounded?, randomized?: -10 to 10, turns?: 0.01 to 1024, divergence?: 0 to 1000, innerRadius?: 0 to 0.999, angleX?, angleY?: degrees (as addon.shapes.box3d: positive tips the top, or turns the right side, away from you), angleZ?: degrees clockwise in the page, parallelX?, parallelY?, parallelZ?, reset?: bool} an omitted or null value keeps each shape's setting → {updated}",
has_selection,
configure
),
]
}
fn parse_arc_params(p: &Value) -> ((f64, f64), shapes::EllipseMode) {
/// The number `p` gives for `key`, or `default` when `p` leaves it out or gives null. A number
/// outside `range` is an error that names `key` and the range.
fn ranged(p: &Value, key: &str, default: f64, range: &RangeInclusive<f64>, cmd: &str) -> Result<f64> {
let x = number_or(p, key, default, cmd)?;
if !range.contains(&x) {
let (min, max) = (range.start(), range.end());
return Err(bad(cmd, format!("`{key}` must be between {min} and {max} (got {x})")));
}
Ok(x)
}
/// The number of sides or points `p` gives for `key`, or `default` when `p` leaves it out or gives
/// null. A fraction or a number outside [`COUNTS`] is an error.
fn count(p: &Value, key: &str, default: u32, cmd: &str) -> Result<u32> {
let x = number_or(p, key, f64::from(default), cmd)?;
if x.fract() != 0.0 || !COUNTS.contains(&x) {
let (min, max) = (COUNTS.start(), COUNTS.end());
return Err(bad(cmd, format!("`{key}` must be a whole number between {min} and {max} (got {x})")));
}
Ok(x as u32)
}
/// The size `p` gives for `key`: a number that is not negative.
fn size(p: &Value, key: &str, cmd: &str) -> Result<f64> {
not_negative(f64_req(p, key, cmd)?, key, cmd)
}
/// The size `p` gives for `key`, or `default` when `p` leaves it out or gives null.
fn size_or(p: &Value, key: &str, default: f64, cmd: &str) -> Result<f64> {
not_negative(number_or(p, key, default, cmd)?, key, cmd)
}
fn not_negative(x: f64, key: &str, cmd: &str) -> Result<f64> {
if x < 0.0 {
return Err(bad(cmd, format!("`{key}` must be a number not below 0")));
}
Ok(x)
}
/// The four corner radii `p` gives as `radii` ([tl, tr, br, bl]), or `None` when `p` leaves it out
/// or gives null. Anything but four numbers that are not negative is an error.
fn radii_param(p: &Value, cmd: &str) -> Result<Option<[f64; 4]>> {
let key = "radii";
match p.get(key) {
None | Some(Value::Null) => Ok(None),
Some(v) => {
let four = v.as_array().filter(|a| a.len() == 4).and_then(|a| {
let mut out = [0.0; 4];
for (slot, item) in out.iter_mut().zip(a) {
*slot = item.as_f64().filter(|r| *r >= 0.0)?;
}
Some(out)
});
four.map(Some).ok_or_else(|| bad(cmd, format!("`{key}` must be four numbers not below 0: [tl, tr, br, bl]")))
}
}
}
/// The mode `p` gives as `arcMode`, or `None` when `p` leaves it out or gives null. Any other
/// value than one of [`ARC_MODES`] (or `slice` or `open`) is an error.
fn arc_mode(p: &Value, cmd: &str) -> Result<Option<shapes::EllipseMode>> {
let key = "arcMode";
match p.get(key) {
None | Some(Value::Null) => Ok(None),
Some(v) => v.as_str().and_then(parse_mode).map(Some).ok_or_else(|| bad(cmd, format!("`{key}` must be one of {}", ARC_MODES.join(", ")))),
}
}
fn parse_arc_params(p: &Value, cmd: &str) -> Result<((f64, f64), shapes::EllipseMode)> {
let start = f64_or(p, "startAngle", 0.0);
let end = f64_or(p, "endAngle", 360.0);
let mode = match str_param(p, "arcMode") {
Some("pie" | "slice") => shapes::EllipseMode::Slice,
Some("chord") => shapes::EllipseMode::Chord,
Some("arc" | "open") => shapes::EllipseMode::Arc,
_ => shapes::EllipseMode::Whole,
};
let mode = arc_mode(p, cmd)?.unwrap_or(shapes::EllipseMode::Whole);
let span = (end - start).rem_euclid(360.0);
let pie = if mode == shapes::EllipseMode::Whole && (span < 1e-6 || (span - 360.0).abs() < 1e-6) { (0.0, 360.0) } else { (start, end) };
(pie, mode)
Ok((pie, mode))
}
fn rect_of(p: &Value, c: &str) -> Result<Rect> {
let x = f64_req(p, "x", c)?;
let y = f64_req(p, "y", c)?;
let w = f64_req(p, "width", c)?;
let h = f64_req(p, "height", c)?;
Ok(Rect::new(x, y, x + w.abs(), y + h.abs()).abs())
let w = size(p, "width", c)?;
let h = size(p, "height", c)?;
Ok(Rect::new(x, y, x + w, y + h))
}
fn rect_xf(r: Rect, rot: f64) -> Affine {
@@ -141,18 +222,9 @@ fn rectangle(s: &mut Session, p: &Value) -> Result<Value> {
let c = "addon.shapes.rectangle";
check_numbers(p, &["radius", "startAngle", "endAngle", "rotation"], c)?;
let r = rect_of(p, c)?;
let radius = f64_or(p, "radius", 0.0).max(0.0);
let mut radii = [radius; 4];
if let Some(arr) = p.get("radii").and_then(Value::as_array)
&& arr.len() == 4
{
for (i, item) in arr.iter().enumerate() {
if let Some(val) = item.as_f64().filter(|n| n.is_finite()) {
radii[i] = val.max(0.0);
}
}
}
let (pie, arc_mode) = parse_arc_params(p);
let radius = size_or(p, "radius", 0.0, c)?;
let radii = radii_param(p, c)?.unwrap_or([radius; 4]);
let (pie, arc_mode) = parse_arc_params(p, c)?;
let xf = rect_xf(r, f64_or(p, "rotation", 0.0));
let live = LiveShape::Rectangle { w: r.width(), h: r.height(), radii, kinds: Default::default(), radii_relative: false, xf };
let cs = CustomShape::Rectangle { w: r.width(), h: r.height(), radii, kinds: Default::default(), radii_relative: false, xf, pie, arc_mode };
@@ -164,7 +236,7 @@ fn ellipse(s: &mut Session, p: &Value) -> Result<Value> {
let c = "addon.shapes.ellipse";
check_numbers(p, &["startAngle", "endAngle", "rotation"], c)?;
let r = rect_of(p, c)?;
let (pie, arc_mode) = parse_arc_params(p);
let (pie, arc_mode) = parse_arc_params(p, c)?;
let xf = rect_xf(r, f64_or(p, "rotation", 0.0));
let live = LiveShape::Ellipse { w: r.width(), h: r.height(), pie, xf };
let cs = CustomShape::Ellipse { w: r.width(), h: r.height(), pie, arc_mode, xf };
@@ -174,13 +246,13 @@ fn ellipse(s: &mut Session, p: &Value) -> Result<Value> {
fn polygon(s: &mut Session, p: &Value) -> Result<Value> {
let c = "addon.shapes.polygon";
let center = Point::new(f64_req(p, "cx", c)?, f64_req(p, "cy", c)?);
let r = f64_req(p, "radius", c)?.abs();
let r = size(p, "radius", c)?;
check_numbers(p, &["sides", "rotation", "rounded", "randomized", "startAngle", "endAngle"], c)?;
let sides = (f64_or(p, "sides", 6.0).round() as i64).clamp(3, 1000) as u32;
let sides = count(p, "sides", 6, c)?;
let rot = f64_or(p, "rotation", 0.0);
let rounded = f64_or(p, "rounded", 0.0).clamp(-10.0, 10.0);
let randomized = f64_or(p, "randomized", 0.0).clamp(-10.0, 10.0);
let (pie, arc_mode) = parse_arc_params(p);
let rounded = ranged(p, "rounded", 0.0, &ROUNDING, c)?;
let randomized = ranged(p, "randomized", 0.0, &ROUNDING, c)?;
let (pie, arc_mode) = parse_arc_params(p, c)?;
let xf = Affine::translate(center.to_vec2()) * vectorcraft_geom::rotate_deg(rot);
let live = LiveShape::Polygon { radius: r, sides, xf, radii: vec![], kinds: vec![], radii_relative: false };
let cs = CustomShape::Polygon {
@@ -202,16 +274,17 @@ fn polygon(s: &mut Session, p: &Value) -> Result<Value> {
fn star(s: &mut Session, p: &Value) -> Result<Value> {
let c = "addon.shapes.star";
let center = Point::new(f64_req(p, "cx", c)?, f64_req(p, "cy", c)?);
let r1 = f64_req(p, "radius1", c)?.abs();
let r1 = size(p, "radius1", c)?;
check_numbers(p, &["radius2", "points", "rotation", "rounded", "randomized", "startAngle", "endAngle"], c)?;
let r2 = f64_or(p, "radius2", r1 / 2.0).abs();
let points = (f64_or(p, "points", 5.0).round() as i64).clamp(3, 1000) as u32;
// The inner radius is a fraction of the outer one, in `SPOKE_RATIO`.
let r2 = ranged(p, "radius2", r1 / 2.0, &(r1 * SPOKE_RATIO.start()..=r1 * SPOKE_RATIO.end()), c)?;
let points = count(p, "points", 5, c)?;
let rot = f64_or(p, "rotation", 0.0);
let rounded = f64_or(p, "rounded", 0.0).clamp(-10.0, 10.0);
let randomized = f64_or(p, "randomized", 0.0).clamp(-10.0, 10.0);
let (pie, arc_mode) = parse_arc_params(p);
let rounded = ranged(p, "rounded", 0.0, &ROUNDING, c)?;
let randomized = ranged(p, "randomized", 0.0, &ROUNDING, c)?;
let (pie, arc_mode) = parse_arc_params(p, c)?;
let xf = Affine::translate(center.to_vec2()) * vectorcraft_geom::rotate_deg(rot);
let ratio = if r1 > 1e-9 { (r2 / r1).clamp(0.01, 1.0) } else { 0.5 };
let ratio = if r1 > 1e-9 { (r2 / r1).clamp(*SPOKE_RATIO.start(), *SPOKE_RATIO.end()) } else { 0.5 };
let cs = CustomShape::Polygon {
radius: r1,
sides: points,
@@ -231,12 +304,12 @@ fn star(s: &mut Session, p: &Value) -> Result<Value> {
fn spiral(s: &mut Session, p: &Value) -> Result<Value> {
let c = "addon.shapes.spiral";
let center = Point::new(f64_req(p, "cx", c)?, f64_req(p, "cy", c)?);
let r = f64_req(p, "radius", c)?.abs();
let r = size(p, "radius", c)?;
check_numbers(p, &["turns", "divergence", "innerRadius"], c)?;
check_switches(p, &["clockwise"], c)?;
let turns = f64_or(p, "turns", 3.0).clamp(0.01, 1024.0);
let divergence = f64_or(p, "divergence", 1.0).clamp(0.0, 1000.0);
let inner_radius = f64_or(p, "innerRadius", 0.0).clamp(0.0, 0.999);
let turns = ranged(p, "turns", 3.0, &TURNS, c)?;
let divergence = ranged(p, "divergence", 1.0, &DIVERGENCE, c)?;
let inner_radius = ranged(p, "innerRadius", 0.0, &INNER_RADIUS, c)?;
let clockwise = bool_or(p, "clockwise", true);
let cs = CustomShape::Spiral { center, radius: r, turns, divergence, inner_radius, clockwise };
insert_custom(s, "Spiral", None, cs)
@@ -299,24 +372,54 @@ fn configure(s: &mut Session, p: &Value) -> Result<Value> {
c,
)?;
check_switches(p, &["isStar", "parallelX", "parallelY", "parallelZ", "reset"], c)?;
check_configure(p, c)?;
let ids = targets(s, p)?;
let reset = bool_or(p, "reset", false);
let spiral_hint = p.get("turns").is_some() || p.get("divergence").is_some() || p.get("innerRadius").is_some();
// The shapes the params change, worked out first so that a call that changes nothing records
// no undo step.
let changes: Vec<(NodeId, CustomShape)> = {
let doc = &s.doc()?.doc;
ids.into_iter()
.filter_map(|id| {
let n = doc.node(id)?;
let before = CustomShape::read_for_update(n, spiral_hint)?;
let mut cs = before.clone();
let stored = CustomShape::read(n).is_some();
(apply_shape_params(&mut cs, p, reset) && (cs != before || !stored)).then_some((id, cs))
})
.collect()
};
if changes.is_empty() {
return Ok(json!({ "updated": 0 }));
}
let updated = s.edit("Live Shape", |d, _| {
let mut count = 0usize;
for id in ids {
let Some(n) = d.node_mut(id) else { continue };
let Some(mut cs) = CustomShape::read_for_update(n, spiral_hint) else { continue };
if apply_shape_params(&mut cs, p, reset) {
cs.write_to_node(n);
count += 1;
}
for (id, cs) in &changes {
let Some(n) = d.node_mut(*id) else { continue };
cs.write_to_node(n);
count += 1;
}
Ok(count)
})?;
Ok(json!({ "updated": updated }))
}
/// Error unless every bounded value `p` gives is in its range, before any shape changes.
fn check_configure(p: &Value, c: &str) -> Result<()> {
size_or(p, "radius", 0.0, c)?;
radii_param(p, c)?;
arc_mode(p, c)?;
count(p, "sides", 6, c)?;
ranged(p, "spokeRatio", 0.5, &SPOKE_RATIO, c)?;
ranged(p, "rounded", 0.0, &ROUNDING, c)?;
ranged(p, "randomized", 0.0, &ROUNDING, c)?;
ranged(p, "turns", 3.0, &TURNS, c)?;
ranged(p, "divergence", 1.0, &DIVERGENCE, c)?;
ranged(p, "innerRadius", 0.0, &INNER_RADIUS, c)?;
Ok(())
}
fn num_param(p: &Value, k: &str) -> Option<f64> {
p.get(k).and_then(Value::as_f64).filter(|v| v.is_finite())
}
@@ -331,8 +434,8 @@ fn apply_arc_params(p: &Value, pie: &mut (f64, f64), arc_mode: &mut shapes::Elli
pie.1 = a1.rem_euclid(360.0);
changed = true;
}
if let Some(m) = str_param(p, "arcMode") {
*arc_mode = parse_mode(m);
if let Some(m) = str_param(p, "arcMode").and_then(parse_mode) {
*arc_mode = m;
if *arc_mode == shapes::EllipseMode::Whole {
*pie = (0.0, 360.0);
} else if (pie.1 - pie.0).abs() < 1e-6 || ((pie.1 - pie.0).abs() - 360.0).abs() < 1e-6 {
@@ -351,19 +454,14 @@ fn apply_rect_params(p: &Value, reset: bool, max_r: f64, radii: &mut [f64; 4], p
*arc_mode = shapes::EllipseMode::Whole;
changed = true;
}
// A radius larger than half the rectangle's shorter side is fitted to that size.
if let Some(r) = num_param(p, "radius") {
*radii = [r.clamp(0.0, max_r); 4];
changed = true;
}
if let Some(arr) = p.get("radii").and_then(Value::as_array)
&& arr.len() == 4
{
for (i, v) in arr.iter().enumerate() {
if let Some(r) = v.as_f64().filter(|x| x.is_finite()) {
radii[i] = r.clamp(0.0, max_r);
changed = true;
}
}
if let Ok(Some(four)) = radii_param(p, "addon.shapes.configure") {
*radii = four.map(|r| r.clamp(0.0, max_r));
changed = true;
}
changed | apply_arc_params(p, pie, arc_mode)
}
@@ -378,8 +476,9 @@ fn apply_poly_params(p: &Value, reset: bool, sides: &mut u32, spoke_ratio: &mut
}
changed = true;
}
if let Some(n_sides) = p.get("sides").and_then(Value::as_u64) {
*sides = (n_sides as u32).clamp(3, 1000);
// `check_configure` has refused values outside their ranges.
if let Some(n_sides) = num_param(p, "sides") {
*sides = n_sides as u32;
changed = true;
}
if let Some(star) = p.get("isStar").and_then(Value::as_bool) {
@@ -387,15 +486,15 @@ fn apply_poly_params(p: &Value, reset: bool, sides: &mut u32, spoke_ratio: &mut
changed = true;
}
if let Some(sr) = num_param(p, "spokeRatio") {
*spoke_ratio = Some(sr.clamp(0.01, 1.0));
*spoke_ratio = Some(sr);
changed = true;
}
if let Some(rnd) = num_param(p, "rounded") {
*rounded = rnd.clamp(-10.0, 10.0);
*rounded = rnd;
changed = true;
}
if let Some(rand) = num_param(p, "randomized") {
*randomized = rand.clamp(-10.0, 10.0);
*randomized = rand;
changed = true;
}
changed
@@ -429,15 +528,15 @@ fn apply_shape_params(cs: &mut CustomShape, p: &Value, reset: bool) -> bool {
changed = true;
}
if let Some(t) = num_param(p, "turns") {
*turns = t.clamp(0.01, 1024.0);
*turns = t;
changed = true;
}
if let Some(div) = num_param(p, "divergence") {
*divergence = div.clamp(0.0, 1000.0);
*divergence = div;
changed = true;
}
if let Some(ir) = num_param(p, "innerRadius") {
*inner_radius = ir.clamp(0.0, 0.999);
*inner_radius = ir;
changed = true;
}
changed
@@ -470,11 +569,12 @@ fn apply_shape_params(cs: &mut CustomShape, p: &Value, reset: bool) -> bool {
}
}
fn parse_mode(m: &str) -> shapes::EllipseMode {
fn parse_mode(m: &str) -> Option<shapes::EllipseMode> {
match m {
"pie" | "slice" => shapes::EllipseMode::Slice,
"chord" => shapes::EllipseMode::Chord,
"arc" | "open" => shapes::EllipseMode::Arc,
_ => shapes::EllipseMode::Whole,
"whole" => Some(shapes::EllipseMode::Whole),
"pie" | "slice" => Some(shapes::EllipseMode::Slice),
"chord" => Some(shapes::EllipseMode::Chord),
"arc" | "open" => Some(shapes::EllipseMode::Arc),
_ => None,
}
}
@@ -135,3 +135,169 @@ fn addon_shapes_commands_and_format_roundtrip_preserve_paths() {
assert_eq!(CustomShape::read(a), CustomShape::read(b));
}
}
fn id_of(s: &mut Session, cmd: &str, p: serde_json::Value) -> NodeId {
NodeId(s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"))["id"].as_u64().unwrap())
}
fn shape_of(s: &Session, id: NodeId) -> CustomShape {
CustomShape::read(s.doc().unwrap().doc.node(id).unwrap()).unwrap()
}
/// Runs `cmd` with `p`, which must fail with `msg` and leave the document and its history as they
/// were.
fn refused(s: &mut Session, cmd: &str, p: serde_json::Value, msg: &str) {
let saved = vectorcraft_format::save(&s.doc().unwrap().doc, false);
let undo = s.doc().unwrap().history.undo.len();
let e = s.execute(cmd, &p).map(|v| v.to_string()).unwrap_err().to_string();
assert!(e.contains(msg), "{cmd} {p}: {e}");
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "{cmd} {p}");
assert_eq!(vectorcraft_format::save(&s.doc().unwrap().doc, false), saved, "{cmd} {p}");
}
#[test]
fn polygon_and_star_refuse_counts_that_are_not_whole_numbers_from_3_to_1000() {
let mut s = new_session();
for n in [json!(2.5), json!(2), json!(0), json!(-6), json!(1000.5), json!(5000), json!(4_294_967_299u64)] {
let x = n.as_f64().unwrap();
let p = json!({"cx": 0.0, "cy": 0.0, "radius": 10.0, "sides": n});
refused(&mut s, "addon.shapes.polygon", p, &format!("`sides` must be a whole number between 3 and 1000 (got {x})"));
let p = json!({"cx": 0.0, "cy": 0.0, "radius1": 10.0, "points": n});
refused(&mut s, "addon.shapes.star", p, &format!("`points` must be a whole number between 3 and 1000 (got {x})"));
}
refused(&mut s, "addon.shapes.polygon", json!({"cx": 0.0, "cy": 0.0, "radius": 10.0, "sides": "6"}), "`sides` must be a number");
refused(&mut s, "addon.shapes.star", json!({"cx": 0.0, "cy": 0.0, "radius1": 10.0, "points": [5]}), "`points` must be a number");
// The ends of the range are taken as given, and an omitted or null count is the default.
for (n, want) in [(json!(3), 3), (json!(1000), 1000), (json!(7.0), 7), (json!(null), 6)] {
let id = id_of(&mut s, "addon.shapes.polygon", json!({"cx": 0.0, "cy": 0.0, "radius": 10.0, "sides": n}));
assert!(matches!(shape_of(&s, id), CustomShape::Polygon { sides, spoke_ratio: None, .. } if sides == want), "{n}");
}
let id = id_of(&mut s, "addon.shapes.polygon", json!({"cx": 0.0, "cy": 0.0, "radius": 10.0}));
assert!(matches!(shape_of(&s, id), CustomShape::Polygon { sides: 6, .. }));
for (n, want) in [(json!(3), 3), (json!(1000), 1000), (json!(null), 5)] {
let id = id_of(&mut s, "addon.shapes.star", json!({"cx": 0.0, "cy": 0.0, "radius1": 10.0, "points": n}));
assert!(matches!(shape_of(&s, id), CustomShape::Polygon { sides, spoke_ratio: Some(_), .. } if sides == want), "{n}");
}
}
#[test]
fn shapes_refuse_numbers_outside_their_ranges() {
let mut s = new_session();
let poly = json!({"cx": 0.0, "cy": 0.0, "radius": 10.0});
let star = json!({"cx": 0.0, "cy": 0.0, "radius1": 10.0});
let spiral = json!({"cx": 0.0, "cy": 0.0, "radius": 10.0});
let with = |base: &serde_json::Value, key: &str, v: f64| {
let mut p = base.clone();
p[key] = json!(v);
p
};
for (cmd, base, key, bad, good) in [
("addon.shapes.polygon", &poly, "rounded", [-10.5, 10.5], [-10.0, 10.0]),
("addon.shapes.polygon", &poly, "randomized", [-11.0, 10.01], [-10.0, 10.0]),
("addon.shapes.star", &star, "rounded", [-10.5, 10.5], [-10.0, 10.0]),
("addon.shapes.star", &star, "randomized", [-11.0, 10.01], [-10.0, 10.0]),
("addon.shapes.star", &star, "radius2", [0.05, 11.0], [0.1, 10.0]),
("addon.shapes.spiral", &spiral, "turns", [0.0, 1024.5], [0.01, 1024.0]),
("addon.shapes.spiral", &spiral, "divergence", [-0.1, 1000.5], [0.0, 1000.0]),
("addon.shapes.spiral", &spiral, "innerRadius", [-0.1, 1.0], [0.0, 0.999]),
] {
let (min, max) = (good[0], good[1]);
for x in bad {
refused(&mut s, cmd, with(base, key, x), &format!("`{key}` must be between {min} and {max} (got {x})"));
}
for x in good {
s.execute(cmd, &with(base, key, x)).unwrap_or_else(|e| panic!("{cmd} {key} {x}: {e}"));
}
}
// Sizes are not negative.
for (cmd, p, key) in [
("addon.shapes.polygon", json!({"cx": 0.0, "cy": 0.0, "radius": -10.0}), "radius"),
("addon.shapes.star", json!({"cx": 0.0, "cy": 0.0, "radius1": -10.0}), "radius1"),
("addon.shapes.spiral", json!({"cx": 0.0, "cy": 0.0, "radius": -10.0}), "radius"),
("addon.shapes.rectangle", json!({"x": 0.0, "y": 0.0, "width": -10.0, "height": 10.0}), "width"),
("addon.shapes.rectangle", json!({"x": 0.0, "y": 0.0, "width": 10.0, "height": 10.0, "radius": -1.0}), "radius"),
("addon.shapes.ellipse", json!({"x": 0.0, "y": 0.0, "width": 10.0, "height": -10.0}), "height"),
("addon.shapes.box3d", json!({"x": 0.0, "y": 0.0, "width": -10.0, "height": 10.0}), "width"),
] {
refused(&mut s, cmd, p, &format!("`{key}` must be a number not below 0"));
}
// Corner radii are four numbers that are not negative.
for radii in [json!([1.0, 2.0, 3.0]), json!([1.0, 2.0, 3.0, -4.0]), json!([1.0, 2.0, 3.0, "4"]), json!(4.0)] {
let p = json!({"x": 0.0, "y": 0.0, "width": 100.0, "height": 80.0, "radii": radii});
refused(&mut s, "addon.shapes.rectangle", p, "`radii` must be four numbers not below 0: [tl, tr, br, bl]");
}
// An arc mode is one of the names the commands take.
for mode in [json!("slices"), json!(""), json!(3)] {
let p = json!({"x": 0.0, "y": 0.0, "width": 100.0, "height": 80.0, "arcMode": mode});
refused(&mut s, "addon.shapes.rectangle", p.clone(), "`arcMode` must be one of whole, pie, chord, arc");
refused(&mut s, "addon.shapes.ellipse", p, "`arcMode` must be one of whole, pie, chord, arc");
}
for mode in ["whole", "pie", "slice", "chord", "arc", "open"] {
s.execute("addon.shapes.ellipse", &json!({"x": 0.0, "y": 0.0, "width": 100.0, "height": 80.0, "arcMode": mode})).unwrap();
}
}
#[test]
fn configure_refuses_values_outside_their_ranges_before_changing_any_shape() {
let mut s = new_session();
let rect = id_of(&mut s, "addon.shapes.rectangle", json!({"x": 0.0, "y": 0.0, "width": 100.0, "height": 80.0, "radius": 4.0}));
let poly = id_of(&mut s, "addon.shapes.polygon", json!({"cx": 50.0, "cy": 50.0, "radius": 40.0, "sides": 5}));
let spiral = id_of(&mut s, "addon.shapes.spiral", json!({"cx": 60.0, "cy": 60.0, "radius": 45.0}));
let ids = json!([rect.0, poly.0, spiral.0]);
let cfg = |key: &str, v: serde_json::Value| json!({"ids": ids.clone(), key: v});
let c = "addon.shapes.configure";
for n in [json!(2.5), json!(2), json!(-1), json!(5000), json!(4_294_967_299u64)] {
let x = n.as_f64().unwrap();
refused(&mut s, c, cfg("sides", n), &format!("`sides` must be a whole number between 3 and 1000 (got {x})"));
}
for (key, bad, min, max) in [
("spokeRatio", [0.0, 1.5], 0.01, 1.0),
("rounded", [-10.5, 11.0], -10.0, 10.0),
("randomized", [-11.0, 10.5], -10.0, 10.0),
("turns", [0.0, 2000.0], 0.01, 1024.0),
("divergence", [-1.0, 1000.5], 0.0, 1000.0),
("innerRadius", [-0.5, 1.0], 0.0, 0.999),
] {
for x in bad {
refused(&mut s, c, cfg(key, json!(x)), &format!("`{key}` must be between {min} and {max} (got {x})"));
}
}
refused(&mut s, c, cfg("radius", json!(-1.0)), "`radius` must be a number not below 0");
for radii in [json!([1.0, 2.0]), json!([1.0, 2.0, 3.0, -1.0]), json!("4")] {
refused(&mut s, c, cfg("radii", radii), "`radii` must be four numbers not below 0: [tl, tr, br, bl]");
}
refused(&mut s, c, cfg("arcMode", json!("bogus")), "`arcMode` must be one of whole, pie, chord, arc");
// A valid value next to a refused one changes no shape either.
refused(&mut s, c, json!({"ids": ids.clone(), "sides": 8, "turns": 0.0}), "`turns` must be between 0.01 and 1024 (got 0)");
// In range, the values are taken as given; a radius larger than the rectangle takes is fitted to it.
s.execute(c, &json!({"ids": [poly.0], "sides": 1000, "rounded": -10.0, "randomized": 10.0})).unwrap();
assert!(matches!(shape_of(&s, poly), CustomShape::Polygon { sides: 1000, rounded, randomized, .. } if rounded == -10.0 && randomized == 10.0));
s.execute(c, &json!({"ids": [poly.0], "sides": 3})).unwrap();
assert!(matches!(shape_of(&s, poly), CustomShape::Polygon { sides: 3, .. }));
s.execute(c, &json!({"ids": [spiral.0], "turns": 1024.0, "divergence": 0.0, "innerRadius": 0.999})).unwrap();
assert!(
matches!(shape_of(&s, spiral), CustomShape::Spiral { turns, divergence, inner_radius, .. } if turns == 1024.0 && divergence == 0.0 && inner_radius == 0.999)
);
s.execute(c, &json!({"ids": [rect.0], "radius": 1000.0})).unwrap();
assert!(matches!(shape_of(&s, rect), CustomShape::Rectangle { radii, .. } if radii == [40.0; 4]));
}
#[test]
fn configure_records_no_undo_step_when_nothing_changes() {
let mut s = new_session();
let poly = id_of(&mut s, "addon.shapes.polygon", json!({"cx": 50.0, "cy": 50.0, "radius": 40.0, "sides": 5, "rounded": 0.5}));
let undo = s.doc().unwrap().history.undo.len();
let r = s.execute("addon.shapes.configure", &json!({"ids": [poly.0], "sides": 5, "rounded": 0.5})).unwrap();
assert_eq!(r["updated"], 0);
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
let r = s.execute("addon.shapes.configure", &json!({"ids": [poly.0], "sides": 6})).unwrap();
assert_eq!(r["updated"], 1);
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
assert!(matches!(shape_of(&s, poly), CustomShape::Polygon { sides: 6, .. }));
}
+3 -3
View File
@@ -36,7 +36,7 @@ use super::paint::paint_from;
use super::{AppearanceAttrs, EyedropperAttrs, FillAttrs, StrokeAttrs};
use super::{
CommandSpec, NodeId, Result, Session, bad, check_numbers, check_switches, cmd, has_doc, has_selection, ids_param, index, leaf_targets,
object_param, ok, paint_targets, str_param, switch_or, targets,
object_param, ok, paint_targets, str_param, switch_or, targets, u64_req,
};
use crate::EngineError;
@@ -604,9 +604,9 @@ pub(crate) fn edits_stroke(s: &Session, p: &Value, item: ItemTarget, default: bo
Ok(ids.get(item.anchor(s, &ids)).and_then(|id| d.node(*id)?.appearance.items.get(index)).map_or(default, |it| !it.is_fill()))
}
/// The whole number of 0 or more that `key` holds, read with [`index`].
/// The whole number of 0 or more that `key` holds, read with [`u64_req`] and [`index`].
pub(crate) fn index_param(p: &Value, key: &str, cmd: &str) -> Result<usize> {
p.get(key).and_then(Value::as_u64).map(index).ok_or_else(|| bad(cmd, format!("missing integer `{key}`")))
u64_req(p, key, cmd).map(index)
}
fn set_active_item(s: &mut Session, p: &Value) -> Result<Value> {
+2 -2
View File
@@ -53,7 +53,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Export Assets",
[],
None,
"{assets?: [asset id…] (default all but the 3D assets, which assets.export3D writes), folder?, zip?, openLocation?, formats?, preset?, settings?, prefix?, subfolders?, antiAlias?} each asset's art alone, cropped to it, in every format row: document.exportForScreens {assets} at the document's export settings (document.exportSettings) under the ones given; with subfolders false every file goes into the folder itself, mobile and density rows too → what document.exportForScreens returns (no folder: the files, or one zip, as dataBase64). Leaves the remembered settings as they are (assets.settings.set changes them)",
"{assets?: [asset id…] (default all but the 3D assets, which assets.export3D writes), folder?, zip?, openLocation?, formats?, preset?, settings?, prefix?, subfolders?, antiAlias?} each asset's art alone, cropped to it, in every format row: document.exportForScreens {assets} at the document's export settings (document.exportSettings) under the ones given; with subfolders false every file goes into the folder itself, mobile and density rows too → what document.exportForScreens returns (no folder: the files, or one zip, as dataBase64; warnings name a key in the rows or settings given that only another format takes, not one in the remembered settings). Leaves the remembered settings as they are (assets.settings.set changes them)",
has_doc,
export
),
@@ -429,7 +429,7 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
}
q.insert("assets".into(), json!(all));
}
export_screens(s, &Value::Object(q)).map_err(|e| of_cmd(C, e))
export_screens(s, &Value::Object(q), p).map_err(|e| of_cmd(C, e))
}
fn settings_set(s: &mut Session, p: &Value) -> Result<Value> {
+1 -1
View File
@@ -226,7 +226,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Break Link to Symbol",
[],
None,
"{ids?|id?} expand selected instances into plain art (with their appearance changes to a dynamic symbol), placed through the symbol's 9-slice guides when it has them, with the instance's opacity mask; a hidden or locked instance's art stays hidden or locked and is not selected → {ids}",
"{ids?|id?} expand selected instances into plain art (with their appearance changes to a dynamic symbol), placed through the symbol's 9-slice guides when it has them, with the instance's opacity mask and its own bounding box (square after Reset Bounding Box); a hidden or locked instance's art stays hidden or locked and is not selected → {ids}",
has_selection,
symbol_break_link
),
+11 -3
View File
@@ -393,8 +393,10 @@ pub(super) fn symbol_place(s: &mut Session, p: &Value) -> Result<Value> {
/// The plain art of instance `inst` in document space (fresh ids), with the instance's own
/// appearance changes to a dynamic symbol's art when `own`, placed through its symbol's 9-slice
/// guides when it has them. The instance's opacity mask goes on the art, or on a group around it
/// when the art has a mask of its own.
/// guides when it has them. The art's bounding boxes turn with the instance's own box, so after
/// Reset Bounding Box they stand as the instance's box does. The instance's opacity mask goes on
/// the art, or on a group around it when the art has a mask of its own; a group added around the
/// art takes the instance's box angle.
fn expanded_instance(d: &mut Document, inst: &Node, own: bool) -> Option<Node> {
let NodeKind::SymbolInstance { symbol, xf } = &inst.kind else { return None };
let sym = d.symbols.iter().find(|s| &s.name == symbol)?;
@@ -402,12 +404,17 @@ fn expanded_instance(d: &mut Document, inst: &Node, own: bool) -> Option<Node> {
if let Some((color, amount)) = brush::stain(inst) {
brush::tint_node(&mut art, &color, amount);
}
let art = d.place_symbol_art(symbol, &art, *xf)?;
let mut art = d.place_symbol_art(symbol, &art, *xf)?;
let turn = vectorcraft_doc::orient::placed_turn(inst.bbox_angle, *xf);
if turn != 0.0 {
art.turn_boxes(turn);
}
let mut art = d.reid(&art);
if inst.opacity < 1.0 || inst.blend != vectorcraft_color::BlendMode::Normal {
if !matches!(art.kind, NodeKind::Group { .. }) {
let id = d.alloc_id();
art = Node::group(id, vec![Arc::new(art)]);
art.bbox_angle = inst.bbox_angle;
}
art.opacity *= inst.opacity;
art.blend = inst.blend;
@@ -416,6 +423,7 @@ fn expanded_instance(d: &mut Document, inst: &Node, own: bool) -> Option<Node> {
if art.extras().mask.is_some() {
let id = d.alloc_id();
art = Node::group(id, vec![Arc::new(art)]);
art.bbox_angle = inst.bbox_angle;
}
art.extras_mut().mask = Some(mask.clone());
}
@@ -127,6 +127,17 @@ fn node_refs(n: &Node, f: Visit) {
if let Some(cm) = Document::color_mask_of(m) {
f(Ref::Name(Res::Image, &cm.key));
}
// A graph keeps paints and style names for its series and labels, which a redraw gives them.
if let Some(g) = m.extras().graph.as_deref() {
vectorcraft_doc::swatches::graph_paints(m).for_each(|p| paint_refs(p, f));
let l = &g.label_styles;
for s in [&l.values, &l.categories, &l.legend].into_iter().flatten().filter_map(|st| st.style_name.as_ref()) {
f(Ref::Name(Res::CharStyle, s));
}
for s in [&l.values_para, &l.categories_para, &l.legend_para].into_iter().flatten().filter_map(|pa| pa.style_name.as_ref()) {
f(Ref::Name(Res::ParaStyle, s));
}
}
match &m.kind {
NodeKind::Text(t) => {
for r in &t.runs {
@@ -671,6 +682,27 @@ impl Relink {
self.name(Res::Image, &mut cm.key);
self.name(Res::Image, &mut cm.from);
}
if let Some(g) = n.extras_if_any().and_then(|e| e.graph.as_deref_mut()) {
for s in &mut g.series_paints {
s.fill.iter_mut().chain(s.stroke.iter_mut()).for_each(|p| self.paint(p));
}
let l = &mut g.label_styles;
for st in [&mut l.values, &mut l.categories, &mut l.legend].into_iter().flatten() {
self.paint(&mut st.fill);
self.paint(&mut st.stroke);
if let Some(s) = &mut st.style_name {
self.name(Res::CharStyle, s);
}
}
for pa in [&mut l.values_para, &mut l.categories_para, &mut l.legend_para].into_iter().flatten() {
if let Some(s) = &mut pa.style_name {
self.name(Res::ParaStyle, s);
}
}
for r in [&mut l.values_rows, &mut l.categories_rows, &mut l.legend_rows].into_iter().flatten() {
r.paint.fill.iter_mut().chain(r.paint.stroke.iter_mut()).for_each(|p| self.paint(p));
}
}
match &mut n.kind {
NodeKind::Text(t) => {
for r in &mut t.runs {
+2 -2
View File
@@ -231,9 +231,9 @@ pub(super) fn within_input_bound(q: Point) -> bool {
q.x.is_finite() && q.y.is_finite() && q.x.abs() < MAX_INPUT && q.y.abs() < MAX_INPUT
}
/// The whole number of 0 or more that `key` holds, read with [`index`].
/// The whole number of 0 or more that `key` holds, read with [`u64_req`](crate::cmd::u64_req) and [`index`].
pub(super) fn usize_req(p: &Value, key: &str, cmd: &str) -> Result<usize> {
p.get(key).and_then(Value::as_u64).map(index).ok_or_else(|| bad(cmd, format!("missing integer `{key}`")))
crate::cmd::u64_req(p, key, cmd).map(index)
}
/// The whole number of 0 or more that `key` holds, or `None` when `key` is absent or null. Any other
+17 -4
View File
@@ -11,7 +11,9 @@ use super::{
// ---------- Pencil / Paintbrush ----------
/// Fit a freehand polyline: drop jitter, build a spline through the samples (sharp turns stay
/// corners) and simplify it to `tol`.
/// corners) and simplify it to `tol`. A closed stroke is fitted as the open one is and closed by a
/// straight segment from its end back to its start: fitting round the gap would curve that segment
/// on from both ends, past them and back.
fn fit_freehand(pts: &[Point], tol: f64, closed: bool) -> SubPath {
let min = (tol * 0.3).max(vectorcraft_tools::draw2::MIN_FIT_TOLERANCE * 0.3);
let (Some(&p0), Some(&pn)) = (pts.first(), pts.last()) else { return SubPath::new(vec![], closed) };
@@ -41,15 +43,26 @@ fn fit_freehand(pts: &[Point], tol: f64, closed: bool) -> SubPath {
(kept[i], corner)
})
.collect();
let sp = vectorcraft_tools::draw2::catmull_rom(&marked, closed);
let sp = vectorcraft_tools::draw2::catmull_rom(&marked, false);
if sp.anchors.len() < 3 {
return sp;
// Two anchors closed would run back over the line itself.
return SubPath::new(sp.anchors, closed && n > 2);
}
let fitted = po::simplify_with(
&PathData::single(sp.clone()),
&SimplifyOptions { tolerance: tol, corner_angle_deg: 60.0, straight_lines: false, ..Default::default() },
);
fitted.subpaths.into_iter().next().filter(|s| s.anchors.len() >= 2).unwrap_or(sp)
let mut anchors = fitted.subpaths.into_iter().next().filter(|s| s.anchors.len() >= 2).unwrap_or(sp).anchors;
if closed {
// No handle on either side of the closing segment.
if let Some(l) = anchors.last_mut() {
l.h_out = l.p;
}
if let Some(f) = anchors.first_mut() {
f.h_in = f.p;
}
}
SubPath::new(anchors, closed)
}
/// The path of a freehand stroke: fitted at `tol` ([`fit_freehand`]), or with no `tol` the points
+17 -12
View File
@@ -11,7 +11,7 @@ use super::clipboard::SwatchChoices;
use super::{
CommandSpec, Result, Session, always, bad, can_clear_history, can_redo, can_undo, check_numbers, checked_ids_param, clock_date, cmd, date_param,
f64_req, finite_numbers, has_clipboard, has_doc, has_selection, has_selection_guides_or_artboard_tool, has_selection_or_guides, index, number_or,
object_param, ok, point_param, str_param, switch_or,
object_param, ok, point_param, str_param, switch_or, u64_req,
};
use crate::{Clipboard, EngineError, SourceLayer, inspect};
@@ -249,7 +249,10 @@ fn file_close(s: &mut Session, p: &Value) -> Result<Value> {
}
fn doc_activate(s: &mut Session, p: &Value) -> Result<Value> {
let i = p.get("index").and_then(Value::as_u64).map(index).ok_or_else(|| bad("document.activate", "missing index"))?;
let i = match p.get("index") {
None | Some(Value::Null) => return Err(bad("document.activate", "missing index")),
Some(v) => v.as_u64().map(index).ok_or_else(|| bad("document.activate", "`index` must be a whole number"))?,
};
if s.set_active(i) { ok() } else { Err(bad("document.activate", "no such document")) }
}
@@ -262,7 +265,10 @@ fn doc_move(s: &mut Session, p: &Value) -> Result<Value> {
if from >= n {
return Err(bad("document.move", "no such document"));
}
let to = p.get("to").and_then(Value::as_u64).map(index).ok_or_else(|| bad("document.move", "missing `to`: a non-negative integer"))?;
let to = match p.get("to") {
None | Some(Value::Null) => return Err(bad("document.move", "missing `to`: a non-negative integer")),
Some(v) => v.as_u64().map(index).ok_or_else(|| bad("document.move", "`to` must be a whole number"))?,
};
let to = to.min(n.saturating_sub(1));
if !s.move_document(from, to) {
return Err(bad("document.move", "no such document"));
@@ -562,12 +568,11 @@ fn switch_units(s: &mut Session, _: &Value) -> Result<Value> {
Ok(json!({ "units": next.label() }))
}
/// The `artboard` param of the ruler origin commands: an artboard index, 0 when not given.
/// The `artboard` param of the ruler origin commands: an artboard index, 0 when left out or null. A
/// value that is not a whole number is "`artboard` must be a whole number"; one past the artboards
/// is "index out of range".
fn artboard_param(s: &Session, p: &Value, c: &str) -> Result<usize> {
let index = match p.get("artboard") {
None => 0,
Some(v) => v.as_u64().and_then(|n| usize::try_from(n).ok()).ok_or_else(|| bad(c, "index out of range"))?,
};
let index = super::index_opt(p, "artboard", c)?.unwrap_or(0);
if index >= s.doc()?.doc.artboards.len() {
return Err(bad(c, "index out of range"));
}
@@ -577,14 +582,15 @@ fn artboard_param(s: &Session, p: &Value, c: &str) -> Result<usize> {
fn ruler_origin(s: &mut Session, p: &Value) -> Result<Value> {
let d = &s.doc()?.doc;
// A document without artboards measures from its global zero point.
let index = if d.artboards.is_empty() && p.get("artboard").is_none() { 0 } else { artboard_param(s, p, "document.rulerOrigin")? };
let index =
if d.artboards.is_empty() && p.get("artboard").is_none_or(Value::is_null) { 0 } else { artboard_param(s, p, "document.rulerOrigin")? };
Ok(json!(s.doc()?.doc.ruler_origin_for(index)))
}
fn set_ruler_origin(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "document.setRulerOrigin";
let origin = Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?);
let index = if s.doc()?.doc.rulers == RulerMode::Global && p.get("artboard").is_none() { 0 } else { artboard_param(s, p, C)? };
let index = if s.doc()?.doc.rulers == RulerMode::Global && p.get("artboard").is_none_or(Value::is_null) { 0 } else { artboard_param(s, p, C)? };
change_ruler_origin(s, index, Some(origin), "Ruler Origin")
}
@@ -1190,8 +1196,7 @@ fn clear(s: &mut Session, p: &Value) -> Result<Value> {
/// Document ids scope node ids: two tabs may contain objects with the same node id.
fn drop_selection(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "edit.dropSelection";
let key = "sourceDocument";
let uid = p[key].as_u64().ok_or_else(|| bad(CMD, format!("missing integer `{key}`")))?;
let uid = u64_req(p, "sourceDocument", CMD)?;
let point = |key: &str| finite_numbers::<2>(&p[key]).ok_or_else(|| bad(CMD, format!("{key} must be [x, y] with finite coordinates")));
let grab = point("grab")?;
let at = point("at")?;
+5 -3
View File
@@ -865,8 +865,8 @@ fn load_texture(params: &mut Value, cmd: &str) -> Result<()> {
/// Has `n` an appearance Expand Appearance turns into objects: a path that isn't basic (several
/// fills or strokes, effects, transparency of a fill or stroke, a brush), a group or layer with
/// fills, strokes or effects of its own or such members, other objects with effects, type with
/// fills or strokes of its own, or a symbol instance whose active fill or stroke has a geometry
/// effect ([`effects::has_item_geometry`])?
/// fills or strokes of its own, or a symbol instance whose active fill or stroke has a geometry or
/// raster effect ([`effects::has_item_geometry`], [`effects::has_instance_row_raster`])?
pub(crate) fn expandable(n: &Node) -> bool {
expandable_except(n, &[])
}
@@ -883,7 +883,9 @@ pub(crate) fn expandable_except(n: &Node, skip: &[NodeId]) -> bool {
!n.appearance.items.is_empty() || !n.appearance.effects.is_empty() || children.iter().any(|c| expandable_except(c, skip))
}
NodeKind::Text(_) => !n.appearance.items.is_empty() || !n.appearance.effects.is_empty(),
NodeKind::Image(_) | NodeKind::SymbolInstance { .. } => effects::has_item_geometry(n) || !n.appearance.effects.is_empty(),
NodeKind::Image(_) | NodeKind::SymbolInstance { .. } => {
effects::has_item_geometry(n) || effects::has_instance_row_raster(n) || !n.appearance.effects.is_empty()
}
_ => !n.appearance.effects.is_empty(),
}
}
+1 -1
View File
@@ -92,7 +92,7 @@ pub use pdfimport::{LoadOptions, page_document};
pub(crate) use save::job_for;
pub(crate) use save::save as save_document;
pub use save::{
SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, TEMPLATE_FORMATS, dialog_folder, documents_dialog_folder, export_folder,
SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, TEMPLATE_FORMATS, dialog_folder, documents_dialog_folder, export_folder, file_reads,
leave_out_unreadable_save_paths, reads_option as save_reads_option, remembered_save_options, save_filters, save_format, save_job, save_plan,
save_with, stamp_save_dates, template_filters, templates_dialog_folder, templates_folder,
};
+1 -1
View File
@@ -54,7 +54,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Export for Screens",
["File", "Export"],
None,
"{folder?, zip?: false (one .zip of every file, each deflated or stored, whichever is smaller: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboard?: index | artboards?: [index…] | range?: \"1-3\" (default all), assets?: [asset id…] (instead of artboards: each asset's art alone, cropped to its bounds and named after the asset; see assets.list), layers?: [layer id…] (instead of artboards: each layer's art alone, sublayers included, named after the layer), selection?: one|each (instead of artboards: the selected art as one file, or a file per selected object, named after it), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), includeArtboardFill?: true (Include Artboard Fill: each artboard's background color behind its art; false leaves it out), pdfs?: multiple|single (Export PDFs As: with several artboards, single writes them as the pages of one PDF per PDF row, named after the document), exportPreset?: (a preset of document.exportPresets.list: its settings, under these params), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), folders?: none|rows (as subfolders)|format (a folder per extension: png, svg, pdf…)|item (a folder per artboard, asset, layer or object)|itemFormat (item/extension) (default: rows with subfolders or a preset, else none), name?: (a file name pattern instead of the item's name, with <filename> for the document, <name> for the artboard, asset, layer or object, <index> from 1, #### for the counter zero-padded to the run of #, <format>, <scale>, <suffix> (else the row's suffix ends the name), YYYYMMDD, YYYY-MM-DD, YYYY, <date>, <time>, <YYYY>, <MM>, <DD>, <hh>, <mm> and <ss>; a trailing .ext is dropped, as in `COMPANY-Logo-<filename>-####-YYYYMMDD`; different files it names alike get -2, -3…), counterStart?: 1, counterPer?: item (every file of an item has its number)|file, date?: YYYY-MM-DD (the date the pattern uses; default today, UTC), preset?: mobile (PNG 1x, 2x, 3x) | density (PNG 0.75x–4x in ldpi…xxxhdpi sub-folders) instead of formats (turns subfolders on), formats?: [{format: png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft, scale?: 1 | \"2x\" | \"100w\" | \"100h\" | \"72ppi\", width?: px | height?: px | ppi? (raster only; width, then height, then ppi win over scale), suffix?: (raster default: @2x, @100w, @100h, none at 1x; vector formats drop size suffixes), folder?: (its sub-folder), quality?: (jpg 0–100), …the format's options (antiAlias, background, preset (pdf)…)}], settings?: {png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft: {…options for every row of that format}} (rows' own options win), prefix?, antiAlias?: none|art|type (raster rows without their own), openLocation?: (remembered; the app shows the files after exporting)} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N; a row whose file would take a name another row's file has, in any case, gets -2, -3… too, and identical rows write one file; row folders that differ only in case are one folder, spelled as the first row spells it) → {files: [path…]} (into the folder of exportPreset, outside the automation read roots: no ZIP path, and files relative to that folder); no folder → {files: [{name, dataBase64}]} (names include sub-folders: 2x/Icon@2x.png); every reply has warnings: [the encoders' warnings for the files, each once: an image that couldn't be decoded, type written in the fallback font…]. eps, ai and vectorcraft rows write a document of the item alone (its one artboard). The params (but zip and assets) are remembered in the document (document.exportSettings; not an undo step). A key it doesn't take, a key no format takes in a format row or in settings, settings for a format it doesn't write, and an unknown key inside a PDF, EPS or SVG option's object, are refused. Under automation read roots, an svg or svgz row is refused, before the folder is made or any file is written, while the document links a JavaScript file outside them, as document.export refuses it",
"{folder?, zip?: false (one .zip of every file, each deflated or stored, whichever is smaller: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboard?: index | artboards?: [index…] | range?: \"1-3\" (default all), assets?: [asset id…] (instead of artboards: each asset's art alone, cropped to its bounds and named after the asset; see assets.list), layers?: [layer id…] (instead of artboards: each layer's art alone, sublayers included, named after the layer), selection?: one|each (instead of artboards: the selected art as one file, or a file per selected object, named after it), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), includeArtboardFill?: true (Include Artboard Fill: each artboard's background color behind its art; false leaves it out), pdfs?: multiple|single (Export PDFs As: with several artboards, single writes them as the pages of one PDF per PDF row, named after the document), exportPreset?: (a preset of document.exportPresets.list: its settings, under these params), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), folders?: none|rows (as subfolders)|format (a folder per extension: png, svg, pdf…)|item (a folder per artboard, asset, layer or object)|itemFormat (item/extension) (default: rows with subfolders or a preset, else none), name?: (a file name pattern instead of the item's name, with <filename> for the document, <name> for the artboard, asset, layer or object, <index> from 1, #### for the counter zero-padded to the run of #, <format>, <scale>, <suffix> (else the row's suffix ends the name), YYYYMMDD, YYYY-MM-DD, YYYY, <date>, <time>, <YYYY>, <MM>, <DD>, <hh>, <mm> and <ss>; a trailing .ext is dropped, as in `COMPANY-Logo-<filename>-####-YYYYMMDD`; different files it names alike get -2, -3…), counterStart?: 1, counterPer?: item (every file of an item has its number)|file, date?: YYYY-MM-DD (the date the pattern uses; default today, UTC), preset?: mobile (PNG 1x, 2x, 3x) | density (PNG 0.75x–4x in ldpi…xxxhdpi sub-folders) instead of formats (turns subfolders on), formats?: [{format: png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft, scale?: 1 | \"2x\" | \"100w\" | \"100h\" | \"72ppi\", width?: px | height?: px | ppi? (raster only; width, then height, then ppi win over scale), suffix?: (raster default: @2x, @100w, @100h, none at 1x; vector formats drop size suffixes), folder?: (its sub-folder), quality?: (jpg 0–100), …the format's options (antiAlias, background, preset (pdf)…)}], settings?: {png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft: {…options for every row of that format}} (rows' own options win), prefix?, antiAlias?: none|art|type (raster rows without their own), openLocation?: (remembered; the app shows the files after exporting)} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N; a row whose file would take a name another row's file has, in any case, gets -2, -3… too, and identical rows write one file; row folders that differ only in case are one folder, spelled as the first row spells it) → {files: [path…]} (into the folder of exportPreset, outside the automation read roots: no ZIP path, and files relative to that folder); no folder → {files: [{name, dataBase64}]} (names include sub-folders: 2x/Icon@2x.png); every reply has warnings: [the encoders' warnings for the files, each once: an image that couldn't be decoded, type written in the fallback font…]. eps, ai and vectorcraft rows write a document of the item alone (its one artboard). The params (but zip and assets) are remembered in the document (document.exportSettings; not an undo step). A key it doesn't take, a key no format takes in a format row or in settings, settings for a format it doesn't write, and an unknown key inside a PDF, EPS or SVG option's object, are refused; a key only another format takes in a row or in settings is not used, and warnings name it as given (formats[0].quality; a key in settings only when no row of that format reads it; not a key of an exportPreset). Under automation read roots, an svg or svgz row is refused, before the folder is made or any file is written, while the document links a JavaScript file outside them, as document.export refuses it",
has_doc,
screens::export_for_screens
),
+13 -14
View File
@@ -32,26 +32,25 @@ pub fn unused_option_notes(cmd: &str, f: &Format, p: &Value) -> Vec<String> {
}
let listed = |k: &str| super::FORMATS.iter().filter(|f| f.write).flat_map(|f| f.options).any(|o| o.name == k);
let written = super::save::written_as(cmd, f, p);
let legacy = f.id == "ai" && super::legacy_ai::wanted(cmd, f, p).unwrap_or(false);
let reads = |k: &str| match (f.id, k) {
// A legacy `.ai` file is not a PDF: it reads its own options only.
("ai", _) if legacy => super::legacy_ai::reads(k),
// The other `.ai` files don't read the legacy file's options, and `preserveEditing` is
// on for them whatever is given.
("ai", "preserveEditing") => false,
("ai", _) if super::legacy_ai::LEGACY_ONLY.contains(&k) => false,
// An export writes one file: only a save writes each artboard to a file of its own, and
// a native file's `range` names those files.
("vectorcraft" | "template" | "ai", "separateArtboards") | ("vectorcraft" | "template", "range") => false,
_ => super::save::reads_option(f, k),
};
let legacy = super::save::legacy_file(cmd, f, p);
given
.iter()
.filter(|(k, v)| !v.is_null() && !EXPORT_PARAMS.contains(&k.as_str()) && listed(k) && !reads(k))
.filter(|(k, v)| !v.is_null() && !EXPORT_PARAMS.contains(&k.as_str()) && listed(k) && !export_reads(f, legacy, k))
.map(|(k, _)| super::save::unused_option_note(k, &written))
.collect()
}
/// Does the file an export writes in `f` read option `key`, `legacy` when it is a legacy `.ai`
/// file? As a save's file does ([`super::save::file_reads`]), except that an export writes one
/// file: only a save writes each artboard to a file of its own, and a native file's `range` names
/// those files.
pub(super) fn export_reads(f: &Format, legacy: bool, key: &str) -> bool {
match (f.id, key) {
("vectorcraft" | "template" | "ai", "separateArtboards") | ("vectorcraft" | "template", "range") => false,
_ => super::save::file_reads(f, legacy, key),
}
}
pub(super) fn serialize(s: &mut Session, p: &Value) -> Result<Value> {
let f = writable("document.serialize", Some(text_param(p, "format", "document.serialize")?.unwrap_or("vectorcraft")), None)?;
check_linked_scripts("document.serialize", f.id, s.doc()?)?;
+69 -10
View File
@@ -95,14 +95,18 @@ pub struct SavePlan {
/// The `modified` param (as `date_param` reads it): the File Info date a Save or Save As to a file
/// stamps; `None`: the time it is written.
pub modified: Option<Option<i64>>,
/// Warnings about the params themselves: each native option (`compress`, `version`,
/// `preview`, `precision`) given beside the path, and each key of `options`, that the format
/// doesn't read, naming the file the save writes.
/// Warnings about the params themselves: each option a save format takes given beside the
/// path, and each key of `options`, that the file the save writes doesn't read
/// ([`file_reads`]), naming that file.
pub notes: Vec<String>,
/// The file the document was read from and what reading it left out, when the save needs
/// `acknowledgeLoss` to write over it and was not given it: every file the save writes is
/// checked against it before the first is written.
pub lossy_source: Option<(String, Vec<String>)>,
/// The `.ai` options the file doesn't read, with the values the document remembers from its
/// last `.ai` save: not in `options`, they go back into the remembered options once the file
/// is the document's own.
pub kept: Map<String, Value>,
}
impl SavePlan {
@@ -279,7 +283,8 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result<SavePlan> {
}
}
// None given: the ones the document was last saved with in this format.
let mut options = if given.is_empty() && format.id == st.format { remembered_save_options(st) } else { given };
let from_memory = given.is_empty() && format.id == st.format;
let mut options = if from_memory { remembered_save_options(st) } else { given };
// Remember the effective choice, including the encoder's default, so another document's
// later save cannot change what this one writes. Invalid non-null values still reach validation.
if matches!(format.id, "vectorcraft" | "template") {
@@ -311,21 +316,54 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result<SavePlan> {
let given = str_param(p, "path").is_some_and(|g| !g.is_empty());
super::check_not_lossy_overwrite(st, path, p, cmd).map_err(|e| if given { e } else { super::access::naming_in_error(e, path) })?;
}
let written = written_as(cmd, format, &Value::Object(options.clone()));
let top = NATIVE_OPTIONS.iter().filter(|k| p.get(**k).is_some_and(|v| !v.is_null())).map(|k| k.to_string());
let chosen = Value::Object(options.clone());
let written = written_as(cmd, format, &chosen);
let legacy = legacy_file(cmd, format, &chosen);
// Beside the path, a key some save format takes; in `options`, any key.
let top = p.as_object().into_iter().flatten().filter(|(k, v)| !v.is_null() && save_listed(k)).map(|(k, _)| k.to_string());
let nested = p.get("options").and_then(Value::as_object).into_iter().flatten().filter(|(_, v)| !v.is_null()).map(|(k, _)| k);
let notes = top
.filter(|k| !reads_option(format, k))
.chain(nested.filter(|k| !reads_option(format, k)).map(|k| format!("options.{k}")))
.filter(|k| !file_reads(format, legacy, k))
.chain(nested.filter(|k| !file_reads(format, legacy, k)).map(|k| format!("options.{k}")))
.map(|k| unused_option_note(&k, &written))
.collect();
// When the document's own file is a `.ai`, the `.ai` options this file doesn't read and the
// request doesn't give keep their remembered values ([`SavePlan::kept`]), so that Save Options
// still shows them (it sends only the ones the file reads). They are neither written nor sent
// again.
let mut kept = Map::new();
if format.id == "ai" && format.id == st.format {
let last = remembered_save_options(st);
for o in format.options.iter().filter(|o| !file_reads(format, legacy, o.name)) {
let value = if from_memory {
options.remove(o.name)
} else if options.contains_key(o.name) {
None
} else {
last.get(o.name).cloned()
};
if let Some(v) = value {
kept.insert(o.name.to_string(), v);
}
}
}
let lossy_source = super::lossy_source(st, p);
Ok(SavePlan { mode, path, format, options, name, folder, modified, notes, lossy_source })
Ok(SavePlan { mode, path, format, options, name, folder, modified, notes, lossy_source, kept })
}
/// The native save options, which a save also takes beside the path.
const NATIVE_OPTIONS: [&str; 4] = ["compress", "version", "preview", "precision"];
/// Does some save format (or template format) take option `key`?
fn save_listed(key: &str) -> bool {
NATIVE_OPTIONS.contains(&key)
|| super::FORMATS
.iter()
.filter(|f| SAVE_FORMATS.contains(&f.id) || TEMPLATE_FORMATS.contains(&f.id))
.flat_map(|f| f.options)
.any(|o| o.name == key)
}
/// The first option `p` gives both beside the path and in `options` with different values, as
/// `(key, top-level value, options value)`. For an SVG save (`svg`), an SVG option's value on
/// either side is the one its `svg: {…}` object gives, else the flat one.
@@ -467,6 +505,26 @@ pub fn reads_option(f: &Format, key: &str) -> bool {
f.options.iter().any(|o| o.name == key) || (is_pdf(f) && (super::pdf::OPTIONS.iter().any(|o| o.name == key) || super::pdf::is_setting(key)))
}
/// Does the file a save or an export writes in `f` read option `key`, `legacy` when it is a legacy
/// `.ai` file ([`super::legacy_ai::wanted`])? As [`reads_option`], except for `.ai` and `.ait`
/// files: a legacy file reads its own options only ([`super::legacy_ai::reads`]), and the others
/// don't read the legacy file's options or `preserveEditing`, which is on for them whatever is
/// given.
pub fn file_reads(f: &Format, legacy: bool, key: &str) -> bool {
match (f.id, key) {
("ai", _) if legacy => super::legacy_ai::reads(key),
("ai" | "ait", "preserveEditing") => false,
("ai" | "ait", _) if super::legacy_ai::LEGACY_ONLY.contains(&key) => false,
_ => reads_option(f, key),
}
}
/// Is a save or an export of `cmd` in `f` with options `p` to write a legacy `.ai` file? An
/// unknown version is not one (the save or export refuses it).
pub(super) fn legacy_file(cmd: &str, f: &Format, p: &Value) -> bool {
f.id == "ai" && super::legacy_ai::wanted(cmd, f, p).unwrap_or(false)
}
/// The note a save or an export gives for option `key`, as the request names it, which the file it
/// writes (`written`, see [`written_as`]) doesn't read.
pub(super) fn unused_option_note(key: &str, written: &Format) -> String {
@@ -1037,6 +1095,7 @@ impl SaveJob {
st.path = Some(path);
st.format = self.plan.format.id;
st.save_options = self.plan.options;
st.save_options.extend(self.plan.kept);
// A later save rounds only when it gives `precision` too.
st.save_options.remove("precision");
st.save_artboards = self.snapshot.artboards.iter().map(|a| a.id).collect();
@@ -1239,7 +1298,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {
"Save Document",
[],
None,
"{path?, format?: vectorcraft|template|pdf|svg|svgz|ai|eps (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings say when the format doesn't read compress, version, preview or precision given flat, or a key given in options), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), precision?: 0..9 (native: round path anchors and handles to this many decimal places, a smaller file; colors, transforms and type stay exact; default: exact, and a later save without it writes exactly again), separateArtboards?: false (native and .ai: also save each artboard of `range` to <name>-<artboard>.<ext> beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: true (native: also carry a PDF of every artboard; false omits it) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file and EPS (below): a .ai file is a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly, on the active artboard (the app's, else the one the document opened on; with editingData: false, the first); separateArtboards writes more files, one per artboard. EPS takes the options of document.exportEps: by default one file whose bounding box fits the visible art (the first artboard when no art shows), carrying the whole editable document; useArtboards: true (also when artboard, artboards or range names artboards; file.formatOptions then reports it true) makes the chosen artboard the page, drawn with its background color (without Use Artboards, only the first artboard's page when no art shows has its color), and with two or more chosen the file at the path is the central file of the visible art (again the first artboard when none shows) and each artboard is also written to <name>_<artboard>.eps beside it, holding that artboard; selectedOnly is refused; its PostScript appearance flattens transparency, as EPS requires. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF/EPS saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified. Under automation read roots, a reply leaves out the document's own file outside them when no path is given (files and linked then name the files by their file names), and a suggested folder outside them; an svg or svgz save (a document whose own format is SVG or SVGZ included) is refused, before any file is written, while the document links a JavaScript file outside them (svgInteractivity; a URL is not checked), also by file.saveAs and file.saveCopy. A key that no save format takes, beside the path or in options, and an unknown key inside a PDF option's object or svg, are refused. A terminal filesystem permission/read-only refusal returns READ_ONLY_LOCATION: followed by the reason; recovery-copy failures keep their original error. Non-UI callers never open a save panel",
"{path?, format?: vectorcraft|template|pdf|svg|svgz|ai|eps (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings name an option given flat or in options that the file doesn't read: a legacy .ai reads only its own options, and other .ai files don't read outlineText, hiddenObjects, transparentImages or preserveEditing), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), precision?: 0..9 (native: round path anchors and handles to this many decimal places, a smaller file; colors, transforms and type stay exact; default: exact, and a later save without it writes exactly again), separateArtboards?: false (native and .ai: also save each artboard of `range` to <name>-<artboard>.<ext> beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: true (native: also carry a PDF of every artboard; false omits it) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file and EPS (below): a .ai file is a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly, on the active artboard (the app's, else the one the document opened on; with editingData: false, the first); separateArtboards writes more files, one per artboard. EPS takes the options of document.exportEps: by default one file whose bounding box fits the visible art (the first artboard when no art shows), carrying the whole editable document; useArtboards: true (also when artboard, artboards or range names artboards; file.formatOptions then reports it true) makes the chosen artboard the page, drawn with its background color (without Use Artboards, only the first artboard's page when no art shows has its color), and with two or more chosen the file at the path is the central file of the visible art (again the first artboard when none shows) and each artboard is also written to <name>_<artboard>.eps beside it, holding that artboard; selectedOnly is refused; its PostScript appearance flattens transparency, as EPS requires. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF/EPS saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified. Under automation read roots, a reply leaves out the document's own file outside them when no path is given (files and linked then name the files by their file names), and a suggested folder outside them; an svg or svgz save (a document whose own format is SVG or SVGZ included) is refused, before any file is written, while the document links a JavaScript file outside them (svgInteractivity; a URL is not checked), also by file.saveAs and file.saveCopy. A key that no save format takes, beside the path or in options, and an unknown key inside a PDF option's object or svg, are refused. A terminal filesystem permission/read-only refusal returns READ_ONLY_LOCATION: followed by the reason; recovery-copy failures keep their original error. Non-UI callers never open a save panel",
has_doc,
|s, p| save(s, SaveMode::Save, p)
),
+87 -7
View File
@@ -18,7 +18,7 @@ use vectorcraft_geom::Rect;
use super::super::{EngineError, NodeId, Result, Session, bad, bool_or, str_param};
use super::encode::{add_note, single_artboard};
use super::export::{export_crop, isolated, selected_artwork, without_artboards};
use super::export::{export_crop, export_reads, isolated, selected_artwork, without_artboards};
use super::naming::{self, NameParts};
use super::{
ARTBOARD_PARAMS, ArtboardPick, Format, Note, artboard_file_names_keeping_dots, create_dir, distinct_names_by, encode_with_notes, merge,
@@ -524,6 +524,77 @@ fn rows_of(s: &Session, p: &Value, preset: Option<&str>) -> Result<Vec<Row>> {
Ok(rows)
}
/// A note for each option of a format row (`formats`) or of a format's `settings` in the request
/// `asked` that some format takes but the file the row writes, or every row of that format writes,
/// doesn't read (`{format: png, quality: 80}`), in the wording of a save's note and named as the
/// request names it (`formats[0].quality`, `settings.png.quality`). The rows and settings the
/// export writes with are those of `p` (`asked` with a preset or the remembered settings laid
/// under it). A row's own keys (`scale`, `suffix`…), the keys the export reads for every format
/// (`includeArtboardFill`, `richBlack`, `params`) and an artboard choice (the export picks the
/// artboards) get none, and so does an exporter plug-in's format.
pub(crate) fn unused_option_notes(asked: &Value, p: &Value) -> Vec<String> {
let listed = |k: &str| super::FORMATS.iter().filter(|f| f.write).flat_map(|f| f.options).any(|o| o.name == k);
let own = |k: &str| {
ROW_KEYS.contains(&k) || ARTBOARD_PARAMS.contains(&k) || matches!(k, "useArtboards" | "includeArtboardFill" | "richBlack" | "params")
};
// The keys of `given` that `f`, writing with `options` (the format's settings and the row's
// own), doesn't read.
let unread = |f: &Format, given: &Map<String, Value>, options: &Value| -> Vec<String> {
if vectorcraft_plugins::export::by_format(f.id).is_some() {
return Vec::new();
}
let legacy = super::save::legacy_file(C, f, options);
given.iter().filter(|(k, v)| !v.is_null() && !own(k) && listed(k) && !export_reads(f, legacy, k)).map(|(k, _)| k.clone()).collect()
};
let note = |key: String, f: &Format, options: &Value| super::save::unused_option_note(&key, &super::save::written_as(C, f, options));
let settings = p.get("settings").and_then(Value::as_object);
let of_format = |id: &str| settings.and_then(|s| s.get(id)).and_then(Value::as_object).cloned().unwrap_or_default();
// A row's format and the options it writes with.
let row_of = |row: &Value| -> Option<(&'static Format, Value)> {
let (Some(row), Ok(f)) = (row.as_object(), writable(C, Some(str_param(row, "format").unwrap_or("png")), None)) else { return None };
let mut options = of_format(f.id);
options.extend(row.iter().map(|(k, v)| (k.clone(), v.clone())));
Some((f, Value::Object(options)))
};
let mut out: Vec<String> = Vec::new();
if let Some(Value::Array(rows)) = asked.get("formats") {
for (i, row) in rows.iter().enumerate() {
let (Some((f, options)), Some(given)) = (row_of(row), row.as_object()) else { continue };
out.extend(unread(f, given, &options).into_iter().map(|k| note(format!("formats[{i}].{k}"), f, &options)));
}
}
// The rows written, each with its own keys: a format's settings are read as each of its rows
// writes them (a row may name another `.ai` version), else as they are.
let written: Vec<(&Format, Value, Map<String, Value>)> = match p.get("formats") {
Some(Value::Array(rows)) => {
rows.iter().filter_map(|row| row_of(row).map(|(f, options)| (f, options, row.as_object().cloned().unwrap_or_default()))).collect()
}
_ => Vec::new(),
};
for (id, given) in asked.get("settings").and_then(Value::as_object).into_iter().flatten() {
let (Some(given), Ok(f)) = (given.as_object(), writable(C, Some(id), None)) else { continue };
let mut rows: Vec<(&Value, Option<&Map<String, Value>>)> =
written.iter().filter(|(g, ..)| g.id == f.id).map(|(_, o, row)| (o, Some(row))).collect();
let alone = Value::Object(of_format(f.id));
if rows.is_empty() {
rows.push((&alone, None));
}
let unread_by: Vec<Vec<String>> = rows.iter().map(|(o, _)| unread(f, given, o)).collect();
// A setting a row reads and doesn't give itself is used. One no row uses is noted when a
// row doesn't read it, naming the first such row's file.
for (k, v) in given {
// A row's own key, null too, replaces the setting.
let overridden = |row: Option<&Map<String, Value>>| row.is_some_and(|r| r.contains_key(k));
let used = rows.iter().zip(&unread_by).any(|((_, row), u)| !u.contains(k) && !overridden(*row));
let unread_row = rows.iter().zip(&unread_by).find(|(_, u)| u.contains(k));
if let (false, false, Some(((options, _), _))) = (v.is_null(), used, unread_row) {
out.push(note(format!("settings.{id}.{k}"), f, options));
}
}
}
out
}
/// Check the export settings in `p` (formats, preset, settings…) as an export would read them.
pub(crate) fn check_settings(s: &Session, p: &Value) -> Result<()> {
rows_of(s, p, ScreenParams::of(p)?.preset.as_deref()).map(|_| ())
@@ -646,9 +717,10 @@ impl Output {
pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result<Value> {
// A folder the client gave, as against the one its `exportPreset` keeps.
let given = p.get("folder").is_some();
let asked = p;
let p = with_preset(s, p)?;
let hidden = if given { None } else { str_param(&p, "folder").filter(|f| !f.is_empty() && !super::access::shown(f)) };
let mut r = export(s, &p).map_err(|e| match hidden {
let mut r = export(s, &p, asked).map_err(|e| match hidden {
Some(folder) => super::access::naming_in_error(e, folder),
None => e,
})?;
@@ -1022,9 +1094,12 @@ pub(crate) fn with_export_preset(s: &Session, cmd: &str, settings: &mut Map<Stri
/// else the bounds of all art). Items that share a name get `-2`, `-3`… instead of overwriting, and
/// so do different files that would share a name, with or without the `name` pattern. The reply's
/// `warnings` lists every warning the encoders give for the files, each once, what the files lose
/// first ([`super::note_texts`]).
pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
/// first ([`super::note_texts`]), and a note for each option of the request `asked` (`p` before a
/// preset or the remembered settings were laid under it) that the files the rows write don't read
/// ([`unused_option_notes`]).
pub(crate) fn export(s: &mut Session, p: &Value, asked: &Value) -> Result<Value> {
check_passwords(p)?;
let unused = unused_option_notes(asked, p);
let o = ScreenParams::of(p)?;
let rows = rows_of(s, p, o.preset.as_deref())?;
o.one_source()?;
@@ -1054,7 +1129,7 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
// Include Artboard Fill off: the encoders leave the background colors out.
let fill = if o.include_artboard_fill == Some(false) { json!({ "includeArtboardFill": false }) } else { json!({}) };
let rows = rows.into_iter().map(|r| Row { options: merge(with_black_output(s, &r.options), fill.clone()), ..r }).collect();
let export = ScreenExport { cmd: C, o, rows, naming, items, hidden: None };
let export = ScreenExport { cmd: C, o, rows, naming, items, unused, hidden: None };
match &mut s.screens_hand_off {
Some(slot) => {
*slot = Some(export);
@@ -1075,6 +1150,8 @@ pub struct ScreenExport {
rows: Vec<Row>,
naming: Naming,
items: Vec<Item>,
/// The notes on options the files don't read ([`unused_option_notes`]).
unused: Vec<String>,
/// The export folder and the name the request that prepared the export may see it by, when the
/// folder came from the `exportPreset` and is outside that request's read roots: the reply and
/// the errors of [`ScreenExport::write`] name it as [`export_for_screens`] does.
@@ -1139,14 +1216,17 @@ impl ScreenExport {
/// [`ScreenExport::write`].
fn encode_and_write(export: ScreenExport, writer: &mut dyn FnMut(&str, &[u8]) -> Result<()>) -> Result<Value> {
let ScreenExport { o, rows, naming, items, .. } = export;
let ScreenExport { o, rows, naming, items, unused, .. } = export;
let folder = o.folder.as_deref().filter(|f| !f.is_empty());
if let Some(dir) = folder {
create_dir(dir)?;
}
let mut out = Output::default();
// The encoders' notes for every file, each once.
// The notes on the options, then the encoders' notes for every file, each once.
let mut notes: Vec<Note> = Vec::new();
for text in unused {
add_note(&mut notes, Note::setting(text));
}
// Include Bleed: a grown artboard already holds the bleed. Assets, layers and a selection are
// written on a page of their own bounds, which it doesn't grow.
let bleed_on_page = o.include_bleed && o.assets.is_none() && o.layers.is_none() && o.selection.is_none();
+2 -2
View File
@@ -748,8 +748,8 @@ fn a_native_save_streams_the_bytes_the_encoder_makes() {
}
/// An option given beside the path (an SVG option flat or in `svg`) and in `options` with different
/// values is refused before anything is written; `compress`, `version`, `preview` or `precision` beside the path of a format
/// that doesn't read it is named in the warnings.
/// values is refused before anything is written; a native option beside the path of a format that doesn't read it is named
/// in the warnings.
#[test]
fn a_save_refuses_disagreeing_options_and_names_unread_native_ones() {
let d = tmp_dir("save-options");
@@ -280,3 +280,102 @@ fn note_texts_give_the_losses_first_each_kind_in_order() {
assert_eq!(note_texts(&[Note::setting("s1"), Note::setting("s2")]), ["s1", "s2"]);
assert!(note_texts(&[]).is_empty());
}
/// A save notes the `.ai` options its file doesn't read as an export does: a PDF-compatible file
/// doesn't read the legacy file's options or `preserveEditing`, and a legacy file reads its own
/// options only. The notes cover the options beside the path and in `options`.
#[test]
fn a_save_notes_the_ai_options_its_file_does_not_read() {
let (mut s, notes) = (with_art(), unused);
let r = s
.execute("file.saveCopy", &json!({"format": "ai", "outlineText": true, "preserveEditing": false, "options": {"hiddenObjects": "omit"}}))
.unwrap();
assert_eq!(
notes(&r),
[
"`outlineText` was not used: PDF-compatible .ai files don't read it",
"`preserveEditing` was not used: PDF-compatible .ai files don't read it",
"`options.hiddenObjects` was not used: PDF-compatible .ai files don't read it"
],
"{r}"
);
let p = json!({"format": "ai", "aiVersion": "7", "compress": true, "marks": {"trim": true}, "options": {"pdfCompatible": false}});
let r = s.execute("file.saveCopy", &p).unwrap();
assert_eq!(
notes(&r),
[
"`compress` was not used: legacy .ai (version 7) files don't read it",
"`marks` was not used: legacy .ai (version 7) files don't read it",
"`options.pdfCompatible` was not used: legacy .ai (version 7) files don't read it"
],
"{r}"
);
// The file's own options get none.
for p in [
json!({"format": "ai", "aiVersion": "7", "outlineText": true, "options": {"hiddenObjects": "omit", "range": "1"}}),
json!({"format": "ai", "compress": true, "marks": {"trim": true}, "options": {"editingData": false}}),
json!({"format": "pdf", "marks": {"trim": true}, "preserveEditing": false}),
] {
let r = s.execute("file.saveCopy", &p).unwrap();
assert!(notes(&r).is_empty(), "{p}: {r}");
}
}
/// A save notes an option beside the path that only another save format takes, as it notes one in
/// `options`.
#[test]
fn a_save_notes_an_option_beside_the_path_that_only_another_save_format_takes() {
let (mut s, notes) = (with_art(), unused);
let r = s.execute("file.saveCopy", &json!({"format": "vectorcraft", "marks": {"trim": true}, "svg": {"minify": true}})).unwrap();
assert_eq!(notes(&r), ["`marks` was not used: VectorCraft files don't read it", "`svg` was not used: VectorCraft files don't read it"], "{r}");
}
/// The `.ai` options a save's file doesn't read keep the values the document's last save in that
/// format gave them, so a save from Save Options, which sends only the options the file reads,
/// leaves the other version's choices as they were.
#[test]
fn a_save_keeps_the_ai_options_its_file_does_not_read_from_the_last_save() {
let mut s = with_art();
let dir = tmp_dir("ai-kept");
let path = dir.join("kept.ai").to_string_lossy().to_string();
let value = |s: &mut Session, key: &str| s.execute("file.formatOptions", &json!({"format": "ai"})).unwrap()["options"][key]["value"].clone();
s.execute("file.saveAs", &json!({"path": path, "format": "ai", "options": {"aiVersion": "7", "outlineText": true}})).unwrap();
s.execute("file.saveAs", &json!({"path": path, "format": "ai", "options": {"aiVersion": "pdf", "editingData": false}})).unwrap();
assert_eq!(value(&mut s, "outlineText"), json!(true));
assert_eq!(value(&mut s, "editingData"), json!(false));
s.execute("file.saveAs", &json!({"path": path, "format": "ai", "options": {"aiVersion": "7", "hiddenObjects": "omit"}})).unwrap();
assert_eq!(value(&mut s, "editingData"), json!(false));
// An option the request gives is the one saved.
s.execute("file.saveAs", &json!({"path": path, "format": "ai", "options": {"aiVersion": "7", "outlineText": false}})).unwrap();
assert_eq!(value(&mut s, "outlineText"), json!(false));
// The kept options are neither written nor sent again: a plain save's options (which a retry
// in another folder sends to Save As) hold only what the legacy file reads.
for p in [json!({}), json!({"options": {"aiVersion": "7"}})] {
let plan = save_plan(&s, SaveMode::Save, &p).unwrap();
assert!(!plan.options.contains_key("editingData") && !plan.options.contains_key("compress"), "{:?}", plan.options);
assert_eq!(plan.kept.get("editingData"), Some(&json!(false)));
assert!(plan.notes.is_empty(), "{:?}", plan.notes);
let retry = json!({"path": path, "format": "ai", "options": plan.options});
let r = s.execute("file.saveAs", &retry).unwrap();
assert!(!r["warnings"].to_string().contains("was not used"), "{r}");
assert_eq!(value(&mut s, "editingData"), json!(false));
}
std::fs::remove_dir_all(&dir).unwrap();
}
/// The first `.ai` save of a document whose own file is another format remembers only the options
/// it was given: nothing from that format's options is kept.
#[test]
fn a_first_ai_save_keeps_only_the_options_it_is_given() {
let mut s = with_art();
let dir = tmp_dir("ai-first");
let native = dir.join("first.vectorcraft").to_string_lossy().to_string();
let path = dir.join("first.ai").to_string_lossy().to_string();
s.execute("file.saveAs", &json!({"path": native, "options": {"compress": false}})).unwrap();
let plan = save_plan(&s, SaveMode::SaveAs, &json!({"path": path, "format": "ai", "options": {"aiVersion": "7"}})).unwrap();
assert!(plan.kept.is_empty(), "{:?}", plan.kept);
s.execute("file.saveAs", &json!({"path": path, "format": "ai", "options": {"aiVersion": "7"}})).unwrap();
let o = s.execute("file.formatOptions", &json!({"format": "ai"})).unwrap();
assert_eq!(o["options"]["compress"]["value"], json!(true), "{o}");
std::fs::remove_dir_all(&dir).unwrap();
}
+23 -1
View File
@@ -304,11 +304,22 @@ fn options_not_applied_yet_and_document_features_warn() {
let frame = s.execute("shape.rectangle", &json!({"x": 20, "y": 20, "width": 50, "height": 40})).unwrap()["id"].as_u64().unwrap();
s.execute("paint.setStroke", &json!({"ids": [frame], "swatch": "Dots"})).unwrap();
assert_eq!(warnings(&export(&mut s, json!({}))).len(), 0);
// A knockout group is approximated: its warning comes back from every PDF path.
// A knockout group is written as a PDF knockout group: nothing to report.
s.execute("select.set", &json!({"ids": [frame]})).unwrap();
s.execute("object.group", &json!({})).unwrap();
s.execute("transparency.set", &json!({"knockout": "on"})).unwrap();
let knockout = |w: Vec<String>| w.iter().any(|w| w.contains("knockout groups"));
assert!(!knockout(warnings(&export(&mut s, json!({})))));
// One whose soft masks would draw a brushed stroke is approximated: its warning comes back
// from every PDF path.
let rect =
|s: &mut Session, x: i32| s.execute("shape.rectangle", &json!({"x": x, "y": 45, "width": 40, "height": 30})).unwrap()["id"].as_u64().unwrap();
let (lower, upper) = (rect(&mut s, 20), rect(&mut s, 40));
s.execute("brush.apply", &json!({"ids": [upper], "name": "3 pt. Round"})).unwrap();
s.execute("object.setProps", &json!({"ids": [lower, upper], "opacity": 50, "knockoutShape": true})).unwrap();
s.execute("select.set", &json!({"ids": [lower, upper]})).unwrap();
s.execute("object.group", &json!({})).unwrap();
s.execute("transparency.set", &json!({"knockout": "on"})).unwrap();
assert!(knockout(warnings(&export(&mut s, json!({})))));
assert!(knockout(warnings(&s.execute("document.export", &as_export(&json!({}))).unwrap())));
// Other formats have none.
@@ -432,6 +443,17 @@ fn pdf_settings_summarise_changes_and_warnings() {
s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [g]})).unwrap();
assert_eq!(s.execute("document.pdfSettings", &json!({"includeDocument": true})).unwrap()["warnings"], json!([]));
s.execute("transparency.set", &json!({"knockout": "on"})).unwrap();
// A knockout group is written as a PDF knockout group: nothing to report.
assert_eq!(s.execute("document.pdfSettings", &json!({"includeDocument": true})).unwrap()["warnings"], json!([]));
// One whose soft masks would draw a brushed stroke is approximated.
let rect =
|s: &mut Session, x: i32| s.execute("shape.rectangle", &json!({"x": x, "y": 45, "width": 40, "height": 30})).unwrap()["id"].as_u64().unwrap();
let (lower, upper) = (rect(&mut s, 20), rect(&mut s, 40));
s.execute("brush.apply", &json!({"ids": [upper], "name": "3 pt. Round"})).unwrap();
s.execute("object.setProps", &json!({"ids": [lower, upper], "opacity": 50, "knockoutShape": true})).unwrap();
s.execute("select.set", &json!({"ids": [lower, upper]})).unwrap();
s.execute("object.group", &json!({})).unwrap();
s.execute("transparency.set", &json!({"knockout": "on"})).unwrap();
assert_eq!(s.execute("document.pdfSettings", &json!({})).unwrap()["warnings"], json!([]));
let full = s.execute("document.pdfSettings", &json!({"includeDocument": true})).unwrap();
assert!(warnings(&full).iter().any(|w| w.contains("knockout groups")), "{}", full["warnings"]);
@@ -1163,3 +1163,93 @@ fn single_file_pdfs_hold_every_chosen_artboard() {
assert_eq!(names(&r), ["Artboard-1.pdf", "Artboard-2.pdf"]);
assert!(s.execute("document.exportForScreens", &json!({"pdfs": "both", "formats": [{"format": "pdf"}]})).is_err());
}
/// The warnings of reply `r` that name an option the file doesn't read.
fn unused(r: &Value) -> Vec<String> {
r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).filter(|w| w.contains("was not used")).map(str::to_string).collect()
}
/// An option of a format row, or of a format's settings, that only another format reads is
/// accepted, and the reply's warnings name it as the request does, in the words of a save's note.
#[test]
fn a_row_option_only_another_format_reads_gets_a_note() {
let mut s = session(1);
let p = json!({
"formats": [{"format": "png", "quality": 80}, {"format": "jpg", "quality": 80}, {"format": "ai", "aiVersion": "7", "compress": true}],
"settings": {"png": {"quality": 50, "interlaced": true}, "svg": {"interlaced": true}},
});
let r = export(&mut s, p);
assert_eq!(
unused(&r),
[
"`formats[0].quality` was not used: PNG files don't read it",
"`formats[2].compress` was not used: legacy .ai (version 7) files don't read it",
"`settings.png.quality` was not used: PNG files don't read it",
"`settings.svg.interlaced` was not used: SVG files don't read it"
],
"{r}"
);
// A row's own keys, the keys every row takes and the artboard choices get none.
let p = json!({"formats": [
{"format": "png", "scale": "2x", "suffix": "@2x", "folder": "x", "includeArtboardFill": false, "artboard": 0, "useArtboards": true},
{"format": "pdf", "marks": {"trim": true}, "preset": "High Quality Print"},
{"format": "vectorcraft", "compress": true, "range": "1"},
]});
let r = export(&mut s, p);
assert!(unused(&r).is_empty(), "{r}");
// A format's settings are read as each row of that format writes them: a row can name another
// `.ai` version than the settings do.
let r = export(&mut s, json!({"formats": [{"format": "ai", "aiVersion": "7"}], "settings": {"ai": {"marks": {"trim": true}}}}));
assert_eq!(unused(&r), ["`settings.ai.marks` was not used: legacy .ai (version 7) files don't read it"], "{r}");
let r = export(&mut s, json!({"formats": [{"format": "ai", "aiVersion": "pdf"}], "settings": {"ai": {"aiVersion": "7", "compress": true}}}));
assert!(unused(&r).is_empty(), "{r}");
// A setting another row of the format reads gets none; one no row reads names the first row's
// file.
let rows = json!([{"format": "ai", "aiVersion": "7"}, {"format": "ai"}]);
let r = export(&mut s, json!({"formats": rows, "settings": {"ai": {"editingData": false}}}));
assert!(unused(&r).is_empty(), "{r}");
let r = export(&mut s, json!({"formats": rows, "settings": {"ai": {"quality": 80}}}));
assert_eq!(unused(&r), ["`settings.ai.quality` was not used: legacy .ai (version 7) files don't read it"], "{r}");
// A row that gives the key itself doesn't use the setting.
let rows = json!([{"format": "ai", "aiVersion": "7"}, {"format": "ai", "editingData": true}]);
let r = export(&mut s, json!({"formats": rows, "settings": {"ai": {"editingData": false}}}));
assert_eq!(unused(&r), ["`settings.ai.editingData` was not used: legacy .ai (version 7) files don't read it"], "{r}");
let rows = json!([{"format": "ai", "aiVersion": "7"}, {"format": "ai", "editingData": null}]);
let r = export(&mut s, json!({"formats": rows, "settings": {"ai": {"editingData": false}}}));
assert_eq!(unused(&r), ["`settings.ai.editingData` was not used: legacy .ai (version 7) files don't read it"], "{r}");
// An export writes one file per row and item: a native row doesn't read `separateArtboards`.
let r = export(&mut s, json!({"formats": [{"format": "vectorcraft", "separateArtboards": true}]}));
assert_eq!(unused(&r), ["`formats[0].separateArtboards` was not used: VectorCraft files don't read it"], "{r}");
}
/// The Asset Export panel's export notes the options of its request, not those of the settings
/// the document remembers.
#[test]
fn an_asset_export_notes_the_options_it_is_given() {
let mut s = session(1);
s.execute("select.all", &json!({})).unwrap();
s.execute("assets.add", &json!({"multiple": false})).unwrap();
s.execute("assets.settings.set", &json!({"formats": [{"format": "png", "quality": 80}]})).unwrap();
let r = s.execute("assets.export", &json!({})).unwrap();
assert!(unused(&r).is_empty(), "{r}");
let r = s.execute("assets.export", &json!({"formats": [{"format": "svg", "quality": 80}]})).unwrap();
assert_eq!(unused(&r), ["`formats[0].quality` was not used: SVG files don't read it"], "{r}");
// Settings given are read as the remembered rows write them.
s.execute("assets.settings.set", &json!({"formats": [{"format": "ai", "aiVersion": "7"}]})).unwrap();
let r = s.execute("assets.export", &json!({"settings": {"ai": {"editingData": false}}})).unwrap();
assert_eq!(unused(&r), ["`settings.ai.editingData` was not used: legacy .ai (version 7) files don't read it"], "{r}");
s.execute("assets.settings.set", &json!({"formats": [{"format": "png", "quality": 80}]})).unwrap();
// So does the export a client prepares.
let p = json!({"formats": [{"format": "svg", "quality": 80}]});
let r = s.prepare_screen_export("assets.export", &p).unwrap().write(&mut |_: &str, _: &[u8]| Ok(())).unwrap();
assert_eq!(unused(&r), ["`formats[0].quality` was not used: SVG files don't read it"], "{r}");
}
/// A preset's options add no notes: Export for Screens notes the options of its request.
#[test]
fn an_export_preset_adds_no_notes() {
let mut s = session(1);
s.execute("document.exportPresets.save", &json!({"name": "Mixed", "settings": {"formats": [{"format": "png", "quality": 80}]}})).unwrap();
let r = export(&mut s, json!({"exportPreset": "Mixed"}));
assert!(unused(&r).is_empty(), "{r}");
}
+1 -1
View File
@@ -33,7 +33,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Gradient Vector",
[],
None,
"{start?: [x,y], end?: [x,y] (document coordinates; both or neither: omitted, the vector stays), aspect?: % (radial, conic and diamond: the extent's height / width across the vector, a negative one mirroring a conic's sweep and an error for the other kinds; default: kept), focal?: [x,y] (document coordinates) | null (radial: the focal point, where the first stop sits, pulled inside the extent ellipse; null centres it; default: it keeps its place in the ellipse; an error on a conic or diamond gradient), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (alias: index; omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\", index?: appearance item index (as item)} set the gradient vector, aspect ratio and focal point (solid paints become the default gradient; type objects set it on their runs, in text space). On a path directly in a graph series, the graph also stores the gradient with its placement when it is on the topmost visible fill or stroke (docs/mcp.md); on a graph label's row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind, with the gradient fitted to each label",
"{start?: [x,y], end?: [x,y] (document coordinates; both or neither: omitted, the vector stays), aspect?: % (radial, conic and diamond: the extent's height / width across the vector, a negative one mirroring a conic's sweep and an error for the other kinds; default: kept), focal?: [x,y] (document coordinates) | null (radial: the focal point, where the first stop sits, pulled inside the extent ellipse; null centres it; default: it keeps its place in the ellipse; an error on a conic or diamond gradient), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (alias: index; omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\", index?: appearance item index (as item)} set the gradient vector, aspect ratio and focal point (solid paints become the default gradient; type objects set it on their runs, in text space). On a path directly in a graph series, the graph also stores the gradient with its placement relative to the path's box when it is on the topmost visible fill or stroke, and every path of the series takes it at the same place on its own box (docs/mcp.md); on a graph label's row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind, with the gradient fitted to each label",
has_doc,
set_gradient_geom
),
+2 -2
View File
@@ -6,7 +6,7 @@ use super::labels::{
recolour_text, runs, shadow_of,
};
use super::markers::{MARKER_BUDGET, marker_cost, marker_designs};
use super::series::{series_marks, with_series_opacity};
use super::series::{series_marks, with_series_gradients, with_series_opacity};
use super::{
Arc, BezPath, Color, Document, GraphKind, GraphSpec, Justify, Node, NodeKind, Paint, PathData, Point, Rect, TickLength, ValueAxisSide, shapes,
};
@@ -1006,7 +1006,7 @@ impl<'a> Plot<'a> {
}
}
let name = g.series.get(s).cloned().filter(|n| !n.is_empty()).unwrap_or_else(|| format!("Series {}", s + 1));
let grp = self.b.series(&name, s, with_series_opacity(g, s, items));
let grp = self.b.series(&name, s, with_series_opacity(g, s, with_series_gradients(g, s, items)));
self.b.out.push(grp);
}
self.b.out.append(&mut self.on_top);
+133 -7
View File
@@ -2,7 +2,7 @@ use vectorcraft_color::BlendMode;
use super::axis::radar_axis;
use super::labels::{Role, filled_lines_on, label_para, label_style};
use super::{Arc, CharStyle, Color, Document, GraphKind, GraphSpec, HashMap, Node, NodeId, NodeKind, Paint, Result, Session, Value, bad};
use super::{Arc, CharStyle, Color, Document, GraphKind, GraphSpec, HashMap, Node, NodeId, NodeKind, Paint, Rect, Result, Session, Value, bad};
// ---------- generation ----------
@@ -229,7 +229,8 @@ pub(crate) fn capture_series_top_paints(doc: &mut Document, edited: &[(NodeId, b
continue;
}
let (paint, width) = shown_paint(node, *fill);
captures.push((r.graph, r.index, *fill, paint, (!*fill).then_some(width)));
let width = (!*fill).then_some(width);
captures.push((r.graph, r.index, *fill, paint, width, paint_box(node, width)));
}
store_series_paints(doc, captures)
}
@@ -281,11 +282,129 @@ fn settle_series_paints(doc: &mut Document, before: SeriesPaintsBefore) -> bool
changed
}
/// Store each capture (graph, series index, fill or stroke, paint, stroke weight) in its series. Returns whether a
/// The box a series keeps a gradient relative to: (0, 0)–(1, 1), as graphic styles keep theirs.
const UNIT_BOX: Rect = vectorcraft_doc::GraphicStyle::UNIT_BOX;
/// The gradient `slot` keeps for its fill (`fill`) or stroke as the graph draws it on box `on`: one kept relative to the
/// unit box placed on `on`, also when `on` has no width or no height, and one kept in document coordinates as it is kept.
/// None when the slot keeps no gradient, or keeps one relative to the unit box and there is no box.
fn kept_on(slot: &vectorcraft_doc::SeriesPaint, fill: bool, on: Option<Rect>) -> Option<Paint> {
let (kept, unit) = if fill { (&slot.fill, slot.fill_unit_box) } else { (&slot.stroke, slot.stroke_unit_box) };
let Some(Paint::Gradient(g)) = kept else { return None };
if !unit {
return Some(Paint::Gradient(g.clone()));
}
let on = on?;
let mut g = g.clone();
g.rebase(UNIT_BOX, on);
Some(Paint::Gradient(g))
}
/// Whether `a` and `b` are the same paint, a gradient's vector, focal point and freeform points each within 1e-6 pt (the
/// rounding a move or a placement on another box leaves).
fn same_paint_placed(a: &Paint, b: &Paint) -> bool {
let (Paint::Gradient(a), Paint::Gradient(b)) = (a, b) else { return a == b };
let near = |p: vectorcraft_geom::Point, q: vectorcraft_geom::Point| (p - q).hypot() <= 1e-6;
let mut a = (**a).clone();
match (&mut a.geom, &b.geom) {
(Some(ag), Some(bg)) => {
let focal = match (ag.focal, bg.focal) {
(Some(p), Some(q)) => near(p, q),
(p, q) => p.is_none() && q.is_none(),
};
if !(near(ag.start, bg.start) && near(ag.end, bg.end) && focal) {
return false;
}
(ag.start, ag.end, ag.focal) = (bg.start, bg.end, bg.focal);
}
(None, None) => {}
_ => return false,
}
match (&mut a.freeform, &b.freeform) {
(Some(af), Some(bf)) => {
if af.points.len() != bf.points.len() || !af.points.iter().zip(&bf.points).all(|(p, q)| near(p.at, q.at)) {
return false;
}
for (p, q) in af.points.iter_mut().zip(&bf.points) {
p.at = q.at;
}
}
(None, None) => {}
_ => return false,
}
a == **b
}
/// The box a gradient on `node`'s fill (`width` None) or on its stroke of `width` is placed on: the geometric bounds, grown
/// by half the weight for a stroke. The box can have no width or no height.
fn paint_box(node: &Node, width: Option<f64>) -> Option<Rect> {
let b = node.geometric_bounds()?;
Some(width.map_or(b, |w| vectorcraft_doc::appearance::stroke_paint_bounds(b, w)))
}
/// `paint` as a series keeps it: a gradient placed on `from` (the edited mark's [`paint_box`]) is moved to the same place
/// relative to [`UNIT_BOX`], so each mark takes it on its own box. A box with no width or no height, which a gradient's
/// place cannot be kept relative to, leaves the paint in document coordinates. Returns the paint and whether it was moved.
fn unit_box_paint(paint: Paint, from: Option<Rect>) -> (Paint, bool) {
match (paint, from.filter(|b| b.width() > 1e-9 && b.height() > 1e-9)) {
(Paint::Gradient(mut g), Some(from)) => {
g.rebase(from, UNIT_BOX);
(Paint::Gradient(g), true)
}
(paint, _) => (paint, false),
}
}
/// The paths directly inside series `i`'s group (`items`) with the series' kept gradients, which are kept relative to the
/// unit box, placed on each path's own box: a fill on its geometric bounds, a stroke on its stroke's box. Paints kept in
/// document coordinates (from files saved before the unit box) are left as they are.
pub(super) fn with_series_gradients(g: &GraphSpec, i: usize, mut items: Vec<Arc<Node>>) -> Vec<Arc<Node>> {
let Some(slot) = g.series_paints.get(i) else { return items };
fn kept(p: &Option<Paint>, unit: bool) -> Option<&Paint> {
p.as_ref().filter(|p| unit && matches!(p, Paint::Gradient(_)))
}
let (fill, stroke) = (kept(&slot.fill, slot.fill_unit_box), kept(&slot.stroke, slot.stroke_unit_box));
if fill.is_none() && stroke.is_none() {
return items;
}
// A Filled Lines band takes the kept stroke as its fill when no fill is kept.
let band_fill = if slot.fill.is_none() { stroke } else { None };
let fill_from_store = |p: &Paint| fill == Some(p) || band_fill == Some(p);
let stroke_from_store = |p: &Paint| stroke == Some(p);
for item in &mut items {
if !matches!(item.kind, NodeKind::Path { .. }) {
continue;
}
let Some(b) = item.geometric_bounds() else { continue };
let ap = &item.appearance;
if !ap.fill().is_some_and(|f| fill_from_store(&f.paint)) && !ap.stroke().is_some_and(|st| stroke_from_store(&st.paint)) {
continue;
}
let ap = &mut Arc::make_mut(item).appearance;
if let Some(f) = ap.fill_mut().filter(|f| fill_from_store(&f.paint))
&& let Paint::Gradient(gp) = &mut f.paint
{
gp.rebase(UNIT_BOX, b);
}
if let Some(st) = ap.stroke_mut().filter(|st| stroke_from_store(&st.paint)) {
let to = st.paint_bounds(b);
if let Paint::Gradient(gp) = &mut st.paint {
gp.rebase(UNIT_BOX, to);
}
}
}
items
}
/// A paint taken from a series mark: graph, series index, fill or stroke, paint, stroke weight and the mark's
/// [`paint_box`].
type Capture = (NodeId, usize, bool, Paint, Option<f64>, Option<Rect>);
/// Store each capture in its series; a gradient is kept relative to the unit box ([`unit_box_paint`]). Returns whether a
/// series' captured fill or stroke differs from what the graph stored before the first capture.
fn store_series_paints(doc: &mut Document, captures: Vec<(NodeId, usize, bool, Paint, Option<f64>)>) -> bool {
fn store_series_paints(doc: &mut Document, captures: Vec<Capture>) -> bool {
let mut before = SeriesPaintsBefore::new();
for (graph, index, fill, paint, width) in captures {
for (graph, index, fill, paint, width, from) in captures {
let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue };
before.entry(graph).or_insert_with(|| spec.series_paints.clone());
// `index` is below the graph's series count (`series_members`).
@@ -293,10 +412,17 @@ fn store_series_paints(doc: &mut Document, captures: Vec<(NodeId, usize, bool, P
spec.series_paints.resize(index + 1, vectorcraft_doc::SeriesPaint::default());
}
let Some(slot) = spec.series_paints.get_mut(index) else { continue };
// A mark that shows the kept gradient as it was drawn leaves the kept gradient as it is.
let drawn = kept_on(slot, fill, from).is_some_and(|kept| same_paint_placed(&kept, &paint));
let (paint, unit) = unit_box_paint(paint, from);
if fill {
slot.fill = Some(paint);
if !drawn {
(slot.fill, slot.fill_unit_box) = (Some(paint), unit);
}
} else {
slot.stroke = Some(paint);
if !drawn {
(slot.stroke, slot.stroke_unit_box) = (Some(paint), unit);
}
slot.stroke_width = width;
}
}
+273 -12
View File
@@ -2754,10 +2754,39 @@ fn a_gradient_edit_on_a_series_mark_is_kept() {
series_0_is(&mut s, id, (&fill, 0.4, &red, 2.0), &was);
}
/// A gradient placed on a series mark's fill with `paint.setGradientGeom` is stored on the graph as it is placed, and
/// every mark of the series has that fill, placement included, after the graph is drawn again.
/// Where gradient `paint` sits on box `b`: its vector's start and end, then each freeform point, as fractions of the box.
fn gradient_place(paint: &Paint, b: vectorcraft_geom::Rect) -> Vec<f64> {
let Paint::Gradient(g) = paint else { panic!("not a gradient: {paint:?}") };
let points = g.geom.iter().flat_map(|geom| [geom.start, geom.end]).chain(g.freeform.iter().flat_map(|f| f.points.iter().map(|p| p.at)));
points.flat_map(|p| [(p.x - b.x0) / b.width(), (p.y - b.y0) / b.height()]).collect()
}
/// The box a gradient on `mark`'s fill (`stroke` false) or stroke is placed on.
fn paint_box_of(mark: &vectorcraft_doc::Node, stroke: bool) -> vectorcraft_geom::Rect {
let b = mark.geometric_bounds().unwrap();
if stroke { vectorcraft_doc::appearance::stroke_paint_bounds(b, mark.appearance.stroke_width()) } else { b }
}
/// The fill (`stroke` false) or stroke of every path of series 0, the legend swatch included, has a gradient at `place` on
/// its own box, and the gradients are not all at one place in the document. Returns how many paths were checked.
fn series_0_gradients_at(s: &Session, id: NodeId, stroke: bool, place: &[f64]) -> usize {
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let paths: Vec<_> = series(&n, 0).children().unwrap().iter().filter(|m| matches!(m.kind, NodeKind::Path { .. })).cloned().collect();
let paints: Vec<Paint> = paths.iter().map(|m| if stroke { m.appearance.stroke_paint() } else { m.appearance.fill_paint() }).collect();
for (m, paint) in paths.iter().zip(&paints) {
let at = gradient_place(paint, paint_box_of(m, stroke));
assert_eq!(at.len(), place.len(), "{at:?}");
assert!(at.iter().zip(place).all(|(a, b)| (a - b).abs() < 1e-9), "{at:?} is not {place:?}");
}
assert!(paints.windows(2).any(|w| w[0] != w[1]), "every mark has the gradient at one place: {paints:?}");
paths.len()
}
/// A gradient placed on a series mark's fill with `paint.setGradientGeom` is stored relative to the mark's box, and every
/// mark of the series has it at the same place on its own box after the graph is drawn again, also after a data change
/// moves and resizes the marks and after the graph is moved.
#[test]
fn a_gradient_placed_on_a_series_mark_is_kept_with_its_placement() {
fn a_gradient_placed_on_a_series_mark_takes_the_same_place_on_every_mark() {
let (mut s, id, _, marks) = column_marks();
// `paint.setFill` stores the gradient before it is placed.
s.execute("paint.setFill", &json!({"ids": [marks[0].0], "gradient": {"kind": "linear"}})).unwrap();
@@ -2766,26 +2795,258 @@ fn a_gradient_placed_on_a_series_mark_is_kept_with_its_placement() {
s.execute("paint.setGradientGeom", &json!({"ids": [marks[0].0], "start": [0, 0], "end": [10, 40]})).unwrap();
let placed = fill_of(&s, marks[0]);
assert_ne!(placed, fill, "the command places the gradient");
assert_eq!(spec(&s, id).series_paints[0].fill, Some(placed.clone()));
s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap();
assert!(marks_of(&s, id, 0).iter().all(|m| m.0 == placed), "{:?}", marks_of(&s, id, 0));
let mark = s.doc().unwrap().doc.node(marks[0]).unwrap().clone();
let place = gradient_place(&placed, paint_box_of(&mark, false));
let kept = spec(&s, id).series_paints[0].clone();
assert!(kept.fill_unit_box);
assert_eq!(gradient_place(kept.fill.as_ref().unwrap(), vectorcraft_doc::GraphicStyle::UNIT_BOX), place);
s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4], [6, 1]]})).unwrap();
assert!(series_0_gradients_at(&s, id, false, &place) >= 4);
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("object.move", &json!({"dx": 50, "dy": 30})).unwrap();
s.execute("graph.setData", &json!({"id": id.0, "rows": [[2, 2], [5, 4], [1, 1]]})).unwrap();
series_0_gradients_at(&s, id, false, &place);
}
/// A point recolored with `paint.freeform.setPoint` in a freeform gradient on a series mark's fill is stored on the graph.
/// A gradient placed on a series mark's stroke is kept relative to the mark's stroke box, and every mark of the series has
/// it at the same place on its own stroke box after the graph is drawn again.
#[test]
fn a_gradient_placed_on_a_series_marks_stroke_takes_the_same_place_on_every_mark() {
let (mut s, id, _, marks) = column_marks();
s.execute("paint.setStroke", &json!({"ids": [marks[1].0], "gradient": {"kind": "radial"}})).unwrap();
s.execute("stroke.set", &json!({"ids": [marks[1].0], "weight": 4})).unwrap();
s.execute("paint.setGradientGeom", &json!({"ids": [marks[1].0], "stroke": true, "start": [150, 200], "end": [170, 260]})).unwrap();
let mark = s.doc().unwrap().doc.node(marks[1]).unwrap().clone();
let place = gradient_place(&mark.appearance.stroke_paint(), paint_box_of(&mark, true));
assert!(spec(&s, id).series_paints[0].stroke_unit_box);
s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4], [6, 1]]})).unwrap();
series_0_gradients_at(&s, id, true, &place);
}
/// A placed gradient picked up with the Eyedropper from art outside the graph is kept relative to the series mark it is
/// dropped on, and every mark of the series has it at the same place on its own box.
#[test]
fn a_placed_gradient_dropped_on_a_series_mark_takes_the_same_place_on_every_mark() {
let (mut s, id, _, marks) = column_marks();
let r = NodeId(s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 40, "height": 20})).unwrap()["id"].as_u64().unwrap());
s.execute("paint.setFill", &json!({"ids": [r.0], "gradient": {"kind": "linear"}})).unwrap();
s.execute("paint.setGradientGeom", &json!({"ids": [r.0], "start": [610, 600], "end": [630, 620]})).unwrap();
s.execute("appearance.copyFrom", &json!({"source": r.0, "ids": [marks[2].0]})).unwrap();
let mark = s.doc().unwrap().doc.node(marks[2]).unwrap().clone();
let place = gradient_place(&mark.appearance.fill_paint(), paint_box_of(&mark, false));
assert!(spec(&s, id).series_paints[0].fill_unit_box);
s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4], [6, 1]]})).unwrap();
series_0_gradients_at(&s, id, false, &place);
}
/// A graphic style with a placed gradient applied to a series mark is kept relative to that mark's box, and every mark of
/// the series has the gradient at the same place on its own box.
#[test]
fn a_graphic_style_gradient_applied_to_a_series_mark_takes_the_same_place_on_every_mark() {
let (mut s, id, _, marks) = column_marks();
let r = NodeId(s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 40, "height": 20})).unwrap()["id"].as_u64().unwrap());
s.execute("paint.setFill", &json!({"ids": [r.0], "gradient": {"kind": "linear"}})).unwrap();
s.execute("paint.setGradientGeom", &json!({"ids": [r.0], "start": [610, 600], "end": [630, 620]})).unwrap();
s.execute("graphicStyle.new", &json!({"id": r.0, "name": "Placed"})).unwrap();
s.execute("graphicStyle.apply", &json!({"name": "Placed", "ids": [marks[2].0]})).unwrap();
let mark = s.doc().unwrap().doc.node(marks[2]).unwrap().clone();
let place = gradient_place(&mark.appearance.fill_paint(), paint_box_of(&mark, false));
assert!(spec(&s, id).series_paints[0].fill_unit_box);
s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4], [6, 1]]})).unwrap();
series_0_gradients_at(&s, id, false, &place);
}
/// A point recolored with `paint.freeform.setPoint` in a freeform gradient on a series mark's fill is stored on the graph,
/// and every mark of the series has the gradient's points at the same place on its own box after the graph is drawn again.
#[test]
fn a_freeform_gradient_edit_on_a_series_mark_is_kept() {
let (mut s, id, _, marks) = column_marks();
let points = json!([{"at": [20, 20], "color": "#53aadf"}, {"at": [50, 50], "color": "#ff8000"}]);
// `paint.setFill` stores the gradient before its point is recolored.
s.execute("paint.setFill", &json!({"ids": [marks[0].0], "gradient": {"kind": "freeform", "freeform": {"points": points}}})).unwrap();
let fill = fill_of(&s, marks[0]);
assert_eq!(spec(&s, id).series_paints[0].fill, Some(fill.clone()));
s.execute("paint.freeform.setPoint", &json!({"ids": [marks[0].0], "index": 1, "color": "#00ff00"})).unwrap();
let edited = fill_of(&s, marks[0]);
assert_ne!(edited, fill, "the command recolors the point");
assert_eq!(spec(&s, id).series_paints[0].fill, Some(edited.clone()));
let Paint::Gradient(g) = &edited else { panic!("{edited:?}") };
assert_eq!(g.freeform.as_ref().unwrap().points[1].color.to_hex(), "#00ff00");
let mark = s.doc().unwrap().doc.node(marks[0]).unwrap().clone();
let place = gradient_place(&edited, paint_box_of(&mark, false));
let kept = spec(&s, id).series_paints[0].clone();
assert!(kept.fill_unit_box);
assert_eq!(gradient_place(kept.fill.as_ref().unwrap(), vectorcraft_doc::GraphicStyle::UNIT_BOX), place);
s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap();
assert!(marks_of(&s, id, 0).iter().all(|m| m.0 == edited), "{:?}", marks_of(&s, id, 0));
series_0_gradients_at(&s, id, false, &place);
}
/// A gradient a graph kept in document coordinates (a file saved before gradients were kept relative to the unit box) is
/// drawn on every mark as it was kept.
#[test]
fn a_gradient_kept_in_document_coordinates_is_drawn_as_kept() {
let (mut s, id, _, marks) = column_marks();
s.execute("paint.setFill", &json!({"ids": [marks[0].0], "gradient": {"kind": "linear"}})).unwrap();
s.execute("paint.setGradientGeom", &json!({"ids": [marks[0].0], "start": [0, 0], "end": [10, 40]})).unwrap();
let placed = fill_of(&s, marks[0]);
s.edit("Kept in document coordinates", |d, _| {
let mut extras = d.node_mut(id).unwrap().extras_mut();
let slot = &mut extras.graph.as_mut().unwrap().series_paints[0];
(slot.fill, slot.fill_unit_box) = (Some(placed.clone()), false);
Ok(())
})
.unwrap();
s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap();
assert!(marks_of(&s, id, 0).iter().all(|m| m.0 == placed), "{:?}", marks_of(&s, id, 0));
// The same gradient kept for the fill relative to the unit box leaves the stroke kept in document coordinates as it is.
s.edit("Kept fill and stroke", |d, _| {
let mut extras = d.node_mut(id).unwrap().extras_mut();
let slot = &mut extras.graph.as_mut().unwrap().series_paints[0];
(slot.fill, slot.fill_unit_box) = (Some(placed.clone()), true);
(slot.stroke, slot.stroke_unit_box, slot.stroke_width) = (Some(placed.clone()), false, Some(2.0));
Ok(())
})
.unwrap();
s.execute("graph.setData", &json!({"id": id.0, "rows": [[2, 2], [3, 4]]})).unwrap();
let drawn = marks_of(&s, id, 0);
assert!(drawn.iter().all(|m| m.1 == placed), "{drawn:?}");
series_0_gradients_at(&s, id, false, &gradient_place(&placed, vectorcraft_doc::GraphicStyle::UNIT_BOX));
}
/// A gradient placed on a series path with no height (a flat line's fill) is kept in document coordinates, and every
/// path of the series that takes the kept fill has it at that place in the document.
#[test]
fn a_gradient_placed_on_a_series_path_with_no_height_is_kept_in_document_coordinates() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "line");
s.execute("graph.setData", &json!({"id": id.0, "rows": [[3, 2], [3, 4], [3, 6]]})).unwrap();
let flat = |s: &Session| {
let parts = series_parts(s, id, 0);
parts.into_iter().find(|m| m.appearance.fill_paint().is_none() && m.geometric_bounds().is_some_and(|b| b.height() < 1e-9)).unwrap().id
};
let line = flat(&s);
s.execute("paint.setFill", &json!({"ids": [line.0], "gradient": {"kind": "linear"}})).unwrap();
s.execute("paint.setGradientGeom", &json!({"ids": [line.0], "start": [150, 120], "end": [300, 280]})).unwrap();
let placed = fill_of(&s, line);
let kept = spec(&s, id).series_paints[0].clone();
assert!(!kept.fill_unit_box);
assert_eq!(kept.fill, Some(placed.clone()));
s.execute("graph.setData", &json!({"id": id.0, "rows": [[3, 1], [3, 5], [3, 2]]})).unwrap();
let fills: Vec<Paint> = series_parts(&s, id, 0).iter().map(|m| m.appearance.fill_paint()).filter(|p| matches!(p, Paint::Gradient(_))).collect();
assert!(fills.len() >= 2 && fills.iter().all(|p| *p == placed), "{fills:?}");
}
/// Writing a series mark's stroke weight as it is, after the graph drew the mark's stroke from a gradient the series
/// keeps relative to the unit box, leaves the kept gradient, the document and its undo history as they are, on every mark.
#[test]
fn rewriting_a_series_marks_drawn_gradient_stroke_records_no_undo_step() {
let (mut s, id, _, marks) = column_marks();
s.execute("paint.setStroke", &json!({"ids": [marks[1].0], "gradient": {"kind": "radial"}})).unwrap();
s.execute("stroke.set", &json!({"ids": [marks[1].0], "weight": 4})).unwrap();
s.execute("paint.setGradientGeom", &json!({"ids": [marks[1].0], "stroke": true, "start": [150, 200], "end": [170, 260]})).unwrap();
s.execute("graph.setData", &json!({"id": id.0, "rows": [[1.3, 2], [3.7, 4], [6.1, 1]]})).unwrap();
let was = s.doc().unwrap().doc.clone();
let undo = s.doc().unwrap().history.undo.len();
let drawn: Vec<_> = series_parts(&s, id, 0).into_iter().filter(|m| matches!(m.appearance.stroke_paint(), Paint::Gradient(_))).collect();
assert!(drawn.len() >= 4, "{}", drawn.len());
for mark in drawn {
let row = top_row(&s, mark.id, false);
s.execute("appearance.setItem", &json!({"ids": [mark.id.0], "index": row, "weight": mark.appearance.stroke_width()})).unwrap();
assert!(s.doc().unwrap().doc == was, "writing mark {:?} changed the document", mark.id);
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
}
// A mark moved with its gradient shows the kept gradient on its moved box, up to rounding.
let kept = spec(&s, id).series_paints[0].clone();
for (i, mark) in series_parts(&s, id, 0).into_iter().filter(|m| matches!(m.appearance.stroke_paint(), Paint::Gradient(_))).enumerate() {
s.execute("select.set", &json!({"ids": [mark.id.0]})).unwrap();
s.execute("object.move", &json!({"dx": 13.37 + i as f64 * 0.731, "dy": -7.19})).unwrap();
let moved = s.doc().unwrap().doc.node(mark.id).unwrap().geometric_bounds().unwrap();
assert!((moved.x0 - mark.geometric_bounds().unwrap().x0 - 13.37 - i as f64 * 0.731).abs() < 1e-9, "mark {:?} did not move", mark.id);
let undo = s.doc().unwrap().history.undo.len();
let row = top_row(&s, mark.id, false);
s.execute("appearance.setItem", &json!({"ids": [mark.id.0], "index": row, "weight": mark.appearance.stroke_width()})).unwrap();
assert_eq!(spec(&s, id).series_paints[0], kept, "writing moved mark {:?} changed the kept stroke", mark.id);
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
}
}
/// Writing the fill of a series mark with no height (a column for a value of 0) as it is, after the graph drew the mark's
/// fill from a gradient the series keeps relative to the unit box, leaves the kept gradient and its unit-box flag as they
/// are.
#[test]
fn rewriting_a_drawn_gradient_fill_on_a_series_mark_with_no_height_keeps_the_kept_gradient() {
let (mut s, id, _, marks) = column_marks();
s.execute("paint.setFill", &json!({"ids": [marks[0].0], "gradient": {"kind": "linear"}})).unwrap();
s.execute("paint.setGradientGeom", &json!({"ids": [marks[0].0], "start": [0, 0], "end": [10, 40]})).unwrap();
let kept = spec(&s, id).series_paints[0].clone();
assert!(kept.fill_unit_box);
s.execute("graph.setData", &json!({"id": id.0, "rows": [[0, 2], [3, 4], [6, 1]]})).unwrap();
let flat = series_parts(&s, id, 0)
.into_iter()
.find(|m| matches!(m.appearance.fill_paint(), Paint::Gradient(_)) && m.geometric_bounds().is_some_and(|b| b.height() < 1e-9))
.unwrap()
.id;
let changed = s.edit("Write the fill as it is", |d, _| Ok(super::capture_series_top_paints(d, &[(flat, true)]))).unwrap();
assert!(!changed);
assert_eq!(spec(&s, id).series_paints[0], kept);
}
/// Writing a series mark's stroke weight as it is, after the graph drew the mark's stroke from a gradient the series keeps
/// in document coordinates (a file saved before the unit box), leaves the kept gradient and its unit-box flag as they are
/// and records no undo step.
#[test]
fn rewriting_a_series_marks_stroke_drawn_from_a_gradient_kept_in_document_coordinates_keeps_it() {
let (mut s, id, _, marks) = column_marks();
s.execute("paint.setStroke", &json!({"ids": [marks[1].0], "gradient": {"kind": "radial"}})).unwrap();
s.execute("stroke.set", &json!({"ids": [marks[1].0], "weight": 2})).unwrap();
s.execute("paint.setGradientGeom", &json!({"ids": [marks[1].0], "stroke": true, "start": [150, 200], "end": [170, 260]})).unwrap();
let placed = s.doc().unwrap().doc.node(marks[1]).unwrap().appearance.stroke_paint();
s.edit("Kept in document coordinates", |d, _| {
let mut extras = d.node_mut(id).unwrap().extras_mut();
let slot = &mut extras.graph.as_mut().unwrap().series_paints[0];
(slot.stroke, slot.stroke_unit_box, slot.stroke_width) = (Some(placed.clone()), false, Some(2.0));
Ok(())
})
.unwrap();
s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4], [6, 1]]})).unwrap();
let kept = spec(&s, id).series_paints[0].clone();
let undo = s.doc().unwrap().history.undo.len();
let drawn: Vec<_> = series_parts(&s, id, 0).into_iter().filter(|m| m.appearance.stroke_paint() == placed).collect();
assert!(drawn.len() >= 3, "{}", drawn.len());
for mark in drawn {
let row = top_row(&s, mark.id, false);
s.execute("appearance.setItem", &json!({"ids": [mark.id.0], "index": row, "weight": mark.appearance.stroke_width()})).unwrap();
assert_eq!(spec(&s, id).series_paints[0], kept, "writing mark {:?} changed the kept stroke", mark.id);
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
}
}
/// A gradient placed on a line series' stroke is kept relative to the line's stroke box, and with Draw Filled Lines the
/// band, which takes the kept stroke as its fill, has it at the same place on its own box.
#[test]
fn a_gradient_kept_for_a_lines_stroke_takes_the_same_place_on_its_filled_line() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "line");
let line = series_parts(&s, id, 0).into_iter().find(|m| m.appearance.fill_paint().is_none()).unwrap().id;
s.execute("paint.setStroke", &json!({"ids": [line.0], "gradient": {"kind": "linear"}})).unwrap();
s.execute("paint.setGradientGeom", &json!({"ids": [line.0], "stroke": true, "start": [150, 120], "end": [300, 280]})).unwrap();
let kept = spec(&s, id).series_paints[0].clone();
assert!(kept.stroke_unit_box && kept.fill.is_none());
let place = gradient_place(kept.stroke.as_ref().unwrap(), vectorcraft_doc::GraphicStyle::UNIT_BOX);
s.execute("graph.setType", &json!({"id": id.0, "filledLines": true})).unwrap();
let band = series_parts(&s, id, 0).into_iter().find(|m| matches!(m.appearance.fill_paint(), Paint::Gradient(_))).unwrap();
let at = gradient_place(&band.appearance.fill_paint(), band.geometric_bounds().unwrap());
assert!(at.iter().zip(&place).all(|(a, b)| (a - b).abs() < 1e-9), "{at:?} is not {place:?}");
}
/// A series' kept unit-box flags are read from and written to a file, and a file without them reads as document
/// coordinates.
#[test]
fn a_series_paints_unit_box_flags_round_trip() {
let kept = vectorcraft_doc::SeriesPaint { fill_unit_box: true, stroke_unit_box: true, ..Default::default() };
let text = serde_json::to_string(&kept).unwrap();
assert_eq!(text, r#"{"fillUnitBox":true,"strokeUnitBox":true}"#);
assert_eq!(serde_json::from_str::<vectorcraft_doc::SeriesPaint>(&text).unwrap(), kept);
let old: vectorcraft_doc::SeriesPaint = serde_json::from_str("{}").unwrap();
assert!(!old.fill_unit_box && !old.stroke_unit_box);
}
/// A series mark's fill recolored with Invert or Complement (the proxy, or one appearance row), an Edit Colors command,
+3 -3
View File
@@ -21,7 +21,7 @@ use vectorcraft_color::{Color, GradientPaint, Paint};
use vectorcraft_doc::{Appearance, Node, NodeId, TextStyleDef};
use super::clipboard::SwatchChoices;
use super::{CommandSpec, EngineError, Result, Session, always, bad, cmd, color_value, has_doc, ids_param, index, ok, point_param, str_param};
use super::{CommandSpec, EngineError, Result, Session, always, bad, cmd, color_value, has_doc, ids_param, index_opt, ok, point_param, str_param};
use crate::Clipboard;
/// The extension of a library file.
@@ -1649,11 +1649,11 @@ fn move_group(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "library.moveGroup";
let id = library_id(s, p, C)?;
let group = group_param(p, C)?;
let to = p.get("to").and_then(Value::as_u64).ok_or_else(|| bad(C, "missing `to` (an index)"))?;
let to = index_opt(p, "to", C)?.ok_or_else(|| bad(C, "missing `to` (an index)"))?;
let (name, at) = s.libraries.change(&id, |lib| {
let i = group_at(lib, group, C)?;
let g = lib.groups.remove(i);
let at = index(to).min(lib.groups.len());
let at = to.min(lib.groups.len());
let name = g.name.clone();
lib.groups.insert(at, g);
Ok((name, at))
+1 -1
View File
@@ -88,7 +88,7 @@ pub fn specs() -> Vec<CommandSpec> {
fn artboard_reorder(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "artboard.reorder";
let i = artboard_index(&s.doc()?.doc, p, C)?;
let to = p.get("to").and_then(Value::as_u64).map(index).ok_or_else(|| bad(C, "missing to"))?;
let to = super::index_opt(p, "to", C)?.ok_or_else(|| bad(C, "missing to"))?;
s.edit("Reorder Artboards", |d, _| {
if i >= d.artboards.len() {
return Err(EngineError::Other("no such artboard".into()));
+7 -7
View File
@@ -12,7 +12,7 @@ use super::draw2::usize_opt;
use super::pathops::Beside;
use super::{
CommandSpec, Result, Session, bad, check_numbers, check_switches, cmd, f64_or, f64_req, finite_numbers, has_anchors, has_doc, has_selected_path,
has_selection, ids_param, index, number_or, object_param, ok, str_param, switch_or, targets,
has_selection, ids_param, index_opt, number_or, object_param, ok, str_param, switch_or, targets,
};
use crate::EngineError;
@@ -170,7 +170,7 @@ pub(super) fn path_mut(d: &mut vectorcraft_doc::Document, id: NodeId) -> Result<
fn append_anchor(s: &mut Session, p: &Value) -> Result<Value> {
let id = object_param(p, "id", "path.appendAnchor")?.ok_or_else(|| bad("path.appendAnchor", "missing id"))?;
let a = anchor_from_json(p, "path.appendAnchor")?;
let subpath = p.get("subpath").and_then(Value::as_u64).map(|u| u as usize);
let subpath = index_opt(p, "subpath", "path.appendAnchor")?;
s.edit("Pen", |d, sel| {
let path = path_mut(d, id)?;
let si = match subpath {
@@ -208,7 +208,7 @@ fn append_anchor(s: &mut Session, p: &Value) -> Result<Value> {
/// `at` first moves the anchor, its handles with it (the Pen's closing drag with Space held).
fn close(s: &mut Session, p: &Value) -> Result<Value> {
let id = object_param(p, "id", "path.close")?.ok_or_else(|| bad("path.close", "missing id"))?;
let subpath = p.get("subpath").and_then(Value::as_u64).map(|u| u as usize);
let subpath = index_opt(p, "subpath", "path.close")?;
let handle = |key: &str| match p.get(key).filter(|h| !h.is_null()) {
None => Ok(None),
Some(h) => finite_numbers(h).map(|[x, y]| Some(Point::new(x, y))).ok_or_else(|| bad("path.close", format!("`{key}` must be [x, y]"))),
@@ -310,7 +310,7 @@ pub(crate) fn transform_anchors(s: &mut Session, label: &str, xf: vectorcraft_ge
fn set_handle(s: &mut Session, p: &Value) -> Result<Value> {
let id = object_param(p, "id", "path.setHandle")?.ok_or_else(|| bad("path.setHandle", "missing id"))?;
let si = usize_opt(p, "subpath", "path.setHandle")?.unwrap_or(0);
let ai = p.get("anchor").and_then(Value::as_u64).map(index).ok_or_else(|| bad("path.setHandle", "missing anchor"))?;
let ai = index_opt(p, "anchor", "path.setHandle")?.ok_or_else(|| bad("path.setHandle", "missing anchor"))?;
let out = str_param(p, "which") != Some("in");
let pos = Point::new(f64_req(p, "x", "path.setHandle")?, f64_req(p, "y", "path.setHandle")?);
let independent = switch_or(p, "independent", false, "path.setHandle")?;
@@ -337,7 +337,7 @@ fn set_handle(s: &mut Session, p: &Value) -> Result<Value> {
fn move_anchor(s: &mut Session, p: &Value) -> Result<Value> {
let id = object_param(p, "id", "path.moveAnchor")?.ok_or_else(|| bad("path.moveAnchor", "missing id"))?;
let si = usize_opt(p, "subpath", "path.moveAnchor")?.unwrap_or(0);
let ai = p.get("anchor").and_then(Value::as_u64).map(index).ok_or_else(|| bad("path.moveAnchor", "missing anchor"))?;
let ai = index_opt(p, "anchor", "path.moveAnchor")?.ok_or_else(|| bad("path.moveAnchor", "missing anchor"))?;
let pos = Point::new(f64_req(p, "x", "path.moveAnchor")?, f64_req(p, "y", "path.moveAnchor")?);
// An anchor already at (x, y) changes nothing and records no undo step.
if let Some(a) = anchor_of(s, id, si, ai)?
@@ -453,7 +453,7 @@ pub(super) fn paths_in(d: &vectorcraft_doc::Document, ids: &[NodeId]) -> Vec<Nod
fn reverse(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.reverse";
let want = p.get("reversed").map(|v| v.as_bool().ok_or_else(|| bad(C, "`reversed` must be true or false"))).transpose()?;
let subpath = p.get("subpath").and_then(Value::as_u64).map(|u| u as usize);
let subpath = index_opt(p, "subpath", C)?;
let ids = paths_in(&s.doc()?.doc, &targets(s, p)?);
if ids.is_empty() {
return Err(bad(C, "select paths"));
@@ -1784,7 +1784,7 @@ fn reshape(s: &mut Session, p: &Value) -> Result<Value> {
fn insert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
let id = object_param(p, "id", "path.insertAnchor")?.ok_or_else(|| bad("path.insertAnchor", "missing id"))?;
let si = usize_opt(p, "subpath", "path.insertAnchor")?.unwrap_or(0);
let seg = p.get("segment").and_then(Value::as_u64).map(index).ok_or_else(|| bad("path.insertAnchor", "missing segment"))?;
let seg = index_opt(p, "segment", "path.insertAnchor")?.ok_or_else(|| bad("path.insertAnchor", "missing segment"))?;
let t = number_or(p, "t", 0.5, "path.insertAnchor")?.clamp(0.0, 1.0);
let idx = s.edit("Add Anchor Point", |d, sel| {
let path = path_mut(d, id)?;
+1 -1
View File
@@ -1724,7 +1724,7 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
let mut out = json!({ "changed": changed, "swatches": added });
not_changed.report(&mut out);
if let Some(e) = edit {
e.defaults(s);
e.defaults(s)?;
out["relinked"] = json!(e.relinked());
}
if converted {
+1
View File
@@ -1176,6 +1176,7 @@ pub fn jobs(s: &mut Session, store: &Arc<dyn RecoveryStore>) -> Result<(Vec<Reco
modified: None,
notes: Vec::new(),
lossy_source: None,
kept: serde_json::Map::new(),
};
let save = fileio::job_for(s, st, plan)?;
let meta = Meta {
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -21,7 +21,7 @@ use vectorcraft_color::Paint;
use vectorcraft_doc::{Appearance, DEFAULT_GRAPHIC_STYLE, Document, GraphicStyle, Node, NodeId, NodeKind, Shared};
use vectorcraft_geom::Rect;
use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, has_doc, index, object_param, ok, str_param, unique_name};
use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, has_doc, index_opt, object_param, ok, str_param, unique_name};
use crate::EngineError;
pub fn specs() -> Vec<CommandSpec> {
@@ -585,10 +585,10 @@ fn style_merge(s: &mut Session, p: &Value) -> Result<Value> {
fn style_move(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "graphicStyle.move";
let name = str_param(p, "name").ok_or_else(|| bad(cmd, "missing name"))?;
let to = p.get("to").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "missing `to` (an index)"))?;
let to = index_opt(p, "to", cmd)?.ok_or_else(|| bad(cmd, "missing `to` (an index)"))?;
let d = &s.doc()?.doc;
let from = style_index(d, name)?;
let to = index(to).min(d.graphic_styles.len() - 1);
let to = to.min(d.graphic_styles.len() - 1);
if from == to {
return ok();
}
+40 -16
View File
@@ -322,7 +322,7 @@ fn swatch_delete(s: &mut Session, p: &Value) -> Result<Value> {
super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(C, e))?;
Ok(unlinked)
})?;
map_default_paints(s, &mut unlink);
map_default_paints(s, &mut unlink)?;
Ok(json!({"deleted": names, "unlinked": unlinked}))
}
@@ -660,20 +660,41 @@ impl Relink {
renamed || recolored
}
fn defaults(&self, s: &mut Session) {
map_default_paints(s, &mut |c, l, t| self.apply(c, l, t));
fn defaults(&self, s: &mut Session) -> Result<()> {
map_default_paints(s, &mut |c, l, t| self.apply(c, l, t))
}
}
/// Apply a swatch-link rewrite to the default fill and stroke for new art, except during a live
/// preview (Cancel rolls the document back, not the session).
pub(super) fn map_default_paints(s: &mut Session, f: &mut LinkFn) {
if s.in_interaction() {
return;
/// Apply a swatch-link rewrite to the fill and stroke new art takes and to the paints of its
/// template appearance, except during a live preview (Cancel rolls the document back, not the
/// session). A `command.batch` step rewrites them as the command run on its own does: a failed
/// batch, and a canceled batch preview, put back the paints the batch started with. A change is
/// recorded with the step just made, so Undo and Redo put these paints back with the document.
pub(super) fn map_default_paints(s: &mut Session, f: &mut LinkFn) -> Result<()> {
if s.in_interaction() && s.batch_stash.is_none() {
return Ok(());
}
for p in [&mut s.paint.fill, &mut s.paint.stroke] {
p.map_links(f);
let start = super::colormgmt::new_art_colors_before(s)?;
let pd = &mut s.paint;
let template = pd.appearance.iter_mut().flat_map(|a| a.items.iter_mut()).map(|it| match it {
vectorcraft_doc::AppearanceItem::Fill(f) => &mut f.paint,
vectorcraft_doc::AppearanceItem::Stroke(s) => &mut s.paint,
});
let mut changed = false;
for p in [&mut pd.fill, &mut pd.stroke].into_iter().chain(template) {
changed |= p.map_links(f);
}
if changed {
super::colormgmt::record_new_art_colors(s, start)?;
// The step takes the paints the command finally leaves, after the selection's appearance
// is inherited.
let st = s.doc()?;
if st.interaction.is_none() {
let uid = st.uid;
s.batch_paint_docs.push(uid);
}
}
Ok(())
}
/// Apply a swatch-link rewrite to the document ([`Document::map_solid_paints`]) and to the art of
@@ -888,7 +909,7 @@ fn swatch_edit(s: &mut Session, p: &Value) -> Result<Value> {
super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(C, e))?;
Ok(relinked)
})?;
relink.defaults(s);
relink.defaults(s)?;
Ok(json!({"name": to, "relinked": relinked}))
}
@@ -1084,7 +1105,7 @@ fn swatch_merge(s: &mut Session, p: &Value) -> Result<Value> {
super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(C, e))?;
Ok(relinked)
})?;
map_default_paints(s, &mut relink);
map_default_paints(s, &mut relink)?;
Ok(json!({"name": keep, "merged": merged, "relinked": relinked}))
}
@@ -1141,7 +1162,7 @@ fn swatch_spot_options(s: &mut Session, p: &Value) -> Result<Value> {
let relinked = map_swatch_links(d, &mut follow_bases(&bases));
Ok((relinked, bases))
})?;
map_default_paints(s, &mut follow_bases(&bases));
map_default_paints(s, &mut follow_bases(&bases))?;
Ok(json!({"useLab": use_lab, "relinked": relinked}))
}
@@ -1165,8 +1186,11 @@ pub(super) struct GroupEdit {
impl GroupEdit {
/// Apply the link changes to the default fill and stroke for new art.
pub(super) fn defaults(&self, s: &mut Session) {
self.relinks.iter().for_each(|r| r.defaults(s));
pub(super) fn defaults(&self, s: &mut Session) -> Result<()> {
if self.relinks.is_empty() {
return Ok(());
}
map_default_paints(s, &mut |c, l, t| self.relinks.iter().any(|r| r.apply(c, l, t)))
}
/// How many paints linked to the group's swatches the rewrite changed.
@@ -1242,6 +1266,6 @@ fn swatch_edit_group(s: &mut Session, p: &Value) -> Result<Value> {
colors.into_iter().enumerate().map(|(i, c)| if old.get(i) == Some(&Some(c)) { c } else { super::paint::new_color(s, p, c) }).collect();
let rename = str_param(p, "rename");
let e = s.edit("Edit Color Group", |d, _| rewrite_group(d, group, &colors, rename, C))?;
e.defaults(s);
e.defaults(s)?;
Ok(json!({"name": e.name, "swatches": e.swatches, "relinked": e.relinked}))
}
+1 -1
View File
@@ -980,7 +980,7 @@ fn reset_defaults(s: &mut Session, p: &Value) -> Result<Value> {
Ok(added)
})?;
if replace {
map_default_paints(s, &mut |_, l, _| unlink_dead(&live, l));
map_default_paints(s, &mut |_, l, _| unlink_dead(&live, l))?;
}
Ok(json!({ "added": added }))
}
+3 -3
View File
@@ -12,8 +12,8 @@ use vectorcraft_geom::{Affine, PathData, Vec2};
use super::edit::selected_roots;
use super::pathops::{length_given, number_given, shape_node};
use super::{
CommandSpec, EngineError, Result, Session, bad, check_numbers, cmd, color_value, f64_req, has_doc, has_selection, index, number_or, object_param,
ok, str_param, switch_or, targets, text_param,
CommandSpec, EngineError, Result, Session, bad, check_numbers, cmd, color_value, f64_req, has_doc, has_selection, index, index_opt, number_or,
object_param, ok, str_param, switch_or, targets, text_param,
};
pub fn specs() -> Vec<CommandSpec> {
@@ -1376,7 +1376,7 @@ fn set_path_handle(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.setPathHandle";
let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?;
let si = super::draw2::usize_opt(p, "subpath", C)?.unwrap_or(0);
let ai = p.get("anchor").and_then(Value::as_u64).map(index).ok_or_else(|| bad(C, "missing anchor"))?;
let ai = index_opt(p, "anchor", C)?.ok_or_else(|| bad(C, "missing anchor"))?;
let out = super::object::choice(p, C, "which", &["in", "out"])? != Some("in");
let pos = vectorcraft_geom::Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?);
let independent = switch_or(p, "independent", false, C)?;
+3 -4
View File
@@ -1688,10 +1688,9 @@ fn warichu_is_a_character_attribute_set_saved_and_exported() {
assert!(["warichu", "warichuLines", "warichuGap", "warichuScale", "warichuAlign"].iter().all(|k| saved.get(*k).is_none()), "{saved}");
let plain_svg = vectorcraft_svg::export(&s.doc().unwrap().doc, &Default::default());
let plain_pdf = vectorcraft_pdf::export(&s.doc().unwrap().doc, &vectorcraft_pdf::PdfOptions::uncompressed()).unwrap();
let text_ops = |pdf: &[u8]| {
let s = String::from_utf8_lossy(pdf);
s.contains("Tj") || s.contains("TJ")
};
// Only text objects count: the file ID in the trailer and the XMP is base64 that changes each
// second and now and then spells `Tj` or `TJ`.
let text_ops = |pdf: &[u8]| String::from_utf8_lossy(pdf).lines().any(|l| l.starts_with("BT ") && (l.contains("Tj") || l.contains("TJ")));
assert!(plain_svg.contains("<text") && text_ops(&plain_pdf));
// text.setFormat with the values the characters already have changes nothing and records no
+11 -1
View File
@@ -138,7 +138,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Paste without Formatting",
["Edit"],
Some("Cmd+Alt+V"),
"{center?, dx?, dy?, swatchConflict?} paste as edit.paste; pasted text takes the default character style (one run) and leaves its character and paragraph styles behind → {ids, added, merged, renamed}",
"{center?, dx?, dy?, swatchConflict?} paste as edit.paste; pasted text takes the default character style (one run) and leaves its character and paragraph styles behind, as do the labels of pasted graphs → {ids, added, merged, renamed}",
has_clipboard,
paste_plain
),
@@ -1460,6 +1460,16 @@ fn strip_formatting(n: &mut vectorcraft_doc::Node) {
t.set_all_paras(Default::default());
refresh_bounds(t);
}
// A graph keeps the names of its labels' character and paragraph styles for each redraw.
if let Some(g) = n.extras_if_any().and_then(|e| e.graph.as_deref_mut()) {
let l = &mut g.label_styles;
for st in [&mut l.values, &mut l.categories, &mut l.legend].into_iter().flatten() {
st.style_name = None;
}
for pa in [&mut l.values_para, &mut l.categories_para, &mut l.legend_para].into_iter().flatten() {
pa.style_name = None;
}
}
if let Some(ch) = n.children_mut() {
for c in ch.iter_mut() {
strip_formatting(std::sync::Arc::make_mut(c));
+5 -4
View File
@@ -369,12 +369,13 @@ impl DocState {
/// menu asks on every frame it is open, and measuring builds the solids of 3D effects, so the
/// answer is kept for this version of the document and those artboards, and a change of the
/// selection or the view keeps it. It is measured again when what the measure reads outside the
/// document changes: the installed plug-ins, whose effects draw art, and the installed fonts,
/// which set the bounds of all type.
/// document changes: the installed plug-ins, whose effects draw art, and the installed fonts and
/// the hyphenation exceptions, which set the bounds of all type.
pub fn any_art_to_fit(&self, indices: &[usize]) -> bool {
self.any_art_to_fit_with(indices, (vectorcraft_plugins::registry::revision(), vectorcraft_text::FontDb::global().generation()))
self.any_art_to_fit_with(indices, (vectorcraft_plugins::registry::revision(), vectorcraft_text::layout_inputs()))
}
/// [`Self::any_art_to_fit`], with `outside` the generations of the plug-ins and the fonts.
/// [`Self::any_art_to_fit`], with `outside` the generations of the plug-ins and the layout inputs
/// ([`vectorcraft_text::layout_inputs`]).
pub(crate) fn any_art_to_fit_with(&self, indices: &[usize], outside: (u64, u64)) -> bool {
// Measuring runs renderer and plug-in code; should it ever ask again while measuring, it
// is measured without being kept rather than borrowed twice.
+2 -1
View File
@@ -26,7 +26,8 @@
//! Before a run starts, its preprocessor lines are read ([`include`]): included files take the
//! place of their lines, and an error in one is reported at its own file, line and column.
//! `$.evalFile(path)` runs another file in the same global environment and gives its last value,
//! `$.fileName` is the file being run and `$.includePath` the `#includepath` list.
//! `$.fileName` is the file of the code that reads it (the file being run for code that `eval` or
//! `Function` compiled) and `$.includePath` the `#includepath` list.
//!
//! A run writes to the Script Console with `$.write` and `$.writeln`, and `vc.log` adds a line
//! there too. The session's console gets a start line, the run's output, its uncaught error, an end
+4 -2
View File
@@ -25,9 +25,11 @@ impl Session {
// Clicks find an image's own fills and strokes where their effects draw them.
vectorcraft_doc::hit::set_image_paint(vectorcraft_render::effects::image_paint);
// Clicks find a blend's steps and an envelope's distorted art where the canvas draws them,
// evaluated again when the fonts or the plug-ins change.
// evaluated again when the fonts, the hyphenation exceptions or the plug-ins change.
vectorcraft_doc::hit::set_live_art(vectorcraft_render::effects::expand_live, || {
vectorcraft_text::FontDb::global().generation().rotate_left(32) ^ vectorcraft_render::effects::plugin_revision()
let mut h = std::hash::DefaultHasher::new();
std::hash::Hash::hash(&(vectorcraft_text::layout_inputs(), vectorcraft_render::effects::plugin_revision()), &mut h);
std::hash::Hasher::finish(&h)
});
// Envelopes map type from the bounds of its glyph outlines, underlines and strikethroughs.
vectorcraft_text::register_envelope_ink();
+119
View File
@@ -560,6 +560,125 @@ fn reset_bounding_box_then_release_intertwine() {
reset_then_release("object.intertwine.make", "object.intertwine.release");
}
/// A symbol of two rectangles, the first turned `inner` degrees before the symbol is made, placed
/// as an instance that is then turned 20°.
fn turned_instance(inner: f64) -> (Session, NodeId) {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 100.0, 50.0);
let b = rect(&mut s, 150.0, 220.0, 100.0, 50.0);
if inner != 0.0 {
sel(&mut s, &[a]);
s.execute("object.rotate", &json!({"angle": inner})).unwrap();
}
sel(&mut s, &[a, b]);
let inst = id_of(&s.execute("symbol.new", &json!({"name": "Pair"})).unwrap());
sel(&mut s, &[inst]);
s.execute("object.rotate", &json!({"angle": 20})).unwrap();
assert!(near(angle(&s, inst), 20.0));
(s, inst)
}
/// Break Link and Expand with the parameters that turn instance `inst` into its art.
fn expand_instance(s: &mut Session, command: &str, inst: NodeId) {
sel(s, &[inst]);
let params = if command == "symbol.breakLink" { json!({"ids": [inst.0]}) } else { json!({"object": true, "fill": false, "stroke": false}) };
s.execute(command, &params).unwrap();
assert!(s.doc().unwrap().doc.node(inst).is_none_or(|n| !matches!(n.kind, vectorcraft_doc::NodeKind::SymbolInstance { .. })), "{command}");
}
/// The angles of the objects inside the document's only top-level object, that object included.
fn art_angles(s: &Session) -> Vec<f64> {
let d = &s.doc().unwrap().doc;
let layer = d.layers.first().unwrap();
let top = layer.children().unwrap();
assert_eq!(top.len(), 1);
let mut out = vec![];
subtree_angles(&top[0], &mut out);
out
}
#[test]
fn reset_bounding_box_then_expand_symbol_instance() {
for command in ["symbol.breakLink", "object.expand"] {
let (mut s, inst) = turned_instance(0.0);
assert_eq!(s.execute("object.resetBoundingBox", &json!({})).unwrap()["changed"], 1);
assert_eq!(angle(&s, inst), 0.0);
expand_instance(&mut s, command, inst);
let angles = art_angles(&s);
assert!(angles.len() >= 2 && angles.iter().all(|a| near(*a, 0.0)), "{command}: {angles:?}");
s.execute("select.all", &json!({})).unwrap();
assert_eq!(selection_box(&s).angle, 0.0, "{command}");
}
}
#[test]
fn expanding_a_turned_symbol_instance_keeps_its_angle() {
for command in ["symbol.breakLink", "object.expand"] {
let (mut s, inst) = turned_instance(0.0);
expand_instance(&mut s, command, inst);
let angles = art_angles(&s);
assert!(angles.len() >= 2 && angles.iter().all(|a| near(*a, 20.0)), "{command}: {angles:?}");
}
}
#[test]
fn reset_bounding_box_then_expand_keeps_the_angles_inside_the_symbol() {
for command in ["symbol.breakLink", "object.expand"] {
let (mut s, inst) = turned_instance(30.0);
s.execute("object.resetBoundingBox", &json!({})).unwrap();
expand_instance(&mut s, command, inst);
// The turned rectangle keeps the 30° it had in the symbol; the rest stand square.
let mut angles = art_angles(&s);
angles.sort_by(f64::total_cmp);
assert!(near(*angles.last().unwrap(), 30.0) && angles[..angles.len() - 1].iter().all(|a| near(*a, 0.0)), "{command}: {angles:?}");
}
}
/// The group that an instance's opacity adds around the art of a one-object symbol stands as the
/// instance's box does, turned or reset.
#[test]
fn expanding_a_faded_symbol_instance_gives_its_group_the_instance_box() {
for command in ["symbol.breakLink", "object.expand"] {
for reset in [false, true] {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 100.0, 50.0);
sel(&mut s, &[a]);
let inst = id_of(&s.execute("symbol.new", &json!({"name": "One"})).unwrap());
sel(&mut s, &[inst]);
s.execute("object.rotate", &json!({"angle": 20})).unwrap();
s.execute("object.setProps", &json!({"ids": [inst.0], "opacity": 50})).unwrap();
if reset {
s.execute("object.resetBoundingBox", &json!({})).unwrap();
}
expand_instance(&mut s, command, inst);
let want = if reset { 0.0 } else { 20.0 };
let angles = art_angles(&s);
assert!(angles.len() == 2 && angles.iter().all(|a| near(*a, want)), "{command}, reset {reset}: {angles:?}");
}
}
}
/// A mirrored instance: turned 170° and then reflected, its box stands 180° from the box its
/// transform gives in one step. The art takes the instance's box, and stands square after a reset.
#[test]
fn expanding_a_reflected_symbol_instance_keeps_its_box() {
for command in ["symbol.breakLink", "object.expand"] {
for reset in [false, true] {
let (mut s, inst) = turned_instance(0.0);
s.execute("object.rotate", &json!({"angle": 150})).unwrap();
s.execute("object.reflect", &json!({"axis": "horizontal"})).unwrap();
assert!(near(angle(&s, inst), -170.0), "{}", angle(&s, inst));
if reset {
s.execute("object.resetBoundingBox", &json!({})).unwrap();
}
let want = angle(&s, inst);
expand_instance(&mut s, command, inst);
let angles = art_angles(&s);
assert!(angles.len() >= 2 && angles.iter().all(|a| near(*a, want)), "{command}, reset {reset}: {angles:?}");
}
}
}
#[test]
fn reset_bounding_box_then_release_linked_opacity_mask() {
let mut s = session();
+66
View File
@@ -468,6 +468,72 @@ fn styles_symbols_and_images_of_the_same_name_are_renamed() {
assert_eq!(key(&s, img2), src_key);
}
/// A graph whose series fill, value labels and legend rows paint with global swatch "Brand" and
/// whose value labels are set in character style "Emphasis" and paragraph style "Body", as a
/// later redraw would give them to its marks and labels.
fn styled_graph(s: &mut Session) -> NodeId {
s.execute("swatch.new", &json!({"name": "Brand", "color": "#ff0000", "global": true})).unwrap();
s.execute("charStyle.new", &json!({"name": "Emphasis", "attrs": {"size": 24.0}})).unwrap();
s.execute("paraStyle.new", &json!({"name": "Body", "attrs": {"space_after": 8.0}})).unwrap();
let v = s.execute("graph.create", &json!({"type": "column", "x": 200, "y": 200, "width": 60, "height": 40, "csv": ",2024\nQ1,3\nQ2,5"}));
let id = id_of(&v.unwrap());
let brand = linked(hex("#ff0000"), "Brand", 1.0);
let mut extras = doc_mut(s).node_mut(id).unwrap().extras_mut();
let g = extras.graph.as_mut().unwrap();
g.series_paints = vec![vectorcraft_doc::SeriesPaint { fill: Some(brand.clone()), ..Default::default() }];
let l = &mut g.label_styles;
l.values = Some(vectorcraft_doc::CharStyle { fill: brand.clone(), style_name: Some("Emphasis".into()), ..Default::default() });
l.values_para = Some(vectorcraft_doc::ParaStyle { style_name: Some("Body".into()), ..Default::default() });
let rows = vectorcraft_doc::SeriesPaint { stroke: Some(brand), ..Default::default() };
l.legend_rows = Some(vectorcraft_doc::graph::LabelRows { paint: rows, ..Default::default() });
id
}
/// The swatch names a graph's stored paints link to, and its value labels' character and
/// paragraph style names.
fn graph_links(s: &Session, id: NodeId) -> (Vec<String>, Option<String>, Option<String>) {
let n = node(s, id);
let swatches = vectorcraft_doc::swatches::graph_paints(&n).filter_map(|p| match p {
Paint::Solid { swatch: Some(w), .. } => Some(w.clone()),
_ => None,
});
let l = &n.extras().graph.as_deref().unwrap().label_styles;
let values = l.values.as_ref().and_then(|st| st.style_name.clone());
(swatches.collect(), values, l.values_para.as_ref().and_then(|pa| pa.style_name.clone()))
}
/// A pasted graph brings the swatches and text styles its series and labels keep, and follows
/// them when they come in under new names.
#[test]
fn a_pasted_graph_brings_and_follows_its_label_styles_and_paints() {
let mut s = session();
let g = styled_graph(&mut s);
copy(&mut s, &[g]);
new_doc(&mut s);
let v = s.execute("edit.pasteInPlace", &json!({})).unwrap();
let d = doc(&s);
assert!(d.swatch("Brand").is_some(), "{v}");
assert!(d.char_styles.iter().any(|x| x.name == "Emphasis") && d.para_styles.iter().any(|x| x.name == "Body"), "{v}");
let brand = vec!["Brand".to_string(); 3];
assert_eq!(graph_links(&s, ids_of(&v)[0]), (brand.clone(), Some("Emphasis".into()), Some("Body".into())));
// Into a document that gives each name something else: they come in renamed.
new_doc(&mut s);
s.execute("swatch.new", &json!({"name": "Brand", "color": "#0000ff", "global": true})).unwrap();
s.execute("charStyle.new", &json!({"name": "Emphasis", "attrs": {"size": 8.0}})).unwrap();
s.execute("paraStyle.new", &json!({"name": "Body", "attrs": {"space_after": 2.0}})).unwrap();
let v = s.execute("edit.pasteInPlace", &json!({"swatchConflict": "add"})).unwrap();
let brand2 = vec!["Brand 2".to_string(); 3];
assert_eq!(graph_links(&s, ids_of(&v)[0]), (brand2, Some("Emphasis 2".into()), Some("Body 2".into())), "{v}");
// Without formatting: the labels' text styles stay behind.
new_doc(&mut s);
let v = s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap();
assert!(doc(&s).char_styles.is_empty() && doc(&s).para_styles.is_empty(), "{v}");
assert_eq!(graph_links(&s, ids_of(&v)[0]), (brand, None, None));
}
#[test]
fn pasting_again_reuses_what_an_earlier_paste_added() {
let mut s = session();
+19
View File
@@ -184,6 +184,25 @@ fn pencil_alt_closes_path() {
assert!(ps[0].1.is_closed());
}
/// A Pencil stroke drawn as a dome and closed with Alt at the release closes with a straight segment
/// across the bottom, from where the stroke ends to where it starts: the path stays within where the
/// pen went, rather than curving on past both ends and back like a hat's brim.
#[test]
fn pencil_alt_closes_a_stroke_straight_back_to_its_start() {
let mut s = session();
let pts: Vec<(f64, f64)> =
(0..=60).map(|i| f64::from(i) * std::f64::consts::PI / 60.0).map(|a| (300.0 - 150.0 * a.cos(), 400.0 - 200.0 * a.sin())).collect();
drag(&mut s, "pencil", &pts, Mods { alt: true, ..Default::default() });
let ps = paths(&s);
let sp = &ps[0].1.subpaths[0];
assert!(sp.closed);
let b = ps[0].1.bounds().unwrap();
assert!(b.x0 > 149.5 && b.x1 < 450.5 && b.y1 < 400.5, "within the stroke: {b:?}");
let (first, last) = (sp.anchors.first().unwrap(), sp.anchors.last().unwrap());
assert!(first.p.distance(Point::new(150.0, 400.0)) < 1e-6 && last.p.distance(Point::new(450.0, 400.0)) < 1e-6, "{first:?} {last:?}");
assert_eq!((last.h_out, first.h_in), (last.p, first.p), "a straight closing segment");
}
/// #3799: with Shift held, a wavy Pencil drag draws one straight segment at 45° steps from the
/// Constrain Angle (here 30°: a drag at about 60° comes out at 75°).
#[test]
+116
View File
@@ -422,6 +422,122 @@ fn a_compound_clipping_path_in_the_art_keeps_its_shape() {
assert_eq!(seen, (true, true));
}
/// A Drop Shadow 20 pt to the right on row `row` of instance `id`, solid black with no blur.
fn shadow_row(s: &mut Session, id: NodeId, row: usize) {
let params = json!({"x": 20, "y": 0, "blur": 0, "opacity": 100, "mode": "normal"});
run(s, "effect.apply", json!({"ids": [id.0], "item": row, "effect": "stylize.dropShadow", "params": params}));
}
/// Is `p` dark: a shadow, not the white page or the red fill?
fn dark(p: [u8; 4]) -> bool {
p[..3].iter().all(|c| *c < 100)
}
/// A raster effect on a symbol instance's fill draws on the filled shapes of its art: the shadow
/// falls right of the square, the bounds take it in, a hidden fill draws none, and Expand
/// Appearance keeps the look in one undo step.
#[test]
fn a_symbol_instance_fill_shadow_shadows_its_filled_art() {
let mut s = session();
let (id, row) = instance_with_row(&mut s, "Square", true);
assert!(near(render(&s).pixel(160, 100), WHITE));
shadow_row(&mut s, id, row);
let before = render(&s);
assert!(dark(before.pixel(165, 100)), "the fill's shadow falls right of the square: {:?}", before.pixel(165, 100));
assert!(near(before.pixel(100, 100), RED), "the art draws over its shadow: {:?}", before.pixel(100, 100));
let doc = s.doc().unwrap().doc.clone();
let b = vectorcraft_render::painted_bounds(&doc, doc.node(id).unwrap()).expect("the instance has bounds");
assert!(b.x1 >= 169.0, "the bounds take in the shadow: {b:?}");
// A view that shows only the shadow, right of the square: the instance is still drawn.
let opts = vectorcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let right = vectorcraft_render::Renderer::new().render(&doc, 200, 200, vectorcraft_geom::Affine::translate((-155.0, 0.0)), &opts);
assert!(dark(right.pixel(10, 100)), "the shadow past the square's box is drawn: {:?}", right.pixel(10, 100));
assert!(expandable(&s), "the fill's shadow can be expanded");
run(&mut s, "effect.expandAppearance", json!({}));
let ch = node(&s, id).children().cloned().expect("the instance becomes a group");
assert!(matches!(ch[0].kind, NodeKind::Image(_)), "the shadow is an image under the art: {}", ch[0].kind_label());
assert!(matches!(ch[1].kind, NodeKind::SymbolInstance { .. }), "the art stays an instance: {}", ch[1].kind_label());
no_effects_left(&ch[1]);
let after = render(&s);
assert!(differing(&before, &after, 24) < 400, "expanding changed {} pixels", differing(&before, &after, 24));
run(&mut s, "edit.undo", json!({}));
let n = node(&s, id);
assert!(matches!(n.kind, NodeKind::SymbolInstance { .. }), "one undo gives the instance back");
assert!(n.appearance.items[row].effects().iter().any(|e| e.id == "stylize.dropShadow"));
run(&mut s, "appearance.setItem", json!({"ids": [id.0], "index": row, "visible": false}));
assert!(near(render(&s).pixel(165, 100), WHITE), "a hidden fill draws no shadow");
}
/// A raster effect on a symbol instance's stroke draws on the stroked shapes of its art only: the
/// stroke's shadow falls right of the stroke, and none falls where only the fill would cast one.
#[test]
fn a_symbol_instance_stroke_shadow_shadows_only_its_stroked_art() {
let mut s = session();
let (id, row) = instance_with_row(&mut s, "Square", false);
shadow_row(&mut s, id, row);
let r = render(&s);
assert!(dark(r.pixel(170, 100)), "the stroke's shadow falls right of the stroke: {:?}", r.pixel(170, 100));
assert!(near(r.pixel(160, 100), WHITE), "the fill casts no shadow: {:?}", r.pixel(160, 100));
}
/// Geometry and raster effects on one fill of an instance: the shadow falls from the grown fill.
#[test]
fn a_symbol_instance_fill_shadow_follows_the_fill_offset_before_it() {
let mut s = session();
let (id, row) = instance_with_row(&mut s, "Square", true);
offset_row(&mut s, id, row, 10.0);
shadow_row(&mut s, id, row);
let r = render(&s);
assert!(near(r.pixel(44, 100), RED), "the fill grows by the offset: {:?}", r.pixel(44, 100));
assert!(dark(r.pixel(175, 100)), "the shadow falls from the grown fill: {:?}", r.pixel(175, 100));
}
/// An instance inside a symbol's art passes the outer instance's fill shadow on to its own art.
#[test]
fn a_nested_symbol_instance_takes_the_outer_fill_shadow() {
let mut s = session();
square(&mut s);
run(&mut s, "symbol.new", json!({"name": "Inner"}));
let id = NodeId(run(&mut s, "symbol.new", json!({"name": "Outer"}))["id"].as_u64().unwrap());
run(&mut s, "appearance.addFill", json!({"ids": [id.0]}));
let row = node(&s, id).appearance.items.len() - 1;
run(&mut s, "appearance.setItem", json!({"ids": [id.0], "index": row, "none": true}));
shadow_row(&mut s, id, row);
assert!(dark(render(&s).pixel(165, 100)), "the inner art casts the shadow: {:?}", render(&s).pixel(165, 100));
}
/// Export draws a symbol instance's fill shadow on the filled shapes of its art: baking leaves the
/// shadow on the art's fill, not its stroke, and PDF export makes an image of it.
#[test]
fn export_draws_a_symbol_instance_fill_shadow() {
let mut s = session();
let (id, row) = instance_with_row(&mut s, "Square", true);
shadow_row(&mut s, id, row);
let doc = s.doc().unwrap().doc.clone();
let baked = vectorcraft_render::effects::bake_document(&doc).expect("the instance is baked");
let n = baked.node(id).expect("the baked art keeps the instance's id").clone();
assert!(!matches!(n.kind, NodeKind::SymbolInstance { .. }));
let shadowed = |i: &vectorcraft_doc::AppearanceItem| i.effects().iter().any(|e| e.id == "stylize.dropShadow");
let (mut fills, mut strokes) = (0, 0);
n.walk(&mut |part| {
fills += part.appearance.items.iter().filter(|i| i.is_fill() && shadowed(i)).count();
strokes += part.appearance.items.iter().filter(|i| !i.is_fill() && shadowed(i)).count();
});
assert_eq!((fills, strokes), (1, 0), "the art's fill carries the shadow");
let flat = crate::flatten_raster_effects(&doc).expect("the shadow becomes an image");
let mut image = None;
flat.node(id).expect("the flattened art keeps the instance's id").walk(&mut |part| {
if matches!(part.kind, NodeKind::Image(_)) {
image = part.geometric_bounds();
}
});
let image = image.expect("the shadow is an image");
assert!(image.x1 >= 165.0, "the image holds the shadow right of the square: {image:?}");
}
#[test]
fn two_fills_and_an_offset_stroke_become_three_paths_that_look_the_same() {
let mut s = session();
+7 -6
View File
@@ -138,7 +138,7 @@ fn knockout_settings_round_trip_and_old_bools_load() {
}
#[test]
fn pdf_and_svg_write_knockout_groups_as_masks() {
fn pdf_writes_knockout_groups_and_svg_writes_them_as_masks() {
let mut s = session();
let g = knockout_group(&mut s);
let plain = s.doc().unwrap().doc.clone();
@@ -149,10 +149,11 @@ fn pdf_and_svg_write_knockout_groups_as_masks() {
let r = pdf(&plain);
assert!(!String::from_utf8_lossy(&r.bytes).contains("/SMask") && r.warnings.is_empty());
let r = pdf(&ko);
let text = String::from_utf8_lossy(&r.bytes);
// The lower rectangle is drawn through a luminosity mask made of the upper one's alpha.
assert!(text.contains("/Luminosity") && text.contains("/Alpha"), "soft masks");
assert!(r.warnings.iter().any(|w| w.contains("knockout")), "{:?}", r.warnings);
let text = String::from_utf8_lossy(&r.bytes).replace(' ', "");
// The rectangles knock out the area they paint: a PDF knockout group, without soft masks.
assert_eq!(text.matches("/Ktrue").count(), 1, "a knockout group");
assert!(!text.contains("/Luminosity"), "no soft masks");
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
let svg = |d: &Document| vectorcraft_svg::export(d, &Default::default());
assert!(!svg(&plain).contains("knockout"));
@@ -173,5 +174,5 @@ fn pdf_and_svg_write_knockout_groups_as_masks() {
assert!(svg(&page).contains("isolation"));
page.page_knockout = true;
assert_eq!(svg(&page).matches("mask=\"url(#knockout-").count(), 1, "the layer is neutral: its objects knock each other out");
assert!(String::from_utf8_lossy(&pdf(&page).bytes).contains("/Luminosity"));
assert!(String::from_utf8_lossy(&pdf(&page).bytes).replace(' ', "").contains("/Ktrue"));
}
+130
View File
@@ -572,6 +572,64 @@ fn a_malformed_required_number_is_not_reported_as_missing() {
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
}
/// A required whole number given as something other than a whole number of 0 or more (`"3"`, `2.5`,
/// `-1`, `true`, `[]`, `{}`) is refused as the wrong type, naming its key, not as a missing integer; one
/// left out or given as null is still missing. An optional one left out or null keeps its default.
/// Neither changes the document or records an undo step.
#[test]
fn a_malformed_required_integer_is_not_reported_as_missing() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap();
let doc = s.doc().unwrap().doc.clone();
let undo = s.doc().unwrap().history.undo.len();
let with = |base: &serde_json::Value, key: &str, v: Option<serde_json::Value>| {
let mut p = base.clone();
let o = p.as_object_mut().unwrap();
match v {
Some(v) => o.insert(key.into(), v),
None => o.remove(key),
};
p
};
let refused = |s: &mut Session, cmd: &str, p: &serde_json::Value, want: &str| match s.execute(cmd, p) {
Err(EngineError::BadParams { cmd: c, msg }) => assert_eq!((c.as_str(), msg.as_str()), (cmd, want), "{p}"),
other => panic!("{cmd} {p}: {other:?}"),
};
let ends = json!({"sourceDocument": 1, "grab": [0, 0], "at": [0, 0], "ids": [1]});
let cases = [
("appearance.setItem", json!({"index": 0}), "index", "missing integer `index`"),
("path.removeAnchor", json!({"id": 1, "anchor": 0}), "anchor", "missing integer `anchor`"),
("path.split", json!({"id": 1, "anchor": 0}), "anchor", "need `anchor` or `segment` + `t`"),
("addon.pen.removePoint", json!({"anchor": 0}), "anchor", "missing integer `anchor`"),
("edit.dropSelection", ends, "sourceDocument", "missing integer `sourceDocument`"),
("document.activate", json!({"index": 0}), "index", "missing index"),
("document.move", json!({"to": 0}), "to", "missing `to`: a non-negative integer"),
];
let malformed = [json!("3"), json!(2.5), json!(-1), json!(true), json!([]), json!({})];
for (cmd, base, key, missing) in &cases {
for v in &malformed {
refused(&mut s, cmd, &with(base, key, Some(v.clone())), &format!("`{key}` must be a whole number"));
}
for p in [with(base, key, None), with(base, key, Some(json!(null)))] {
refused(&mut s, cmd, &p, missing);
}
}
// An optional `subpath` given as anything else is refused the same way; left out or null, it is
// subpath 0 and the command goes on to read `anchor`.
let base = json!({"id": 1, "anchor": "3"});
for v in &malformed {
refused(&mut s, "path.removeAnchor", &with(&base, "subpath", Some(v.clone())), "`subpath` must be a whole number");
}
for p in [with(&base, "subpath", None), with(&base, "subpath", Some(json!(null)))] {
refused(&mut s, "path.removeAnchor", &p, "`anchor` must be a whole number");
}
assert_eq!(s.doc().unwrap().doc, doc);
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
// A whole JSON number is a whole number.
s.execute("document.activate", &json!({"index": 0})).unwrap();
}
/// A command that takes the keys of one that isn't checked yet isn't checked either.
#[test]
fn a_reference_to_an_unchecked_command_is_unchecked() {
@@ -791,3 +849,75 @@ fn journal_entries_pass_the_check() {
assert!(check_params(id, params).is_ok(), "{id} {params}");
}
}
/// An index of a path, type, panel, library, style or ruler command given as something other than a
/// whole number of 0 or more (`"3"`, `2.5`, `-1`, `true`, `[]`, `{}`) is refused as the wrong type,
/// naming its key. A required one is not reported as missing, and an optional one (`subpath` of
/// `path.appendAnchor`, `path.close` and `path.reverse`, `artboard` of the ruler origin commands) is
/// not read as its default. A required one left out or null is still missing, and an optional one left
/// out or null keeps its default. Neither changes the document or records an undo step.
#[test]
fn a_malformed_index_is_not_missing_or_its_default() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = s.execute("path.create", &json!({"d": "M0 0 L10 0 M20 0 L30 0"})).unwrap()["id"].clone();
s.execute("library.create", &json!({"name": "Brand"})).unwrap();
s.execute("library.createGroup", &json!({"name": "Warm"})).unwrap();
let doc = s.doc().unwrap().doc.clone();
let undo = s.doc().unwrap().history.undo.len();
let with = |base: &serde_json::Value, key: &str, v: Option<serde_json::Value>| {
let mut p = base.clone();
let o = p.as_object_mut().unwrap();
match v {
Some(v) => o.insert(key.into(), v),
None => o.remove(key),
};
p
};
let refused = |s: &mut Session, cmd: &str, p: &serde_json::Value, want: &str| match s.execute(cmd, p) {
Err(EngineError::BadParams { cmd: c, msg }) => assert_eq!((c.as_str(), msg.as_str()), (cmd, want), "{p}"),
other => panic!("{cmd} {p}: {other:?}"),
};
let malformed = [json!("3"), json!(2.5), json!(-1), json!(true), json!([]), json!({})];
let handle = json!({"id": id, "x": 5, "y": 5});
let required = [
("path.setHandle", handle.clone(), "anchor", "missing anchor"),
("path.moveAnchor", handle.clone(), "anchor", "missing anchor"),
("path.insertAnchor", json!({"id": id}), "segment", "missing segment"),
("text.setPathHandle", handle.clone(), "anchor", "missing anchor"),
("artboard.reorder", json!({"index": 0}), "to", "missing to"),
("library.moveGroup", json!({"group": "Warm"}), "to", "missing `to` (an index)"),
("graphicStyle.move", json!({"name": "Default"}), "to", "missing `to` (an index)"),
];
for (cmd, base, key, missing) in &required {
for v in &malformed {
refused(&mut s, cmd, &with(base, key, Some(v.clone())), &format!("`{key}` must be a whole number"));
}
for p in [with(base, key, None), with(base, key, Some(json!(null)))] {
refused(&mut s, cmd, &p, missing);
}
}
let optional = [
("path.appendAnchor", handle.clone(), "subpath"),
("path.close", json!({"id": id}), "subpath"),
("path.reverse", json!({"ids": [id]}), "subpath"),
("document.rulerOrigin", json!({}), "artboard"),
("document.setRulerOrigin", json!({"x": 5, "y": 5}), "artboard"),
("document.resetRulerOrigin", json!({}), "artboard"),
];
for (cmd, base, key) in &optional {
for v in &malformed {
refused(&mut s, cmd, &with(base, key, Some(v.clone())), &format!("`{key}` must be a whole number"));
}
}
// A whole number that names no artboard is still out of range.
refused(&mut s, "document.resetRulerOrigin", &json!({"artboard": 1}), "index out of range");
assert_eq!(s.doc().unwrap().doc, doc);
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
// Left out or null, an optional one keeps its default.
for (cmd, base, key) in &optional {
for p in [with(base, key, None), with(base, key, Some(json!(null)))] {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"));
}
}
}
+21 -1
View File
@@ -693,7 +693,6 @@ fn use_precise_cursors_swaps_the_tools_own_pointers_for_a_crosshair() {
("blobBrush", Cursor::BlobBrush),
("shaper", Cursor::Shaper),
("pencil", Cursor::PencilNew),
("smooth", Cursor::Smooth),
("pathEraser", Cursor::PathEraser),
("join", Cursor::Join),
("eraser", Cursor::Eraser),
@@ -738,6 +737,27 @@ fn use_precise_cursors_swaps_the_tools_own_pointers_for_a_crosshair() {
}
}
/// With no selected path, the Smooth tool shows Not Allowed, and Use Precise Cursors leaves it so; with a
/// selected path, it shows its own pointer, or a crosshair with the preference on.
#[test]
fn use_precise_cursors_keeps_the_smooth_tools_not_allowed_pointer() {
use vectorcraft_tools::{Cursor, Mods};
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
let at = vectorcraft_geom::Point::new(300.0, 300.0);
s.select_tool("smooth", ViewInfo::default()).unwrap();
for precise in [false, true] {
s.execute("prefs.set", &json!({"key": "usePreciseCursors", "value": precise})).unwrap();
assert_eq!(s.cursor(at, Mods::default(), ViewInfo::default()), Cursor::NotAllowed, "precise {precise}");
}
let id = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [id]})).unwrap();
for (precise, c) in [(false, Cursor::Smooth), (true, Cursor::Crosshair)] {
s.execute("prefs.set", &json!({"key": "usePreciseCursors", "value": precise})).unwrap();
assert_eq!(s.cursor(at, Mods::default(), ViewInfo::default()), c, "precise {precise}");
}
}
/// With Alt held while drawing, the Pencil and the Paintbrush show their closing cursors, and Use Precise Cursors
/// turns those into a crosshair too.
#[test]
+140
View File
@@ -1075,6 +1075,146 @@ fn deleting_a_global_swatch_unlinks_art_that_keeps_its_colour() {
assert_eq!(fill_of(&s, a), linked("#2a6fb0", "Brand"));
}
/// A session whose default fill and stroke for new art are linked to the global swatch `Brand`
/// (`#2a6fb0`), in color group `G`, beside the global swatch `Other` (`#ff8800`).
fn brand_defaults_session() -> Session {
let mut s = session();
run(&mut s, "swatch.new", json!({"name": "Brand", "color": "#2a6fb0", "global": true}));
run(&mut s, "swatch.newGroup", json!({"name": "G", "swatches": ["Brand"]}));
run(&mut s, "swatch.new", json!({"name": "Other", "color": "#ff8800", "global": true}));
run(&mut s, "select.set", json!({"ids": []}));
run(&mut s, "paint.setFill", json!({"swatch": "Brand"}));
run(&mut s, "paint.setStroke", json!({"swatch": "Brand"}));
assert_eq!(s.paint.fill, linked("#2a6fb0", "Brand"));
s
}
fn fill_and_stroke(s: &Session, id: NodeId) -> (Paint, Paint) {
let a = &doc(s).node(id).unwrap().appearance;
(a.fill_paint(), a.stroke_paint())
}
/// Run `steps` and then draw a rectangle, once one command at a time and once as one
/// `command.batch`, and check that both leave the same default paints and give the rectangle drawn
/// in the batch, and one drawn after it, the same fill and stroke. Returns the batch's rectangle's
/// fill and stroke.
fn batch_matches_steps(steps: &[(&str, Value)]) -> (Paint, Paint) {
let shape = ("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}));
let mut one_by_one = brand_defaults_session();
for (id, p) in steps {
run(&mut one_by_one, id, p.clone());
}
let single = rect(&mut one_by_one);
let mut batched = brand_defaults_session();
let commands: Vec<Value> = steps.iter().chain([&shape]).map(|(id, p)| json!({"command": id, "params": p})).collect();
let r = run(&mut batched, "command.batch", json!({ "commands": commands }));
let in_batch = NodeId(r["results"][steps.len()]["id"].as_u64().unwrap());
assert_eq!(batched.paint.fill, one_by_one.paint.fill, "the default fill after {steps:?}");
assert_eq!(batched.paint.stroke, one_by_one.paint.stroke, "the default stroke after {steps:?}");
let expected = fill_and_stroke(&one_by_one, single);
assert_eq!(fill_and_stroke(&batched, in_batch), expected, "art drawn in the batch after {steps:?}");
let later = rect(&mut batched);
assert_eq!(fill_and_stroke(&batched, later), expected, "art drawn after the batch after {steps:?}");
expected
}
#[test]
fn swatch_steps_in_a_batch_change_the_new_art_paints_as_commands_do() {
let blue = Paint::solid(Color::from_hex("#2a6fb0").unwrap());
assert_eq!(batch_matches_steps(&[("swatch.delete", json!({"name": "Brand"}))]), (blue.clone(), blue));
let link = linked("#2a6fb0", "Brand");
assert_eq!(batch_matches_steps(&[("swatch.delete", json!({"name": "Brand", "unlink": false}))]), (link.clone(), link));
let signal = linked("#00ff00", "Signal");
let edit = ("swatch.edit", json!({"name": "Brand", "newName": "Signal", "color": "#00ff00"}));
assert_eq!(batch_matches_steps(&[edit]), (signal.clone(), signal));
let other = linked("#ff8800", "Other");
assert_eq!(batch_matches_steps(&[("swatch.merge", json!({"names": ["Other", "Brand"]}))]), (other.clone(), other));
let gray = linked("#ababab", "Brand");
assert_eq!(batch_matches_steps(&[("swatch.editGroup", json!({"group": "G", "colors": ["#ababab"]}))]), (gray.clone(), gray));
}
#[test]
fn undo_and_redo_of_a_swatch_edit_put_back_the_new_art_paints() {
let edit = json!({"name": "Brand", "newName": "Signal", "color": "#00ff00"});
let delete = json!({"name": "Brand"});
for (id, p, fill) in
[("swatch.edit", &edit, linked("#00ff00", "Signal")), ("swatch.delete", &delete, Paint::solid(Color::from_hex("#2a6fb0").unwrap()))]
{
for batch in [false, true] {
let mut s = brand_defaults_session();
let before = s.paint.clone();
if batch {
run(&mut s, "command.batch", json!({"commands": [{"command": id, "params": p}]}));
} else {
run(&mut s, id, p.clone());
}
assert_eq!(s.paint.fill, fill, "{id}, batch: {batch}");
let after = s.paint.clone();
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.paint, before, "Undo of {id}, batch: {batch}");
run(&mut s, "edit.redo", json!({}));
assert_eq!(s.paint, after, "Redo of {id}, batch: {batch}");
}
}
}
#[test]
fn swatch_edits_reach_the_paints_of_the_new_art_template() {
let mut s = brand_defaults_session();
s.paint.appearance = Some(vectorcraft_doc::Appearance::basic(linked("#2a6fb0", "Brand"), linked("#2a6fb0", "Brand"), 1.0));
run(&mut s, "swatch.edit", json!({"name": "Brand", "newName": "Signal", "color": "#00ff00"}));
let template = |s: &Session| -> Vec<Paint> {
s.paint
.appearance
.iter()
.flat_map(|a| a.items.iter())
.map(|it| match it {
vectorcraft_doc::AppearanceItem::Fill(f) => f.paint.clone(),
vectorcraft_doc::AppearanceItem::Stroke(k) => k.paint.clone(),
})
.collect()
};
assert_eq!(template(&s), [linked("#00ff00", "Signal"), linked("#00ff00", "Signal")]);
run(&mut s, "swatch.delete", json!({"name": "Signal"}));
let green = Paint::solid(Color::from_hex("#00ff00").unwrap());
assert_eq!(template(&s), [green.clone(), green]);
}
#[test]
fn a_failed_batch_puts_back_the_new_art_paints_a_swatch_step_changed() {
let mut s = brand_defaults_session();
let before = s.paint.clone();
let steps = json!([
{"command": "swatch.edit", "params": {"name": "Brand", "newName": "Signal", "color": "#00ff00"}},
{"command": "no.such.command", "params": {}},
]);
assert!(s.execute("command.batch", &json!({ "commands": steps })).is_err());
assert_eq!(s.paint, before);
assert_eq!(doc(&s).swatch("Brand").unwrap().paint.color(), Some(Color::from_hex("#2a6fb0").unwrap()));
}
#[test]
fn canceling_a_swatch_options_preview_keeps_the_new_art_paints() {
let mut s = brand_defaults_session();
let before = s.paint.clone();
s.begin_interaction("Swatch Options").unwrap();
s.preview("swatch.edit", &json!({"name": "Brand", "newName": "Signal", "color": "#00ff00"})).unwrap();
s.cancel_interaction().unwrap();
assert_eq!(s.paint, before);
assert!(doc(&s).swatch("Signal").is_none());
// A previewed batch that is canceled puts them back too, and one that is accepted keeps them.
let steps = json!([{"command": "swatch.edit", "params": {"name": "Brand", "newName": "Signal", "color": "#00ff00"}}]);
s.begin_interaction("Batch Preview").unwrap();
s.preview("command.batch", &json!({ "commands": steps })).unwrap();
s.cancel_interaction().unwrap();
assert_eq!(s.paint, before);
s.begin_interaction("Batch Preview").unwrap();
s.preview("command.batch", &json!({ "commands": steps })).unwrap();
s.commit_interaction().unwrap();
assert_eq!(s.paint.fill, linked("#00ff00", "Signal"));
assert_eq!(s.paint.stroke, linked("#00ff00", "Signal"));
}
#[test]
fn deleting_several_swatches_and_groups() {
let mut s = session();
@@ -20,9 +20,18 @@ fn close(a: Rect, b: Rect) -> bool {
/// A 100 × 100 frame with a 10 × 10 square in its top-left corner and one in its center, made
/// into symbol "Panel" with 9-slice guides on, its instance selected at its natural size.
fn panel_symbol() -> (Session, NodeId) {
panel_symbol_filled(None)
}
/// [`panel_symbol`] with the frame filled with `fill` when there is one.
fn panel_symbol_filled(fill: Option<vectorcraft_color::Paint>) -> (Session, NodeId) {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
let ids = [rect(&mut s, 50.0, 50.0, 100.0, 100.0), rect(&mut s, 50.0, 50.0, 10.0, 10.0), rect(&mut s, 95.0, 95.0, 10.0, 10.0)];
if let Some(fill) = fill {
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
doc.node_mut(ids[0]).unwrap().appearance = vectorcraft_doc::Appearance::basic(fill, vectorcraft_color::Paint::None, 0.0).into();
}
s.select(|_, sel| sel.set(ids)).unwrap();
let r = s.execute("symbol.new", &json!({"name": "Panel", "nineSlice": true})).unwrap();
assert_eq!(r["nineSlice"], json!([0.25, 0.25, 0.25, 0.25]));
@@ -350,3 +359,39 @@ fn guides_outside_the_art_survive_saving_and_reopening_a_native_file() {
assert!(back.symbol_slice_map(symbol, *xf).is_none(), "{bad}: scaled as a whole");
}
}
/// [`panel_symbol`] with a gradient on the frame, so that the frame is drawn in a clip group for
/// each cell, its instance resized to 200 × 150 and turned 20°.
fn turned_gradient_panel() -> (Session, NodeId) {
let gradient = vectorcraft_color::Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient::default())));
let (mut s, id) = panel_symbol_filled(Some(gradient));
s.execute("object.setBounds", &json!({"x": 50, "y": 50, "width": 200, "height": 150, "reference": 0})).unwrap();
s.execute("object.rotate", &json!({"angle": 20})).unwrap();
(s, id)
}
/// The bounding box angles of the objects under the art that replaced an instance, and whether
/// that art holds a clip group.
fn expanded_angles(s: &Session, art: NodeId) -> (Vec<f64>, bool) {
let (mut angles, mut clips) = (vec![], false);
s.doc().unwrap().doc.node(art).unwrap().walk(&mut |c| {
angles.push(c.bbox_angle);
clips |= matches!(c.kind, NodeKind::Group { clip: true, .. });
});
(angles, clips)
}
#[test]
fn break_link_turns_the_cells_of_a_sliced_instance_with_its_box() {
for reset in [false, true] {
let (mut s, _) = turned_gradient_panel();
if reset {
assert_eq!(s.execute("object.resetBoundingBox", &json!({})).unwrap()["changed"], 1);
}
let r = s.execute("symbol.breakLink", &json!({})).unwrap();
let (angles, clips) = expanded_angles(&s, NodeId(r["ids"][0].as_u64().unwrap()));
assert!(clips, "the frame is drawn in clip groups");
let want = if reset { 0.0 } else { 20.0 };
assert!(angles.iter().all(|a| (a - want).abs() < 1e-6), "reset {reset}: {angles:?}");
}
}