mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-09 13:07:41 +00:00
Merge main into fix/shape-builder-merge-off-ui-thread
# Conflicts: # crates/ui-egui/src/menus/invoke.rs
This commit is contained in:
@@ -46,6 +46,10 @@ impl eframe::App for App {
|
||||
#[cfg(windows)]
|
||||
windows_stylus::raw_input_hook(raw);
|
||||
file_access::unconfined(|| self.app.raw_input_hook(ctx, raw));
|
||||
// A mouse-up that never reached the window (#4326) is delivered once AppKit reports
|
||||
// that no mouse button is down.
|
||||
#[cfg(target_os = "macos")]
|
||||
self.app.release_lost_buttons(ctx, raw, objc2_app_kit::NSEvent::pressedMouseButtons() != 0);
|
||||
}
|
||||
#[cfg_attr(not(target_os = "macos"), allow(unused_variables))]
|
||||
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
|
||||
|
||||
@@ -126,6 +126,61 @@ fn circular_subpath(sub: &Sub) -> Option<CircularSub> {
|
||||
if samples.iter().any(|p| (centre.distance(*p) - radius).abs() > tolerance) {
|
||||
return None;
|
||||
}
|
||||
// Verify that the curve progresses monotonically around the circle without reversing,
|
||||
// both along each cubic segment's angular derivative and across sampled intervals,
|
||||
// and that closed subpaths make exactly one full turn while open subpaths do not exceed one turn.
|
||||
let mut total_turn = 0.0;
|
||||
let mut dir: Option<bool> = None;
|
||||
let ang_threshold = radius * radius * 1e-9;
|
||||
let mut seg_from = sub.start;
|
||||
for seg in &sub.segs {
|
||||
let Seg::Cubic(a, b, to) = *seg else { return None };
|
||||
for step in 0..=32 {
|
||||
let t = f64::from(step) / 32.0;
|
||||
let p = cubic_point(seg_from, a, b, to, t);
|
||||
let u = 1.0 - t;
|
||||
let dx = 3.0 * u * u * (a.x - seg_from.x) + 6.0 * u * t * (b.x - a.x) + 3.0 * t * t * (to.x - b.x);
|
||||
let dy = 3.0 * u * u * (a.y - seg_from.y) + 6.0 * u * t * (b.y - a.y) + 3.0 * t * t * (to.y - b.y);
|
||||
let cross = (p.x - centre.x) * dy - (p.y - centre.y) * dx;
|
||||
if cross.abs() > ang_threshold {
|
||||
let is_ccw = cross > 0.0;
|
||||
if let Some(expected_ccw) = dir {
|
||||
if is_ccw != expected_ccw {
|
||||
return None;
|
||||
}
|
||||
} else {
|
||||
dir = Some(is_ccw);
|
||||
}
|
||||
}
|
||||
}
|
||||
seg_from = to;
|
||||
}
|
||||
for w in samples.windows(2) {
|
||||
let (p0, p1) = (w[0], w[1]);
|
||||
let (v0x, v0y) = (p0.x - centre.x, p0.y - centre.y);
|
||||
let (v1x, v1y) = (p1.x - centre.x, p1.y - centre.y);
|
||||
let cross = v0x * v1y - v0y * v1x;
|
||||
let dot = v0x * v1x + v0y * v1y;
|
||||
let d_theta = cross.atan2(dot);
|
||||
if d_theta.abs() > 1e-9 {
|
||||
let is_ccw = d_theta > 0.0;
|
||||
if let Some(expected_ccw) = dir {
|
||||
if is_ccw != expected_ccw {
|
||||
return None;
|
||||
}
|
||||
} else {
|
||||
dir = Some(is_ccw);
|
||||
}
|
||||
total_turn += d_theta;
|
||||
}
|
||||
}
|
||||
if sub.closed {
|
||||
if (total_turn.abs() - std::f64::consts::TAU).abs() > 1e-3 {
|
||||
return None;
|
||||
}
|
||||
} else if total_turn.abs() < 1e-6 || total_turn.abs() >= std::f64::consts::TAU - 1e-3 {
|
||||
return None;
|
||||
}
|
||||
Some(CircularSub { centre, radius, samples })
|
||||
}
|
||||
|
||||
|
||||
@@ -246,6 +246,52 @@ fn closed_circular_arc_with_a_closing_chord_is_not_a_circle() {
|
||||
assert!(!kinds(&r12).iter().any(|k| k == "CIRCLE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_closed_sliver_along_a_circle_is_not_a_circle() {
|
||||
let d = doc_with(|d| {
|
||||
let (cx, cy, r) = (60.0, 250.0, 200.0);
|
||||
let at = |deg: f64| {
|
||||
let rad = deg.to_radians();
|
||||
Point::new(cx + r * rad.cos(), cy + r * rad.sin())
|
||||
};
|
||||
let along = |sliver: &mut vectorcraft_geom::BezPath, deg0: f64, deg1: f64| {
|
||||
let p0 = at(deg0);
|
||||
let p3 = at(deg1);
|
||||
let step = (deg1 - deg0).to_radians();
|
||||
let k = 4.0 / 3.0 * (step / 4.0).tan();
|
||||
let r0 = deg0.to_radians();
|
||||
let r1 = deg1.to_radians();
|
||||
let p1 = Point::new(p0.x - r * r0.sin() * k, p0.y + r * r0.cos() * k);
|
||||
let p2 = Point::new(p3.x + r * r1.sin() * k, p3.y - r * r1.cos() * k);
|
||||
sliver.curve_to(p1, p2, p3);
|
||||
};
|
||||
let mut sliver = vectorcraft_geom::BezPath::new();
|
||||
sliver.move_to(at(-110.0));
|
||||
along(&mut sliver, -110.0, -90.0);
|
||||
along(&mut sliver, -90.0, -70.0);
|
||||
along(&mut sliver, -70.0, -110.0);
|
||||
sliver.close_path();
|
||||
vec![path(d, PathData::from_bezpath(&sliver), Paint::None, Paint::solid(Color::BLACK), 1.0)]
|
||||
});
|
||||
|
||||
let modern = export(&d, &opts(&d)).unwrap();
|
||||
assert_ne!(kinds(&modern), ["CIRCLE"]);
|
||||
assert_eq!(kinds(&modern), ["SPLINE"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cubic_with_hidden_angular_reversal_is_not_promoted_to_arc() {
|
||||
let d = doc_with(|d| {
|
||||
let mut b = vectorcraft_geom::BezPath::new();
|
||||
b.move_to(Point::new(-8.404028665134, 62.061475842818));
|
||||
b.curve_to(Point::new(-13.285253116737, 62.103123153545), Point::new(52.811163576591, 52.471253544459), Point::new(60.0, 50.0));
|
||||
vec![path(d, PathData::from_bezpath(&b), Paint::None, Paint::solid(Color::BLACK), 1.0)]
|
||||
});
|
||||
let dxf = export(&d, &opts(&d)).unwrap();
|
||||
assert_ne!(kinds(&dxf), ["ARC"]);
|
||||
assert_eq!(kinds(&dxf), ["SPLINE"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spline_closed_flags_follow_path_intent_across_versions_without_changing_geometry() {
|
||||
let cases = [(true, true), (true, false), (false, false), (false, true)];
|
||||
|
||||
+259
-3
@@ -1059,13 +1059,146 @@ pub fn steps_knockout_shows(steps: &[Arc<Node>]) -> bool {
|
||||
!steps.iter().all(|c| paints_opaquely(c))
|
||||
}
|
||||
|
||||
/// Evaluate a blend: the keys and generated steps in paint order.
|
||||
/// Most steps a blend makes in all, the steps of the blends inside its keys and steps included.
|
||||
/// Each step of a blend whose keys are blends is a copy of one of them, which makes its own steps
|
||||
/// when drawn, so nesting multiplies the step counts capped pair by pair: five levels of 15 steps
|
||||
/// are 1.3 million steps from a 23 KB file, 3.4 GB to draw (#3579). Past it, a blend makes
|
||||
/// proportionally fewer steps between each pair of keys ([`fit_steps`]).
|
||||
pub const MAX_BLEND_STEPS: usize = 100_000;
|
||||
|
||||
/// The steps between each pair of keys, as the blend's spacing asks for them.
|
||||
fn requested_steps(keys: &[Arc<Node>], spec: &BlendSpec, rail: Option<&Rail>) -> Vec<usize> {
|
||||
keys.windows(2)
|
||||
.enumerate()
|
||||
.map(|(i, w)| match w {
|
||||
[a, b] => blend_step_count(a, b, spec.spacing, rail.map_or_else(|| center_of(a).distance(center_of(b)), |r| r.len(i))),
|
||||
_ => 0,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Most steps `spacing` makes between two keys, whatever their size and distance
|
||||
/// ([`blend_step_count`]'s clamps).
|
||||
fn most_steps(spacing: BlendSpacing) -> usize {
|
||||
match spacing {
|
||||
BlendSpacing::Steps(n) => n.clamp(1, 1000) as usize,
|
||||
BlendSpacing::Distance(_) => 1000,
|
||||
BlendSpacing::SmoothColor => 256,
|
||||
}
|
||||
}
|
||||
|
||||
/// `want` steps between each pair of keys, fewer where they would make more than
|
||||
/// [`MAX_BLEND_STEPS`] in all with `keys` steps already made by the keys and `each[i]` made by
|
||||
/// one step of pair `i` (itself and what the blends in it make): every pair keeps the same share
|
||||
/// of its steps, rounded down, and none when the keys alone fill the budget.
|
||||
fn fit_steps(want: &[usize], each: &[usize], keys: usize) -> Vec<usize> {
|
||||
let room = MAX_BLEND_STEPS.saturating_sub(keys) as u128;
|
||||
let demand = want.iter().zip(each).map(|(&n, &c)| n as u128 * c as u128).fold(0u128, u128::saturating_add);
|
||||
if demand <= room {
|
||||
return want.to_vec();
|
||||
}
|
||||
// `demand` is more than `room`, so it isn't 0, and each share is below `n`.
|
||||
want.iter().map(|&n| usize::try_from(n as u128 * room / demand).unwrap_or(0)).collect()
|
||||
}
|
||||
|
||||
/// Counts the steps blends make in all ([`MAX_BLEND_STEPS`]), each object counted once by its
|
||||
/// address while the count runs.
|
||||
#[derive(Default)]
|
||||
struct StepCount {
|
||||
known: std::collections::HashMap<(usize, bool), usize>,
|
||||
}
|
||||
|
||||
impl StepCount {
|
||||
/// Steps the blends in `n` make in all when drawn, each keeping to [`MAX_BLEND_STEPS`].
|
||||
/// `copy`: `n` is part of a blend's step, a copy moved and scaled into place. A blend spaced
|
||||
/// by distance (or by distance where the colours match) makes more steps scaled up, so a
|
||||
/// copy counts the most its spacing can make between each pair, an upper bound for any scale.
|
||||
fn node(&mut self, n: &Node, copy: bool) -> usize {
|
||||
let at = (std::ptr::from_ref(n) as usize, copy);
|
||||
if let Some(&known) = self.known.get(&at) {
|
||||
return known;
|
||||
}
|
||||
let count = match &n.kind {
|
||||
NodeKind::Blend { children, spec } if copy => self.most(children, spec),
|
||||
NodeKind::Blend { children, spec } => self.blend(children, spec, Rail::new(children, spec).as_ref()).1,
|
||||
// Each instance draws the source again.
|
||||
NodeKind::Repeat(r) => r.instances(None).saturating_mul(self.nodes(&r.source, copy)),
|
||||
_ => self.nodes(n.children().map_or(&[], Vec::as_slice), copy),
|
||||
};
|
||||
self.known.insert(at, count);
|
||||
count
|
||||
}
|
||||
|
||||
fn nodes(&mut self, nodes: &[Arc<Node>], copy: bool) -> usize {
|
||||
nodes.iter().map(|c| self.node(c, copy)).fold(0, usize::saturating_add)
|
||||
}
|
||||
|
||||
/// What one step between keys `a` and `b` makes: itself and the steps of the blends in it.
|
||||
/// Groups pair their children as [`Lerp::new`] does; anything else is a copy of the nearer key.
|
||||
fn step(&mut self, a: &Node, b: &Node) -> usize {
|
||||
let inner = match (&a.kind, &b.kind) {
|
||||
(NodeKind::Group { children: ca, clip: k1 }, NodeKind::Group { children: cb, clip: k2 })
|
||||
if k1 == k2 && !(*k1 && ca.len() != cb.len()) =>
|
||||
{
|
||||
(0..ca.len().max(cb.len()))
|
||||
.map(|i| match (ca.get(i), cb.get(i)) {
|
||||
(Some(x), Some(y)) => self.step(x, y).saturating_sub(1),
|
||||
(Some(x), None) | (None, Some(x)) => self.node(x, true),
|
||||
(None, None) => 0,
|
||||
})
|
||||
.fold(0, usize::saturating_add)
|
||||
}
|
||||
_ => self.node(a, true).max(self.node(b, true)),
|
||||
};
|
||||
inner.saturating_add(1)
|
||||
}
|
||||
|
||||
/// What each step between each pair of `keys` makes ([`Self::step`]).
|
||||
fn steps(&mut self, keys: &[Arc<Node>]) -> Vec<usize> {
|
||||
keys.windows(2)
|
||||
.map(|w| match w {
|
||||
[a, b] => self.step(a, b),
|
||||
_ => 1,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The steps between each pair of keys a blend makes ([`fit_steps`]), and the steps it makes
|
||||
/// in all, its keys' included.
|
||||
fn blend(&mut self, keys: &[Arc<Node>], spec: &BlendSpec, rail: Option<&Rail>) -> (Vec<usize>, usize) {
|
||||
let inside = self.nodes(keys, false);
|
||||
let each = self.steps(keys);
|
||||
let kept = fit_steps(&requested_steps(keys, spec, rail), &each, inside);
|
||||
let total = kept.iter().zip(&each).map(|(&n, &c)| n.saturating_mul(c)).fold(inside, usize::saturating_add);
|
||||
(kept, total)
|
||||
}
|
||||
|
||||
/// The most steps a copy of a blend makes in all, at any scale: what [`Self::blend`] counts
|
||||
/// with [`most_steps`] between each pair, before rounding.
|
||||
fn most(&mut self, keys: &[Arc<Node>], spec: &BlendSpec) -> usize {
|
||||
let inside = self.nodes(keys, true);
|
||||
let each = self.steps(keys);
|
||||
let demand = each.iter().map(|&c| c.saturating_mul(most_steps(spec.spacing))).fold(0, usize::saturating_add);
|
||||
inside.saturating_add(demand.min(MAX_BLEND_STEPS.saturating_sub(inside)))
|
||||
}
|
||||
}
|
||||
|
||||
/// How many steps `keys` blended with `spec` make between each pair of keys: as many as the
|
||||
/// spacing asks for, fewer when they and the steps of the blends nested in them would pass
|
||||
/// [`MAX_BLEND_STEPS`] ([`blend_expand`] makes these).
|
||||
pub fn blend_steps(keys: &[Arc<Node>], spec: &BlendSpec) -> Vec<usize> {
|
||||
StepCount::default().blend(keys, spec, Rail::new(keys, spec).as_ref()).0
|
||||
}
|
||||
|
||||
/// Evaluate a blend: the keys and generated steps in paint order. The steps keep to
|
||||
/// [`MAX_BLEND_STEPS`] in all ([`blend_steps`]).
|
||||
pub fn blend_expand(keys: &[Arc<Node>], spec: &BlendSpec) -> Vec<Node> {
|
||||
let k = keys.len();
|
||||
if k < 2 {
|
||||
return keys.iter().map(|n| (**n).clone()).collect();
|
||||
}
|
||||
let rail = Rail::new(keys, spec);
|
||||
let (counts, _) = StepCount::default().blend(keys, spec, rail.as_ref());
|
||||
let mut out = Vec::new();
|
||||
for (i, a) in keys.iter().enumerate() {
|
||||
let mut key = (**a).clone();
|
||||
@@ -1074,8 +1207,7 @@ pub fn blend_expand(keys: &[Arc<Node>], spec: &BlendSpec) -> Vec<Node> {
|
||||
}
|
||||
out.push(key);
|
||||
let Some(b) = keys.get(i + 1) else { break };
|
||||
let len = rail.as_ref().map_or_else(|| center_of(a).distance(center_of(b)), |r| r.len(i));
|
||||
let n = blend_step_count(a, b, spec.spacing, len);
|
||||
let n = counts.get(i).copied().unwrap_or(0);
|
||||
let lerp = Lerp::new(a, b, (spec.start(i), spec.start(i + 1)));
|
||||
for j in 1..=n {
|
||||
// Even position of the step, then where the easing puts its shape and its colours.
|
||||
@@ -1301,4 +1433,128 @@ mod tests {
|
||||
mid.walk(&mut |n| ids.push(n.id));
|
||||
assert!(ids.iter().all(|i| *i == NodeId(0)), "{ids:?}");
|
||||
}
|
||||
|
||||
/// A blend of `a` and `b` spaced by `spacing`.
|
||||
fn blend_of(a: Node, b: Node, spacing: BlendSpacing) -> Node {
|
||||
let spec = BlendSpec { spacing, ..Default::default() };
|
||||
Node::new(NodeId(1), NodeKind::Blend { children: vec![Arc::new(a), Arc::new(b)], spec: Box::new(spec) })
|
||||
}
|
||||
|
||||
/// A blend's keys and options.
|
||||
fn blend_parts(n: &Node) -> Option<(&Vec<Arc<Node>>, &BlendSpec)> {
|
||||
match &n.kind {
|
||||
NodeKind::Blend { children, spec } => Some((children, spec)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A square of side `side` at (`x`, `y`).
|
||||
fn square(x: f64, y: f64, side: f64) -> Node {
|
||||
Node::path(NodeId(1), shapes::rectangle(Rect::new(x, y, x + side, y + side)), Appearance::default())
|
||||
}
|
||||
|
||||
/// `levels` of blends of 15 steps whose keys are the blends a level down (#3579's input).
|
||||
fn nested(levels: u32, x: f64) -> Node {
|
||||
if levels == 0 {
|
||||
return square(x, 0.0, 4.0);
|
||||
}
|
||||
let w = 40.0 * f64::from(levels);
|
||||
blend_of(nested(levels - 1, x), nested(levels - 1, x + w), BlendSpacing::Steps(15))
|
||||
}
|
||||
|
||||
/// The steps drawing `n` makes in all, each blend evaluated as the renderer does: one level at
|
||||
/// a time, the blends in its keys and steps evaluated again.
|
||||
fn drawn_steps(n: &Node) -> usize {
|
||||
match &n.kind {
|
||||
NodeKind::Blend { children, spec } => {
|
||||
let out = blend_expand(children, spec);
|
||||
out.len() - children.len() + out.iter().map(drawn_steps).sum::<usize>()
|
||||
}
|
||||
_ => n.children().map_or(0, |c| c.iter().map(|c| drawn_steps(c)).sum()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Steps `levels` of [`nested`] blends of 15 steps make in all: each level its 15 steps,
|
||||
/// and again the steps of the level below in each key and each step.
|
||||
fn nested_steps(levels: u32) -> usize {
|
||||
(17usize.pow(levels) - 1) * 15 / 16
|
||||
}
|
||||
|
||||
/// #3579: blends whose keys are blends draw their steps again in every step above them. Five
|
||||
/// levels of 15 steps made 1.3 million steps from a 23 KB file. Now a blend makes fewer steps
|
||||
/// where they would pass `MAX_BLEND_STEPS`, and none where its keys already fill it, so each
|
||||
/// level past that adds only what its keys draw.
|
||||
#[test]
|
||||
fn nested_blends_keep_to_the_step_budget() {
|
||||
assert_eq!(nested_steps(5), 1_331_115);
|
||||
let four = nested_steps(4);
|
||||
assert!(four < MAX_BLEND_STEPS && 2 * four > MAX_BLEND_STEPS);
|
||||
let five = nested(5, 0.0);
|
||||
let (children, spec) = blend_parts(&five).unwrap();
|
||||
assert_eq!(blend_steps(children, spec), vec![0]);
|
||||
assert_eq!(drawn_steps(&five), 2 * four);
|
||||
assert_eq!(drawn_steps(&nested(6, 0.0)), 4 * four);
|
||||
// A blend whose keys leave room keeps the same share of its steps between each pair.
|
||||
let mut three = nested(3, 0.0);
|
||||
if let NodeKind::Blend { spec, .. } = &mut three.kind {
|
||||
spec.spacing = BlendSpacing::Steps(1000);
|
||||
}
|
||||
let (children, spec) = blend_parts(&three).unwrap();
|
||||
let inside = 2 * nested_steps(2);
|
||||
let each = 1 + nested_steps(2);
|
||||
let kept = (MAX_BLEND_STEPS - inside) * 1000 / (1000 * each);
|
||||
assert_eq!(blend_steps(children, spec), vec![kept]);
|
||||
let drawn = drawn_steps(&three);
|
||||
assert_eq!(drawn, inside + kept * each);
|
||||
assert!(drawn <= MAX_BLEND_STEPS && drawn > MAX_BLEND_STEPS - each, "{drawn}");
|
||||
}
|
||||
|
||||
/// Nested blends within the budget make every step their spacing asks for, as before.
|
||||
#[test]
|
||||
fn nested_blends_within_the_budget_draw_every_step() {
|
||||
for levels in 1..=4 {
|
||||
let n = nested(levels, 0.0);
|
||||
let (children, spec) = blend_parts(&n).unwrap();
|
||||
assert_eq!(blend_steps(children, spec), vec![15]);
|
||||
assert_eq!(drawn_steps(&n), nested_steps(levels), "{levels}");
|
||||
}
|
||||
// The keys, then 15 steps, then the last key, each a blend whose keys moved with it.
|
||||
let two = nested(2, 0.0);
|
||||
let (children, spec) = blend_parts(&two).unwrap();
|
||||
let out = blend_expand(children, spec);
|
||||
assert_eq!(out.len(), 17);
|
||||
assert_eq!(out[0], *children[0]);
|
||||
assert!(out.iter().all(|s| matches!(s.kind, NodeKind::Blend { .. })));
|
||||
assert_eq!(out[8].geometric_bounds(), children[0].geometric_bounds().map(|b| b + Vec2::new(40.0, 0.0)));
|
||||
}
|
||||
|
||||
/// A blend spaced by distance makes more steps scaled up, so a step's copy of one counts the
|
||||
/// most its spacing can make. Here a tiny blend of one step is blended with a large blend of
|
||||
/// one step: copies of the tiny one, scaled up toward the large one's size, make up to 1000
|
||||
/// steps each, which neither key's own count shows.
|
||||
#[test]
|
||||
fn scaled_copies_of_blends_spaced_by_distance_keep_to_the_budget() {
|
||||
let tiny = blend_of(square(0.0, 0.0, 1.0), square(2.0, 0.0, 1.0), BlendSpacing::Distance(1.0));
|
||||
let large = blend_of(square(0.0, 100.0, 1000.0), square(2000.0, 100.0, 1000.0), BlendSpacing::Steps(1));
|
||||
assert_eq!((drawn_steps(&tiny), drawn_steps(&large)), (1, 1));
|
||||
let n = blend_of(tiny, large, BlendSpacing::Steps(1000));
|
||||
let (children, spec) = blend_parts(&n).unwrap();
|
||||
let steps = blend_steps(children, spec);
|
||||
assert!(steps[0] < 1000, "{steps:?}");
|
||||
let drawn = drawn_steps(&n);
|
||||
assert!(drawn <= MAX_BLEND_STEPS && drawn > 1000, "{drawn}");
|
||||
}
|
||||
|
||||
/// A flat blend of many keys shares the budget evenly between its pairs.
|
||||
#[test]
|
||||
fn many_keys_share_the_step_budget() {
|
||||
let keys: Vec<Arc<Node>> = (0..300).map(|i| key(f64::from(i) * 20.0)).collect();
|
||||
let spec = BlendSpec { spacing: BlendSpacing::Steps(1000), ..Default::default() };
|
||||
let steps = blend_steps(&keys, &spec);
|
||||
assert!(steps.iter().all(|&n| n == MAX_BLEND_STEPS * 1000 / (299 * 1000)), "{:?}", &steps[..3]);
|
||||
assert_eq!(blend_expand(&keys, &spec).len(), 300 + steps.iter().sum::<usize>());
|
||||
// Within the budget, every step stays.
|
||||
assert_eq!(blend_steps(&keys[..50], &spec), vec![1000; 49]);
|
||||
assert_eq!(fit_steps(&[10, 20], &[1, 1], MAX_BLEND_STEPS), vec![0, 0]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,6 +179,21 @@ pub fn has_multi(s: &Session) -> std::result::Result<(), String> {
|
||||
let st = s.active().ok_or("no document open")?;
|
||||
if st.selection.len() < 2 { Err("select at least two objects".into()) } else { Ok(()) }
|
||||
}
|
||||
/// A document is open and it is not in isolation mode: Object › Lock's and Object › Hide's items,
|
||||
/// Unlock All and Show All are off in isolation mode. Pattern editing mode and opacity mask
|
||||
/// editing isolate their art too, and do not count as isolation mode here while their own layer
|
||||
/// is the isolated one. An object isolated inside them counts.
|
||||
pub fn outside_isolation(s: &Session) -> std::result::Result<(), String> {
|
||||
let st = s.active().ok_or("no document open")?;
|
||||
let editing_layer = |l| st.doc.pattern_edit.as_ref().is_some_and(|e| e.layer == l) || st.doc.mask_edit.as_ref().is_some_and(|e| e.layer == l);
|
||||
if st.isolation.is_some_and(|l| !editing_layer(l)) { Err("exit isolation mode first".into()) } else { Ok(()) }
|
||||
}
|
||||
/// Objects are selected ([`has_selection`]) and the document is not in isolation mode
|
||||
/// ([`outside_isolation`]).
|
||||
pub fn has_selection_outside_isolation(s: &Session) -> std::result::Result<(), String> {
|
||||
outside_isolation(s)?;
|
||||
has_selection(s)
|
||||
}
|
||||
pub fn can_undo(s: &Session) -> std::result::Result<(), String> {
|
||||
s.active().filter(|d| !d.history.undo.is_empty()).map(|_| ()).ok_or_else(|| "nothing to undo".into())
|
||||
}
|
||||
|
||||
@@ -19,7 +19,8 @@ use vectorcraft_doc::{Document, ImageBlob, Node, NodeId, NodeKind};
|
||||
|
||||
use super::edit::selected_roots;
|
||||
use super::{
|
||||
CommandSpec, EngineError, Result, Session, always, bad, bool_or, cmd, color_value, f64_or, has_selection, ids_param, str_param, unique_name,
|
||||
CommandSpec, EngineError, Result, Session, always, bad, bool_or, check_numbers, cmd, color_value, f64_or, f64_req, has_selection, ids_param,
|
||||
str_param, unique_name,
|
||||
};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -70,7 +71,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Saturate…",
|
||||
["Edit", "Edit Colors"],
|
||||
None,
|
||||
"{intensity: -100..100 (%), fill?, stroke?, includeImages?, includePatterns?, ids?} scale saturation by (1 + intensity/100), reaching what edit.colors.invert does → {changed}",
|
||||
"{intensity: -100..100 (%; a number outside the range counts as its nearest end), fill?, stroke?, includeImages?, includePatterns?, ids?} scale saturation by (1 + intensity/100), reaching what edit.colors.invert does → {changed}",
|
||||
has_selection,
|
||||
saturate
|
||||
),
|
||||
@@ -345,18 +346,21 @@ fn run_pass(s: &mut Session, p: &Value, label: &str, pass: Recolor) -> Result<Va
|
||||
}
|
||||
|
||||
fn saturate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = (f64_or(p, "intensity", 0.0).clamp(-100.0, 100.0) / 100.0) as f32;
|
||||
let i = (f64_req(p, "intensity", "edit.colors.saturate")?.clamp(-100.0, 100.0) / 100.0) as f32;
|
||||
recolor(s, p, "Saturate", &move |c| {
|
||||
let [h, sat, v] = c.to_hsb();
|
||||
if sat <= 0.0 {
|
||||
let to = (sat * (1.0 + i)).clamp(0.0, 1.0);
|
||||
// A color whose saturation stays the same is returned as it is, so it keeps its swatch link.
|
||||
if sat <= 0.0 || to == sat {
|
||||
return c;
|
||||
}
|
||||
keep_model(c, Color::from_hsb(h, (sat * (1.0 + i)).clamp(0.0, 1.0), v))
|
||||
keep_model(c, Color::from_hsb(h, to, v))
|
||||
})
|
||||
}
|
||||
|
||||
fn adjust_balance(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "edit.colors.adjustBalance";
|
||||
check_numbers(p, &["r", "g", "b", "c", "m", "y", "k", "gray", "tint"], C)?;
|
||||
let given = |keys: &[&str]| keys.iter().any(|k| p.get(*k).is_some());
|
||||
let mode = match str_param(p, "mode") {
|
||||
Some(m) => m.to_ascii_lowercase(),
|
||||
@@ -513,6 +517,7 @@ fn harmony(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids: Vec<String> = Harmony::ALL.iter().map(|h| h.id()).collect();
|
||||
bad(C, format!("unknown rule `{rule}` ({})", ids.join("|")))
|
||||
})?;
|
||||
check_numbers(p, &["steps", "amount"], C)?;
|
||||
let mut opts = GuideOptions::default();
|
||||
if let Some(n) = p.get("steps").and_then(Value::as_u64) {
|
||||
opts.steps = n.min(u32::MAX as u64) as u32;
|
||||
|
||||
@@ -591,11 +591,12 @@ fn grid_group(s: &mut Session, label: &str, (paths, bbox_angle): (Vec<PathData>,
|
||||
}
|
||||
|
||||
/// A grid's divider count `count` (an error unless it is a number or null; 5 unless it is a JSON
|
||||
/// integer of 0 or more, so 4.0 and 2.5 give 5; at most 999) with its skew `key` in percent (0 when
|
||||
/// whole number of 0 or more, so 4.0 gives 4 and 2.5 gives 5; at most 999) with its skew `key` in percent (0 when
|
||||
/// absent, clamped to ±[`shapes::GRID_SKEW_MAX`]).
|
||||
fn dividers(p: &Value, count: &str, key: &str, cmd: &str) -> Result<(u32, f64)> {
|
||||
check_numbers(p, &[count], cmd)?;
|
||||
let n = p.get(count).and_then(Value::as_u64).unwrap_or(5).min(999) as u32;
|
||||
let whole = |v: &Value| v.as_u64().or_else(|| v.as_f64().filter(|f| f.is_finite() && *f >= 0.0 && f.fract() == 0.0).map(|f| f as u64));
|
||||
let n = p.get(count).and_then(whole).unwrap_or(5).min(999) as u32;
|
||||
let skew = match p.get(key) {
|
||||
None | Some(Value::Null) => 0.0,
|
||||
Some(v) => v.as_f64().filter(|v| v.is_finite()).ok_or_else(|| bad(cmd, format!("`{key}` must be a number")))?,
|
||||
|
||||
@@ -109,13 +109,14 @@ pub(super) fn remove_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
|
||||
let ai = usize_req(p, "anchor", C)?;
|
||||
let curvature = switch_or(p, "curvature", false, C)?;
|
||||
let removed = s.edit("Delete Anchor Point", |d, sel| {
|
||||
let sp = path_mut(d, id)?.subpaths.get(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?;
|
||||
if ai >= sp.anchors.len() {
|
||||
return Err(EngineError::Other("no such anchor".into()));
|
||||
}
|
||||
let mut emptied = Beside::replacing(d, &[id]);
|
||||
let removed = remove_anchors_of(d, sel, &mut emptied, id, [(si, ai)])?;
|
||||
let removed = remove_anchors_of(d, sel, &mut emptied, id, [(si, ai)], curvature)?;
|
||||
emptied.finish(d)?;
|
||||
Ok(removed)
|
||||
})?;
|
||||
@@ -141,7 +142,7 @@ pub(super) fn remove_anchors(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let shape: Vec<(usize, bool)> =
|
||||
d.node(id).and_then(Node::path_data).map(|p| p.subpaths.iter().map(|sp| (sp.anchors.len(), sp.closed)).collect()).unwrap_or_default();
|
||||
let next = following_anchors(&shape, &anchors);
|
||||
if remove_anchors_of(d, sel, &mut emptied, id, anchors)? {
|
||||
if remove_anchors_of(d, sel, &mut emptied, id, anchors, false)? {
|
||||
removed += 1;
|
||||
} else if !next.is_empty() {
|
||||
// The next anchor along is selected, so the next Remove Anchor Points goes on from
|
||||
@@ -180,14 +181,16 @@ fn following_anchors(shape: &[(usize, bool)], removed: &[(usize, usize)]) -> BTr
|
||||
}
|
||||
|
||||
/// Remove `anchors` (subpath, anchor) of path `id` without opening it, refitting the curve round
|
||||
/// each one. A subpath left with one anchor is removed, and a path left with no subpath is removed
|
||||
/// through `emptied`. True when the path went.
|
||||
/// each one, or with `curvature` re-curving it as the Curvature tool curves its points
|
||||
/// ([`vectorcraft_tools::draw2::curvature_remove`]). A subpath left with one anchor is removed,
|
||||
/// and a path left with no subpath is removed through `emptied`. True when the path went.
|
||||
fn remove_anchors_of(
|
||||
d: &mut Document,
|
||||
sel: &mut Selection,
|
||||
emptied: &mut Beside,
|
||||
id: NodeId,
|
||||
anchors: impl IntoIterator<Item = (usize, usize)>,
|
||||
curvature: bool,
|
||||
) -> Result<bool> {
|
||||
let mut anchors: Vec<(usize, usize)> = anchors.into_iter().collect();
|
||||
// The last first, so the indexes still to come stay valid.
|
||||
@@ -196,7 +199,11 @@ fn remove_anchors_of(
|
||||
let path = path_mut(d, id)?;
|
||||
for (si, ai) in anchors {
|
||||
if let Some(sp) = path.subpaths.get_mut(si) {
|
||||
po::remove_anchor(sp, ai);
|
||||
if curvature {
|
||||
vectorcraft_tools::draw2::curvature_remove(sp, ai);
|
||||
} else {
|
||||
po::remove_anchor(sp, ai);
|
||||
}
|
||||
}
|
||||
}
|
||||
path.subpaths.retain(|sp| sp.anchors.len() >= 2);
|
||||
|
||||
@@ -38,7 +38,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Delete Anchor Point",
|
||||
[],
|
||||
None,
|
||||
"{id, subpath?, anchor} remove one anchor, re-fitting the curve",
|
||||
"{id, subpath?, anchor, curvature?} remove one anchor, re-fitting the curve (with curvature: true, the curve where it was follows the remaining anchors, as the Curvature tool curves its points)",
|
||||
has_doc,
|
||||
remove_anchor
|
||||
),
|
||||
@@ -128,7 +128,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Blob Brush",
|
||||
[],
|
||||
None,
|
||||
"{points: [[x,y]…], size?: pt (10), merge?: true, mergeOnlySelection?: bool (false: merge with any touching same-color blob; true: only with selected ones), keepSelected?: bool (false: the drawn or merged path is not selected, and the rest of the selection stays; true: it is selected)} filled brush shape, merged with touching same-colour blobs → {id}",
|
||||
"{points: [[x,y]…], size?: pt (10), fidelity?: pt (0.01–100; the brush follows the stroke fitted at this tolerance as path.freehand fits a Pencil stroke, and a higher value gives a smoother shape; without it the brush follows every point), merge?: true, mergeOnlySelection?: bool (false: merge with any touching same-color blob; true: only with selected ones), keepSelected?: bool (false: the drawn or merged path is not selected, and the rest of the selection stays; true: it is selected)} filled brush shape, merged with the topmost touching same-color blob and the touching ones next to it in stacking order in its group or layer → {id}",
|
||||
has_doc,
|
||||
blob
|
||||
),
|
||||
|
||||
@@ -168,6 +168,33 @@ pub(super) fn target_paths(doc: &Document, sel: &Selection, area: Rect) -> Vec<N
|
||||
out
|
||||
}
|
||||
|
||||
/// [`target_paths`] for the tools that cut fills, with each target's geometry and fill rule: the
|
||||
/// members of a compound path whose members are all closed paths give the compound path itself,
|
||||
/// once, so its fill (holes included) is cut as one shape under its own fill rule.
|
||||
pub(super) fn fill_targets(doc: &Document, sel: &Selection, area: Rect) -> Result<Vec<(NodeId, PathData, FillRule)>> {
|
||||
let closed_compound = |n: &Node| {
|
||||
matches!(&n.kind, NodeKind::Compound { children, .. }
|
||||
if children.iter().all(|c| matches!(&c.kind, NodeKind::Path { path, .. } if path.is_closed())))
|
||||
};
|
||||
let editable = doc.editable();
|
||||
let mut out: Vec<(NodeId, PathData, FillRule)> = vec![];
|
||||
for id in target_paths(doc, sel, area) {
|
||||
let compound = doc.parent_of(id).and_then(|p| doc.node(p)).filter(|p| closed_compound(p) && editable(p.id));
|
||||
let target = compound.map_or(id, |c| c.id);
|
||||
if out.iter().any(|t| t.0 == target) {
|
||||
continue;
|
||||
}
|
||||
let (pd, rule) = match compound.map(|c| &c.kind) {
|
||||
Some(NodeKind::Compound { children, rule }) => {
|
||||
(PathData::new(children.iter().filter_map(|c| c.path_data()).flat_map(|p| p.subpaths.clone()).collect()), *rule)
|
||||
}
|
||||
_ => path_of(doc, id)?,
|
||||
};
|
||||
out.push((target, pd, rule));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The members of a compound path with geometry `pd`: one path per subpath, with fresh ids.
|
||||
fn compound_members(d: &mut Document, pd: PathData) -> Vec<Arc<Node>> {
|
||||
pd.subpaths.into_iter().map(|sp| Arc::new(Node::path(d.alloc_id(), PathData::single(sp), Appearance::default()))).collect()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::common::{
|
||||
dist_to_polyline, ids_json, path_mut, path_of, points_param, poly_bounds, replace_with_pieces, sibling_with, target_paths, usize_opt,
|
||||
dist_to_polyline, fill_targets, ids_json, path_mut, path_of, points_param, poly_bounds, replace_with_pieces, sibling_with, target_paths,
|
||||
usize_opt,
|
||||
};
|
||||
use super::{
|
||||
Anchor, AnchorKind, Beside, BezPath, BoolOp, Cap, EngineError, FillRule, Join, NodeId, ParamCurve, PathData, Point, Result, Session, SubPath,
|
||||
@@ -236,8 +237,7 @@ pub(super) fn knife(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let strip = po::outline_stroke(&PathData::single(SubPath::polyline(&pts, false)), KNIFE_GAP, Cap::Butt, Join::Round, 4.0);
|
||||
let mut plan: Vec<(NodeId, Vec<PathData>)> = vec![];
|
||||
for id in target_paths(&st.doc, &st.selection, poly_bounds(&pts, 1.0)) {
|
||||
let (pd, rule) = path_of(&st.doc, id)?;
|
||||
for (id, pd, rule) in fill_targets(&st.doc, &st.selection, poly_bounds(&pts, 1.0))? {
|
||||
if !pd.is_closed() {
|
||||
continue;
|
||||
}
|
||||
@@ -281,8 +281,7 @@ pub(super) fn erase_region(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let squash = eraser_tip(angle, roundness).inverse();
|
||||
let round: Vec<Point> = pts.iter().map(|q| squash * *q).collect();
|
||||
let mut plan: Vec<(NodeId, Vec<PathData>)> = vec![];
|
||||
for id in target_paths(&st.doc, &st.selection, poly_bounds(&pts, r)) {
|
||||
let (pd, rule) = path_of(&st.doc, id)?;
|
||||
for (id, pd, rule) in fill_targets(&st.doc, &st.selection, poly_bounds(&pts, r))? {
|
||||
if pd.is_closed() {
|
||||
if po::area(&po::boolean(&pd, rule, ®ion, FillRule::NonZero, BoolOp::Intersect), FillRule::NonZero) <= 1e-6 {
|
||||
continue;
|
||||
|
||||
@@ -3,8 +3,9 @@ use super::common::{
|
||||
within_input_bound,
|
||||
};
|
||||
use super::{
|
||||
Anchor, Appearance, BTreeSet, BoolOp, Color, EngineError, FillRule, NewArt, NodeId, NodeKind, Paint, PathData, Point, PressureProfile, Result,
|
||||
Selection, Session, SimplifyOptions, SubPath, Value, bad, id_param, insert_look, json, number_or, po, remove_each, str_param, switch_or,
|
||||
Anchor, Appearance, BTreeSet, BoolOp, Cap, Color, Document, EngineError, FillRule, Join, NewArt, Node, NodeId, NodeKind, Paint, PathData, Point,
|
||||
PressureProfile, Result, Selection, Session, SimplifyOptions, SubPath, Value, bad, id_param, insert_look, json, number_or, po, remove_each,
|
||||
str_param, switch_or,
|
||||
};
|
||||
|
||||
// ---------- Pencil / Paintbrush ----------
|
||||
@@ -336,6 +337,35 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
// ---------- Blob Brush ----------
|
||||
|
||||
/// Whether `n` is a path a Blob Brush stroke of `paint` can merge with: the same fill and no stroke.
|
||||
fn blob_match(n: &Node, paint: &Paint) -> bool {
|
||||
matches!(n.kind, NodeKind::Path { guide: false, .. }) && n.appearance.fill_paint() == *paint && n.appearance.stroke_paint().is_none()
|
||||
}
|
||||
|
||||
/// The paths of `touched` that a Blob Brush stroke of `paint` merges with: the topmost of them, and
|
||||
/// the others in the same group or layer that are adjacent to it in stacking order, with no object
|
||||
/// between them other than a path of the same fill and no stroke.
|
||||
fn adjacent_to_topmost(doc: &Document, touched: &[NodeId], paint: &Paint) -> Vec<NodeId> {
|
||||
let Some(top) = doc.paint_order(touched.iter().copied()).last().copied() else {
|
||||
return vec![];
|
||||
};
|
||||
let Some((parent, at, _)) = doc.position(top) else {
|
||||
return vec![top];
|
||||
};
|
||||
let Some(siblings) = doc.children(parent) else {
|
||||
return vec![top];
|
||||
};
|
||||
let mut out = vec![top];
|
||||
for n in siblings.iter().take(at).rev() {
|
||||
if touched.contains(&n.id) {
|
||||
out.push(n.id);
|
||||
} else if !blob_match(n, paint) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub(super) fn blob(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.blob";
|
||||
let pts = points_param(p, "points", C)?;
|
||||
@@ -343,7 +373,18 @@ pub(super) fn blob(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !(size.is_finite() && size > 0.0) {
|
||||
return Err(bad(C, "size must be positive"));
|
||||
}
|
||||
let region = brush_region(&pts, size);
|
||||
// With `fidelity` (pt), the brush follows the stroke fitted by [`fit_freehand`], as a Pencil
|
||||
// stroke is fitted. Without it, or when the fitted stroke has no length (a short stroke back to
|
||||
// its start), the brush follows every point.
|
||||
let fidelity = match p.get("fidelity") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(_) => Some(number_or(p, "fidelity", 0.0, C)?.clamp(vectorcraft_tools::draw2::MIN_FIDELITY, 100.0)),
|
||||
};
|
||||
let fitted = fidelity
|
||||
.filter(|_| pts.len() > 1)
|
||||
.map(|tol| po::outline_stroke(&PathData::single(fit_freehand(&pts, tol, false)), size, Cap::Round, Join::Round, 4.0))
|
||||
.filter(|r| !r.is_empty());
|
||||
let region = fitted.unwrap_or_else(|| brush_region(&pts, size));
|
||||
if region.is_empty() {
|
||||
return Err(EngineError::Other("empty brush stroke".into()));
|
||||
}
|
||||
@@ -362,9 +403,11 @@ pub(super) fn blob(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if merge && !(only_selection && st.selection.is_empty()) {
|
||||
let all = Selection::default();
|
||||
let from = if only_selection { &st.selection } else { &all };
|
||||
for id in target_paths(&st.doc, from, poly_bounds(&pts, size)) {
|
||||
// A fitted stroke can curve outside the points' bounds, so the area also takes in the shape.
|
||||
let area = region.control_bounds().map_or_else(|| poly_bounds(&pts, size), |b| b.union(poly_bounds(&pts, size)));
|
||||
for id in target_paths(&st.doc, from, area) {
|
||||
let Some(n) = st.doc.node(id) else { continue };
|
||||
if n.appearance.fill_paint() != paint || !n.appearance.stroke_paint().is_none() {
|
||||
if !blob_match(n, &paint) {
|
||||
continue;
|
||||
}
|
||||
let (pd, rule) = path_of(&st.doc, id)?;
|
||||
@@ -372,6 +415,8 @@ pub(super) fn blob(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
merge_ids.push((id, pd, rule));
|
||||
}
|
||||
}
|
||||
let joining = adjacent_to_topmost(&st.doc, &merge_ids.iter().map(|m| m.0).collect::<Vec<_>>(), &paint);
|
||||
merge_ids.retain(|m| joining.contains(&m.0));
|
||||
}
|
||||
let ap = NewArt::plain(Appearance::basic(paint, Paint::None, 1.0));
|
||||
if merge_ids.is_empty() {
|
||||
@@ -380,11 +425,10 @@ pub(super) fn blob(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut inputs: Vec<(&PathData, FillRule)> = vec![(®ion, FillRule::NonZero)];
|
||||
inputs.extend(merge_ids.iter().map(|(_, pd, r)| (pd, *r)));
|
||||
let merged = po::try_unite_all(&inputs).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let Some(((keep, _, _), rest)) = merge_ids.split_last() else {
|
||||
let Some(keep) = st.doc.paint_order(merge_ids.iter().map(|m| m.0)).last().copied() else {
|
||||
return insert_look(s, "Blob Brush", path_kind(region), ap, None, keep_selected);
|
||||
};
|
||||
let keep = *keep;
|
||||
let others: Vec<NodeId> = rest.iter().map(|m| m.0).collect();
|
||||
let others: Vec<NodeId> = merge_ids.iter().map(|m| m.0).filter(|id| *id != keep).collect();
|
||||
s.edit("Blob Brush", |d, sel| {
|
||||
*path_mut(d, keep)? = merged;
|
||||
remove_each(d, &others)?;
|
||||
|
||||
@@ -42,6 +42,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
set_units
|
||||
),
|
||||
cmd!(
|
||||
"document.switchUnits",
|
||||
"Switch Units",
|
||||
[],
|
||||
Some("Cmd+Alt+Shift+U"),
|
||||
"{} the document's units become the next of Points, Picas, Inches, Millimeters, Centimeters, Pixels, Feet & Inches, Meters, Yards and Feet (the order a right-click on a ruler lists them), Feet going back to Points; one undo step → {units}",
|
||||
has_doc,
|
||||
switch_units
|
||||
),
|
||||
cmd!(
|
||||
query "document.rulerOrigin",
|
||||
"Ruler Origin",
|
||||
@@ -332,6 +341,14 @@ fn set_units(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Switch Units: the unit after the document's in [`Unit::ALL`], wrapping around.
|
||||
fn switch_units(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let now = s.doc()?.doc.units;
|
||||
let next = Unit::ALL.iter().position(|u| *u == now).and_then(|i| Unit::ALL.get((i + 1) % Unit::ALL.len())).copied().unwrap_or_default();
|
||||
s.set_document_units(next)?;
|
||||
Ok(json!({ "units": next.label() }))
|
||||
}
|
||||
|
||||
/// The `artboard` param of the ruler origin commands: an artboard index, 0 when not given.
|
||||
fn artboard_param(s: &Session, p: &Value, c: &str) -> Result<usize> {
|
||||
let index = match p.get("artboard") {
|
||||
|
||||
@@ -131,7 +131,11 @@ impl Expandable {
|
||||
e.placed |= matches!(c.kind, NodeKind::PlacedDocument(_));
|
||||
e.repeats |= matches!(c.kind, NodeKind::Repeat(_));
|
||||
e.intertwines |= matches!(c.kind, NodeKind::Intertwine { .. });
|
||||
e.stroke |= shape && !c.appearance.stroke_paint().is_none();
|
||||
e.stroke |= match &c.kind {
|
||||
// Type's outlines take its characters' stroke (see `type.createOutlines`).
|
||||
NodeKind::Text(t) => t.runs.iter().any(|r| r.style.has_stroke()),
|
||||
_ => shape && !c.appearance.stroke_paint().is_none(),
|
||||
};
|
||||
e.gradient |= match &c.kind {
|
||||
NodeKind::Text(t) => t.runs.iter().any(|r| matches!(r.style.fill, Paint::Gradient(_))),
|
||||
_ => shape && c.appearance.items.iter().any(|i| gradient_fill(i).is_some()),
|
||||
|
||||
@@ -27,15 +27,19 @@ pub(super) fn batch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let active = s.active;
|
||||
s.batch_stash = Some(vec![]);
|
||||
let mut grouped = Vec::with_capacity(cmds.len());
|
||||
// The Type tool keeps what the steps' Undo and Redo change in it, to put back on a rollback.
|
||||
s.notify_text_tool("batch.begin");
|
||||
let r = run_steps(s, &label, artboard, &mut cmds, &mut grouped);
|
||||
let stash = s.batch_stash.take().unwrap_or_default();
|
||||
let results = match r {
|
||||
Ok(results) => {
|
||||
s.notify_text_tool("batch.end");
|
||||
s.batch_steps = Some(grouped);
|
||||
results
|
||||
}
|
||||
Err(e) => {
|
||||
roll_back(s, start, &stash);
|
||||
s.notify_text_tool("batch.rollBack");
|
||||
s.active = active;
|
||||
(s.paint, s.fill_active, s.draw_mode, s.draw_inside, s.clipboard, s.untitled_counter, s.explicit_undo_group) = saved;
|
||||
s.pending_paint = None;
|
||||
|
||||
@@ -54,7 +54,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Export for Screens",
|
||||
["File", "Export"],
|
||||
None,
|
||||
"{folder?, zip?: false (one store-only .zip of every file: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), 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), 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) → {files: [path…]}; 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)",
|
||||
"{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}), 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), 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) → {files: [path…]}; 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)",
|
||||
has_doc,
|
||||
screens::export_for_screens
|
||||
),
|
||||
|
||||
@@ -17,6 +17,7 @@ use vectorcraft_render::encode::{RasterExportOptions, RasterFormat, bmp, psd, tg
|
||||
use super::super::{EngineError, Result, bad, color_value, parse_range};
|
||||
use super::Format;
|
||||
use super::imagemap::{self, MapKind};
|
||||
use super::screens::ScreenSize;
|
||||
|
||||
const C: &str = "document.export";
|
||||
|
||||
@@ -147,6 +148,10 @@ struct RasterOptions {
|
||||
scale: Option<f64>,
|
||||
/// Pixels per inch (wins over `scale`).
|
||||
ppi: Option<f64>,
|
||||
/// The width of each file in pixels (wins over `height`, `ppi` and `scale`).
|
||||
width: Option<f64>,
|
||||
/// The height of each file in pixels (wins over `ppi` and `scale`).
|
||||
height: Option<f64>,
|
||||
quality: Option<u8>,
|
||||
background: Option<Value>,
|
||||
anti_alias: Option<String>,
|
||||
@@ -179,7 +184,24 @@ struct RasterOptions {
|
||||
rich_black: Option<bool>,
|
||||
}
|
||||
|
||||
/// `ppi` with its scale kept within 0.01–64 pixels per point.
|
||||
fn clamped_ppi(ppi: f64) -> f64 {
|
||||
(ppi / 72.0).clamp(0.01, 64.0) * 72.0
|
||||
}
|
||||
|
||||
impl RasterOptions {
|
||||
/// The pixel size each file is scaled to: `width`, else `height`; `None` when neither is set.
|
||||
fn pixel_size(&self) -> Result<Option<ScreenSize>> {
|
||||
let checked = |key: &str, n: f64| {
|
||||
if n.is_finite() && n > 0.0 { Ok(n) } else { Err(bad(C, format!("{key} must be a positive number, not {n}"))) }
|
||||
};
|
||||
match (self.width, self.height) {
|
||||
(Some(w), _) => checked("width", w).map(|w| Some(ScreenSize::Width(w))),
|
||||
(None, Some(h)) => checked("height", h).map(|h| Some(ScreenSize::Height(h))),
|
||||
(None, None) => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// The render and encoder settings: `ppi`, else 72 × `scale`, within 0.01–64 pixels per point;
|
||||
/// `background`, else `page` (the document's background).
|
||||
fn settings(&self, page: Option<[u8; 3]>) -> Result<RasterExportOptions> {
|
||||
@@ -190,7 +212,7 @@ impl RasterOptions {
|
||||
let color_model = parse(self.color_model.as_deref(), jpeg::ColorModel::from_id, "colorModel", "rgb, cmyk or gray")?;
|
||||
let embed_icc = self.embed_icc.unwrap_or(true);
|
||||
Ok(RasterExportOptions {
|
||||
ppi: (ppi / 72.0).clamp(0.01, 64.0) * 72.0,
|
||||
ppi: clamped_ppi(ppi),
|
||||
background: match &self.background {
|
||||
Some(v) => background(v).map_err(|e| bad(C, e))?,
|
||||
None => page,
|
||||
@@ -519,8 +541,8 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result<Encoded> {
|
||||
// New Document → Background Contents: White makes the export opaque, unless `background`
|
||||
// says otherwise (JPEG has no alpha: white either way).
|
||||
let page = (doc.setup.background == vectorcraft_doc::Background::White).then_some([255; 3]);
|
||||
let settings = o.settings(page)?;
|
||||
let scale = settings.scale();
|
||||
let mut settings = o.settings(page)?;
|
||||
let size = o.pixel_size()?;
|
||||
let format = match f.id {
|
||||
"png" => RasterFormat::Png,
|
||||
"jpg" => RasterFormat::Jpeg,
|
||||
@@ -550,6 +572,11 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result<Encoded> {
|
||||
vectorcraft_doc::cancel::check()?;
|
||||
vectorcraft_doc::cancel::progress(done as u64, total, &format!("Rendering artboard {} of {total}", done + 1));
|
||||
let region = doc.artboards.get(b).ok_or_else(|| bad(C, format!("no artboard {}", b + 1)))?.rect;
|
||||
// `width` or `height`: each file gets the scale that makes its region that size.
|
||||
if let Some(size) = size {
|
||||
settings.ppi = clamped_ppi(72.0 * size.scale_for(C, region)?);
|
||||
}
|
||||
let scale = settings.scale();
|
||||
check_format_size(f, region.width() * scale, region.height() * scale)?;
|
||||
vectorcraft_render::raster_size(region, scale).map_err(|e| bad(C, e))?;
|
||||
enc.maps.extend(imagemap::build(doc, region, scale, map, enc.files.len()));
|
||||
|
||||
@@ -75,6 +75,20 @@ pub(super) const RANGE: FormatOption = FormatOption {
|
||||
|
||||
const SCALE: FormatOption = FormatOption { name: "scale", ty: "number", default: "1", description: "pixels per point (0.01–64)" };
|
||||
|
||||
const WIDTH: FormatOption = FormatOption {
|
||||
name: "width",
|
||||
ty: "number",
|
||||
default: "null",
|
||||
description: "the width of each file in pixels: each artboard, rect or art bounds gets its own scale (0.01–64 pixels per point, stored in the file except in GIF, WebP and Targa); wins over height, ppi and scale",
|
||||
};
|
||||
|
||||
const HEIGHT: FormatOption = FormatOption {
|
||||
name: "height",
|
||||
ty: "number",
|
||||
default: "null",
|
||||
description: "the height of each file in pixels, as width; wins over ppi and scale",
|
||||
};
|
||||
|
||||
const QUALITY: FormatOption =
|
||||
FormatOption { name: "quality", ty: "integer", default: "90", description: "JPEG quality 0–100 (the JPEG Options dialog shows 0–10)" };
|
||||
|
||||
@@ -96,7 +110,7 @@ const PPI: FormatOption = FormatOption {
|
||||
name: "ppi",
|
||||
ty: "number",
|
||||
default: "72",
|
||||
description: "resolution in pixels per inch (72 = one pixel per point; 150, 300…), stored in the file; wins over scale",
|
||||
description: "resolution in pixels per inch (72 = one pixel per point; 150, 300…), stored in the file except in GIF, WebP and Targa; wins over scale",
|
||||
};
|
||||
|
||||
const BACKGROUND: FormatOption = FormatOption {
|
||||
@@ -198,6 +212,8 @@ const PALETTE_OPTIONS: &[FormatOption] = &[
|
||||
RECT,
|
||||
PPI,
|
||||
SCALE,
|
||||
WIDTH,
|
||||
HEIGHT,
|
||||
BACKGROUND,
|
||||
ANTI_ALIAS,
|
||||
INTERLACED,
|
||||
@@ -219,7 +235,7 @@ const BYTE_ORDER: FormatOption = FormatOption {
|
||||
};
|
||||
|
||||
const TIFF_OPTIONS: &[FormatOption] =
|
||||
&[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, RECT, PPI, SCALE, BACKGROUND, ANTI_ALIAS, COLOR_MODEL, LZW, BYTE_ORDER, EMBED_ICC];
|
||||
&[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, RECT, PPI, SCALE, WIDTH, HEIGHT, BACKGROUND, ANTI_ALIAS, COLOR_MODEL, LZW, BYTE_ORDER, EMBED_ICC];
|
||||
|
||||
const BMP_COLOR_MODEL: FormatOption =
|
||||
FormatOption { name: "colorModel", ty: "string", default: "\"rgb\"", description: "rgb or gray (greys at every depth)" };
|
||||
@@ -256,6 +272,8 @@ const BMP_OPTIONS: &[FormatOption] = &[
|
||||
RECT,
|
||||
PPI,
|
||||
SCALE,
|
||||
WIDTH,
|
||||
HEIGHT,
|
||||
BACKGROUND,
|
||||
ANTI_ALIAS,
|
||||
BMP_COLOR_MODEL,
|
||||
@@ -275,7 +293,7 @@ const TGA_DEPTH: FormatOption = FormatOption {
|
||||
description: "bits per pixel: 16 (one-bit alpha), 24 (flattened on white) or 32 (keeps transparency)",
|
||||
};
|
||||
|
||||
const TGA_OPTIONS: &[FormatOption] = &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, RECT, PPI, SCALE, BACKGROUND, ANTI_ALIAS, TGA_DEPTH];
|
||||
const TGA_OPTIONS: &[FormatOption] = &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, RECT, PPI, SCALE, WIDTH, HEIGHT, BACKGROUND, ANTI_ALIAS, TGA_DEPTH];
|
||||
|
||||
const PSD_LAYERS: FormatOption = FormatOption {
|
||||
name: "layers",
|
||||
@@ -306,6 +324,8 @@ const PSD_OPTIONS: &[FormatOption] = &[
|
||||
RECT,
|
||||
PPI,
|
||||
SCALE,
|
||||
WIDTH,
|
||||
HEIGHT,
|
||||
BACKGROUND,
|
||||
ANTI_ALIAS,
|
||||
COLOR_MODEL,
|
||||
@@ -374,7 +394,7 @@ pub const FORMATS: &[Format] = &[
|
||||
read: true,
|
||||
write: true,
|
||||
raster: true,
|
||||
options: &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, RECT, PPI, SCALE, BACKGROUND, ANTI_ALIAS, INTERLACED, EMBED_ICC],
|
||||
options: &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, RECT, PPI, SCALE, WIDTH, HEIGHT, BACKGROUND, ANTI_ALIAS, INTERLACED, EMBED_ICC],
|
||||
},
|
||||
Format {
|
||||
id: "jpg",
|
||||
@@ -392,6 +412,8 @@ pub const FORMATS: &[Format] = &[
|
||||
RECT,
|
||||
PPI,
|
||||
SCALE,
|
||||
WIDTH,
|
||||
HEIGHT,
|
||||
BACKGROUND,
|
||||
ANTI_ALIAS,
|
||||
QUALITY,
|
||||
@@ -411,7 +433,7 @@ pub const FORMATS: &[Format] = &[
|
||||
read: true,
|
||||
write: true,
|
||||
raster: true,
|
||||
options: &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, RECT, PPI, SCALE, BACKGROUND, ANTI_ALIAS, LOSSLESS, WEBP_QUALITY],
|
||||
options: &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, RECT, PPI, SCALE, WIDTH, HEIGHT, BACKGROUND, ANTI_ALIAS, LOSSLESS, WEBP_QUALITY],
|
||||
},
|
||||
Format {
|
||||
id: "tiff",
|
||||
|
||||
@@ -95,9 +95,9 @@ impl ScreenSize {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pixels per point for `region`.
|
||||
fn scale_for(self, region: Rect) -> Result<f64> {
|
||||
let per = |px: f64, pt: f64| (pt > 0.0).then(|| px / pt).ok_or_else(|| bad(C, "an empty artboard has no width or height to scale to"));
|
||||
/// Pixels per point for `region` (`cmd` names the command in the error).
|
||||
pub(super) fn scale_for(self, cmd: &str, region: Rect) -> Result<f64> {
|
||||
let per = |px: f64, pt: f64| (pt > 0.0).then(|| px / pt).ok_or_else(|| bad(cmd, "an empty artboard has no width or height to scale to"));
|
||||
match self {
|
||||
Self::Width(w) => per(w, region.width()),
|
||||
Self::Height(h) => per(h, region.height()),
|
||||
@@ -264,7 +264,7 @@ impl Row {
|
||||
// The resolution is stored in the file too.
|
||||
ScreenSize::Ppi(ppi) => o["ppi"] = json!(ppi),
|
||||
size => {
|
||||
o["scale"] = json!(size.scale_for(region)?);
|
||||
o["scale"] = json!(size.scale_for(C, region)?);
|
||||
if let Some(m) = o.as_object_mut() {
|
||||
m.remove("ppi");
|
||||
}
|
||||
@@ -818,14 +818,21 @@ fn encode_and_write(export: ScreenExport, writer: &mut dyn FnMut(&str, &[u8]) ->
|
||||
files += 1;
|
||||
}
|
||||
}
|
||||
// A cancel that came while the last file was encoded returns no files. A folder export
|
||||
// without a ZIP has written every file by now, so it returns them.
|
||||
if o.zip || folder.is_none() {
|
||||
vectorcraft_doc::cancel::check()?;
|
||||
}
|
||||
let names: Vec<&str> = out.kept.iter().map(|(n, _)| n.as_str()).collect();
|
||||
if o.zip {
|
||||
let zip = super::zip::store(&out.kept).map_err(|e| bad(C, e))?;
|
||||
let zip = super::zip::deflate(&out.kept, super::zip::FIXED_TIME, vectorcraft_doc::cancel::is_cancelled);
|
||||
// A cancel that came while the files were deflated returns and writes no archive.
|
||||
vectorcraft_doc::cancel::check()?;
|
||||
let zip = zip.map_err(|e| bad(C, e))?;
|
||||
let zip_name = safe_file_name(&format!("{}{}.zip", naming.prefix, naming.filename));
|
||||
return match folder {
|
||||
Some(dir) => {
|
||||
let path = format!("{dir}/{zip_name}");
|
||||
vectorcraft_doc::cancel::check()?;
|
||||
writer(&path, &zip)?;
|
||||
Ok(json!({ "path": path, "bytes": zip.len(), "files": names, "warnings": warnings }))
|
||||
}
|
||||
|
||||
@@ -82,16 +82,18 @@ fn an_illustrator_ai_file_opens_from_its_editing_data() {
|
||||
assert_ne!(names(&s.doc().unwrap().doc.layers), ["Art", "Hidden"]);
|
||||
}
|
||||
|
||||
/// A palette of three swatches and a color group `Inks` holding a fourth.
|
||||
const INKS_PALETTE: &str = "%AI5_BeginPalette\n0 0 Pb\n0 0 0 0 1 1 1 Xa\n(White)\nPc\n0.04 0.3 0 0 81.96 21 -8 (Pink ink) 0 2 Xx\n(Pink ink)\nPc\n\
|
||||
0.06 0.98 1 0 (Red ink) 0 x\n(Red ink)\nPc\n1 (Inks) 1 Pg\n0.02 0.1 1 0 (Yellow ink) 0 x\n(Yellow ink)\nPc\nPB\n%AI5_EndPalette\n";
|
||||
|
||||
/// A `.ai` file opens with the swatches its palette holds, spot colours the art doesn't use and colour
|
||||
/// groups included, in place of the default swatches; art painted with Registration (`XZ`) still
|
||||
/// opens from the editing data.
|
||||
#[test]
|
||||
fn an_ai_file_opens_with_the_swatches_of_its_palette() {
|
||||
let palette = "%AI5_BeginPalette\n0 0 Pb\n0 0 0 0 1 1 1 Xa\n(White)\nPc\n0.04 0.3 0 0 81.96 21 -8 (Pink ink) 0 2 Xx\n(Pink ink)\nPc\n\
|
||||
0.06 0.98 1 0 (Red ink) 0 x\n(Red ink)\nPc\n1 (Inks) 1 Pg\n0.02 0.1 1 0 (Yellow ink) 0 x\n(Yellow ink)\nPc\nPB\n%AI5_EndPalette\n";
|
||||
// The square the page draws, in the red ink, after a stroke colour of Registration.
|
||||
let art = format!("0 0 0 0 0 0 0 ([Registration]) 0 1 XZ\n0.06 0.98 1 0 (Red ink) 0 x\n{}f\n", ai::rect(10.0, 50.0, 40.0, 90.0));
|
||||
let data = ai::editing_data(200.0, 100.0, &format!("{palette}{}", ai::layer("Art", &art)));
|
||||
let data = ai::editing_data(200.0, 100.0, &format!("{INKS_PALETTE}{}", ai::layer("Art", &art)));
|
||||
let mut s = Session::new();
|
||||
let r = open(&mut s, "inks.ai", &ai::ai(&compressed(data.as_bytes()), "1 0 0 rg 10 50 30 40 re f"), json!({})).unwrap();
|
||||
assert_eq!(r["format"], json!("ai"), "{r}");
|
||||
@@ -113,6 +115,45 @@ fn an_ai_file_opens_with_the_swatches_of_its_palette() {
|
||||
);
|
||||
}
|
||||
|
||||
/// A `.ai` whose editing data has a palette and no layers, as a swatch library is saved, opens as its
|
||||
/// PDF part with the swatches and color groups of its palette in place of the default swatches.
|
||||
#[test]
|
||||
fn an_ai_file_without_layers_opens_with_the_swatches_of_its_palette() {
|
||||
let data = ai::editing_data(200.0, 100.0, INKS_PALETTE);
|
||||
let mut s = Session::new();
|
||||
let r = open(&mut s, "inks.ai", &ai::ai(&compressed(data.as_bytes()), "1 0 0 rg 10 50 30 40 re f"), json!({})).unwrap();
|
||||
assert_eq!(r["format"], json!("ai"), "{r}");
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
let swatches: Vec<&str> = doc.swatches.iter().map(|s| s.name.as_str()).collect();
|
||||
assert_eq!(swatches, ["[None]", "White", "Pink ink", "Red ink"], "{r}");
|
||||
let groups: Vec<&str> = doc.swatch_groups.iter().map(|g| g.name.as_str()).collect();
|
||||
assert_eq!(groups, ["Inks"]);
|
||||
assert!(doc.swatch("Yellow ink").is_some_and(|s| s.spot));
|
||||
}
|
||||
|
||||
/// Other Library… on a `.ai` swatch library (a palette and no layers) gives the library the swatches
|
||||
/// of its palette, a gradient swatch among them, not the default swatches.
|
||||
#[test]
|
||||
fn a_swatch_library_in_a_ai_file_without_layers_loads_its_palette() {
|
||||
let gradient =
|
||||
"%AI5_BeginGradient: (Sky)\n(Sky) 0 2 Bd\n[\n%_0 0 0 0 1 1 1 2 1 6 50 0 Bs\n%_0 0 0 1 0 0 0 2 0.5 6 50 100 Bs\nBD\n%AI5_EndGradient\n";
|
||||
let palette = INKS_PALETTE.replace("0 0 Pb\n", "0 0 Pb\nBb\n2 (Sky) 0 0 0 1 1 0 0 1 0 0 1 Bg\n0 BB\n(Sky)\nPc\n");
|
||||
let data = ai::editing_data(200.0, 100.0, &format!("{gradient}{palette}"));
|
||||
let file = ai::ai(&compressed(data.as_bytes()), "1 0 0 rg 10 50 30 40 re f");
|
||||
let mut s = Session::new();
|
||||
let p = json!({"dataBase64": vectorcraft_format::base64_encode(&file), "name": "inks.ai"});
|
||||
let r = s.execute("swatch.library.load", &p).unwrap();
|
||||
assert_eq!(r["count"], json!(5), "{r}");
|
||||
let lib = s.execute("swatch.library.get", &json!({"library": r["library"]})).unwrap();
|
||||
let swatches: Vec<&str> = lib["swatches"].as_array().unwrap().iter().map(|w| w["name"].as_str().unwrap()).collect();
|
||||
assert_eq!(swatches, ["Sky", "White", "Pink ink", "Red ink", "Yellow ink"], "{lib}");
|
||||
assert_eq!(lib["groups"][0]["name"], json!("Inks"), "{lib}");
|
||||
let doc = s.execute("document.open", &json!({"name": "inks.ai", "dataBase64": p["dataBase64"]}));
|
||||
assert!(doc.is_ok(), "{doc:?}");
|
||||
let sky = s.doc().unwrap().doc.swatch("Sky").map(|w| w.paint.clone());
|
||||
assert!(matches!(sky, Some(vectorcraft_color::Paint::Gradient(_))), "{sky:?}");
|
||||
}
|
||||
|
||||
/// A 1-bit RGB image opens from the editing data in the colors its PDF part shows.
|
||||
#[test]
|
||||
fn a_one_bit_rgb_image_opens_from_the_editing_data_in_its_colors() {
|
||||
|
||||
@@ -179,7 +179,7 @@ fn formats_list_the_raster_options() {
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("document.formats", &json!({})).unwrap();
|
||||
let opts = |id: &str| r["formats"].as_array().unwrap().iter().find(|f| f["id"] == id).unwrap()["options"].clone();
|
||||
for k in ["ppi", "scale", "background", "antiAlias", "interlaced"] {
|
||||
for k in ["ppi", "scale", "width", "height", "background", "antiAlias", "interlaced"] {
|
||||
assert!(opts("png").get(k).is_some(), "png lists {k}");
|
||||
}
|
||||
assert_eq!(opts("png")["antiAlias"]["default"], "art");
|
||||
@@ -334,3 +334,64 @@ fn export_rect_refuses_what_cannot_be_exported() {
|
||||
assert!(s.execute("document.export", &p).is_err(), "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn width_or_height_sets_the_pixel_size_of_a_raster_export() {
|
||||
let mut s = session(200.0, 100.0, 1);
|
||||
let size = |s: &mut Session, p: Value| rgba(&b64(&s.execute("document.export", &p).unwrap())).dimensions();
|
||||
assert_eq!(size(&mut s, json!({"format": "png", "width": 512})), (512, 256));
|
||||
assert_eq!(size(&mut s, json!({"format": "png", "height": 128})), (256, 128));
|
||||
assert_eq!(size(&mut s, json!({"format": "png", "width": 512, "height": 50})), (512, 256), "width wins over height");
|
||||
assert_eq!(size(&mut s, json!({"format": "png", "width": 512, "scale": 4, "ppi": 300})), (512, 256), "width wins over scale and ppi");
|
||||
assert_eq!(size(&mut s, json!({"format": "png", "height": 50, "ppi": 300})), (100, 50), "height wins over ppi");
|
||||
assert_eq!(size(&mut s, json!({"format": "webp", "width": 400, "height": null})), (400, 200), "a null height is no height");
|
||||
assert_eq!(size(&mut s, json!({"format": "png", "width": 1})), (2, 1), "the scale stays at 0.01 pixels per point or more");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_width_or_height_that_is_not_a_positive_number_is_refused() {
|
||||
let mut s = session(200.0, 100.0, 1);
|
||||
for (p, msg) in [
|
||||
(json!({"width": 0}), "width must be a positive number, not 0"),
|
||||
(json!({"width": -1}), "width must be a positive number, not -1"),
|
||||
(json!({"height": -2.5}), "height must be a positive number, not -2.5"),
|
||||
(json!({"width": -1, "height": 100}), "width must be a positive number, not -1"),
|
||||
] {
|
||||
let mut p = p.clone();
|
||||
p["format"] = json!("png");
|
||||
let e = s.execute("document.export", &p).unwrap_err().to_string();
|
||||
assert!(e.contains(msg), "{p}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_artboard_file_gets_its_own_scale_for_a_width() {
|
||||
let mut s = session(200.0, 100.0, 1);
|
||||
s.execute("artboard.new", &json!({"x": 300, "y": 0, "width": 100, "height": 400})).unwrap();
|
||||
let r = s.execute("document.export", &json!({"format": "png", "useArtboards": true, "width": 512})).unwrap();
|
||||
let sizes: Vec<(u32, u32)> = r["files"].as_array().unwrap().iter().map(|f| rgba(&b64(f)).dimensions()).collect();
|
||||
assert_eq!(sizes, [(512, 256), (512, 2048)], "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_sized_by_width_stores_the_resolution_that_results() {
|
||||
let mut s = session(200.0, 100.0, 1);
|
||||
// 512 px over 200 pt is 2.56 pixels per point: 184.32 ppi.
|
||||
for format in ["jpg", "png"] {
|
||||
let file = b64(&s.execute("document.export", &json!({"format": format, "width": 512})).unwrap());
|
||||
let (x, y) = super::ppi::resolution(&file).unwrap();
|
||||
assert!((x - 184.32).abs() < 0.6 && (y - 184.32).abs() < 0.6, "{format}: {x} × {y}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_for_screens_rows_keep_their_own_size() {
|
||||
let mut s = session(200.0, 100.0, 1);
|
||||
let r = s
|
||||
.execute(
|
||||
"document.exportForScreens",
|
||||
&json!({"formats": [{"format": "png", "scale": "512w"}], "settings": {"png": {"height": 10, "width": 30}}}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(rgba(&b64(&r["files"][0])).dimensions(), (512, 256), "{r}");
|
||||
}
|
||||
|
||||
@@ -634,6 +634,151 @@ fn zip_archives_store_their_files() {
|
||||
assert!(super::zip::entries(&broken).is_err(), "the CRC catches a changed byte");
|
||||
}
|
||||
|
||||
/// The compression method of each file in `zip` (0: stored, 8: deflated), from its central directory.
|
||||
fn methods(zip: &[u8]) -> Vec<u16> {
|
||||
let u16_at = |at: usize| usize::from(u16::from_le_bytes([zip[at], zip[at + 1]]));
|
||||
let end = zip.len() - 22;
|
||||
let mut at = u32::from_le_bytes(zip[end + 16..end + 20].try_into().unwrap()) as usize;
|
||||
(0..u16_at(end + 10))
|
||||
.map(|_| {
|
||||
let method = u16_at(at + 10) as u16;
|
||||
at += 46 + u16_at(at + 28) + u16_at(at + 30) + u16_at(at + 32);
|
||||
method
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zip_archives_deflate_what_gets_smaller() {
|
||||
// A xorshift run: bytes that deflating makes larger.
|
||||
let mut x = 0x9E37_79B9u32;
|
||||
let noise: Vec<u8> = (0..4096)
|
||||
.map(|_| {
|
||||
x ^= x << 13;
|
||||
x ^= x >> 17;
|
||||
x ^= x << 5;
|
||||
x as u8
|
||||
})
|
||||
.collect();
|
||||
let text = "<rect x=\"5\" y=\"5\" width=\"20\" height=\"20\"/>\n".repeat(100).into_bytes();
|
||||
let files = [("art.svg", text.clone()), ("sub/ünï.png", noise.clone()), ("empty", vec![])];
|
||||
let zip = super::zip::deflate(&files, super::zip::FIXED_TIME, || false).unwrap();
|
||||
assert_eq!(methods(&zip), [8, 0, 0], "the text deflated, the noise and the empty file stored");
|
||||
assert!(zip.windows(noise.len()).any(|w| w == noise.as_slice()), "a stored file's bytes are in the archive as they are");
|
||||
assert!(zip.len() < super::zip::store(&files).unwrap().len());
|
||||
let back = super::zip::entries(&zip).unwrap();
|
||||
assert_eq!(back.len(), 3);
|
||||
for ((n, b), (m, c)) in files.iter().zip(&back) {
|
||||
assert_eq!((*n, b), (m.as_str(), c));
|
||||
}
|
||||
assert_eq!(super::zip::deflate(&files, super::zip::FIXED_TIME, || false).unwrap(), zip, "the same files, the same archive");
|
||||
}
|
||||
|
||||
/// `deflate` asks `stop` before each chunk of a file and drops the archive when it answers true.
|
||||
#[test]
|
||||
fn zip_deflating_stops_between_chunks() {
|
||||
let big = vec![7u8; 3 * super::zip::DEFLATE_CHUNK];
|
||||
let files = [("a.bin", big.clone()), ("b.bin", big)];
|
||||
let asked = std::cell::Cell::new(0);
|
||||
let all = super::zip::deflate(&files, super::zip::FIXED_TIME, || {
|
||||
asked.set(asked.get() + 1);
|
||||
false
|
||||
});
|
||||
assert!(all.is_ok());
|
||||
assert_eq!(asked.get(), 6, "once per chunk of each file");
|
||||
asked.set(0);
|
||||
let r = super::zip::deflate(&files, super::zip::FIXED_TIME, || {
|
||||
asked.set(asked.get() + 1);
|
||||
asked.get() == 2
|
||||
});
|
||||
assert!(r.is_err(), "stopped");
|
||||
assert_eq!(asked.get(), 2, "nothing more is deflated after the stop");
|
||||
}
|
||||
|
||||
/// A cancel that comes after the last file was encoded (here: during the last step, a row that
|
||||
/// repeats the one before it and writes nothing) returns no files and no archive, as it writes no
|
||||
/// archive into a folder.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn a_cancel_after_the_last_file_returns_nothing() {
|
||||
let mut s = session(1);
|
||||
let dir = std::env::temp_dir().join(format!("vc-screens-cancel-last-{}", std::process::id()));
|
||||
let folder = dir.to_string_lossy().replace(std::path::MAIN_SEPARATOR, "/");
|
||||
let rows = json!([{"format": "svg"}, {"format": "svg"}]);
|
||||
for p in [json!({ "formats": rows }), json!({ "formats": rows, "zip": true }), json!({ "formats": rows, "zip": true, "folder": folder })] {
|
||||
let prepared = s.prepare_screen_export("document.exportForScreens", &p).unwrap();
|
||||
let token = vectorcraft_doc::cancel::CancelToken::new();
|
||||
let on_last = token.clone();
|
||||
let sink: vectorcraft_doc::cancel::ProgressSink = std::rc::Rc::new(move |done, total, _| {
|
||||
if done + 1 == total {
|
||||
on_last.cancel();
|
||||
}
|
||||
});
|
||||
let mut written = vec![];
|
||||
let r = vectorcraft_doc::cancel::scope(&token, Some(sink), || {
|
||||
prepared.write(&mut |path: &str, _: &[u8]| {
|
||||
written.push(path.to_string());
|
||||
Ok(())
|
||||
})
|
||||
});
|
||||
assert!(matches!(r, Err(crate::EngineError::Cancelled)), "{p}: {r:?}");
|
||||
assert_eq!(written, Vec::<String>::new(), "{p}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// A folder export without a ZIP writes each file as it goes: a cancel during the last step, a
|
||||
/// row that repeats the one before it and writes nothing, comes after every file was written, so
|
||||
/// the export returns the written files instead of the cancelled error.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn a_cancel_after_the_last_file_keeps_a_finished_folder_export() {
|
||||
let mut s = session(1);
|
||||
let dir = std::env::temp_dir().join(format!("vc-screens-cancel-folder-{}", std::process::id()));
|
||||
let folder = dir.to_string_lossy().replace(std::path::MAIN_SEPARATOR, "/");
|
||||
let p = json!({ "formats": [{"format": "svg"}, {"format": "svg"}], "folder": folder });
|
||||
let prepared = s.prepare_screen_export("document.exportForScreens", &p).unwrap();
|
||||
let token = vectorcraft_doc::cancel::CancelToken::new();
|
||||
let on_last = token.clone();
|
||||
let sink: vectorcraft_doc::cancel::ProgressSink = std::rc::Rc::new(move |done, total, _| {
|
||||
if done + 1 == total {
|
||||
on_last.cancel();
|
||||
}
|
||||
});
|
||||
let mut written = vec![];
|
||||
let r = vectorcraft_doc::cancel::scope(&token, Some(sink), || {
|
||||
prepared.write(&mut |path: &str, _: &[u8]| {
|
||||
written.push(path.to_string());
|
||||
Ok(())
|
||||
})
|
||||
});
|
||||
let r = r.unwrap();
|
||||
assert_eq!(written.len(), 1, "{written:?}");
|
||||
assert_eq!(r["files"], json!(written));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zip_exports_deflate_their_files() {
|
||||
let mut s = session(1);
|
||||
for i in 0..40 {
|
||||
s.execute("shape.rectangle", &json!({"x": i % 30, "y": i % 20, "width": 8, "height": 6})).unwrap();
|
||||
}
|
||||
let p = json!({"formats": [{"format": "svg"}, {"format": "png"}]});
|
||||
let plain = files(&export(&mut s, p.clone()));
|
||||
let mut q = p;
|
||||
q["zip"] = json!(true);
|
||||
let r = export(&mut s, q);
|
||||
let zip = b64(&r);
|
||||
assert_eq!(super::zip::entries(&zip).unwrap(), plain, "every file comes back byte for byte");
|
||||
let svg = plain.iter().position(|(n, _)| n.ends_with(".svg")).unwrap();
|
||||
let png = plain.iter().position(|(n, _)| n.ends_with(".png")).unwrap();
|
||||
let methods = methods(&zip);
|
||||
assert_eq!((methods[svg], methods[png]), (8, 0), "the SVG deflated, the PNG stored");
|
||||
assert!(zip.len() < super::zip::store(&plain).unwrap().len(), "smaller than the stored archive");
|
||||
assert_eq!(r["bytes"].as_u64(), Some(zip.len() as u64));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_document_remembers_its_export_settings() {
|
||||
let mut s = session(2);
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
//! A store-only (uncompressed) ZIP archive: several exported files as one download. The files are
|
||||
//! already compressed (PNG, JPEG, PDF…), so storing them keeps the archive fast to write and read.
|
||||
//! ZIP archives: several exported files as one download. [`store`] writes them uncompressed, and
|
||||
//! [`deflate`] deflates each file that gets smaller and stores the others.
|
||||
//!
|
||||
//! [`Archive`] reads the files of a ZIP archive, stored or deflated, as PKWARE's APPNOTE.TXT
|
||||
//! describes the format (<https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT>).
|
||||
|
||||
use std::io::Read as _;
|
||||
use std::io::{Read as _, Write as _};
|
||||
|
||||
use flate2::Crc;
|
||||
|
||||
/// The DOS date of every entry: 1980-01-01, the earliest a ZIP date can say (no clock on the web,
|
||||
/// and a fixed date keeps archives of the same files identical).
|
||||
const DOS_DATE: u16 = (1 << 5) | 1;
|
||||
/// The MS-DOS time and date [`store`] gives every entry: midnight on [`DOS_DATE`].
|
||||
pub const FIXED_TIME: (u16, u16) = (0, DOS_DATE);
|
||||
/// General purpose flag: file names are UTF-8.
|
||||
const UTF8_NAMES: u16 = 1 << 11;
|
||||
/// Version needed to extract: 2.0.
|
||||
const VERSION: u16 = 20;
|
||||
/// How many bytes of a file [`deflate`] compresses between two calls of its `stop`: 1 MiB.
|
||||
pub const DEFLATE_CHUNK: usize = 1 << 20;
|
||||
|
||||
/// The archive of `files` (`(name, bytes)`; `/` separates folders in a name), stored without
|
||||
/// compression. Fails past the limits of a ZIP without its 64-bit extension (65535 files, 4 GB).
|
||||
@@ -70,6 +74,63 @@ pub fn store<N: AsRef<str>, B: AsRef<[u8]>>(files: &[(N, B)]) -> Result<Vec<u8>,
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The archive of `files` (`(name, bytes)`; `/` separates folders in a name), each deflated, or
|
||||
/// stored when deflating doesn't make it smaller, every entry dated `(time, date)` (MS-DOS format).
|
||||
/// Fails past the same limits as [`store`]. `stop` is asked before each [`DEFLATE_CHUNK`] bytes are
|
||||
/// deflated; when it answers true, the archive is dropped and an error returned.
|
||||
pub fn deflate<N: AsRef<str>, B: AsRef<[u8]>>(files: &[(N, B)], (time, date): (u16, u16), stop: impl Fn() -> bool) -> Result<Vec<u8>, String> {
|
||||
let too_big = || "too much to zip: at most 65535 files and 4 GB".to_string();
|
||||
let (mut out, mut central) = (Vec::new(), Vec::new());
|
||||
for (name, bytes) in files {
|
||||
let (name, bytes) = (name.as_ref(), bytes.as_ref());
|
||||
let mut crc = Crc::new();
|
||||
crc.update(bytes);
|
||||
let mut enc = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
for chunk in bytes.chunks(DEFLATE_CHUNK) {
|
||||
if stop() {
|
||||
return Err("stopped before the archive was finished".into());
|
||||
}
|
||||
enc.write_all(chunk).map_err(|e| e.to_string())?;
|
||||
}
|
||||
let deflated = enc.finish().map_err(|e| e.to_string())?;
|
||||
let (method, data): (u16, &[u8]) = if deflated.len() < bytes.len() { (8, &deflated) } else { (0, bytes) };
|
||||
let offset = u32::try_from(out.len()).map_err(|_| too_big())?;
|
||||
let (packed, size) = (u32::try_from(data.len()).map_err(|_| too_big())?, u32::try_from(bytes.len()).map_err(|_| too_big())?);
|
||||
let name_len = u16::try_from(name.len()).map_err(|_| format!("file name too long to zip: {name}"))?;
|
||||
// Version 2.0, UTF-8 names, method, time, date, CRC-32, sizes, name length, no extra field.
|
||||
let common =
|
||||
[&VERSION.to_le_bytes()[..], &UTF8_NAMES.to_le_bytes(), &method.to_le_bytes(), &time.to_le_bytes(), &date.to_le_bytes()].concat();
|
||||
let sums = [crc.sum().to_le_bytes(), packed.to_le_bytes(), size.to_le_bytes()].concat();
|
||||
out.extend_from_slice(&0x0403_4b50u32.to_le_bytes());
|
||||
out.extend_from_slice(&common);
|
||||
out.extend_from_slice(&sums);
|
||||
out.extend_from_slice(&name_len.to_le_bytes());
|
||||
out.extend_from_slice(&0u16.to_le_bytes());
|
||||
out.extend_from_slice(name.as_bytes());
|
||||
out.extend_from_slice(data);
|
||||
central.extend_from_slice(&0x0201_4b50u32.to_le_bytes());
|
||||
central.extend_from_slice(&VERSION.to_le_bytes());
|
||||
central.extend_from_slice(&common);
|
||||
central.extend_from_slice(&sums);
|
||||
central.extend_from_slice(&name_len.to_le_bytes());
|
||||
// No extra field or comment, disk 0, no attributes.
|
||||
central.extend_from_slice(&[0; 12]);
|
||||
central.extend_from_slice(&offset.to_le_bytes());
|
||||
central.extend_from_slice(name.as_bytes());
|
||||
}
|
||||
let count = u16::try_from(files.len()).map_err(|_| too_big())?;
|
||||
let (start, len) = (u32::try_from(out.len()).map_err(|_| too_big())?, u32::try_from(central.len()).map_err(|_| too_big())?);
|
||||
out.extend_from_slice(¢ral);
|
||||
out.extend_from_slice(&0x0605_4b50u32.to_le_bytes());
|
||||
out.extend_from_slice(&[0; 4]);
|
||||
out.extend_from_slice(&count.to_le_bytes());
|
||||
out.extend_from_slice(&count.to_le_bytes());
|
||||
out.extend_from_slice(&len.to_le_bytes());
|
||||
out.extend_from_slice(&start.to_le_bytes());
|
||||
out.extend_from_slice(&0u16.to_le_bytes());
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Does `bytes` start with a ZIP archive's first local file header?
|
||||
pub fn is_zip(bytes: &[u8]) -> bool {
|
||||
bytes.starts_with(&0x0403_4b50u32.to_le_bytes())
|
||||
@@ -209,8 +270,8 @@ impl<'a> Archive<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The entries of a store-only archive written by [`store`] (read through its central directory,
|
||||
/// checking every CRC) → `(name, bytes)`.
|
||||
/// The entries of an archive written by [`store`] or [`deflate`] (read through its central
|
||||
/// directory, checking every CRC and size) → `(name, bytes)`.
|
||||
#[cfg(test)]
|
||||
pub fn entries(zip: &[u8]) -> Result<Vec<(String, Vec<u8>)>, String> {
|
||||
let u16_at = |at: usize| zip.get(at..at + 2).map(|b| u16::from_le_bytes([b[0], b[1]])).ok_or("truncated");
|
||||
@@ -223,10 +284,12 @@ pub fn entries(zip: &[u8]) -> Result<Vec<(String, Vec<u8>)>, String> {
|
||||
let mut at = u32_at(end + 16)? as usize;
|
||||
let mut out = vec![];
|
||||
for _ in 0..count {
|
||||
if u32_at(at)? != 0x0201_4b50 || u16_at(at + 10)? != 0 {
|
||||
return Err("not a stored central record".into());
|
||||
let method = u16_at(at + 10)?;
|
||||
if u32_at(at)? != 0x0201_4b50 || (method != 0 && method != 8) {
|
||||
return Err("not a stored or deflated central record".into());
|
||||
}
|
||||
let (crc, size, name_len) = (u32_at(at + 16)?, u32_at(at + 20)? as usize, u16_at(at + 28)? as usize);
|
||||
let (crc, packed, name_len) = (u32_at(at + 16)?, u32_at(at + 20)? as usize, u16_at(at + 28)? as usize);
|
||||
let size = u32_at(at + 24)? as usize;
|
||||
let extra = u16_at(at + 30)? as usize + u16_at(at + 32)? as usize;
|
||||
let local = u32_at(at + 42)? as usize;
|
||||
let name = String::from_utf8(zip.get(at + 46..at + 46 + name_len).ok_or("truncated")?.to_vec()).map_err(|e| e.to_string())?;
|
||||
@@ -234,11 +297,18 @@ pub fn entries(zip: &[u8]) -> Result<Vec<(String, Vec<u8>)>, String> {
|
||||
return Err(format!("{name}: no local header"));
|
||||
}
|
||||
let data = local + 30 + u16_at(local + 26)? as usize + u16_at(local + 28)? as usize;
|
||||
let bytes = zip.get(data..data + size).ok_or("truncated")?.to_vec();
|
||||
let data = zip.get(data..data + packed).ok_or("truncated")?;
|
||||
let bytes = if method == 8 {
|
||||
let mut inflated = Vec::new();
|
||||
flate2::read::DeflateDecoder::new(data).read_to_end(&mut inflated).map_err(|e| format!("{name}: {e}"))?;
|
||||
inflated
|
||||
} else {
|
||||
data.to_vec()
|
||||
};
|
||||
let mut c = Crc::new();
|
||||
c.update(&bytes);
|
||||
if c.sum() != crc {
|
||||
return Err(format!("{name}: CRC mismatch"));
|
||||
if c.sum() != crc || bytes.len() != size {
|
||||
return Err(format!("{name}: CRC or size mismatch"));
|
||||
}
|
||||
out.push((name, bytes));
|
||||
at += 46 + name_len + extra;
|
||||
|
||||
@@ -15,7 +15,7 @@ use vectorcraft_geom::{Affine, Rect};
|
||||
|
||||
use super::{
|
||||
CommandSpec, Result, Session, bad, bool_or, check_numbers, check_switches, checked_id, checked_ids_param, cmd, color_value, f64_or, has_doc,
|
||||
has_selection, id_param, ids_param, index, ok, str_param,
|
||||
has_selection, id_param, ids_param, index, ok, str_param, switch_or,
|
||||
};
|
||||
use crate::EngineError;
|
||||
|
||||
@@ -29,7 +29,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"New Layer…",
|
||||
["Window", "Layers"],
|
||||
Some("Cmd+L"),
|
||||
"{name?, top?: bool, color?, template?, locked?, visible?, printable?, preview?, dimImages?} a layer directly above the current layer, at its level (a sublayer beside a current sublayer); `top`: above all layers (Ctrl-click the button). Options as layer.setProps → {id}",
|
||||
"{name?, top?: bool, below?: bool, color?, template?, locked?, visible?, printable?, preview?, dimImages?} a layer directly above the current layer, at its level (a sublayer beside a current sublayer); `top`: above all layers (Ctrl/Cmd-click the button); `below`: directly below the current layer, at its level (Ctrl/Cmd+Alt-click the button), not with `top`. Options as layer.setProps → {id}",
|
||||
has_doc,
|
||||
new_layer
|
||||
),
|
||||
@@ -38,7 +38,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"New Sublayer…",
|
||||
["Window", "Layers"],
|
||||
None,
|
||||
"{parent?: layer id (default: the highlighted row's layer, else the current layer), name?, …options as layer.new} a sublayer on top of the parent's contents → {id}",
|
||||
"{parent?: layer id (default: the highlighted row's layer, else the current layer), bottom?: bool, name?, …options as layer.new} a sublayer on top of the parent's contents; `bottom`: under the parent's contents, above a clipping layer's clipping path (Ctrl/Cmd+Alt-click the button) → {id}",
|
||||
has_doc,
|
||||
new_sublayer
|
||||
),
|
||||
@@ -405,7 +405,7 @@ fn check_options(cmd: &str, p: &Value, xml_id: bool) -> Result<()> {
|
||||
/// Layers panel → Paste Remembers Layers.
|
||||
fn paste_remembers_layers(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cur = s.doc()?.doc.paste_remembers_layers;
|
||||
let on = bool_or(p, "on", !cur);
|
||||
let on = switch_or(p, "on", !cur, "layer.pasteRemembersLayers")?;
|
||||
if on != cur {
|
||||
s.edit("Paste Remembers Layers", |d, _| {
|
||||
d.paste_remembers_layers = on;
|
||||
@@ -429,7 +429,7 @@ fn target(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let layer = n.is_layer();
|
||||
let rows = st.layer_rows.clone();
|
||||
if p.get("add").and_then(Value::as_bool).unwrap_or(false) {
|
||||
if switch_or(p, "add", false, C)? {
|
||||
s.select(|d, sel| sel.toggle_target(d, id))?;
|
||||
} else {
|
||||
s.select(|d, sel| sel.set_target(d, id))?;
|
||||
@@ -480,11 +480,16 @@ fn clip_toggle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
/// A new layer with the options in `p`, at `parent`/`index`; it becomes current and highlighted.
|
||||
fn add_layer(s: &mut Session, p: &Value, cmd: &str, label: &str, place: impl FnOnce(&Document) -> Result<(Option<NodeId>, usize)>) -> Result<Value> {
|
||||
check_switches(p, &["top"], cmd)?;
|
||||
let xml_id = s.prefs.identify_objects_by == "xmlId";
|
||||
check_options(cmd, p, xml_id)?;
|
||||
let name = str_param(p, "name").filter(|n| !n.trim().is_empty()).map(str::to_string);
|
||||
let id = s.edit(label, |d, _| {
|
||||
let (parent, index) = place(d)?;
|
||||
let (parent, mut index) = place(d)?;
|
||||
// The clipping path stays at the bottom of a clipping layer.
|
||||
if d.children(parent).is_some_and(|c| !c.is_empty()) && parent.and_then(|p| d.node(p)).is_some_and(Node::clips) {
|
||||
index = index.max(1);
|
||||
}
|
||||
let mut n = make_layer(d, name.as_deref());
|
||||
apply_options(&mut n, p, xml_id);
|
||||
Ok(d.insert(parent, index, n)?)
|
||||
@@ -496,12 +501,17 @@ fn add_layer(s: &mut Session, p: &Value, cmd: &str, label: &str, place: impl FnO
|
||||
}
|
||||
|
||||
fn new_layer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "layer.new";
|
||||
let below = switch_or(p, "below", false, C)?;
|
||||
if below && bool_or(p, "top", false) {
|
||||
return Err(bad(C, "`top` and `below` can't both be true"));
|
||||
}
|
||||
let st = s.doc()?;
|
||||
// Above the current layer, at its level; on top of everything with `top`.
|
||||
// Above the current layer, at its level; below it with `below`; on top of everything with `top`.
|
||||
let cur = st.current_layer().filter(|_| !bool_or(p, "top", false));
|
||||
add_layer(s, p, "layer.new", "New Layer", |d| match cur.and_then(|c| d.position(c)) {
|
||||
Some((parent, i, _)) => Ok((parent, i + 1)),
|
||||
None => Ok((None, usize::MAX)),
|
||||
add_layer(s, p, C, "New Layer", |d| match cur.and_then(|c| d.position(c)) {
|
||||
Some((parent, i, _)) => Ok((parent, if below { i } else { i + 1 })),
|
||||
None => Ok((None, if below { 0 } else { usize::MAX })),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -513,7 +523,8 @@ fn new_sublayer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !st.doc.node(parent).is_some_and(Node::is_layer) {
|
||||
return Err(bad(C, "the parent must be a layer or sublayer"));
|
||||
}
|
||||
add_layer(s, p, C, "New Sublayer", |_| Ok((Some(parent), usize::MAX)))
|
||||
let index = if switch_or(p, "bottom", false, C)? { 0 } else { usize::MAX };
|
||||
add_layer(s, p, C, "New Sublayer", |_| Ok((Some(parent), index)))
|
||||
}
|
||||
|
||||
fn delete_rows(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -669,10 +680,12 @@ fn in_isolation(doc: &Document, isolation: Option<NodeId>, id: NodeId) -> bool {
|
||||
fn select_art(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "layer.selectAll";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let add = bool_or(p, "add", false);
|
||||
let add = switch_or(p, "add", false, C)?;
|
||||
let st = s.doc()?;
|
||||
let n = st.doc.node(id).ok_or(EngineError::NoNode(id))?;
|
||||
let ids: Vec<NodeId> = if !st.doc.is_editable(id) || !in_isolation(&st.doc, st.isolation, id) {
|
||||
// An isolated layer's own row selects the art inside it.
|
||||
let isolated_layer = st.isolation == Some(id) && n.is_layer();
|
||||
let ids: Vec<NodeId> = if !st.doc.is_editable(id) || !(isolated_layer || in_isolation(&st.doc, st.isolation, id)) {
|
||||
vec![]
|
||||
} else if n.is_layer() {
|
||||
n.layer_art(true)
|
||||
@@ -719,7 +732,7 @@ fn move_rows(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"inside" | "into" => Place::Inside,
|
||||
other => return Err(bad(C, format!("place is above, below or inside, not `{other}`"))),
|
||||
};
|
||||
let copy = bool_or(p, "copy", false);
|
||||
let copy = switch_or(p, "copy", false, C)?;
|
||||
let st = s.doc()?;
|
||||
let d = &st.doc;
|
||||
d.node(target).ok_or(EngineError::NoNode(target))?;
|
||||
@@ -829,6 +842,16 @@ fn artboard_color(p: &Value, cmd: &str) -> Result<Option<Option<vectorcraft_colo
|
||||
}
|
||||
}
|
||||
|
||||
/// An artboard command's text param `key`: None when absent or null, else the string. Any other
|
||||
/// value, such as 5, is an error: "`{key}` must be a string".
|
||||
fn artboard_str<'a>(p: &'a Value, key: &str, cmd: &str) -> Result<Option<&'a str>> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::String(v)) => Ok(Some(v)),
|
||||
Some(_) => Err(bad(cmd, format!("`{key}` must be a string"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// An artboard command's `videoRulerPixelAspectRatio` param: None when absent, else a number within
|
||||
/// [`vectorcraft_doc::VIDEO_PIXEL_ASPECT_RANGE`].
|
||||
fn video_pixel_aspect(p: &Value, cmd: &str) -> Result<Option<f64>> {
|
||||
@@ -849,12 +872,13 @@ fn artboard_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let r = rect_from(p, def);
|
||||
let color = artboard_color(p, "artboard.new")?.flatten();
|
||||
check_switches(p, &["showCenterMark", "showCrossHairs", "showVideoSafeAreas"], "artboard.new")?;
|
||||
check_numbers(p, &["x", "y", "width", "height"], "artboard.new")?;
|
||||
let given = artboard_str(p, "name", "artboard.new")?.map(str::trim).filter(|n| !n.is_empty());
|
||||
let (show_center_mark, show_cross_hairs) = (bool_or(p, "showCenterMark", false), bool_or(p, "showCrossHairs", false));
|
||||
let show_video_safe_areas = bool_or(p, "showVideoSafeAreas", false);
|
||||
let video_ruler_pixel_aspect_ratio = video_pixel_aspect(p, "artboard.new")?.unwrap_or(1.0);
|
||||
let idx = s.edit("New Artboard", |d, _| {
|
||||
let id = d.next_artboard_id();
|
||||
let given = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty());
|
||||
let name = given.map_or_else(|| format!("Artboard {}", d.artboards.len() + 1), str::to_string);
|
||||
d.artboards.push(Artboard {
|
||||
id,
|
||||
@@ -994,7 +1018,7 @@ fn artboard_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
edits.push(ArtboardEdit {
|
||||
index,
|
||||
now,
|
||||
name: str_param(q, "name").map(str::trim),
|
||||
name: artboard_str(q, "name", "artboard.setProps")?.map(str::trim),
|
||||
color,
|
||||
lock,
|
||||
center_mark,
|
||||
|
||||
@@ -10,7 +10,7 @@ use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
|
||||
|
||||
use super::layer::{NewLayers, row_roots, rows_param};
|
||||
use super::{CommandSpec, Result, Session, bad, bool_or, checked_id, cmd, has_doc, id_param, ok};
|
||||
use super::{CommandSpec, Result, Session, bad, checked_id, cmd, has_doc, id_param, ok, switch_or};
|
||||
use crate::EngineError;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -225,7 +225,7 @@ fn template(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let d = &s.doc()?.doc;
|
||||
let ids: Vec<NodeId> = ids.into_iter().filter(|id| d.node(*id).is_some_and(Node::is_layer)).collect();
|
||||
let Some(first) = ids.first().and_then(|id| d.node(*id)) else { return Err(bad("layer.template", "select a layer")) };
|
||||
let on = bool_or(p, "on", !first.is_template());
|
||||
let on = switch_or(p, "on", !first.is_template(), "layer.template")?;
|
||||
s.execute("layer.setProps", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "template": on}))?;
|
||||
Ok(json!({ "on": on }))
|
||||
}
|
||||
@@ -334,7 +334,7 @@ fn flatten(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn release(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "layer.releaseToLayers";
|
||||
let build = bool_or(p, "build", false);
|
||||
let build = switch_or(p, "build", false, C)?;
|
||||
let st = s.doc()?;
|
||||
let id = match id_param(p, "id") {
|
||||
Some(id) => id,
|
||||
|
||||
@@ -16,7 +16,7 @@ use vectorcraft_render::encode::{RasterExportOptions, RasterFormat};
|
||||
use super::edit::{duplicate_in, selected_roots};
|
||||
use super::{
|
||||
CommandSpec, EngineError, Result, Session, bad, check_numbers, check_switches, cmd, f64_or, fileio, find_command, has_doc, has_selection,
|
||||
switch_or,
|
||||
has_selection_outside_isolation, switch_or,
|
||||
};
|
||||
|
||||
/// Session-level (not saved) state owned by the menu commands.
|
||||
@@ -44,7 +44,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
["Object", "Lock"],
|
||||
None,
|
||||
"{} lock objects in the same layer that are above and overlap the selection, deselecting every selected object left locked or hidden (the key object too) → {count}",
|
||||
has_selection,
|
||||
has_selection_outside_isolation,
|
||||
|s, _| above(s, true)
|
||||
),
|
||||
cmd!(
|
||||
@@ -53,7 +53,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
["Object", "Lock"],
|
||||
None,
|
||||
"{} lock every layer that holds no selected object → {count}",
|
||||
has_selection,
|
||||
has_selection_outside_isolation,
|
||||
|s, _| other_layers(s, true)
|
||||
),
|
||||
cmd!(
|
||||
@@ -62,7 +62,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
["Object", "Hide"],
|
||||
None,
|
||||
"{} hide objects in the same layer that are above and overlap the selection, deselecting every selected object left hidden or locked (the key object too) → {count}",
|
||||
has_selection,
|
||||
has_selection_outside_isolation,
|
||||
|s, _| above(s, false)
|
||||
),
|
||||
cmd!(
|
||||
@@ -71,7 +71,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
["Object", "Hide"],
|
||||
None,
|
||||
"{} hide every layer that holds no selected object → {count}",
|
||||
has_selection,
|
||||
has_selection_outside_isolation,
|
||||
|s, _| other_layers(s, false)
|
||||
),
|
||||
cmd!(
|
||||
|
||||
@@ -6,6 +6,7 @@ use super::props::set_props;
|
||||
use super::shape::{expand_shape, set_live_shape};
|
||||
use super::transform::{move_cmd, nudge, reflect, rotate, scale, shear, transform, transform_again};
|
||||
use super::{CommandSpec, cmd, has_doc, has_multi, has_selection, has_selection_or_guides};
|
||||
use crate::cmd::{has_selection_outside_isolation, outside_isolation};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -150,20 +151,20 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
["Object", "Lock"],
|
||||
Some("Cmd+2"),
|
||||
"{ids?: [id…] (selected first; default: the selection)}",
|
||||
has_selection,
|
||||
has_selection_outside_isolation,
|
||||
lock
|
||||
),
|
||||
cmd!("object.unlockAll", "Unlock All", ["Object"], Some("Cmd+Alt+2"), "{}", has_doc, unlock_all),
|
||||
cmd!("object.unlockAll", "Unlock All", ["Object"], Some("Cmd+Alt+2"), "{}", outside_isolation, unlock_all),
|
||||
cmd!(
|
||||
"object.hide",
|
||||
"Selection",
|
||||
["Object", "Hide"],
|
||||
Some("Cmd+3"),
|
||||
"{ids?: [id…] (selected first; default: the selection)}",
|
||||
has_selection,
|
||||
has_selection_outside_isolation,
|
||||
hide
|
||||
),
|
||||
cmd!("object.showAll", "Show All", ["Object"], Some("Cmd+Alt+3"), "{}", has_doc, show_all),
|
||||
cmd!("object.showAll", "Show All", ["Object"], Some("Cmd+Alt+3"), "{}", outside_isolation, show_all),
|
||||
cmd!(
|
||||
"object.compoundPath.make",
|
||||
"Make",
|
||||
@@ -260,7 +261,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Distribute Spacing",
|
||||
["Window", "Align"],
|
||||
None,
|
||||
"{axis: \"horizontal\"|\"vertical\", spacing?: pt (the key object, select.key, or the key guide, select.key {guide}, stays put and the others are spaced from it; a key guide along the axis leaves everything where it is), to?: \"selection\"|\"artboard\"|\"key\" (used without spacing and without anchor points selected, and with a key guide; default: the key while there is one, else the selection; \"key\" without spacing spaces as \"selection\" does), artboard?: index (0-based; with to: \"artboard\", the artboard to space across; the app passes the active one; default: as object.align's), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} without spacing, make the gaps between the selected objects equal, in the order of their left (top) edges, with the selected ruler guides across the axis as items with no size (#1597; guides along the axis stay): the first item's left (top) edge and the last one's right (bottom) edge stay put, or with to: \"artboard\" go to the artboard's left (top) and right (bottom) edges. With anchor points selected (Direct Selection), the points are spaced evenly along the axis instead, or `spacing` apart from the first (#1636)",
|
||||
"{axis?: \"horizontal\"|\"vertical\" (default: \"horizontal\"), spacing?: pt (the key object, select.key, or the key guide, select.key {guide}, stays put and the others are spaced from it; a key guide along the axis leaves everything where it is), to?: \"selection\"|\"artboard\"|\"key\" (used without spacing and without anchor points selected, and with a key guide; default: the key while there is one, else the selection; \"key\" without spacing spaces as \"selection\" does), artboard?: index (0-based; with to: \"artboard\", the artboard to space across; the app passes the active one; default: as object.align's), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} without spacing, make the gaps between the selected objects equal, in the order of their left (top) edges, with the selected ruler guides across the axis as items with no size (#1597; guides along the axis stay): the first item's left (top) edge and the last one's right (bottom) edge stay put, or with to: \"artboard\" go to the artboard's left (top) and right (bottom) edges. With anchor points selected (Direct Selection), the points are spaced evenly along the axis instead, or `spacing` apart from the first (#1636)",
|
||||
can_distribute,
|
||||
distribute_spacing
|
||||
),
|
||||
|
||||
@@ -343,6 +343,15 @@ impl TransparencyInfo {
|
||||
ids,
|
||||
}
|
||||
}
|
||||
|
||||
/// The opacity of the focused gradient mesh `point` (see `transparency.set`) instead of its
|
||||
/// object's.
|
||||
fn with_mesh_point(mut self, point: Option<(NodeId, usize, f32)>) -> Self {
|
||||
if let Some((_, _, o)) = point {
|
||||
self.opacity = Some((o as f64 * 10_000.0).round() / 100.0);
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// The nodes of `ids` that exist: looked up one by one when few, else in one walk of the tree.
|
||||
@@ -370,7 +379,10 @@ impl Session {
|
||||
/// edited (`transparency.info` without ids).
|
||||
pub fn transparency_info(&self) -> TransparencyInfo {
|
||||
match (self.active(), transparency_targets(self, &Value::Null)) {
|
||||
(Some(st), Ok(ids)) => TransparencyInfo::of(&st.doc, ids),
|
||||
(Some(st), Ok(ids)) => {
|
||||
let point = super::paint::focused_mesh_point_of(self, &ids);
|
||||
TransparencyInfo::of(&st.doc, ids).with_mesh_point(point)
|
||||
}
|
||||
_ => TransparencyInfo::default(),
|
||||
}
|
||||
}
|
||||
@@ -378,7 +390,9 @@ impl Session {
|
||||
|
||||
fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let i = TransparencyInfo::of(&st.doc, transparency_targets(s, p)?);
|
||||
let ids = transparency_targets(s, p)?;
|
||||
let point = super::paint::focused_mesh_point_of(s, &ids);
|
||||
let i = TransparencyInfo::of(&st.doc, ids).with_mesh_point(point);
|
||||
Ok(json!({
|
||||
"ids": i.ids.iter().map(|id| id.0).collect::<Vec<_>>(),
|
||||
"opacity": i.opacity,
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
//! Without a folder (the web, agents) the same files come back as a zip archive.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet, HashSet};
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use vectorcraft_doc::links::hash_bytes;
|
||||
@@ -14,7 +13,7 @@ use vectorcraft_doc::{Document, LinkInfo};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::fileio::{create_dir, write_file};
|
||||
use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, cmd, has_doc, str_param, unique_name};
|
||||
use super::{CommandSpec, Result, Session, bad, bool_or, cmd, has_doc, str_param, unique_name};
|
||||
|
||||
const C: &str = "file.package";
|
||||
|
||||
@@ -550,53 +549,9 @@ fn dos_time() -> (u16, u16) {
|
||||
}
|
||||
|
||||
/// A zip archive of `files` ((path with `/` separators, bytes)), each deflated, or stored when
|
||||
/// that isn't smaller.
|
||||
/// that isn't smaller, every entry dated now.
|
||||
pub(crate) fn zip(files: &[(String, &[u8])]) -> Result<Vec<u8>> {
|
||||
let too_big = || bad(C, "too large for a zip archive: package to a folder");
|
||||
let io = |e: std::io::Error| EngineError::Other(e.to_string());
|
||||
let (time, date) = dos_time();
|
||||
let (mut out, mut central) = (Vec::new(), Vec::new());
|
||||
for (name, bytes) in files {
|
||||
let mut crc = flate2::Crc::new();
|
||||
crc.update(bytes);
|
||||
let mut enc = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
enc.write_all(bytes).map_err(io)?;
|
||||
let deflated = enc.finish().map_err(io)?;
|
||||
let (method, data): (u16, &[u8]) = if deflated.len() < bytes.len() { (8, &deflated) } else { (0, bytes) };
|
||||
let offset = u32::try_from(out.len()).map_err(|_| too_big())?;
|
||||
let (packed, size) = (u32::try_from(data.len()).map_err(|_| too_big())?, u32::try_from(bytes.len()).map_err(|_| too_big())?);
|
||||
let name_len = u16::try_from(name.len()).map_err(|_| too_big())?;
|
||||
// Version 2.0, UTF-8 names, method, time, date, CRC-32, sizes, name length, no extra field.
|
||||
let common = [&20u16.to_le_bytes()[..], &0x0800u16.to_le_bytes(), &method.to_le_bytes(), &time.to_le_bytes(), &date.to_le_bytes()].concat();
|
||||
let sums = [crc.sum().to_le_bytes(), packed.to_le_bytes(), size.to_le_bytes()].concat();
|
||||
out.extend_from_slice(&0x0403_4b50u32.to_le_bytes());
|
||||
out.extend_from_slice(&common);
|
||||
out.extend_from_slice(&sums);
|
||||
out.extend_from_slice(&name_len.to_le_bytes());
|
||||
out.extend_from_slice(&0u16.to_le_bytes());
|
||||
out.extend_from_slice(name.as_bytes());
|
||||
out.extend_from_slice(data);
|
||||
central.extend_from_slice(&0x0201_4b50u32.to_le_bytes());
|
||||
central.extend_from_slice(&20u16.to_le_bytes());
|
||||
central.extend_from_slice(&common);
|
||||
central.extend_from_slice(&sums);
|
||||
central.extend_from_slice(&name_len.to_le_bytes());
|
||||
// No extra field or comment, disk 0, no attributes.
|
||||
central.extend_from_slice(&[0; 12]);
|
||||
central.extend_from_slice(&offset.to_le_bytes());
|
||||
central.extend_from_slice(name.as_bytes());
|
||||
}
|
||||
let count = u16::try_from(files.len()).map_err(|_| too_big())?;
|
||||
let (start, len) = (u32::try_from(out.len()).map_err(|_| too_big())?, u32::try_from(central.len()).map_err(|_| too_big())?);
|
||||
out.extend_from_slice(¢ral);
|
||||
out.extend_from_slice(&0x0605_4b50u32.to_le_bytes());
|
||||
out.extend_from_slice(&[0; 4]);
|
||||
out.extend_from_slice(&count.to_le_bytes());
|
||||
out.extend_from_slice(&count.to_le_bytes());
|
||||
out.extend_from_slice(&len.to_le_bytes());
|
||||
out.extend_from_slice(&start.to_le_bytes());
|
||||
out.extend_from_slice(&0u16.to_le_bytes());
|
||||
Ok(out)
|
||||
super::fileio::zip::deflate(files, dos_time(), || false).map_err(|_| bad(C, "too large for a zip archive: package to a folder"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -71,7 +71,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Transparency",
|
||||
["Window", "Transparency"],
|
||||
None,
|
||||
"{ids?|id?, opacity?: 0..100, blend?: name, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, item?: appearance item index|null, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} for `ids`, the selection, or the object whose opacity mask is being edited; opacity and blend go to the targeted fill/stroke item (omitted: the Appearance panel's active item, else the objects)",
|
||||
"{ids?|id?, opacity?: 0..100, blend?: name, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, item?: appearance item index|null, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} for `ids`, the selection, or the object whose opacity mask is being edited; opacity and blend go to the targeted fill/stroke item (omitted: the Appearance panel's active item, else the objects; a gradient mesh point last clicked with the Mesh tool or Direct Selection takes the opacity itself)",
|
||||
has_doc,
|
||||
transparency
|
||||
),
|
||||
@@ -602,6 +602,14 @@ fn focused_mesh_point(s: &Session) -> Option<(NodeId, usize)> {
|
||||
Some((NodeId(pt.get("id")?.as_u64()?), usize::try_from(pt.get("index")?.as_u64()?).ok()?))
|
||||
}
|
||||
|
||||
/// The focused mesh point (see [`focused_mesh_point`]) with its opacity, when `ids` is just the
|
||||
/// gradient mesh it belongs to.
|
||||
pub(crate) fn focused_mesh_point_of(s: &Session, ids: &[NodeId]) -> Option<(NodeId, usize, f32)> {
|
||||
let (id, index) = focused_mesh_point(s).filter(|(id, _)| ids == [*id])?;
|
||||
let NodeKind::Mesh(m) = &s.doc().ok()?.doc.node(id)?.kind else { return None };
|
||||
Some((id, index, m.points.get(index)?.opacity))
|
||||
}
|
||||
|
||||
/// A type run's stroke; a painted stroke on a run without one gets 1 pt.
|
||||
fn set_run_stroke(style: &mut CharStyle, paint: Paint) {
|
||||
if !paint.is_none() {
|
||||
@@ -745,6 +753,19 @@ fn transparency(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
q.remove("item");
|
||||
q.remove("id");
|
||||
q.insert("ids".into(), json!(ids.iter().map(|id| id.0).collect::<Vec<_>>()));
|
||||
// The gradient mesh point last clicked with the Mesh tool or Direct Selection takes the
|
||||
// opacity itself, so only the mesh around it fades (#4206).
|
||||
if matches!(item, ItemTarget::Top)
|
||||
&& let Some(o) = q.get("opacity").and_then(Value::as_f64)
|
||||
&& let Some((id, index, _)) = focused_mesh_point_of(s, &ids)
|
||||
{
|
||||
q.remove("opacity");
|
||||
let opacity = f64::from(super::opacitymask::percent(o));
|
||||
s.execute("object.mesh.setPointColor", &json!({"id": id.0, "index": index, "opacity": opacity}))?;
|
||||
if q.keys().all(|k| k == "ids") {
|
||||
return ok();
|
||||
}
|
||||
}
|
||||
let ItemTarget::Item { index, explicit, aligned } = item else {
|
||||
return s.execute("object.setProps", &Value::Object(q));
|
||||
};
|
||||
|
||||
@@ -1251,13 +1251,21 @@ fn insert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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 t = number_or(p, "t", 0.5, "path.insertAnchor")?.clamp(0.0, 1.0);
|
||||
let idx = s.edit("Add Anchor Point", |d, _| {
|
||||
let idx = s.edit("Add Anchor Point", |d, sel| {
|
||||
let path = path_mut(d, id)?;
|
||||
let sp = path.subpaths.get_mut(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?;
|
||||
if seg >= sp.segment_count() {
|
||||
return Err(EngineError::Other("no such segment".into()));
|
||||
}
|
||||
Ok(sp.insert_anchor(seg, t))
|
||||
let idx = sp.insert_anchor(seg, t);
|
||||
// A path that wasn't selected becomes the selection, its new anchor direct-selected, so
|
||||
// the point added shows and can be edited at once (#4237).
|
||||
if !sel.contains(id) {
|
||||
sel.clear();
|
||||
sel.add(id);
|
||||
sel.anchors.entry(id).or_default().insert((si, idx));
|
||||
}
|
||||
Ok(idx)
|
||||
})?;
|
||||
Ok(json!({ "anchor": idx }))
|
||||
}
|
||||
|
||||
@@ -128,7 +128,10 @@ impl Session {
|
||||
}
|
||||
if self.depth == 0 {
|
||||
self.journal_in_undo_groups(doc, &grouped, journal);
|
||||
self.after_command();
|
||||
self.after_command(Some(id));
|
||||
} else if matches!(id, "edit.undo" | "edit.redo") {
|
||||
// A step of `command.batch`: the tool hears of it as it does of the command alone.
|
||||
self.notify_text_tool(id);
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
@@ -165,6 +165,7 @@ mod transparencygrid;
|
||||
mod trap;
|
||||
mod typearea;
|
||||
mod typescale;
|
||||
mod typeundo;
|
||||
mod units;
|
||||
mod variables;
|
||||
mod webexport;
|
||||
|
||||
@@ -47,6 +47,19 @@ fn harmony_query_returns_the_group_and_its_grid() {
|
||||
assert!(s.execute("color.harmony", &json!({"color": "#ff0000", "rule": "triad", "variation": "loud"})).is_err());
|
||||
}
|
||||
|
||||
/// The Color Guide refuses `steps` or `amount` that is not a number, which it would otherwise
|
||||
/// replace with its default.
|
||||
#[test]
|
||||
fn harmony_refuses_steps_or_amount_that_is_not_a_number() {
|
||||
let mut s = Session::new();
|
||||
for p in [json!({"color": "#ff0000", "rule": "triad", "steps": "2"}), json!({"color": "#ff0000", "rule": "triad", "amount": "100"})] {
|
||||
let e = s.execute("color.harmony", &p).unwrap_err().to_string();
|
||||
assert!(e.contains("must be a number"), "{p}: {e}");
|
||||
}
|
||||
let r = s.execute("color.harmony", &json!({"color": "#ff0000", "rule": "triad", "steps": null, "amount": null})).unwrap();
|
||||
assert_eq!(r["grid"][0].as_array().unwrap().len(), 9, "a null is read as a key left out");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_swatch_saves_several_colours_in_one_step() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -130,6 +130,28 @@ fn set_units_is_an_alias() {
|
||||
assert!(s.execute("document.setUnits", &json!({"units": "parsecs"})).is_err());
|
||||
}
|
||||
|
||||
/// Switch Units (Cmd+Alt+Shift+U) steps the document's units through every unit in the order a
|
||||
/// right-click on a ruler lists them, Feet going back to Points, one undo step each.
|
||||
#[test]
|
||||
fn switch_units_steps_through_the_units_and_wraps() {
|
||||
let spec = command_specs().iter().find(|c| c.id == "document.switchUnits").expect("registered");
|
||||
assert_eq!((spec.label, spec.shortcut), ("Switch Units", Some("Cmd+Alt+Shift+U")));
|
||||
assert!(Session::new().execute("document.switchUnits", &json!({})).is_err(), "no document");
|
||||
let mut s = session();
|
||||
assert_eq!(s.doc().unwrap().doc.units, Unit::Points);
|
||||
let order = ["Picas", "Inches", "Millimeters", "Centimeters", "Pixels", "Feet & Inches", "Meters", "Yards", "Feet", "Points"];
|
||||
for (i, label) in order.iter().enumerate() {
|
||||
assert_eq!(s.execute("document.switchUnits", &json!({})).unwrap(), json!({"units": label}));
|
||||
assert_eq!(s.doc().unwrap().doc.units.label(), *label);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), i + 1, "one undo step each");
|
||||
}
|
||||
s.execute("document.setUnits", &json!({"units": "Feet"})).unwrap();
|
||||
s.execute("document.switchUnits", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.units, Unit::Points, "after Feet comes Points");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.units, Unit::Feet);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn language_picks_its_quotes_unless_quotes_are_given() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -465,6 +465,132 @@ fn blob_brush_merges_only_with_the_selection_and_keep_selected_off_deselects() {
|
||||
assert!(s.doc().unwrap().doc.node(all).is_some() && s.doc().unwrap().doc.node(other).is_some());
|
||||
}
|
||||
|
||||
/// A Blob Brush stroke merges with the topmost matching path it touches and with the touched
|
||||
/// matching paths next to it in stacking order in the same group or layer; matching paths in other
|
||||
/// layers or groups, or below an object of another kind, are left alone.
|
||||
#[test]
|
||||
fn blob_brush_merges_with_the_topmost_match_and_its_neighbors() {
|
||||
let blob = |s: &mut Session, x: f64| {
|
||||
NodeId(s.execute("path.blob", &json!({"points": [[x, 60], [x, 140]], "size": 20})).unwrap()["id"].as_u64().unwrap())
|
||||
};
|
||||
let across = json!({"points": [[50, 100], [450, 100]], "size": 10});
|
||||
let parent = |s: &Session, id: NodeId| s.doc().unwrap().doc.parent_of(id);
|
||||
|
||||
// Two layers: only the path on the upper layer merges.
|
||||
let mut s = session();
|
||||
let low = blob(&mut s, 100.0);
|
||||
let upper = NodeId(s.execute("layer.new", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
let high = blob(&mut s, 200.0);
|
||||
let area_low = area(&path(&s, low));
|
||||
let id = NodeId(s.execute("path.blob", &across).unwrap()["id"].as_u64().unwrap());
|
||||
assert_eq!(id, high);
|
||||
assert_eq!(parent(&s, high), Some(upper));
|
||||
assert_eq!(paths(&s).len(), 2);
|
||||
assert_eq!(area(&path(&s, low)), area_low, "the path on the lower layer is left as it was");
|
||||
|
||||
// A path of another color between two matches: only the upper one merges.
|
||||
let mut s = session();
|
||||
let below = blob(&mut s, 100.0);
|
||||
s.execute("paint.setStroke", &json!({"color": "#ff0000"})).unwrap();
|
||||
let red = NodeId(s.execute("path.blob", &json!({"points": [[100, 400], [200, 400]], "size": 10})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("paint.setStroke", &json!({"color": "#000000"})).unwrap();
|
||||
let above = blob(&mut s, 200.0);
|
||||
let (area_below, area_red) = (area(&path(&s, below)), area(&path(&s, red)));
|
||||
let id = NodeId(s.execute("path.blob", &across).unwrap()["id"].as_u64().unwrap());
|
||||
assert_eq!(id, above);
|
||||
assert_eq!(paths(&s).len(), 3);
|
||||
assert_eq!(area(&path(&s, below)), area_below, "the path below the red one is left as it was");
|
||||
assert_eq!(area(&path(&s, red)), area_red);
|
||||
|
||||
// A matching path the stroke does not touch, between two it touches: both touched ones merge,
|
||||
// and the untouched one is left as it was.
|
||||
let mut s = session();
|
||||
let lower = blob(&mut s, 100.0);
|
||||
let untouched = NodeId(s.execute("path.blob", &json!({"points": [[200, 300], [200, 380]], "size": 20})).unwrap()["id"].as_u64().unwrap());
|
||||
let upper = blob(&mut s, 300.0);
|
||||
let area_untouched = area(&path(&s, untouched));
|
||||
let id = NodeId(s.execute("path.blob", &across).unwrap()["id"].as_u64().unwrap());
|
||||
assert_eq!(id, upper);
|
||||
assert!(s.doc().unwrap().doc.node(lower).is_none(), "the lower path joined the upper one");
|
||||
assert_eq!(area(&path(&s, untouched)), area_untouched);
|
||||
assert_eq!(paths(&s).len(), 2);
|
||||
|
||||
// Two adjacent matches in a group both merge; a match outside the group is left alone.
|
||||
let mut s = session();
|
||||
let outside = blob(&mut s, 100.0);
|
||||
let a = blob(&mut s, 200.0);
|
||||
let b = blob(&mut s, 300.0);
|
||||
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
|
||||
let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
let area_outside = area(&path(&s, outside));
|
||||
let id = NodeId(s.execute("path.blob", &across).unwrap()["id"].as_u64().unwrap());
|
||||
assert_eq!(id, b, "the topmost match keeps its id");
|
||||
assert!(s.doc().unwrap().doc.node(a).is_none(), "a joined b");
|
||||
assert_eq!(parent(&s, b), Some(g));
|
||||
assert_eq!(paths(&s).len(), 2);
|
||||
assert_eq!(area(&path(&s, outside)), area_outside, "the path outside the group is left as it was");
|
||||
}
|
||||
|
||||
/// Blob Brush Fidelity: the brush of `path.blob` follows the stroke fitted at `fidelity` points, as
|
||||
/// `path.freehand` fits a Pencil stroke; without `fidelity` it follows every point. On a jittery
|
||||
/// stroke, 0.01 pt keeps the jitter and 1.5 pt and 10 pt smooth it away, with fewer anchors and a
|
||||
/// shape of about the same area. The tool's Fidelity is 1.5 pt by default.
|
||||
#[test]
|
||||
fn blob_brush_fidelity_fits_the_stroke_the_brush_follows() {
|
||||
let mut s = session();
|
||||
// A wave drawn with a hand's jitter: every other point 1 pt off.
|
||||
let wave: Vec<[f64; 2]> =
|
||||
(0..=80).map(|i| [20.0 + f64::from(i) * 4.0, 200.0 + 30.0 * (f64::from(i) * 0.125).sin() + if i % 2 == 0 { 1.0 } else { -1.0 }]).collect();
|
||||
// The anchors and area of the blob the wave paints at height `dy` with `fidelity` (None: left out).
|
||||
let mut blob = |dy: f64, fidelity: Option<f64>| {
|
||||
let pts: Vec<[f64; 2]> = wave.iter().map(|q| [q[0], q[1] + dy]).collect();
|
||||
let mut p = json!({"points": pts, "size": 20, "merge": false});
|
||||
if let Some(f) = fidelity {
|
||||
p["fidelity"] = json!(f);
|
||||
}
|
||||
let id = NodeId(s.execute("path.blob", &p).unwrap()["id"].as_u64().unwrap());
|
||||
let pd = path(&s, id);
|
||||
(pd.subpaths.iter().map(|sp| sp.anchors.len()).sum::<usize>(), area(&pd))
|
||||
};
|
||||
let (unfitted, unfitted_area) = blob(0.0, None);
|
||||
let (default, default_area) = blob(100.0, Some(1.5));
|
||||
let (smooth, smooth_area) = blob(200.0, Some(10.0));
|
||||
let (accurate, _) = blob(300.0, Some(0.01));
|
||||
assert!(default < unfitted, "{default} anchors at 1.5 pt, {unfitted} unfitted");
|
||||
assert!(smooth < default, "{smooth} anchors at 10 pt, {default} at 1.5 pt, {unfitted} unfitted");
|
||||
assert!(default < accurate, "{default} anchors at 1.5 pt, {accurate} at 0.01 pt");
|
||||
for (a, f) in [(default_area, 1.5), (smooth_area, 10.0)] {
|
||||
assert!((a - unfitted_area).abs() < unfitted_area * 0.05, "{a} pt² at {f} pt, {unfitted_area} pt² unfitted");
|
||||
}
|
||||
// A short stroke back to its start that the fit leaves with no length still paints.
|
||||
let back = json!({"points": [[100, 600], [110, 600], [100, 600]], "size": 10, "fidelity": 100, "merge": false});
|
||||
let dab = NodeId(s.execute("path.blob", &back).unwrap()["id"].as_u64().unwrap());
|
||||
assert!(area(&path(&s, dab)) > 70.0, "{}", area(&path(&s, dab)));
|
||||
s.select_tool("blobBrush", view()).unwrap();
|
||||
assert_eq!(s.tool_options()["fidelity"], json!(1.5));
|
||||
}
|
||||
|
||||
/// A stroke fitted at its Fidelity can curve outside its points, and it still merges with a blob
|
||||
/// of its color that the curve covers there.
|
||||
#[test]
|
||||
fn blob_brush_merges_with_a_blob_its_fitted_stroke_curves_over() {
|
||||
let stroke = json!({"points": [[100, 100], [600, 100], [600, 600]], "size": 10, "fidelity": 1.5});
|
||||
// Where the fitted shape reaches farthest outside the points' bounds padded by the size.
|
||||
let mut first = session();
|
||||
let id = NodeId(first.execute("path.blob", &with(&stroke, json!({"merge": false}))).unwrap()["id"].as_u64().unwrap());
|
||||
let outside = |p: Point| (90.0 - p.x).max(p.x - 610.0).max(90.0 - p.y).max(p.y - 610.0);
|
||||
let far =
|
||||
path(&first, id).subpaths.iter().flat_map(|sp| sp.anchors.iter().map(|a| a.p)).max_by(|a, b| outside(*a).total_cmp(&outside(*b))).unwrap();
|
||||
assert!(outside(far) > 5.0, "the fitted stroke curves {} pt past the padded bounds", outside(far));
|
||||
// A small blob there merges with the stroke.
|
||||
let mut s = session();
|
||||
let small = NodeId(s.execute("path.blob", &json!({"points": [[far.x, far.y]], "size": 6, "merge": false})).unwrap()["id"].as_u64().unwrap());
|
||||
let merged = NodeId(s.execute("path.blob", &stroke).unwrap()["id"].as_u64().unwrap());
|
||||
assert_eq!(merged, small);
|
||||
assert_eq!(paths(&s).len(), 1);
|
||||
}
|
||||
|
||||
/// Three points already curve in the saved path: both ends carry a handle into the curve.
|
||||
#[test]
|
||||
fn path_curvature_with_three_points_saves_end_handles() {
|
||||
@@ -597,6 +723,40 @@ fn curvature_click_first_point_closes() {
|
||||
assert_eq!(p.anchor_count(), 3);
|
||||
}
|
||||
|
||||
/// Delete on a point of a finished Curvature path re-curves the rest through the remaining points,
|
||||
/// as if they had been drawn without it, in one undo step.
|
||||
#[test]
|
||||
fn curvature_delete_curves_through_the_remaining_points() {
|
||||
let mut s = session();
|
||||
let v = view();
|
||||
s.select_tool("curvature", v).unwrap();
|
||||
let pts = [(100.0, 300.0), (200.0, 200.0), (300.0, 320.0), (400.0, 220.0), (500.0, 300.0)];
|
||||
for (x, y) in pts {
|
||||
gesture(&mut s, &[(x, y)], Mods::default());
|
||||
}
|
||||
s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap();
|
||||
let id = paths(&s)[0].0;
|
||||
let before = path(&s, id).subpaths[0].clone();
|
||||
let remaining = |gone: usize| -> Vec<(Point, bool)> {
|
||||
pts.iter().enumerate().filter(|&(i, _)| i != gone).map(|(_, &(x, y))| (Point::new(x, y), false)).collect()
|
||||
};
|
||||
for (gone, &(x, y)) in pts.iter().enumerate() {
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
let steps = undo_steps(&s);
|
||||
gesture(&mut s, &[(x, y)], Mods::default());
|
||||
s.tool_key(ToolKey::Delete, Mods::default(), v).unwrap();
|
||||
assert_eq!(undo_steps(&s), steps + 1, "one step");
|
||||
let want = vectorcraft_tools::draw2::curvature_spline(&remaining(gone), false);
|
||||
let got = path(&s, id).subpaths[0].clone();
|
||||
assert_eq!(got.anchors.len(), want.anchors.len());
|
||||
for (a, b) in got.anchors.iter().zip(&want.anchors) {
|
||||
assert!(a.p.distance(b.p) < 1e-9 && a.h_in.distance(b.h_in) < 1e-6 && a.h_out.distance(b.h_out) < 1e-6, "point {gone}: {a:?} != {b:?}");
|
||||
}
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(path(&s, id).subpaths[0], before);
|
||||
}
|
||||
}
|
||||
|
||||
/// The Curvature tool edits a path another tool drew (#798), one undo step per edit, keeping its
|
||||
/// shape except where edited.
|
||||
#[test]
|
||||
@@ -740,6 +900,26 @@ fn scissors_opens_closed_path() {
|
||||
assert!(near(p.subpaths[0].anchors[5].p, Point::new(200.0, 150.0)));
|
||||
}
|
||||
|
||||
/// Alt held with the Scissors tool switches it to the Add Anchor Point tool: a click adds an anchor
|
||||
/// in one undo step and the path stays closed. Once Alt is let go, a click cuts again.
|
||||
#[test]
|
||||
fn scissors_with_alt_adds_an_anchor_point() {
|
||||
let mut s = session();
|
||||
let id = rect(&mut s, 100.0, 100.0, 100.0, 100.0);
|
||||
s.select_tool("scissors", view()).unwrap();
|
||||
assert_eq!(gesture(&mut s, &[(200.0, 150.0)], Mods { alt: true, ..Mods::default() }), 1);
|
||||
let p = path(&s, id);
|
||||
assert_eq!(paths(&s).len(), 1);
|
||||
assert!(p.is_closed());
|
||||
assert_eq!(p.anchor_count(), 5);
|
||||
assert!(near(p.subpaths[0].anchors[2].p, Point::new(200.0, 150.0)));
|
||||
assert_eq!(s.tool_id(), "scissors");
|
||||
gesture(&mut s, &[(150.0, 200.0)], Mods::default());
|
||||
let p = path(&s, id);
|
||||
assert!(!p.is_closed());
|
||||
assert_eq!(p.anchor_count(), 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scissors_splits_open_path_in_two() {
|
||||
let mut s = session();
|
||||
@@ -809,6 +989,52 @@ fn eraser_removes_small_shape_entirely() {
|
||||
assert!(paths(&s).is_empty());
|
||||
}
|
||||
|
||||
/// #4495: the Eraser cuts a compound path as one shape: its hole stays empty, the erased area is
|
||||
/// empty, and the rest stays filled, with either fill rule. A notch inside the ring keeps one
|
||||
/// compound path; a drag through the whole ring leaves compound paths whose holes stay holes.
|
||||
#[test]
|
||||
fn eraser_keeps_the_holes_of_a_compound_path() {
|
||||
for rule in ["nonZero", "evenOdd"] {
|
||||
for (drag, erased) in [([[200, 110], [200, 120]], (200.0, 115.0)), ([[200, 80], [200, 320]], (200.0, 120.0))] {
|
||||
let mut s = session();
|
||||
let outer = rect(&mut s, 100.0, 100.0, 200.0, 200.0);
|
||||
let inner = rect(&mut s, 150.0, 150.0, 100.0, 100.0);
|
||||
s.execute("select.set", &json!({"ids": [outer.0, inner.0]})).unwrap();
|
||||
s.execute("object.compoundPath.make", &json!({})).unwrap();
|
||||
s.execute("path.setFillRule", &json!({"rule": rule})).unwrap();
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
s.execute("path.eraseRegion", &json!({"points": drag, "size": 10})).unwrap();
|
||||
// Whether any top-level shape fills `p`, a compound path with all its members at once.
|
||||
let filled = |x: f64, y: f64| {
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
let mut hit = false;
|
||||
for layer in &doc.layers {
|
||||
for n in layer.children().unwrap() {
|
||||
let (subs, rule) = match &n.kind {
|
||||
NodeKind::Path { path, rule, .. } => (path.subpaths.clone(), *rule),
|
||||
NodeKind::Compound { children, rule } => {
|
||||
(children.iter().flat_map(|c| c.path_data().unwrap().subpaths.clone()).collect(), *rule)
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
hit |= vectorcraft_geom::hit::fill_contains(&PathData::new(subs).to_bezpath(), rule, Point::new(x, y));
|
||||
}
|
||||
}
|
||||
hit
|
||||
};
|
||||
assert!(!filled(170.0, 200.0), "{rule} {drag:?}: the hole stays empty");
|
||||
assert!(!filled(erased.0, erased.1), "{rule} {drag:?}: the erased area is empty");
|
||||
assert!(filled(120.0, 200.0) && filled(280.0, 200.0), "{rule} {drag:?}: the ring stays filled");
|
||||
if drag[1][1] == 120 {
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
let tops: Vec<_> = doc.layers.iter().flat_map(|l| l.children().unwrap().iter().cloned()).collect();
|
||||
assert_eq!(tops.len(), 1, "{rule}: one object");
|
||||
assert!(matches!(tops[0].kind, NodeKind::Compound { .. }), "{rule}: still a compound path");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The Eraser's tip is an ellipse `size` across its long axis, at `angle` counter-clockwise from
|
||||
/// horizontal, with a short axis `roundness` percent as long: a vertical drag across a square
|
||||
/// removes a band as wide as the tip is across, and a press on a horizontal line removes the
|
||||
@@ -974,6 +1200,22 @@ fn line_family_tools_draw_and_click_asks_dialog() {
|
||||
assert_eq!(ui, vec![UiRequest::Dialog("polarGrid".into(), json!({"x": 10.0, "y": 10.0}))]);
|
||||
}
|
||||
|
||||
/// A closed `shape.arc` with Base Along Y runs from the box's bottom-left corner to its top-right
|
||||
/// one and closes through the top-left corner, the X-base arc's sector mirrored top to bottom.
|
||||
#[test]
|
||||
fn shape_arc_closed_along_y_closes_through_the_top_left_corner() {
|
||||
let mut s = session();
|
||||
let p = json!({"x": 100, "y": 50, "lengthX": 80, "lengthY": 40, "baseAlong": "y", "slope": 100, "closed": true});
|
||||
let id = NodeId(s.execute("shape.arc", &p).unwrap()["id"].as_u64().unwrap());
|
||||
let path = s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().clone();
|
||||
let sp = &path.subpaths[0];
|
||||
assert!(sp.closed);
|
||||
let ends: Vec<Point> = sp.anchors.iter().map(|a| a.p).collect();
|
||||
assert_eq!(ends, [Point::new(100.0, 90.0), Point::new(180.0, 50.0), Point::new(100.0, 50.0)]);
|
||||
let area = sp.signed_area().abs() / (80.0 * 40.0);
|
||||
assert!((area - std::f64::consts::FRAC_PI_4).abs() < 0.01, "{area}");
|
||||
}
|
||||
|
||||
/// The children of the grid a `shape.rectangularGrid` / `shape.polarGrid` call made, bottom first.
|
||||
fn grid(s: &mut Session, cmd: &str, p: serde_json::Value) -> Vec<std::sync::Arc<vectorcraft_doc::Node>> {
|
||||
let id = NodeId(s.execute(cmd, &p).unwrap()["id"].as_u64().unwrap());
|
||||
@@ -1076,6 +1318,22 @@ fn polar_grid_options_compound_fill_and_skew() {
|
||||
assert!(tips(&inward)[1..].iter().all(|t| t.x < 50.0), "counterclockwise of the top: {:?}", tips(&inward));
|
||||
}
|
||||
|
||||
/// A divider count written as a whole float (`3.0`) is the same as `3`; a fraction gives the default 5.
|
||||
#[test]
|
||||
fn grid_divider_counts_accept_whole_floats() {
|
||||
let mut s = session();
|
||||
let size = json!({"x": 0, "y": 0, "width": 100, "height": 100});
|
||||
let float = grid(&mut s, "shape.rectangularGrid", with(&size, json!({"rows": 3.0, "columns": 3.0})));
|
||||
let int = grid(&mut s, "shape.rectangularGrid", with(&size, json!({"rows": 3, "columns": 3})));
|
||||
assert_eq!(float.len(), int.len());
|
||||
assert_eq!(float.len(), 7);
|
||||
let float = grid(&mut s, "shape.polarGrid", with(&size, json!({"concentric": 2.0, "radial": 3.0})));
|
||||
let int = grid(&mut s, "shape.polarGrid", with(&size, json!({"concentric": 2, "radial": 3})));
|
||||
assert_eq!(float.len(), int.len());
|
||||
let frac = grid(&mut s, "shape.rectangularGrid", with(&size, json!({"rows": 2.5, "columns": 2.5})));
|
||||
assert_eq!(frac.len(), 11);
|
||||
}
|
||||
|
||||
/// The grid options are checked: a divider count or a skew is a number and a switch true or false,
|
||||
/// or the command fails without an undo step.
|
||||
#[test]
|
||||
@@ -1190,6 +1448,7 @@ fn drawing_commands_refuse_a_size_given_as_text() {
|
||||
("path.smoothRegion", "fidelity"),
|
||||
("path.joinScrub", "tolerance"),
|
||||
("path.blob", "size"),
|
||||
("path.blob", "fidelity"),
|
||||
] {
|
||||
refuses_a_number_given_as_text(&mut s, cmd, &stroke, key);
|
||||
}
|
||||
@@ -1737,6 +1996,30 @@ fn remove_anchor_points_keeps_the_paths_closed() {
|
||||
assert_eq!(spec.menu, &["Object", "Path"][..]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_anchor_point_selects_the_path_it_was_added_to() {
|
||||
// #4237: an anchor added to a path that wasn't selected selects that path, the new anchor
|
||||
// direct-selected, so it shows at once; undo puts the old selection back. A path already
|
||||
// selected (the Pen's Auto Add) keeps its selection.
|
||||
let mut s = session();
|
||||
let other = rect(&mut s, 300.0, 0.0, 50.0, 50.0);
|
||||
let line = |s: &mut Session| {
|
||||
NodeId(s.execute("path.create", &json!({"anchors": [{"x": 0, "y": 0}, {"x": 100, "y": 0}]})).unwrap()["id"].as_u64().unwrap())
|
||||
};
|
||||
let id = line(&mut s);
|
||||
s.execute("select.set", &json!({"ids": [other.0]})).unwrap();
|
||||
s.execute("path.insertAnchor", &json!({"id": id.0, "segment": 0, "t": 0.5})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.objects, [id]);
|
||||
assert_eq!(selected_anchors(&s, id), [(0, 1)]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.objects, [other]);
|
||||
let kept = line(&mut s);
|
||||
s.execute("select.set", &json!({"ids": [kept.0]})).unwrap();
|
||||
s.execute("path.insertAnchor", &json!({"id": kept.0, "segment": 0, "t": 0.5})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.objects, [kept]);
|
||||
assert!(selected_anchors(&s, kept).is_empty(), "a path selected as a whole stays so");
|
||||
}
|
||||
|
||||
fn selected_anchors(s: &Session, id: NodeId) -> Vec<(usize, usize)> {
|
||||
s.doc().unwrap().selection.anchors.get(&id).map(|set| set.iter().copied().collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
@@ -70,6 +70,60 @@ fn balance_previews_and_commits_one_step() {
|
||||
assert_eq!(fill_of(&s, a).to_hex(), "#808080");
|
||||
}
|
||||
|
||||
/// Saturate needs its intensity: a call without one, or with one that is not a number, is an error
|
||||
/// that changes nothing and records no undo step. A number outside −100..100 counts as its nearest end.
|
||||
#[test]
|
||||
fn saturate_needs_a_number_intensity() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, json!("#cc6666"));
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
for p in [json!({}), json!({"intensity": "40"}), json!({"intensity": null})] {
|
||||
let e = s.execute("edit.colors.saturate", &p).unwrap_err().to_string();
|
||||
assert!(e.contains("missing number `intensity`"), "{p}: {e}");
|
||||
}
|
||||
assert_eq!(fill_of(&s, a).to_hex(), "#cc6666");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no undo step");
|
||||
s.execute("edit.colors.saturate", &json!({"intensity": 100})).unwrap();
|
||||
let full = fill_of(&s, a).to_hex();
|
||||
assert_ne!(full, "#cc6666");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.colors.saturate", &json!({"intensity": 250})).unwrap();
|
||||
assert_eq!(fill_of(&s, a).to_hex(), full, "250 counts as 100");
|
||||
}
|
||||
|
||||
/// Saturate at 0 leaves every color as it was: a swatch-linked fill keeps its exact color and its link.
|
||||
#[test]
|
||||
fn saturate_keeps_a_color_it_does_not_change() {
|
||||
let mut s = session();
|
||||
s.execute("swatch.new", &json!({"name": "Sky", "color": "#3a7bd5", "global": true})).unwrap();
|
||||
let a = rect(&mut s, json!("#3a7bd5"));
|
||||
s.execute("paint.setFill", &json!({"ids": [a.0], "swatch": "Sky"})).unwrap();
|
||||
let b = rect(&mut s, json!({"c": 20, "m": 40, "y": 0, "k": 10}));
|
||||
let paint = |s: &Session, id: NodeId| s.doc().unwrap().doc.node(id).unwrap().appearance.fill_paint().clone();
|
||||
let (sky, cmyk) = (paint(&s, a), paint(&s, b));
|
||||
let v = s.execute("edit.colors.saturate", &json!({"ids": [a.0, b.0], "intensity": 0})).unwrap();
|
||||
assert_eq!(v["changed"], 0);
|
||||
assert_eq!(paint(&s, a), sky);
|
||||
assert_eq!(paint(&s, b), cmyk);
|
||||
}
|
||||
|
||||
/// Adjust Color Balance refuses a channel that is not a number, which it would otherwise read as 0.
|
||||
#[test]
|
||||
fn adjust_balance_refuses_a_channel_that_is_not_a_number() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, json!("#808080"));
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
for p in [json!({"mode": "rgb", "r": "40"}), json!({"c": "10"}), json!({"gray": true}), json!({"tint": "5"})] {
|
||||
let e = s.execute("edit.colors.adjustBalance", &p).unwrap_err().to_string();
|
||||
assert!(e.contains("must be a number"), "{p}: {e}");
|
||||
}
|
||||
assert_eq!(fill_of(&s, a).to_hex(), "#808080");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no undo step");
|
||||
// A null is read as a channel left out.
|
||||
s.execute("edit.colors.adjustBalance", &json!({"mode": "rgb", "r": 20, "g": null})).unwrap();
|
||||
assert_eq!(fill_of(&s, a).to_hex(), "#b38080");
|
||||
}
|
||||
|
||||
// ---------- meshes, images and patterns ----------
|
||||
|
||||
fn mesh_colors(s: &Session, id: NodeId) -> Vec<Color> {
|
||||
@@ -133,6 +187,52 @@ fn a_clicked_mesh_point_takes_the_fill_colour() {
|
||||
assert_eq!((after[0], after[3]), (before[0], before[3]));
|
||||
}
|
||||
|
||||
fn mesh_opacities(s: &Session, id: NodeId) -> Vec<f32> {
|
||||
match &s.doc().unwrap().doc.node(id).unwrap().kind {
|
||||
NodeKind::Mesh(m) => m.points.iter().map(|p| p.opacity).collect(),
|
||||
_ => panic!("not a mesh"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The Transparency panel's opacity on a clicked mesh point fades that point alone, not the whole
|
||||
/// object (#4206).
|
||||
#[test]
|
||||
fn a_clicked_mesh_point_takes_the_opacity() {
|
||||
let mut s = session();
|
||||
let m = rect(&mut s, json!("#ff0000"));
|
||||
s.execute("object.mesh.create", &json!({"rows": 2, "cols": 2})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [m.0]})).unwrap();
|
||||
let v = ViewInfo::default();
|
||||
let click = |s: &mut Session, i: usize| {
|
||||
let NodeKind::Mesh(g) = &s.doc().unwrap().doc.node(m).unwrap().kind else { panic!("not a mesh") };
|
||||
let q = g.points[i].p;
|
||||
for kind in [PointerKind::Down, PointerKind::Up] {
|
||||
s.pointer(&PointerEvent::new(kind, q.x, q.y), v).unwrap();
|
||||
}
|
||||
};
|
||||
let object_opacity = |s: &Session| s.doc().unwrap().doc.node(m).unwrap().opacity;
|
||||
for (tool, point) in [("mesh", 4), ("directSelection", 4)] {
|
||||
s.select_tool(tool, v).unwrap();
|
||||
click(&mut s, point);
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
s.execute("transparency.set", &json!({"opacity": 30})).unwrap();
|
||||
let o = mesh_opacities(&s, m);
|
||||
assert!((o[4] - 0.3).abs() < 1e-6, "{tool}: the clicked point fades: {o:?}");
|
||||
assert!(o.iter().enumerate().all(|(i, a)| i == 4 || *a == 1.0), "{tool}: the other points stay opaque: {o:?}");
|
||||
assert_eq!(object_opacity(&s), 1.0, "{tool}: the object stays opaque");
|
||||
assert_eq!(s.transparency_info().opacity, Some(30.0), "{tool}: the panel shows the point's opacity");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(mesh_opacities(&s, m).iter().all(|a| *a == 1.0));
|
||||
}
|
||||
// With no point focused the object takes the opacity.
|
||||
s.select_tool("selection", v).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [m.0]})).unwrap();
|
||||
s.execute("transparency.set", &json!({"opacity": 30})).unwrap();
|
||||
assert!((object_opacity(&s) - 0.3).abs() < 1e-6);
|
||||
assert!(mesh_opacities(&s, m).iter().all(|a| *a == 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blend_moves_a_mesh_to_the_blended_colour() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -403,6 +403,58 @@ fn new_layers_go_above_the_current_one_at_its_level() {
|
||||
assert_eq!(unique.len(), names.len(), "{names:?}");
|
||||
}
|
||||
|
||||
/// `below` puts a new layer directly below the current layer, at its level, and `bottom` puts a
|
||||
/// new sublayer under the parent's contents, each in one undo step.
|
||||
#[test]
|
||||
fn new_layers_go_below_the_current_one_and_sublayers_under_the_contents() {
|
||||
let mut s = session();
|
||||
let (layer, sub, a, g, _, c) = tree(&mut s);
|
||||
run(&mut s, "layer.setCurrent", json!({"id": sub.0}));
|
||||
let before = undos(&s);
|
||||
let n = id(&run(&mut s, "layer.new", json!({"below": true})));
|
||||
assert_eq!(children(&s, layer), vec![c, n, sub], "a sublayer directly below the current sublayer");
|
||||
assert_eq!(undos(&s), before + 1);
|
||||
assert_eq!(s.doc().unwrap().highlighted_rows(), vec![n]);
|
||||
assert_eq!(s.doc().unwrap().current_layer(), Some(n));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(children(&s, layer), vec![c, sub]);
|
||||
// At the top level: below the current layer, above the layers under it.
|
||||
let top = id(&run(&mut s, "layer.new", json!({"top": true, "name": "Top"})));
|
||||
let middle = id(&run(&mut s, "layer.new", json!({"below": true, "name": "Middle", "locked": true})));
|
||||
let order = s.doc().unwrap().doc.layers.iter().map(|l| l.id).collect::<Vec<_>>();
|
||||
assert_eq!(order, vec![layer, middle, top]);
|
||||
assert!(node(&s, middle).locked, "options as without `below`");
|
||||
run(&mut s, "layer.setCurrent", json!({"id": layer.0}));
|
||||
let bottom = id(&run(&mut s, "layer.new", json!({"below": true})));
|
||||
assert_eq!(s.doc().unwrap().doc.layers[0].id, bottom, "below the bottom layer");
|
||||
// Sublayers: on top of the parent's contents, or under them with `bottom`.
|
||||
let before = undos(&s);
|
||||
let under = id(&run(&mut s, "layer.newSublayer", json!({"parent": sub.0, "bottom": true})));
|
||||
assert_eq!(children(&s, sub), vec![under, a, g]);
|
||||
assert_eq!(undos(&s), before + 1);
|
||||
assert_eq!(s.doc().unwrap().highlighted_rows(), vec![under]);
|
||||
let over = id(&run(&mut s, "layer.newSublayer", json!({"parent": sub.0, "bottom": false})));
|
||||
assert_eq!(children(&s, sub), vec![under, a, g, over]);
|
||||
// Bad values change nothing.
|
||||
let count = s.doc().unwrap().doc.node_count();
|
||||
for (cmd, p) in [
|
||||
("layer.new", json!({"below": true, "top": true})),
|
||||
("layer.new", json!({"below": "yes"})),
|
||||
("layer.newSublayer", json!({"parent": sub.0, "bottom": 1})),
|
||||
] {
|
||||
assert!(s.execute(cmd, &p).is_err(), "{cmd} {p}");
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().doc.node_count(), count);
|
||||
// In a clipping layer the clipping path stays at the bottom.
|
||||
let mut s = session();
|
||||
let layer = s.doc().unwrap().doc.layers[0].id;
|
||||
let (back, front) = (rect(&mut s, 0.0), rect(&mut s, 100.0));
|
||||
run(&mut s, "layer.clippingMask.toggle", json!({"id": layer.0}));
|
||||
assert_eq!(children(&s, layer), vec![front, back], "the top object clips");
|
||||
let under = id(&run(&mut s, "layer.newSublayer", json!({"parent": layer.0, "bottom": true})));
|
||||
assert_eq!(children(&s, layer), vec![front, under, back]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_move_above_below_and_into_layers_and_groups() {
|
||||
let mut s = session();
|
||||
@@ -472,6 +524,60 @@ fn moves_are_validated() {
|
||||
assert_eq!(parent(&s, sub), None);
|
||||
}
|
||||
|
||||
/// The Layers panel's commands refuse a switch given as text or as another value that is not true
|
||||
/// or false, and leave the document, the selection, the highlighted rows and the undo history as
|
||||
/// they were; true, false and null still work.
|
||||
#[test]
|
||||
fn layer_commands_refuse_a_switch_given_as_text() {
|
||||
let fixture = || {
|
||||
let mut s = session();
|
||||
let (layer, sub, a, g, _, c) = tree(&mut s);
|
||||
run(&mut s, "layer.setCurrent", json!({"id": sub.0}));
|
||||
(s, layer, sub, a, g, c)
|
||||
};
|
||||
let (_, layer, sub, a, g, _) = fixture();
|
||||
let mut unexpected = vec![];
|
||||
for (cmd, p, key) in [
|
||||
("layer.new", json!({"top": "true"}), "top"),
|
||||
("layer.new", json!({"top": 1, "name": "Top"}), "top"),
|
||||
("layer.newSublayer", json!({"top": "false"}), "top"),
|
||||
("layer.selectAll", json!({"id": layer.0, "add": "true"}), "add"),
|
||||
("layer.target", json!({"id": layer.0, "add": 1}), "add"),
|
||||
("layer.move", json!({"ids": [a.0], "target": layer.0, "copy": "true"}), "copy"),
|
||||
("layer.move", json!({"ids": [a.0], "target": g.0, "copy": {}}), "copy"),
|
||||
("layer.template", json!({"ids": [layer.0], "on": "false"}), "on"),
|
||||
("layer.pasteRemembersLayers", json!({"on": "false"}), "on"),
|
||||
("layer.pasteRemembersLayers", json!({"on": 0}), "on"),
|
||||
("layer.releaseToLayers", json!({"id": sub.0, "build": [true]}), "build"),
|
||||
] {
|
||||
// Each call on a fresh copy of the same tree, so one that goes through does not change the next.
|
||||
let (mut s, l, sl, o, gr, _) = fixture();
|
||||
assert_eq!((l, sl, o, gr), (layer, sub, a, g), "the same ids");
|
||||
let before = layer_target_snapshot(&s);
|
||||
let r = s.execute(cmd, &p);
|
||||
let want = format!("`{key}` must be true or false");
|
||||
if !matches!(&r, Err(EngineError::BadParams { cmd: got, msg }) if got == cmd && *msg == want) {
|
||||
unexpected.push(format!("{cmd} {p}: {r:?}"));
|
||||
} else if layer_target_snapshot(&s) != before {
|
||||
unexpected.push(format!("{cmd} {p}: refused, but the document, selection, rows or history changed"));
|
||||
}
|
||||
}
|
||||
assert!(unexpected.is_empty(), "not refused: {}", unexpected.join("; "));
|
||||
let (mut s, layer, sub, a, g, c) = fixture();
|
||||
// A null reads as the key left out.
|
||||
let n = id(&run(&mut s, "layer.new", json!({"top": null})));
|
||||
assert_eq!(parent(&s, n), Some(layer), "beside the current sublayer");
|
||||
run(&mut s, "layer.selectAll", json!({"id": sub.0, "add": null}));
|
||||
run(&mut s, "layer.selectAll", json!({"id": c.0, "add": true}));
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![a, g, c]);
|
||||
run(&mut s, "layer.move", json!({"ids": [a.0], "target": g.0, "copy": false}));
|
||||
assert_eq!(parent(&s, a), Some(g));
|
||||
assert_eq!(run(&mut s, "layer.template", json!({"ids": [layer.0], "on": null}))["on"], true);
|
||||
assert_eq!(run(&mut s, "layer.template", json!({"ids": [layer.0], "on": false}))["on"], false);
|
||||
assert_eq!(run(&mut s, "layer.pasteRemembersLayers", json!({"on": null}))["on"], true, "null toggles");
|
||||
assert_eq!(ids(&run(&mut s, "layer.releaseToLayers", json!({"id": g.0, "build": null}))).len(), 2);
|
||||
}
|
||||
|
||||
/// `node.move` into a hidden or locked layer deselects the object it moves, here the key object.
|
||||
/// Undo puts the object back and selects it again as the key object.
|
||||
#[test]
|
||||
|
||||
@@ -286,6 +286,145 @@ fn lock_and_hide_other_layers() {
|
||||
assert!(!s.doc().unwrap().doc.node(l2).unwrap().visible);
|
||||
}
|
||||
|
||||
/// Whether `id` is visible and whether it is locked.
|
||||
fn shown_and_locked(s: &Session, id: NodeId) -> (bool, bool) {
|
||||
let n = node(s, id);
|
||||
(n.visible, n.locked)
|
||||
}
|
||||
|
||||
/// Each of `commands` is off in isolation mode: the command list reports it disabled, its error
|
||||
/// names isolation mode, and so does the error of a call that gives the selection as `ids` (for a
|
||||
/// command that takes them).
|
||||
fn off_in_isolation(s: &mut Session, commands: &[&str]) {
|
||||
let ids: Vec<u64> = selected(s).iter().map(|i| i.0).collect();
|
||||
for c in commands {
|
||||
let spec = find_command(c).unwrap();
|
||||
assert_eq!(spec.info(s).disabled_reason.as_deref(), Some("exit isolation mode first"), "{c}");
|
||||
let given = cmd::params::takes_key(spec.params, "ids").then(|| json!({"ids": ids}));
|
||||
for p in std::iter::once(json!({})).chain(given) {
|
||||
let e = s.execute(c, &p).unwrap_err().to_string();
|
||||
assert!(e.contains("exit isolation mode first"), "{c} {p}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// In isolation mode, Lock › Selection and Hide › Selection are off and change nothing, in an
|
||||
/// isolated group and in an isolated compound path with one of its paths selected. After Exit
|
||||
/// Isolation Mode they lock and hide again.
|
||||
#[test]
|
||||
fn lock_and_hide_selection_are_off_in_isolation_mode() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
let b = rect(&mut s, 20.0, 0.0, 10.0, 10.0);
|
||||
sel(&mut s, &[a, b]);
|
||||
let g = id_of(&s.execute("object.group", &json!({})).unwrap());
|
||||
let outer = rect(&mut s, 100.0, 100.0, 100.0, 100.0);
|
||||
let hole = rect(&mut s, 120.0, 120.0, 20.0, 20.0);
|
||||
sel(&mut s, &[outer, hole]);
|
||||
let compound = id_of(&s.execute("object.compoundPath.make", &json!({})).unwrap());
|
||||
let ids = [a, b, g, outer, hole, compound];
|
||||
let commands = ["object.lock", "object.hide"];
|
||||
for (isolated, member) in [(g, a), (compound, hole)] {
|
||||
s.execute("object.isolate", &json!({"id": isolated.0})).unwrap();
|
||||
sel(&mut s, &[member]);
|
||||
off_in_isolation(&mut s, &commands);
|
||||
for id in ids {
|
||||
assert_eq!(shown_and_locked(&s, id), (true, false), "{id:?} in isolation of {isolated:?}");
|
||||
}
|
||||
assert_eq!(selected(&s), [member]);
|
||||
s.execute("object.exitIsolation", &json!({})).unwrap();
|
||||
}
|
||||
sel(&mut s, &[a]);
|
||||
s.execute("object.lock", &json!({})).unwrap();
|
||||
sel(&mut s, &[outer]);
|
||||
s.execute("object.hide", &json!({})).unwrap();
|
||||
assert_eq!(shown_and_locked(&s, a), (true, true));
|
||||
assert_eq!(shown_and_locked(&s, compound), (false, false));
|
||||
}
|
||||
|
||||
/// In isolation mode, Unlock All and Show All are off: the locked and the hidden objects outside
|
||||
/// and inside the isolated group stay as they are. Pattern editing mode and opacity mask editing
|
||||
/// are not isolation mode for them.
|
||||
#[test]
|
||||
fn unlock_all_and_show_all_are_off_in_isolation_mode() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
let b = rect(&mut s, 20.0, 0.0, 10.0, 10.0);
|
||||
let c = rect(&mut s, 40.0, 0.0, 10.0, 10.0);
|
||||
sel(&mut s, &[a, b, c]);
|
||||
let g = id_of(&s.execute("object.group", &json!({})).unwrap());
|
||||
let locked = rect(&mut s, 100.0, 0.0, 10.0, 10.0);
|
||||
let hidden = rect(&mut s, 120.0, 0.0, 10.0, 10.0);
|
||||
s.execute("layer.setProps", &json!({"id": locked.0, "locked": true})).unwrap();
|
||||
s.execute("layer.setProps", &json!({"id": hidden.0, "visible": false})).unwrap();
|
||||
s.execute("layer.setProps", &json!({"id": b.0, "locked": true})).unwrap();
|
||||
s.execute("layer.setProps", &json!({"id": c.0, "visible": false})).unwrap();
|
||||
s.execute("object.isolate", &json!({"id": g.0})).unwrap();
|
||||
sel(&mut s, &[a]);
|
||||
off_in_isolation(&mut s, &["object.unlockAll", "object.showAll"]);
|
||||
for (id, want) in [(locked, (true, true)), (hidden, (false, false)), (b, (true, true)), (c, (false, false))] {
|
||||
assert_eq!(shown_and_locked(&s, id), want, "{id:?}");
|
||||
}
|
||||
s.execute("object.exitIsolation", &json!({})).unwrap();
|
||||
s.execute("object.unlockAll", &json!({})).unwrap();
|
||||
s.execute("object.showAll", &json!({})).unwrap();
|
||||
for id in [locked, hidden, b, c] {
|
||||
assert_eq!(shown_and_locked(&s, id), (true, false), "{id:?}");
|
||||
}
|
||||
|
||||
// Pattern editing mode: the tile's art hides and shows again.
|
||||
let r = rect(&mut s, 300.0, 300.0, 10.0, 10.0);
|
||||
sel(&mut s, &[r]);
|
||||
s.execute("object.pattern.make", &json!({"name": "P"})).unwrap();
|
||||
let tile = selected(&s);
|
||||
s.execute("object.hide", &json!({})).unwrap();
|
||||
assert!(!node(&s, tile[0]).visible, "hidden in pattern editing mode");
|
||||
s.execute("object.showAll", &json!({})).unwrap();
|
||||
assert!(node(&s, tile[0]).visible, "shown in pattern editing mode");
|
||||
// An object isolated inside pattern editing mode is isolation mode again.
|
||||
s.execute("object.isolate", &json!({"id": tile[0].0})).unwrap();
|
||||
sel(&mut s, &[tile[0]]);
|
||||
off_in_isolation(&mut s, &["object.unlockAll", "object.showAll"]);
|
||||
s.execute("object.pattern.cancel", &json!({})).unwrap();
|
||||
|
||||
// Opacity mask editing: the mask's art locks and unlocks again.
|
||||
let obj = rect(&mut s, 0.0, 300.0, 200.0, 200.0);
|
||||
let m = rect(&mut s, 0.0, 300.0, 100.0, 200.0);
|
||||
sel(&mut s, &[obj, m]);
|
||||
s.execute("transparency.makeOpacityMask", &json!({})).unwrap();
|
||||
s.execute("transparency.editOpacityMask", &json!({})).unwrap();
|
||||
let mask = selected(&s);
|
||||
s.execute("object.lock", &json!({})).unwrap();
|
||||
assert!(node(&s, mask[0]).locked, "locked in opacity mask editing");
|
||||
s.execute("object.unlockAll", &json!({})).unwrap();
|
||||
assert!(!node(&s, mask[0]).locked, "unlocked in opacity mask editing");
|
||||
}
|
||||
|
||||
/// In isolation mode, Lock › All Artwork Above, Lock › Other Layers, Hide › All Artwork Above and
|
||||
/// Hide › Other Layers are off: the art above and the other layer stay as they are.
|
||||
#[test]
|
||||
fn lock_and_hide_above_and_other_layers_are_off_in_isolation_mode() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
let b = rect(&mut s, 200.0, 0.0, 10.0, 10.0);
|
||||
sel(&mut s, &[a, b]);
|
||||
let g = id_of(&s.execute("object.group", &json!({})).unwrap());
|
||||
let over = rect(&mut s, 50.0, 50.0, 100.0, 100.0);
|
||||
let l2 = id_of(&s.execute("layer.new", &json!({})).unwrap());
|
||||
s.execute("object.isolate", &json!({"id": g.0})).unwrap();
|
||||
sel(&mut s, &[a]);
|
||||
off_in_isolation(&mut s, &["object.lock.above", "object.lock.otherLayers", "object.hide.above", "object.hide.otherLayers"]);
|
||||
for id in [a, b, g, over, l2] {
|
||||
assert_eq!(shown_and_locked(&s, id), (true, false), "{id:?}");
|
||||
}
|
||||
s.execute("object.exitIsolation", &json!({})).unwrap();
|
||||
sel(&mut s, &[g]);
|
||||
assert_eq!(s.execute("object.lock.above", &json!({})).unwrap(), json!({"count": 1}));
|
||||
s.execute("object.hide.otherLayers", &json!({})).unwrap();
|
||||
assert_eq!(shown_and_locked(&s, over), (true, true));
|
||||
assert_eq!(shown_and_locked(&s, l2), (false, false));
|
||||
}
|
||||
|
||||
/// How many steps there are to undo and to redo, and whether the document has unsaved changes.
|
||||
fn history(s: &Session) -> (usize, usize, bool) {
|
||||
let st = s.doc().unwrap();
|
||||
@@ -645,6 +784,31 @@ fn expand_live_shape_and_text_in_one_undo_step() {
|
||||
assert!(s.doc().unwrap().doc.node(t).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_text_outlines_its_stroke_too() {
|
||||
let mut s = session();
|
||||
let t = text(&mut s, "Hi");
|
||||
sel(&mut s, &[t]);
|
||||
s.execute("paint.setStroke", &json!({"color": "#ff0000"})).unwrap();
|
||||
let info = s.execute("object.expand.info", &json!({})).unwrap();
|
||||
assert_eq!(info["stroke"], json!(true), "stroked type has a stroke to expand");
|
||||
let n = undo_len(&s);
|
||||
s.execute("object.expand", &json!({})).unwrap();
|
||||
assert_eq!(undo_len(&s), n + 1);
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let (mut paths, mut stroked) = (0, 0);
|
||||
for id in &s.doc().unwrap().selection.objects {
|
||||
d.node(*id).unwrap().walk(&mut |c| {
|
||||
if matches!(c.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) {
|
||||
paths += 1;
|
||||
stroked += usize::from(!c.appearance.stroke_paint().is_none());
|
||||
}
|
||||
});
|
||||
}
|
||||
assert!(paths > 0);
|
||||
assert_eq!(stroked, 0, "every glyph's stroke became a filled outline ({paths} paths)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_nothing_errors() {
|
||||
let mut s = session();
|
||||
@@ -1577,6 +1741,20 @@ fn layers_panel_selects_nothing_outside_the_isolated_group() {
|
||||
assert_eq!(selected(&s), vec![b]);
|
||||
}
|
||||
|
||||
/// In isolation mode the selection column of the isolated layer's own row selects the art on it.
|
||||
#[test]
|
||||
fn layers_panel_selects_the_art_of_the_isolated_layer() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 50.0, 50.0);
|
||||
let b = rect(&mut s, 100.0, 0.0, 50.0, 50.0);
|
||||
let layer = s.active().unwrap().doc.layers[0].id;
|
||||
s.execute("object.isolate", &json!({"id": layer.0})).unwrap();
|
||||
s.execute("layer.selectAll", &json!({"id": layer.0})).unwrap();
|
||||
let mut got = selected(&s);
|
||||
got.sort_by_key(|i| i.0);
|
||||
assert_eq!(got, vec![a, b]);
|
||||
}
|
||||
|
||||
/// In isolation mode a recalled selection leaves out the objects outside the isolated group, and
|
||||
/// Next Object Above selects nothing outside it.
|
||||
#[test]
|
||||
|
||||
@@ -311,6 +311,39 @@ fn artboard_commands_refuse_a_boolean_or_number_given_as_text() {
|
||||
assert_eq!(node(&s, r).geometric_bounds().map(|b| b.x0), Some(20.0));
|
||||
}
|
||||
|
||||
/// New Artboard refuses a position or size given as text or as another value that is not a number,
|
||||
/// such as "10", and New Artboard and Artboard Options refuse a name that is not text, such as 5.
|
||||
/// The artboards and the undo history stay as they were; a null reads as the key left out.
|
||||
#[test]
|
||||
fn artboard_commands_refuse_a_number_or_name_of_the_wrong_type() {
|
||||
use crate::tests::{not_a_number, not_refused};
|
||||
let mut s = session();
|
||||
s.execute("artboard.new", &json!({})).unwrap();
|
||||
let mut calls: Vec<_> = [
|
||||
("artboard.new", "x", json!({"x": "10"})),
|
||||
("artboard.new", "y", json!({"x": 2000, "y": [0]})),
|
||||
("artboard.new", "width", json!({"width": "200"})),
|
||||
("artboard.new", "height", json!({"height": true})),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(cmd, key, p)| not_a_number(cmd, p, key))
|
||||
.collect();
|
||||
let not_text = |cmd: &str, p: serde_json::Value| (cmd.to_string(), p, "`name` must be a string".to_string());
|
||||
calls.extend([
|
||||
not_text("artboard.new", json!({"name": 5})),
|
||||
not_text("artboard.setProps", json!({"index": 0, "name": 5})),
|
||||
not_text("artboard.setProps", json!({"index": 0, "x": 50, "name": true})),
|
||||
not_text("artboard.setProps", json!({"artboards": [{"index": 0, "name": "Front"}, {"index": 1, "name": ["Back"]}]})),
|
||||
]);
|
||||
let wrong = not_refused(&mut s, &calls);
|
||||
assert!(wrong.is_empty(), "not refused: {wrong:#?}");
|
||||
s.execute("artboard.new", &json!({"x": null, "y": null, "width": null, "height": null, "name": null})).unwrap();
|
||||
s.execute("artboard.setProps", &json!({"index": 0, "name": "Front"})).unwrap();
|
||||
s.execute("artboard.setProps", &json!({"index": 0, "name": null})).unwrap();
|
||||
let names: Vec<String> = boards(&s).into_iter().map(|(_, n)| n).collect();
|
||||
assert_eq!(names, ["Front", "Artboard 2", "Artboard 3"]);
|
||||
}
|
||||
|
||||
/// Artboards 1 and 2, each with a square on it, and an artboard 3 below artboard 1 with a square on
|
||||
/// it, with the Artboard tool selecting artboards 1 and 2 → the squares' ids, artboard by artboard.
|
||||
fn two_selected_artboards(s: &mut Session) -> [u64; 3] {
|
||||
|
||||
@@ -430,6 +430,17 @@ fn offset_rect_grows_bounds() {
|
||||
assert!(close(bounds(&s, r[0]), Rect::new(105.0, 105.0, 145.0, 135.0), 1e-2));
|
||||
}
|
||||
|
||||
/// #3773: an open path whose last anchor sits on its first is offset as the closed shape, not as a
|
||||
/// ring around its outline.
|
||||
#[test]
|
||||
fn offset_open_path_whose_ends_meet_grows_the_shape() {
|
||||
let mut s = session();
|
||||
s.execute("path.create", &json!({"d": "M0 0 L40 0 L40 40 L0 40 L0 0"})).unwrap();
|
||||
let r = ids(&s.execute("object.path.offsetPath", &json!({"offset": 2, "joins": "miter"})).unwrap());
|
||||
assert!((area_of(&s, r[0]) - 44.0 * 44.0).abs() < 1e-3, "{}", area_of(&s, r[0]));
|
||||
assert_eq!(path_of(&s, r[0]).0.subpaths.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outline_stroke_of_line() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -994,6 +994,41 @@ fn a_frame_dragged_with_the_type_tools_is_the_dragged_rectangle() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Through the session, a click with Shift held and the Type tool chosen makes vertical type and
|
||||
/// edits it with the Type tool still chosen, and the pointer with Shift held is the vertical
|
||||
/// I-beam. A click with Alt held and the Type on a Path tool chosen turns a rectangle into area
|
||||
/// type.
|
||||
#[test]
|
||||
fn shift_and_alt_switch_the_type_tools_through_the_session() {
|
||||
let mut s = session();
|
||||
let v = ViewInfo::default();
|
||||
let (shift, alt) = (Mods { shift: true, ..Mods::default() }, Mods { alt: true, ..Mods::default() });
|
||||
s.select_tool("type", v).unwrap();
|
||||
assert_eq!(s.cursor(Point::new(400.0, 300.0), shift, v), vectorcraft_tools::Cursor::TypeVertical);
|
||||
assert_eq!(s.cursor(Point::new(400.0, 300.0), Mods::default(), v), vectorcraft_tools::Cursor::Text);
|
||||
for k in [PointerKind::Down, PointerKind::Up] {
|
||||
s.pointer(&PointerEvent::new(k, 400.0, 300.0).with_mods(shift), v).unwrap();
|
||||
}
|
||||
assert_eq!(s.tool_id(), "type");
|
||||
let id = s.doc().unwrap().selection.objects[0];
|
||||
assert!(obj(&s, id).vertical);
|
||||
assert!(s.tool_wants_text());
|
||||
s.tool_text("縦", v).unwrap();
|
||||
assert_eq!(obj(&s, id).plain_text(), "縦");
|
||||
key(&mut s, ToolKey::Escape, Mods::default());
|
||||
|
||||
let rect = NodeId(s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 200, "height": 100})).unwrap()["id"].as_u64().unwrap());
|
||||
s.select_tool("typeOnPath", v).unwrap();
|
||||
for k in [PointerKind::Down, PointerKind::Up] {
|
||||
s.pointer(&PointerEvent::new(k, 100.0, 150.0).with_mods(alt), v).unwrap();
|
||||
}
|
||||
assert_eq!(s.tool_id(), "typeOnPath");
|
||||
assert!(s.doc().unwrap().doc.node(rect).is_none(), "the rectangle became the frame");
|
||||
let id = s.doc().unwrap().selection.objects[0];
|
||||
assert!(matches!(obj(&s, id).kind, TextKind::Area { .. }));
|
||||
assert!(!obj(&s, id).vertical);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn area_type_from_a_circle_keeps_glyphs_inside() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
//! Edit › Undo and Redo while the Type tool edits: the caret goes to where the step changed the text.
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::{NodeId, NodeKind};
|
||||
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
|
||||
|
||||
use crate::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.prefs.placeholder_text = false;
|
||||
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn plain(s: &Session, id: NodeId) -> String {
|
||||
match &s.doc().unwrap().doc.node(id).unwrap().kind {
|
||||
NodeKind::Text(t) => t.plain_text(),
|
||||
_ => panic!("not text"),
|
||||
}
|
||||
}
|
||||
|
||||
fn caret(s: &Session) -> (u64, u64) {
|
||||
let o = s.tool_options();
|
||||
(o["caret"].as_u64().unwrap(), o["anchor"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
/// "abc" typed before "XYZ": Undo puts the caret before "XYZ" and Redo after "abc", from the
|
||||
/// menu command and from the History panel's steps alike (both run `edit.undo` and `edit.redo`).
|
||||
#[test]
|
||||
fn undo_and_redo_put_the_caret_where_the_step_changed_the_text() {
|
||||
let v = ViewInfo::default();
|
||||
let mut s = session();
|
||||
let r = s.execute("text.create", &json!({"x": 100, "y": 100, "text": "XYZ", "size": 20})).unwrap();
|
||||
let id = NodeId(r["id"].as_u64().unwrap());
|
||||
s.select_tool("type", v).unwrap();
|
||||
for k in [PointerKind::Down, PointerKind::Up] {
|
||||
s.pointer(&PointerEvent::new(k, 101.0, 95.0), v).unwrap();
|
||||
}
|
||||
s.set_tool_option("select", &json!({"start": 0, "end": 0}));
|
||||
s.tool_text("abc", v).unwrap();
|
||||
assert_eq!((plain(&s, id).as_str(), caret(&s)), ("abcXYZ", (3, 3)));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!((plain(&s, id).as_str(), caret(&s)), ("XYZ", (0, 0)));
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!((plain(&s, id).as_str(), caret(&s)), ("abcXYZ", (3, 3)));
|
||||
// A deleted character comes back with the caret after it, wherever the caret went since.
|
||||
s.tool_key(ToolKey::End, Mods::default(), v).unwrap();
|
||||
s.tool_key(ToolKey::Backspace, Mods::default(), v).unwrap();
|
||||
assert_eq!(plain(&s, id), "abcXY");
|
||||
s.tool_key(ToolKey::Home, Mods::default(), v).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!((plain(&s, id).as_str(), caret(&s)), ("abcXYZ", (6, 6)));
|
||||
}
|
||||
|
||||
/// Undo and Redo run as steps of `command.batch` put the caret where they do run alone.
|
||||
#[test]
|
||||
fn undo_and_redo_in_a_batch_put_the_caret_where_the_step_changed_the_text() {
|
||||
let v = ViewInfo::default();
|
||||
let mut s = session();
|
||||
let r = s.execute("text.create", &json!({"x": 100, "y": 100, "text": "XYZ", "size": 20})).unwrap();
|
||||
let id = NodeId(r["id"].as_u64().unwrap());
|
||||
s.select_tool("type", v).unwrap();
|
||||
for k in [PointerKind::Down, PointerKind::Up] {
|
||||
s.pointer(&PointerEvent::new(k, 101.0, 95.0), v).unwrap();
|
||||
}
|
||||
s.set_tool_option("select", &json!({"start": 0, "end": 0}));
|
||||
s.tool_text("abc", v).unwrap();
|
||||
s.execute("command.batch", &json!({"commands": [{"command": "edit.undo"}]})).unwrap();
|
||||
assert_eq!((plain(&s, id).as_str(), caret(&s)), ("XYZ", (0, 0)));
|
||||
s.execute("command.batch", &json!({"commands": [{"command": "edit.redo"}]})).unwrap();
|
||||
assert_eq!((plain(&s, id).as_str(), caret(&s)), ("abcXYZ", (3, 3)));
|
||||
// A batch whose later step fails is rolled back: the text and the caret are as they were, and
|
||||
// Undo still takes back the typing.
|
||||
let failed = json!({"commands": [{"command": "edit.undo"}, {"command": "no.suchCommand"}]});
|
||||
assert!(s.execute("command.batch", &failed).is_err());
|
||||
assert_eq!((plain(&s, id).as_str(), caret(&s)), ("abcXYZ", (3, 3)));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!((plain(&s, id).as_str(), caret(&s)), ("XYZ", (0, 0)));
|
||||
}
|
||||
@@ -201,7 +201,7 @@ impl Session {
|
||||
let r = self.apply_actions(acts);
|
||||
self.keep_tool_settings();
|
||||
self.tool = lender;
|
||||
self.after_command();
|
||||
self.after_command(None);
|
||||
r.map(drop)
|
||||
}
|
||||
|
||||
@@ -912,10 +912,15 @@ impl Session {
|
||||
|
||||
/// Let the active tool finish work a command from outside it ended ([`vectorcraft_tools::Tool::after_command`]):
|
||||
/// the Type tool and the Eyedropper clear a character range whose text is no longer selected.
|
||||
pub(crate) fn after_command(&mut self) {
|
||||
/// After `edit.undo` or `edit.redo` (`cmd`), the tool is told first, so the Type tool puts the
|
||||
/// caret where the step changed the text.
|
||||
pub(crate) fn after_command(&mut self, cmd: Option<&str>) {
|
||||
if self.in_tool_actions || (!self.tool.wants_text() && self.tool.id() != "eyedropper") {
|
||||
return;
|
||||
}
|
||||
if let Some(cmd @ ("edit.undo" | "edit.redo")) = cmd {
|
||||
self.notify_text_tool(cmd);
|
||||
}
|
||||
let acts = self.with_tool_cx(self.last_view, |t, cx| t.after_command(cx));
|
||||
// The command itself succeeded: a failure here only leaves the edit as it was.
|
||||
if let Err(e) = self.apply_actions(acts) {
|
||||
@@ -923,6 +928,17 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// Tell the active tool that `edit.undo` or `edit.redo` ran, on its own or as a step of
|
||||
/// `command.batch`, so the Type tool puts the caret where the step changed the text; or that a
|
||||
/// `command.batch` began, ended or was rolled back (`batch.begin`, `batch.end`,
|
||||
/// `batch.rollBack`), so the Type tool puts back what its Undo and Redo steps changed when it fails.
|
||||
pub(crate) fn notify_text_tool(&mut self, what: &str) {
|
||||
if self.in_tool_actions || (!self.tool.wants_text() && self.tool.id() != "eyedropper") {
|
||||
return;
|
||||
}
|
||||
self.with_tool_cx(self.last_view, |t, cx| t.notify(cx, what));
|
||||
}
|
||||
|
||||
/// Apply tool actions. Errors from previews are reported but keep the interaction alive.
|
||||
pub fn apply_actions(&mut self, acts: Vec<Action>) -> Result<Vec<UiRequest>> {
|
||||
let outer = std::mem::replace(&mut self.in_tool_actions, true);
|
||||
|
||||
@@ -33,7 +33,7 @@ use super::lex::find;
|
||||
use crate::ps;
|
||||
|
||||
pub(crate) use frames::text_frames;
|
||||
pub use read::{IMAGE_TOO_LARGE, Structure, artboards, read, variables};
|
||||
pub use read::{IMAGE_TOO_LARGE, PaletteSwatches, Structure, artboards, palette, read, variables};
|
||||
pub(crate) use read::{slot_frame, slot_of};
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -74,7 +74,7 @@ mod ops;
|
||||
mod types;
|
||||
|
||||
use art::preview_png;
|
||||
pub use document::{VariableLibrary, artboards, variables};
|
||||
pub use document::{PaletteSwatches, VariableLibrary, artboards, palette, variables};
|
||||
pub(crate) use document::{slot_frame, slot_of};
|
||||
pub use types::IMAGE_TOO_LARGE;
|
||||
use types::{BLEND_BUDGET, FLIP, Frame, GState, Kind, PatternHead, Place, Raster};
|
||||
|
||||
@@ -2,6 +2,7 @@ use super::art::{element_name, elements, join_and, sane};
|
||||
use super::containers::drop_commented_copies;
|
||||
use super::types::{FLIP, Kind, MAX_DEPTH};
|
||||
use super::{Affine, Artboard, ColorMode, Document, NodeKind, Obj, OpacityMask, Paint, Point, Reader, Rect, Structure, Swatch, Unit, V, obj};
|
||||
use vectorcraft_doc::{ImageBlob, PatternDef};
|
||||
|
||||
/// `%AI5_RulerUnits` → [`Unit`] (Illustrator 7 spec table 3; centimeters is 4; pixels is 6 in later files).
|
||||
fn ruler_unit(v: u8) -> Option<Unit> {
|
||||
@@ -72,6 +73,44 @@ fn artboard_array(data: &[u8]) -> Option<&[u8]> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The palette of a `.ai` file's editing data, as [`palette`] reads it.
|
||||
pub struct PaletteSwatches {
|
||||
pub swatches: Vec<Swatch>,
|
||||
pub groups: Vec<vectorcraft_color::swatch::SwatchGroup>,
|
||||
/// The patterns the editing data defines that the given patterns lack and a swatch paints with.
|
||||
pub patterns: Vec<PatternDef>,
|
||||
/// The image blobs the art of `patterns` shows, by key.
|
||||
pub images: std::collections::BTreeMap<String, ImageBlob>,
|
||||
}
|
||||
|
||||
/// The swatches and color groups of the palette of the editing data `data`, for a file whose
|
||||
/// layers aren't read: the swatches its PDF part has are the defaults (#4036). They come as
|
||||
/// [`read`] makes them, with pattern swatches only for the patterns in `patterns` and those the
|
||||
/// editing data defines; `None` when the palette is empty or reading stops before it.
|
||||
pub fn palette(data: &[u8], patterns: &[PatternDef]) -> Option<PaletteSwatches> {
|
||||
let mut r = Reader::new(data);
|
||||
// The palette, the gradients and the patterns it names come before the art, so an error in the
|
||||
// art leaves them read.
|
||||
let _ = r.run();
|
||||
let mut known = patterns.to_vec();
|
||||
let defined = std::mem::take(&mut r.doc.patterns);
|
||||
known.extend(defined.iter().filter(|d| !patterns.iter().any(|p| p.name == d.name)).cloned());
|
||||
let (swatches, groups) = r.palette_swatches(&known)?;
|
||||
let painted = |name: &str| {
|
||||
swatches.iter().chain(groups.iter().flat_map(|g| &g.swatches)).any(|s| matches!(&s.paint, Paint::Pattern { pattern, .. } if pattern == name))
|
||||
};
|
||||
let added: Vec<PatternDef> = defined.into_iter().filter(|d| !patterns.iter().any(|p| p.name == d.name) && painted(&d.name)).collect();
|
||||
let mut shown = Document::new(1.0, 1.0);
|
||||
shown.patterns.clone_from(&added);
|
||||
let mut images = std::collections::BTreeMap::new();
|
||||
shown.visit_images(|_, im| {
|
||||
if let Some(blob) = r.doc.images.get(&im.key) {
|
||||
images.insert(im.key.clone(), blob.clone());
|
||||
}
|
||||
});
|
||||
Some(PaletteSwatches { swatches, groups, patterns: added, images })
|
||||
}
|
||||
|
||||
/// [`artboards`] as far as reading `data` from its start gets: those read before an error, and
|
||||
/// those of a `/Document` still open when it stopped (its art can be what stopped it).
|
||||
fn artboards_read(data: &[u8]) -> Vec<(String, Rect)> {
|
||||
@@ -290,6 +329,40 @@ impl<'a> Reader<'a> {
|
||||
if self.pattern.is_some() { FLIP } else { self.ensure_space() }
|
||||
}
|
||||
|
||||
/// The swatches of the palette, `[None]` first, in its order, and its color groups; a gradient
|
||||
/// swatch only when its gradient was read, and a pattern swatch only for a pattern in
|
||||
/// `patterns`. `None` when the palette is empty.
|
||||
fn palette_swatches(&mut self, patterns: &[PatternDef]) -> Option<(Vec<Swatch>, Vec<vectorcraft_color::swatch::SwatchGroup>)> {
|
||||
if self.palette.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let palette = std::mem::take(&mut self.palette);
|
||||
let swatch = |e: crate::import::ai::palette::Entry| -> Option<Swatch> {
|
||||
use crate::import::ai::palette::Cell;
|
||||
let (paint, global, spot) = match e.cell {
|
||||
Cell::Color { color, global, spot } => (Paint::solid(color), global, spot),
|
||||
Cell::Gradient(g) => {
|
||||
let mut gp = vectorcraft_color::gradient::GradientPaint::new(self.gradients.get(&g)?.clone());
|
||||
gp.swatch = Some(e.name.clone());
|
||||
(Paint::Gradient(Box::new(gp)), false, false)
|
||||
}
|
||||
Cell::Pattern(p) => {
|
||||
patterns.iter().find(|d| d.name == p)?;
|
||||
(Paint::Pattern { pattern: p, xf: Affine::IDENTITY }, false, false)
|
||||
}
|
||||
};
|
||||
Some(Swatch { name: e.name, paint, global, spot })
|
||||
};
|
||||
let mut swatches = vec![Swatch { name: "[None]".into(), paint: Paint::None, global: false, spot: false }];
|
||||
swatches.extend(palette.swatches.into_iter().filter_map(swatch));
|
||||
let groups: Vec<_> = palette
|
||||
.groups
|
||||
.into_iter()
|
||||
.map(|(name, s)| vectorcraft_color::swatch::SwatchGroup { name, swatches: s.into_iter().filter_map(swatch).collect() })
|
||||
.collect();
|
||||
Some((swatches, groups))
|
||||
}
|
||||
|
||||
pub(super) fn finish(mut self) -> Result<Structure, String> {
|
||||
if !self.unknown.is_empty() {
|
||||
let ops: Vec<String> = self.unknown.iter().map(|o| format!("`{o}`")).collect();
|
||||
@@ -349,31 +422,7 @@ impl<'a> Reader<'a> {
|
||||
doc.units = u;
|
||||
}
|
||||
// The file's own swatches in place of the defaults, in its order and colour groups.
|
||||
if !self.palette.is_empty() {
|
||||
let palette = std::mem::take(&mut self.palette);
|
||||
let swatch = |e: crate::import::ai::palette::Entry| -> Option<Swatch> {
|
||||
use crate::import::ai::palette::Cell;
|
||||
let (paint, global, spot) = match e.cell {
|
||||
Cell::Color { color, global, spot } => (Paint::solid(color), global, spot),
|
||||
Cell::Gradient(g) => {
|
||||
let mut gp = vectorcraft_color::gradient::GradientPaint::new(self.gradients.get(&g)?.clone());
|
||||
gp.swatch = Some(e.name.clone());
|
||||
(Paint::Gradient(Box::new(gp)), false, false)
|
||||
}
|
||||
Cell::Pattern(p) => {
|
||||
doc.patterns.iter().find(|d| d.name == p)?;
|
||||
(Paint::Pattern { pattern: p, xf: Affine::IDENTITY }, false, false)
|
||||
}
|
||||
};
|
||||
Some(Swatch { name: e.name, paint, global, spot })
|
||||
};
|
||||
let mut swatches = vec![Swatch { name: "[None]".into(), paint: Paint::None, global: false, spot: false }];
|
||||
swatches.extend(palette.swatches.into_iter().filter_map(swatch));
|
||||
let groups: Vec<_> = palette
|
||||
.groups
|
||||
.into_iter()
|
||||
.map(|(name, s)| vectorcraft_color::swatch::SwatchGroup { name, swatches: s.into_iter().filter_map(swatch).collect() })
|
||||
.collect();
|
||||
if let Some((swatches, groups)) = self.palette_swatches(&doc.patterns) {
|
||||
(doc.swatches, doc.swatch_groups) = (swatches.into(), groups.into());
|
||||
}
|
||||
// The colour operators' definitions first: a gradient, defined before the art, names a
|
||||
|
||||
@@ -6,10 +6,11 @@ use super::{Affine, Arc, Cow, Document, Imported, Node, NodeKind, PathData, Rect
|
||||
/// its program in the legacy format, see [`editing_copy`]): its layers (the
|
||||
/// hidden ones too), groups, compound paths, clipping groups and artboards, with the text and the
|
||||
/// strokes around it of the page. When the file has no editing copy, `visible` as it was; when it
|
||||
/// has one that can't be used, `visible` with a warning that says why.
|
||||
/// has one that can't be used, `visible` with a warning that says why, and with the swatches of
|
||||
/// the editing copy's palette ([`use_palette`]) when it could be read.
|
||||
pub(in super::super) fn layered(ps: &[u8], visible: Imported, trust: bool) -> Imported {
|
||||
let Some(data) = editing_copy(ps) else { return visible };
|
||||
let result = data.and_then(|data| build(&data, Some(&visible.document), Page::Art, false, trust, 1.0));
|
||||
let result = data.as_deref().map_err(Clone::clone).and_then(|data| build(data, Some(&visible.document), Page::Art, false, trust, 1.0));
|
||||
match result {
|
||||
Ok((document, mut warnings)) => {
|
||||
for w in &visible.warnings {
|
||||
@@ -21,6 +22,9 @@ pub(in super::super) fn layered(ps: &[u8], visible: Imported, trust: bool) -> Im
|
||||
}
|
||||
Err(why) => {
|
||||
let mut v = visible;
|
||||
if let Ok(data) = &data {
|
||||
use_palette(data, &mut v.document);
|
||||
}
|
||||
v.warnings.push(format!("{LAYERS_UNREAD} ({why}): it comes in as its page, in one layer"));
|
||||
v
|
||||
}
|
||||
@@ -35,7 +39,8 @@ pub(in super::super) fn layered(ps: &[u8], visible: Imported, trust: bool) -> Im
|
||||
/// notes, or a note saying why the layers weren't read when the editing data can't be decompressed
|
||||
/// or its layers can't be read. The flag returned is `true` when the document is the one read from
|
||||
/// the layers; else the document is `visible`, its pages moved by `place_pages` when the editing
|
||||
/// data was decompressed but its layers can't be read. `outlined`: the PDF part's type was read as
|
||||
/// data was decompressed but its layers can't be read, and its swatches those of the editing data's
|
||||
/// palette ([`use_palette`]) when it was decompressed. `outlined`: the PDF part's type was read as
|
||||
/// outlines (a file whose type shows then comes in as its PDF part).
|
||||
/// `trust`: layers that draw differently from the PDF part are used all the same, with a warning.
|
||||
/// `unit`: the size in points of a unit of the PDF part's user space, its first page's `UserUnit`,
|
||||
@@ -52,12 +57,15 @@ pub fn layered_ai(private: &[u8], visible: Document, warnings: Vec<String>, outl
|
||||
Err(why) => return (visible, unread(warnings, &why), false),
|
||||
};
|
||||
if !has_layers(&data) {
|
||||
let mut visible = visible;
|
||||
use_palette(&data, &mut visible);
|
||||
return (visible, warnings, false);
|
||||
}
|
||||
// The PDF part, its pages placed and named as the editing data's artboards are, when the
|
||||
// layers can't be used (`why`).
|
||||
let from_pdf_part = |mut visible: Document, warnings: Vec<String>, why: &str| {
|
||||
place_pages(&ai::artboards(&data), &mut visible, unit);
|
||||
use_palette(&data, &mut visible);
|
||||
let mut all = unread(warnings, why);
|
||||
all.extend(ai::variables(&data).note());
|
||||
(visible, all, false)
|
||||
@@ -78,6 +86,29 @@ pub fn layered_ai(private: &[u8], visible: Document, warnings: Vec<String>, outl
|
||||
}
|
||||
}
|
||||
|
||||
/// `doc` (a `.ai` read as its PDF part, in place of its layers) with the swatches and color groups
|
||||
/// of the palette of its editing data `data` in place of the default ones, as [`build`] gives a
|
||||
/// document read from the layers. The spot and pattern swatches of the PDF part's art that the
|
||||
/// palette doesn't have follow the palette's swatches. The patterns that the editing data defines and
|
||||
/// the PDF part lacks come in for the palette's pattern swatches, with the images they show. Left as
|
||||
/// it is when the palette is empty.
|
||||
fn use_palette(data: &[u8], doc: &mut Document) {
|
||||
let Some(ai::PaletteSwatches { swatches, groups, patterns, images }) = ai::palette(data, &doc.patterns) else { return };
|
||||
for (key, blob) in images {
|
||||
doc.images.entry(key).or_insert(blob);
|
||||
}
|
||||
for mut p in patterns {
|
||||
p.art = p.art.iter().map(|n| Arc::new(doc.reid(n))).collect();
|
||||
doc.patterns.push(p);
|
||||
}
|
||||
let named = |name: &str| swatches.iter().chain(groups.iter().flat_map(|g| &g.swatches)).any(|s| s.name == name);
|
||||
let art: Vec<_> =
|
||||
doc.swatches.iter().filter(|s| (s.spot || matches!(s.paint, vectorcraft_color::Paint::Pattern { .. })) && !named(&s.name)).cloned().collect();
|
||||
let mut swatches = swatches;
|
||||
swatches.extend(art);
|
||||
(doc.swatches, doc.swatch_groups) = (swatches.into(), groups.into());
|
||||
}
|
||||
|
||||
/// The pages of `doc` (a `.ai` read as its PDF part: a page per artboard, in a row) named as
|
||||
/// `boards` (the artboards of its editing data, in art space) name them, and moved, with the art on
|
||||
/// each, to where they are, so the artboards keep their names and layout when the layers can't be
|
||||
|
||||
@@ -266,6 +266,68 @@ fn an_ai_file_with_an_image_too_large_for_its_editing_data_comes_in_as_its_pdf_p
|
||||
assert!(read, "{warnings:?}");
|
||||
}
|
||||
|
||||
/// A palette of `[None]` and White, and a color group `Inks` holding a spot color, Red ink.
|
||||
const PALETTE: &str =
|
||||
"%AI5_BeginPalette\n0 0 Pb\n0 0 0 0 1 1 1 Xa\n(White)\nPc\n1 (Inks) 1 Pg\n0.06 0.98 1 0 (Red ink) 0 x\n(Red ink)\nPc\nPB\n%AI5_EndPalette\n";
|
||||
|
||||
/// A `.ai` that comes in as its PDF part, whose layers can't be used, still gets the swatches and
|
||||
/// color groups of its editing data's palette in place of the defaults, and keeps the inks its PDF
|
||||
/// part's art paints with that the palette doesn't have.
|
||||
#[test]
|
||||
fn an_ai_file_that_comes_in_as_its_pdf_part_keeps_the_swatches_of_its_palette() {
|
||||
let image = "%AI5_File:\n%AI5_BeginRaster\n() 1 XG\n/DeviceGray XN\n[ 1 0 0 1 0 100 ] 6000 6000 0 Xh\n\
|
||||
[ 1 0 0 1 0 100 ] 0 0 6000 6000 6000 6000 8 1 0 0 1 0 4 4 0 0\n%%BeginData: 1\rXI\n\u{0}%%EndData\r\nXH\r\n%AI5_EndRaster\r\nN";
|
||||
let art = native(&format!("{PALETTE}{}", layer("Texture", true, &format!("{}\n{image}", square(10, 10)))));
|
||||
let mut part = pdf_part(10.0, 86.0);
|
||||
let gold = vectorcraft_color::Paint::solid(vectorcraft_color::Color::cmyk(0.0, 0.2, 0.9, 0.1));
|
||||
part.swatches.push(vectorcraft_color::swatch::Swatch { name: "Gold ink".into(), paint: gold, global: true, spot: true });
|
||||
let (d, warnings, read) = crate::layered_ai(&private(&art), part, vec![], false, true, 1.0);
|
||||
assert!(!read, "the PDF part is used: {warnings:?}");
|
||||
let swatches: Vec<&str> = d.swatches.iter().map(|s| s.name.as_str()).collect();
|
||||
assert_eq!(swatches, ["[None]", "White", "Gold ink"], "{warnings:?}");
|
||||
let groups: Vec<&str> = d.swatch_groups.iter().map(|g| g.name.as_str()).collect();
|
||||
assert_eq!(groups, ["Inks"]);
|
||||
assert!(d.swatch("Red ink").is_some_and(|s| s.spot));
|
||||
}
|
||||
|
||||
/// A `.ai` pattern library (a palette of pattern swatches and no layers) whose PDF part defines no
|
||||
/// pattern gets the patterns its editing data defines with the palette's pattern swatches.
|
||||
#[test]
|
||||
fn an_ai_file_without_layers_keeps_the_patterns_of_its_palette() {
|
||||
let dots = "%AI3_BeginPattern: (Dots)\n(Dots) 0 0 4 4\n%_0 0 0 1 k\n%_1 1 m\n%_3 1 L\n%_3 3 L\n%_1 3 L\n%_f\nE\n%AI3_EndPattern\n";
|
||||
let palette =
|
||||
"%AI5_BeginPalette\n0 0 Pb\n0 0 0 0 1 1 1 Xa\n(White)\nPc\n(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n(Dots)\nPc\nPB\n%AI5_EndPalette\n";
|
||||
let art = native(&format!("{dots}{palette}"));
|
||||
let (d, warnings, read) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec![], false, true, 1.0);
|
||||
assert!(!read, "{warnings:?}");
|
||||
let swatches: Vec<&str> = d.swatches.iter().map(|s| s.name.as_str()).collect();
|
||||
assert_eq!(swatches, ["[None]", "White", "Dots"], "{warnings:?}");
|
||||
assert!(matches!(&d.swatch("Dots").map(|s| &s.paint), Some(vectorcraft_color::Paint::Pattern { pattern, .. }) if pattern == "Dots"));
|
||||
assert!(d.pattern("Dots").is_some_and(|p| !p.art.is_empty()));
|
||||
|
||||
// A pattern that shows an image brings the image's data with it.
|
||||
let pic = "%AI3_BeginPattern: (Pic)\n(Pic) 0 0 4 4\n%AI5_File:\n%AI5_BeginRaster\n() 1 XG\n/DeviceGray XN\n[ 4 0 0 4 0 4 ] 1 1 0 Xh\n\
|
||||
[ 4 0 0 4 0 4 ] 0 0 1 1 1 1 8 1 0 0 1 0 4 4 0 0\n%%BeginData: 1\rXI\n\u{0}%%EndData\r\nXH\r\n%AI5_EndRaster\r\nN\nE\n%AI3_EndPattern\n";
|
||||
let art = native(&format!("{pic}{}", palette.replace("(Dots)", "(Pic)")));
|
||||
let (d, warnings, _) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec![], false, true, 1.0);
|
||||
let mut keys = vec![];
|
||||
d.visit_images(|_, im| keys.push(im.key.clone()));
|
||||
assert_eq!(keys.len(), 1, "{warnings:?}");
|
||||
assert!(keys.iter().all(|k| d.images.contains_key(k)), "{keys:?}");
|
||||
}
|
||||
|
||||
/// An EPS whose editing data can't be used, a palette with no layers among others, comes in as its
|
||||
/// page with the swatches and color groups of that palette in place of the defaults.
|
||||
#[test]
|
||||
fn an_eps_that_comes_in_as_its_page_keeps_the_swatches_of_its_palette() {
|
||||
let r = import(&eps(&page_square(10, 10), &native(PALETTE))).unwrap();
|
||||
assert_eq!(names(&r.document), ["Layer 1"], "{:?}", r.warnings);
|
||||
let swatches: Vec<&str> = r.document.swatches.iter().map(|s| s.name.as_str()).collect();
|
||||
assert_eq!(swatches, ["[None]", "White"], "{:?}", r.warnings);
|
||||
let groups: Vec<&str> = r.document.swatch_groups.iter().map(|g| g.name.as_str()).collect();
|
||||
assert_eq!(groups, ["Inks"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ai_file_gets_its_layers_and_the_art_outside_its_artboard() {
|
||||
// A square on the artboard, and one above it (the artboard is y 0 to 100 here).
|
||||
|
||||
@@ -191,9 +191,12 @@ pub fn arc_segment(anchor: Point, reference: usize, length_x: f64, length_y: f64
|
||||
arc(a, b, slope, closed, base_along_x)
|
||||
}
|
||||
|
||||
/// Quarter-ellipse arc from `a` to `b` (Arc tool default: open, x-axis base when `base_along_x`).
|
||||
/// `slope` is −100…100 (concave…convex); 0 is a straight chord.
|
||||
pub fn arc(a: Point, b: Point, slope: f64, closed: bool, base_along_x: bool) -> PathData {
|
||||
/// Quarter-ellipse arc from `a` to `b` (Arc tool default: open). `slope` is −100…100
|
||||
/// (concave…convex); 0 is a straight chord. A convex arc bulges toward the corner `(b.x, a.y)`,
|
||||
/// and a closed arc closes through the corner `(a.x, b.y)`, so a closed arc at slope 100 is the
|
||||
/// quarter-ellipse sector. The shape does not depend on `_base_along_x`: for a Y-base arc,
|
||||
/// [`arc_segment`] passes ends on the box mirrored top to bottom.
|
||||
pub fn arc(a: Point, b: Point, slope: f64, closed: bool, _base_along_x: bool) -> PathData {
|
||||
let t = slope.clamp(-100.0, 100.0) / 100.0;
|
||||
let corner = if t >= 0.0 { Point::new(b.x, a.y) } else { Point::new(a.x, b.y) };
|
||||
let k = KAPPA * t.abs();
|
||||
@@ -202,8 +205,7 @@ pub fn arc(a: Point, b: Point, slope: f64, closed: bool, base_along_x: bool) ->
|
||||
let mut anchors =
|
||||
vec![Anchor { p: a, h_in: a, h_out: h1, kind: AnchorKind::Corner }, Anchor { p: b, h_in: h2, h_out: b, kind: AnchorKind::Corner }];
|
||||
if closed {
|
||||
let o = if base_along_x { Point::new(a.x, b.y) } else { Point::new(b.x, a.y) };
|
||||
anchors.push(Anchor::corner(o));
|
||||
anchors.push(Anchor::corner(Point::new(a.x, b.y)));
|
||||
}
|
||||
PathData::single(SubPath::new(anchors, closed))
|
||||
}
|
||||
@@ -545,6 +547,37 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// An arc with Base Along Y, open or closed, convex or concave, is the arc with Base Along X in
|
||||
/// the same box mirrored top to bottom, anchor for anchor and handle for handle.
|
||||
#[test]
|
||||
fn a_y_base_arc_is_the_x_base_arc_mirrored_top_to_bottom() {
|
||||
let mirror = |p: Point| Point::new(p.x, 20.0 + 70.0 - p.y);
|
||||
for closed in [false, true] {
|
||||
for slope in [100.0, 50.0, -50.0, -100.0] {
|
||||
let x = arc_segment(Point::new(10.0, 20.0), 0, 100.0, 50.0, true, slope, closed);
|
||||
let y = arc_segment(Point::new(10.0, 20.0), 0, 100.0, 50.0, false, slope, closed);
|
||||
let (xs, ys) = (&x.subpaths[0], &y.subpaths[0]);
|
||||
assert_eq!(ys.closed, closed);
|
||||
assert_eq!(xs.anchors.len(), ys.anchors.len(), "slope {slope}, closed {closed}");
|
||||
for (i, (p, q)) in xs.anchors.iter().zip(&ys.anchors).enumerate() {
|
||||
for (u, v) in [(p.p, q.p), (p.h_in, q.h_in), (p.h_out, q.h_out)] {
|
||||
assert!(mirror(u).distance(v) < 1e-9, "slope {slope}, closed {closed}, anchor {i}: {u:?} mirrored is not {v:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A closed convex arc is the quarter-ellipse sector of its box with either base: its area is
|
||||
/// a quarter of the ellipse's, π/4 of the box's.
|
||||
#[test]
|
||||
fn a_closed_convex_arc_is_a_quarter_ellipse_sector_with_either_base() {
|
||||
for base_along_x in [true, false] {
|
||||
let a = arc_segment(Point::ZERO, 0, 100.0, 50.0, base_along_x, 100.0, true).subpaths[0].signed_area().abs();
|
||||
assert!((a / (100.0 * 50.0) - PI / 4.0).abs() < 0.01, "base along x {base_along_x}: area {a}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arc_open_and_closed() {
|
||||
assert_eq!(arc(Point::ZERO, Point::new(10.0, 10.0), 0.0, false, true).anchor_count(), 2);
|
||||
|
||||
@@ -1413,6 +1413,30 @@ fn e_during_a_marquee_gesture_selects_only_the_objects_inside() {
|
||||
assert_eq!(serde_json::from_str::<Value>(&text_of(&r)).unwrap()["handledBy"], "tool.select", "{r}");
|
||||
}
|
||||
|
||||
/// `press_key {key: "c"}` and `{key: "f"}` while an arc is being dragged go to the Arc tool, and
|
||||
/// the release draws one arc. With nothing dragged, C is a shortcut again.
|
||||
#[test]
|
||||
fn c_and_f_during_an_arc_gesture_go_to_the_arc_tool() {
|
||||
let mut s = server();
|
||||
let count = |s: &mut Server, id: u64| {
|
||||
let r = call(s, id, "inspect_document", json!({}));
|
||||
serde_json::from_str::<Value>(&text_of(&r)).unwrap()["objects"].as_u64().unwrap()
|
||||
};
|
||||
let before = count(&mut s, 1);
|
||||
let begin = json!([{"kind": "down", "x": 100, "y": 100}, {"kind": "drag", "x": 200, "y": 180}]);
|
||||
let r = call(&mut s, 2, "pointer_gesture", json!({"tool": "arc", "events": begin}));
|
||||
assert_eq!(r["isError"], false, "{r}");
|
||||
for (id, key) in [(3, "c"), (4, "f")] {
|
||||
let r = call(&mut s, id, "press_key", json!({"key": key}));
|
||||
assert_eq!(serde_json::from_str::<Value>(&text_of(&r)).unwrap()["handledBy"], "tool", "{key}: {r}");
|
||||
}
|
||||
let r = call(&mut s, 5, "pointer_gesture", json!({"tool": "arc", "events": [{"kind": "up", "x": 200, "y": 180}]}));
|
||||
assert_eq!(r["isError"], false, "{r}");
|
||||
assert_eq!(count(&mut s, 6), before + 1);
|
||||
let r = call(&mut s, 7, "press_key", json!({"key": "c"}));
|
||||
assert_ne!(serde_json::from_str::<Value>(&text_of(&r)).unwrap()["handledBy"], "tool", "{r}");
|
||||
}
|
||||
|
||||
/// A click on one of two selected objects makes it the key object: `pointer_gesture` replies
|
||||
/// with it as `key` and `inspect_document` lists it, and a click on the key again lets it go.
|
||||
#[test]
|
||||
|
||||
@@ -14,6 +14,12 @@ use vectorcraft_geom::{Anchor, FillRule, PathData, SubPath};
|
||||
use crate::PathOpsError;
|
||||
use crate::fit::{end_tangent, fit_single, is_straight, sample, start_tangent};
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// The contour passes made over an arrangement on this thread, each of which walks all of it.
|
||||
pub(crate) static CONTOUR_PASSES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// Default curve-refit tolerance (document points) used to merge split curve pieces back together.
|
||||
/// Similar in spirit to Illustrator's Pathfinder "Precision" option (0.028 pt).
|
||||
pub const DEFAULT_PRECISION: f64 = 0.01;
|
||||
@@ -314,11 +320,13 @@ impl Arrangement {
|
||||
|
||||
/// [`Arrangement::contours`] with the test on the windings themselves (see [`Self::covered`]).
|
||||
pub(crate) fn contours_where(&self, pred: impl Fn(&Multi) -> bool) -> Contours {
|
||||
#[cfg(test)]
|
||||
CONTOUR_PASSES.with(|n| n.set(n.get() + 1));
|
||||
self.top.contours(pred)
|
||||
}
|
||||
|
||||
pub(crate) fn contours(&self, pred: impl Fn(&[bool]) -> bool) -> Contours {
|
||||
self.top.contours(|w| pred(&self.mask(w)))
|
||||
self.contours_where(|w| pred(&self.mask(w)))
|
||||
}
|
||||
|
||||
/// All distinct non-empty coverage masks that occur next to some edge, sorted.
|
||||
|
||||
@@ -94,13 +94,15 @@ pub fn stroke_region(outlines: &[BezPath]) -> PathData {
|
||||
|
||||
/// Object → Path → Offset Path. Positive `delta` grows the filled area, negative insets it.
|
||||
/// Closed subpaths are treated as a non-zero filled region; open subpaths are outlined with a
|
||||
/// stroke of width `2|delta|` (butt caps).
|
||||
/// stroke of width `2|delta|` (butt caps). An open subpath whose last anchor sits on its first is
|
||||
/// offset as a closed one.
|
||||
pub fn offset_path(path: &PathData, delta: f64, join: Join, miter_limit: f64) -> PathData {
|
||||
if !delta.is_finite() || path.is_empty() {
|
||||
return PathData::default();
|
||||
}
|
||||
let closed = PathData::new(path.subpaths.iter().filter(|s| s.closed && s.anchors.len() > 1).cloned().collect());
|
||||
let open: Vec<SubPath> = path.subpaths.iter().filter(|s| !s.closed && s.anchors.len() > 1).cloned().collect();
|
||||
let subpaths: Vec<SubPath> = path.subpaths.iter().map(close_if_ends_meet).collect();
|
||||
let closed = PathData::new(subpaths.iter().filter(|s| s.closed && s.anchors.len() > 1).cloned().collect());
|
||||
let open: Vec<SubPath> = subpaths.into_iter().filter(|s| !s.closed && s.anchors.len() > 1).collect();
|
||||
let d = delta.abs();
|
||||
let fill = fill_bezpath(&closed);
|
||||
let ring = if d > 0.0 { stroke_bez(&closed.to_bezpath(), 2.0 * d, Cap::Butt, join, miter_limit) } else { BezPath::new() };
|
||||
@@ -128,6 +130,23 @@ pub fn offset_path(path: &PathData, delta: f64, join: Join, miter_limit: f64) ->
|
||||
contours_to_path(&c, keep, &Tidy::free(DEFAULT_PRECISION))
|
||||
}
|
||||
|
||||
/// An open subpath of three or more anchors whose last anchor lies within 1e-6 of its first (the
|
||||
/// test Object → Path → Join applies to one path) as a closed subpath: the last anchor is merged
|
||||
/// into the first, which takes its in-handle. Any other subpath is returned unchanged.
|
||||
fn close_if_ends_meet(sp: &SubPath) -> SubPath {
|
||||
let mut sp = sp.clone();
|
||||
if !sp.closed
|
||||
&& sp.anchors.len() > 2
|
||||
&& sp.anchors.first().zip(sp.anchors.last()).is_some_and(|(f, l)| f.p.distance(l.p) < 1e-6)
|
||||
&& let Some(last) = sp.anchors.pop()
|
||||
&& let Some(first) = sp.anchors.first_mut()
|
||||
{
|
||||
first.h_in = last.h_in;
|
||||
sp.closed = true;
|
||||
}
|
||||
sp
|
||||
}
|
||||
|
||||
fn has_marked_ancestor(c: &Contours, mut i: usize, marked: &[bool]) -> bool {
|
||||
loop {
|
||||
if marked[i] {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use kurbo::{BezPath, Line, ParamCurve, ParamCurveNearest, PathSeg, Point, Shape as _};
|
||||
use linesweeper::topology::ContourIdx;
|
||||
use linesweeper::topology::{ContourIdx, Contours};
|
||||
use vectorcraft_geom::{FillRule, PathData};
|
||||
|
||||
use crate::boolean::{
|
||||
@@ -200,18 +200,42 @@ pub fn pathfinder_with(op: PathfinderOp, shapes: &[Shape], precision: f64) -> Ve
|
||||
}
|
||||
}
|
||||
|
||||
/// The faces of `arr`, each distinct coverage mask's in turn (sorted), each face grouped as
|
||||
/// `arr.contours(|m| m == mask).grouped()` groups it.
|
||||
/// The faces of `arr`, each distinct coverage mask's in turn (sorted), each face an outer contour
|
||||
/// and the holes of its mask directly inside it ([`face_groups`]).
|
||||
fn regions_of(arr: &Arrangement) -> Vec<Region> {
|
||||
let masks = arr.distinct_masks();
|
||||
let refs: Vec<&[bool]> = masks.iter().map(Vec::as_slice).collect();
|
||||
let mut faces: Vec<Vec<PathData>> = vec![Vec::new(); masks.len()];
|
||||
each_face(arr, &refs, |m, c, group| {
|
||||
if let Some(f) = faces.get_mut(m) {
|
||||
f.push(contours_to_path(c, group.iter().copied(), &arr.tidy));
|
||||
}
|
||||
});
|
||||
let mut out = Vec::new();
|
||||
for (mask, paths) in masks.iter().zip(faces) {
|
||||
let sources: Vec<usize> = mask.iter().enumerate().filter(|(_, b)| **b).map(|(i, _)| i).collect();
|
||||
out.extend(paths.into_iter().filter(|p| !p.is_empty()).map(|path| Region { path, sources: sources.clone() }));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Calls `f` with each face of `arr` whose coverage is one of `masks` (distinct): the index of
|
||||
/// its mask in `masks`, the contour pass it is in, and its contours, an outer contour and the
|
||||
/// holes of its mask directly inside it ([`face_groups`]). Each mask's faces come in the order
|
||||
/// of that mask's own contour pass.
|
||||
///
|
||||
/// A contour pass walks the whole arrangement, so one pass per mask would cost masks × edges
|
||||
/// (two blends crossing make thousands of each). Instead the masks go into batches whose faces
|
||||
/// share no vertex (so no edge either): one pass per batch walks each face's contours exactly as
|
||||
/// its own mask's pass would, and the mask a contour bounds is the one of its batch at its first
|
||||
/// point.
|
||||
fn regions_of(arr: &Arrangement) -> Vec<Region> {
|
||||
let masks = arr.distinct_masks();
|
||||
let ids: HashMap<&[bool], usize> = masks.iter().enumerate().map(|(i, m)| (m.as_slice(), i)).collect();
|
||||
let id = |w: &Multi| ids.get(arr.mask(w).as_slice()).copied();
|
||||
fn each_face(arr: &Arrangement, masks: &[&[bool]], mut f: impl FnMut(usize, &Contours, &[ContourIdx])) {
|
||||
if masks.is_empty() {
|
||||
return;
|
||||
}
|
||||
// Each mask is found by the inputs it covers.
|
||||
let ids = CoverIds::new(masks.iter().map(|m| m.iter().enumerate().filter(|(_, b)| **b).map(|(k, _)| k).collect()).collect());
|
||||
let id = |w: &Multi| ids.of(arr, w);
|
||||
let top = &arr.top;
|
||||
// The masks of the faces around each vertex.
|
||||
let mut around: HashMap<(u64, u64), Vec<usize>> = HashMap::new();
|
||||
@@ -246,9 +270,8 @@ fn regions_of(arr: &Arrangement) -> Vec<Region> {
|
||||
batch[m] = taken.iter().position(|t| !t).unwrap_or(batches);
|
||||
batches = batches.max(batch[m] + 1);
|
||||
}
|
||||
let mut faces: Vec<Vec<PathData>> = vec![Vec::new(); masks.len()];
|
||||
for b in 0..batches {
|
||||
let c = top.contours(|w| id(w).is_some_and(|m| batch[m] == b));
|
||||
let c = arr.contours_where(|w| id(w).is_some_and(|m| batch[m] == b));
|
||||
let owner: Option<Vec<usize>> = c
|
||||
.contours()
|
||||
.map(|ct| {
|
||||
@@ -260,22 +283,46 @@ fn regions_of(arr: &Arrangement) -> Vec<Region> {
|
||||
// Not expected: every contour starts at a vertex of the arrangement. Fall back to a
|
||||
// pass per mask for this batch.
|
||||
for (m, mask) in masks.iter().enumerate().filter(|(m, _)| batch[*m] == b) {
|
||||
let c = arr.contours(|k| k == mask.as_slice());
|
||||
let c = arr.contours(|k| k == *mask);
|
||||
let owner = vec![m; c.contours().count()];
|
||||
faces[m].extend(face_groups(&c, &owner).into_iter().map(|(_, g)| contours_to_path(&c, g, &arr.tidy)));
|
||||
for (_, group) in face_groups(&c, &owner) {
|
||||
f(m, &c, &group);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
};
|
||||
for (m, group) in face_groups(&c, &owner) {
|
||||
faces[m].push(contours_to_path(&c, group, &arr.tidy));
|
||||
f(m, &c, &group);
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for (mask, paths) in masks.iter().zip(faces) {
|
||||
let sources: Vec<usize> = mask.iter().enumerate().filter(|(_, b)| **b).map(|(i, _)| i).collect();
|
||||
out.extend(paths.into_iter().filter(|p| !p.is_empty()).map(|path| Region { path, sources: sources.clone() }));
|
||||
}
|
||||
|
||||
/// Lists of inputs, each ascending as [`Arrangement::covered`] gives them, found by the inputs
|
||||
/// that cover a winding.
|
||||
struct CoverIds {
|
||||
/// The indices in `lists` of the lists with each hash ([`CoverIds::hash`]).
|
||||
by_hash: HashMap<u64, Vec<usize>>,
|
||||
lists: Vec<Vec<usize>>,
|
||||
}
|
||||
|
||||
impl CoverIds {
|
||||
fn new(lists: Vec<Vec<usize>>) -> Self {
|
||||
let mut by_hash: HashMap<u64, Vec<usize>> = HashMap::new();
|
||||
for (i, l) in lists.iter().enumerate() {
|
||||
by_hash.entry(Self::hash(l.iter().copied())).or_default().push(i);
|
||||
}
|
||||
Self { by_hash, lists }
|
||||
}
|
||||
|
||||
fn hash(inputs: impl Iterator<Item = usize>) -> u64 {
|
||||
inputs.fold(0xcbf2_9ce4_8422_2325, |h, i| (h ^ i as u64).wrapping_mul(0x0000_0100_0000_01b3).rotate_left(29))
|
||||
}
|
||||
|
||||
/// The index of the list of the inputs that cover winding `w`, if it is one of them.
|
||||
fn of(&self, arr: &Arrangement, w: &Multi) -> Option<usize> {
|
||||
let same = |&&i: &&usize| self.lists.get(i).is_some_and(|l| arr.covered(w).eq(l.iter().copied()));
|
||||
self.by_hash.get(&Self::hash(arr.covered(w)))?.iter().find(same).copied()
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Splits a contour pass into faces: each face is an outer contour plus the holes of the same mask
|
||||
@@ -444,23 +491,36 @@ impl FaceMerger {
|
||||
}
|
||||
picks.push((mask, crate::planar::interior_point(&r.path)?));
|
||||
}
|
||||
// `masks` holds the coverages in the order they were first picked, `points` the picked
|
||||
// points of each, and `found` the faces of each that contain one of its points.
|
||||
let mut masks: Vec<&[bool]> = vec![];
|
||||
let mut points: Vec<Vec<Point>> = vec![];
|
||||
let mut index: HashMap<&[bool], usize> = HashMap::new();
|
||||
for (mask, p) in &picks {
|
||||
let m = *index.entry(mask.as_slice()).or_insert_with(|| {
|
||||
masks.push(mask);
|
||||
points.push(Vec::new());
|
||||
masks.len() - 1
|
||||
});
|
||||
if let Some(at) = points.get_mut(m) {
|
||||
at.push(*p);
|
||||
}
|
||||
}
|
||||
let mut found: Vec<Vec<BezPath>> = vec![Vec::new(); masks.len()];
|
||||
each_face(arr, &masks, |m, c, group| {
|
||||
let mut face = BezPath::new();
|
||||
for &i in group {
|
||||
face.extend(c[i].path.iter());
|
||||
}
|
||||
if let (Some(at), Some(out)) = (points.get(m), found.get_mut(m))
|
||||
&& at.iter().any(|p| face.winding(*p) != 0)
|
||||
{
|
||||
out.push(face);
|
||||
}
|
||||
});
|
||||
let mut raw = BezPath::new();
|
||||
let mut done: Vec<&[bool]> = vec![];
|
||||
for (mask, _) in &picks {
|
||||
if done.contains(&mask.as_slice()) {
|
||||
continue;
|
||||
}
|
||||
done.push(mask);
|
||||
let c = arr.contours(|k| k == mask.as_slice());
|
||||
for group in c.grouped() {
|
||||
let mut face = BezPath::new();
|
||||
for i in group {
|
||||
face.extend(c[i].path.iter());
|
||||
}
|
||||
if picks.iter().any(|(m, p)| m == mask && face.winding(*p) != 0) {
|
||||
raw.extend(face.iter());
|
||||
}
|
||||
}
|
||||
for face in found.iter().flatten() {
|
||||
raw.extend(face.iter());
|
||||
}
|
||||
Some(raw)
|
||||
}
|
||||
@@ -501,10 +561,9 @@ fn add_slivers(arr: &Arrangement, faces: &[&Region], rest: Option<&Rest>, raw: &
|
||||
let shapes = arr.rules.len();
|
||||
let cover = cover(faces, shapes);
|
||||
let merged_cover = rest.map(|r| self::cover(&r.merged, shapes)).unwrap_or_default();
|
||||
let picked: HashSet<&[usize]> = faces.iter().map(|r| r.sources.as_slice()).collect();
|
||||
let beside: HashSet<&[usize]> = rest.iter().flat_map(|r| r.merged.iter().map(|f| f.sources.as_slice())).collect();
|
||||
let coverage = |w: &Multi| arr.covered(w).collect::<Vec<_>>();
|
||||
let is_picked = |w: &Multi| picked.contains(coverage(w).as_slice());
|
||||
let picked = CoverIds::new(faces.iter().map(|r| r.sources.clone()).collect());
|
||||
let beside = CoverIds::new(rest.iter().flat_map(|r| r.merged.iter().map(|f| f.sources.clone())).collect());
|
||||
let is_picked = |w: &Multi| picked.of(arr, w).is_some();
|
||||
let within = |w: &Multi, c: &[bool]| arr.covered(w).next().is_some() && arr.covered(w).all(|i| c.get(i) == Some(&true));
|
||||
let ours = |w: &Multi| rest.is_none_or(|r| arr.covered(w).any(|i| i == r.shape));
|
||||
// The coverage of each face next to a picked one, if only shapes of `faces` cover it, and the
|
||||
@@ -523,7 +582,7 @@ fn add_slivers(arr: &Arrangement, faces: &[&Region], rest: Option<&Rest>, raw: &
|
||||
}
|
||||
if let Some(r) = rest
|
||||
&& within(w, &merged_cover)
|
||||
&& beside.contains(coverage(other).as_slice())
|
||||
&& beside.of(arr, other).is_some()
|
||||
{
|
||||
let ends = [h, h.other_half()].map(|e| vertex_key(top.point(e).to_kurbo()));
|
||||
if ends.iter().all(|k| r.corners.contains(k)) {
|
||||
@@ -533,15 +592,22 @@ fn add_slivers(arr: &Arrangement, faces: &[&Region], rest: Option<&Rest>, raw: &
|
||||
}
|
||||
}
|
||||
let along_merged = |path: &BezPath| path.elements().iter().any(|el| el.end_point().is_some_and(|p| taken.contains(&vertex_key(p))));
|
||||
for mask in next_to {
|
||||
let c = arr.contours(|k| k == mask.as_slice());
|
||||
for group in c.grouped() {
|
||||
if contours_to_path(&c, group.iter().copied(), &arr.tidy).is_empty() && !group.iter().any(|&i| along_merged(&c[i].path)) {
|
||||
for i in group {
|
||||
raw.extend(c[i].path.iter());
|
||||
}
|
||||
let masks: Vec<&[bool]> = next_to.iter().map(Vec::as_slice).collect();
|
||||
let mut found: Vec<Vec<BezPath>> = vec![Vec::new(); masks.len()];
|
||||
each_face(arr, &masks, |m, c, group| {
|
||||
if contours_to_path(c, group.iter().copied(), &arr.tidy).is_empty()
|
||||
&& !group.iter().any(|&i| along_merged(&c[i].path))
|
||||
&& let Some(out) = found.get_mut(m)
|
||||
{
|
||||
let mut face = BezPath::new();
|
||||
for &i in group {
|
||||
face.extend(c[i].path.iter());
|
||||
}
|
||||
out.push(face);
|
||||
}
|
||||
});
|
||||
for face in found.iter().flatten() {
|
||||
raw.extend(face.iter());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -853,10 +919,12 @@ fn mid_point(c: &[Seg]) -> Point {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::boolean::CONTOUR_PASSES;
|
||||
use proptest::prelude::*;
|
||||
use vectorcraft_geom::{Rect, SubPath, shapes};
|
||||
|
||||
/// The faces one contour pass per mask finds: what [`regions_of`] must match exactly.
|
||||
/// The faces one contour pass per mask finds: what [`regions_of`] must match exactly when no
|
||||
/// face lies inside a hole of its own mask (`grouped` puts such an island in the frame's face).
|
||||
fn regions_one_mask_at_a_time(arr: &Arrangement) -> Vec<Region> {
|
||||
let mut out = Vec::new();
|
||||
for mask in arr.distinct_masks() {
|
||||
@@ -1064,6 +1132,81 @@ mod tests {
|
||||
assert!(later.subpaths.iter().flat_map(|sp| &sp.anchors).all(|a| a.p != moved), "{later:?}");
|
||||
}
|
||||
|
||||
/// A merge walks the arrangement once for each batch of the faces' coverages. 49 rectangles in
|
||||
/// a row, each overlapping the next, make 97 faces, each with a coverage of its own.
|
||||
#[test]
|
||||
fn merging_many_faces_walks_the_arrangement_a_few_times() {
|
||||
let v: Vec<Shape> = (0..49)
|
||||
.map(|k| {
|
||||
let x = 10.0 * f64::from(k);
|
||||
Shape::new(rect(x, 0.0, x + 15.0, 40.0), FillRule::NonZero, 1)
|
||||
})
|
||||
.collect();
|
||||
let faces = regions(&v);
|
||||
assert_eq!(faces.len(), 97);
|
||||
// The merge takes every face but the one only rectangle 20 covers. That face lies next to
|
||||
// merged faces, so the merge also looks for slivers there.
|
||||
let left = faces.iter().position(|r| r.sources == [20]).unwrap();
|
||||
let picked: Vec<&Region> = faces.iter().enumerate().filter(|(k, _)| *k != left).map(|(_, r)| r).collect();
|
||||
let merger = FaceMerger::new(&v);
|
||||
CONTOUR_PASSES.with(|n| n.set(0));
|
||||
let merged = merger.merge(&picked);
|
||||
let passes = CONTOUR_PASSES.with(std::cell::Cell::get);
|
||||
assert!(passes <= 8, "{passes} contour passes");
|
||||
// The result is the row, 495 pt long, less the 5 pt that only rectangle 20 covers.
|
||||
assert_eq!(merged.subpaths.len(), 2, "{merged:?}");
|
||||
let area = crate::area(&merged, FillRule::NonZero);
|
||||
assert!((area - 490.0 * 40.0).abs() < 1e-6, "{area}");
|
||||
}
|
||||
|
||||
/// The lists of inputs a winding covers are found by those inputs, also among lists that share
|
||||
/// a hash: here every list is filed under every hash.
|
||||
#[test]
|
||||
fn cover_ids_tell_apart_lists_with_one_hash() {
|
||||
let v: Vec<Shape> = (0..3)
|
||||
.map(|k| {
|
||||
let x = 10.0 * f64::from(k);
|
||||
Shape::new(rect(x, 0.0, x + 15.0, 40.0), FillRule::NonZero, 1)
|
||||
})
|
||||
.collect();
|
||||
let arr = arrangement(&v, DEFAULT_PRECISION).unwrap();
|
||||
let lists = vec![vec![0], vec![0, 1], vec![1], vec![1, 2], vec![2]];
|
||||
let mut ids = CoverIds::new(lists.clone());
|
||||
let all: Vec<usize> = (0..lists.len()).collect();
|
||||
for l in &lists {
|
||||
ids.by_hash.insert(CoverIds::hash(l.iter().copied()), all.clone());
|
||||
}
|
||||
let mut seen = vec![false; lists.len()];
|
||||
for s in arr.top.segment_indices() {
|
||||
let h = s.first_half();
|
||||
for w in [arr.top.winding_clockwise(h), arr.top.winding_counter_clockwise(h)] {
|
||||
let covered: Vec<usize> = arr.covered(w).collect();
|
||||
let want = lists.iter().position(|l| *l == covered);
|
||||
assert_eq!(ids.of(&arr, w), want, "{covered:?}");
|
||||
if let Some(i) = want {
|
||||
seen[i] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(seen.iter().all(|s| *s), "every list is next to an edge: {seen:?}");
|
||||
}
|
||||
|
||||
/// A frame and the island in its hole, both of one even-odd shape, are two faces with the
|
||||
/// same coverage: merging the frame leaves the island out.
|
||||
#[test]
|
||||
fn merging_a_frame_leaves_out_the_island_in_its_hole() {
|
||||
let mut p = rect(0.0, 0.0, 100.0, 100.0);
|
||||
p.subpaths.extend(rect(20.0, 20.0, 80.0, 80.0).subpaths);
|
||||
p.subpaths.extend(rect(40.0, 40.0, 60.0, 60.0).subpaths);
|
||||
let v = [Shape::new(p, FillRule::EvenOdd, 1)];
|
||||
let faces = regions(&v);
|
||||
assert_eq!(faces.len(), 2);
|
||||
let frame = faces.iter().find(|r| r.contains(Point::new(10.0, 10.0))).unwrap();
|
||||
let merged = FaceMerger::new(&v).merge(&[frame]);
|
||||
let area = crate::area(&merged, FillRule::NonZero);
|
||||
assert!((area - (100.0 * 100.0 - 60.0 * 60.0)).abs() < 1e-6, "{area}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn divide_open_paths_cut_faces_without_taking_the_cutters_paint() {
|
||||
let line = |a: (f64, f64), b: (f64, f64)| shapes::line(Point::from(a), Point::from(b));
|
||||
|
||||
@@ -294,6 +294,28 @@ fn offset_rect_joins() {
|
||||
assert!(offset_path(&r, -6.0, Join::Miter, 4.0).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_of_an_open_path_whose_ends_meet_is_the_closed_shape_offset() {
|
||||
// Issue #3773: an open path whose last anchor sits on its first offsets like the closed shape,
|
||||
// not as a ring around its outline.
|
||||
let corners = [Point::new(0.0, 0.0), Point::new(20.0, 0.0), Point::new(20.0, 10.0), Point::new(0.0, 10.0), Point::new(0.0, 0.0)];
|
||||
let r = PathData::single(SubPath::polyline(&corners, false));
|
||||
let grown = offset_path(&r, 2.0, Join::Miter, 4.0);
|
||||
assert!(close(ar(&grown), 24.0 * 14.0, 1e-6), "{}", ar(&grown));
|
||||
assert_eq!(grown.subpaths.len(), 1);
|
||||
assert_eq!(grown.anchor_count(), 4);
|
||||
assert!(close(ar(&offset_path(&r, -2.0, Join::Miter, 4.0)), 16.0 * 6.0, 1e-6));
|
||||
// A curved outline: the last anchor's in-handle shapes the closing curve.
|
||||
let mut sp = circle(0.0, 0.0, 10.0).subpaths.remove(0);
|
||||
let first = sp.anchors[0];
|
||||
sp.anchors.push(vectorcraft_geom::Anchor::with_handles(first.p, first.h_in, first.p));
|
||||
sp.anchors[0].h_in = first.p;
|
||||
sp.closed = false;
|
||||
let o = offset_path(&PathData::single(sp), 3.0, Join::Round, 4.0);
|
||||
assert!(close(ar(&o), PI * 169.0, 2e-3), "{}", ar(&o));
|
||||
assert_eq!(o.subpaths.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outline_stroke_of_line() {
|
||||
let l = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(30.0, 40.0)], false));
|
||||
|
||||
@@ -301,6 +301,9 @@ impl<'p> Colors<'p> {
|
||||
let process = cmyk.iter().any(|v| *v > 0.0);
|
||||
let (c, t) = match (ink, first_ink) {
|
||||
(Some(_), _) if several || process => {
|
||||
// The color can't link to one swatch, but each ink it paints with is still a
|
||||
// spot swatch.
|
||||
self.mark_inks(Space::Inks(space), comps);
|
||||
self.mixed = true;
|
||||
return None;
|
||||
}
|
||||
@@ -320,6 +323,24 @@ impl<'p> Colors<'p> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark each ink that components `comps` of `space` paint with (a tint above 0) as used, so it
|
||||
/// becomes a spot swatch; nothing for a space with a colorant that can't be read.
|
||||
pub fn mark_inks(&mut self, space: Space, comps: &[f32]) {
|
||||
let Space::Inks(i) = space else { return };
|
||||
let Some(s) = self.spaces.get(i) else { return };
|
||||
if s.colorants.iter().any(|c| matches!(c, Colorant::Unknown)) {
|
||||
return;
|
||||
}
|
||||
for (c, t) in s.colorants.iter().zip(comps) {
|
||||
if let Colorant::Ink(k) = *c
|
||||
&& round3(t.clamp(0.0, 1.0)) > 0.0
|
||||
&& let Some(ink) = self.inks.get_mut(k)
|
||||
{
|
||||
ink.used = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the file's Separation and DeviceN colour spaces (once, when a colour first needs them).
|
||||
fn scan(&mut self) {
|
||||
if self.scanned {
|
||||
|
||||
@@ -45,6 +45,10 @@ pub(crate) fn shading_gradient(sp: &ShadingPattern, colors: &mut Colors<'_>, ink
|
||||
for i in 0..=N {
|
||||
let t = i as f32 / N as f32;
|
||||
pts.push((t, sample(t)?.to_rgb()));
|
||||
// An ink that paints only at samples dropped below is still a spot swatch.
|
||||
if let Some(v) = at(t) {
|
||||
colors.mark_inks(space, &v);
|
||||
}
|
||||
}
|
||||
let ts: Vec<f32> = linear_keep(&pts).iter().filter_map(|&k| pts.get(k)).map(|p| p.0).collect();
|
||||
let mut stop_at = |t: f32, offset: f32| -> GradientStop {
|
||||
|
||||
@@ -164,6 +164,48 @@ fn device_n_inks_become_spot_swatches_and_process_plates_cmyk() {
|
||||
assert_eq!(d.swatches.iter().filter(|s| s.spot).count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_spot_ink_mixed_with_a_process_plate_keeps_its_swatch() {
|
||||
// Gold and Cyan → CMYK (cyan, 0, gold, 0): a fill, then an axial shading of Gold 0 to 1 over
|
||||
// Cyan at 0.3.
|
||||
let space = format!("[/DeviceN [/Gold /Cyan] /DeviceCMYK {} 0 R]", first_extra(1));
|
||||
let func = "<< /FunctionType 4 /Domain [0 1 0 1] /Range [0 1 0 1 0 1 0 1] /Length 17 >>\nstream\n{ exch 0 exch 0 }\nendstream";
|
||||
let shading = format!(
|
||||
"<< /ShadingType 2 /ColorSpace {space} /Coords [0 0 50 0] /Function << /FunctionType 2 /Domain [0 1] /C0 [0 0.3] /C1 [1 0.3] /N 1 >> /Extend [true true] >>"
|
||||
);
|
||||
let fill = page_with(&format!("/CS0 {space}"), &[("CS0", "0.5 0.3")]);
|
||||
let sh = PdfPage { resources: format!("/Shading << /Sh0 {shading} >>"), ..PdfPage::new(100.0, 50.0, "q 0 0 50 50 re W n /Sh0 sh Q") };
|
||||
for (what, page) in [("fill", fill), ("shading", sh)] {
|
||||
let r = import_with_report(&pdf_with(&[page], &[func], None), &ImportOptions::default()).unwrap();
|
||||
let d = &r.document;
|
||||
assert!(r.warnings.iter().any(|w| w.contains("several inks")), "{what}: {:?}", r.warnings);
|
||||
assert!(links(d).iter().all(Option::is_none), "{what}: the art stays RGB");
|
||||
let gold = d.swatch("Gold").unwrap_or_else(|| panic!("{what}: no Gold swatch"));
|
||||
assert!(gold.spot && gold.global, "{what}: Gold is a spot swatch");
|
||||
assert!(near(gold.paint.color().unwrap(), Color::cmyk(0.0, 0.0, 1.0, 0.0)), "{what}: {gold:?}");
|
||||
assert_eq!(d.swatches.iter().filter(|s| s.spot).count(), 1, "{what}: Cyan is a process plate");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_spot_ink_only_between_a_shadings_stops_keeps_its_swatch() {
|
||||
// Varnish and Cyan → CMYK (cyan, 0, 0, 0): Varnish draws nothing, so the shading's RGB is one
|
||||
// colour and only its ends (Varnish 0) become stops; Varnish rises to 1 at the middle.
|
||||
let space = format!("[/DeviceN [/Varnish /Cyan] /DeviceCMYK {} 0 R]", first_extra(1));
|
||||
let func = "<< /FunctionType 4 /Domain [0 1 0 1] /Range [0 1 0 1 0 1 0 1] /Length 18 >>\nstream\n{ exch pop 0 0 0 }\nendstream";
|
||||
let half = |a: u8, b: u8| format!("<< /FunctionType 2 /Domain [0 1] /C0 [{a} 0.3] /C1 [{b} 0.3] /N 1 >>");
|
||||
let shading = format!(
|
||||
"<< /ShadingType 2 /ColorSpace {space} /Coords [0 0 50 0] /Function << /FunctionType 3 /Domain [0 1] /Functions [{} {}] /Bounds [0.5] /Encode [0 1 0 1] >> /Extend [true true] >>",
|
||||
half(0, 1),
|
||||
half(1, 0)
|
||||
);
|
||||
let page = PdfPage { resources: format!("/Shading << /Sh0 {shading} >>"), ..PdfPage::new(100.0, 50.0, "q 0 0 50 50 re W n /Sh0 sh Q") };
|
||||
let r = import_with_report(&pdf_with(&[page], &[func], None), &ImportOptions::default()).unwrap();
|
||||
let d = &r.document;
|
||||
let varnish = d.swatch("Varnish").unwrap_or_else(|| panic!("no Varnish swatch: {:?}", d.swatches));
|
||||
assert!(varnish.spot && varnish.global, "{varnish:?}");
|
||||
}
|
||||
|
||||
/// The spaces `/CS1` and `/CS2` of two inks (`[/{kind} {a} …]`, `[/{kind} {b} …]`) with the same
|
||||
/// alternate and tint transform, blue at 100 %: they look the same to the interpreter.
|
||||
fn blues(kind: &str, a: &str, b: &str) -> String {
|
||||
|
||||
@@ -43,7 +43,7 @@ pub fn glyph_art(document: &[u8], gid: u32, upem: f64, fill: Color, stroke: Opti
|
||||
let coloured = ["currentColor", "currentcolor", "context-fill", "context-stroke"].iter().any(|k| svg.contains(k));
|
||||
// usvg resolves `context-fill` and `context-stroke` only for markers.
|
||||
let svg = svg.replace("context-fill", &hex(fill)).replace("context-stroke", &stroke.map_or_else(|| "none".into(), hex));
|
||||
let (doc, _) = crate::import::import(&svg, &ImportOptions::default()).ok()?;
|
||||
let (doc, _) = crate::import::import(&svg, &ImportOptions::default(), crate::MAX_NODES).ok()?;
|
||||
let mut art = if whole {
|
||||
Node::group(NodeId(0), doc.layers.iter().map(|l| Arc::new(Node::group(NodeId(0), l.children().cloned().unwrap_or_default()))).collect())
|
||||
} else {
|
||||
|
||||
@@ -20,8 +20,13 @@ use text::TextSlots;
|
||||
|
||||
pub use depth::MAX_ELEMENT_DEPTH;
|
||||
pub(crate) use depth::xml_too_deep;
|
||||
pub use files::MAX_NESTED_SVG_IMAGES;
|
||||
pub(crate) use fx::BLEND;
|
||||
|
||||
/// Most objects an imported SVG may expand to: a guard against `<use>` and nested-image
|
||||
/// expansion bombs (#3564).
|
||||
pub const MAX_NODES: usize = if cfg!(target_arch = "wasm32") { 500_000 } else { 2_000_000 };
|
||||
|
||||
mod convert;
|
||||
mod importer;
|
||||
mod layers;
|
||||
@@ -58,7 +63,7 @@ fn parse(svg: &str) -> Result<roxmltree::Document<'_>, SvgError> {
|
||||
}
|
||||
|
||||
/// The document SVG text `svg` holds, read with `opts`, and the warnings about what it couldn't keep.
|
||||
pub(crate) fn import(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec<String>), SvgError> {
|
||||
pub(crate) fn import(svg: &str, opts: &ImportOptions, max_nodes: usize) -> Result<(Document, Vec<String>), SvgError> {
|
||||
// Before anything reads the tree: the XML reader, usvg, and what is made of its tree, all by
|
||||
// recursion.
|
||||
let too_deep = || SvgError::TooDeep(depth::MAX_ELEMENT_DEPTH);
|
||||
@@ -116,6 +121,10 @@ pub(crate) fn import(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec<S
|
||||
sublayers: found.sublayers,
|
||||
sublayer_groups: HashSet::new(),
|
||||
screen: k,
|
||||
converted_nodes: 1,
|
||||
max_nodes,
|
||||
error: None,
|
||||
nested_svg_images: 0,
|
||||
};
|
||||
im.doc.svg_scripts = found.scripts;
|
||||
if im.files.nested_text() {
|
||||
@@ -183,7 +192,9 @@ pub(crate) fn import(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec<S
|
||||
}
|
||||
None => false,
|
||||
};
|
||||
let id = im.doc.alloc_id();
|
||||
let Some(id) = im.alloc_id() else {
|
||||
return Err(im.error.unwrap_or(SvgError::TooLarge(im.max_nodes)));
|
||||
};
|
||||
let preset = (im.doc.layers.len() % vectorcraft_doc::LAYER_COLORS.len()) as u8;
|
||||
let mut l = Node::layer(id, &name, LayerColor::Preset(preset));
|
||||
l.set_clips(clip);
|
||||
@@ -197,10 +208,16 @@ pub(crate) fn import(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec<S
|
||||
im.keep_foreign(g.id(), &mut l);
|
||||
im.doc.layers.push(Arc::new(l));
|
||||
}
|
||||
if let Some(err) = im.error.clone() {
|
||||
return Err(err);
|
||||
}
|
||||
} else {
|
||||
// An export of a one-layer document names the layer on the root element, its art (groups
|
||||
// among it) loose inside: the one layer takes that name.
|
||||
let children = im.children(top, base);
|
||||
if let Some(err) = im.error.clone() {
|
||||
return Err(err);
|
||||
}
|
||||
if let Some(l) = im.doc.layers.first_mut() {
|
||||
let l = Arc::make_mut(l);
|
||||
if let Some(ch) = l.children_mut() {
|
||||
@@ -211,6 +228,9 @@ pub(crate) fn import(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec<S
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(err) = im.error {
|
||||
return Err(err);
|
||||
}
|
||||
if !im.sublayer_groups.is_empty() {
|
||||
for l in &mut im.doc.layers {
|
||||
promote_sublayers(Arc::make_mut(l), &im.sublayer_groups);
|
||||
@@ -260,4 +280,12 @@ struct Importer {
|
||||
/// Points per screen pixel (the root's user unit before its `viewBox`): what a non-scaling
|
||||
/// stroke's width is measured in.
|
||||
screen: f64,
|
||||
/// How many objects were converted so far.
|
||||
converted_nodes: usize,
|
||||
/// Most objects the document may expand to.
|
||||
max_nodes: usize,
|
||||
/// An error encountered while converting objects (e.g. [`SvgError::TooLarge`]).
|
||||
error: Option<SvgError>,
|
||||
/// How many nested SVG images were converted so far.
|
||||
nested_svg_images: usize,
|
||||
}
|
||||
|
||||
@@ -15,6 +15,9 @@ use vectorcraft_doc::LinkInfo;
|
||||
use super::{Edits, depth, href};
|
||||
use crate::{ImportOptions, MAX_ELEMENT_DEPTH, MAX_SVGZ, SvgError};
|
||||
|
||||
/// Most nested SVG images an imported SVG may decode (#3564).
|
||||
pub const MAX_NESTED_SVG_IMAGES: usize = 256;
|
||||
|
||||
/// The files an SVG's images link to.
|
||||
#[derive(Default)]
|
||||
pub(in crate::import) struct Files {
|
||||
@@ -36,6 +39,10 @@ pub(in crate::import) struct Files {
|
||||
too_deep: AtomicBool,
|
||||
/// An SVGZ image was left out for unpacking past what the SVGZ images may.
|
||||
too_big: AtomicBool,
|
||||
/// How many nested SVG images were decoded so far.
|
||||
svg_images: AtomicUsize,
|
||||
/// SVG images were left out for exceeding [`MAX_NESTED_SVG_IMAGES`].
|
||||
too_many_svg_images: AtomicBool,
|
||||
}
|
||||
|
||||
/// How usvg holds the bytes of a raster of one format (`ImageKind::PNG`…).
|
||||
@@ -214,6 +221,9 @@ impl Files {
|
||||
if self.too_big.load(Ordering::Relaxed) {
|
||||
notes.push(format!("SVGZ images were left out: together they unpack to more than {} MB", MAX_SVGZ >> 20));
|
||||
}
|
||||
if self.too_many_svg_images.load(Ordering::Relaxed) {
|
||||
notes.push(format!("SVG images were left out: more than {MAX_NESTED_SVG_IMAGES} nested SVG images"));
|
||||
}
|
||||
notes
|
||||
}
|
||||
|
||||
@@ -268,6 +278,9 @@ impl Files {
|
||||
/// can't read, when it isn't SVG or when its elements nest deeper than the SVGs around it
|
||||
/// leave: usvg reads it while it reads them, on the same stack.
|
||||
fn svg(&self, text: &str, opts: &usvg::Options) -> Option<ImageKind> {
|
||||
if self.svg_images.fetch_add(1, Ordering::Relaxed) >= MAX_NESTED_SVG_IMAGES {
|
||||
return leave_out(&self.too_many_svg_images, true);
|
||||
}
|
||||
// Measured as the SVG holding it is (`import`): the text before the XML reader.
|
||||
let used = self.depth.load(Ordering::Relaxed);
|
||||
let left = MAX_ELEMENT_DEPTH.saturating_sub(used);
|
||||
|
||||
@@ -3,9 +3,10 @@ use super::layers::filter_names;
|
||||
use super::prepass::link;
|
||||
use super::{
|
||||
Affine, Appearance, AppearanceItem, Arc, BlendMode, Color, Dash, FillLayer, FillRule, Gradient, GradientGeom, GradientKind, GradientPaint,
|
||||
GradientStop, ImageBlob, ImageObject, Importer, LineCap, LineJoin, Node, NodeKind, Paint, PathData, PatternDef, Point, Rect, Reuse, StrokeLayer,
|
||||
Vec2, fnv1a, fx, shapes, spread, text, vector_effect,
|
||||
GradientStop, ImageBlob, ImageObject, Importer, LineCap, LineJoin, Node, NodeId, NodeKind, Paint, PathData, PatternDef, Point, Rect, Reuse,
|
||||
StrokeLayer, Vec2, fnv1a, fx, shapes, spread, text, vector_effect,
|
||||
};
|
||||
use crate::SvgError;
|
||||
|
||||
/// How many clip paths deep (`<clipPath clip-path>`) an object's clip is read.
|
||||
const MAX_CLIP_NEST: usize = 16;
|
||||
@@ -41,12 +42,27 @@ impl Importer {
|
||||
}
|
||||
|
||||
pub(super) fn children(&mut self, g: &usvg::Group, acc: Affine) -> Vec<Arc<Node>> {
|
||||
g.children().iter().filter_map(|c| self.node(c, acc)).map(Arc::new).collect()
|
||||
if self.error.is_some() {
|
||||
return vec![];
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for c in g.children() {
|
||||
if self.error.is_some() {
|
||||
break;
|
||||
}
|
||||
if let Some(node) = self.node(c, acc) {
|
||||
out.push(Arc::new(node));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The object usvg node `n` makes under transform `acc`, with what the file kept on its element
|
||||
/// (data, event and other applications' attributes); `None` for what imports as nothing.
|
||||
pub(super) fn node(&mut self, n: &usvg::Node, acc: Affine) -> Option<Node> {
|
||||
if self.error.is_some() {
|
||||
return None;
|
||||
}
|
||||
let mut out = match n {
|
||||
usvg::Node::Group(g) => self.group(g, acc),
|
||||
usvg::Node::Path(p) => self.path(p, acc),
|
||||
@@ -97,17 +113,33 @@ impl Importer {
|
||||
self.labels.get(id).map_or(id, String::as_str)
|
||||
}
|
||||
|
||||
/// Allocate a fresh node id within [`Importer::max_nodes`].
|
||||
pub(super) fn alloc_id(&mut self) -> Option<NodeId> {
|
||||
if self.error.is_some() {
|
||||
return None;
|
||||
}
|
||||
if self.converted_nodes >= self.max_nodes {
|
||||
self.error = Some(SvgError::TooLarge(self.max_nodes));
|
||||
return None;
|
||||
}
|
||||
self.converted_nodes += 1;
|
||||
Some(self.doc.alloc_id())
|
||||
}
|
||||
|
||||
/// A new object named after element `id` (unless it has none or a made-up one).
|
||||
fn named(&mut self, id: &str, kind: NodeKind) -> Node {
|
||||
let nid = self.doc.alloc_id();
|
||||
fn named(&mut self, id: &str, kind: NodeKind) -> Option<Node> {
|
||||
let nid = self.alloc_id()?;
|
||||
let mut n = Node::new(nid, kind);
|
||||
if !id.is_empty() && !made_up(id) {
|
||||
n.name = Some(self.name_of(id).to_string());
|
||||
}
|
||||
n
|
||||
Some(n)
|
||||
}
|
||||
|
||||
fn group(&mut self, g: &usvg::Group, acc: Affine) -> Option<Node> {
|
||||
if self.error.is_some() {
|
||||
return None;
|
||||
}
|
||||
let url = self.links.get(g.id()).cloned();
|
||||
let mut n = match self.instance(g, acc) {
|
||||
Some(n) => n,
|
||||
@@ -143,7 +175,7 @@ impl Importer {
|
||||
let art = match kids.len() {
|
||||
0 => return None,
|
||||
1 => Arc::unwrap_or_clone(kids.pop()?),
|
||||
_ => self.named("", NodeKind::Group { children: kids, clip: false }),
|
||||
_ => self.named("", NodeKind::Group { children: kids, clip: false })?,
|
||||
};
|
||||
// usvg clips a `<use>` of a symbol to its viewport: that only counts when it cuts the art.
|
||||
let whole = g.clip_path().is_none_or(|cp| uncut(cp, ts, &art, xf));
|
||||
@@ -164,7 +196,7 @@ impl Importer {
|
||||
};
|
||||
let label = if made_up(g.id()) { "a symbol instance".to_string() } else { format!("'{}'", g.id()) };
|
||||
let mask = g.mask().and_then(|m| self.opacity_mask(m, ts, &label));
|
||||
let mut n = self.named(g.id(), NodeKind::SymbolInstance { symbol: name, xf });
|
||||
let mut n = self.named(g.id(), NodeKind::SymbolInstance { symbol: name, xf })?;
|
||||
n.opacity = g.opacity().get();
|
||||
n.blend = blend(g.blend_mode());
|
||||
n.isolate = g.isolate();
|
||||
@@ -227,7 +259,7 @@ impl Importer {
|
||||
ap
|
||||
});
|
||||
text::fold_scale(&mut obj, grow);
|
||||
let mut n = self.named(&name, NodeKind::Text(Box::new(obj)));
|
||||
let mut n = self.named(&name, NodeKind::Text(Box::new(obj)))?;
|
||||
if let Some(ap) = under {
|
||||
n.appearance = ap.into();
|
||||
}
|
||||
@@ -277,12 +309,12 @@ impl Importer {
|
||||
let mut n = if let Some(cp) = g.clip_path() {
|
||||
// SVG filters before it clips: the effects go on the art inside the clip.
|
||||
if !effects.is_empty() {
|
||||
let mut inner = self.named("", NodeKind::Group { children, clip: false });
|
||||
let mut inner = self.named("", NodeKind::Group { children, clip: false })?;
|
||||
inner.appearance.effects = effects.clone();
|
||||
children = vec![Arc::new(inner)];
|
||||
}
|
||||
let children = self.clipped(cp, ts, children, &label)?;
|
||||
self.named(id, NodeKind::Group { children, clip: true })
|
||||
self.named(id, NodeKind::Group { children, clip: true })?
|
||||
} else {
|
||||
if children.is_empty() {
|
||||
return None;
|
||||
@@ -310,7 +342,7 @@ impl Importer {
|
||||
c.appearance.effects.extend(effects);
|
||||
return Some(c);
|
||||
}
|
||||
let mut n = self.named(id, NodeKind::Group { children, clip: false });
|
||||
let mut n = self.named(id, NodeKind::Group { children, clip: false })?;
|
||||
n.appearance.effects = effects;
|
||||
n
|
||||
};
|
||||
@@ -358,7 +390,7 @@ impl Importer {
|
||||
// over when clipped, white when not, inverted when the mask is).
|
||||
let art = match children.len() {
|
||||
1 => Arc::unwrap_or_clone(children.pop()?),
|
||||
_ => self.named("", NodeKind::Group { children, clip: false }),
|
||||
_ => self.named("", NodeKind::Group { children, clip: false })?,
|
||||
};
|
||||
let mut mask = vectorcraft_doc::OpacityMask::new(art, clip);
|
||||
mask.invert = invert;
|
||||
@@ -384,7 +416,7 @@ impl Importer {
|
||||
for clip in clips {
|
||||
let mut ch = vec![Arc::new(clip)];
|
||||
ch.extend(children);
|
||||
children = vec![Arc::new(self.named("", NodeKind::Group { children: ch, clip: true }))];
|
||||
children = vec![Arc::new(self.named("", NodeKind::Group { children: ch, clip: true })?)];
|
||||
}
|
||||
let mut ch = vec![Arc::new(outer)];
|
||||
ch.extend(children);
|
||||
@@ -400,7 +432,9 @@ impl Importer {
|
||||
if let NodeKind::Layer { children, clip: clip @ true, .. } = &mut l.kind {
|
||||
*clip = false;
|
||||
let clipped = std::mem::take(children);
|
||||
children.push(Arc::new(self.named("", NodeKind::Group { children: clipped, clip: true })));
|
||||
if let Some(group) = self.named("", NodeKind::Group { children: clipped, clip: true }) {
|
||||
children.push(Arc::new(group));
|
||||
}
|
||||
}
|
||||
if let Some(ch) = l.children_mut() {
|
||||
let i = index.min(ch.len());
|
||||
@@ -421,16 +455,15 @@ impl Importer {
|
||||
let id = cp.id().to_string();
|
||||
if subs.len() == 1 {
|
||||
let path = PathData::new(subs);
|
||||
return Some(self.named(&id, NodeKind::Path { path, rule: r, live: None, clipping: true, guide: false }));
|
||||
return self.named(&id, NodeKind::Path { path, rule: r, live: None, clipping: true, guide: false });
|
||||
}
|
||||
let children = subs
|
||||
.into_iter()
|
||||
.map(|sp| {
|
||||
let nid = self.doc.alloc_id();
|
||||
Arc::new(Node::new(nid, NodeKind::Path { path: PathData::single(sp), rule: r, live: None, clipping: false, guide: false }))
|
||||
})
|
||||
.collect();
|
||||
Some(self.named(&id, NodeKind::Compound { children, rule: r }))
|
||||
let mut children = Vec::with_capacity(subs.len());
|
||||
for sp in subs {
|
||||
let nid = self.alloc_id()?;
|
||||
children
|
||||
.push(Arc::new(Node::new(nid, NodeKind::Path { path: PathData::single(sp), rule: r, live: None, clipping: false, guide: false })));
|
||||
}
|
||||
self.named(&id, NodeKind::Compound { children, rule: r })
|
||||
}
|
||||
|
||||
/// A usvg paint mapped by `m`, for art covering `area` (after `m`: where a reflected or
|
||||
@@ -502,9 +535,11 @@ impl Importer {
|
||||
}
|
||||
let clip =
|
||||
self.named("", NodeKind::Path { path: shapes::rectangle(tile), rule: FillRule::NonZero, live: None, clipping: true, guide: false });
|
||||
let Some(clip) = clip else { return Paint::None };
|
||||
let mut children = vec![Arc::new(clip)];
|
||||
children.extend(content);
|
||||
let art = self.named("", NodeKind::Group { children, clip: true });
|
||||
let Some(art) = art else { return Paint::None };
|
||||
let base = if pt.id().is_empty() { "Pattern" } else { pt.id() };
|
||||
let name = unique_name(base, |n| self.doc.pattern(n).is_some());
|
||||
let mut def = PatternDef::new(&name, vec![Arc::new(art)]);
|
||||
@@ -575,19 +610,16 @@ impl Importer {
|
||||
let appearance: vectorcraft_doc::Shared<Appearance> = appearance.into();
|
||||
let mut n = if path.subpaths.len() > 1 {
|
||||
// Multi-subpath SVG paths are compound paths (as in Illustrator).
|
||||
let children = path
|
||||
.subpaths
|
||||
.into_iter()
|
||||
.map(|sp| {
|
||||
let id = self.doc.alloc_id();
|
||||
let mut c = Node::new(id, NodeKind::Path { path: PathData::single(sp), rule: r, live: None, clipping: false, guide: false });
|
||||
c.appearance = appearance.clone();
|
||||
Arc::new(c)
|
||||
})
|
||||
.collect();
|
||||
self.named(p.id(), NodeKind::Compound { children, rule: r })
|
||||
let mut children = Vec::with_capacity(path.subpaths.len());
|
||||
for sp in path.subpaths {
|
||||
let id = self.alloc_id()?;
|
||||
let mut c = Node::new(id, NodeKind::Path { path: PathData::single(sp), rule: r, live: None, clipping: false, guide: false });
|
||||
c.appearance = appearance.clone();
|
||||
children.push(Arc::new(c));
|
||||
}
|
||||
self.named(p.id(), NodeKind::Compound { children, rule: r })?
|
||||
} else {
|
||||
self.named(p.id(), NodeKind::Path { path, rule: r, live: None, clipping: false, guide: false })
|
||||
self.named(p.id(), NodeKind::Path { path, rule: r, live: None, clipping: false, guide: false })?
|
||||
};
|
||||
n.appearance = appearance;
|
||||
Some(n)
|
||||
@@ -620,15 +652,23 @@ impl Importer {
|
||||
if link.is_some() && !missing { blob.with_proxy() } else { blob }
|
||||
});
|
||||
let xf = acc * Affine::scale_non_uniform(size.width() as f64 / pw as f64, size.height() as f64 / ph as f64);
|
||||
Some(self.named(
|
||||
self.named(
|
||||
i.id(),
|
||||
NodeKind::Image(Box::new(ImageObject { key, width: pw, height: ph, xf, link, placement: Default::default(), pixels: None })),
|
||||
))
|
||||
)
|
||||
}
|
||||
|
||||
/// An SVG shown as an image (a data URL or a linked file) → its art, `acc` mapping its size.
|
||||
/// Its ids are its own: they mean nothing in the SVG around it.
|
||||
fn svg_image(&mut self, tree: &usvg::Tree, acc: Affine) -> Option<Node> {
|
||||
if self.error.is_some() {
|
||||
return None;
|
||||
}
|
||||
if self.nested_svg_images >= crate::MAX_NESTED_SVG_IMAGES {
|
||||
self.error = Some(SvgError::TooManyImages(crate::MAX_NESTED_SVG_IMAGES));
|
||||
return None;
|
||||
}
|
||||
self.nested_svg_images += 1;
|
||||
let outer = (
|
||||
std::mem::take(&mut self.links),
|
||||
std::mem::take(&mut self.hidden),
|
||||
@@ -643,3 +683,47 @@ impl Importer {
|
||||
art
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::import::files::Files;
|
||||
use crate::import::text::TextSlots;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use vectorcraft_doc::Document;
|
||||
|
||||
#[test]
|
||||
fn svg_image_cap_sets_too_many_images_error() {
|
||||
let mut im = Importer {
|
||||
doc: Document::new(100.0, 100.0),
|
||||
warnings: vec![],
|
||||
mask_flags: HashMap::new(),
|
||||
links: HashMap::new(),
|
||||
slots: TextSlots::default(),
|
||||
patterns: HashMap::new(),
|
||||
midpoints: HashMap::new(),
|
||||
labels: HashMap::new(),
|
||||
hidden: HashSet::new(),
|
||||
uses: HashMap::new(),
|
||||
symbols: HashMap::new(),
|
||||
blends: HashMap::new(),
|
||||
files: Files::default(),
|
||||
non_scaling: HashSet::new(),
|
||||
data: HashMap::new(),
|
||||
events: HashMap::new(),
|
||||
foreign: HashMap::new(),
|
||||
sublayers: HashSet::new(),
|
||||
sublayer_groups: HashSet::new(),
|
||||
screen: 1.0,
|
||||
converted_nodes: 0,
|
||||
max_nodes: 1000,
|
||||
error: None,
|
||||
nested_svg_images: crate::MAX_NESTED_SVG_IMAGES,
|
||||
};
|
||||
let tree = usvg::Tree::from_str(r#"<svg xmlns="http://www.w3.org/2000/svg"/>"#, &usvg::Options::default()).unwrap();
|
||||
let res = im.svg_image(&tree, Affine::IDENTITY);
|
||||
assert!(res.is_none());
|
||||
assert!(matches!(im.error, Some(SvgError::TooManyImages(crate::MAX_NESTED_SVG_IMAGES))));
|
||||
assert_eq!(im.error.unwrap().to_string(), "the SVG expands to more than 256 nested SVG images");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,6 +162,7 @@ pub(super) struct PendingText {
|
||||
}
|
||||
|
||||
/// The texts [`prepare`] replaced, by placeholder index.
|
||||
#[derive(Default)]
|
||||
pub(super) struct TextSlots {
|
||||
prefix: String,
|
||||
pub texts: Vec<PendingText>,
|
||||
|
||||
+16
-3
@@ -333,7 +333,7 @@ pub struct LinkedImage {
|
||||
pub bytes: std::sync::Arc<Vec<u8>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[derive(Clone, Debug, thiserror::Error)]
|
||||
pub enum SvgError {
|
||||
#[error("SVG parse error: {0}")]
|
||||
Parse(String),
|
||||
@@ -343,6 +343,14 @@ pub enum SvgError {
|
||||
/// SVGZ data unpacks to more than the reader takes (a guard against decompression bombs).
|
||||
#[error("SVG parse error: {0}")]
|
||||
TooBig(String),
|
||||
/// The SVG expands to more objects than the reader takes (a guard against `<use>` and
|
||||
/// nested-image expansion bombs, #3564).
|
||||
#[error("the SVG expands to more than {0} objects")]
|
||||
TooLarge(usize),
|
||||
/// The SVG expands to more nested SVG images than the reader takes (a guard against
|
||||
/// nested-image expansion bombs, #3564).
|
||||
#[error("the SVG expands to more than {0} nested SVG images")]
|
||||
TooManyImages(usize),
|
||||
}
|
||||
|
||||
/// Export a document as an SVG string (linked images are not returned: see [`export_full`]).
|
||||
@@ -541,13 +549,18 @@ pub fn import_with_report(svg: &str) -> Result<(Document, Vec<String>), SvgError
|
||||
}
|
||||
|
||||
pub use glyph::glyph_art;
|
||||
pub use import::{MAX_ELEMENT_DEPTH, MAX_FOREIGN, MAX_FOREIGN_VALUE};
|
||||
pub use import::{MAX_ELEMENT_DEPTH, MAX_FOREIGN, MAX_FOREIGN_VALUE, MAX_NESTED_SVG_IMAGES, MAX_NODES};
|
||||
|
||||
/// Import an SVG document reading the files it links to as `opts` says, also returning warnings.
|
||||
pub fn import_with(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec<String>), SvgError> {
|
||||
import_within(svg, opts, MAX_NODES)
|
||||
}
|
||||
|
||||
/// [`import_with`], capping the objects the document may expand to at `max_nodes`.
|
||||
pub fn import_within(svg: &str, opts: &ImportOptions, max_nodes: usize) -> Result<(Document, Vec<String>), SvgError> {
|
||||
// usvg converts the tree by recursion, in large stack frames: the nesting it is given is
|
||||
// capped (`import::depth`), and it gets room for that cap whatever thread asks.
|
||||
vectorcraft_doc::depth::with_deep_stack(|| import::import(svg, opts))
|
||||
vectorcraft_doc::depth::with_deep_stack(|| import::import(svg, opts, max_nodes))
|
||||
}
|
||||
|
||||
/// Reads a linked file: its bytes and the link to it (absolute path, size, time and hash), or
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
use vectorcraft_doc::{Document, ImageObject, LinkInfo, Node, NodeKind};
|
||||
use vectorcraft_geom::Rect;
|
||||
use vectorcraft_svg::{ImportOptions, import_with, import_with_report};
|
||||
use vectorcraft_svg::{ImportOptions, MAX_NESTED_SVG_IMAGES, SvgError, import_with, import_with_report, import_within};
|
||||
use vectorcraft_testkit::raster::{Image, assert_similar, render_artboard};
|
||||
|
||||
fn svg(body: &str) -> String {
|
||||
@@ -372,3 +372,54 @@ fn inkscape_pages_become_artboards() {
|
||||
fn svg_doc() -> String {
|
||||
svg(r#"<rect x="10" y="10" width="20" height="20"/>"#)
|
||||
}
|
||||
|
||||
/// An ordinary nested SVG image imports within budget, while `<use>`-of-nested-image fan-out
|
||||
/// past the node budget is refused with `SvgError::TooLarge` (#3564).
|
||||
#[test]
|
||||
fn nested_image_within_budget_imports_and_use_fan_out_is_refused() {
|
||||
let inner = r#"<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20"><rect width="10" height="10" fill="red"/></svg>"#;
|
||||
let nested = format!("data:image/svg+xml;base64,{}", vectorcraft_svg::base64_encode(inner.as_bytes()));
|
||||
let single = format!(r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><image href="{nested}" width="20" height="20"/></svg>"#);
|
||||
// An ordinary nested image imports within a small budget (3 objects: Layer 1, Group, Rect).
|
||||
let (d, _) = import_within(&single, &ImportOptions::default(), 10).unwrap();
|
||||
assert!(!d.layers[0].children().unwrap().is_empty());
|
||||
|
||||
// Fan-out duplicating the nested image via <use> exceeds the budget and is refused.
|
||||
let uses = "<use href=\"#item\"/>".repeat(20);
|
||||
let fanout = format!(
|
||||
r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
|
||||
<defs><g id="item"><image href="{nested}" width="20" height="20"/></g></defs>
|
||||
{uses}
|
||||
</svg>"#
|
||||
);
|
||||
let err = import_within(&fanout, &ImportOptions::default(), 5).unwrap_err();
|
||||
assert!(matches!(err, SvgError::TooLarge(5)), "{err:?}");
|
||||
assert_eq!(err.to_string(), "the SVG expands to more than 5 objects");
|
||||
|
||||
// Highly amplified multi-level <use> tree is refused without exploding memory.
|
||||
let multi_level = format!(
|
||||
r##"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
|
||||
<defs>
|
||||
<g id="g0"><image href="{nested}" width="20" height="20"/></g>
|
||||
<g id="g1"><use href="#g0"/><use href="#g0"/></g>
|
||||
<g id="g2"><use href="#g1"/><use href="#g1"/></g>
|
||||
<g id="g3"><use href="#g2"/><use href="#g2"/></g>
|
||||
<g id="g4"><use href="#g3"/><use href="#g3"/></g>
|
||||
</defs>
|
||||
<use href="#g4"/>
|
||||
</svg>"##
|
||||
);
|
||||
let err = import_within(&multi_level, &ImportOptions::default(), 15).unwrap_err();
|
||||
assert!(matches!(err, SvgError::TooLarge(15)), "{err:?}");
|
||||
}
|
||||
|
||||
/// Nested SVG images exceeding `MAX_NESTED_SVG_IMAGES` are left out with a warning note (#3564).
|
||||
#[test]
|
||||
fn nested_svg_images_past_count_cap_are_left_out() {
|
||||
let inner = r#"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><rect width="5" height="5"/></svg>"#;
|
||||
let nested = format!("data:image/svg+xml;base64,{}", vectorcraft_svg::base64_encode(inner.as_bytes()));
|
||||
let images = format!(r#"<image href="{nested}" width="10" height="10"/>"#).repeat(MAX_NESTED_SVG_IMAGES + 10);
|
||||
let svg = format!(r#"<svg xmlns="http://www.w3.org/2000/svg" width="500" height="500">{images}</svg>"#);
|
||||
let (_d, warnings) = import_with(&svg, &ImportOptions::default()).unwrap();
|
||||
assert!(warnings.iter().any(|w| w.contains(&format!("more than {MAX_NESTED_SVG_IMAGES} nested SVG images"))));
|
||||
}
|
||||
|
||||
@@ -91,9 +91,11 @@ pub fn scale_for_drag(r: Rect, handle: Handle, p: Point, proportional: bool, fro
|
||||
let mut sy = if ay && d0.y.abs() > 1e-9 { d1.y / d0.y } else { 1.0 };
|
||||
if proportional {
|
||||
if handle.is_corner() {
|
||||
let s = if sx.abs() > sy.abs() { sx } else { sy };
|
||||
sx = s.abs() * sx.signum();
|
||||
sy = s.abs() * sy.signum();
|
||||
// Follow the axis the pointer moved further along (a drag along one axis must still
|
||||
// resize), and mirror both axes together when it crosses the origin.
|
||||
let s = if (sx - 1.0).abs() >= (sy - 1.0).abs() { sx } else { sy };
|
||||
sx = s;
|
||||
sy = s;
|
||||
} else if ax {
|
||||
sy = sx.abs();
|
||||
} else {
|
||||
@@ -151,6 +153,25 @@ mod tests {
|
||||
assert_eq!(a.transform_rect_bbox(R), Rect::new(0.0, 0.0, 200.0, 100.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proportional_corner_follows_a_drag_along_one_axis() {
|
||||
let scale = |p: Point, from_center: bool| {
|
||||
let a = scale_for_drag(R, Handle::BottomRight, p, true, from_center).as_coeffs();
|
||||
(a[0], a[3])
|
||||
};
|
||||
for from_center in [false, true] {
|
||||
let o = if from_center { R.center() } else { Point::new(0.0, 0.0) };
|
||||
let (hx, hy) = (100.0 - o.x, 50.0 - o.y);
|
||||
// Horizontal drag, then vertical drag: the size follows the pointer, uniformly.
|
||||
for (t, want) in [(0.5, 0.5), (0.25, 0.25), (-0.5, -0.5), (-1.0, -1.0)] {
|
||||
for p in [Point::new(o.x + hx * t, 50.0), Point::new(100.0, o.y + hy * t)] {
|
||||
let (sx, sy) = scale(p, from_center);
|
||||
assert!((sx - want).abs() < 1e-9 && (sy - want).abs() < 1e-9, "{p:?} center={from_center}: got ({sx}, {sy}), want {want}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_side_and_center() {
|
||||
let a = scale_for_drag(R, Handle::Right, Point::new(150.0, 999.0), false, false);
|
||||
|
||||
@@ -163,7 +163,7 @@ id = "PenAdd"
|
||||
group = "Pen"
|
||||
shown_when = [
|
||||
"The Pen over a segment of a selected path with Auto Add/Delete on (Shift off), or over a selected blend's spine.",
|
||||
"The Add Anchor Point tool everywhere (and the Delete Anchor Point tool with Alt).",
|
||||
"The Add Anchor Point tool everywhere (and the Delete Anchor Point tool and the Scissors tool with Alt).",
|
||||
"The Curvature tool over a segment.",
|
||||
"Stands in for MeshAdd and PuppetPin until they're drawn.",
|
||||
]
|
||||
@@ -622,14 +622,14 @@ hotspot = "The blade's point."
|
||||
[[cursor]]
|
||||
id = "Scissors"
|
||||
group = "Drawing and editing tools"
|
||||
shown_when = ["The Scissors tool over a path segment: a click splits the path there."]
|
||||
shown_when = ["The Scissors tool over a path segment, Alt up: a click splits the path there."]
|
||||
hotspot = "Where the blades cross, exact enough to cut where aimed."
|
||||
look = "Scissors. ScissorsNotAllowed is this drawing with a badge."
|
||||
|
||||
[[cursor]]
|
||||
id = "ScissorsNotAllowed"
|
||||
group = "Drawing and editing tools"
|
||||
shown_when = ["The Scissors tool away from every path segment: a click does nothing."]
|
||||
shown_when = ["The Scissors tool away from every path segment, Alt up: a click does nothing."]
|
||||
hotspot = "The same point as Scissors."
|
||||
look = "The Scissors with a small not-allowed badge, so the cursor keeps its size and shape and only the badge says a click won't cut."
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
//! pointer sample so far (and its pen pressure while Use Pressure Pen is on), so the result is a
|
||||
//! pure function of the parameters (replay reproduces it exactly). Holding the brush still with
|
||||
//! Twirl, Pucker or Bloat repeats the last sample every [`HOLD_EVERY`] seconds of [`Tool::tick`]
|
||||
//! time: one more dab there each time.
|
||||
//! time: one more dab there each time. Escape during a stroke cancels it, and Escape during an
|
||||
//! Alt-drag that sizes the brush puts the brush size back.
|
||||
//!
|
||||
//! The kernel resamples the stroke into dabs spaced a fraction of the brush apart ([`Dabber`]:
|
||||
//! more samples only add dabs, so a growing stroke can be applied as it grows). For each dab it
|
||||
@@ -19,7 +20,7 @@ use vectorcraft_geom::kurbo::ParamCurveArclen;
|
||||
use vectorcraft_geom::{Anchor, AnchorKind, PathData, Point, Rect, SubPath, Vec2};
|
||||
|
||||
use super::ellipse_path;
|
||||
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext};
|
||||
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum LiquifyKind {
|
||||
@@ -813,6 +814,27 @@ impl Tool for LiquifyTool {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The tool claims Escape during an Alt-drag that sizes the brush.
|
||||
fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool {
|
||||
key == ToolKey::Escape && self.sizing.is_some()
|
||||
}
|
||||
/// Escape during a stroke cancels it: the art goes back to how the press found it. During an
|
||||
/// Alt-drag sizing the brush, Escape puts the brush size back.
|
||||
fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
|
||||
if key != ToolKey::Escape {
|
||||
return vec![];
|
||||
}
|
||||
if let Some(s) = self.sizing.take() {
|
||||
(self.params.width, self.params.height) = (s.width, s.height);
|
||||
return vec![];
|
||||
}
|
||||
if !std::mem::take(&mut self.active) {
|
||||
return vec![];
|
||||
}
|
||||
self.points.clear();
|
||||
(self.held, self.previewed) = (0.0, 0);
|
||||
vec![Action::Cancel]
|
||||
}
|
||||
fn busy(&self) -> bool {
|
||||
self.active
|
||||
}
|
||||
@@ -960,4 +982,41 @@ mod tests {
|
||||
let r = LiquifyParams::from_json(&json!({"tool": "warp", "diameter": 40, "intensity": 80})).unwrap();
|
||||
assert_eq!((r.width, r.height, r.intensity), (40.0, 40.0, 0.8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_cancels_a_stroke_or_a_brush_size_drag() {
|
||||
use crate::testutil::{cx, doc_with_rect, paint};
|
||||
let (d, id) = doc_with_rect();
|
||||
let s = vectorcraft_doc::Selection { objects: vec![id], ..Default::default() };
|
||||
let p = paint();
|
||||
let c = cx(&d, &s, &p);
|
||||
let alt = Mods { alt: true, ..Default::default() };
|
||||
let ev = |kind, x, y, mods| PointerEvent::new(kind, x, y).with_mods(mods);
|
||||
for tool in ["warp", "twirl"] {
|
||||
let mut t = LiquifyTool::new(tool);
|
||||
assert!(t.key(&c, ToolKey::Escape, Mods::default()).is_empty(), "{tool}: no stroke, Escape is the shortcut's");
|
||||
assert!(!t.claims_key(&c, ToolKey::Escape), "{tool}");
|
||||
assert!(matches!(t.pointer(&c, &ev(PointerKind::Down, 190.0, 150.0, Mods::default()))[0], Action::Begin(_)), "{tool}");
|
||||
t.pointer(&c, &ev(PointerKind::Drag, 220.0, 150.0, Mods::default()));
|
||||
assert!(t.busy(), "{tool}");
|
||||
assert_eq!(t.key(&c, ToolKey::Escape, Mods::default()), vec![Action::Cancel], "{tool}");
|
||||
assert!(!t.busy() && !t.wants_ticks(), "{tool}");
|
||||
// The rest of the stroke does nothing.
|
||||
assert!(t.tick(&c, 1.0).is_empty(), "{tool}");
|
||||
assert!(t.pointer(&c, &ev(PointerKind::Drag, 240.0, 150.0, Mods::default())).is_empty(), "{tool}");
|
||||
assert!(t.pointer(&c, &ev(PointerKind::Up, 240.0, 150.0, Mods::default())).is_empty(), "{tool}");
|
||||
// Alt-drag sizes the brush; Escape puts the size back and the rest of that drag does nothing.
|
||||
let size = (t.params.width, t.params.height);
|
||||
t.pointer(&c, &ev(PointerKind::Down, 150.0, 150.0, alt));
|
||||
t.pointer(&c, &ev(PointerKind::Drag, 180.0, 170.0, alt));
|
||||
assert_ne!((t.params.width, t.params.height), size, "{tool}");
|
||||
assert!(t.claims_key(&c, ToolKey::Escape), "{tool}");
|
||||
assert!(t.key(&c, ToolKey::Escape, Mods::default()).is_empty(), "{tool}");
|
||||
assert_eq!((t.params.width, t.params.height), size, "{tool}");
|
||||
assert!(!t.claims_key(&c, ToolKey::Escape), "{tool}");
|
||||
t.pointer(&c, &ev(PointerKind::Drag, 200.0, 200.0, alt));
|
||||
assert!(t.pointer(&c, &ev(PointerKind::Up, 200.0, 200.0, alt)).is_empty(), "{tool}");
|
||||
assert_eq!((t.params.width, t.params.height), size, "{tool}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1094,6 +1094,29 @@ mod tests {
|
||||
assert!(!grid_overlays(&d, 1.0).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_cancels_a_grid_tool_drag() {
|
||||
let mut d = Document::new(800.0, 600.0);
|
||||
grid(2).store(&mut d);
|
||||
let (s, p) = (Selection::default(), paint());
|
||||
let c = cx(&d, &s, &p);
|
||||
let g = PerspectiveGrid::effective(&d);
|
||||
let mut t = PerspectiveGridTool::default();
|
||||
assert!(t.key(&c, ToolKey::Escape, Mods::default()).is_empty(), "no drag: Escape is the shortcut's");
|
||||
let widget = g.plane_widgets()[0].1;
|
||||
for (what, at, begin) in [("handle", Point::new(g.vp_left, g.horizon), "Edit Perspective Grid"), ("plane", widget, "Move Plane")] {
|
||||
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, at.x, at.y)), vec![Action::Begin(begin.into())], "{what}");
|
||||
assert!(!t.pointer(&c, &PointerEvent::new(PointerKind::Drag, at.x - 40.0, at.y + 10.0)).is_empty(), "{what}");
|
||||
assert!(t.key(&c, ToolKey::Enter, Mods::default()).is_empty(), "{what}: Enter leaves the drag alone");
|
||||
assert!(t.busy(), "{what}");
|
||||
assert_eq!(t.key(&c, ToolKey::Escape, Mods::default()), vec![Action::Cancel], "{what}");
|
||||
assert!(!t.busy(), "{what}");
|
||||
// The rest of the drag does nothing.
|
||||
assert!(t.pointer(&c, &PointerEvent::new(PointerKind::Drag, at.x - 60.0, at.y)).is_empty(), "{what}");
|
||||
assert!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, at.x - 60.0, at.y)).is_empty(), "{what}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn depth_moves_along_the_plane_normal() {
|
||||
// A grid with a viewing angle foreshortens each axis by its own distance.
|
||||
@@ -1186,6 +1209,41 @@ mod tests {
|
||||
assert!(!t.busy());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_cancels_a_perspective_selection_drag() {
|
||||
let (mut d, id) = doc_with_rect();
|
||||
grid(2).store(&mut d);
|
||||
set_attachment(d.node_mut(id).unwrap(), Plane::Right, 0.0);
|
||||
let mut s = Selection::default();
|
||||
s.add(id);
|
||||
let p = paint();
|
||||
let c = cx(&d, &s, &p);
|
||||
let g = PerspectiveGrid::current(&d);
|
||||
let (_, _, rect) = g.plane_bounds(&d, &[id]).unwrap();
|
||||
let h = g.homography(Plane::Right).unwrap();
|
||||
let (corner, mid) = (h.apply(Point::new(rect.x1, rect.y1)).unwrap(), h.apply(rect.center()).unwrap());
|
||||
let widget = g.plane_widgets()[0].1;
|
||||
let mut t = PerspectiveSelectionTool::default();
|
||||
assert!(t.key(&c, ToolKey::Escape, Mods::default()).is_empty(), "no drag: Escape is the shortcut's");
|
||||
for (what, at) in [("resize", corner), ("move", mid), ("plane", widget)] {
|
||||
t.pointer(&c, &PointerEvent::new(PointerKind::Down, at.x, at.y));
|
||||
let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, at.x + 20.0, at.y + 10.0));
|
||||
assert!(acts.iter().any(|a| matches!(a, Action::Preview(..))), "{what}: {acts:?}");
|
||||
assert_eq!(t.key(&c, ToolKey::Escape, Mods::default()), vec![Action::Cancel], "{what}");
|
||||
assert!(!t.busy(), "{what}");
|
||||
assert!(!t.overlays(&c).iter().any(|o| matches!(o, Overlay::Measure { .. })), "{what}: the measurement goes");
|
||||
// The rest of the drag does nothing.
|
||||
assert!(t.pointer(&c, &PointerEvent::new(PointerKind::Drag, at.x + 40.0, at.y)).is_empty(), "{what}");
|
||||
assert!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, at.x + 40.0, at.y)).is_empty(), "{what}");
|
||||
}
|
||||
// A press that has not moved past the drag threshold has nothing to cancel.
|
||||
t.pointer(&c, &PointerEvent::new(PointerKind::Down, mid.x, mid.y));
|
||||
assert!(t.busy());
|
||||
assert!(t.key(&c, ToolKey::Escape, Mods::default()).is_empty());
|
||||
assert!(!t.busy());
|
||||
assert!(t.pointer(&c, &PointerEvent::new(PointerKind::Drag, mid.x + 20.0, mid.y)).is_empty(), "no move begins");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resize_cursors_follow_the_handle_on_screen() {
|
||||
assert_eq!(resize_cursor(Vec2::new(1.0, 0.1)), Cursor::ResizeH);
|
||||
|
||||
@@ -92,6 +92,15 @@ impl Tool for PerspectiveGridTool {
|
||||
}
|
||||
.turned(cx.view_angle())
|
||||
}
|
||||
/// Escape during a handle or plane widget drag cancels it: the grid and the plane go back to
|
||||
/// the start of the drag.
|
||||
fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
|
||||
if key != ToolKey::Escape || !self.busy() {
|
||||
return vec![];
|
||||
}
|
||||
(self.drag, self.plane) = (None, None);
|
||||
vec![Action::Cancel]
|
||||
}
|
||||
fn busy(&self) -> bool {
|
||||
self.drag.is_some() || self.plane.is_some()
|
||||
}
|
||||
@@ -270,7 +279,16 @@ impl Tool for PerspectiveSelectionTool {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Escape during a move, a resize or a plane widget drag cancels it: the objects and the plane
|
||||
/// go back to the start of the drag.
|
||||
fn key(&mut self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Vec<Action> {
|
||||
if key == ToolKey::Escape && self.busy() {
|
||||
self.measure = None;
|
||||
return match std::mem::take(&mut self.drag) {
|
||||
SelDrag::Move { began: false, .. } => vec![],
|
||||
_ => vec![Action::Cancel],
|
||||
};
|
||||
}
|
||||
if let (ToolKey::Digit(5), SelDrag::Move { perp, began, .. }) = (key, &mut self.drag) {
|
||||
*perp = !*perp;
|
||||
return if *began { self.move_preview(cx).into_iter().collect() } else { vec![] };
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
//! adds a pin and selects it; Shift-click adds pins to the selection (or takes them out); dragging
|
||||
//! a pin moves every selected pin and warps the art; Alt-dragging near (not on) a selected pin
|
||||
//! turns the art around it, and that pin keeps the turn; Delete/Backspace remove the selected pins.
|
||||
//! A click off the mesh places nothing and says so.
|
||||
//! A click off the mesh places nothing and says so. Escape during a drag cancels it: the art, its
|
||||
//! pins and the selected pins go back to how the press found them.
|
||||
//!
|
||||
//! The pins live in the document ([`PuppetPins`]) with the rest shape they started from, so every
|
||||
//! warp starts from that shape (dragging a pin back restores the original, warps don't stack) and
|
||||
@@ -224,6 +225,8 @@ pub struct PuppetWarpTool {
|
||||
/// Selected pins (indices into the pins).
|
||||
selected: Vec<usize>,
|
||||
drag: Option<Gesture>,
|
||||
/// The selected pins before the press that began the drag (Escape puts them back).
|
||||
before_drag: Vec<usize>,
|
||||
/// The pins as the drag began (each preview starts from them).
|
||||
start: Option<Arc<Cached>>,
|
||||
/// The pointer and the modifiers held while hovering (for the turn ring).
|
||||
@@ -245,6 +248,7 @@ impl Default for PuppetWarpTool {
|
||||
ids: vec![],
|
||||
selected: vec![],
|
||||
drag: None,
|
||||
before_drag: vec![],
|
||||
start: None,
|
||||
hover: None,
|
||||
off_mesh: None,
|
||||
@@ -368,14 +372,17 @@ impl Tool for PuppetWarpTool {
|
||||
&& let Some(q) = set.pins.get(i)
|
||||
{
|
||||
self.drag = Some(Gesture::Turn { pin: i, base: set.turn_of(i), from: (p - q.at).atan2(), angle: 0.0 });
|
||||
self.before_drag = self.selected.clone();
|
||||
self.start = Some(c.clone());
|
||||
return vec![Action::Begin("Puppet Warp".into())];
|
||||
}
|
||||
if let Some(i) = Self::pin_at(cx, set, p) {
|
||||
let before = self.selected.clone();
|
||||
if !self.pick(i, ev.mods.shift) {
|
||||
return vec![];
|
||||
}
|
||||
self.drag = Some(Gesture::Move { from: p, to: p });
|
||||
self.before_drag = before;
|
||||
self.start = Some(c.clone());
|
||||
return vec![Action::Begin("Puppet Warp".into())];
|
||||
}
|
||||
@@ -426,7 +433,14 @@ impl Tool for PuppetWarpTool {
|
||||
&& cx.selection.objects == self.ids
|
||||
&& self.pins(cx).is_some_and(|c| self.selected.iter().any(|i| *i < c.set.pins.len()))
|
||||
}
|
||||
/// Escape during a pin drag or a turn cancels it: the art and its pins go back to the start of
|
||||
/// the drag, and the pins selected before the press are selected again.
|
||||
fn key(&mut self, cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
|
||||
if key == ToolKey::Escape && self.drag.take().is_some() {
|
||||
self.start = None;
|
||||
self.selected = std::mem::take(&mut self.before_drag);
|
||||
return vec![Action::Cancel, Action::Notify("pins".into())];
|
||||
}
|
||||
if !self.claims_key(cx, key) {
|
||||
return vec![];
|
||||
}
|
||||
@@ -572,6 +586,11 @@ mod tests {
|
||||
self.run(p);
|
||||
}
|
||||
Action::Commit => self.base = None,
|
||||
Action::Cancel => {
|
||||
if let Some(b) = self.base.take() {
|
||||
self.doc = b;
|
||||
}
|
||||
}
|
||||
Action::Exec(c, p) if c == "object.puppetWarp" => self.run(p),
|
||||
Action::Notify(w) => {
|
||||
let p = paint();
|
||||
@@ -678,6 +697,32 @@ mod tests {
|
||||
assert_eq!(r.bounds(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_cancels_a_pin_drag_or_a_turn_and_selects_the_pins_selected_before_it() {
|
||||
let mut r = Rig::new();
|
||||
r.click(110.0, 110.0, Mods::default());
|
||||
r.click(190.0, 110.0, Mods::default());
|
||||
let b = r.pins().len() - 1;
|
||||
assert_eq!(r.tool.selected, vec![b]);
|
||||
assert!(r.key(ToolKey::Escape).is_empty(), "no drag: Escape is the shortcut's");
|
||||
let (pins, art) = (r.pins(), r.doc.node(r.sel.objects[0]).cloned().unwrap());
|
||||
// A press on the unselected pin selects it and drags it; Escape puts everything back.
|
||||
for (what, press, to, mods) in [("move", (110.0, 110.0), (130.0, 140.0), Mods::default()), ("turn", (205.0, 110.0), (190.0, 125.0), alt())] {
|
||||
assert_eq!(r.ev(PointerKind::Down, press.0, press.1, mods), vec![Action::Begin("Puppet Warp".into())], "{what}");
|
||||
r.ev(PointerKind::Drag, to.0, to.1, mods);
|
||||
assert_ne!(r.doc.node(r.sel.objects[0]), Some(&art), "{what}: the drag warps the art");
|
||||
assert_eq!(r.key(ToolKey::Escape), vec![Action::Cancel, Action::Notify("pins".into())], "{what}");
|
||||
assert_eq!(r.doc.node(r.sel.objects[0]), Some(&art), "{what}: Escape puts the art back");
|
||||
assert_eq!(r.pins(), pins, "{what}: and the pins");
|
||||
assert_eq!(r.tool.selected, vec![b], "{what}: and the pins selected before the press");
|
||||
assert!(!r.tool.busy(), "{what}");
|
||||
// The rest of the drag does nothing.
|
||||
assert!(r.ev(PointerKind::Drag, to.0 + 10.0, to.1, mods).is_empty(), "{what}");
|
||||
assert!(r.ev(PointerKind::Up, to.0 + 10.0, to.1, mods).is_empty(), "{what}");
|
||||
assert_eq!(r.pins(), pins, "{what}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_drag_near_a_selected_pin_turns_the_art_around_it() {
|
||||
let mut r = Rig::new();
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
//! `stroke.widthPoint.copy {id, index, t}` or, for several points, `stroke.widthProfile.set {ids,
|
||||
//! points}`, and commit on release; Delete runs `stroke.widthPoint.remove {id, indices}`.
|
||||
//!
|
||||
//! Escape deselects the selected width points and sends no command.
|
||||
//!
|
||||
//! A compound path's stroke is the compound's: its members are edited through it, and the width
|
||||
//! points show on every subpath of the stroke (one profile runs along each). The cursor says what a
|
||||
//! drag does: add a width point on a stroke, or move or widen the one under it.
|
||||
@@ -391,14 +393,19 @@ impl Tool for WidthTool {
|
||||
},
|
||||
}
|
||||
}
|
||||
/// Delete/Backspace remove the selected width points; with none selected they are the
|
||||
/// shortcut's (Clear deletes the selected objects).
|
||||
/// Delete/Backspace remove the selected width points and Escape deselects them; with none
|
||||
/// selected the keys do what they do with other tools (Clear deletes the selected objects, and
|
||||
/// Escape leaves isolation mode, for one).
|
||||
fn claims_key(&self, cx: &ToolContext, key: ToolKey) -> bool {
|
||||
matches!(key, ToolKey::Delete | ToolKey::Backspace)
|
||||
matches!(key, ToolKey::Delete | ToolKey::Backspace | ToolKey::Escape)
|
||||
&& self.drag.is_none()
|
||||
&& self.selected.as_ref().is_some_and(|(id, _)| !self.selected_indices(cx, *id).is_empty())
|
||||
}
|
||||
fn key(&mut self, cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
|
||||
if key == ToolKey::Escape && self.drag.is_none() {
|
||||
self.selected = None;
|
||||
return vec![];
|
||||
}
|
||||
if !matches!(key, ToolKey::Delete | ToolKey::Backspace) {
|
||||
return vec![];
|
||||
}
|
||||
@@ -650,6 +657,40 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_deselects_the_width_points() {
|
||||
let (d, id) = doc_three_points();
|
||||
let (s, p) = (Selection::default(), paint());
|
||||
let c = cx(&d, &s, &p);
|
||||
let mut t = WidthTool::default();
|
||||
let (none, shift) = (Mods::default(), Mods { shift: true, ..Default::default() });
|
||||
// With nothing selected the tool leaves Escape to the app.
|
||||
assert!(!t.claims_key(&c, ToolKey::Escape));
|
||||
// Select the points at t = 0.25 and 0.75: Escape is the tool's and deselects both, sending
|
||||
// nothing to the document.
|
||||
t.pointer(&c, &ev(PointerKind::Down, 150.0, 200.0, none));
|
||||
t.pointer(&c, &ev(PointerKind::Up, 150.0, 200.0, none));
|
||||
t.pointer(&c, &ev(PointerKind::Down, 250.0, 200.0, shift));
|
||||
t.pointer(&c, &ev(PointerKind::Up, 250.0, 200.0, shift));
|
||||
assert_eq!(t.selected_indices(&c, id), vec![1, 3]);
|
||||
t.pointer(&c, &ev(PointerKind::Move, 200.0, 202.0, none));
|
||||
let thick = |t: &WidthTool| t.overlays(&c).iter().filter(|o| matches!(o, Overlay::Path { width, .. } if *width > 2.0)).count();
|
||||
assert_eq!(thick(&t), 2, "the two selected diamonds are drawn selected");
|
||||
assert!(t.claims_key(&c, ToolKey::Escape));
|
||||
assert!(t.key(&c, ToolKey::Escape, none).is_empty());
|
||||
assert!(t.selected_indices(&c, id).is_empty());
|
||||
assert_eq!(thick(&t), 0, "no diamond is drawn selected");
|
||||
// Delete then removes nothing, and Escape is the app's again.
|
||||
assert!(!t.claims_key(&c, ToolKey::Delete) && !t.claims_key(&c, ToolKey::Escape));
|
||||
assert!(t.key(&c, ToolKey::Delete, none).is_empty());
|
||||
// During a drag the dragged point stays selected.
|
||||
t.pointer(&c, &ev(PointerKind::Down, 200.0, 200.0, none));
|
||||
t.pointer(&c, &ev(PointerKind::Drag, 210.0, 200.0, none));
|
||||
assert!(!t.claims_key(&c, ToolKey::Escape));
|
||||
t.key(&c, ToolKey::Escape, none);
|
||||
assert!(t.selected.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn width_points_show_on_every_subpath() {
|
||||
let (d, c, _) = doc_compound();
|
||||
|
||||
@@ -17,7 +17,9 @@ use vectorcraft_pathops::{Cap, Join};
|
||||
use crate::{Action, Tool, ToolContext};
|
||||
|
||||
pub use anchor::AnchorTool;
|
||||
pub use curvature::{CurvatureTool, EndCurve, curvature_close, curvature_extend, curvature_insert, curvature_move, curvature_smooth};
|
||||
pub use curvature::{
|
||||
CurvatureTool, EndCurve, curvature_close, curvature_extend, curvature_insert, curvature_move, curvature_remove, curvature_smooth,
|
||||
};
|
||||
pub use family::FamilyTool;
|
||||
pub use gesture::GestureTool;
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
//! still held: at 45° steps from where it was). Space held during a convert- or handle-drag moves
|
||||
//! the anchor, handles and all, as while drawing with the Pen. Blend spine points and handles
|
||||
//! follow the same conversions (#2544).
|
||||
//! Scissors: click a path to split it there.
|
||||
//! Scissors: click a path to split it there (Alt = add, as the Add Anchor Point tool does).
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::NodeId;
|
||||
@@ -316,6 +316,9 @@ impl Tool for AnchorTool {
|
||||
}
|
||||
}
|
||||
("scissors", PointerKind::Down) => {
|
||||
if ev.mods.alt {
|
||||
return Self::add(cx, p);
|
||||
}
|
||||
if let Some((id, si, ai)) = hit_anchor(cx, p, point) {
|
||||
return vec![Action::Exec("path.split".into(), json!({"id": id.0, "subpath": si, "anchor": ai}))];
|
||||
}
|
||||
@@ -554,6 +557,7 @@ impl Tool for AnchorTool {
|
||||
"addAnchor" => Cursor::PenDelete,
|
||||
"deleteAnchor" if !m.alt => Cursor::PenDelete,
|
||||
"deleteAnchor" => Cursor::PenAdd,
|
||||
"scissors" if m.alt => Cursor::PenAdd,
|
||||
"scissors" => {
|
||||
if hit_segment(cx, p, cx.pick_tol()).is_some() {
|
||||
Cursor::Scissors
|
||||
|
||||
@@ -301,6 +301,34 @@ fn scissors_splits_at_segment() {
|
||||
);
|
||||
}
|
||||
|
||||
/// With Alt held the Scissors tool acts as the Add Anchor Point tool: a click sends what the Add
|
||||
/// Anchor Point tool's click sends (an anchor inserted on a segment, nothing away from every path)
|
||||
/// and never splits, and the cursor is the Add Anchor Point tool's everywhere. Without Alt it cuts
|
||||
/// again.
|
||||
#[test]
|
||||
fn scissors_with_alt_adds_an_anchor() {
|
||||
let (d, id) = doc_with_rect();
|
||||
let s = Selection::default();
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut t = AnchorTool::new("scissors");
|
||||
let mut add = AnchorTool::new("addAnchor");
|
||||
let alt = Mods { alt: true, ..Mods::default() };
|
||||
for (x, y) in [(200.0, 150.0), (150.0, 101.0), (200.0, 100.0), (400.0, 400.0)] {
|
||||
let down = PointerEvent::new(PointerKind::Down, x, y);
|
||||
let a = t.pointer(&cx, &down.with_mods(alt));
|
||||
assert_eq!(a, add.pointer(&cx, &down), "({x}, {y})");
|
||||
assert!(!a.iter().any(|a| matches!(a, Action::Exec(c, _) if c == "path.split")), "({x}, {y})");
|
||||
assert_eq!(t.cursor(&cx, Point::new(x, y), alt), Cursor::PenAdd, "({x}, {y})");
|
||||
}
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 200.0, 150.0).with_mods(alt));
|
||||
assert!(matches!(&a[..], [Action::Exec(c, v)] if c == "path.insertAnchor" && v["id"] == id.0 && v["segment"] == 1));
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 200.0, 150.0));
|
||||
assert!(matches!(&a[..], [Action::Exec(c, v)] if c == "path.split" && v["id"] == id.0 && v["segment"] == 1));
|
||||
assert_eq!(t.cursor(&cx, Point::new(200.0, 150.0), Mods::default()), Cursor::Scissors);
|
||||
assert_eq!(t.cursor(&cx, Point::new(400.0, 400.0), Mods::default()), Cursor::ScissorsNotAllowed);
|
||||
}
|
||||
|
||||
/// The Add Anchor Point, Delete Anchor Point, Anchor Point and Scissors tools pick anchors and
|
||||
/// segments only on the art a click can pick: not on a Shaper Group's original shapes unless
|
||||
/// they are isolated (Construction mode), and in isolation mode only on the isolated object.
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
//! It edits any selected path too, whatever drew it, keeping its shape except where edited
|
||||
//! (#798). A click on one of its points makes that the current point (direct-selected); a drag
|
||||
//! moves it, re-curving only the two segments at it ([`curvature_move`]); Alt-click or
|
||||
//! double-click toggles it between smooth and corner and Backspace/Delete removes it. A click on
|
||||
//! double-click toggles it between smooth and corner and Backspace/Delete removes it, the curve
|
||||
//! where it was then following the remaining points ([`curvature_remove`]). A click on
|
||||
//! a segment adds a smooth point there without changing the shape ([`curvature_insert`]), and a
|
||||
//! drag moves the new point at once. While the current point is an end of an open path, clicks go
|
||||
//! on from it ([`curvature_extend`]): the old segments keep their shape, the new ones curve
|
||||
@@ -20,7 +21,7 @@ use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{NodeId, Selection};
|
||||
use vectorcraft_geom::{Anchor, AnchorKind, BezPath, Point, SubPath, Vec2};
|
||||
|
||||
use super::{FEEDBACK, anchor_in, curvature_spline, editable_paths, end_handle, remove_anchor, segment_in, smooth_handles};
|
||||
use super::{FEEDBACK, anchor_in, curvature_spline, editable_paths, end_handle, segment_in, smooth_handles};
|
||||
use crate::guides::{DrawSnap, Leave, Targets};
|
||||
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
|
||||
|
||||
@@ -212,6 +213,106 @@ pub fn curvature_smooth(sp: &mut SubPath, i: usize) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove anchor `i` of `sp` as the Curvature tool deletes a point: the curve where it was follows
|
||||
/// the remaining points. Each neighbor that curves by the tool's rule (a smooth anchor, or an end
|
||||
/// of an open path with a handle into its segment) takes new handles from its new neighbors
|
||||
/// ([`smooth_handles`]; at an end, the one [`end_handle`] into the curve), and so does an end beyond
|
||||
/// one of them whose handle mirrors its tangent. A corner neighbor keeps its handles' directions,
|
||||
/// the one on the new segment scaled with its chord, as [`curvature_move`] scales them. With two
|
||||
/// anchors left the subpath is open, and those that curve by the tool's rule lose their handles, as
|
||||
/// [`curvature_spline`] draws two points as a line; a corner keeps its handles. On a path the tool
|
||||
/// drew, the result is the path it draws through the remaining points, except that an Alt-clicked
|
||||
/// end beside a corner without handles curves (the path stores it like any end there). False, and
|
||||
/// `sp` unchanged, when there is no anchor `i`.
|
||||
pub fn curvature_remove(sp: &mut SubPath, i: usize) -> bool {
|
||||
let n = sp.anchors.len();
|
||||
let Some(a) = sp.anchors.get(i).copied() else { return false };
|
||||
let closed = sp.closed;
|
||||
let is_end = |j: usize| !closed && (j == 0 || j + 1 == n);
|
||||
// An end without a handle beside a corner without handles is stored the same whether it was
|
||||
// Alt-clicked or not ([`curvature_spline`] gives it no handle either way), so it curves, as an
|
||||
// end the tool places does by default. Beside a tangent, a missing handle means a corner.
|
||||
let flags: Vec<bool> = sp
|
||||
.anchors
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(j, b)| {
|
||||
if !is_end(j) {
|
||||
return b.kind == AnchorKind::Smooth;
|
||||
}
|
||||
let near = sp.anchors.get(if j == 0 { 1 } else { n.saturating_sub(2) });
|
||||
b.has_in() || b.has_out() || near.is_none_or(|q| end_handle(b.p, q, j == 0).is_none())
|
||||
})
|
||||
.collect();
|
||||
let curved = |j: usize| flags.get(j).copied().unwrap_or(false);
|
||||
let [prev, next] = neighbours(sp, i);
|
||||
// A corner on both sides of the removed point: its handle on the new segment scales with it.
|
||||
if let (Some(pi), Some(ni)) = (prev, next)
|
||||
&& pi != ni
|
||||
{
|
||||
for (j, other, out) in [(pi, ni, true), (ni, pi, false)] {
|
||||
let to = sp.anchors.get(other).map(|o| o.p);
|
||||
if curved(j) {
|
||||
continue;
|
||||
}
|
||||
let Some((b, to)) = sp.anchors.get_mut(j).zip(to) else { continue };
|
||||
let old = b.p.distance(a.p);
|
||||
let s = if old > 1e-9 { b.p.distance(to) / old } else { 1.0 };
|
||||
let h = if out { &mut b.h_out } else { &mut b.h_in };
|
||||
*h = b.p + (*h - b.p) * s;
|
||||
}
|
||||
}
|
||||
let mut again: Vec<usize> = [prev, next].into_iter().flatten().filter(|&j| curved(j)).collect();
|
||||
// An end beside a smooth neighbor mirrors that neighbor's tangent, which changes.
|
||||
for j in again.clone() {
|
||||
if !is_end(j) {
|
||||
again.extend(neighbours(sp, j).into_iter().flatten().filter(|&k| k != i && is_end(k) && curved(k)));
|
||||
}
|
||||
}
|
||||
sp.anchors.remove(i);
|
||||
let mut again: Vec<usize> = again.into_iter().map(|j| if j > i { j - 1 } else { j }).collect();
|
||||
again.sort_unstable();
|
||||
again.dedup();
|
||||
let m = sp.anchors.len();
|
||||
if m < 3 {
|
||||
sp.closed = false;
|
||||
}
|
||||
if !sp.closed {
|
||||
// A new end has nothing beyond it.
|
||||
if let Some(e) = sp.anchors.first_mut() {
|
||||
e.h_in = e.p;
|
||||
}
|
||||
if let Some(e) = sp.anchors.last_mut() {
|
||||
e.h_out = e.p;
|
||||
}
|
||||
}
|
||||
let end = |j: usize| !sp.closed && (j == 0 || j + 1 == m);
|
||||
let (ends, inner): (Vec<usize>, Vec<usize>) = again.into_iter().partition(|&j| end(j));
|
||||
// The inner anchors first: an end's handle mirrors its neighbor's tangent.
|
||||
for j in inner {
|
||||
let [b, c] = neighbours(sp, j).map(|k| k.and_then(|k| sp.anchors.get(k)).map(|x| x.p));
|
||||
let Some(x) = sp.anchors.get(j).map(|x| x.p) else { continue };
|
||||
let fresh = match (b, c) {
|
||||
(Some(b), Some(c)) => smooth_handles(b, x, c).map(|(h_in, h_out)| Anchor { p: x, h_in, h_out, kind: AnchorKind::Smooth }),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(y) = sp.anchors.get_mut(j) {
|
||||
*y = fresh.unwrap_or_else(|| Anchor::corner(x));
|
||||
}
|
||||
}
|
||||
for j in ends {
|
||||
let first = j == 0;
|
||||
let near = if first { sp.anchors.get(1) } else { j.checked_sub(1).and_then(|k| sp.anchors.get(k)) }.copied();
|
||||
let Some(x) = sp.anchors.get(j).map(|x| x.p) else { continue };
|
||||
// Two points are a line.
|
||||
let h = near.filter(|_| m >= 3).and_then(|q| end_handle(x, &q, first)).unwrap_or(x);
|
||||
if let Some(y) = sp.anchors.get_mut(j) {
|
||||
*y = if first { Anchor { h_out: h, ..Anchor::corner(x) } } else { Anchor { h_in: h, ..Anchor::corner(x) } };
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Go on from an end of the open subpath `sp` (its start with `start`) to a new end at `p`; the old
|
||||
/// end bends into the new segment as `how` says. The new end has no handle on the outer side and,
|
||||
/// unless it is a `corner` (Alt-clicked), the one [`end_handle`] into the curve, so its segment
|
||||
@@ -313,6 +414,12 @@ fn select_point((id, si, ai): (NodeId, usize, usize)) -> Action {
|
||||
Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[si, ai]], "mode": "set"}))
|
||||
}
|
||||
|
||||
/// Delete anchor `(id, si, ai)` of a selected path, the curve where it was following the remaining
|
||||
/// points ([`curvature_remove`]).
|
||||
fn delete_point((id, si, ai): (NodeId, usize, usize)) -> Action {
|
||||
Action::Exec("path.removeAnchor".into(), json!({"id": id.0, "subpath": si, "anchor": ai, "curvature": true}))
|
||||
}
|
||||
|
||||
/// Toggle anchor `(id, si, ai)` of a selected path between smooth and corner. An end of an open
|
||||
/// path counts as smooth while it has a handle into its segment (the ends the tool curves are
|
||||
/// corners with one handle).
|
||||
@@ -560,7 +667,7 @@ impl Tool for CurvatureTool {
|
||||
}
|
||||
ToolKey::Backspace | ToolKey::Delete if self.claims_key(cx, key) => {
|
||||
if self.active(cx).is_none() {
|
||||
return current_point(cx).map(remove_anchor).into_iter().collect();
|
||||
return current_point(cx).map(delete_point).into_iter().collect();
|
||||
}
|
||||
let i = self.current.unwrap_or(self.pts.len() - 1).min(self.pts.len() - 1);
|
||||
self.pts.remove(i);
|
||||
|
||||
@@ -244,6 +244,62 @@ fn curvature_smooth_uses_the_tool_rule() {
|
||||
assert_eq!(fold, before);
|
||||
}
|
||||
|
||||
/// Removing a point of a path the tool drew leaves the path it draws through the remaining points,
|
||||
/// open or closed, and two points left are a straight line.
|
||||
#[test]
|
||||
fn curvature_remove_curves_through_the_remaining_points() {
|
||||
let at = |corners: &[usize]| -> Vec<(Point, bool)> {
|
||||
[(0.0, 0.0), (100.0, -80.0), (220.0, 30.0), (300.0, -60.0), (420.0, 10.0), (500.0, -40.0)]
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(j, &(x, y))| (pt(x, y), corners.contains(&j)))
|
||||
.collect()
|
||||
};
|
||||
// A corner in the middle, and corners next to both ends (the ends then have no handle).
|
||||
for pts in [at(&[2]), at(&[1, 4]), at(&[])] {
|
||||
for closed in [false, true] {
|
||||
for i in 0..pts.len() {
|
||||
let mut sp = curvature_spline(&pts, closed);
|
||||
assert!(curvature_remove(&mut sp, i));
|
||||
let rest: Vec<(Point, bool)> = pts.iter().enumerate().filter(|&(j, _)| j != i).map(|(_, &q)| q).collect();
|
||||
let want = curvature_spline(&rest, closed);
|
||||
assert_eq!(sp.closed, want.closed);
|
||||
for (a, b) in sp.anchors.iter().zip(&want.anchors) {
|
||||
assert!(a.h_in.distance(b.h_in) < 1e-6 && a.h_out.distance(b.h_out) < 1e-6, "{pts:?} closed {closed}, point {i}: {a:?} != {b:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let pts = at(&[2]);
|
||||
for closed in [false, true] {
|
||||
let mut sp = curvature_spline(&pts[..3], closed);
|
||||
assert!(curvature_remove(&mut sp, 1));
|
||||
assert!(!sp.closed && sp.segment_is_line(0), "two points are a line: {sp:?}");
|
||||
}
|
||||
let mut sp = curvature_spline(&pts, false);
|
||||
let before = sp.clone();
|
||||
assert!(!curvature_remove(&mut sp, 9));
|
||||
assert_eq!(sp, before);
|
||||
}
|
||||
|
||||
/// A corner neighbor with handles keeps their directions; its handle on the new segment scales
|
||||
/// with the segment's chord.
|
||||
#[test]
|
||||
fn curvature_remove_keeps_a_corner_neighbors_handle_direction() {
|
||||
let corner = Anchor { p: pt(100.0, 0.0), h_in: pt(80.0, -20.0), h_out: pt(120.0, -30.0), kind: AnchorKind::Corner };
|
||||
let mut sp = SubPath::new(vec![Anchor::corner(pt(0.0, 0.0)), corner, Anchor::corner(pt(200.0, 0.0)), Anchor::corner(pt(300.0, 0.0))], false);
|
||||
assert!(curvature_remove(&mut sp, 2));
|
||||
let c = sp.anchors[1];
|
||||
assert_eq!(c.h_in, corner.h_in);
|
||||
let (old, new) = (corner.h_out - corner.p, c.h_out - c.p);
|
||||
assert!(old.cross(new).abs() < 1e-9 && old.dot(new) > 0.0, "{c:?}");
|
||||
assert!((new.hypot() / old.hypot() - 2.0).abs() < 1e-9, "the chord doubled: {c:?}");
|
||||
// Two anchors left: the corner keeps its handle on the segment that stays.
|
||||
let mut sp = SubPath::new(vec![Anchor::corner(pt(0.0, 0.0)), corner, Anchor::corner(pt(200.0, 0.0))], false);
|
||||
assert!(curvature_remove(&mut sp, 2));
|
||||
assert_eq!((sp.anchors[1].h_in, sp.anchors[1].h_out), (corner.h_in, corner.p));
|
||||
}
|
||||
|
||||
/// A curved end counts as smooth for the toggle: Alt-click turns it into a corner.
|
||||
#[test]
|
||||
fn alt_click_on_a_curved_end_makes_it_a_corner() {
|
||||
@@ -345,7 +401,7 @@ fn alt_click_or_double_click_toggles_and_delete_removes() {
|
||||
let s = selected(id, Some(2));
|
||||
let cx = quiet(&d, &s, &p);
|
||||
assert!(t.claims_key(&cx, ToolKey::Backspace));
|
||||
assert_eq!(t.key(&cx, ToolKey::Delete, Mods::default()), vec![remove_anchor((id, 0, 2))]);
|
||||
assert_eq!(t.key(&cx, ToolKey::Delete, Mods::default()), vec![delete_point((id, 0, 2))]);
|
||||
// Esc lets go of the point.
|
||||
assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]);
|
||||
}
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
//! Drag draws (Shift = equal axes / square, Alt = from the centre for arc and grids, Space held
|
||||
//! moves the shape being drawn); a click without dragging asks the UI for the options dialog. The
|
||||
//! start point and the dragged corner snap to Smart Guides ([`DrawSnap`]), hovering too.
|
||||
//! While dragging, ↑/↓ change the spiral's segments, the grid rows or the concentric dividers; ←/→
|
||||
//! change grid columns / radial dividers.
|
||||
//! While dragging, ↑/↓ change the arc's slope, the spiral's segments, the grid rows or the
|
||||
//! concentric dividers; ←/→ change grid columns / radial dividers. C switches the arc between open
|
||||
//! and closed, and F switches its base between the X and Y axes.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_geom::Point;
|
||||
@@ -13,6 +14,9 @@ use crate::guides::{DrawSnap, Leave};
|
||||
use crate::shape::{box_params, drag_rect, turned_box};
|
||||
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
|
||||
|
||||
/// How much ↑ or ↓ changes the arc's slope while it is dragged out.
|
||||
const ARC_SLOPE_STEP: f64 = 10.0;
|
||||
|
||||
pub struct FamilyTool {
|
||||
id: &'static str,
|
||||
start: Option<Point>,
|
||||
@@ -215,6 +219,22 @@ impl Tool for FamilyTool {
|
||||
fn key(&mut self, cx: &ToolContext, key: ToolKey, _m: Mods) -> Vec<Action> {
|
||||
let Some(s) = self.start.filter(|_| self.began) else { return vec![] };
|
||||
let changed = match (self.id, key) {
|
||||
("arc", ToolKey::Up) => {
|
||||
self.slope = (self.slope + ARC_SLOPE_STEP).min(100.0);
|
||||
true
|
||||
}
|
||||
("arc", ToolKey::Down) => {
|
||||
self.slope = (self.slope - ARC_SLOPE_STEP).max(-100.0);
|
||||
true
|
||||
}
|
||||
("arc", ToolKey::Letter('c')) => {
|
||||
self.closed = !self.closed;
|
||||
true
|
||||
}
|
||||
("arc", ToolKey::Letter('f')) => {
|
||||
self.base_along = if self.base_along == "y" { "x" } else { "y" };
|
||||
true
|
||||
}
|
||||
("spiral", ToolKey::Up) => {
|
||||
self.segments = (self.segments + 1).min(1000);
|
||||
true
|
||||
@@ -253,6 +273,10 @@ impl Tool for FamilyTool {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
/// The Arc tool takes C and F while an arc is dragged out: C switches it between open and closed, and F switches its base axis.
|
||||
fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool {
|
||||
self.id == "arc" && self.began && self.start.is_some() && matches!(key, ToolKey::Letter('c' | 'f'))
|
||||
}
|
||||
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||
let mut o = self.snap.guides().to_vec();
|
||||
if let Some(s) = self.start.filter(|_| self.began && cx.measurement_labels) {
|
||||
@@ -469,6 +493,53 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// While an arc is dragged out, C switches it between open and closed, F switches its base
|
||||
/// between the X and Y axes and ↑/↓ change its slope by 10 within −100…100; the arc drawn
|
||||
/// next keeps them.
|
||||
#[test]
|
||||
fn arc_drag_keys_change_its_type_base_and_slope() {
|
||||
let (d, _) = doc_with_rect();
|
||||
let s = Selection::default();
|
||||
let p = paint();
|
||||
let mut cx = cx(&d, &s, &p);
|
||||
cx.smart_guides = false;
|
||||
let mut t = FamilyTool::new("arc");
|
||||
let (c, f) = (ToolKey::Letter('c'), ToolKey::Letter('f'));
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0));
|
||||
assert!(!t.claims_key(&cx, c), "no drag yet");
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 60.0, 40.0));
|
||||
assert!(t.claims_key(&cx, c) && t.claims_key(&cx, f) && !t.claims_key(&cx, ToolKey::Letter('e')));
|
||||
let preview = |a: Vec<Action>| match &a[..] {
|
||||
[Action::Preview(c, v)] if c == "shape.arc" => v.clone(),
|
||||
_ => panic!("{a:?}"),
|
||||
};
|
||||
let v = preview(t.key(&cx, c, Mods::default()));
|
||||
assert_eq!((v["closed"].clone(), v["baseAlong"].clone(), v["slope"].clone()), (json!(true), json!("x"), json!(50.0)));
|
||||
let v = preview(t.key(&cx, f, Mods::default()));
|
||||
assert_eq!((v["closed"].clone(), v["baseAlong"].clone()), (json!(true), json!("y")));
|
||||
assert_eq!((v["x"].clone(), v["y"].clone(), v["reference"].clone()), (json!(10.0), json!(10.0), json!(0)), "the reference point stays");
|
||||
assert_eq!(preview(t.key(&cx, ToolKey::Up, Mods::default()))["slope"], json!(60.0));
|
||||
for _ in 0..30 {
|
||||
t.key(&cx, ToolKey::Down, Mods::default());
|
||||
}
|
||||
assert_eq!(preview(t.key(&cx, ToolKey::Down, Mods::default()))["slope"], json!(-100.0));
|
||||
for _ in 0..30 {
|
||||
t.key(&cx, ToolKey::Up, Mods::default());
|
||||
}
|
||||
assert_eq!(preview(t.key(&cx, ToolKey::Up, Mods::default()))["slope"], json!(100.0));
|
||||
let v = preview(t.key(&cx, c, Mods::default()));
|
||||
assert_eq!(v["closed"], json!(false), "C again opens it");
|
||||
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 60.0, 40.0)), vec![Action::Commit]);
|
||||
assert!(!t.claims_key(&cx, c), "the drag is over");
|
||||
let o = t.options();
|
||||
assert_eq!((o["closed"].clone(), o["baseAlong"].clone(), o["slope"].clone()), (json!(false), json!("y"), json!(100.0)));
|
||||
// The other tools of the family leave C and F to their shortcuts.
|
||||
let mut g = FamilyTool::new("rectangularGrid");
|
||||
g.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0));
|
||||
g.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 60.0, 40.0));
|
||||
assert!(!g.claims_key(&cx, c) && !g.claims_key(&cx, f));
|
||||
}
|
||||
|
||||
/// General › Constrain Angle: grids are drawn along the turned axes, Shift's square too; at 0°
|
||||
/// and whole turns as before.
|
||||
#[test]
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
//! pressed less than fully on sends `[x, y, pressure]` points) for pressure-sensitive brushes. The
|
||||
//! other tools collect the drag polyline (shown as an overlay) and run
|
||||
//! one command on release. With Shift held, the Blob Brush's points stay on the horizontal or
|
||||
//! vertical line through the press point.
|
||||
//! vertical line through the press point. Alt held at the press, or before the Blob Brush's drag
|
||||
//! has moved, switches the Blob Brush to the Smooth tool: the drag smooths the selected paths.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{NodeId, NodeKind};
|
||||
@@ -22,6 +23,10 @@ use super::{FEEDBACK, eraser_area, points_json, polyline};
|
||||
use crate::guides::DrawSnap;
|
||||
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
|
||||
|
||||
/// How far from its drag the Smooth tool smooths, in screen pixels. The Pencil uses the same
|
||||
/// radius while Alt switches it to the Smooth tool.
|
||||
const SMOOTH_RADIUS: f64 = 12.0;
|
||||
|
||||
pub struct GestureTool {
|
||||
/// How many points the last preview of the stroke had ([`crate::previews_at`]).
|
||||
previewed: usize,
|
||||
@@ -42,7 +47,7 @@ pub struct GestureTool {
|
||||
/// Pencil/Paintbrush starting on a selected path away from its ends: (path, Edit Selected
|
||||
/// Paths' distance in points); the stroke redraws the section it ends on (#2850).
|
||||
redraw: Option<(NodeId, f64)>,
|
||||
/// Fit tolerance in points (Pencil/Paintbrush/Smooth "Fidelity").
|
||||
/// Fit tolerance in points (Pencil/Paintbrush/Smooth/Blob Brush "Fidelity").
|
||||
pub fidelity: f64,
|
||||
/// Brush / eraser diameter in points (the Eraser's across its tip's long axis).
|
||||
pub size: f64,
|
||||
@@ -66,8 +71,11 @@ pub struct GestureTool {
|
||||
/// preview shows the points joined by straight segments and the release fits them (Pencil
|
||||
/// only, on by default).
|
||||
pub live_fit: bool,
|
||||
/// Active drag is smoothing with the Pencil tool.
|
||||
/// Active drag is smoothing with the Pencil or the Blob Brush (Alt held).
|
||||
smoothing: bool,
|
||||
/// The pointer has moved from the press point, by any distance (a move too short to add a
|
||||
/// point counts).
|
||||
moved: bool,
|
||||
/// Active Pencil drag draws a straight line (Shift or Alt held at the press): the stroke is
|
||||
/// the press point and the pointer.
|
||||
straight: bool,
|
||||
@@ -138,6 +146,7 @@ impl GestureTool {
|
||||
live_preview: true,
|
||||
live_fit: true,
|
||||
smoothing: false,
|
||||
moved: false,
|
||||
straight: false,
|
||||
unconstrained: false,
|
||||
shift_raw: vec![],
|
||||
@@ -185,12 +194,18 @@ impl GestureTool {
|
||||
self.id == "pencil" && !(self.alt_smooth && m.alt) && (m.shift || m.alt)
|
||||
}
|
||||
|
||||
/// Would Alt held now switch the Blob Brush to the Smooth tool? It does before a press, and
|
||||
/// during a drag that has not moved from the press point yet.
|
||||
fn blob_alt_smooths(&self, m: Mods) -> bool {
|
||||
self.id == "blobBrush" && m.alt && !self.moved
|
||||
}
|
||||
|
||||
/// Where the stroke takes a pointer at `p`. For the Blob Brush with Shift held, that is the
|
||||
/// horizontal or vertical line through the press point that is nearer in angle to `p` (90°
|
||||
/// steps from the Constrain Angle), at `p`'s distance from the press point. The points dragged
|
||||
/// since Shift went down move to that line too, each at its pointer position's distance and on
|
||||
/// that position's side of the press point; a pointer at the press point leaves them on their
|
||||
/// line. Without Shift the stroke takes `p`.
|
||||
/// line. Without Shift, or while the Blob Brush smooths, the stroke takes `p`.
|
||||
fn constrained(&mut self, cx: &ToolContext, p: Point, m: Mods) -> Point {
|
||||
self.shift_next = None;
|
||||
if self.straight && self.unconstrained {
|
||||
@@ -201,7 +216,7 @@ impl GestureTool {
|
||||
{
|
||||
return start + vectorcraft_geom::constrain_angle_from(p - start, 45.0, cx.constrain_angle);
|
||||
}
|
||||
let Some(&start) = self.points.first().filter(|_| self.id == "blobBrush" && m.shift) else {
|
||||
let Some(&start) = self.points.first().filter(|_| self.id == "blobBrush" && m.shift && !self.smoothing) else {
|
||||
self.shift_raw.clear();
|
||||
self.shift_line = None;
|
||||
return p;
|
||||
@@ -226,6 +241,7 @@ impl GestureTool {
|
||||
|
||||
/// Forget the stroke.
|
||||
fn clear(&mut self) {
|
||||
self.moved = false;
|
||||
self.points.clear();
|
||||
self.pressures.clear();
|
||||
self.previewed = 0;
|
||||
@@ -339,9 +355,13 @@ impl GestureTool {
|
||||
let pts = &self.points;
|
||||
let has_sel = !cx.selection.is_empty();
|
||||
let tol = cx.tol(self.edit_within);
|
||||
let radius = cx.tol(SMOOTH_RADIUS);
|
||||
if self.smoothing {
|
||||
if has_sel && pts.len() >= 2 {
|
||||
return vec![Action::Exec("path.smoothRegion".into(), json!({"points": points_json(pts), "radius": tol, "fidelity": self.fidelity}))];
|
||||
return vec![Action::Exec(
|
||||
"path.smoothRegion".into(),
|
||||
json!({"points": points_json(pts), "radius": radius, "fidelity": self.fidelity}),
|
||||
)];
|
||||
}
|
||||
return vec![];
|
||||
}
|
||||
@@ -368,6 +388,7 @@ impl GestureTool {
|
||||
json!({
|
||||
"points": points_json(pts),
|
||||
"size": self.size,
|
||||
"fidelity": self.fidelity,
|
||||
"keepSelected": self.keep_selected,
|
||||
"mergeOnlySelection": self.merge_only_selection
|
||||
}),
|
||||
@@ -378,7 +399,7 @@ impl GestureTool {
|
||||
)],
|
||||
"knife" if pts.len() >= 2 => vec![Action::Exec("path.knife".into(), json!({"points": points_json(pts)}))],
|
||||
"smooth" if has_sel && pts.len() >= 2 => {
|
||||
vec![Action::Exec("path.smoothRegion".into(), json!({"points": points_json(pts), "radius": tol, "fidelity": self.fidelity}))]
|
||||
vec![Action::Exec("path.smoothRegion".into(), json!({"points": points_json(pts), "radius": radius, "fidelity": self.fidelity}))]
|
||||
}
|
||||
"pathEraser" if has_sel => vec![Action::Exec("path.eraseSegments".into(), json!({"points": points_json(pts), "width": cx.tol(8.0)}))],
|
||||
"join" if pts.len() >= 2 => vec![Action::Exec("path.joinScrub".into(), json!({"points": points_json(pts), "tolerance": tol}))],
|
||||
@@ -405,7 +426,9 @@ impl Tool for GestureTool {
|
||||
self.active = true;
|
||||
self.began = false;
|
||||
self.near_start = false;
|
||||
self.smoothing = self.id == "pencil" && self.alt_smooth && ev.mods.alt;
|
||||
// Alt switches the Pencil to the Smooth tool while Alt key toggles to Smooth Tool is on, and always
|
||||
// switches the Blob Brush.
|
||||
self.smoothing = ev.mods.alt && (self.id == "blobBrush" || (self.id == "pencil" && self.alt_smooth));
|
||||
// Alt draws a straight line only while it doesn't lend the Smooth tool; with Shift
|
||||
// too, the line keeps Shift's 45° steps.
|
||||
self.straight = self.id == "pencil" && !self.smoothing && (ev.mods.shift || ev.mods.alt);
|
||||
@@ -435,10 +458,11 @@ impl Tool for GestureTool {
|
||||
if !self.active {
|
||||
return vec![];
|
||||
}
|
||||
let p = self.constrained(cx, p, ev.mods);
|
||||
if !self.began && !self.straight && self.id == "pencil" && self.alt_smooth && ev.mods.alt {
|
||||
if (!self.began && !self.straight && self.id == "pencil" && self.alt_smooth && ev.mods.alt) || self.blob_alt_smooths(ev.mods) {
|
||||
self.smoothing = true;
|
||||
}
|
||||
self.moved |= self.points.first().is_some_and(|start| p.distance(*start) > vectorcraft_geom::EPS);
|
||||
let p = self.constrained(cx, p, ev.mods);
|
||||
let mut end = p;
|
||||
if self.snaps() && !self.smoothing && !self.straight {
|
||||
// Where the stroke would end: shown, taken on release.
|
||||
@@ -496,6 +520,12 @@ impl Tool for GestureTool {
|
||||
if !self.active {
|
||||
return vec![];
|
||||
}
|
||||
// A release away from the press point counts as movement.
|
||||
self.moved |= self.points.first().is_some_and(|start| p.distance(*start) > vectorcraft_geom::EPS);
|
||||
// Alt pressed while the pointer stood still at the press point switches the release too.
|
||||
if self.blob_alt_smooths(ev.mods) {
|
||||
self.smoothing = true;
|
||||
}
|
||||
let p = self.constrained(cx, p, ev.mods);
|
||||
let end = if self.snaps() && !self.smoothing && (!self.straight || self.unconstrained) { self.snap.drag(cx, p, None) } else { p };
|
||||
self.snap.clear();
|
||||
@@ -577,6 +607,7 @@ impl Tool for GestureTool {
|
||||
self.active = false;
|
||||
self.near_start = false;
|
||||
self.began = false;
|
||||
self.smoothing = false;
|
||||
self.straight = false;
|
||||
self.unconstrained = false;
|
||||
self.clear();
|
||||
@@ -588,8 +619,9 @@ impl Tool for GestureTool {
|
||||
if !self.active || self.points.len() < 2 || (self.began && self.previews()) {
|
||||
return o;
|
||||
}
|
||||
// A Blob Brush drag that smooths shows the Smooth tool's thin line.
|
||||
let width = match self.id {
|
||||
"blobBrush" | "eraser" => (self.size * cx.zoom) as f32,
|
||||
"blobBrush" | "eraser" if !self.smoothing => (self.size * cx.zoom) as f32,
|
||||
_ => 1.0,
|
||||
};
|
||||
let color = if self.id == "eraser" { [0x80, 0x80, 0x80] } else { FEEDBACK };
|
||||
@@ -600,7 +632,7 @@ impl Tool for GestureTool {
|
||||
return o;
|
||||
}
|
||||
o.push(Overlay::Path { path: polyline(&self.points), color, width, dashed: self.id == "knife" });
|
||||
if cx.measurement_labels && self.id == "blobBrush" {
|
||||
if cx.measurement_labels && self.id == "blobBrush" && !self.smoothing {
|
||||
let last = self.points[self.points.len() - 1];
|
||||
let length = self.points.windows(2).map(|w| w[0].distance(w[1])).sum();
|
||||
o.push(Overlay::Measure { p: last + vectorcraft_geom::Vec2::new(cx.tol(12.0), cx.tol(12.0)), text: format!("L: {}", cx.len(length)) });
|
||||
@@ -608,9 +640,9 @@ impl Tool for GestureTool {
|
||||
o
|
||||
}
|
||||
fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor {
|
||||
// A drag that smooths, or Alt before a Pencil stroke has begun to draw (a stroke that
|
||||
// has begun keeps drawing with Alt).
|
||||
if self.smoothing || (self.id == "pencil" && self.alt_smooth && m.alt && !self.began) {
|
||||
// A drag that smooths, or Alt before a Pencil or Blob Brush stroke has begun to draw (a
|
||||
// stroke that has begun keeps drawing with Alt).
|
||||
if self.smoothing || (self.id == "pencil" && self.alt_smooth && m.alt && !self.began) || self.blob_alt_smooths(m) {
|
||||
return Cursor::Smooth;
|
||||
}
|
||||
// Before a press that would draw a straight line: over an end of a selected open path,
|
||||
@@ -667,7 +699,9 @@ impl Tool for GestureTool {
|
||||
})
|
||||
}
|
||||
"smooth" => json!({"fidelity": self.fidelity}),
|
||||
"blobBrush" => json!({"size": self.size, "keepSelected": self.keep_selected, "mergeOnlySelection": self.merge_only_selection}),
|
||||
"blobBrush" => {
|
||||
json!({"fidelity": self.fidelity, "size": self.size, "keepSelected": self.keep_selected, "mergeOnlySelection": self.merge_only_selection})
|
||||
}
|
||||
"eraser" => json!({"size": self.size, "angle": self.angle, "roundness": self.roundness}),
|
||||
_ => Value::Null,
|
||||
}
|
||||
|
||||
@@ -283,6 +283,23 @@ fn blob_brush_sends_keep_selected_and_merge_only_with_selection() {
|
||||
assert_eq!(GestureTool::new("eraser").options(), json!({"size": 10.0, "angle": 0.0, "roundness": 100.0}));
|
||||
}
|
||||
|
||||
/// The Blob Brush has a Fidelity, 1.5 pt by default and 0.01 to 100 pt, and its stroke sends it.
|
||||
#[test]
|
||||
fn blob_brush_sends_its_fidelity() {
|
||||
let (d, _) = doc_with_rect();
|
||||
let s = Selection::default();
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut t = GestureTool::new("blobBrush");
|
||||
assert_eq!(t.options()["fidelity"], json!(1.5));
|
||||
for (set, sent) in [(1.5, 1.5), (6.0, 6.0), (0.001, 0.01), (500.0, 100.0)] {
|
||||
t.set_option("fidelity", &json!(set));
|
||||
assert_eq!(t.options()["fidelity"], json!(sent));
|
||||
let acts = drag(&mut t, &cx, &[(50.0, 150.0), (250.0, 150.0)], Mods::default());
|
||||
assert!(matches!(&acts[2][0], Action::Exec(c, v) if c == "path.blob" && v["fidelity"] == json!(sent)), "{set}: {acts:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Shift keeps a Blob Brush stroke on the horizontal or vertical line through the press point,
|
||||
/// whichever is nearer to the pointer, at 90° steps from the Constrain Angle; the points
|
||||
/// dragged with Shift held move to the other line together, and each point follows the Shift
|
||||
@@ -336,6 +353,90 @@ fn blob_brush_shift_keeps_the_stroke_horizontal_or_vertical() {
|
||||
assert!(pts.len() == 3 && pts[2] == (0.0, 0.0) && (a - 30.0).abs() < 1e-9, "{pts:?}");
|
||||
}
|
||||
|
||||
/// Alt switches the Blob Brush to the Smooth tool: held before the press, or pressed before the
|
||||
/// drag has moved, the cursor shows the Smooth tool, the drag shows a thin line and its release
|
||||
/// smooths the selected paths (`path.smoothRegion`) instead of painting. Shift does not
|
||||
/// constrain a smoothing drag, a stroke that has moved keeps painting when Alt goes down, and
|
||||
/// with nothing selected the release changes no path.
|
||||
#[test]
|
||||
fn blob_brush_alt_switches_to_the_smooth_tool() {
|
||||
let (d, id) = doc_with_rect();
|
||||
let mut s = Selection::default();
|
||||
s.set([id]);
|
||||
let p = paint();
|
||||
let cx = ToolContext { measurement_labels: true, ..cx(&d, &s, &p) };
|
||||
let alt = Mods { alt: true, ..Default::default() };
|
||||
let at = Point::new(50.0, 50.0);
|
||||
let smoothed = |acts: &[Vec<Action>]| -> Option<Value> {
|
||||
match acts.last().and_then(|a| a.first()) {
|
||||
Some(Action::Exec(c, v)) if c == "path.smoothRegion" => Some(v.clone()),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
let mut t = GestureTool::new("blobBrush");
|
||||
assert_eq!(t.cursor(&cx, at, alt), Cursor::Smooth);
|
||||
assert_eq!(t.cursor(&cx, at, Mods::default()), Cursor::BlobBrush);
|
||||
let stroke = [(50.0, 150.0), (150.0, 150.0), (250.0, 160.0)];
|
||||
let acts = drag_held(&mut t, &cx, &stroke, alt);
|
||||
assert!(acts[..acts.len() - 1].iter().all(|a| a.is_empty()), "{acts:?}");
|
||||
let v = smoothed(&acts).unwrap_or_else(|| panic!("{acts:?}"));
|
||||
assert_eq!(v, json!({"points": [[50.0, 150.0], [150.0, 150.0], [250.0, 160.0]], "radius": cx.tol(12.0), "fidelity": 1.5}));
|
||||
// Mid-drag the cursor stays the Smooth tool's when Alt is let go, and the line is thin with
|
||||
// no length label.
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 150.0).with_mods(alt));
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 150.0).with_mods(alt));
|
||||
assert_eq!(t.cursor(&cx, at, Mods::default()), Cursor::Smooth);
|
||||
let o = t.overlays(&cx);
|
||||
assert!(o.iter().any(|o| matches!(o, Overlay::Path { width, .. } if *width == 1.0)), "{o:?}");
|
||||
assert!(!o.iter().any(|o| matches!(o, Overlay::Measure { .. })), "{o:?}");
|
||||
// After another tool is chosen mid-drag, the Blob Brush shows its own cursor when it is chosen
|
||||
// again, and so does the Pencil after a drag that Alt key toggles to Smooth Tool made smooth.
|
||||
assert!(t.deactivate(&cx).is_empty());
|
||||
assert_eq!(t.cursor(&cx, at, Mods::default()), Cursor::BlobBrush);
|
||||
let mut pencil = GestureTool::new("pencil");
|
||||
pencil.set_option("altSmooth", &json!(true));
|
||||
pencil.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 150.0).with_mods(alt));
|
||||
pencil.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 150.0).with_mods(alt));
|
||||
assert!(pencil.deactivate(&cx).is_empty());
|
||||
assert_eq!(pencil.cursor(&cx, at, Mods::default()), Cursor::PencilNew);
|
||||
// Alt pressed before the drag has moved smooths too, and Shift leaves the points where the
|
||||
// pointer went.
|
||||
let alt_shift = Mods { alt: true, shift: true, ..Default::default() };
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 150.0));
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 170.0).with_mods(alt_shift));
|
||||
let acts = [t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 250.0, 160.0).with_mods(alt_shift))];
|
||||
let v = smoothed(&acts).unwrap_or_else(|| panic!("{acts:?}"));
|
||||
assert_eq!(v["points"], json!([[50.0, 150.0], [150.0, 170.0], [250.0, 160.0]]));
|
||||
// A stroke that has moved keeps painting with Alt down.
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 150.0));
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 150.0));
|
||||
assert_eq!(t.cursor(&cx, at, alt), Cursor::BlobBrush);
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200.0, 150.0).with_mods(alt));
|
||||
let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 250.0, 150.0).with_mods(alt));
|
||||
assert!(matches!(acts.as_slice(), [Action::Exec(c, v)] if c == "path.blob" && v["points"].as_array().unwrap().len() == 4), "{acts:?}");
|
||||
// So does a stroke that has moved too little to add a point.
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 150.0));
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 50.25, 150.0));
|
||||
assert_eq!(t.cursor(&cx, at, alt), Cursor::BlobBrush);
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 150.0).with_mods(alt));
|
||||
let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 250.0, 150.0).with_mods(alt));
|
||||
assert!(matches!(acts.as_slice(), [Action::Exec(c, _)] if c == "path.blob"), "{acts:?}");
|
||||
// Alt pressed while the pointer stands still at the press point does not paint on the release.
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 150.0));
|
||||
assert_eq!(t.cursor(&cx, at, alt), Cursor::Smooth);
|
||||
let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 50.0, 150.0).with_mods(alt));
|
||||
assert!(acts.is_empty(), "{acts:?}");
|
||||
// A release away from the press point with no drag before it paints when Alt goes down.
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 150.0));
|
||||
let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0, 150.0).with_mods(alt));
|
||||
assert!(matches!(acts.as_slice(), [Action::Exec(c, _)] if c == "path.blob"), "{acts:?}");
|
||||
// With nothing selected an Alt drag sends nothing.
|
||||
let none = Selection::default();
|
||||
let empty = crate::testutil::cx(&d, &none, &p);
|
||||
let acts = drag_held(&mut t, &empty, &stroke, alt);
|
||||
assert!(acts.iter().all(|a| a.is_empty()), "{acts:?}");
|
||||
}
|
||||
|
||||
/// #3799: Shift held at the press draws a straight Pencil line from the press point, at 45°
|
||||
/// steps from the Constrain Angle, previewed as it is dragged; the Paintbrush keeps following
|
||||
/// the hand.
|
||||
@@ -703,6 +804,34 @@ fn pencil_alt_with_alt_to_smooth_on_smooths_instead_of_drawing_a_line() {
|
||||
assert!(matches!(&acts.last().unwrap()[0], Action::Exec(c, v) if c == "path.smoothRegion" && v["points"].as_array().unwrap().len() == 3));
|
||||
}
|
||||
|
||||
/// While Alt switches the Pencil to the Smooth tool, the Pencil smooths within the Smooth tool's
|
||||
/// radius at any Edit selected paths Within value, 0 included.
|
||||
#[test]
|
||||
fn pencil_alt_smooths_within_the_smooth_tools_radius() {
|
||||
let (d, id) = doc_with_rect();
|
||||
let mut s = Selection::default();
|
||||
s.set([id]);
|
||||
let p = paint();
|
||||
let mut cx = cx(&d, &s, &p);
|
||||
cx.smart_guides = false;
|
||||
let pts = [(50.0, 150.0), (150.0, 150.0), (250.0, 160.0)];
|
||||
let radius = |acts: &[Vec<Action>]| match acts.last().and_then(|a| a.first()) {
|
||||
Some(Action::Exec(c, v)) if c == "path.smoothRegion" => v["radius"].as_f64().unwrap(),
|
||||
a => panic!("expected path.smoothRegion, got: {a:?}"),
|
||||
};
|
||||
let alt = Mods { alt: true, ..Default::default() };
|
||||
for zoom in [1.0, 2.0] {
|
||||
cx.zoom = zoom;
|
||||
let want = radius(&drag(&mut GestureTool::new("smooth"), &cx, &pts, Mods::default()));
|
||||
for within in [0.0, 5.0, 40.0] {
|
||||
let mut t = GestureTool::new("pencil");
|
||||
t.set_option("altSmooth", &json!(true));
|
||||
t.set_option("editWithin", &json!(within));
|
||||
assert_eq!(radius(&drag_held(&mut t, &cx, &pts, alt)), want, "zoom {zoom}, editWithin {within}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #3799: Alt's line starts and ends on Smart Guides as a stroke does, and pressed on an end of
|
||||
/// a selected open path it continues the path from that end to the pointer.
|
||||
#[test]
|
||||
|
||||
@@ -40,7 +40,7 @@ const ROWS: &[Row] = &[
|
||||
(&["pencil"], None, &["altSmooth", "livePreview", "liveFit"]),
|
||||
(&["smooth"], None, &["fidelity"]),
|
||||
(&["blobBrush", "eraser"], None, &["size"]),
|
||||
(&["blobBrush"], None, &["keepSelected", "mergeOnlySelection"]),
|
||||
(&["blobBrush"], None, &["fidelity", "keepSelected", "mergeOnlySelection"]),
|
||||
// Eraser Tool Options' tip shape.
|
||||
(&["eraser"], None, &["angle", "roundness"]),
|
||||
// Live Paint Selection Tool Options (`builder::LivePaintSelectionTool`).
|
||||
|
||||
+200
-7
@@ -6,6 +6,9 @@
|
||||
//! ends the editing, and the next click makes new type. Point type placed by a click and left
|
||||
//! empty is discarded when editing ends (`text.discardEmpty`); frames keep their shape. Where new
|
||||
//! type goes (the click, the frame's corners) snaps to Smart Guides ([`DrawSnap`]), hovering too.
|
||||
//! While no type is being edited, a press with Shift held makes the new type with the tool's
|
||||
//! horizontal or vertical counterpart, and a press with Alt (Option) held makes it with Type on a
|
||||
//! Path in place of Area Type or the reverse ([`TypeTool::acting`]); the pointer shows that tool.
|
||||
//!
|
||||
//! While editing: caret movement by character / word (Cmd or Alt) / line (Up/Down) / line ends
|
||||
//! (Home/End; Cmd = whole text), Shift extends the selection, drag selects, double-click selects a
|
||||
@@ -84,6 +87,43 @@ struct TypedList {
|
||||
list: vectorcraft_doc::text::ParaList,
|
||||
}
|
||||
|
||||
/// An undo step that changed the text of the type object the tool edits, in the order the history
|
||||
/// holds them. The plain text before and after it is kept as a hash. For a step the tool made (a
|
||||
/// typing session or a typed list), `ends` holds the byte where the changed span ends before and
|
||||
/// after it, so that Undo and Redo of the step put the caret there even where the change repeats
|
||||
/// the characters next to it; a step a command from outside the tool made has none.
|
||||
#[derive(Clone)]
|
||||
struct OwnEdit {
|
||||
id: NodeId,
|
||||
before: u64,
|
||||
after: u64,
|
||||
ends: Option<(usize, usize)>,
|
||||
/// False once Undo took the step back, until Redo makes it again.
|
||||
done: bool,
|
||||
}
|
||||
|
||||
/// What Undo and Redo change in the tool, kept when a `command.batch` begins so that a batch that
|
||||
/// fails and is rolled back puts it back ([`TypeTool::batch_notice`]).
|
||||
struct HistoryState {
|
||||
editing: Option<NodeId>,
|
||||
caret: usize,
|
||||
anchor: usize,
|
||||
goal_x: Option<f64>,
|
||||
known: Option<(NodeId, String)>,
|
||||
edits: Vec<OwnEdit>,
|
||||
stepped: bool,
|
||||
}
|
||||
|
||||
/// How many undo steps [`TypeTool::edits`] keeps.
|
||||
const OWN_EDITS_MAX: usize = 256;
|
||||
|
||||
fn text_hash(s: &str) -> u64 {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
s.hash(&mut h);
|
||||
h.finish()
|
||||
}
|
||||
|
||||
/// The edited text's layout, keyed by the font database's generation and the text.
|
||||
type CachedLayout = ((u64, TextObject), Arc<TextLayout>);
|
||||
|
||||
@@ -136,6 +176,8 @@ pub struct TypeTool {
|
||||
press: Option<(Point, Point)>,
|
||||
/// The other corner of an area dragged out (snapped).
|
||||
drag: Option<Point>,
|
||||
/// The Type tool the press acts as, with the keys held at the press ([`Self::acting`]).
|
||||
held: Option<(Mode, bool)>,
|
||||
/// Smart Guides for where new type goes.
|
||||
snap: DrawSnap,
|
||||
/// The press landed in the edited text: dragging selects.
|
||||
@@ -147,6 +189,15 @@ pub struct TypeTool {
|
||||
/// The list typed characters last started (`text.startList`), so that after Undo puts them back
|
||||
/// the caret goes on after them, and after Redo it goes back.
|
||||
listed: Option<TypedList>,
|
||||
/// The edited object's plain text as the tool last left or saw it, so that after Undo or Redo
|
||||
/// the caret goes to the end of the span the step changed.
|
||||
known: Option<(NodeId, String)>,
|
||||
/// The tool's own last undo steps, oldest first.
|
||||
edits: Vec<OwnEdit>,
|
||||
/// The command that ran was Undo or Redo ([`TypeTool::after_history_step`]).
|
||||
stepped: bool,
|
||||
/// The state at the start of each `command.batch` still running, innermost last.
|
||||
batches: Vec<HistoryState>,
|
||||
cache: Mutex<Option<CachedLayout>>,
|
||||
}
|
||||
|
||||
@@ -160,6 +211,22 @@ impl TypeTool {
|
||||
Self { mode, vertical: id.starts_with("vertical"), ..Default::default() }
|
||||
}
|
||||
|
||||
/// The Type tool (which tool, and whether vertical) that a press with `mods` held acts as.
|
||||
/// While no type is being edited, Shift switches between the horizontal and the vertical tool,
|
||||
/// and Alt switches between Area Type and Type on a Path (the Type tool has no counterpart for
|
||||
/// Alt); both together do both. While type is being edited, it is the tool chosen.
|
||||
fn acting(&self, mods: Mods) -> (Mode, bool) {
|
||||
if self.editing.is_some() {
|
||||
return (self.mode, self.vertical);
|
||||
}
|
||||
let mode = match self.mode {
|
||||
Mode::Area if mods.alt => Mode::OnPath,
|
||||
Mode::OnPath if mods.alt => Mode::Area,
|
||||
mode => mode,
|
||||
};
|
||||
(mode, self.vertical != mods.shift)
|
||||
}
|
||||
|
||||
fn text<'a>(cx: &'a ToolContext, id: NodeId) -> Option<&'a TextObject> {
|
||||
match &cx.doc.node(id)?.kind {
|
||||
NodeKind::Text(t) => Some(t),
|
||||
@@ -300,7 +367,8 @@ impl TypeTool {
|
||||
self.typing = None;
|
||||
self.preedit = None;
|
||||
}
|
||||
if self.typing.is_none() {
|
||||
let fresh = self.typing.is_none();
|
||||
if fresh {
|
||||
out.push(Action::Begin("Typing".into()));
|
||||
self.typing_ended = false;
|
||||
}
|
||||
@@ -322,6 +390,7 @@ impl TypeTool {
|
||||
let runs = edit::slice_runs(&ty.cur, p, cur.len() - s);
|
||||
let runs = if runs.is_empty() { json!([]) } else { serde_json::to_value(&runs).unwrap_or(json!([])) };
|
||||
out.push(Action::Preview("text.editRange".into(), json!({"id": id.0, "start": p, "end": base.len() - s, "runs": runs})));
|
||||
self.note_edit(fresh, id, (&base, base.len() - s), (&cur, cur.len() - s));
|
||||
out
|
||||
}
|
||||
|
||||
@@ -343,6 +412,7 @@ impl TypeTool {
|
||||
self.editing = Some(id);
|
||||
self.fresh = None;
|
||||
self.listed = None;
|
||||
self.known = None;
|
||||
self.caret = caret;
|
||||
self.anchor = caret;
|
||||
self.goal_x = None;
|
||||
@@ -366,10 +436,129 @@ impl TypeTool {
|
||||
out.push(Action::Exec("text.startList".into(), json!({"id": id.0, "at": at})));
|
||||
self.caret = start;
|
||||
self.anchor = start;
|
||||
self.listed = Some(TypedList { id, start, end: at, text: t.plain_text(), list });
|
||||
let text = t.plain_text();
|
||||
if let Some(listed) = text.get(..start).zip(text.get(at..)).map(|(a, b)| format!("{a}{b}")) {
|
||||
self.note_edit(true, id, (&text, at), (&listed, start));
|
||||
}
|
||||
self.listed = Some(TypedList { id, start, end: at, text, list });
|
||||
out
|
||||
}
|
||||
|
||||
/// Note the edited object's text as it is in the document now.
|
||||
fn remember(&mut self, cx: &ToolContext) {
|
||||
self.known = self.editing.and_then(|id| Some((id, Self::text(cx, id)?.plain_text())));
|
||||
}
|
||||
|
||||
/// After a command from outside the tool: unless it was Undo or Redo, a command that changed
|
||||
/// the edited text recorded a step, which drops the steps Undo took back from Redo, so the tool
|
||||
/// drops them too and notes the step. A command that leaves the text as it was (a query) keeps
|
||||
/// them.
|
||||
fn after_outside_command(&mut self, cx: &ToolContext) {
|
||||
let stepped = std::mem::take(&mut self.stepped);
|
||||
let known = self.known.take();
|
||||
self.remember(cx);
|
||||
if stepped || known == self.known {
|
||||
return;
|
||||
}
|
||||
self.edits.retain(|e| e.done);
|
||||
if let (Some((was, old)), Some((id, new))) = (&known, &self.known)
|
||||
&& was == id
|
||||
{
|
||||
let e = OwnEdit { id: *id, before: text_hash(old), after: text_hash(new), ends: None, done: true };
|
||||
self.push_edit(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Note an undo step of the tool's own in `id`: the text before it and after it, each with the
|
||||
/// byte where the changed span ends. `fresh` starts a new step; otherwise the step the tool
|
||||
/// noted last grows (the typing session goes on). A new step drops the steps Undo took back,
|
||||
/// as the history drops them from Redo.
|
||||
fn note_edit(&mut self, fresh: bool, id: NodeId, before: (&str, usize), after: (&str, usize)) {
|
||||
let e = OwnEdit { id, before: text_hash(before.0), after: text_hash(after.0), ends: Some((before.1, after.1)), done: true };
|
||||
self.edits.retain(|e| e.done);
|
||||
match self.edits.last_mut() {
|
||||
Some(last) if !fresh && last.id == id && last.ends.is_some() => *last = e,
|
||||
_ => self.push_edit(e),
|
||||
}
|
||||
self.known = Some((id, after.0.to_string()));
|
||||
}
|
||||
|
||||
fn push_edit(&mut self, e: OwnEdit) {
|
||||
if self.edits.len() >= OWN_EDITS_MAX {
|
||||
self.edits.remove(0);
|
||||
}
|
||||
self.edits.push(e);
|
||||
}
|
||||
|
||||
/// After Undo or Redo, the caret goes to the end of the span the step changed in the edited
|
||||
/// text, with nothing selected. A step that leaves the text as it was (a formatting change)
|
||||
/// leaves the caret and the selection where they are, within the text.
|
||||
fn after_history_step(&mut self, cx: &ToolContext, undo: bool) {
|
||||
self.stepped = true;
|
||||
let Some(id) = self.editing else { return };
|
||||
let Some(text) = Self::text(cx, id).map(TextObject::plain_text) else { return };
|
||||
match self.known.take() {
|
||||
Some((known, old)) if known == id && old != text => {
|
||||
let (old_hash, new_hash) = (text_hash(&old), text_hash(&text));
|
||||
// Undo takes back the object's last step still made that changed its text, and Redo
|
||||
// makes again the one Undo took back last, when its texts match (a step that left
|
||||
// the text as it was does not come here). A step the tool made knows where its
|
||||
// span ends. Otherwise the text after the changed span is the common suffix (a
|
||||
// removal's span is empty).
|
||||
let changed = |e: &&mut OwnEdit| e.id == id && e.before != e.after;
|
||||
let step = if undo {
|
||||
self.edits.iter_mut().rev().filter(|e| e.done).find(changed).filter(|e| (e.after, e.before) == (old_hash, new_hash))
|
||||
} else {
|
||||
self.edits.iter_mut().filter(|e| !e.done).find(changed).filter(|e| (e.before, e.after) == (old_hash, new_hash))
|
||||
};
|
||||
let own = step.and_then(|e| {
|
||||
e.done = !undo;
|
||||
e.ends.map(|(before, after)| if undo { before } else { after })
|
||||
});
|
||||
self.caret = match own {
|
||||
Some(end) => edit::floor_char(&text, end),
|
||||
None => text.len() - common_affixes(&old, &text, usize::MAX).1,
|
||||
};
|
||||
self.anchor = self.caret;
|
||||
self.goal_x = None;
|
||||
}
|
||||
_ => {
|
||||
self.caret = edit::floor_char(&text, self.caret);
|
||||
self.anchor = edit::floor_char(&text, self.anchor);
|
||||
}
|
||||
}
|
||||
self.known = Some((id, text));
|
||||
}
|
||||
|
||||
/// A `command.batch` began (`batch.begin`), ended (`batch.end`), or failed and was rolled back
|
||||
/// (`batch.rollBack`). After a rollback, what the batch's Undo and Redo steps changed in the tool
|
||||
/// is as it was at the batch's start, when the tool still edits the same object.
|
||||
fn batch_notice(&mut self, what: &str) {
|
||||
match what {
|
||||
"batch.begin" => self.batches.push(HistoryState {
|
||||
editing: self.editing,
|
||||
caret: self.caret,
|
||||
anchor: self.anchor,
|
||||
goal_x: self.goal_x,
|
||||
known: self.known.clone(),
|
||||
edits: self.edits.clone(),
|
||||
stepped: self.stepped,
|
||||
}),
|
||||
"batch.end" => {
|
||||
self.batches.pop();
|
||||
}
|
||||
"batch.rollBack" => {
|
||||
if let Some(s) = self.batches.pop()
|
||||
&& s.editing == self.editing
|
||||
{
|
||||
(self.caret, self.anchor, self.goal_x) = (s.caret, s.anchor, s.goal_x);
|
||||
(self.known, self.edits, self.stepped) = (s.known, s.edits, s.stepped);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// After Undo of a list that typed characters started (and of what was typed after it), the
|
||||
/// text is as it was typed: the caret goes on after the characters. After Redo the text is as
|
||||
/// the list left it: the caret goes back to the paragraph's start.
|
||||
@@ -428,6 +617,7 @@ impl TypeTool {
|
||||
let byte = vectorcraft_text::hit_byte(&lay, t.xf.inverse() * p);
|
||||
let mut out = self.finish(cx);
|
||||
self.start_editing(h.leaf, byte);
|
||||
self.remember(cx);
|
||||
self.clicks = (Some(p), 1);
|
||||
out.push(Action::Exec("select.set".into(), json!({"ids": [h.leaf.0]})));
|
||||
Some(out)
|
||||
@@ -437,6 +627,7 @@ impl TypeTool {
|
||||
/// edited, a click that lands on no type only ends the editing; the next click makes new type
|
||||
/// (#2871). A drag that draws an area-type frame makes it either way.
|
||||
fn on_up(&mut self, cx: &ToolContext, start: Point, at: Point) -> Vec<Action> {
|
||||
let (mode, vertical) = self.held.take().unwrap_or((self.mode, self.vertical));
|
||||
// Click into existing text: place the caret.
|
||||
if let Some(out) = self.edit_at(cx, start) {
|
||||
return out;
|
||||
@@ -459,13 +650,13 @@ impl TypeTool {
|
||||
return out;
|
||||
}
|
||||
// Area Type / Type on a Path: click a path.
|
||||
if self.mode != Mode::Type && drag.is_none() {
|
||||
let on_path = self.mode == Mode::OnPath;
|
||||
if mode != Mode::Type && drag.is_none() {
|
||||
let on_path = mode == Mode::OnPath;
|
||||
if let Some(pid) = Self::path_at(cx, start, !on_path) {
|
||||
let mode = if on_path { "onPath" } else { "area" };
|
||||
out.push(Action::Exec(
|
||||
"text.createInPath".into(),
|
||||
json!({"path": pid.0, "mode": mode, "text": "", "at": [start.x, start.y], "vertical": self.vertical, "placeholder": cx.placeholder_text}),
|
||||
json!({"path": pid.0, "mode": mode, "text": "", "at": [start.x, start.y], "vertical": vertical, "placeholder": cx.placeholder_text}),
|
||||
));
|
||||
out.push(Action::Notify("text.editNew".into()));
|
||||
return out;
|
||||
@@ -481,7 +672,7 @@ impl TypeTool {
|
||||
if let Some(rotation) = vectorcraft_geom::constrain_rotation(cx.constrain_angle) {
|
||||
params["rotation"] = json!(rotation);
|
||||
}
|
||||
params["vertical"] = json!(self.vertical);
|
||||
params["vertical"] = json!(vertical);
|
||||
params["placeholder"] = json!(cx.placeholder_text);
|
||||
out.push(Action::Exec("text.create".into(), params));
|
||||
out.push(Action::Notify("text.editNew".into()));
|
||||
@@ -505,7 +696,8 @@ impl TypeTool {
|
||||
// new document text by a stale range.
|
||||
self.preedit = None;
|
||||
}
|
||||
if self.typing.is_none() {
|
||||
let fresh = self.typing.is_none();
|
||||
if fresh {
|
||||
out.push(Action::Begin("Typing".into()));
|
||||
self.typing_ended = false;
|
||||
}
|
||||
@@ -547,6 +739,7 @@ impl TypeTool {
|
||||
let runs = edit::slice_runs(&ty.cur, p, cur.len() - s);
|
||||
let runs = if runs.is_empty() { json!([]) } else { serde_json::to_value(&runs).unwrap_or(json!([])) };
|
||||
out.push(Action::Preview("text.editRange".into(), json!({"id": id.0, "start": p, "end": base.len() - s, "runs": runs})));
|
||||
self.note_edit(fresh, id, (&base, base.len() - s), (&cur, cur.len() - s));
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ fn key(tool: &mut TypeTool, d: &Document, k: ToolKey, mods: Mods) -> Vec<Action>
|
||||
|
||||
const SHIFT: Mods = Mods { shift: true, alt: false, cmd: false, ctrl: false, space: false };
|
||||
const CMD: Mods = Mods { shift: false, alt: false, cmd: true, ctrl: false, space: false };
|
||||
const ALT: Mods = Mods { shift: false, alt: true, cmd: false, ctrl: false, space: false };
|
||||
const SHIFT_ALT: Mods = Mods { shift: true, alt: true, cmd: false, ctrl: false, space: false };
|
||||
|
||||
/// Apply the tool's previews like the engine would (on top of the session snapshot).
|
||||
fn apply(d: &mut Document, snapshot: &Document, acts: &[Action]) {
|
||||
@@ -260,6 +262,219 @@ fn typing_is_one_coalesced_preview() {
|
||||
assert!(a4.contains(&Action::Begin("Typing".into())));
|
||||
}
|
||||
|
||||
/// What the engine does around Edit › Undo or Redo (`what`) that puts the document back to `to`:
|
||||
/// it ends the typing session, runs the step, tells the tool, then lets it finish the command.
|
||||
fn history_step(tool: &mut TypeTool, to: &Document, id: NodeId, what: &str) {
|
||||
let (mut sel, p) = (Selection::default(), paint());
|
||||
sel.set([id]);
|
||||
tool.set_option("endTyping", &json!(true));
|
||||
tool.notify(&cx(to, &sel, &p), what);
|
||||
assert_eq!(tool.after_command(&cx(to, &sel, &p)), vec![]);
|
||||
}
|
||||
|
||||
fn type_in(tool: &mut TypeTool, d: &mut Document, s: &str) {
|
||||
let (sel, p) = (Selection::default(), paint());
|
||||
let snap = d.clone();
|
||||
let acts = tool.text_input(&cx(d, &sel, &p), s);
|
||||
apply(d, &snap, &acts);
|
||||
}
|
||||
|
||||
fn plain_of(d: &Document, id: NodeId) -> String {
|
||||
let NodeKind::Text(t) = &d.node(id).unwrap().kind else { panic!("text") };
|
||||
t.plain_text()
|
||||
}
|
||||
|
||||
/// Undo of typing puts the caret where the typed text began, and Redo puts it after the text.
|
||||
#[test]
|
||||
fn undo_and_redo_of_typing_put_the_caret_where_the_text_changed() {
|
||||
let (mut d, id, mut tool) = editing("XYZ");
|
||||
let before = d.clone();
|
||||
type_in(&mut tool, &mut d, "abc");
|
||||
assert_eq!((plain_of(&d, id).as_str(), tool.caret), ("abcXYZ", 3));
|
||||
let after = d.clone();
|
||||
history_step(&mut tool, &before, id, "edit.undo");
|
||||
assert_eq!((tool.caret, tool.anchor), (0, 0));
|
||||
history_step(&mut tool, &after, id, "edit.redo");
|
||||
assert_eq!((tool.caret, tool.anchor), (3, 3));
|
||||
// Typing in the middle of the text: Undo goes back to where the typing began.
|
||||
tool.caret = 4;
|
||||
tool.anchor = 4;
|
||||
type_in(&mut tool, &mut d, "-");
|
||||
assert_eq!(plain_of(&d, id), "abcX-YZ");
|
||||
history_step(&mut tool, &after, id, "edit.undo");
|
||||
assert_eq!((tool.caret, tool.anchor), (4, 4));
|
||||
}
|
||||
|
||||
/// Undo of a deletion puts the caret after the text it puts back, and Redo where it was taken out.
|
||||
#[test]
|
||||
fn undo_of_a_deletion_puts_the_caret_after_the_restored_text() {
|
||||
let (mut d, id, mut tool) = editing("Hello big world");
|
||||
let before = d.clone();
|
||||
let snap = d.clone();
|
||||
tool.caret = 10;
|
||||
tool.anchor = 10;
|
||||
let acts = key(&mut tool, &d, ToolKey::Backspace, Mods { alt: true, ..Default::default() });
|
||||
apply(&mut d, &snap, &acts);
|
||||
assert_eq!((plain_of(&d, id).as_str(), tool.caret), ("Hello world", 6));
|
||||
let after = d.clone();
|
||||
// The caret moved away before the Undo.
|
||||
key(&mut tool, &d, ToolKey::End, Mods::default());
|
||||
history_step(&mut tool, &before, id, "edit.undo");
|
||||
assert_eq!((tool.caret, tool.anchor), (10, 10));
|
||||
history_step(&mut tool, &after, id, "edit.redo");
|
||||
assert_eq!((tool.caret, tool.anchor), (6, 6));
|
||||
}
|
||||
|
||||
/// A step that changes no characters (a formatting change) leaves the caret and the selection, and
|
||||
/// the Undo of the typing before it still puts the caret where the typing began.
|
||||
#[test]
|
||||
fn undo_of_formatting_leaves_the_caret_and_selection() {
|
||||
let (mut d, id, mut tool) = editing("Hello world");
|
||||
let before = d.clone();
|
||||
tool.caret = 5;
|
||||
tool.anchor = 5;
|
||||
type_in(&mut tool, &mut d, ",");
|
||||
let typed = d.clone();
|
||||
// A panel makes the typed text larger (a command from outside the tool).
|
||||
let mut large = d.clone();
|
||||
let NodeKind::Text(t) = &mut large.node_mut(id).unwrap().kind else { panic!("text") };
|
||||
edit::style_range(&mut t.runs, 0, 6, |s| s.size = 30.0);
|
||||
let (mut sel, p) = (Selection::default(), paint());
|
||||
sel.set([id]);
|
||||
tool.set_option("endTyping", &json!(true));
|
||||
tool.after_command(&cx(&large, &sel, &p));
|
||||
tool.anchor = 2;
|
||||
tool.caret = 8;
|
||||
history_step(&mut tool, &typed, id, "edit.undo");
|
||||
assert_eq!((tool.anchor, tool.caret), (2, 8));
|
||||
history_step(&mut tool, &before, id, "edit.undo");
|
||||
assert_eq!((tool.anchor, tool.caret), (5, 5));
|
||||
}
|
||||
|
||||
/// The caret lands on character boundaries in multi-byte text.
|
||||
#[test]
|
||||
fn undo_and_redo_keep_the_caret_on_character_boundaries() {
|
||||
let (mut d, id, mut tool) = editing("café 🙂 ok");
|
||||
let before = d.clone();
|
||||
tool.caret = "café 🙂".len();
|
||||
tool.anchor = tool.caret;
|
||||
type_in(&mut tool, &mut d, "é🙂");
|
||||
assert_eq!(plain_of(&d, id), "café 🙂é🙂 ok");
|
||||
let after = d.clone();
|
||||
history_step(&mut tool, &before, id, "edit.undo");
|
||||
assert_eq!(tool.caret, "café 🙂".len());
|
||||
history_step(&mut tool, &after, id, "edit.redo");
|
||||
assert_eq!(tool.caret, "café 🙂é🙂".len());
|
||||
}
|
||||
|
||||
/// Typing or deleting the same characters as the text next to it: Undo and Redo put the caret where
|
||||
/// the tool made the change, not at the other end of the repeated characters.
|
||||
#[test]
|
||||
fn undo_and_redo_of_repeated_characters_find_the_tools_own_edit() {
|
||||
for (text, at, typed, undone, redone) in [("aaa", 0, "a", 0, 1), ("éé", 2, "é", 2, 4), ("ab ab", 3, "ab ", 3, 6)] {
|
||||
let (mut d, id, mut tool) = editing(text);
|
||||
let before = d.clone();
|
||||
tool.caret = at;
|
||||
tool.anchor = at;
|
||||
type_in(&mut tool, &mut d, typed);
|
||||
let after = d.clone();
|
||||
history_step(&mut tool, &before, id, "edit.undo");
|
||||
assert_eq!((tool.caret, tool.anchor), (undone, undone), "{text}: undo");
|
||||
history_step(&mut tool, &after, id, "edit.redo");
|
||||
assert_eq!((tool.caret, tool.anchor), (redone, redone), "{text}: redo");
|
||||
}
|
||||
// A deleted character next to the same character comes back with the caret after it.
|
||||
let (mut d, id, mut tool) = editing("aaa");
|
||||
let before = d.clone();
|
||||
tool.caret = 1;
|
||||
tool.anchor = 1;
|
||||
let snap = d.clone();
|
||||
let acts = key(&mut tool, &d, ToolKey::Backspace, Mods::default());
|
||||
apply(&mut d, &snap, &acts);
|
||||
assert_eq!(plain_of(&d, id), "aa");
|
||||
history_step(&mut tool, &before, id, "edit.undo");
|
||||
assert_eq!(tool.caret, 1);
|
||||
// Two steps whose texts are each other's: each Undo and Redo finds its own step.
|
||||
let (mut d, id, mut tool) = editing("aaa");
|
||||
let first = d.clone();
|
||||
type_in(&mut tool, &mut d, "a");
|
||||
let second = d.clone();
|
||||
key(&mut tool, &d, ToolKey::End, Mods::default());
|
||||
let snap = d.clone();
|
||||
let acts = key(&mut tool, &d, ToolKey::Backspace, Mods::default());
|
||||
apply(&mut d, &snap, &acts);
|
||||
let third = d.clone();
|
||||
assert_eq!(plain_of(&third, id), "aaa");
|
||||
for (to, what, at) in [(&second, "edit.undo", 4), (&first, "edit.undo", 0), (&second, "edit.redo", 1), (&third, "edit.redo", 3)] {
|
||||
history_step(&mut tool, to, id, what);
|
||||
assert_eq!(tool.caret, at, "{what} to {}", plain_of(to, id));
|
||||
}
|
||||
// A step from outside the tool after an Undo drops the undone step from Redo: Redo of the
|
||||
// outside step does not take the dropped step's place.
|
||||
let (mut d, id, mut tool) = editing("aaa");
|
||||
let first = d.clone();
|
||||
type_in(&mut tool, &mut d, "a");
|
||||
history_step(&mut tool, &first, id, "edit.undo");
|
||||
assert_eq!(tool.caret, 0);
|
||||
let mut appended = first.clone();
|
||||
let NodeKind::Text(t) = &mut appended.node_mut(id).unwrap().kind else { panic!("text") };
|
||||
edit::replace_range(&mut t.runs, 3, 3, "a");
|
||||
let (mut sel, p) = (Selection::default(), paint());
|
||||
sel.set([id]);
|
||||
assert_eq!(tool.after_command(&cx(&appended, &sel, &p)), vec![]);
|
||||
history_step(&mut tool, &first, id, "edit.undo");
|
||||
history_step(&mut tool, &appended, id, "edit.redo");
|
||||
assert_eq!(tool.caret, 4);
|
||||
// A command from outside the tool that leaves the text as it is (a query) keeps the undone
|
||||
// step for Redo.
|
||||
let (mut d, id, mut tool) = editing("aaa");
|
||||
let first = d.clone();
|
||||
type_in(&mut tool, &mut d, "a");
|
||||
let second = d.clone();
|
||||
history_step(&mut tool, &first, id, "edit.undo");
|
||||
let (mut sel, p) = (Selection::default(), paint());
|
||||
sel.set([id]);
|
||||
assert_eq!(tool.after_command(&cx(&first, &sel, &p)), vec![]);
|
||||
history_step(&mut tool, &second, id, "edit.redo");
|
||||
assert_eq!(tool.caret, 1);
|
||||
// Commands from outside the tool that repeat the change of a step the tool made are steps of
|
||||
// their own: Undo of the last one does not take back the tool's step.
|
||||
let (mut d, id, mut tool) = editing("aaa");
|
||||
let first = d.clone();
|
||||
type_in(&mut tool, &mut d, "a");
|
||||
let typed = d.clone();
|
||||
let (mut sel, p) = (Selection::default(), paint());
|
||||
sel.set([id]);
|
||||
tool.set_option("endTyping", &json!(true));
|
||||
let mut removed = typed.clone();
|
||||
let NodeKind::Text(t) = &mut removed.node_mut(id).unwrap().kind else { panic!("text") };
|
||||
edit::replace_range(&mut t.runs, 3, 4, "");
|
||||
assert_eq!(tool.after_command(&cx(&removed, &sel, &p)), vec![]);
|
||||
let mut appended = removed.clone();
|
||||
let NodeKind::Text(t) = &mut appended.node_mut(id).unwrap().kind else { panic!("text") };
|
||||
edit::replace_range(&mut t.runs, 3, 3, "a");
|
||||
assert_eq!(tool.after_command(&cx(&appended, &sel, &p)), vec![]);
|
||||
for (to, what, at) in [(&removed, "edit.undo", 3), (&typed, "edit.undo", 4), (&first, "edit.undo", 0), (&typed, "edit.redo", 1)] {
|
||||
history_step(&mut tool, to, id, what);
|
||||
assert_eq!(tool.caret, at, "{what} to {}", plain_of(to, id));
|
||||
}
|
||||
// A typing session that leaves the text as it was (a character typed and deleted again) does
|
||||
// not stop Undo of the step before it from finding where that step typed.
|
||||
let (mut d, id, mut tool) = editing("aaa");
|
||||
let first = d.clone();
|
||||
type_in(&mut tool, &mut d, "a");
|
||||
key(&mut tool, &d, ToolKey::End, Mods::default());
|
||||
// The session's previews each replace the text it started from.
|
||||
let session = d.clone();
|
||||
type_in(&mut tool, &mut d, "b");
|
||||
let acts = key(&mut tool, &d, ToolKey::Backspace, Mods::default());
|
||||
apply(&mut d, &session, &acts);
|
||||
assert_eq!(plain_of(&d, id), "aaaa");
|
||||
history_step(&mut tool, &d, id, "edit.undo");
|
||||
history_step(&mut tool, &first, id, "edit.undo");
|
||||
assert_eq!(tool.caret, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_arrows_select_and_typing_replaces() {
|
||||
let (d, _, mut tool) = editing("Hello world");
|
||||
@@ -473,6 +688,147 @@ fn vertical_tools_preserve_their_ids_and_create_vertical_text() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A press from `tool` at `from` with `down` held, dragged to `to` (none for a click) and released
|
||||
/// with `up` held → the actions of the release.
|
||||
fn gesture(tool: &mut TypeTool, c: &ToolContext, from: (f64, f64), to: Option<(f64, f64)>, down: Mods, up: Mods) -> Vec<Action> {
|
||||
tool.pointer(c, &PointerEvent::new(PointerKind::Down, from.0, from.1).with_mods(down));
|
||||
let end = to.unwrap_or(from);
|
||||
if to.is_some() {
|
||||
tool.pointer(c, &PointerEvent::new(PointerKind::Drag, end.0, end.1).with_mods(down));
|
||||
}
|
||||
tool.pointer(c, &PointerEvent::new(PointerKind::Up, end.0, end.1).with_mods(up))
|
||||
}
|
||||
|
||||
/// The command that makes new type among `acts`, with its params.
|
||||
fn made(acts: &[Action]) -> Option<(&str, &Value)> {
|
||||
acts.iter().find_map(|a| match a {
|
||||
Action::Exec(cmd, p) if cmd == "text.create" || cmd == "text.createInPath" => Some((cmd.as_str(), p)),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// [`crate::testutil::doc_with_rect`] plus an open path from (100, 400) to (400, 400).
|
||||
fn doc_with_rect_and_line() -> (Document, NodeId, NodeId) {
|
||||
let (mut d, rect) = crate::testutil::doc_with_rect();
|
||||
let l = d.layers[0].id;
|
||||
let line = d.alloc_id();
|
||||
let path = vectorcraft_geom::PathData::from_bezpath(&BezPath::from_vec(vec![
|
||||
vectorcraft_geom::PathEl::MoveTo(Point::new(100.0, 400.0)),
|
||||
vectorcraft_geom::PathEl::LineTo(Point::new(400.0, 400.0)),
|
||||
]));
|
||||
d.insert(Some(l), 1, Node::path(line, path, vectorcraft_doc::Appearance::default_art())).unwrap();
|
||||
(d, rect, line)
|
||||
}
|
||||
|
||||
/// Shift held at the press makes the new type as the horizontal or vertical counterpart of each
|
||||
/// Type tool would, for a click and for a drag; the keys at the release do not matter, and the
|
||||
/// next press without Shift acts as the tool chosen.
|
||||
#[test]
|
||||
fn shift_switches_between_the_horizontal_and_the_vertical_type_tool() {
|
||||
let (d, rect) = crate::testutil::doc_with_rect();
|
||||
let (sel, p) = (Selection::default(), paint());
|
||||
let c = cx(&d, &sel, &p);
|
||||
for (id, at, cmd) in [
|
||||
("type", (400.0, 400.0), "text.create"),
|
||||
("verticalType", (400.0, 400.0), "text.create"),
|
||||
("areaType", (100.0, 150.0), "text.createInPath"),
|
||||
("verticalAreaType", (100.0, 150.0), "text.createInPath"),
|
||||
("typeOnPath", (150.0, 100.0), "text.createInPath"),
|
||||
("verticalTypeOnPath", (150.0, 100.0), "text.createInPath"),
|
||||
] {
|
||||
let vertical = id.starts_with("vertical");
|
||||
let mut tool = TypeTool::new(id);
|
||||
let acts = gesture(&mut tool, &c, at, None, SHIFT, Mods::default());
|
||||
let (made_by, params) = made(&acts).unwrap_or_else(|| panic!("{id}: {acts:?}"));
|
||||
assert_eq!(made_by, cmd, "{id}");
|
||||
assert_eq!(params["vertical"], json!(!vertical), "{id} with Shift");
|
||||
if cmd == "text.createInPath" {
|
||||
assert_eq!(params["path"], json!(rect.0), "{id}");
|
||||
assert_eq!(params["mode"], if id.contains("OnPath") { "onPath" } else { "area" }, "{id}");
|
||||
}
|
||||
assert_eq!(tool.id(), id, "the tool chosen stays");
|
||||
let acts = gesture(&mut tool, &c, at, None, Mods::default(), SHIFT);
|
||||
assert_eq!(made(&acts).map(|(_, p)| p["vertical"].clone()), Some(json!(vertical)), "{id} without Shift at the press");
|
||||
}
|
||||
// A frame dragged out with Shift held.
|
||||
let mut tool = TypeTool::new("type");
|
||||
let acts = gesture(&mut tool, &c, (300.0, 300.0), Some((400.0, 400.0)), SHIFT, Mods::default());
|
||||
let (cmd, params) = made(&acts).unwrap_or_else(|| panic!("{acts:?}"));
|
||||
assert_eq!((cmd, ¶ms["area"], ¶ms["vertical"]), ("text.create", &json!({"width": 100.0, "height": 100.0}), &json!(true)));
|
||||
}
|
||||
|
||||
/// Alt held at the press makes the new type as Type on a Path would with the Area Type tools and
|
||||
/// as Area Type would with the Type on a Path tools, keeping their writing direction; with Shift
|
||||
/// too, the direction switches as well. The Type tools have no counterpart for Alt.
|
||||
#[test]
|
||||
fn alt_switches_between_area_type_and_type_on_a_path() {
|
||||
let (d, rect, line) = doc_with_rect_and_line();
|
||||
let (sel, p) = (Selection::default(), paint());
|
||||
let c = cx(&d, &sel, &p);
|
||||
let (on_line, on_rect) = ((250.0, 400.0), (100.0, 150.0));
|
||||
for (id, at, mods, path, mode, vertical) in [
|
||||
("areaType", on_line, ALT, line, "onPath", false),
|
||||
("verticalAreaType", on_line, ALT, line, "onPath", true),
|
||||
("typeOnPath", on_rect, ALT, rect, "area", false),
|
||||
("verticalTypeOnPath", on_rect, ALT, rect, "area", true),
|
||||
("areaType", on_line, SHIFT_ALT, line, "onPath", true),
|
||||
("verticalTypeOnPath", on_rect, SHIFT_ALT, rect, "area", false),
|
||||
] {
|
||||
let mut tool = TypeTool::new(id);
|
||||
let acts = gesture(&mut tool, &c, at, None, mods, Mods::default());
|
||||
let (cmd, params) = made(&acts).unwrap_or_else(|| panic!("{id} {mods:?}: {acts:?}"));
|
||||
assert_eq!(cmd, "text.createInPath", "{id} {mods:?}");
|
||||
assert_eq!((¶ms["path"], ¶ms["mode"], ¶ms["vertical"]), (&json!(path.0), &json!(mode), &json!(vertical)), "{id} {mods:?}");
|
||||
assert_eq!(tool.id(), id);
|
||||
}
|
||||
// An open path is no frame for the Area Type tool without Alt, and Type on a Path keeps the
|
||||
// rectangle as a baseline.
|
||||
let acts = gesture(&mut TypeTool::new("areaType"), &c, on_line, None, Mods::default(), Mods::default());
|
||||
assert_eq!(made(&acts).map(|(cmd, _)| cmd), Some("text.create"), "{acts:?}");
|
||||
let acts = gesture(&mut TypeTool::new("typeOnPath"), &c, on_rect, None, Mods::default(), Mods::default());
|
||||
assert_eq!(made(&acts).map(|(_, p)| p["mode"].clone()), Some(json!("onPath")), "{acts:?}");
|
||||
for id in ["type", "verticalType"] {
|
||||
let acts = gesture(&mut TypeTool::new(id), &c, (450.0, 450.0), None, ALT, Mods::default());
|
||||
let (cmd, params) = made(&acts).unwrap_or_else(|| panic!("{id}: {acts:?}"));
|
||||
assert_eq!((cmd, ¶ms["vertical"]), ("text.create", &json!(id == "verticalType")), "{id} with Alt");
|
||||
}
|
||||
}
|
||||
|
||||
/// The pointer shows the Type tool that a press would act as with the keys held, and while a press
|
||||
/// is held, the one it acts as.
|
||||
#[test]
|
||||
fn the_pointer_shows_the_type_tool_shift_or_alt_switches_to() {
|
||||
let (d, _, _) = doc_with_rect_and_line();
|
||||
let (sel, p) = (Selection::default(), paint());
|
||||
let c = cx(&d, &sel, &p);
|
||||
let (away, on_line) = (Point::new(450.0, 450.0), Point::new(250.0, 400.0));
|
||||
let cursor = |id: &str, at: Point, mods: Mods| TypeTool::new(id).cursor(&c, at, mods);
|
||||
assert_eq!(cursor("type", away, SHIFT), Cursor::TypeVertical);
|
||||
assert_eq!(cursor("verticalType", away, SHIFT), Cursor::Text);
|
||||
assert_eq!(cursor("verticalAreaType", away, SHIFT), Cursor::Text);
|
||||
assert_eq!(cursor("areaType", on_line, Mods::default()), Cursor::Text, "no frame for area type");
|
||||
assert_eq!(cursor("areaType", on_line, ALT), Cursor::Crosshair, "a baseline for type on a path");
|
||||
assert_eq!(cursor("typeOnPath", on_line, ALT), Cursor::Text);
|
||||
let mut tool = TypeTool::new("type");
|
||||
tool.pointer(&c, &PointerEvent::new(PointerKind::Down, away.x, away.y).with_mods(SHIFT));
|
||||
assert_eq!(tool.cursor(&c, away, Mods::default()), Cursor::TypeVertical, "the press keeps the tool it acts as");
|
||||
tool.pointer(&c, &PointerEvent::new(PointerKind::Up, away.x, away.y));
|
||||
assert_eq!(tool.cursor(&c, away, Mods::default()), Cursor::Text);
|
||||
}
|
||||
|
||||
/// While type is being edited, Shift and Alt switch no tool: a frame dragged out is the tool
|
||||
/// chosen's, and the pointer stays its own.
|
||||
#[test]
|
||||
fn shift_and_alt_switch_no_tool_while_type_is_edited() {
|
||||
let (d, _, mut tool) = editing("Hello");
|
||||
let (sel, p) = (Selection::default(), paint());
|
||||
let c = cx(&d, &sel, &p);
|
||||
assert_eq!(tool.cursor(&c, Point::new(400.0, 400.0), SHIFT), Cursor::Text);
|
||||
let acts = gesture(&mut tool, &c, (300.0, 300.0), Some((400.0, 400.0)), SHIFT_ALT, SHIFT_ALT);
|
||||
let (cmd, params) = made(&acts).unwrap_or_else(|| panic!("{acts:?}"));
|
||||
assert_eq!((cmd, ¶ms["vertical"]), ("text.create", &json!(false)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ime_ranges_convert_characters_to_bytes() {
|
||||
assert_eq!(char_range_to_bytes("ががく", 1..3), Some(3..9));
|
||||
|
||||
@@ -54,6 +54,7 @@ impl Tool for TypeTool {
|
||||
return out;
|
||||
}
|
||||
self.press = Some((ev.pos, self.snap.press(cx, ev.pos, self.editing.as_slice(), None)));
|
||||
self.held = Some(self.acting(ev.mods));
|
||||
self.drag = None;
|
||||
vec![]
|
||||
}
|
||||
@@ -455,6 +456,7 @@ impl Tool for TypeTool {
|
||||
/// goes before the paragraph's text.
|
||||
fn after_command(&mut self, cx: &ToolContext) -> Vec<Action> {
|
||||
self.after_typed_list(cx);
|
||||
self.after_outside_command(cx);
|
||||
let Some(id) = self.editing else { return vec![] };
|
||||
let mut cur = Some(id).filter(|i| cx.doc.node(*i).is_some());
|
||||
while let Some(c) = cur {
|
||||
@@ -465,12 +467,19 @@ impl Tool for TypeTool {
|
||||
}
|
||||
self.finish(cx)
|
||||
}
|
||||
/// After `text.create` / `text.createInPath` the engine selects the new object; edit it.
|
||||
/// After `text.create` / `text.createInPath` the engine selects the new object; edit it. After
|
||||
/// `edit.undo` / `edit.redo` the caret goes to where the step changed the edited text. The
|
||||
/// `batch.*` notices keep what a failed `command.batch` changed from staying ([`TypeTool::batch_notice`]).
|
||||
fn notify(&mut self, cx: &ToolContext, what: &str) {
|
||||
if what == "text.editNew"
|
||||
if matches!(what, "edit.undo" | "edit.redo") {
|
||||
self.after_history_step(cx, what == "edit.undo");
|
||||
} else if what.starts_with("batch.") {
|
||||
self.batch_notice(what);
|
||||
} else if what == "text.editNew"
|
||||
&& let Some(id) = cx.selection.objects.first().copied()
|
||||
{
|
||||
self.start_editing(id, 0);
|
||||
self.remember(cx);
|
||||
let t = Self::text(cx, id);
|
||||
self.fresh = t.filter(|t| matches!(t.kind, TextKind::Point)).map(|_| id);
|
||||
// Placeholder text comes selected: typing replaces it.
|
||||
@@ -626,10 +635,16 @@ impl Tool for TypeTool {
|
||||
}
|
||||
o
|
||||
}
|
||||
fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor {
|
||||
if self.mode != Mode::Type && Self::path_at(cx, p, self.mode == Mode::Area).is_some() {
|
||||
/// The pointer of the Type tool that a press would act as with the keys held, or, while a
|
||||
/// press is held, of the one it acts as ([`TypeTool::acting`]).
|
||||
fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor {
|
||||
let (mode, vertical) = match (self.press, self.held) {
|
||||
(Some(_), Some(held)) => held,
|
||||
_ => self.acting(m),
|
||||
};
|
||||
if mode != Mode::Type && Self::path_at(cx, p, mode == Mode::Area).is_some() {
|
||||
return Cursor::Crosshair;
|
||||
}
|
||||
if self.vertical { Cursor::TypeVertical } else { Cursor::Text }
|
||||
if vertical { Cursor::TypeVertical } else { Cursor::Text }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,6 +124,48 @@ impl VectorcraftApp {
|
||||
self.synthetic_frame = false;
|
||||
}
|
||||
|
||||
/// Release the pointer buttons egui still holds down when the system reports that no mouse
|
||||
/// button is down (`system_held` is false), at the pointer's last position. egui ends a drag
|
||||
/// only on a button release or Escape, so a release the window never received leaves a drag,
|
||||
/// such as a dock column's resize, following the pointer (#4326). Frames that carry a button
|
||||
/// event, and control-channel input that holds a button between steps, are left alone.
|
||||
pub fn release_lost_buttons(&self, ctx: &egui::Context, raw: &mut egui::RawInput, system_held: bool) {
|
||||
if system_held
|
||||
|| self.synthetic_injected
|
||||
|| !self.synthetic.is_empty()
|
||||
|| raw.events.iter().any(|e| matches!(e, egui::Event::PointerButton { .. }))
|
||||
{
|
||||
return;
|
||||
}
|
||||
let (down, pos, modifiers) = ctx.input_for(raw.viewport_id, |i| {
|
||||
let p = &i.pointer;
|
||||
let down: Vec<egui::PointerButton> = [
|
||||
egui::PointerButton::Primary,
|
||||
egui::PointerButton::Secondary,
|
||||
egui::PointerButton::Middle,
|
||||
egui::PointerButton::Extra1,
|
||||
egui::PointerButton::Extra2,
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|b| p.button_down(*b))
|
||||
.collect();
|
||||
(down, p.latest_pos().or_else(|| p.interact_pos()).or_else(|| p.press_origin()), i.modifiers)
|
||||
});
|
||||
// The releases go after this frame's last pointer movement, at its position, so a pointer
|
||||
// that moved away from the press does not end it with a click at the press position.
|
||||
let moved = raw.events.iter().enumerate().rev().find_map(|(i, e)| match e {
|
||||
egui::Event::PointerMoved(p) => Some((i + 1, *p)),
|
||||
_ => None,
|
||||
});
|
||||
let (at, pos) = match moved {
|
||||
Some((at, p)) => (at, Some(p)),
|
||||
None => (0, pos),
|
||||
};
|
||||
let Some(pos) = pos else { return };
|
||||
let releases = down.into_iter().map(|button| egui::Event::PointerButton { pos, button, pressed: false, modifiers });
|
||||
raw.events.splice(at..at, releases);
|
||||
}
|
||||
|
||||
/// Inject synthetic events (one press/release step, wheel turn or short key sequence per host
|
||||
/// iteration), carrying control provenance until a settled UI pass has taken the batch. The
|
||||
/// batch belongs to the one root viewport the shipped hosts draw (see
|
||||
|
||||
@@ -5,12 +5,13 @@ use super::{Affine, CacheKey, VectorcraftApp, Xf};
|
||||
/// A raster whose bytes don't match its size gives an empty image of that size.
|
||||
pub(crate) fn color_image(img: &vectorcraft_render::Rendered) -> egui::ColorImage {
|
||||
let size = [img.width as usize, img.height as usize];
|
||||
let mut pixels = vec![egui::Color32::TRANSPARENT; size[0] * size[1]];
|
||||
let bytes: &mut [u8] = bytemuck::cast_slice_mut(&mut pixels);
|
||||
if bytes.len() == img.pixels.len() {
|
||||
bytes.copy_from_slice(&img.pixels);
|
||||
let expected = size[0].saturating_mul(size[1]).saturating_mul(4);
|
||||
if expected == img.pixels.len() {
|
||||
let pixels: Vec<egui::Color32> = bytemuck::cast_slice(&img.pixels).to_vec();
|
||||
egui::ColorImage::new(size, pixels)
|
||||
} else {
|
||||
egui::ColorImage::new(size, vec![egui::Color32::TRANSPARENT; size[0].saturating_mul(size[1])])
|
||||
}
|
||||
egui::ColorImage::new(size, pixels)
|
||||
}
|
||||
|
||||
/// The pixels of the `w`×`h` frame through `view` to render again for `doc`, when only the document
|
||||
|
||||
@@ -225,7 +225,9 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) {
|
||||
if colored && widgets::icon_button(ui, "palette", tl!("Recolor Artwork"), false, 30.0).clicked() {
|
||||
run = Some(("ui.recolorDialog", json!({})));
|
||||
}
|
||||
if widgets::icon_button(ui, "lock", tl!("Lock (⌘2)"), false, 30.0).clicked() {
|
||||
// The Lock button is off in isolation mode, as Object › Lock › Selection is.
|
||||
let lockable = crate::menus::enabled(app, "object.lock");
|
||||
if widgets::icon_button_enabled(ui, "lock", tl!("Lock (⌘2)"), false, lockable, 30.0).clicked() {
|
||||
run = Some(("object.lock", json!({})));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6102,6 +6102,43 @@ fn the_task_bar_recolors_coloured_art() {
|
||||
assert!(app.ui.dialog.is_none(), "no Recolor button without colours");
|
||||
}
|
||||
|
||||
/// The Contextual Task Bar's Lock button is off in isolation mode, as Object › Lock › Selection
|
||||
/// is: a click locks nothing and shows no error. Outside isolation mode it locks the selection.
|
||||
#[test]
|
||||
fn the_task_bar_lock_button_is_off_in_isolation_mode() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
let a = app.session.execute("shape.rectangle", &json!({"x": 150, "y": 130, "width": 40, "height": 40})).unwrap()["id"].clone();
|
||||
let b = app.session.execute("shape.rectangle", &json!({"x": 210, "y": 130, "width": 40, "height": 40})).unwrap()["id"].clone();
|
||||
app.session.execute("select.set", &json!({"ids": [a.clone(), b]})).unwrap();
|
||||
let g = app.session.execute("object.group", &json!({})).unwrap()["id"].clone();
|
||||
app.select_tool("selection");
|
||||
let ctx = egui::Context::default();
|
||||
let click_lock = |app: &mut VectorcraftApp| {
|
||||
frame(app, &ctx, vec![]);
|
||||
frame(app, &ctx, vec![]);
|
||||
let bar = task_bar_rect(&ctx).expect("the task bar shows");
|
||||
// Right to left: the frame's margin, More Options, then Lock.
|
||||
let at = pos2(bar.right() - 8.0 - 30.0 - 6.0 - 15.0, bar.center().y);
|
||||
let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
frame(app, &ctx, vec![egui::Event::PointerMoved(at)]);
|
||||
frame(app, &ctx, vec![button(true)]);
|
||||
frame(app, &ctx, vec![button(false)]);
|
||||
};
|
||||
let locked = |app: &VectorcraftApp, id: &serde_json::Value| {
|
||||
app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap().locked
|
||||
};
|
||||
app.session.execute("object.isolate", &json!({"id": g.clone()})).unwrap();
|
||||
app.session.execute("select.set", &json!({"ids": [a.clone()]})).unwrap();
|
||||
click_lock(&mut app);
|
||||
assert!(!locked(&app, &a), "nothing is locked in isolation mode");
|
||||
assert_eq!(app.ui.status, "", "no error");
|
||||
app.session.execute("object.exitIsolation", &json!({})).unwrap();
|
||||
app.session.execute("select.set", &json!({"ids": [g.clone()]})).unwrap();
|
||||
click_lock(&mut app);
|
||||
assert!(locked(&app, &g), "the group is locked outside isolation mode");
|
||||
}
|
||||
|
||||
/// The Contextual Task Bar shows Image Trace first for a selected image: it traces the image
|
||||
/// with the Default preset and leaves the Image Trace panel as it was. For the Image Trace
|
||||
/// object it made, the first button is Expand, which keeps only the traced paths.
|
||||
|
||||
@@ -334,6 +334,9 @@ fn focus_frame(app: &mut VectorcraftApp, ctx: &egui::Context, focused: bool, eve
|
||||
fn the_canvas_hands_the_tool_cursor_to_the_os_and_idles_while_the_pointer_rests() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||||
// Every frame renders on this thread, however long it takes: a frame finished by the render
|
||||
// worker during the idle frames asks for a repaint.
|
||||
app.canvas.worker_started = true;
|
||||
let ctx = egui::Context::default();
|
||||
for _ in 0..3 {
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! preferences. OK sets them with `tool.setOption {tool, values}`. The Pencil also has `liveFit`
|
||||
//! (Live curve fitting, on: its preview fits the stroke while it is drawn).
|
||||
//!
|
||||
//! Fields: `tool`; `fidelity` (points; Pencil, Paintbrush and Smooth), `fill` (Pencil and
|
||||
//! Fields: `tool`; `fidelity` (points; Pencil, Paintbrush, Smooth and Blob Brush), `fill` (Pencil and
|
||||
//! Paintbrush), `keepSelected` (Pencil, Paintbrush and Blob Brush), `mergeOnlySelection` (Blob
|
||||
//! Brush), `closeWithin` and `editWithin` (screen pixels, 0 turns it off; Pencil and Paintbrush),
|
||||
//! `angle` (degrees) and `roundness` (percent; Eraser), `livePreview` (Pencil: the stroke shows
|
||||
|
||||
@@ -98,7 +98,8 @@ fn each_tool_shows_its_options_and_ok_sets_them() {
|
||||
let pb_text = painted(&mut app);
|
||||
assert!(pb_text.contains("Paintbrush Tool Options"));
|
||||
assert!(!pb_text.contains("Alt key toggles to Smooth Tool") && !pb_text.contains("Live preview"), "{pb_text}");
|
||||
// Smooth has Fidelity only, the Eraser Size only, and the Blob Brush Size, Keep selected and Merge only with selection.
|
||||
// Smooth has Fidelity only, the Eraser Size only, and the Blob Brush Fidelity, Size, Keep selected and Merge only
|
||||
// with selection.
|
||||
app.run("tool.options", json!({"tool": "smooth"})).unwrap();
|
||||
let text = painted(&mut app);
|
||||
assert!(text.contains("Smooth Tool Options") && text.contains("Fidelity:"), "{text}");
|
||||
@@ -112,9 +113,10 @@ fn each_tool_shows_its_options_and_ok_sets_them() {
|
||||
for tool in ["blobBrush", "eraser"] {
|
||||
app.run("tool.options", json!({"tool": tool})).unwrap();
|
||||
let text = painted(&mut app);
|
||||
assert!(text.contains("Size:") && !text.contains("Fidelity:"), "{tool}: {text}");
|
||||
// The Blob Brush also has Keep selected and Merge only with selection.
|
||||
// The Blob Brush also has Fidelity, Keep selected and Merge only with selection.
|
||||
let blob = tool == "blobBrush";
|
||||
assert!(text.contains("Size:"), "{tool}: {text}");
|
||||
assert_eq!(text.contains("Fidelity:"), blob, "{tool}: {text}");
|
||||
assert_eq!((text.contains("Keep selected"), text.contains("Merge only with selection")), (blob, blob), "{tool}: {text}");
|
||||
// The Eraser also has Angle and Roundness.
|
||||
assert_eq!((text.contains("Angle:"), text.contains("Roundness:")), (!blob, !blob), "{tool}: {text}");
|
||||
@@ -200,6 +202,39 @@ fn a_new_fidelity_changes_what_the_pencil_draws() {
|
||||
assert!(smooth < precise, "{smooth} anchors at fidelity 12, {precise} at the default");
|
||||
}
|
||||
|
||||
/// The Blob Brush's Fidelity, set in its Tool Options, changes the shape its strokes paint: at
|
||||
/// 0.01 pt the brush follows a stroke's jitter, and at the default 1.5 pt it smooths it away.
|
||||
#[test]
|
||||
fn a_new_fidelity_changes_what_the_blob_brush_draws() {
|
||||
let mut app = app();
|
||||
// The anchors of the blob a jittery wave paints around height `y0`.
|
||||
let anchors = |app: &mut VectorcraftApp, y0: f64| {
|
||||
app.select_tool("blobBrush");
|
||||
let view = app.view_info();
|
||||
let jitter = |i: i32| if i % 2 == 0 { 1.0 } else { -1.0 };
|
||||
let pts: Vec<_> = (0..=40).map(|i| (20.0 + f64::from(i) * 8.0, y0 + 30.0 * (f64::from(i) * 0.25).sin() + jitter(i))).collect();
|
||||
let pointer = |kind, (x, y)| vectorcraft_tools::PointerEvent::new(kind, x, y);
|
||||
crate::canvas::dispatch(app, &pointer(vectorcraft_tools::PointerKind::Down, pts[0]), view);
|
||||
for p in &pts[1..] {
|
||||
crate::canvas::dispatch(app, &pointer(vectorcraft_tools::PointerKind::Drag, *p), view);
|
||||
}
|
||||
crate::canvas::dispatch(app, &pointer(vectorcraft_tools::PointerKind::Up, pts[40]), view);
|
||||
let doc = &app.session.doc().unwrap().doc;
|
||||
doc.layers[0].children().unwrap().last().unwrap().path_data().map_or(0, |p| p.subpaths.iter().map(|s| s.anchors.len()).sum::<usize>())
|
||||
};
|
||||
app.run("tool.options", json!({"tool": "blobBrush"})).unwrap();
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().f64("fidelity", 0.0), 1.5);
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
let smooth = anchors(&mut app, 100.0);
|
||||
app.run("tool.options", json!({"tool": "blobBrush"})).unwrap();
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("fidelity".into(), json!(0.01));
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
app.select_tool("selection");
|
||||
assert_eq!(app.session.tool_options_of("blobBrush")["fidelity"].as_f64(), Some(0.01), "it lasts while another tool is chosen");
|
||||
let precise = anchors(&mut app, 200.0);
|
||||
assert!(smooth > 0 && smooth < precise, "{smooth} anchors at the default, {precise} at fidelity 0.01");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_new_path_is_selected_only_with_keep_selected_on() {
|
||||
let mut app = app();
|
||||
|
||||
@@ -664,17 +664,22 @@ mod tests;
|
||||
|
||||
/// `name` lowercased with the Latin accents that occur in language names folded away, so a
|
||||
/// byte-wise sort reads alphabetically. Other scripts are left as they are.
|
||||
fn name_sort_key(name: &str) -> String {
|
||||
pub fn name_sort_key(name: &str) -> String {
|
||||
name.chars()
|
||||
.flat_map(char::to_lowercase)
|
||||
.map(|c| match c {
|
||||
'à'..='å' | 'ā' | 'ă' | 'ą' => 'a',
|
||||
'ç' | 'ć' | 'č' => 'c',
|
||||
'ď' | 'đ' => 'd',
|
||||
'è'..='ë' | 'ē' | 'ě' | 'ę' | 'ế' | 'ệ' => 'e',
|
||||
'ì'..='ï' | 'ī' => 'i',
|
||||
'ñ' | 'ń' | 'ň' => 'n',
|
||||
'ğ' | 'ģ' => 'g',
|
||||
'ì'..='ï' | 'ī' | 'ı' => 'i',
|
||||
'ł' | 'ļ' => 'l',
|
||||
'ñ' | 'ń' | 'ň' | 'ņ' => 'n',
|
||||
'ò'..='ö' | 'ø' | 'ő' => 'o',
|
||||
'ś' | 'š' => 's',
|
||||
'ř' => 'r',
|
||||
'ś' | 'š' | 'ş' | 'ș' => 's',
|
||||
'ť' | 'ț' | 'ţ' => 't',
|
||||
'ù'..='ü' | 'ū' | 'ů' | 'ű' => 'u',
|
||||
'ý' | 'ÿ' => 'y',
|
||||
'ź' | 'ż' | 'ž' => 'z',
|
||||
@@ -696,5 +701,6 @@ mod picker_order_tests {
|
||||
assert!(keys.windows(2).all(|w| w[0] <= w[1]), "{keys:?}");
|
||||
assert_eq!(name_sort_key("Čeština"), "cestina");
|
||||
assert_eq!(name_sort_key("Türkçe"), "turkce");
|
||||
assert_eq!(name_sort_key("Łącza"), "lacza");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2990,10 +2990,10 @@ font-technology Variable Променливи
|
||||
Sixth Space Шесто разстояние
|
||||
Size Размер
|
||||
Size Tile to Art Оразмеряване на плочката според обектите
|
||||
Size/Leading Размер/Интервал
|
||||
Size/Leading Размер/междуредие
|
||||
Size: Размер:
|
||||
Ridges per segment: Изпъкналости на сегмент:
|
||||
Size: {size} Tracking: {tracking} Размер: {size} Интервал: {tracking}
|
||||
Size: {size} Tracking: {tracking} Размер: {size} Разредка: {tracking}
|
||||
Skew Искавяване
|
||||
Skin Tone Ramps Градиенти за цвят на кожата
|
||||
Skip Blank Artboards Пропускане на празни работни области
|
||||
@@ -3156,6 +3156,7 @@ font-technology Variable Променливи
|
||||
Swatch Options… Опции на образец…
|
||||
Swatch Tiles Плочки на образец
|
||||
Swatches Образци
|
||||
Switch Units Превключване на мерните единици
|
||||
Switch workspace Превключване на работната среда
|
||||
Add Content Добавяне на съдържание
|
||||
Click to apply to the selected type Щракнете, за да приложите към избрания текст
|
||||
@@ -3665,8 +3666,8 @@ imageTrace Outline Контур
|
||||
— {n} characters from another font — {n} знака от друг шрифт
|
||||
[Normal Character Style] [Нормален стил на знак]
|
||||
[Normal Paragraph Style] [Нормален стил на абзац]
|
||||
Top-to-Top Leading Разстояние между редовете отгоре до горе
|
||||
Bottom-to-Bottom Leading Разстояние между редовете отдолу до долу
|
||||
Top-to-Top Leading Междуредие отгоре до горе
|
||||
Bottom-to-Bottom Leading Междуредие отдолу до долу
|
||||
Burasagari Бурасагари
|
||||
Force Принудително
|
||||
About Относно
|
||||
@@ -4154,6 +4155,7 @@ axis Both И двете
|
||||
@msg select an object in perspective изберете обект в перспектива
|
||||
@msg select artwork (or a colour group) to recolor изберете графика (или цветова група) за преоцветяване
|
||||
@msg select at least two objects изберете поне два обекта
|
||||
@msg exit isolation mode first първо излезте от режима на изолация
|
||||
@msg select objects, not layers изберете обекти, а не слоеве
|
||||
@msg select one path, or give its `id` изберете един път или задайте неговия `id`
|
||||
@msg select paths to join изберете пътища за обединяване
|
||||
@@ -5374,7 +5376,7 @@ theme Appearance Външен вид
|
||||
Word Spacing: Разстояние между думите:
|
||||
Letter Spacing: Разстояние между буквите:
|
||||
Glyph Scaling: Мащабиране на знаците:
|
||||
Auto Leading: Автоматична междуредова разредка:
|
||||
Auto Leading: Автоматично междуредие:
|
||||
Single Word Justification: Подравняване на единична дума:
|
||||
Full Justify Пълно двустранно
|
||||
Minimum must not exceed Desired, nor Desired Maximum. Минималната стойност не може да надвишава желаната, нито желаната – максималната.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -71,6 +71,7 @@
|
||||
Check Script Zkontrolovat skript
|
||||
Other Script… Jiný skript…
|
||||
Document Setup… Nastavení dokumentu…
|
||||
Switch Units Přepnout jednotky
|
||||
Document Color Mode Barevný režim dokumentu
|
||||
CMYK Color Barvy CMYK
|
||||
RGB Color Barvy RGB
|
||||
@@ -4224,6 +4225,7 @@ imageTrace Outline Obrys
|
||||
@msg `{key}` must be between {min} and {max} (got {x}) `{key}` musí být mezi {min} a {max} (zadáno {x})
|
||||
@msg `{key}` must be one of {_1} `{key}` musí být jedno z {_1}
|
||||
@msg `{key}` must be true or false `{key}` musí být true nebo false
|
||||
@msg `top` and `below` can't both be true `top` a `below` nemohou být zároveň true
|
||||
@msg `select` must be true or false `select` musí být true nebo false
|
||||
@msg `minAnchors` and `maxAnchors` must be whole numbers `minAnchors` a `maxAnchors` musí být celá čísla
|
||||
@msg `{key}` must be a number not below 0 `{key}` musí být číslo nejméně 0
|
||||
@@ -4559,6 +4561,7 @@ imageTrace Outline Obrys
|
||||
@msg select an object in perspective vyberte objekt v perspektivě
|
||||
@msg select artwork (or a colour group) to recolor vyberte kresbu (nebo skupinu barev) k přebarvení
|
||||
@msg select at least two objects vyberte alespoň dva objekty
|
||||
@msg exit isolation mode first nejprve ukončete izolovaný režim
|
||||
@msg select objects, not layers vyberte objekty, ne vrstvy
|
||||
@msg select one path, or give its `id` vyberte jednu cestu, nebo zadejte její `id`
|
||||
@msg select paths to join vyberte cesty ke spojení
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -57,6 +57,7 @@
|
||||
Check Script Kontrollér script
|
||||
Other Script… Andet script…
|
||||
Document Setup… Dokumentopsætning…
|
||||
Switch Units Skift enheder
|
||||
Document Color Mode Dokumentets farvetilstand
|
||||
CMYK Color CMYK-farve
|
||||
RGB Color RGB-farve
|
||||
@@ -4257,6 +4258,7 @@ imageTrace Outline Kontur
|
||||
@msg select an object in perspective markér et objekt i perspektiv
|
||||
@msg select artwork (or a colour group) to recolor markér grafik (eller en farvegruppe) for at skifte farver
|
||||
@msg select at least two objects markér mindst to objekter
|
||||
@msg exit isolation mode first afslut først isoleringstilstand
|
||||
@msg select objects, not layers markér objekter, ikke lag
|
||||
@msg select one path, or give its `id` markér én sti, eller angiv dens `id`
|
||||
@msg select paths to join markér stier, der skal forbindes
|
||||
@@ -5032,7 +5034,7 @@ mark-style Japanese Japansk
|
||||
This system has no screen color sampler Dette system har ingen skærmfarvesampler
|
||||
Color blindness – Tritanopia-type Farveblindhed – tritanopitype
|
||||
Panels Except Tools and Control Paneler undtagen værktøjer og kontrol
|
||||
Reset Tracking and Kerning Nulstil spærring og kerning
|
||||
Reset Tracking and Kerning Nulstil sporing og kerning
|
||||
Reset Horizontal Scale Nulstil vandret skalering
|
||||
@msg span must be "object" or "selection", got {v} span skal være "object" eller "selection", fik {v}
|
||||
@msg span: "selection" places a `gradient`, not a swatch span: "selection" placerer en `gradient`, ikke en farveprøve
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3194,6 +3194,7 @@ effect Points Punkte
|
||||
Swatch Options… Farbfeldoptionen…
|
||||
Swatch Tiles Farbfeldkacheln
|
||||
Swatches Farbfelder
|
||||
Switch Units Einheiten wechseln
|
||||
Switch workspace Arbeitsbereich wechseln
|
||||
Add Content Inhalt hinzufügen
|
||||
Click to apply to the selected type Klicken, um auf den ausgewählten Text anzuwenden
|
||||
@@ -3845,6 +3846,7 @@ theme Appearance Erscheinungsbild
|
||||
@msg `{key}` must be between {min} and {max} (got {x}) `{key}` muss zwischen {min} und {max} liegen (erhalten: {x})
|
||||
@msg `{key}` must be one of {_1} `{key}` muss einer der folgenden Werte sein: {_1}
|
||||
@msg `{key}` must be true or false `{key}` muss true oder false sein
|
||||
@msg `top` and `below` can't both be true `top` und `below` können nicht beide true sein
|
||||
@msg `select` must be true or false `select` muss true oder false sein
|
||||
@msg `minAnchors` and `maxAnchors` must be whole numbers `minAnchors` und `maxAnchors` müssen ganze Zahlen sein
|
||||
@msg `{key}` must be a number not below 0 `{key}` muss eine Zahl nicht kleiner als 0 sein
|
||||
@@ -4182,6 +4184,7 @@ theme Appearance Erscheinungsbild
|
||||
@msg select an object in perspective wählen Sie ein Objekt in der Perspektive aus
|
||||
@msg select artwork (or a colour group) to recolor wählen Sie eine Grafik (oder eine Farbgruppe) zum Neufärben aus
|
||||
@msg select at least two objects wählen Sie mindestens zwei Objekte aus
|
||||
@msg exit isolation mode first beenden Sie zuerst den Isolationsmodus
|
||||
@msg select objects, not layers wählen Sie Objekte aus, keine Ebenen
|
||||
@msg select one path, or give its `id` wählen Sie einen Pfad aus oder geben Sie seine `id` an
|
||||
@msg select paths to join wählen Sie Pfade zum Zusammenfügen aus
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2172,6 +2172,7 @@ axis Both Και τα δύο
|
||||
@msg `{key}` must be between {min} and {max} (got {x}) Το `{key}` πρέπει να είναι μεταξύ {min} και {max} (δόθηκε {x})
|
||||
@msg `{key}` must be one of {_1} Το `{key}` πρέπει να είναι ένα από τα {_1}
|
||||
@msg `{key}` must be true or false Το `{key}` πρέπει να είναι true ή false
|
||||
@msg `top` and `below` can't both be true Τα `top` και `below` δεν μπορούν να είναι και τα δύο true
|
||||
@msg `select` must be true or false Το `select` πρέπει να είναι true ή false
|
||||
@msg `minAnchors` and `maxAnchors` must be whole numbers Τα `minAnchors` και `maxAnchors` πρέπει να είναι ακέραιοι αριθμοί
|
||||
@msg `{key}` must be a number not below 0 Το `{key}` πρέπει να είναι αριθμός όχι μικρότερος από 0
|
||||
@@ -3458,7 +3459,7 @@ theme Appearance Εμφάνιση
|
||||
Size Tile to Art Προσαρμογή μεγέθους πλακιδίου στο σχέδιο
|
||||
Size/Leading Μέγεθος/διάστιχο
|
||||
Ridges per segment: Ράχες ανά τμήμα:
|
||||
Size: {size} Tracking: {tracking} Μέγεθος: {size} Αραίωση: {tracking}
|
||||
Size: {size} Tracking: {tracking} Μέγεθος: {size} Απόσταση χαρακτήρων: {tracking}
|
||||
Skin Tone Ramps Χρωματικές κλίμακες τόνων δέρματος
|
||||
Skip Blank Artboards Παράλειψη κενών πινάκων σχεδίασης
|
||||
Sky Gradients Ντεγκραντέ ουρανού
|
||||
@@ -3569,6 +3570,7 @@ theme Appearance Εμφάνιση
|
||||
Swatch Options Επιλογές δείγματος χρώματος
|
||||
Swatch Options… Επιλογές δείγματος χρώματος…
|
||||
Swatch Tiles Πλακίδια δειγμάτων χρωμάτων
|
||||
Switch Units Αλλαγή μονάδων
|
||||
Switch workspace Αλλαγή χώρου εργασίας
|
||||
Add Content Προσθήκη περιεχομένου
|
||||
Click to apply to the selected type Κλικ για εφαρμογή στο επιλεγμένο κείμενο
|
||||
@@ -4215,6 +4217,7 @@ theme Appearance Εμφάνιση
|
||||
@msg select an object in perspective επιλέξτε αντικείμενο σε προοπτική
|
||||
@msg select artwork (or a colour group) to recolor επιλέξτε σχέδιο (ή ομάδα χρωμάτων) για επαναχρωματισμό
|
||||
@msg select at least two objects επιλέξτε τουλάχιστον δύο αντικείμενα
|
||||
@msg exit isolation mode first βγείτε πρώτα από τη λειτουργία απομόνωσης
|
||||
@msg select objects, not layers επιλέξτε αντικείμενα, όχι επίπεδα
|
||||
@msg select one path, or give its `id` επιλέξτε μία διαδρομή ή δώστε το `id` της
|
||||
@msg select paths to join επιλέξτε διαδρομές για ένωση
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2070 and column 19.
|
@@ -3224,6 +3224,7 @@ font-technology Variable Variables
|
||||
Swatch Options… Opciones de muestra…
|
||||
Swatch Tiles Mosaicos de muestras
|
||||
Swatches Muestras
|
||||
Switch Units Cambiar unidades
|
||||
Switch workspace Cambiar espacio de trabajo
|
||||
Add Content Añadir contenido
|
||||
Click to apply to the selected type Haz clic para aplicarlo al texto seleccionado
|
||||
@@ -3921,6 +3922,7 @@ axis Both Ambos
|
||||
@msg `{key}` must be between {min} and {max} (got {x}) `{key}` debe estar entre {min} y {max} (se recibió {x})
|
||||
@msg `{key}` must be one of {_1} `{key}` debe ser uno de estos: {_1}
|
||||
@msg `{key}` must be true or false `{key}` debe ser true o false
|
||||
@msg `top` and `below` can't both be true `top` y `below` no pueden ser true a la vez
|
||||
@msg `select` must be true or false `select` debe ser true o false
|
||||
@msg `minAnchors` and `maxAnchors` must be whole numbers `minAnchors` y `maxAnchors` deben ser números enteros
|
||||
@msg `{key}` must be a number not below 0 `{key}` debe ser un número no inferior a 0
|
||||
@@ -4257,6 +4259,7 @@ axis Both Ambos
|
||||
@msg select an object in perspective selecciona un objeto en perspectiva
|
||||
@msg select artwork (or a colour group) to recolor selecciona la ilustración (o un grupo de colores) que quieres volver a colorear
|
||||
@msg select at least two objects selecciona al menos dos objetos
|
||||
@msg exit isolation mode first sal primero del modo de aislamiento
|
||||
@msg select objects, not layers selecciona objetos, no capas
|
||||
@msg select one path, or give its `id` selecciona un trazado o indica su `id`
|
||||
@msg select paths to join selecciona los trazados que quieres unir
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -79,6 +79,7 @@
|
||||
Check Script Tarkista komentosarja
|
||||
Other Script… Muu komentosarja…
|
||||
Document Setup… Asiakirjan asetukset…
|
||||
Switch Units Vaihda yksiköitä
|
||||
Document Color Mode Asiakirjan väritila
|
||||
CMYK Color CMYK-väri
|
||||
RGB Color RGB-väri
|
||||
@@ -4142,6 +4143,7 @@ imageTrace Outline Ääriviiva
|
||||
@msg `{key}` must be between {min} and {max} (got {x}) `{key}` on oltava väliltä {min}–{max} (saatiin {x})
|
||||
@msg `{key}` must be one of {_1} `{key}` on oltava jokin seuraavista: {_1}
|
||||
@msg `{key}` must be true or false `{key}` on oltava true tai false
|
||||
@msg `top` and `below` can't both be true `top` ja `below` eivät voi olla molemmat true
|
||||
@msg `select` must be true or false `select` on oltava true tai false
|
||||
@msg `minAnchors` and `maxAnchors` must be whole numbers `minAnchors` ja `maxAnchors` on oltava kokonaislukuja
|
||||
@msg `{key}` must be a number not below 0 `{key}` on oltava luku, joka on vähintään 0
|
||||
@@ -4466,6 +4468,7 @@ imageTrace Outline Ääriviiva
|
||||
@msg select an object in perspective valitse perspektiivissä oleva objekti
|
||||
@msg select artwork (or a colour group) to recolor valitse uudelleenväritettävä kuvitus (tai väriryhmä)
|
||||
@msg select at least two objects valitse vähintään kaksi objektia
|
||||
@msg exit isolation mode first poistu ensin eristystilasta
|
||||
@msg select objects, not layers valitse objekteja, ei tasoja
|
||||
@msg select one path, or give its `id` valitse yksi polku tai anna sen `id`
|
||||
@msg select paths to join valitse liitettävät polut
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3221,6 +3221,7 @@ font-technology Variable Variables
|
||||
Swatch Options… Options de nuance…
|
||||
Swatch Tiles Carreaux de nuances
|
||||
Swatches Nuancier
|
||||
Switch Units Changer d’unités
|
||||
Switch workspace Changer d’espace de travail
|
||||
Add Content Ajouter du contenu
|
||||
Click to apply to the selected type Cliquez pour l’appliquer au texte sélectionné
|
||||
@@ -3918,6 +3919,7 @@ axis Both Les deux
|
||||
@msg `{key}` must be between {min} and {max} (got {x}) `{key}` doit être compris entre {min} et {max} (reçu : {x})
|
||||
@msg `{key}` must be one of {_1} `{key}` doit valoir l’une de ces valeurs : {_1}
|
||||
@msg `{key}` must be true or false `{key}` doit valoir true ou false
|
||||
@msg `top` and `below` can't both be true `top` et `below` ne peuvent pas valoir true tous les deux
|
||||
@msg `select` must be true or false `select` doit valoir true ou false
|
||||
@msg `minAnchors` and `maxAnchors` must be whole numbers `minAnchors` et `maxAnchors` doivent être des nombres entiers
|
||||
@msg `{key}` must be a number not below 0 `{key}` doit être un nombre supérieur ou égal à 0
|
||||
@@ -4254,6 +4256,7 @@ axis Both Les deux
|
||||
@msg select an object in perspective sélectionnez un objet en perspective
|
||||
@msg select artwork (or a colour group) to recolor sélectionnez l’illustration (ou un groupe de couleurs) dont redéfinir les couleurs
|
||||
@msg select at least two objects sélectionnez au moins deux objets
|
||||
@msg exit isolation mode first quittez d’abord le mode Isolation
|
||||
@msg select objects, not layers sélectionnez des objets, pas des calques
|
||||
@msg select one path, or give its `id` sélectionnez un tracé ou indiquez son `id`
|
||||
@msg select paths to join sélectionnez les tracés à joindre
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user