Merge main into feat/radial-blur-center

This commit is contained in:
dotCooCoo
2026-11-14 13:47:19 -08:00
166 changed files with 8846 additions and 1113 deletions
+8 -2
View File
@@ -871,13 +871,17 @@ fn remove_breaks(s: &mut Session, p: &Value) -> Result<Value> {
let named: std::collections::HashSet<NodeId> = ids.iter().copied().collect();
let threads = thread_index(d);
let mut done = std::collections::HashSet::new();
// The threads already found not to be a story whose frames are all among them: their
// other frames are read one at a time without going through the thread again.
let mut partial = std::collections::HashSet::new();
let mut out: Vec<(NodeId, Vec<(usize, bool)>)> = vec![];
for &id in &ids {
if !done.insert(id) {
continue;
}
let story = match threads.get(&id).and_then(|&i| d.text_threads.get(i)) {
Some(thread) => crate::cmd::threads::story_in(d, thread, id),
let thread = threads.get(&id).copied();
let story = match thread.filter(|i| !partial.contains(i)).and_then(|i| d.text_threads.get(i)) {
Some(frames) => crate::cmd::threads::story_in(d, frames, id),
None => vec![],
};
if !story.is_empty() && story.iter().all(|f| named.contains(f)) {
@@ -885,6 +889,8 @@ fn remove_breaks(s: &mut Session, p: &Value) -> Result<Value> {
// Every frame is among them, so each one keeps its breaks.
out.extend(story_cuts(d, &story, brk, true));
} else if let Some(t) = text_of(d, id) {
// `id` is type, so the story of its thread is the same for each of its frames.
partial.extend(thread);
out.push((id, break_cuts(&t.plain_text(), 0, usize::MAX, brk)));
}
}
@@ -379,6 +379,38 @@ fn remove_breaks_reads_the_threads_once() {
}
}
/// Remove Breaks reads each named frame of a story that is not all named on its own, in the order
/// named and each once, as one undo step, and goes through that story's frames once for all of them.
#[test]
fn remove_breaks_goes_through_a_partly_named_story_once() {
for (kind, brk) in [("line", '\u{2028}'), ("paragraph", '\n')] {
let mut s = session();
let a = text(&mut s, json!({"x": 10, "y": 20, "text": "a\u{2028}b\n".repeat(40), "size": 12, "area": {"width": 150, "height": 60}}));
let mut roots = vec![a];
for i in 0..4 {
let r = s.execute("shape.rectangle", &json!({"x": 10, "y": 100 + 70 * i, "width": 150, "height": 60})).unwrap();
roots.push(NodeId(r["id"].as_u64().unwrap()));
}
select(&mut s, &roots);
s.execute("text.thread.create", &json!({})).unwrap();
let f = s.doc().unwrap().doc.text_threads[0].clone();
assert_eq!(f.len(), 5);
let named = [f[4], f[1], f[3]];
let breaks: Vec<usize> = named.iter().map(|id| obj(&s, *id).plain_text().matches(brk).count()).collect();
assert!(breaks.iter().all(|n| *n > 0), "{kind}: each named frame holds breaks: {breaks:?}");
let before = doc_json(&s);
let steps = undo_steps(&s);
let builds = crate::cmd::threads::story_builds();
let r = s.execute("addon.cleanup.removeBreaks", &json!({"kind": kind, "ids": [f[4].0, f[1].0, f[4].0, f[3].0]})).unwrap();
assert_eq!(crate::cmd::threads::story_builds() - builds, 1, "{kind}: the story's frames are gone through once");
assert_eq!(r["removed"].as_u64(), Some(breaks.iter().sum::<usize>() as u64), "{kind}");
assert_eq!(ids(&r), named, "{kind}");
assert_eq!(undo_steps(&s), steps + 1, "{kind}");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(doc_json(&s), before, "{kind}");
}
}
/// Lock `id` (`locked`), else hide it, as an undo step of its own.
fn lock_or_hide(s: &mut Session, id: NodeId, locked: bool) {
s.edit("Lock or Hide", |d, _| {
+4 -1
View File
@@ -2,9 +2,11 @@
//! Bridges cuts bridges out of the selected art so that a stencil cut from it holds together
//! ([`stencil`]), and Weed Box and Lines adds a weed box and weeding lines around the selected art
//! for vinyl cutting ([`weed`]). Banner Hems and Grommets marks the hems and grommets of a banner
//! ([`banner`]). Its submenu (Object › Plug-ins › Cutting Tools) and dialogs are the UI's.
//! ([`banner`]). Nest Shapes packs the selected art onto a sheet or a roll of material ([`nest`]).
//! Its submenu (Object › Plug-ins › Cutting Tools) and dialogs are the UI's.
mod banner;
mod nest;
mod stencil;
mod weed;
@@ -14,5 +16,6 @@ pub fn specs() -> Vec<CommandSpec> {
let mut v = stencil::specs();
v.extend(weed::specs());
v.extend(banner::specs());
v.extend(nest::specs());
v
}
@@ -0,0 +1,403 @@
//! Nest Shapes (`addon.cutting.nest`), a command of the built-in Cutting Tools add-on that packs
//! the selected objects onto a sheet or a roll of material by their bounding boxes
//! (<https://en.wikipedia.org/wiki/Packing_problems>). The sheet is an
//! artboard, or a set size from the artboard's top-left corner; a roll is as wide as the sheet
//! and has no end. The objects are moved, and with `rotate` turned a quarter turn when that places
//! them better; they are never scaled or changed otherwise.
#[cfg(test)]
mod tests;
use std::collections::VecDeque;
use serde_json::{Value, json};
use vectorcraft_doc::{Document, Node, NodeId, NodeKind, Scaling};
use vectorcraft_geom::{Affine, Point, Rect, Vec2};
use vectorcraft_render::effects;
use super::super::super::edit::selected_roots_to_transform;
use super::super::super::object::{keep_compound_aligned, transform_node, transform_patterns};
use super::super::super::pathops::length_given;
use super::super::super::{CommandSpec, bad, check_switches, cmd, has_selection, switch_or};
use crate::{Result, Session};
/// Nest Shapes' command.
pub const NEST: &str = "addon.cutting.nest";
/// The largest sheet width or height, in points.
const MAX_SIZE: f64 = 1_000_000.0;
/// The largest margin or spacing, in points.
const MAX_GAP: f64 = 100_000.0;
/// The most objects one run nests.
const MAX_SHAPES: usize = 10_000;
/// How far a box may reach past the sheet's edge and still fit (rounding).
const EPS: f64 = 1e-9;
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
"addon.cutting.nest",
"Nest Shapes…",
["Object", "Plug-ins", "Cutting Tools"],
None,
"{roll?: bool (false: a sheet `width` × `height`; true: a roll `width` wide with no end), width?: pt 0..1000000 (0: the artboard's width), height?: pt 0..1000000 (0: the artboard's height; not used with roll), margin?: pt 0..100000 (18: kept clear inside each edge of the sheet; on a roll, the sides and the start), spacing?: pt 0..100000 (9: kept between two objects), rotate?: bool (false: objects keep their orientation; true: an object may turn 90° clockwise where that puts its bottom edge higher, or where only the turned object fits), usePreviewBounds?: bool (true: objects are measured with their strokes and effects, the reach of shadows, glows and blurs included; false: by their geometry), artboard?: index (0-based; the sheet's artboard; default: the artboard under the center of the selection, else the first), patterns?: bool (Transform Patterns: pattern fills and strokes move with the art; default: prefs transformPatternTiles)} move the selected objects onto the sheet or roll, which starts at the artboard's top-left corner, packed by their bounding boxes: the tallest first (with rotate, the longest side first), each where its bottom edge is highest and then furthest left, without resizing them. An object on the pixel grid moves by whole points and takes 1 pt more room across and down; a path of a pixel-aligned compound path is not turned. Objects that do not fit on the sheet go in a column to the right of it, `spacing` from its edge and from each other, from its top, in stacking order (back to front). Objects without bounds (an empty group) are not moved. One undo step; nothing changes when no object moves → {placed: count, unplaced: [ids of the objects put beside the sheet], rotated: [ids], skipped: [ids of the objects without bounds], length: pt (from the sheet's top edge to the bottom margin below the lowest object placed)}",
has_selection,
nest
)]
}
/// The length `p` gives for `key`, from 0 to `max`, or `default`.
fn length(p: &Value, key: &str, default: f64, max: f64) -> Result<f64> {
match length_given(p, key, NEST)? {
None => Ok(default),
Some(v) if (0.0..=max).contains(&v) => Ok(v),
Some(_) => Err(bad(NEST, format!("`{key}` must be from 0 to {max} pt"))),
}
}
/// One stretch of a skyline: from `x`, `w` wide, filled down to `y`.
#[derive(Clone, Copy, Debug, PartialEq)]
struct Span {
x: f64,
w: f64,
y: f64,
}
/// Bottom-left packing of boxes on a skyline: a strip `width` wide and `height` deep
/// (`f64::INFINITY` for a roll), whose filled part is the spans' outline along its top.
struct Skyline {
spans: Vec<Span>,
width: f64,
height: f64,
}
impl Skyline {
fn new(width: f64, height: f64) -> Self {
Skyline { spans: vec![Span { x: 0.0, w: width, y: 0.0 }], width, height }
}
/// Where a box `w` × `h` goes: at the left edge of a span, as high as the spans under it
/// allow, choosing the place where its bottom edge is highest and then furthest left. `None`
/// when it fits nowhere. The spans under the box at each span are a window whose two ends only
/// move right, and `window` holds the indices in it whose depth no later span in it reaches, so
/// its front is the deepest: one call takes time in proportion to the number of spans.
fn find(&self, w: f64, h: f64) -> Option<(f64, f64)> {
let mut best: Option<(f64, f64)> = None;
let mut window: VecDeque<usize> = VecDeque::new();
let mut end = 0;
for (i, s) in self.spans.iter().enumerate() {
if s.x + w > self.width + EPS {
break;
}
while window.front().is_some_and(|&j| j < i) {
window.pop_front();
}
end = end.max(i);
while let Some(t) = self.spans.get(end)
&& (end == i || t.x < s.x + w - EPS)
{
while window.back().and_then(|&j| self.spans.get(j)).is_some_and(|b| b.y <= t.y) {
window.pop_back();
}
window.push_back(end);
end += 1;
}
let y = window.front().and_then(|&j| self.spans.get(j)).map_or(0.0, |t| t.y.max(0.0));
if y + h > self.height + EPS {
continue;
}
// The box is `h` tall at every place, so the place with the highest bottom edge is the
// place with the highest top edge.
if best.is_none_or(|(bx, by)| y < by - EPS || ((y - by).abs() <= EPS && s.x < bx)) {
best = Some((s.x, y));
}
}
best
}
/// Fills the skyline down to `y + h` from `x` across `w`.
fn place(&mut self, x: f64, y: f64, w: f64, h: f64) {
if w <= 0.0 {
return;
}
let end = x + w;
let mut out = Vec::with_capacity(self.spans.len() + 2);
let mut added = false;
for s in &self.spans {
let s_end = s.x + s.w;
if s_end <= x || s.x >= end {
out.push(*s);
continue;
}
if s.x < x {
out.push(Span { x: s.x, w: x - s.x, y: s.y });
}
if !added {
out.push(Span { x, w, y: y + h });
added = true;
}
if s_end > end {
out.push(Span { x: end, w: s_end - end, y: s.y });
}
}
// Neighbors filled to the same depth are one span.
let mut merged: Vec<Span> = Vec::with_capacity(out.len());
for s in out {
match merged.last_mut() {
Some(l) if l.y == s.y => l.w = s.x + s.w - l.x,
_ => merged.push(s),
}
}
self.spans = merged;
}
}
/// One box to pack: its width and height as it stands, and when it may turn, as it is turned.
type Sizes = ((f64, f64), Option<(f64, f64)>);
/// The side a box is ordered by: its height, or its longest side when it may turn.
fn lead(b: &Sizes) -> (f64, f64) {
let ((w, h), turned) = *b;
if turned.is_some() { (w.max(h), w.min(h)) } else { (h, w) }
}
/// Packs `boxes` onto a strip `width` wide and `height` deep, the tallest first (a box that may
/// turn by its longest side; then the widest, then in their order), each where [`Skyline::find`]
/// puts it, turned when that puts its bottom edge higher (then further left), or when only the
/// turned box fits. Gives each box's top-left corner and whether it is turned, or `None` for a box
/// that does not fit.
fn pack(boxes: &[Sizes], width: f64, height: f64) -> Vec<Option<(f64, f64, bool)>> {
let mut order: Vec<usize> = (0..boxes.len()).collect();
order.sort_by(|&a, &b| {
let (la, lb) = (lead(&boxes[a]), lead(&boxes[b]));
lb.0.total_cmp(&la.0).then(lb.1.total_cmp(&la.1))
});
let mut sky = Skyline::new(width, height);
let mut at = vec![None; boxes.len()];
for i in order {
let Some(&((w, h), turned)) = boxes.get(i) else { continue };
let mut best = sky.find(w, h).map(|(x, y)| (x, y, false, w, h));
if let Some((tw, th)) = turned
&& let Some((x, y)) = sky.find(tw, th)
{
let better = best.is_none_or(|(bx, by, _, _, bh)| {
let (was, now) = (by + bh, y + th);
now < was - EPS || ((now - was).abs() <= EPS && x < bx - EPS)
});
if better {
best = Some((x, y, true, tw, th));
}
}
if let Some((x, y, t, bw, bh)) = best {
sky.place(x, y, bw, bh);
if let Some(slot) = at.get_mut(i) {
*slot = Some((x, y, t));
}
}
}
at
}
/// A quarter turn clockwise on the page about `c`.
fn quarter_turn(c: Point) -> Affine {
Affine::translate(c.to_vec2()) * vectorcraft_geom::rotate_deg(90.0) * Affine::translate(-c.to_vec2())
}
fn finite(r: &Rect) -> bool {
[r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite())
}
/// The box `id` is packed by: with `preview`, its visual bounds with geometry effects
/// ([`effects::preview::bounds_of`]) and the reach of its raster effects, such as shadows, glows
/// and blurs ([`vectorcraft_render::drawn_bounds`]); else its geometric bounds.
fn measure(d: &vectorcraft_doc::Document, id: NodeId, preview: bool) -> Option<Rect> {
let b = effects::preview::bounds_of(d, &[id], preview).filter(finite)?;
if !preview {
return Some(b);
}
match d.node(id).and_then(|n| vectorcraft_render::drawn_bounds(d, n)).filter(finite) {
Some(r) => Some(b.union(r)),
None => Some(b),
}
}
/// Whether `id` is a path of a pixel-aligned compound path.
fn in_aligned_compound(d: &Document, id: NodeId) -> bool {
d.parent_of(id).and_then(|p| d.node(p)).is_some_and(|n| matches!(n.kind, NodeKind::Compound { .. }) && n.extras().pixel_aligned)
}
/// Whether moving `id` snaps it, or an object in it, to the pixel grid: it, an object it holds or
/// the art of a linked opacity mask in it is pixel-aligned, or it is a path of a pixel-aligned
/// compound path.
fn snaps(d: &Document, id: NodeId) -> bool {
if in_aligned_compound(d, id) {
return true;
}
let mut stack: Vec<&Node> = d.node(id).into_iter().collect();
while let Some(n) = stack.pop() {
if n.extras().pixel_aligned {
return true;
}
stack.extend(n.children().map_or(&[][..], Vec::as_slice).iter().map(|c| &**c));
if let Some(m) = n.extras().mask.as_ref()
&& m.linked
{
stack.push(&*m.art);
}
}
false
}
fn nest(s: &mut Session, p: &Value) -> Result<Value> {
check_switches(p, &["patterns"], NEST)?;
let roll = switch_or(p, "roll", false, NEST)?;
let width = length(p, "width", 0.0, MAX_SIZE)?;
let height = length(p, "height", 0.0, MAX_SIZE)?;
let margin = length(p, "margin", 18.0, MAX_GAP)?;
let spacing = length(p, "spacing", 9.0, MAX_GAP)?;
let turn = switch_or(p, "rotate", false, NEST)?;
let preview = switch_or(p, "usePreviewBounds", true, NEST)?;
let named = match p.get("artboard") {
None | Some(Value::Null) => None,
Some(v) => Some(v.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad(NEST, "`artboard` must be an artboard's index"))?),
};
// A path of a compound path selected on its own moves without the rest of the compound path.
let ids = selected_roots_to_transform(s)?;
if ids.len() > MAX_SHAPES {
return Err(bad(NEST, format!("select at most {MAX_SHAPES} objects to nest")));
}
// Objects without a finite box (an empty group, empty type) are left where they are and
// listed as `skipped`.
let mut skipped = vec![];
let (items, board): (Vec<(NodeId, Rect)>, Rect) = {
let d = &s.doc()?.doc;
let mut items: Vec<(NodeId, Rect)> = Vec::with_capacity(ids.len());
for id in &ids {
match measure(d, *id, preview) {
Some(r) => items.push((*id, r)),
None => skipped.push(id.0),
}
}
let board = match named {
Some(i) => d.artboards.get(i).ok_or_else(|| bad(NEST, format!("there is no artboard {i}")))?.rect,
None => {
let all = items.iter().map(|(_, r)| *r).reduce(|a, b| a.union(b));
let i = all.and_then(|r| d.artboard_at(r.center())).unwrap_or(0);
d.artboards.get(i).ok_or_else(|| bad(NEST, "there is no artboard for the sheet"))?.rect
}
};
(items, board)
};
if items.is_empty() {
return Err(bad(NEST, "the selection has nothing to nest"));
}
let sheet_w = if width > 0.0 { width } else { board.width() };
let sheet_h = if roll {
f64::INFINITY
} else if height > 0.0 {
height
} else {
board.height()
};
// Every box is `spacing` wider and taller, and the strip is `spacing` wider and deeper than
// the room inside the margins, so the spacing falls between boxes and not at the far edges.
let room_w = sheet_w - 2.0 * margin;
let room_h = if roll { f64::INFINITY } else { sheet_h - 2.0 * margin };
if room_w <= 0.0 || room_h <= 0.0 {
return Err(bad(NEST, "the margin leaves no room on the sheet"));
}
let all: Vec<NodeId> = items.iter().map(|(id, _)| *id).collect();
let grid: Vec<bool> = {
let d = &s.doc()?.doc;
all.iter().map(|id| snaps(d, *id)).collect()
};
let sc = Scaling { patterns: transform_patterns(s, p, &all)?, ..Scaling::default() };
// With `rotate`, each object is turned in a copy of the document and measured there, so the
// box packed for a turned object is the box its turned strokes and effects take. A path of a
// pixel-aligned compound path keeps its orientation: turning it snaps the whole compound path
// again, which would move the compound's other paths away from where they were measured.
let turned: Vec<Option<(Affine, Rect)>> = if turn {
let mut t = vectorcraft_doc::Document::clone(&s.doc()?.doc);
let xfs: Vec<Option<Affine>> = items.iter().map(|(id, r)| (!in_aligned_compound(&t, *id)).then(|| quarter_turn(r.center()))).collect();
for ((id, _), xf) in items.iter().zip(&xfs) {
if let (Some(xf), Some(n)) = (xf, t.node_mut(*id)) {
transform_node(n, *xf, sc, true);
}
}
items.iter().zip(xfs).map(|((id, _), xf)| xf.and_then(|xf| measure(&t, *id, preview).map(|r| (xf, r)))).collect()
} else {
vec![None; items.len()]
};
// An object that snaps to the pixel grid moves by whole points, so it stays on the grid and the
// snap after the move does not shift it; its box is 1 pt wider and taller to hold the rounding.
let slack = |g: bool| if g { 1.0 } else { 0.0 };
let boxes: Vec<Sizes> = items
.iter()
.zip(&turned)
.zip(&grid)
.map(|(((_, r), t), g)| {
let pad = spacing + slack(*g);
((r.width() + pad, r.height() + pad), t.map(|(_, q)| (q.width() + pad, q.height() + pad)))
})
.collect();
let at = pack(&boxes, room_w + spacing, room_h + spacing);
let origin = Vec2::new(board.x0 + margin, board.y0 + margin);
// The move that puts the top-left corner of box `b` at `to`: rounded up to whole points for an
// object on the pixel grid, so it moves at most 1 pt further right and down, into its slack.
let shift = |b: &Rect, to: Vec2, g: bool| {
let v = Vec2::new(to.x - b.x0, to.y - b.y0);
if g { Vec2::new(v.x.ceil(), v.y.ceil()) } else { v }
};
// Each move: the quarter turn first, when the object turns, then the shift.
let mut moves: Vec<(NodeId, Option<Affine>, Vec2)> = vec![];
let mut aside: Vec<(NodeId, Option<Affine>, Vec2)> = vec![];
let mut unplaced = vec![];
let mut rotated = vec![];
let mut bottom = 0.0_f64;
// Objects that do not fit go in a column to the right of the sheet, `spacing` from its edge
// and from each other, so that no object placed on the sheet covers them.
let aside_x = board.x0 + sheet_w + spacing;
let mut aside_y = board.y0;
for ((((id, r), spot), t), g) in items.iter().zip(&at).zip(&turned).zip(&grid) {
match (spot, t) {
(Some((x, y, true)), Some((xf, q))) => {
let dv = shift(q, Vec2::new(origin.x + x, origin.y + y), *g);
moves.push((*id, Some(*xf), dv));
rotated.push(id.0);
bottom = bottom.max(q.y1 + dv.y - origin.y);
}
(Some((x, y, _)), _) => {
let dv = shift(r, Vec2::new(origin.x + x, origin.y + y), *g);
moves.push((*id, None, dv));
bottom = bottom.max(r.y1 + dv.y - origin.y);
}
(None, _) => {
aside.push((*id, None, shift(r, Vec2::new(aside_x, aside_y), *g)));
aside_y += r.height() + spacing + slack(*g);
unplaced.push(id.0);
}
}
}
if moves.is_empty() {
return Err(bad(NEST, "no selected object fits on the sheet"));
}
moves.extend(aside);
let length = 2.0 * margin + bottom;
let placed = moves.len() - unplaced.len();
let result = json!({ "placed": placed, "unplaced": unplaced, "rotated": rotated, "skipped": skipped, "length": length });
if moves.iter().all(|(_, xf, dv)| xf.is_none() && dv.x.abs() <= EPS && dv.y.abs() <= EPS) {
return Ok(result);
}
s.edit("Nest Shapes", |d, _| {
for (id, xf, dv) in &moves {
// The turn is the one the object was measured with in the copy, and the shift after it
// is whole points for an object on the pixel grid.
for step in xf.iter().copied().chain([Affine::translate(*dv)]) {
if let Some(n) = d.node_mut(*id) {
transform_node(n, step, sc, true);
}
keep_compound_aligned(d, *id, sc);
}
}
Ok(())
})?;
Ok(result)
}
@@ -0,0 +1,433 @@
//! Tests for `addon.cutting.nest`: packing the selected objects onto a sheet or a roll.
use serde_json::{Value, json};
use vectorcraft_geom::Rect;
use super::{Skyline, pack};
use crate::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
s
}
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
NodeId(s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap()["id"].as_u64().unwrap())
}
fn bounds(s: &Session, id: NodeId) -> Rect {
s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap()
}
fn select(s: &mut Session, ids: &[NodeId]) {
s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
}
fn steps(s: &Session) -> usize {
s.doc().unwrap().history.undo.len()
}
fn overlap(a: Rect, b: Rect) -> bool {
a.x0 < b.x1 - 1e-9 && b.x0 < a.x1 - 1e-9 && a.y0 < b.y1 - 1e-9 && b.y0 < a.y1 - 1e-9
}
#[test]
fn boxes_pack_tallest_first_from_the_top_left() {
// 100 wide: the 60 × 50 box goes first at the left and the 40 × 30 one beside it. The 50 × 20
// one is too wide for the 40 pt beside the first box, so it goes below the first box.
let at = pack(&fixed(&[(40.0, 30.0), (60.0, 50.0), (50.0, 20.0)]), 100.0, f64::INFINITY);
assert_eq!(at, vec![Some((60.0, 0.0, false)), Some((0.0, 0.0, false)), Some((0.0, 50.0, false))]);
}
#[test]
fn a_box_that_does_not_fit_is_left_out() {
let at = pack(&fixed(&[(80.0, 80.0), (80.0, 80.0), (120.0, 10.0)]), 100.0, 100.0);
assert_eq!(at, vec![Some((0.0, 0.0, false)), None, None]);
}
/// Boxes that keep their orientation.
fn fixed(b: &[(f64, f64)]) -> Vec<super::Sizes> {
b.iter().map(|&s| (s, None)).collect()
}
/// Boxes that may turn a quarter.
fn turning(b: &[(f64, f64)]) -> Vec<super::Sizes> {
b.iter().map(|&(w, h)| ((w, h), Some((h, w)))).collect()
}
#[test]
fn a_box_turns_where_its_bottom_edge_ends_higher_or_only_turned_it_fits() {
// A 20 × 90 box lies down as 90 × 20 at the top; a 120 × 10 box only fits the 100 pt strip
// turned, as 10 × 120.
let at = pack(&turning(&[(20.0, 90.0), (120.0, 10.0)]), 100.0, f64::INFINITY);
assert_eq!(at, vec![Some((10.0, 0.0, true)), Some((0.0, 0.0, true))]);
// Without turning, the 120 pt wide box is left out and the other stands up.
let at = pack(&fixed(&[(20.0, 90.0), (120.0, 10.0)]), 100.0, f64::INFINITY);
assert_eq!(at, vec![Some((0.0, 0.0, false)), None]);
// A square box is never turned: turned, it is no better.
let at = pack(&turning(&[(30.0, 30.0)]), 100.0, 100.0);
assert_eq!(at, vec![Some((0.0, 0.0, false))]);
}
#[test]
fn the_skyline_fills_and_merges_spans() {
let mut sky = Skyline::new(100.0, f64::INFINITY);
sky.place(0.0, 0.0, 30.0, 10.0);
sky.place(30.0, 0.0, 30.0, 10.0);
assert_eq!(sky.spans.len(), 2, "two spans at the same depth are one: {:?}", sky.spans);
assert_eq!(sky.find(50.0, 5.0), Some((0.0, 10.0)), "50 wide does not fit in the 40 left at the top");
assert_eq!(sky.find(40.0, 5.0), Some((60.0, 0.0)));
}
#[test]
fn selected_objects_are_nested_on_the_artboard_as_one_undo_step() {
let mut s = session();
let ids: Vec<NodeId> = (0..6).map(|i| rect(&mut s, 900.0 + 300.0 * i as f64, 700.0, 150.0, 100.0 + 10.0 * i as f64)).collect();
select(&mut s, &ids);
let n = steps(&s);
let r = s.execute("addon.cutting.nest", &json!({"margin": 10, "spacing": 5, "usePreviewBounds": false})).unwrap();
assert_eq!(steps(&s), n + 1, "one undo step");
assert_eq!(r["placed"], json!(6));
assert_eq!(r["unplaced"], json!([]));
let boxes: Vec<Rect> = ids.iter().map(|id| bounds(&s, *id)).collect();
for (i, a) in boxes.iter().enumerate() {
assert!(a.x0 >= 10.0 - 1e-9 && a.y0 >= 10.0 - 1e-9 && a.x1 <= 790.0 + 1e-9 && a.y1 <= 590.0 + 1e-9, "{a:?} inside the margins");
assert!((a.width(), a.height()) == (150.0, 100.0 + 10.0 * i as f64), "moved, not resized");
for b in &boxes[i + 1..] {
assert!(!overlap(a.inflate(2.5, 2.5), b.inflate(2.5, 2.5)), "{a:?} and {b:?} are 5 pt apart");
}
}
// The tallest (150 pt) is at the top-left corner inside the margin, and five 150 pt wide
// boxes with 5 pt between them fill the 780 pt inside the margins. The shortest goes 5 pt below
// the 110 pt tall box at the right, where its bottom edge is highest.
assert_eq!((boxes[5].x0, boxes[5].y0), (10.0, 10.0));
assert_eq!(boxes.iter().filter(|b| b.y0 == 10.0).count(), 5);
assert_eq!((boxes[0].x0, boxes[0].y0), (630.0, 125.0));
assert_eq!(r["length"], json!(10.0 + 115.0 + 100.0 + 10.0));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(bounds(&s, ids[0]), Rect::new(900.0, 700.0, 1050.0, 800.0));
}
#[test]
fn objects_that_do_not_fit_go_beside_the_sheet() {
let mut s = session();
let a = rect(&mut s, 900.0, 0.0, 500.0, 400.0);
let b = rect(&mut s, 1500.0, 0.0, 500.0, 400.0);
let big = rect(&mut s, 2100.0, 0.0, 900.0, 100.0);
select(&mut s, &[a, b, big]);
let r = s.execute("addon.cutting.nest", &json!({"margin": 0, "spacing": 5, "usePreviewBounds": false})).unwrap();
assert_eq!(r["placed"], json!(1));
assert_eq!(r["unplaced"], json!([b.0, big.0]));
assert_eq!(bounds(&s, a), Rect::new(0.0, 0.0, 500.0, 400.0));
// A column 5 pt to the right of the 800 pt sheet, in stacking order, 5 pt apart.
assert_eq!(bounds(&s, b), Rect::new(805.0, 0.0, 1305.0, 400.0));
assert_eq!(bounds(&s, big), Rect::new(805.0, 405.0, 1705.0, 505.0));
}
#[test]
fn the_column_beside_the_sheet_is_in_stacking_order() {
let mut s = session();
let back = rect(&mut s, 900.0, 0.0, 900.0, 100.0);
let front = rect(&mut s, 900.0, 200.0, 900.0, 50.0);
let small = rect(&mut s, 900.0, 400.0, 50.0, 50.0);
// Selected front first: the column still starts with the object at the back.
select(&mut s, &[front, small, back]);
let r = s.execute("addon.cutting.nest", &json!({"margin": 0, "spacing": 5, "usePreviewBounds": false})).unwrap();
assert_eq!(r["unplaced"], json!([back.0, front.0]));
assert_eq!(bounds(&s, back), Rect::new(805.0, 0.0, 1705.0, 100.0));
assert_eq!(bounds(&s, front), Rect::new(805.0, 105.0, 1705.0, 155.0));
}
#[test]
fn objects_without_bounds_are_skipped_and_listed() {
let mut s = session();
let a = rect(&mut s, 900.0, 0.0, 50.0, 50.0);
let layer = s.doc().unwrap().doc.parent_of(a);
let empty = s
.edit("empty group", |d, _| {
let g = vectorcraft_doc::Node::new(d.alloc_id(), vectorcraft_doc::NodeKind::Group { children: vec![], clip: false });
Ok(d.insert(layer, usize::MAX, g).unwrap())
})
.unwrap();
select(&mut s, &[a, empty]);
let r = s.execute("addon.cutting.nest", &json!({"margin": 0, "usePreviewBounds": false})).unwrap();
assert_eq!((r["placed"].clone(), r["unplaced"].clone(), r["skipped"].clone()), (json!(1), json!([]), json!([empty.0])));
assert_eq!(bounds(&s, a), Rect::new(0.0, 0.0, 50.0, 50.0));
}
#[test]
fn the_sheet_is_the_artboard_under_the_center_of_the_selection() {
let mut s = session();
s.execute("artboard.new", &json!({"x": 1000, "y": 0, "width": 300, "height": 300})).unwrap();
let a = rect(&mut s, 1100.0, 100.0, 50.0, 50.0);
let b = rect(&mut s, 1200.0, 200.0, 40.0, 40.0);
select(&mut s, &[a, b]);
s.execute("addon.cutting.nest", &json!({"margin": 0, "spacing": 0, "usePreviewBounds": false})).unwrap();
assert_eq!(bounds(&s, a), Rect::new(1000.0, 0.0, 1050.0, 50.0), "on the second artboard, not the first");
assert_eq!(bounds(&s, b), Rect::new(1050.0, 0.0, 1090.0, 40.0));
}
#[test]
fn rotate_turns_clockwise() {
let mut s = session();
// A 400 × 100 group with a 20 pt mark at its top-left corner. Turned clockwise on the page, the
// top-left corner goes to the top-right.
let long = rect(&mut s, 900.0, 0.0, 400.0, 100.0);
let mark = rect(&mut s, 900.0, 0.0, 20.0, 20.0);
select(&mut s, &[long, mark]);
s.execute("object.group", &json!({})).unwrap();
let r = s
.execute("addon.cutting.nest", &json!({"width": 300, "height": 500, "margin": 0, "spacing": 0, "rotate": true, "usePreviewBounds": false}))
.unwrap();
assert_eq!(r["rotated"].as_array().map(Vec::len), Some(1));
assert_eq!(bounds(&s, long), Rect::new(0.0, 0.0, 100.0, 400.0));
assert_eq!(bounds(&s, mark), Rect::new(80.0, 0.0, 100.0, 20.0), "the mark is at the top-right");
}
#[test]
fn a_path_of_a_pixel_aligned_compound_path_is_not_turned() {
let mut s = session();
let outer = rect(&mut s, 900.0, 100.0, 500.0, 200.0);
let hole = rect(&mut s, 950.0, 150.0, 400.0, 100.0);
select(&mut s, &[outer, hole]);
s.execute("object.compoundPath.make", &json!({})).unwrap();
s.execute("object.setPixelAligned", &json!({})).unwrap();
select(&mut s, &[hole]);
// The 400 pt wide path fits the 300 pt sheet only turned, and it keeps its orientation.
let r =
s.execute("addon.cutting.nest", &json!({"width": 300, "height": 500, "margin": 0, "spacing": 0, "rotate": true, "usePreviewBounds": false}));
let e = r.unwrap_err().to_string();
assert!(e.contains("no selected object fits"), "{e}");
}
/// [`Skyline::find`] as a search of every span and every span under the box.
fn find_by_search(sky: &Skyline, w: f64, h: f64) -> Option<(f64, f64)> {
let mut best: Option<(f64, f64)> = None;
for (i, s) in sky.spans.iter().enumerate() {
if s.x + w > sky.width + 1e-9 {
break;
}
let y = sky.spans[i..].iter().take_while(|t| t.x == s.x || t.x < s.x + w - 1e-9).fold(0.0_f64, |m, t| m.max(t.y));
if y + h > sky.height + 1e-9 {
continue;
}
if best.is_none_or(|(bx, by)| y < by - 1e-9 || ((y - by).abs() <= 1e-9 && s.x < bx)) {
best = Some((s.x, y));
}
}
best
}
#[test]
fn find_matches_a_search_of_every_span() {
// A fixed pseudo-random sequence of boxes, each placed where `find` puts it.
let mut seed: u64 = 0x2545_f491_4f6c_dd1d;
let mut next = |n: u64| {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
(seed % n) as f64 + 1.0
};
let mut sky = Skyline::new(200.0, 400.0);
for _ in 0..400 {
let (w, h) = (next(60), next(30));
let got = sky.find(w, h);
assert_eq!(got, find_by_search(&sky, w, h), "{w} × {h} on {:?}", sky.spans);
if let Some((x, y)) = got {
sky.place(x, y, w, h);
}
}
}
#[test]
fn an_object_that_does_not_fit_is_not_covered_by_the_others() {
let mut s = session();
// The wide object sits on the sheet and does not fit; the small one must not land on it.
let wide = rect(&mut s, 0.0, 0.0, 900.0, 100.0);
let small = rect(&mut s, 300.0, 300.0, 50.0, 50.0);
select(&mut s, &[wide, small]);
s.execute("addon.cutting.nest", &json!({"margin": 0, "spacing": 0, "usePreviewBounds": false})).unwrap();
assert!(!overlap(bounds(&s, wide), bounds(&s, small)), "{:?} {:?}", bounds(&s, wide), bounds(&s, small));
}
#[test]
fn objects_on_the_pixel_grid_keep_the_margin_and_spacing() {
let mut s = session();
let a = rect(&mut s, 900.0, 0.0, 100.0, 100.0);
let b = rect(&mut s, 1100.0, 0.0, 100.0, 90.0);
select(&mut s, &[a, b]);
s.execute("object.setPixelAligned", &json!({})).unwrap();
let on_grid = |r: Rect| [r.x0, r.y0, r.x1, r.y1].iter().all(|v| (v * 2.0).fract() == 0.0);
assert!(on_grid(bounds(&s, a)) && on_grid(bounds(&s, b)));
s.execute("addon.cutting.nest", &json!({"margin": 0.6, "spacing": 0.6, "usePreviewBounds": false})).unwrap();
let (ba, bb) = (bounds(&s, a), bounds(&s, b));
assert!(on_grid(ba) && on_grid(bb), "still on the grid: {ba:?} {bb:?}");
assert!(ba.x0 >= 0.6 && ba.y0 >= 0.6 && bb.y0 >= 0.6, "inside the margin: {ba:?} {bb:?}");
assert!(bb.x0 >= ba.x1 + 0.6, "0.6 pt apart: {ba:?} {bb:?}");
assert!(ba.x1.max(bb.x1) <= 800.0 - 0.6, "inside the margin: {ba:?} {bb:?}");
}
#[test]
fn preview_bounds_take_in_a_drop_shadow() {
let mut s = session();
let a = rect(&mut s, 900.0, 0.0, 100.0, 100.0);
let b = rect(&mut s, 1100.0, 0.0, 100.0, 100.0);
s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [a.0], "params": {"x": 20, "y": 0, "blur": 0}})).unwrap();
select(&mut s, &[a, b]);
s.execute("addon.cutting.nest", &json!({"margin": 0, "spacing": 0})).unwrap();
let (ba, bb) = (bounds(&s, a), bounds(&s, b));
let shadow = vectorcraft_render::drawn_bounds(&s.doc().unwrap().doc, s.doc().unwrap().doc.node(a).unwrap()).unwrap();
assert!(shadow.x1 >= ba.x1 + 20.0 - 1e-9, "the shadow reaches 20 pt right: {shadow:?}");
let shadow_b = vectorcraft_render::drawn_bounds(&s.doc().unwrap().doc, s.doc().unwrap().doc.node(b).unwrap()).unwrap_or(bb);
assert!(!overlap(shadow, shadow_b.union(bb)), "the shadow {shadow:?} does not reach {bb:?}");
}
#[test]
fn a_roll_has_no_end() {
let mut s = session();
let ids: Vec<NodeId> = (0..5).map(|i| rect(&mut s, 900.0, 300.0 * i as f64, 500.0, 400.0)).collect();
select(&mut s, &ids);
let r = s.execute("addon.cutting.nest", &json!({"roll": true, "margin": 0, "spacing": 10, "usePreviewBounds": false})).unwrap();
assert_eq!((r["placed"].clone(), r["unplaced"].clone()), (json!(5), json!([])));
let mut tops: Vec<f64> = ids.iter().map(|id| bounds(&s, *id).y0).collect();
tops.sort_by(f64::total_cmp);
assert_eq!(tops, vec![0.0, 410.0, 820.0, 1230.0, 1640.0], "one 500 pt wide shape per row on the 800 pt roll");
assert_eq!(r["length"], json!(2040.0));
}
#[test]
fn width_and_height_set_the_sheet_from_the_artboard_corner() {
let mut s = session();
let ids: Vec<NodeId> = (0..3).map(|i| rect(&mut s, 50.0 * i as f64, 300.0, 40.0, 40.0)).collect();
select(&mut s, &ids);
let r = s.execute("addon.cutting.nest", &json!({"width": "1 in", "height": 200, "margin": 0, "spacing": 0, "usePreviewBounds": false})).unwrap();
assert_eq!(r["placed"], json!(3));
for id in &ids {
let b = bounds(&s, *id);
assert!(b.x0 == 0.0 && b.x1 <= 72.0, "one 40 pt shape per row on a 72 pt sheet: {b:?}");
}
}
#[test]
fn preview_bounds_keep_strokes_apart() {
let mut s = session();
let a = rect(&mut s, 900.0, 0.0, 100.0, 100.0);
let b = rect(&mut s, 1100.0, 0.0, 100.0, 100.0);
select(&mut s, &[a, b]);
s.execute("stroke.set", &json!({"weight": 10})).unwrap();
s.execute("addon.cutting.nest", &json!({"margin": 0, "spacing": 0})).unwrap();
let (ba, bb) = (bounds(&s, a), bounds(&s, b));
assert_eq!((ba.x0, ba.y0), (5.0, 5.0), "the stroke's outer half is inside the sheet");
assert_eq!((bb.x0 - ba.x1), 10.0, "the strokes touch and do not overlap");
}
#[test]
fn a_selection_already_nested_adds_no_undo_step() {
let mut s = session();
let a = rect(&mut s, 18.0, 18.0, 100.0, 100.0);
select(&mut s, &[a]);
let n = steps(&s);
let r = s.execute("addon.cutting.nest", &json!({"usePreviewBounds": false})).unwrap();
assert_eq!(r["placed"], json!(1));
assert_eq!(steps(&s), n, "nothing moved");
}
#[test]
fn bad_params_are_refused_and_change_nothing() {
let mut s = session();
let a = rect(&mut s, 900.0, 0.0, 100.0, 100.0);
select(&mut s, &[a]);
let n = steps(&s);
let bad: [(Value, &str); 10] = [
(json!({"rotate": 1}), "`rotate` must be true or false"),
(json!({"width": -1}), "`width` must be from 0"),
(json!({"height": 2e6}), "`height` must be from 0"),
(json!({"margin": "x"}), "`margin` must be a number"),
(json!({"spacing": 1e6}), "`spacing` must be from 0"),
(json!({"roll": "yes"}), "`roll` must be true or false"),
(json!({"usePreviewBounds": 1}), "`usePreviewBounds` must be true or false"),
(json!({"artboard": 3}), "there is no artboard 3"),
(json!({"artboard": -1}), "`artboard` must be"),
(json!({"margin": 400}), "the margin leaves no room"),
];
for (p, want) in bad {
let e = s.execute("addon.cutting.nest", &p).expect_err(&p.to_string()).to_string();
assert!(e.contains(want), "{p}: {e}");
}
let e = s.execute("addon.cutting.nest", &json!({"width": 50, "margin": 0})).unwrap_err().to_string();
assert!(e.contains("no selected object fits"), "{e}");
let e = s.execute("addon.cutting.nest", &json!({"patterns": "no"})).unwrap_err().to_string();
assert!(e.contains("`patterns` must be true or false"), "{e}");
assert_eq!(steps(&s), n, "a refused run changes nothing");
assert_eq!(bounds(&s, a), Rect::new(900.0, 0.0, 1000.0, 100.0));
}
#[test]
fn the_named_artboard_is_the_sheet() {
let mut s = session();
s.execute("artboard.new", &json!({"x": 1000, "y": 0, "width": 300, "height": 300})).unwrap();
let a = rect(&mut s, 100.0, 100.0, 50.0, 50.0);
select(&mut s, &[a]);
s.execute("addon.cutting.nest", &json!({"artboard": 1, "margin": 0, "usePreviewBounds": false})).unwrap();
assert_eq!(bounds(&s, a), Rect::new(1000.0, 0.0, 1050.0, 50.0));
}
#[test]
fn rotate_turns_objects_a_quarter_to_fit_the_sheet() {
let mut s = session();
// On a 300 × 500 sheet with no margin, a 400 × 100 object only fits standing up.
let long = rect(&mut s, 900.0, 0.0, 400.0, 100.0);
let small = rect(&mut s, 900.0, 300.0, 50.0, 40.0);
select(&mut s, &[long, small]);
let p = json!({"width": 300, "height": 500, "margin": 0, "spacing": 0, "usePreviewBounds": false});
let r = s.execute("addon.cutting.nest", &p).unwrap();
assert_eq!((r["unplaced"].clone(), r["rotated"].clone()), (json!([long.0]), json!([])), "without rotate it does not fit");
s.execute("edit.undo", &json!({})).unwrap();
let mut p = p;
p["rotate"] = json!(true);
let n = steps(&s);
let r = s.execute("addon.cutting.nest", &p).unwrap();
assert_eq!(steps(&s), n + 1, "one undo step");
assert_eq!((r["placed"].clone(), r["unplaced"].clone(), r["rotated"].clone()), (json!(2), json!([]), json!([long.0])));
assert_eq!(bounds(&s, long), Rect::new(0.0, 0.0, 100.0, 400.0), "turned and at the corner");
// The 50 × 40 object stays as it is: turned, its bottom edge would be lower.
assert_eq!(bounds(&s, small), Rect::new(100.0, 0.0, 150.0, 40.0));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(bounds(&s, long), Rect::new(900.0, 0.0, 1300.0, 100.0), "undo turns it back");
}
#[test]
fn rotate_measures_the_turned_preview_bounds() {
let mut s = session();
let a = rect(&mut s, 900.0, 0.0, 400.0, 100.0);
select(&mut s, &[a]);
s.execute("stroke.set", &json!({"weight": 10})).unwrap();
let r = s.execute("addon.cutting.nest", &json!({"width": 300, "height": 500, "margin": 0, "spacing": 0, "rotate": true})).unwrap();
assert_eq!(r["rotated"], json!([a.0]));
assert_eq!(bounds(&s, a), Rect::new(5.0, 5.0, 105.0, 405.0), "the turned stroke's outer half is inside the sheet");
}
#[test]
fn a_compound_paths_path_selected_alone_moves_alone() {
let mut s = session();
let outer = rect(&mut s, 900.0, 100.0, 40.0, 40.0);
let hole = rect(&mut s, 910.0, 110.0, 10.0, 10.0);
select(&mut s, &[outer, hole]);
s.execute("object.compoundPath.make", &json!({})).unwrap();
select(&mut s, &[hole]);
s.execute("addon.cutting.nest", &json!({"margin": 0, "usePreviewBounds": false})).unwrap();
assert_eq!(bounds(&s, hole), Rect::new(0.0, 0.0, 10.0, 10.0), "the path moved");
assert_eq!(bounds(&s, outer), Rect::new(900.0, 100.0, 940.0, 140.0), "the rest of the compound path stayed");
}
#[test]
fn a_group_is_nested_as_one_object() {
let mut s = session();
let a = rect(&mut s, 900.0, 0.0, 50.0, 50.0);
let b = rect(&mut s, 1000.0, 100.0, 50.0, 50.0);
select(&mut s, &[a, b]);
s.execute("object.group", &json!({})).unwrap();
s.execute("addon.cutting.nest", &json!({"margin": 0, "usePreviewBounds": false})).unwrap();
assert_eq!((bounds(&s, a), bounds(&s, b)), (Rect::new(0.0, 0.0, 50.0, 50.0), Rect::new(100.0, 100.0, 150.0, 150.0)));
}
+5 -11
View File
@@ -6,8 +6,8 @@ use vectorcraft_doc::Scaling;
use vectorcraft_geom::{Rect, Vec2};
use super::object::choice;
use super::panelcmds::{artboard_index, artboards_art, locked_and_hidden, named_or_selected, note_artboards};
use super::{Affine, CommandSpec, NodeId, Result, Session, bad, check_switches, cmd, has_doc, switch_or};
use super::panelcmds::{artboard_at, artboards_art, locked_and_hidden, named_or_selected, note_artboards, on_board};
use super::{Affine, CommandSpec, NodeId, Result, Session, bad, check_switches, cmd, has_doc, index, switch_or};
use crate::EngineError;
pub fn specs() -> Vec<CommandSpec> {
@@ -63,13 +63,7 @@ fn boards(s: &mut Session, p: &Value, cmd: &str) -> Result<Boards> {
check_switches(p, &["patterns"], cmd)?;
let to_artboard = choice(p, cmd, "to", &["selection", "artboard"])? == Some("artboard");
let indices = named_or_selected(s, p, cmd)?;
let given = |key: &str| -> Result<Option<usize>> {
match p.get(key).filter(|v| !v.is_null()) {
Some(v) => artboard_index(&s.doc()?.doc, &json!({ "index": v }), cmd).map(Some),
None => Ok(None),
}
};
let (named, recorded) = (given("artboard")?, given("activeArtboard")?);
let (named, recorded) = (artboard_at(&s.doc()?.doc, p, "artboard", cmd)?, artboard_at(&s.doc()?.doc, p, "activeArtboard", cmd)?);
let move_art = switch_or(p, "moveArt", s.tool_options_of("artboard")["moveArt"].as_bool().unwrap_or(true), cmd)?;
let locked_art = locked_and_hidden(s, p, move_art, cmd)?;
note_artboards(s, p, &indices);
@@ -78,8 +72,8 @@ fn boards(s: &mut Session, p: &Value, cmd: &str) -> Result<Boards> {
(false, _) => None,
(true, Some(i)) => Some(i),
(true, None) => {
let active = s.tool_options_of("artboard")["active"].clone();
Some(artboard_index(&s.doc()?.doc, &json!({ "index": active }), cmd)?)
let active = s.tool_options_of("artboard")["active"].as_u64().map(index);
Some(active.map_or_else(|| Err(EngineError::Other("no such artboard".into())), |i| on_board(&s.doc()?.doc, i))?)
}
};
if let Some(i) = reference.filter(|_| named.is_none()) {
+20 -4
View File
@@ -89,7 +89,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Asset Export Settings",
[],
None,
"{exportPreset?: (a preset of document.exportPresets.list: its formats or preset, its format settings over the saved ones, its prefix (none when it has none), its folders (or subfolders: rows or none) with subfolders on for rows, and antiAlias; the other keys go over it), formats?, preset?: mobile|density|\"\" (none), settings?, prefix?, subfolders?, antiAlias?} change the export settings the Asset Export panel and Export for Screens share (document.exportSettings; the values of document.exportForScreens; null removes one): formats replace a preset, a preset replaces the formats → {settings}. Not an undo step",
"{exportPreset?: (a preset of document.exportPresets.list: its formats or preset, its format settings over the saved ones, its prefix (none when it has none), its folders (or subfolders: rows or none) with subfolders on for rows, and antiAlias; the other keys go over it), formats?, preset?: mobile|density|\"\" (none), settings?, prefix?, subfolders?: true|false (Create Sub-folders: true sorts the files into a folder per row; false puts assets.export's files in the folder itself and, given without exportPreset, sets a remembered folder per format or item to none as well), antiAlias?, openLocation?: true|false (Open Location after Export: remembered in document.exportSettings, not in presets or the reply; given alone to a document without settings, Create Sub-folders stays on)} change the export settings the Asset Export panel and Export for Screens share (document.exportSettings; the values of document.exportForScreens; null removes one): formats replace a preset, a preset replaces the formats → {settings}. Not an undo step",
has_doc,
settings_set
),
@@ -435,19 +435,35 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
fn settings_set(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "assets.settings.set";
let o = p.as_object().ok_or_else(|| bad(C, "give the settings to change, e.g. {\"formats\": [{\"format\": \"png\", \"scale\": \"2x\"}]}"))?;
if let Some(k) = o.keys().find(|k| !SCREEN_SETTINGS_KEYS.contains(&k.as_str()) && k.as_str() != "exportPreset") {
return Err(bad(C, format!("`{k}` isn't an export setting: {}", SCREEN_SETTINGS_KEYS.join(", "))));
if let Some(k) = o.keys().find(|k| !SCREEN_SETTINGS_KEYS.contains(&k.as_str()) && !matches!(k.as_str(), "exportPreset" | "openLocation")) {
return Err(bad(C, format!("`{k}` isn't an export setting: {}, openLocation", SCREEN_SETTINGS_KEYS.join(", "))));
}
for k in ["subfolders", "openLocation"] {
if o.get(k).is_some_and(|v| !v.is_boolean() && !v.is_null()) {
return Err(bad(C, format!("`{k}` is true or false")));
}
}
let mut settings = s.doc()?.doc.export_settings.clone();
let mut given = o.clone();
// Create Sub-folders is on for a document without settings (Export for Screens opens so), and
// Open Location after Export set on its own leaves it on.
if settings.is_empty() && o.len() == 1 && o.get("openLocation").is_some_and(Value::is_boolean) {
given.insert("subfolders".into(), json!(true));
}
if let Some(name) = given.remove("exportPreset") {
let name = name.as_str().ok_or_else(|| bad(C, "`exportPreset` is the name of a preset of document.exportPresets.list"))?;
with_screen_export_preset(s, C, &mut settings, name)?;
}
// Create Sub-folders is Export for Screens' folder per row: the dialog opens on the choice.
// Turned off on its own (the panel menu), it also ends a remembered folder per format or item;
// with an export preset, that preset's folder per format or item stays.
let folders = settings.get("folders").and_then(Value::as_str).map(str::to_string);
let sorted = folders.as_deref().is_some_and(|f| f != "none");
match given.get("subfolders").and_then(Value::as_bool) {
Some(true) => _ = settings.insert("folders".into(), json!("rows")),
Some(false) if settings.get("folders").and_then(Value::as_str) == Some("rows") => _ = settings.insert("folders".into(), json!("none")),
Some(false) if folders.as_deref() == Some("rows") || (sorted && !o.contains_key("exportPreset")) => {
_ = settings.insert("folders".into(), json!("none"))
}
_ => {}
}
overlay(&mut settings, &given);
+1 -1
View File
@@ -135,7 +135,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Live Paint Info",
[],
None,
"{group, id?: object id (default: the selection)} → {faces: [{id, fill, bounds}], edges: [{id, stroke, width}], sources, gapOptions: {detect, size: \"small\"|\"medium\"|\"large\"|pt, points, color: #rrggbb} (as livePaint.gapOptions sets them), gaps: [[x0,y0,x1,y1]…] (as livePaint.gapOptions returns them)}",
"{group?|id?: Live Paint group id (one required; id is read when group is left out)} → {faces: [{id, fill (top fill), bounds, appearance: [row…]}], edges: [{id, stroke (top stroke), width (top stroke's), appearance: [row…]}], where a row is {index (in the part's fill and stroke stack, bottom first), kind: \"fill\"|\"stroke\", paint, opacity: 0..1, visible, width?: pt (strokes only), drawn (the part is shown with opacity above 0, and the row is shown, has paint and opacity above 0, a stroke also width above 0)}, sources, gapOptions: {detect, size: \"small\"|\"medium\"|\"large\"|pt, points, color: #rrggbb} (as livePaint.gapOptions sets them), gaps: [[x0,y0,x1,y1]…] (as livePaint.gapOptions returns them)}",
has_doc,
lp_info
),
+49 -5
View File
@@ -1,7 +1,8 @@
use super::{
Appearance, Arc, Beside, Document, EDGE_NAME, EngineError, FACE_NAME, GapOptions, GapSize, LIVE_PAINT_NAME, Node, NodeId, PathData, Point, Rect,
Result, SOURCES_NAME, Selection, Session, SubPath, Value, b, bad, build_children, edge_near, face_at, finite, ids_json, is_live_paint, json,
number_or, object_param, paint_from, po, point_param, repaint_from, replace_roots, root_ids, source_nodes, source_shapes, switch_or,
Appearance, AppearanceItem, Arc, Beside, Document, EDGE_NAME, EngineError, FACE_NAME, GapOptions, GapSize, LIVE_PAINT_NAME, Node, NodeId,
PathData, Point, Rect, Result, SOURCES_NAME, Selection, Session, SubPath, Value, b, bad, build_children, edge_near, face_at, finite, ids_json,
is_live_paint, json, number_or, object_param, paint_from, po, point_param, repaint_from, replace_roots, root_ids, source_nodes, source_shapes,
switch_or,
};
/// `options` as stored on a Live Paint group: none for the defaults.
@@ -524,6 +525,37 @@ pub(super) fn lp_stroke_edge(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "group": g.0, "edge": edge.0 }))
}
/// Every fill and stroke row of face or edge `n`, bottom first, as `livePaint.info` lists them. A
/// row is `drawn` when the part is shown with an opacity above 0 and the row is shown, has paint
/// and has an opacity above 0 (a stroke also a weight above 0): the rule of [`draws`], row by row.
fn appearance_rows(n: &Node) -> Vec<Value> {
let part_shown = n.visible && n.opacity > 0.0;
n.appearance
.items
.iter()
.enumerate()
.map(|(i, item)| {
let shown = part_shown && item.visible() && !item.paint().is_none() && item.opacity() > 0.0;
let mut row = json!({
"index": i,
"kind": item.kind_name(),
"paint": item.paint().label(),
"opacity": item.opacity(),
"visible": item.visible(),
});
let drawn = match item {
AppearanceItem::Fill(_) => shown,
AppearanceItem::Stroke(st) => {
row["width"] = json!(st.width);
shown && st.width > 0.0
}
};
row["drawn"] = json!(drawn);
row
})
.collect()
}
pub(super) fn lp_info(s: &mut Session, p: &Value) -> Result<Value> {
let g = match object_param(p, "group", "livePaint.info")? {
Some(g) => g,
@@ -535,12 +567,24 @@ pub(super) fn lp_info(s: &mut Session, p: &Value) -> Result<Value> {
.iter()
.map(|f| {
let bb = f.geometric_bounds().unwrap_or(Rect::ZERO);
json!({ "id": f.id.0, "fill": f.appearance.fill_paint().label(), "bounds": [bb.x0, bb.y0, bb.x1, bb.y1] })
json!({
"id": f.id.0,
"fill": f.appearance.fill_paint().label(),
"bounds": [bb.x0, bb.y0, bb.x1, bb.y1],
"appearance": appearance_rows(f),
})
})
.collect();
let edges: Vec<Value> = b::edges(n)
.iter()
.map(|e| json!({ "id": e.id.0, "stroke": e.appearance.stroke_paint().label(), "width": e.appearance.stroke_width() }))
.map(|e| {
json!({
"id": e.id.0,
"stroke": e.appearance.stroke_paint().label(),
"width": e.appearance.stroke_width(),
"appearance": appearance_rows(e),
})
})
.collect();
let sources = b::sources(n).and_then(|g| g.children()).map(|c| c.len()).unwrap_or(0);
let options = n.gap_options();
+9 -2
View File
@@ -686,8 +686,15 @@ fn map_graph_colors(g: &mut vectorcraft_doc::GraphSpec, f: &mut dyn FnMut(&Color
}
}
let styles = &mut g.label_styles;
for st in [&mut styles.values, &mut styles.categories, &mut styles.legend].into_iter().flatten() {
map_paint_colors(&mut st.fill, f);
// A legend fill of the labels' own stays theirs when `f` makes it black. The default black legend fill stays black,
// so the labels on dark pie wedges are still drawn white.
styles.mark_own_legend_fill();
let own = styles.legend_fill_is_own();
for (st, fill) in [(&mut styles.values, true), (&mut styles.categories, true), (&mut styles.legend, own)] {
let Some(st) = st else { continue };
if fill {
map_paint_colors(&mut st.fill, f);
}
map_paint_colors(&mut st.stroke, f);
}
for r in [&mut styles.values_rows, &mut styles.categories_rows, &mut styles.legend_rows].into_iter().flatten() {
+14 -5
View File
@@ -671,10 +671,11 @@ fn a_document_replaced_in_the_back_is_measured_before_it_comes_to_the_front() {
}
/// Type a document replaced in the back sets in a composite font that only the front document
/// defines is measured in that font while the front one is registered, and again, as missing, when
/// the replaced document comes to the front.
/// defines is measured as missing, though the front one is registered, also when the replaced
/// document defines no composite font, and still as missing when it comes to the front.
#[test]
fn a_document_replaced_in_the_back_measures_a_font_it_lacks_again_in_front() {
fn a_document_replaced_in_the_back_measures_a_font_it_lacks_as_missing() {
let db = vectorcraft_text::FontDb::global();
let mut s = session();
let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab12", "size": 20, "font": "CF Lacked"})).unwrap()["id"].as_u64().unwrap();
let bytes = vectorcraft_format::save_file(&s.doc().unwrap().doc);
@@ -682,8 +683,16 @@ fn a_document_replaced_in_the_back_measures_a_font_it_lacks_again_in_front() {
s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Lacked")]})).unwrap();
assert!(s.replace_document(0, vectorcraft_format::load(&bytes).unwrap()));
let back = text_in(&s, 0, id);
let expected = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &back).bounds;
assert_eq!(back.cached_bounds, Some(expected));
assert!(s.documents()[0].doc.composite_fonts.is_empty());
let in_front = vectorcraft_text::layout(db, &back).bounds;
// The same type set in a family nothing defines.
let mut nowhere = back.clone();
for r in &mut nowhere.runs {
r.style.font_family = "No Such Family CF Lacked".into();
}
let missing = vectorcraft_text::layout(db, &nowhere).bounds;
assert!((in_front.width() - missing.width()).abs() > 1e-6, "the front document's font draws otherwise");
assert_eq!(back.cached_bounds, Some(missing));
assert!(s.set_active(0));
assert!(vectorcraft_text::FontDb::global().composite_font("CF Lacked").is_none());
let actual = text(&s, id);
+1 -1
View File
@@ -110,7 +110,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Smooth",
[],
None,
"{points: [[x,y]…], radius?: pt (12), fidelity?: pt (2.5; kept within 0.000078125 and about 3195, the Smooth tool's 0.05 to 100 px at the highest and the lowest zoom: the tool sends its Fidelity in screen pixels divided by the zoom)} smooth selected paths near the drag → {ids, before, after}",
"{points: [[x,y]…], radius?: pt (12), fidelity?: pt (2.5; kept within 0.000078125 and about 3195, the Smooth tool's 0.05 to 100 px at the highest and the lowest zoom: the tool sends its Fidelity in screen pixels divided by the zoom)} smooth selected paths near the drag, keeping a corner where the path turns by more than 57.5° at the anchor and over radius and twice radius on either side → {ids, before, after}",
has_selection,
smooth_region
),
+388 -9
View File
@@ -13,9 +13,237 @@ use super::{
/// of path within the reach on either side of it. VectorCraft's own value.
const SMOOTH_SPREAD: f64 = 1.0 / 3.0;
/// The degrees by which a path must turn at an anchor, and over the tool's reach and twice its reach
/// on either side of it, for the Smooth tool to keep the anchor as a corner ([`smooth_corners`]): a
/// turn of more than 57.5°, an angle inside the corner of less than 122.5°. VectorCraft's own value,
/// the same on every drag and at any Fidelity.
const SMOOTH_CORNER_DEG: f64 = 57.5;
/// Most pieces a curved segment is cut into evenly, and most more pieces near each of its ends, to
/// measure lengths along it in [`smooth_corners`].
const CORNER_PIECES: usize = 64;
/// The control polygon's length of `c`, which is at least its length.
fn polygon_len(c: CubicBez) -> f64 {
c.p0.distance(c.p1) + c.p1.distance(c.p2) + c.p2.distance(c.p3)
}
/// The places (0 to 1, in order, without 0 and with 1) on the curved segment `c` that
/// [`smooth_corners`] measures it at: up to [`CORNER_PIECES`] evenly spaced, and near each end, over
/// a stretch at least three times `reach` long where the segment is that long, up to as many more
/// that are no more than `reach` / 8 apart along the control polygon, and as many again in each
/// stretch twice as long after it until the pieces reach three times `reach`. The turns over `reach` and
/// twice `reach` from an anchor are then measured along the curve however long the segment is.
fn corner_places(c: CubicBez, reach: f64) -> Vec<f64> {
let piece = reach / 8.0;
let pieces = |len: f64| if piece > 0.0 { (len / piece).ceil().clamp(1.0, CORNER_PIECES as f64) as usize } else { 1 };
let k = pieces(polygon_len(c)).max(4);
let mut places: Vec<f64> = (1..=k).map(|j| j as f64 / k as f64).collect();
if piece > 0.0 {
for from_start in [true, false] {
// The stretch from the end: halved until its control polygon is at most six reaches long.
let first = 1.0 / k as f64;
let mut t = first;
let part = |t: f64| if from_start { c.subsegment(0.0..t) } else { c.subsegment(1.0 - t..1.0) };
for _ in 0..60 {
if polygon_len(part(t)) <= 6.0 * reach {
break;
}
t /= 2.0;
}
let side = |u: f64| if from_start { u } else { 1.0 - u };
let m = pieces(polygon_len(part(t)));
let mut fine: Vec<f64> = (1..=m).map(|j| t * j as f64 / m as f64).collect();
// Then the stretches twice as long in turn, cut as finely, up to the first even place, until
// the pieces reach three reaches from the end, so twice the reach is measured along the curve
// where a handle that does not reach out makes the curve start more slowly than its polygon.
let mut covered = 0.0;
let mut at = c.eval(side(0.0));
for u in &fine {
let q = c.eval(side(*u));
covered += at.distance(q);
at = q;
}
let (mut from, mut levels) = (t, 0);
while covered < 3.0 * reach && from < first && levels < 60 {
let to = (2.0 * from).min(first);
let piece_len = polygon_len(if from_start { c.subsegment(from..to) } else { c.subsegment(1.0 - to..1.0 - from) });
let m = pieces(piece_len);
for j in 1..=m {
let u = from + (to - from) * j as f64 / m as f64;
let q = c.eval(side(u));
covered += at.distance(q);
at = q;
fine.push(u);
}
(from, levels) = (to, levels + 1);
}
places.extend(fine.into_iter().map(side));
}
}
places.retain(|u| *u > 0.0 && *u <= 1.0);
places.sort_by(f64::total_cmp);
places.dedup();
places
}
/// The degrees between the directions `u` and `v`, `None` when either has no length or is not a
/// number.
fn turn_between(u: kurbo::Vec2, v: kurbo::Vec2) -> Option<f64> {
let (lu, lv) = (u.hypot(), v.hypot());
if !(lu.is_finite() && lv.is_finite() && lu > 1e-12 && lv > 1e-12) {
return None;
}
let deg = u.cross(v).atan2(u.dot(v)).abs().to_degrees();
deg.is_finite().then_some(deg)
}
/// Which anchors of `anchors` (a closed path when `closed`) the Smooth tool keeps as corners: those
/// where the path turns by more than [`SMOOTH_CORNER_DEG`] at the anchor itself, between the
/// direction it arrives along and the one it leaves along, and also over `reach` and over twice
/// `reach` on either side of it, between the direction from the point that far back along the path
/// to the anchor and the direction from the anchor to the point that far ahead. The points are taken
/// no farther than the ends of an open path, and no more than an eighth of the way round a closed
/// one. A jag or a bump smaller than the reach turns sharply at its anchor but not over twice the
/// reach, and an anchor in the middle of a tight curve turns over the reach but not at the anchor, so
/// neither is kept. An open path's first and last anchors are never corners.
fn smooth_corners(anchors: &[Anchor], closed: bool, reach: f64) -> Vec<bool> {
let n = anchors.len();
let mut corners = vec![false; n];
if n < 2 || (n < 3 && !closed) {
return corners;
}
// The distances on either side the turn is measured over: one reach and two, no more than an
// eighth of the way round a closed path. Over that much of a circle the path turns by 45°, so a
// loop shorter than the reach is not all corners, while a square's or a triangle's corners still are.
let mut spans = [reach, 2.0 * reach];
if closed {
let mut total = 0.0;
for i in 0..n {
total += walk(anchors, closed, (i, true), [f64::INFINITY; 2], reach, 1).1;
}
if !(total.is_finite() && total > 0.0) {
return corners;
}
spans = spans.map(|r| r.min(total / 8.0));
}
for (i, flag) in corners.iter_mut().enumerate() {
if !closed && (i == 0 || i == n - 1) {
continue;
}
let (prev, a, next) = (anchors[(i + n - 1) % n], anchors[i], anchors[(i + 1) % n]);
let arrive = [a.p - a.h_in, a.p - prev.h_out, a.p - prev.p].into_iter().find(|d| d.hypot() > 1e-12).unwrap_or_default();
let leave = [a.h_out - a.p, next.h_in - a.p, next.p - a.p].into_iter().find(|d| d.hypot() > 1e-12).unwrap_or_default();
if !turn_between(arrive, leave).is_some_and(|t| t > SMOOTH_CORNER_DEG) {
continue;
}
let [Some(back_near), Some(back_far)] = walk(anchors, closed, (i, false), spans, reach, MAX_CORNER_WALK).0 else { continue };
let [Some(ahead_near), Some(ahead_far)] = walk(anchors, closed, (i, true), spans, reach, MAX_CORNER_WALK).0 else { continue };
*flag = [(back_near, ahead_near, spans[0]), (back_far, ahead_far, spans[1])]
.into_iter()
.all(|(back, ahead, r)| r <= 0.0 || turn_between(a.p - back, ahead - a.p).is_some_and(|t| t > SMOOTH_CORNER_DEG));
}
corners
}
/// Most segments [`walk`] passes from an anchor before it stops, which bounds the work for each
/// anchor on a path of very many very short segments.
const MAX_CORNER_WALK: usize = 256;
/// A walk along the path `anchors` from anchor `from.0`, ahead when `from.1` and back otherwise,
/// past no more than `segments` segments, measured along the lines between each curved segment's
/// points at [`corner_places`]. It gives the points at the distances `to` (in order, the nearer
/// first), an open path's end for one beyond the end, and `None` for one it did not reach within
/// `segments` segments, and the length it walked. Both points are `None` when it meets a length that
/// is not a number or is too large to add, and the length is then infinite.
fn walk(anchors: &[Anchor], closed: bool, from: (usize, bool), to: [f64; 2], reach: f64, segments: usize) -> ([Option<Point>; 2], f64) {
let (n, forward) = (anchors.len(), from.1);
let Some(mut here) = anchors.get(from.0).map(|a| a.p) else { return ([None; 2], 0.0) };
let (mut at, mut gone) = (from.0, 0.0);
let mut found: [Option<Point>; 2] = [None; 2];
let mut at_end = false;
'segments: for _ in 0..segments {
let next = match (forward, closed) {
(true, _) if at + 1 < n => at + 1,
(true, true) => 0,
(false, _) if at > 0 => at - 1,
(false, true) => n - 1,
_ => {
at_end = true;
break;
}
};
let (a, b) = (anchors[at], anchors[next]);
let c = if forward { CubicBez::new(a.p, a.h_out, b.h_in, b.p) } else { CubicBez::new(a.p, a.h_in, b.h_out, b.p) };
let places = if c.p1 == c.p0 && c.p2 == c.p3 { vec![1.0] } else { corner_places(c, reach) };
for u in places {
let q = if u >= 1.0 { c.p3 } else { c.eval(u) };
let d = here.distance(q);
if !(d.is_finite() && (gone + d).is_finite()) {
return ([None; 2], f64::INFINITY);
}
for (slot, &dist) in found.iter_mut().zip(&to) {
if slot.is_none() && gone + d >= dist {
let t = if d > 0.0 { ((dist - gone) / d).clamp(0.0, 1.0) } else { 1.0 };
*slot = Some(here.lerp(q, t));
}
}
gone += d;
here = q;
if found.iter().all(Option::is_some) {
break 'segments;
}
}
at = next;
}
// An open path that ends after the last segment walked ends there too.
if !closed && at == if forward { n - 1 } else { 0 } {
at_end = true;
}
(found.map(|p| p.or(at_end.then_some(here))), gone)
}
/// Whether `a` and `b` are the same anchors, to within a rounding error.
fn same_anchors(a: &[Anchor], b: &[Anchor]) -> bool {
let near = |p: Point, q: Point| p.distance(q) <= 1e-9;
a.len() == b.len() && a.iter().zip(b).all(|(x, y)| near(x.p, y.p) && near(x.h_in, y.h_in) && near(x.h_out, y.h_out))
}
/// [`refit_run`] of the open run `anchors` cut at its anchors marked in `corners`: each part between
/// two corners is smoothed and refitted on its own, so each corner keeps its place and the direction
/// the path arrives and leaves along. `free` applies to the run's first and last anchors only.
fn refit_split(anchors: &[Anchor], corners: &[bool], tol: f64, sigma: f64, free: [bool; 2]) -> Vec<Anchor> {
let last = anchors.len().saturating_sub(1);
let mut cuts: Vec<usize> = (1..last).filter(|&i| corners.get(i).copied().unwrap_or(false)).collect();
if cuts.is_empty() {
return refit_run(anchors, tol, sigma, free);
}
cuts.insert(0, 0);
cuts.push(last);
let mut out: Vec<Anchor> = Vec::with_capacity(anchors.len());
for w in cuts.windows(2) {
let (a, b) = (w[0], w[1]);
let part = refit_run(&anchors[a..=b], tol, sigma, [free[0] && a == 0, free[1] && b == last]);
match out.pop() {
// The corner ends one part and starts the next: it keeps its place, and takes the handle
// in from the part before and the handle out from the part after.
Some(end) => {
let mut rest = part.into_iter();
if let Some(start) = rest.next() {
out.push(Anchor::with_handles(anchors[a].p, end.h_in, start.h_out));
}
out.extend(rest);
}
None => out.extend(part),
}
}
out
}
/// The options the Smooth tool refits with: the path smoothed along its length by a Gaussian of
/// spread `sigma` (none at 0), then refitted within `tol` of that and within twice the distance the
/// smoothing moved it, keeping a corner only where the path turns back on itself. `free` says whether
/// smoothing moved it. The tool cuts the path at its corners first ([`smooth_corners`]), so within a
/// part the refit keeps a corner only where the path turns back on itself. `free` says whether
/// the start and the end of the stretch refitted are ends of an open path, whose direction then
/// follows the smoothed path within the reach rather than the first or last segment.
fn refit_options(tol: f64, sigma: f64, free: [bool; 2]) -> SimplifyOptions {
@@ -321,9 +549,31 @@ fn smooth_subpath(sp: &SubPath, pts: &[Point], radius: f64, share: usize, tol: f
if sp.closed {
let start = sp.anchors[0].p;
let Some(k) = marked.iter().position(|m| !m) else {
// Everything is in range: smooth the whole closed path, across its start too.
let f = po::simplify_with(&PathData::single(sp.clone()), &refit_options(tol, sigma, [false; 2]));
return f.subpaths.into_iter().next().map(|r| start_nearest(r, start, sp));
let corners = smooth_corners(&sp.anchors, true, radius);
let Some(c) = corners.iter().position(|c| *c) else {
// Everything is in range and nothing is a corner: smooth the whole closed path,
// across its start too.
let f = po::simplify_with(&PathData::single(sp.clone()), &refit_options(tol, sigma, [false; 2]));
return f.subpaths.into_iter().next().map(|r| start_nearest(r, start, sp));
};
// Cut the path open at a corner, which then ends the last part as well as starting the first.
let mut open = sp.anchors.clone();
open.rotate_left(c);
let mut cut = corners;
cut.rotate_left(c);
open.push(open[0]);
cut.push(true);
let mut res = refit_split(&open, &cut, tol, sigma, [false; 2]);
if same_anchors(&res, &open) {
return None;
}
let l = res.pop()?;
if let Some(r0) = res.first_mut() {
*r0 = Anchor::with_handles(r0.p, l.h_in, r0.h_out);
}
// The path starts where it did when the refit keeps an anchor there, and otherwise at its
// point nearest there.
return Some(start_nearest(SubPath::new(res, true), start, sp));
};
// Rotate so an untouched anchor comes first, duplicate it at the end and treat as open.
let mut open = sp.clone();
@@ -333,7 +583,10 @@ fn smooth_subpath(sp: &SubPath, pts: &[Point], radius: f64, share: usize, tol: f
open.anchors.push(open.anchors[0]);
m.push(false);
open.closed = false;
let mut res = smooth_open(&open.anchors, &m, near, tol, sigma, false);
let mut res = smooth_open(&open.anchors, &m, near, tol, sigma, radius, false);
if same_anchors(&res, &open.anchors) {
return None;
}
let l = res.pop()?;
if let Some(r0) = res.first_mut() {
r0.h_in = l.h_in;
@@ -342,7 +595,8 @@ fn smooth_subpath(sp: &SubPath, pts: &[Point], radius: f64, share: usize, tol: f
let res = SubPath::new(res, true);
return Some(if k == 0 { res } else { start_nearest(res, start, sp) });
}
Some(SubPath::new(smooth_open(&sp.anchors, &marked, near, tol, sigma, true), false))
let res = smooth_open(&sp.anchors, &marked, near, tol, sigma, radius, true);
(!same_anchors(&res, &sp.anchors)).then(|| SubPath::new(res, false))
}
/// The segment `c`, which ends in the drag's reach (`near`) and starts outside it, split where it
@@ -376,8 +630,10 @@ fn split_at_reach(c: CubicBez, near: &dyn Fn(Point) -> bool) -> Option<(CubicBez
/// drag's reach (`near`): a short drag smooths what is under it, and the rest of a long segment next
/// to it stays as it was (#1512). With `open`, `anchors` are an open path, and the curve at its first
/// or last anchor turns with the smoothing when a run's refit reaches that anchor; a run's other ends,
/// and those of the open run a closed path is cut into, keep their direction.
fn smooth_open(anchors: &[Anchor], marked: &[bool], near: &dyn Fn(Point) -> bool, tol: f64, sigma: f64, open: bool) -> Vec<Anchor> {
/// and those of the open run a closed path is cut into, keep their direction. Each run keeps its
/// corners ([`smooth_corners`] within `radius`, the tool's reach, measured within the run), and a run
/// whose refit changes nothing stays as it was.
fn smooth_open(anchors: &[Anchor], marked: &[bool], near: &dyn Fn(Point) -> bool, tol: f64, sigma: f64, radius: f64, open: bool) -> Vec<Anchor> {
let n = anchors.len();
let mut runs = vec![];
let mut i = 0;
@@ -418,7 +674,11 @@ fn smooth_open(anchors: &[Anchor], marked: &[bool], near: &dyn Fn(Point) -> bool
*outer = Anchor::with_handles(far.p3, far.p2, reach.p1);
}
let free = [open && lo == 0 && head.is_none(), open && hi == n - 1 && tail.is_none()];
let new = refit_run(&piece, tol, sigma, free);
let corners = smooth_corners(&piece, false, radius);
let new = refit_split(&piece, &corners, tol, sigma, free);
if same_anchors(&new, &piece) {
continue;
}
out.splice(lo..=hi, head.into_iter().chain(new).chain(tail));
}
out
@@ -816,4 +1076,123 @@ mod tests {
assert_eq!(drag_stretches(&one), vec![0]);
assert!((dist_to_stretches(&one, &[0], Point::new(13.0, 14.0)) - 5.0).abs() < 1e-12);
}
fn polyline(pts: &[(f64, f64)]) -> Vec<Anchor> {
pts.iter().map(|&(x, y)| Anchor::corner(Point::new(x, y))).collect()
}
/// Anchors on one point, anchors that are not numbers or are too far out to measure, and a
/// zigzag of 100,001 anchors 1 pt high give no corner and do not fail; a square's four anchors
/// are its corners, and an open path's ends never are.
#[test]
fn corners_are_found_only_where_the_path_turns_at_and_around_an_anchor() {
assert_eq!(smooth_corners(&polyline(&[(5.0, 5.0); 4]), false, 12.0), vec![false; 4]);
assert_eq!(smooth_corners(&polyline(&[(0.0, 0.0), (f64::NAN, 1.0), (10.0, 0.0)]), false, 12.0), vec![false; 3]);
assert_eq!(smooth_corners(&polyline(&[(0.0, 0.0), (1e308, 0.0), (1e308, 1e308), (-1e308, 0.0)]), true, 12.0), vec![false; 4]);
let zigzag: Vec<(f64, f64)> = (0..=100_000).map(|i| (f64::from(i), f64::from(i % 2))).collect();
assert!(!smooth_corners(&polyline(&zigzag), false, 12.0).contains(&true));
let square = polyline(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)]);
assert_eq!(smooth_corners(&square, true, 12.0), vec![true; 4]);
assert_eq!(smooth_corners(&square, false, 12.0), vec![false, true, true, false]);
assert_eq!(smooth_corners(&square, false, 0.0), vec![false, true, true, false], "with no reach the turn at the anchor decides");
assert_eq!(smooth_corners(&square[..2], false, 12.0), vec![false; 2]);
}
/// A 90° bump 400 pt high cut into 1 pt segments, between two long straight segments: with a reach
/// of 10 pt its top is a corner, and with a reach of 1,000 pt, where the walk passes more segments
/// than it measures before it gets that far, its top is not one, as the path turns by about 51°
/// over that reach.
#[test]
fn a_corner_is_not_kept_where_the_reach_is_not_measured() {
let side = 400.0 * std::f64::consts::SQRT_2;
let steps = side.ceil() as usize;
let mut pts = vec![(-3000.0, 0.0)];
pts.extend((0..steps).map(|j| {
let f = j as f64 / steps as f64;
(-400.0 + 400.0 * f, 400.0 * f)
}));
pts.extend((0..=steps).map(|j| {
let f = j as f64 / steps as f64;
(400.0 * f, 400.0 - 400.0 * f)
}));
pts.push((3000.0, 0.0));
let anchors = polyline(&pts);
let top = steps + 1;
assert_eq!(anchors[top].p, Point::new(0.0, 400.0));
assert!(smooth_corners(&anchors, false, 10.0)[top]);
assert!(!smooth_corners(&anchors, false, 1000.0)[top]);
}
/// A 60° corner between two curved segments 1,000 pt long is found at a reach of 2 pt as it is
/// when each segment is cut into a thousand pieces: the turns over the reach are measured along the
/// curve near the anchor, not along a chord much longer than the reach.
#[test]
fn a_corner_between_long_curves_is_measured_along_them() {
// The curves arrive along x and leave at 60° to it, each bending away from the other.
let d = kurbo::Vec2::from_angle(-60f64.to_radians());
let side = kurbo::Vec2::new(-d.y, d.x) * 1000.0;
let v = Point::new(0.0, 0.0);
let a = Anchor::with_handles(Point::new(-1000.0, 1000.0), Point::new(-1000.0, 1000.0), Point::new(-300.0, 1000.0));
let b = Anchor::with_handles(v, Point::new(-300.0, 0.0), v + d * 300.0);
let end = v + d * 1000.0 + side;
let c = Anchor::with_handles(end, v + d * 300.0 + side, end);
let long = [a, b, c];
let fine: Vec<Anchor> = {
let sp = SubPath::new(long.to_vec(), false);
let mut pts = vec![];
for i in 0..2 {
let s = sp.segment(i);
for j in 0..1000 {
let p = s.subsegment(f64::from(j) / 1000.0..f64::from(j + 1) / 1000.0);
if j == 0 && i == 0 {
pts.push(Anchor::with_handles(p.p0, p.p0, p.p1));
} else if let Some(last) = pts.last_mut() {
*last = Anchor::with_handles(last.p, last.h_in, p.p1);
}
pts.push(Anchor::with_handles(p.p3, p.p2, p.p3));
}
}
pts
};
let at_v = |anchors: &[Anchor]| {
let corners = smooth_corners(anchors, false, 2.0);
anchors.iter().zip(corners).find(|(x, _)| x.p == v).map(|(_, c)| c)
};
assert_eq!(at_v(&fine), Some(true), "cut finely, the anchor is a corner");
assert_eq!(at_v(&long), Some(true));
}
/// A line that meets a curve leaving with no handle, turning by 58° at the anchor and by about
/// 57.53° over twice a reach of 0.02 pt: the anchor is a corner, measured either way along the path,
/// as the stretch measured finely near the anchor covers twice the reach along the curve.
#[test]
fn a_corner_next_to_a_curve_without_a_handle_is_kept() {
let at = 58f64.to_radians();
let prev = Anchor::corner(Point::new(-100.0 * at.cos(), -100.0 * at.sin()));
let v = Point::new(0.0, 0.0);
let a = Anchor::with_handles(v, v, v);
let end = Point::new(600.0, 600.0);
let next = Anchor::with_handles(end, Point::new(200.0, 0.0), end);
assert_eq!(smooth_corners(&[prev, a, next], false, 0.02), vec![false, true, false]);
let back = |x: Anchor| Anchor::with_handles(x.p, x.h_out, x.h_in);
assert_eq!(smooth_corners(&[back(next), back(a), back(prev)], false, 0.02), vec![false, true, false]);
}
/// A jagged closed path whose length is less than twice the reach: its jags are not corners,
/// while a triangle that small keeps its three.
#[test]
fn a_small_jagged_loop_has_no_corners() {
let ring: Vec<(f64, f64)> = (0..16)
.map(|i| {
let (a, r) = (f64::from(i) / 16.0 * std::f64::consts::TAU, if i % 2 == 0 { 3.0 } else { 2.0 });
(r * a.cos(), r * a.sin())
})
.collect();
let jags = polyline(&ring);
let sp = SubPath::new(jags.clone(), true);
assert!(subpath_len(&sp) < 24.0);
assert!(smooth_corners(&jags, true, 1e-6).iter().all(|c| *c), "each jag turns sharply at its anchor");
assert_eq!(smooth_corners(&jags, true, 12.0), vec![false; 16]);
assert_eq!(smooth_corners(&polyline(&[(0.0, 0.0), (6.0, 0.0), (3.0, 5.196)]), true, 12.0), vec![true; 3]);
}
}
+23 -18
View File
@@ -808,11 +808,11 @@ fn new_document_from_state(s: &mut Session, p: &Value) -> Result<Value> {
let created = date_param(p, "created", C)?;
let st = s.doc()?;
// The row as the panel lists it now: keeping an edit in progress below changes the rows.
let chosen = match p.get("index") {
None | Some(Value::Null) => None,
Some(v) => {
let state = v.as_u64().and_then(|row| st.history.state(&st.doc, index(row)));
Some(state.ok_or_else(|| bad(C, format!("no history state {v}")))?.clone())
let chosen = match super::index_opt(p, "index", C)? {
None => None,
Some(row) => {
let v = &p["index"];
Some(st.history.state(&st.doc, row).ok_or_else(|| bad(C, format!("no history state {v}")))?.clone())
}
};
settle_interaction(s)?;
@@ -1276,23 +1276,28 @@ pub(crate) fn place_clip(s: &mut Session, clip: &Clipboard, center: vectorcraft_
/// The artboards Paste in Place, in Front or in Back puts a copy on, in artboard order: `artboard`
/// (the active one, #693), else `artboards`, else, while the Artboard tool is chosen with several
/// artboards selected, those (noted in the journal as `artboards`, so a replay pastes on the same
/// ones); `None` when none is named. A named index that is not an artboard of the document, a
/// repeated one or an empty list is an error.
/// ones); `None` when none is named. A named value that is not a whole number (null included), a
/// named index that is not an artboard of the document, a repeated one or an empty list is an
/// error; every value is checked for its form before any is checked against the artboards.
fn paste_targets(s: &mut Session, p: &Value, cmd: &str) -> Result<Option<Vec<usize>>> {
let n = s.doc()?.doc.artboards.len();
let one = |v: &Value| v.as_u64().and_then(|i| usize::try_from(i).ok()).filter(|i| *i < n);
let single = match p.get("artboard") {
Some(v) => Some(one(v).ok_or_else(|| bad(cmd, "`artboard` must be the index of an artboard of the document"))?),
None => None,
};
let listed = match p.get("artboards") {
let single = p.get("artboard").map(|v| v.as_u64().map(index).ok_or_else(|| bad(cmd, "`artboard` must be a whole number"))).transpose()?;
let entries = match p.get("artboards") {
Some(v) => {
let list = v.as_array().filter(|a| !a.is_empty()).ok_or_else(|| bad(cmd, "`artboards` must be a list of artboard indices"))?;
let mut all = list
.iter()
.map(one)
.collect::<Option<Vec<usize>>>()
.ok_or_else(|| bad(cmd, "`artboards` names an artboard the document does not have"))?;
let all = list.iter().map(|i| i.as_u64().map(index)).collect::<Option<Vec<usize>>>();
Some(all.ok_or_else(|| bad(cmd, "`artboards` must contain whole numbers"))?)
}
None => None,
};
if single.is_some_and(|i| i >= n) {
return Err(bad(cmd, "`artboard` must be the index of an artboard of the document"));
}
let listed = match entries {
Some(mut all) => {
if all.iter().any(|i| *i >= n) {
return Err(bad(cmd, "`artboards` names an artboard the document does not have"));
}
all.sort_unstable();
if all.windows(2).any(|w| w[0] == w[1]) {
return Err(bad(cmd, "`artboards` names an artboard twice"));
+4 -1
View File
@@ -66,7 +66,10 @@ use serde_json::{Value, json};
pub use action_batch::batch_overwrites_one_file;
pub use dxf::{UNSUPPORTED, Unsupported, unsupported};
pub use encode::{ARTBOARD_PARAMS, ArtboardPick, Encoded, Note, NoteKind, encode, encode_all, encode_with_notes, encode_with_warnings, note_texts};
pub use encode::{
ARTBOARD_PARAMS, ArtboardPick, Encoded, Note, NoteKind, encode, encode_all, encode_with_notes, encode_with_warnings, export_art_bounds,
note_texts,
};
pub(crate) use encode::{anti_alias, background, with_single_artboard};
pub(crate) use export::with_artboard_fills;
pub(crate) use export::{EXPORT_PARAMS, export_crop, isolated};
File diff suppressed because one or more lines are too long
+140 -5
View File
@@ -2,11 +2,13 @@
//! (unknown keys are ignored, so one params object can carry the options of several formats).
use std::borrow::Cow;
use std::collections::HashSet;
use std::sync::Arc;
use serde::Deserialize;
use serde::de::DeserializeOwned;
use serde_json::Value;
use vectorcraft_doc::{Artboard, Document};
use vectorcraft_doc::{Artboard, Document, Node, NodeKind};
use vectorcraft_geom::Rect;
use vectorcraft_render::AntiAlias;
use vectorcraft_render::encode::jpeg::{self, JpegOptions};
@@ -419,13 +421,15 @@ pub(super) fn use_artboards(cmd: &str, p: &Value) -> Result<Option<bool>> {
/// With `useArtboards: false` in `p`: `doc` without its opacity-mask or symbol editing layer and
/// with one artboard, the bounds of its visible art, and `p` without the artboard choice. `None`
/// when `useArtboards` is absent, null or true. A document with no visible art is refused.
pub(super) fn art_page(cmd: &str, doc: &Document, p: &Value) -> Result<Option<(Document, Value)>> {
/// when `useArtboards` is absent, null or true. The objects that `left_out` picks (layers or
/// objects the format leaves out, see [`hidden_nodes`]) are not measured, and a document with no
/// other visible art is refused.
pub(super) fn art_page(cmd: &str, doc: &Document, p: &Value, left_out: impl Fn(&Node) -> bool) -> Result<Option<(Document, Value)>> {
if use_artboards(cmd, p)? != Some(false) {
return Ok(None);
}
let doc = &*doc.without_edit_modes();
let bounds = vectorcraft_render::encode::art_bounds(doc).ok_or_else(|| bad(cmd, "nothing to export: the document has no visible art"))?;
let bounds = bounds_without(doc, &left_out).ok_or_else(|| bad(cmd, "nothing to export: the document has no visible art"))?;
let mut q = super::export::without_artboards(p);
if let Some(o) = q.as_object_mut() {
o.remove("useArtboards");
@@ -433,6 +437,107 @@ pub(super) fn art_page(cmd: &str, doc: &Document, p: &Value) -> Result<Option<(D
Ok(Some((single_artboard(doc, bounds, "Art"), q)))
}
/// The bounds of the visible art of `doc` that a `format` file of the art's bounds draws, as
/// [`art_page`] measures it with that format's default options: for HP-GL without what a plot
/// leaves out ([`vectorcraft_cad::hpgl::write::plots_nothing`]), for other formats all of it (the
/// non-printing layers a PDF leaves out depend on options chosen after the save panel). `None`
/// when there is none, which such an export refuses.
pub fn export_art_bounds(doc: &Document, format: &str) -> Option<Rect> {
let doc = &*doc.without_edit_modes();
bounds_without(doc, &|n| format == "hpgl" && vectorcraft_cad::hpgl::write::plots_nothing(doc, n))
}
/// The bounds of the visible art of `doc` with the objects that `left_out` picks hidden
/// ([`hidden_nodes`]) and the ones in the art of the symbols that instances show taken out
/// ([`without_nodes`]: symbol art is measured with its hidden objects). An instance of a symbol
/// whose art is all picked or hidden is hidden too. The art of a dynamic symbol is measured whole:
/// an instance can paint its objects, and finds them by their places in the art.
fn bounds_without(doc: &Document, left_out: &dyn Fn(&Node) -> bool) -> Option<Rect> {
// The symbols instances show (the first of each name) that aren't dynamic.
let mut names = HashSet::new();
let shown: Vec<(usize, &vectorcraft_doc::Symbol)> =
doc.symbols.iter().enumerate().filter(|(_, s)| names.insert(s.name.as_str())).filter(|(_, s)| !s.dynamic).collect();
// The symbols whose art `left_out` leaves nothing of, those that only hold instances of such
// symbols included.
let mut empty: HashSet<&str> = HashSet::new();
while let Some((_, s)) = shown.iter().find(|(_, s)| !empty.contains(s.name.as_str()) && all_left_out(&s.art, left_out, &empty)) {
empty.insert(&s.name);
}
let left_out =
&|n: &Node| left_out(n) || matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if empty.contains(symbol.as_str()) && !has_effects(n));
let layers = hidden_nodes(&doc.layers, left_out);
let symbols: Vec<(usize, Arc<Node>)> = shown
.iter()
.filter(|(_, s)| !empty.contains(s.name.as_str()))
.filter_map(|(i, s)| without_nodes(&s.art, left_out).map(|art| (*i, art)))
.collect();
if layers.is_none() && symbols.is_empty() {
return vectorcraft_render::encode::art_bounds(doc);
}
let mut d = doc.clone();
if let Some(layers) = layers {
d.layers = layers;
}
for (i, art) in symbols {
if let Some(s) = d.symbols.get_mut(i) {
s.art = art;
}
}
vectorcraft_render::encode::art_bounds(&d)
}
/// Whether `n` is hidden, picked by `left_out`, an instance of one of the `empty` symbols, or a
/// layer or a group holding only such objects (its clipping path among them when it clips). A
/// Shaper group, or a layer, group or instance with effects, fills or strokes of its own, is none
/// of these unless it is hidden or picked.
fn all_left_out(n: &Node, left_out: &dyn Fn(&Node) -> bool, empty: &HashSet<&str>) -> bool {
!n.visible
|| left_out(n)
|| !has_effects(n)
&& n.extras().shaper.is_none()
&& match &n.kind {
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => children.iter().all(|c| all_left_out(c, left_out, empty)),
NodeKind::SymbolInstance { symbol, .. } => empty.contains(symbol.as_str()),
_ => false,
}
}
/// Whether `n` has a visible effect, fill or stroke of its own, which the objects it holds can
/// take part in.
fn has_effects(n: &Node) -> bool {
n.appearance.effects.iter().any(|e| e.visible) || n.appearance.items.iter().any(|i| i.visible())
}
/// `n` without the objects that `left_out` picks in it, when it is a layer or a group, and in the
/// layers and groups in it at any depth; `None` when it picks none of them. A clipping layer or
/// group keeps its clipping path, its first object, and a Shaper group and a layer or group with
/// effects, fills or strokes of its own keep all they hold.
fn without_nodes(n: &Arc<Node>, left_out: &dyn Fn(&Node) -> bool) -> Option<Arc<Node>> {
let (NodeKind::Layer { children, clip, .. } | NodeKind::Group { children, clip }) = &n.kind else { return None };
if has_effects(n) || n.extras().shaper.is_some() {
return None;
}
let mut changed = false;
let kept: Vec<Arc<Node>> = children
.iter()
.enumerate()
.filter_map(|(i, c)| {
if left_out(c) && !(*clip && i == 0) {
changed = true;
return None;
}
Some(without_nodes(c, left_out).inspect(|_| changed = true).unwrap_or_else(|| c.clone()))
})
.collect();
changed.then(|| {
let mut new = (**n).clone();
if let NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } = &mut new.kind {
*children = kept;
}
Arc::new(new)
})
}
/// The `rect` param of a raster export: `[x, y, width, height]` in points, y down. `None` when
/// absent; an error when it is not four finite numbers with a positive size, when the format isn't
/// a raster one, or when it comes with an artboard choice (`rect` is the area, so those would
@@ -481,6 +586,35 @@ pub(crate) fn with_single_artboard(mut d: Document, rect: Rect, name: &str) -> D
d
}
/// `nodes` with the visible objects that `left_out` picks hidden, in layers, sublayers and groups
/// that don't clip, at any depth (what an object it picks holds is not looked at); `None` when it
/// picks none of them. Clipping layers are looked in too, as their sublayers can be picked.
pub(super) fn hidden_nodes(nodes: &[Arc<Node>], left_out: &dyn Fn(&Node) -> bool) -> Option<Vec<Arc<Node>>> {
let mut out: Option<Vec<Arc<Node>>> = None;
for (i, l) in nodes.iter().enumerate() {
let new = if left_out(l) {
l.visible.then(|| {
let mut n = (**l).clone();
n.visible = false;
Arc::new(n)
})
} else {
let (NodeKind::Layer { children, .. } | NodeKind::Group { children, clip: false }) = &l.kind else { continue };
hidden_nodes(children, left_out).map(|c| {
let mut n = (**l).clone();
if let NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } = &mut n.kind {
*children = c;
}
Arc::new(n)
})
};
if let Some(slot) = new.and_then(|new| Some((out.get_or_insert_with(|| nodes.to_vec()).get_mut(i)?, new))) {
*slot.0 = slot.1;
}
}
out
}
fn boards<T>(r: std::result::Result<T, String>) -> Result<T> {
r.map_err(|e| bad(C, e))
}
@@ -636,8 +770,9 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result<Encoded> {
{
return Ok(enc);
}
let left_out = |n: &Node| f.id == "hpgl" && vectorcraft_cad::hpgl::write::plots_nothing(doc, n);
if use_artboards == Some(false)
&& let Some((art, q)) = art_page(C, doc, p)?
&& let Some((art, q)) = art_page(C, doc, p, left_out)?
{
return encode_files(&art, f, &q);
}
+3 -2
View File
@@ -550,9 +550,10 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
// Optical Margin Alignment setting and writing direction. When that changes the direction of a
// frame of a restored document, the story flows through that frame's thread again. Threaded
// frames keep their size: Auto Size is off for them. The story is laid out in the document's
// own composite fonts.
// own composite fonts, and type naming one the document does not define flows as in a missing
// font, also when the document defines none.
let fonts = if doc.text_threads.is_empty() { Default::default() } else { doc.composite_fonts.clone() };
let lent = vectorcraft_text::FontDb::global().lend_composite_fonts(&fonts);
let lent = vectorcraft_text::FontDb::global().lend_document_composite_fonts(&fonts);
if !doc.text_threads.is_empty() && !restored {
crate::cmd::threads::reflow(&Document::new(1.0, 1.0), &mut doc);
} else if !doc.text_threads.is_empty() {
File diff suppressed because one or more lines are too long
+9 -152
View File
@@ -3,26 +3,22 @@
//! header's HDpi and VDpi, JP2 capture and display resolution boxes, or a HEIF image's EXIF
//! resolution tags. Place
//! sizes an image by it (72 ppi when the file has none); images converted to PNG on import keep it
//! as a `pHYs` chunk.
//! as a `pHYs` chunk. The formats the image decoder reads are read by [`vectorcraft_doc::ppi`].
pub(super) use vectorcraft_doc::ppi::png_chunks;
use vectorcraft_doc::ppi::tiff;
pub use vectorcraft_doc::ppi::with_png_resolution;
const INCH_M: f64 = 0.0254;
const INCH_CM: f64 = 2.54;
/// The resolution a file declares as (horizontal, vertical) ppi; `None` when it declares none (or
/// only an aspect ratio).
pub fn resolution(bytes: &[u8]) -> Option<(f64, f64)> {
let r = if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
png(bytes)
} else if bytes.starts_with(&[0xff, 0xd8]) {
jpeg(bytes)
} else if bytes.starts_with(b"II*\0") || bytes.starts_with(b"MM\0*") {
tiff(bytes)
} else if super::jxlread::is_jxl(bytes) {
if vectorcraft_doc::ppi::reads(bytes) {
return vectorcraft_doc::ppi::resolution(bytes);
}
let r = if super::jxlread::is_jxl(bytes) {
jxl(bytes)
} else if bytes.len() >= 12 && &bytes[..4] == b"RIFF" && &bytes[8..12] == b"WEBP" {
webp(bytes)
} else if bytes.starts_with(b"BM") {
bmp(bytes)
} else if super::psdread::is_psd(bytes) {
super::psdread::resolution(bytes)
} else if super::pcxread::is_pcx(bytes) {
@@ -82,121 +78,9 @@ fn slice(b: &[u8], at: usize, n: usize) -> Option<&[u8]> {
fn bytes<const N: usize>(b: &[u8], at: usize) -> Option<[u8; N]> {
slice(b, at, N)?.try_into().ok()
}
fn be16(b: &[u8], at: usize) -> Option<u16> {
bytes(b, at).map(u16::from_be_bytes)
}
fn be32(b: &[u8], at: usize) -> Option<u32> {
bytes(b, at).map(u32::from_be_bytes)
}
fn le32(b: &[u8], at: usize) -> Option<u32> {
bytes(b, at).map(u32::from_le_bytes)
}
/// Pixels per inch from the whole pixels per metre PNG and BMP store: a whole number of ppi when
/// the metre value is that rounded (300 ppi is stored as 11811 per metre, 299.9994 ppi).
fn per_metre(ppm: u32) -> f64 {
let v = ppm as f64 * INCH_M;
let whole = v.round();
if (v - whole).abs() <= INCH_M / 2.0 + 1e-9 { whole } else { v }
}
/// PNG chunks after the signature: (type, data).
pub(super) fn png_chunks(b: &[u8]) -> impl Iterator<Item = (&[u8], &[u8])> {
let mut at = 8;
std::iter::from_fn(move || {
let len = be32(b, at)? as usize;
let ty = slice(b, at + 4, 4)?;
let data = slice(b, at + 8, len)?;
at = at.checked_add(12 + len)?;
Some((ty, data))
})
}
fn png(b: &[u8]) -> Option<(f64, f64)> {
let (_, d) = png_chunks(b).take_while(|(ty, _)| *ty != b"IDAT").find(|(ty, _)| *ty == b"pHYs")?;
// Unit 1 = metre; 0 only gives the pixel aspect ratio.
(d.get(8) == Some(&1)).then(|| (per_metre(be32(d, 0).unwrap_or(0)), per_metre(be32(d, 4).unwrap_or(0))))
}
fn jpeg(b: &[u8]) -> Option<(f64, f64)> {
let (mut jfif, mut exif) = (None, None);
let mut at = 2;
while at + 4 <= b.len() && b[at] == 0xff {
let marker = b[at + 1];
// 0xff fill bytes pad between segments.
if marker == 0xff {
at += 1;
continue;
}
if marker == 0xd8 || marker == 0x01 || (0xd0..=0xd7).contains(&marker) {
at += 2;
continue;
}
// Start of scan or end of image: no more metadata.
if marker == 0xda || marker == 0xd9 {
break;
}
let len = be16(b, at + 2)? as usize;
let seg = slice(b, at + 4, len.checked_sub(2)?)?;
match marker {
0xe0 if seg.starts_with(b"JFIF\0") && seg.len() >= 12 => {
let (x, y) = (be16(seg, 8)? as f64, be16(seg, 10)? as f64);
jfif = match seg[7] {
1 => Some((x, y)),
2 => Some((x * INCH_CM, y * INCH_CM)),
_ => None,
};
}
0xe1 if seg.starts_with(b"Exif\0\0") => exif = tiff(&seg[6..]),
_ => {}
}
at += 2 + len;
}
jfif.or(exif)
}
/// TIFF (or EXIF) IFD0 XResolution / YResolution / ResolutionUnit.
fn tiff(b: &[u8]) -> Option<(f64, f64)> {
let le = b.starts_with(b"II");
let u16_at = |at: usize| bytes(b, at).map(if le { u16::from_le_bytes } else { u16::from_be_bytes });
let u32_at = |at: usize| bytes(b, at).map(if le { u32::from_le_bytes } else { u32::from_be_bytes });
let ifd = u32_at(4)? as usize;
let (mut x, mut y, mut unit) = (None, None, 2);
for i in 0..u16_at(ifd)? as usize {
let e = ifd.checked_add(2 + i * 12)?;
let rational = || -> Option<f64> {
let at = u32_at(e + 8)? as usize;
let (n, d) = (u32_at(at)?, u32_at(at.checked_add(4)?)?);
(d != 0).then(|| n as f64 / d as f64)
};
match u16_at(e)? {
282 => x = rational(),
283 => y = rational(),
296 => unit = u16_at(e + 8)?,
_ => {}
}
}
let k = match unit {
2 => 1.0,
3 => INCH_CM,
_ => return None,
};
let x = x?;
Some((x * k, y.unwrap_or(x) * k))
}
fn webp(b: &[u8]) -> Option<(f64, f64)> {
let mut at = 12;
while at + 8 <= b.len() {
let len = le32(b, at + 4)? as usize;
let data = b.get(at + 8..at + 8 + len)?;
if &b[at..at + 4] == b"EXIF" {
return tiff(data.strip_prefix(b"Exif\0\0").unwrap_or(data));
}
at += 8 + len + (len & 1);
}
None
}
fn jxl(b: &[u8]) -> Option<(f64, f64)> {
if !b.starts_with(&[0, 0, 0, 12, b'J', b'X', b'L', b' ', 13, 10, 135, 10]) {
@@ -224,33 +108,6 @@ fn jxl(b: &[u8]) -> Option<(f64, f64)> {
None
}
fn bmp(b: &[u8]) -> Option<(f64, f64)> {
// BITMAPINFOHEADER (40 bytes or longer) at 14: pixels per metre at +24 and +28.
if le32(b, 14)? < 40 {
return None;
}
let ppm = |at| le32(b, at).filter(|v| (1..=i32::MAX as u32).contains(v)).map(per_metre);
Some((ppm(38)?, ppm(42)?))
}
/// `png` with a `pHYs` chunk declaring `ppi` right after the header (any existing one is replaced).
pub fn with_png_resolution(png: &[u8], (x, y): (f64, f64)) -> Vec<u8> {
let Some(head) = be32(png, 8).and_then(|len| slice(png, 0, 20usize.checked_add(len as usize)?)) else { return png.to_vec() };
let chunk = vectorcraft_render::encode::png::phys_chunk(x, y);
let mut out = Vec::with_capacity(png.len() + chunk.len());
out.extend_from_slice(head);
out.extend_from_slice(&chunk);
let mut at = head.len();
while let Some(chunk) = be32(png, at).and_then(|len| slice(png, at, 12usize.checked_add(len as usize)?)) {
if chunk.get(4..8) != Some(b"pHYs") {
out.extend_from_slice(chunk);
}
at += chunk.len();
}
out.extend_from_slice(png.get(at..).unwrap_or_default());
out
}
#[cfg(test)]
mod tests {
use super::*;
+15 -6
View File
@@ -757,8 +757,9 @@ pub const AI_EDITING_NEEDS_THE_ARTBOARDS: &str =
pub const ART_PAGE_NEEDS_PDF: &str = "pdfCompatible: false needs the artboards to keep the editable artwork when useArtboards is false";
/// With `useArtboards: false` in `p`: the PDF-compatible `.ai` file of one page, the bounds of
/// `doc`'s visible art ([`super::encode::art_page`]). It carries neither the native document nor
/// the editing copy for other apps, and its notes end with [`AI_EDITING_NEEDS_THE_ARTBOARDS`].
/// `doc`'s visible art on the layers its pages draw ([`super::pdf::art_page`]). It carries neither
/// the native document nor the editing copy for other apps, and its notes end with
/// [`AI_EDITING_NEEDS_THE_ARTBOARDS`].
/// `pdfCompatible: false` is refused. `None` when `p` asks for the artboards or for a legacy
/// version 7 `.ai` file.
pub(super) fn encode_ai_art_page(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result<Option<Encoded>> {
@@ -768,7 +769,7 @@ pub(super) fn encode_ai_art_page(cmd: &str, f: &Format, doc: &Document, p: &Valu
if !bool_or(p, "pdfCompatible", true) {
return Err(bad(cmd, ART_PAGE_NEEDS_PDF));
}
let Some((art, q)) = super::encode::art_page(cmd, doc, p)? else { return Ok(None) };
let Some((art, q)) = super::pdf::art_page(cmd, doc, &ai_pdf_params(p, false))? else { return Ok(None) };
let mut enc = encode_ai_pages(cmd, f, &art, &q, false)?;
enc.setting_notes.push(AI_EDITING_NEEDS_THE_ARTBOARDS.to_string());
Ok(Some(enc))
@@ -784,9 +785,10 @@ pub(super) fn encode_ai(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Res
encode_ai_pages(cmd, f, doc, p, true)
}
/// [`encode_ai`] of a `.ai` file with or without PDF content, carrying the native document and
/// the editing copy for other apps when `editing` is true, and neither otherwise.
fn encode_ai_pages(cmd: &str, f: &Format, doc: &Document, p: &Value, editing: bool) -> Result<Encoded> {
/// `p` with the PDF options a `.ai` file is written with: Preserve Editing when `editing` is true,
/// layers unless a standard decides, long pages with a `UserUnit`, and `compress` as
/// `compression.compressText`.
fn ai_pdf_params(p: &Value, editing: bool) -> Value {
let mut params = if p.is_object() { p.clone() } else { json!({}) };
if let Some(o) = params.as_object_mut() {
o.insert("preserveEditing".into(), json!(editing));
@@ -805,6 +807,13 @@ fn encode_ai_pages(cmd: &str, f: &Format, doc: &Document, p: &Value, editing: bo
}
}
}
params
}
/// [`encode_ai`] of a `.ai` file with or without PDF content, carrying the native document and
/// the editing copy for other apps when `editing` is true, and neither otherwise.
fn encode_ai_pages(cmd: &str, f: &Format, doc: &Document, p: &Value, editing: bool) -> Result<Encoded> {
let params = ai_pdf_params(p, editing);
let pdf_compatible = bool_or(&params, "pdfCompatible", true);
let f = &if pdf_compatible { *f } else { without_pdf(f) };
let native_options = super::native::ai_options(cmd, f, doc, &params)?;
+107
View File
@@ -170,6 +170,113 @@ fn a_plot_reads_back_as_its_art() {
assert!((board.height() - (30.0 + pen)).abs() < 0.1, "{board:?}");
}
/// A plot of the art's bounds is measured on the art it plots: art on a non-printing layer, on a
/// layer at 0% opacity or at 0% opacity in a group, a placed image and a path that paints nothing
/// don't move its origin, a clipping layer is measured as clipped, and a document whose only
/// visible art is such art is refused.
#[test]
fn a_plot_of_the_arts_bounds_leaves_out_the_layers_it_does_not_plot() {
let mut s = two_boards();
let plot = |s: &mut Session| s.execute("document.export", &json!({"format": "hpgl", "useArtboards": false})).map(|v| bytes(&v));
let alone = plot(&mut s).unwrap();
let printing = s.doc().unwrap().doc.layers[0].id;
for props in [json!({"printable": false}), json!({})] {
let layer = s.execute("layer.new", &json!({})).unwrap()["id"].clone();
if props.as_object().unwrap().is_empty() {
s.execute("transparency.set", &json!({"ids": [layer], "opacity": 0})).unwrap();
} else {
let mut p = props.clone();
p["id"] = layer.clone();
s.execute("layer.setProps", &p).unwrap();
}
s.execute("shape.rectangle", &json!({"x": -300, "y": 500, "width": 20, "height": 20})).unwrap();
assert_eq!(plot(&mut s).unwrap(), alone, "{props}");
}
// An object at 0% opacity in a group, on a layer that plots.
s.execute("layer.new", &json!({})).unwrap();
let far = s.execute("shape.rectangle", &json!({"x": -500, "y": 700, "width": 20, "height": 20})).unwrap()["id"].clone();
s.execute("object.group", &json!({"ids": [far]})).unwrap();
s.execute("transparency.set", &json!({"ids": [far], "opacity": 0})).unwrap();
assert_eq!(plot(&mut s).unwrap(), alone, "0% opacity in a group");
// A placed image and a path whose fill and stroke paint nothing.
let mut png = vec![];
image::DynamicImage::new_rgb8(4, 4).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
s.execute("file.place", &json!({"name": "far.png", "dataBase64": b64(&png), "at": [-600, 800]})).unwrap();
assert_eq!(plot(&mut s).unwrap(), alone, "a placed image");
let bare = s.execute("shape.rectangle", &json!({"x": 500, "y": -400, "width": 20, "height": 20})).unwrap()["id"].clone();
s.execute("paint.setStroke", &json!({"ids": [bare], "none": true})).unwrap();
assert_eq!(plot(&mut s).unwrap(), alone, "a path that paints nothing");
// A clipping layer's clipping path, which loses its paint, still clips the art measured: a
// large square clipped to the first square draws nothing more.
let clipping = s.execute("layer.new", &json!({})).unwrap()["id"].clone();
s.execute("shape.rectangle", &json!({"x": -300, "y": -300, "width": 800, "height": 800})).unwrap();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 30, "height": 30})).unwrap();
s.execute("layer.clippingMask.toggle", &json!({"id": clipping})).unwrap();
assert_eq!(plot(&mut s).unwrap(), alone, "a clipping layer");
s.execute("layer.setProps", &json!({"id": clipping, "visible": false})).unwrap();
s.execute("layer.setProps", &json!({"id": printing, "visible": false})).unwrap();
let e = plot(&mut s).unwrap_err().to_string();
assert!(e.contains("no visible art"), "{e}");
}
/// A placed image in a symbol's art doesn't move a plot of the art's bounds either, and a
/// document whose only other visible art is an instance of that symbol is refused.
#[test]
fn a_plot_of_the_arts_bounds_leaves_out_images_in_symbol_art() {
let mut s = two_boards();
let plot = |s: &mut Session| s.execute("document.export", &json!({"format": "hpgl", "useArtboards": false})).map(|v| bytes(&v));
let alone = plot(&mut s).unwrap();
let printing = s.doc().unwrap().doc.layers[0].id;
s.execute("layer.new", &json!({})).unwrap();
let mut png = vec![];
image::DynamicImage::new_rgb8(4, 4).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
s.execute("file.place", &json!({"name": "far.png", "dataBase64": b64(&png), "at": [-600, 800]})).unwrap();
s.execute("symbol.new", &json!({"name": "Far"})).unwrap();
assert!(s.doc().unwrap().doc.symbols.iter().any(|sym| sym.name == "Far"));
assert_eq!(plot(&mut s).unwrap(), alone, "an image in symbol art");
s.execute("layer.setProps", &json!({"id": printing, "visible": false})).unwrap();
let e = plot(&mut s).unwrap_err().to_string();
assert!(e.contains("no visible art"), "{e}");
// A symbol of a square and an image is measured by its square.
let bounds = |s: &Session| super::export_art_bounds(&s.doc().unwrap().doc, "hpgl").unwrap();
let square = s.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 20, "height": 20})).unwrap()["id"].clone();
let before = bounds(&s);
let image = s.execute("file.place", &json!({"name": "near.png", "dataBase64": b64(&png), "at": [400, 600]})).unwrap()["ids"][0].clone();
s.execute("symbol.new", &json!({"name": "Both", "ids": [square, image]})).unwrap();
assert_eq!(s.doc().unwrap().doc.symbols.len(), 2);
let after = bounds(&s);
for (a, b) in [(before.x0, after.x0), (before.y0, after.y0), (before.x1, after.x1), (before.y1, after.y1)] {
assert!((a - b).abs() < 1e-6, "{before:?} {after:?}");
}
}
/// A placed image with a Vector Halftone effect is measured when the effect makes dots, and left
/// out when it would take more cells than the effect makes.
#[test]
fn a_plot_of_the_arts_bounds_measures_images_by_their_halftone_dots() {
let mut s = two_boards();
let plot = |s: &mut Session| s.execute("document.export", &json!({"format": "hpgl", "useArtboards": false})).map(|v| bytes(&v));
let printing = s.doc().unwrap().doc.layers[0].id;
s.execute("layer.new", &json!({})).unwrap();
let mut png = vec![];
image::DynamicImage::new_rgb8(100, 100).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
s.execute("file.place", &json!({"name": "dots.png", "dataBase64": b64(&png), "at": [300, 300]})).unwrap();
s.execute("layer.setProps", &json!({"id": printing, "visible": false})).unwrap();
// At 300 lines per inch a 100 pt image takes more cells than the effect makes.
s.execute("effect.apply", &json!({"effect": "addon.color.halftone", "params": {"frequency": 300}})).unwrap();
let e = plot(&mut s).unwrap_err().to_string();
assert!(e.contains("no visible art"), "{e}");
s.execute("effect.apply", &json!({"effect": "addon.color.halftone", "params": {"frequency": 20}})).unwrap();
let out = String::from_utf8(plot(&mut s).unwrap()).unwrap();
assert!(out.contains("PD"), "{out}");
}
#[test]
fn export_options_are_checked() {
let mut s = two_boards();
+42
View File
@@ -103,6 +103,48 @@ fn use_artboards_false_writes_one_page_of_the_arts_bounds() {
}
}
/// A page of the art's bounds is measured on the layers the PDF draws: a non-printing layer that
/// the PDF leaves out doesn't make it larger, unless `includeNonPrinting` or `createLayers` keeps
/// the layer. A `.ai` file of the art's bounds, which keeps layers by default, measures it unless
/// `createLayers` is off. A document whose only visible art doesn't print is refused.
#[test]
fn a_page_of_the_arts_bounds_leaves_out_non_printing_layers() {
let mut s = session(1);
let printing = s.doc().unwrap().doc.layers[0].id;
let notes = s.execute("layer.new", &json!({})).unwrap()["id"].clone();
s.execute("layer.setProps", &json!({"id": notes, "printable": false})).unwrap();
s.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 20, "height": 20})).unwrap();
let size = |bytes: &[u8]| vectorcraft_pdf::import(bytes).unwrap().artboards[0].rect.size();
let printed = size(&b64(&export(&mut s, json!({"useArtboards": false}))));
assert!(printed.width < 50.0 && printed.height < 40.0, "{printed:?}");
let through_export = s.execute("document.export", &as_export(&json!({"useArtboards": false}))).unwrap();
assert_eq!(size(&b64(&through_export)), printed);
let ai = |s: &mut Session, extra: Value| {
let mut p = json!({"format": "ai", "useArtboards": false});
p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
size(&b64(&s.execute("document.export", &p).unwrap_or_else(|e| panic!("{p}: {e}"))))
};
assert_eq!(ai(&mut s, json!({"createLayers": false})), printed);
let kept = [
size(&b64(&export(&mut s, json!({"useArtboards": false, "includeNonPrinting": true})))),
size(&b64(&export(&mut s, json!({"useArtboards": false, "createLayers": true})))),
ai(&mut s, json!({})),
];
for all in kept {
assert!(all.width > 200.0 && all.height > 200.0, "{all:?}");
}
// Only the non-printing layer shows: the PDF would draw nothing.
s.execute("layer.setProps", &json!({"id": printing, "visible": false})).unwrap();
for p in [json!({"useArtboards": false}), json!({"useArtboards": false, "includeNonPrinting": false})] {
let e = s.execute("document.exportPdf", &p).unwrap_err().to_string();
assert!(e.contains("no visible art"), "{p}: {e}");
}
let e = s.execute("document.export", &json!({"format": "ai", "useArtboards": false, "createLayers": false})).unwrap_err().to_string();
assert!(e.contains("no visible art"), "{e}");
assert!(size(&b64(&export(&mut s, json!({"useArtboards": false, "includeNonPrinting": true})))).width < 25.0);
}
/// In a PDF without a standard, the warning for a page longer than 14,400 pt written with a
/// `UserUnit` above 1 names "Write pages longer than 200 in at full size in points": at PDF 1.5
/// (written as 1.6) and later, through `document.exportPdf`, `document.export`, a page of the
+79 -7
View File
@@ -797,22 +797,47 @@ fn place_of(d: &Document, roots: &[NodeId]) -> Option<NodeId> {
}
/// The ruler guides of each of `roots` that is a sublayer (in its sublayers too), with the layer
/// that holds it, in order. Other objects hold none.
/// that holds it, in order. Other objects hold none. Each root, and each child of it and of its
/// sublayers, is looked at once.
fn held_guides(d: &Document, roots: impl IntoIterator<Item = NodeId>) -> Vec<(NodeId, Node)> {
let mut out = vec![];
for id in roots {
let (Some(n), Some(parent)) = (d.node(id), d.parent_of(id)) else { continue };
if n.holds_ruler_guides() {
n.walk(&mut |c| {
if c.is_ruler_guide() {
out.push((parent, c.clone()));
}
});
guide_seen(n);
if n.is_layer() {
guides_of(n, parent, &mut out);
}
}
out
}
/// Push each ruler guide of `layer` and of its sublayers onto `out` in paint order, with `parent`.
fn guides_of(layer: &Node, parent: NodeId, out: &mut Vec<(NodeId, Node)>) {
for c in layer.children().into_iter().flatten() {
guide_seen(c);
if c.is_ruler_guide() {
out.push((parent, Node::clone(c)));
} else if c.is_layer() {
guides_of(c, parent, out);
}
}
}
#[cfg(test)]
thread_local! {
/// The nodes that [`held_guides`] has looked at on this thread, in order.
static GUIDES_SEEN: std::cell::RefCell<Vec<NodeId>> = const { std::cell::RefCell::new(Vec::new()) };
}
/// Note that [`held_guides`] looked at `n`.
#[cfg(test)]
fn guide_seen(n: &Node) {
GUIDES_SEEN.with(|s| s.borrow_mut().push(n.id));
}
#[cfg(not(test))]
fn guide_seen(_: &Node) {}
/// An image of `r` (in a clip group when it is clipped).
fn raster_node(d: &mut Document, r: Raster) -> Node {
let key = unique_key(d, "flattened");
@@ -922,6 +947,53 @@ mod tests {
assert_eq!(flat.children(Some(layer)).and_then(|c| c.last()).map(|c| c.id), Some(g));
}
/// The guides of a sublayer and of its sublayers are gathered in paint order with the layer
/// that holds it, looking at the sublayer and each child of it and of its sublayers once.
/// Other roots give none.
#[test]
fn held_guides_look_at_each_node_once() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
let layer = s.doc().unwrap().doc.layers[0].id;
let sublayer =
|s: &mut Session, parent: NodeId| NodeId(s.execute("layer.newSublayer", &json!({"parent": parent.0})).unwrap()["id"].as_u64().unwrap());
let rect = |s: &mut Session, on: NodeId| {
s.execute("layer.setCurrent", &json!({"id": on.0})).unwrap();
NodeId(s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap())
};
let sub = sublayer(&mut s, layer);
let deep = sublayer(&mut s, sub);
let empty = sublayer(&mut s, layer);
let (a, b, c) = (rect(&mut s, sub), rect(&mut s, deep), rect(&mut s, empty));
let d = Arc::make_mut(&mut s.doc_mut().unwrap().doc);
let mut guide = |on: NodeId, pos: f64| {
let g = Node::new(d.alloc_id(), NodeKind::RulerGuide { vertical: true, pos, artboard: None });
d.insert(Some(on), usize::MAX, g).unwrap()
};
let (g1, g2, g3) = (guide(sub, 1.0), guide(deep, 2.0), guide(sub, 3.0));
let doc = &s.doc().unwrap().doc;
let ids = |n: &Node| {
let mut v = vec![];
n.walk(&mut |c| v.push(c.id));
v
};
assert_eq!(ids(doc.node(sub).unwrap()), vec![sub, deep, b, g2, a, g1, g3]);
let run = |roots: &[NodeId]| {
GUIDES_SEEN.with(|v| v.borrow_mut().clear());
let got: Vec<_> = held_guides(doc, roots.iter().copied()).into_iter().map(|(p, g)| (p, g.id)).collect();
(got, GUIDES_SEEN.with(|v| v.take()))
};
// Every node of the sublayer is looked at once, and the guides keep their paint order.
assert_eq!(run(&[sub]), (vec![(layer, g2), (layer, g1), (layer, g3)], vec![sub, deep, b, g2, a, g1, g3]));
// A sublayer that holds none gives none; a path holds none; a missing root and a top-level
// layer, which has no parent, are skipped.
assert_eq!(run(&[empty]), (vec![], vec![empty, c]));
assert_eq!(run(&[a]), (vec![], vec![a]));
assert_eq!(run(&[NodeId(u64::MAX), layer]), (vec![], vec![]));
// A deeper sublayer gives its guides to the sublayer that holds it.
assert_eq!(run(&[deep, deep]).0, vec![(sub, g2), (sub, g2)]);
}
/// In a file without transparency with Clip Complex Regions on, art under a group's opaque
/// image within one of its pixels joins the group, also where a long image is made at a lower
/// resolution and its pixels are wider than at the preset's.
+131 -29
View File
@@ -6,20 +6,34 @@
//! (<https://helpx.adobe.com/ph_fil/illustrator/how-to/global-editing-sensei.html>), in our words:
//!
//! - With one object selected, Start Global Edit finds the objects that repeat its shape: the
//! same outline, wherever it lies and at whatever size. Two options narrow the match: Appearance
//! (the same fill and stroke too) and Size (the same width and height too). The search covers
//! every artboard and the canvas around them by default; it can be limited to a range of
//! artboards (`1, 3-5`) and can leave out the objects that lie on no artboard. Images, type,
//! masks, linked files and selections of more than one object are not supported.
//! same outline, wherever it lies, at whatever size and at whatever angle. Two options narrow
//! the match: Appearance (the same fill and stroke too) and Size (the same width and height
//! too). The search covers every artboard and the canvas around them by default; it can be
//! limited to a range of artboards (`1, 3-5`) and can leave out the objects that lie on no
//! artboard. Images, type, masks, linked files and selections of more than one object are not
//! supported.
//! - The help page "Edit groups with similar objects"
//! (<https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-groups-with-similar-objects.html>):
//! with a group selected, Start Global Edit finds the groups like it, and an edit made to an
//! object inside one of them is made to the matching object in each of the others. Here a group
//! is like another when it holds as many objects, in the same order, each like the one at its
//! place (paths, compound paths, symbol instances and groups of them, clipping paths included),
//! and they lie the same way relative to each other: the group as a whole is moved or scaled.
//! With Appearance on, each of those objects must have the same fill and stroke too; with Size,
//! the groups the same width and height.
//! and they lie the same way relative to each other: the group as a whole is moved, scaled or
//! rotated. With Appearance on, each of those objects must have the same fill and stroke too;
//! with Size, the groups the same width and height.
//! - The how-to "Design a modern geometric logo"
//! (<https://helpx.adobe.com/vn_vi/illustrator/how-to/modern-logo-design.html>) makes rotated
//! copies of a group of shapes around a point, then starts Global Edit on one object and edits
//! every copy of it at once. Here a rotated copy is found as a moved or scaled one is, and an
//! edit reaches it turned by its own angle: an anchor moved to the right on the selected object
//! moves downward on a copy turned a quarter turn clockwise. The rules below for rotated copies
//! are VectorCraft's own. The angle is the one that lays each anchor and handle of the selected
//! object on the copy's of the same order, so a square whose anchors were turned a quarter turn
//! is a copy turned a quarter turn. A copy is the same shape when, laid so, every anchor and
//! handle lies within a millionth of the larger side of the copy's box of its own ([`SAME`]).
//! A reflected copy is another shape. With Size, a rotated copy has the same size when it is the
//! selected object rotated without scaling: its width and height are compared as they were
//! before the rotation.
//! - Selecting one of the repeated objects with the Selection tool highlights the similar objects
//! at once, before Global Edit starts. Here they are the objects Start Global Edit would take
//! with the current Global Edit Options ([`similar`]).
@@ -29,15 +43,16 @@
//!
//! Here the found objects ([`GlobalEdit::members`]) stay highlighted while the selection keeps the
//! object that was selected, so that the Transform panel and the bounding box measure that object
//! and not the whole set. Each member keeps the transform (a move and a uniform scale) that maps
//! the first object's outline onto its own. After every edit ([`propagate`], run inside
//! [`Session::edit`], so the whole change is one undo step), the first selected member the edit
//! changed is the model: what changed about it is given to every other member, and nothing else,
//! so members found by shape alone keep their own fills and strokes where the edit didn't touch
//! them:
//! and not the whole set. Each member keeps the transform (a move, a uniform scale and, for a
//! rotated copy, a turn) that maps the first object's outline onto its own. After every edit
//! ([`propagate`], run inside [`Session::edit`], so the whole change is one undo step), the first
//! selected member the edit changed is the model: what changed about it is given to every other
//! member, and nothing else, so members found by shape alone keep their own fills and strokes
//! where the edit didn't touch them:
//!
//! - its outline (and anchors), placed through that transform, so in proportion to each member's
//! size. Only geometry is placed so: no stroke weight is scaled to a member's size;
//! size and turned by its angle. Only geometry is placed so: no stroke weight is scaled to a
//! member's size;
//! - its opacity, blending, knockout and the object's effects, each when it changed;
//! - of its fills and strokes, each setting that changed (paint, weight, opacity, blending,
//! effects, caps, dashes, arrowheads …), as it is: it goes to the member's fill or stroke of the
@@ -74,12 +89,12 @@
//! and later edits leave it as it is.
//! The object the edit started from can't be left out (a Shift-click on it selects it or
//! deselects it as before), Global Edit ends when fewer than two members are left, and leaving
//! one out is not an undo step. Rotated copies are not covered.
//! one out is not an undo step.
use serde::Serialize;
use serde_json::{Value, json};
use vectorcraft_doc::{Appearance, AppearanceItem, Document, FillLayer, Node, NodeId, NodeKind, Scaling, Shared, StrokeLayer};
use vectorcraft_geom::{Affine, BezPath, PathData, Point, Rect, Shape};
use vectorcraft_geom::{Affine, BezPath, PathData, Point, Rect, Shape, Vec2};
use super::wand::{fill_of, stroke_of};
use super::{CommandSpec, Result, Session, bad, bool_or, checked_id, cmd, has_doc};
@@ -98,7 +113,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Start Global Edit",
["Select"],
None,
"{appearance?: bool, size?: bool, artboards?: \"all\" | range such as \"1, 3-5\", includeCanvas?: bool} (each defaults to the Global Edit Options) while Global Edit is off: start it on the one selected object (a path, compound path, symbol instance or group of them), with the objects of the same shape (for a group: the groups holding objects like its own, at the same places) (and, with `appearance`, the same fill and stroke; with `size`, the same width and height) on the artboards given (and, with `includeCanvas`, off every artboard), at most 10000 → {active: true, count, ids}; while on: stop it (Escape does the same) → {active: false}",
"{appearance?: bool, size?: bool, artboards?: \"all\" | range such as \"1, 3-5\", includeCanvas?: bool} (each defaults to the Global Edit Options) while Global Edit is off: start it on the one selected object (a path, compound path, symbol instance or group of them), with the objects of the same shape, moved, scaled or rotated copies of it and not reflected ones (for a group: the groups holding objects like its own, at the same places) (and, with `appearance`, the same fill and stroke; with `size`, the same width and height, for a rotated copy before its rotation) on the artboards given (and, with `includeCanvas`, off every artboard), at most 10000 → {active: true, count, ids}; while on: stop it (Escape does the same) → {active: false}",
can_toggle,
toggle
),
@@ -181,7 +196,8 @@ pub type Outlines = Vec<(BezPath, [u8; 3])>;
#[derive(Clone, Debug, PartialEq)]
pub struct GlobalEdit {
/// The objects edited together, the one selected at the start first, each with the transform
/// (a move and a uniform scale) that maps that first object's outline onto its own.
/// (a move, a uniform scale and, for a turned copy, a turn) that maps that first object's
/// outline onto its own.
pub members: Vec<(NodeId, Affine)>,
/// [`highlights`] as of a document revision: the canvas asks every frame.
outlines: std::cell::RefCell<Option<(u64, Outlines)>>,
@@ -257,14 +273,60 @@ impl Form {
}
}
fn same(&self, o: &Form) -> bool {
self.tag == o.tag
&& self.symbols == o.symbols
&& self.points.len() == o.points.len()
&& self.points.iter().zip(&o.points).all(|(a, b)| a.distance(*b) < 1e-6)
/// The transform that lays this form's object on `o`'s, point for point in order, and whether
/// it turns the object: a move and a uniform scale when the two lie the same way, otherwise a
/// turn too. `None` when `o` is another shape, a reflected copy included.
fn onto(&self, o: &Form) -> Option<(Affine, bool)> {
if self.tag != o.tag || self.symbols != o.symbols || self.points.len() != o.points.len() {
return None;
}
let near = |xf: Affine| self.points.iter().zip(&o.points).all(|(a, b)| (xf * *a).distance(*b) < SAME);
let back = self.frame.inverse();
if near(Affine::IDENTITY) {
return Some((o.frame * back, false));
}
let turn = self.turn_onto(o)?;
near(turn).then(|| (o.frame * turn * back, true))
}
/// The turn, uniform scale and move (both forms in their unit boxes) that best lays this
/// form's points on `o`'s in order, by least squares. A turn within [`SQUARE`] of a quarter
/// turn is that quarter turn exactly.
fn turn_onto(&self, o: &Form) -> Option<Affine> {
let n = self.points.len() as f64;
let middle = |f: &Form| f.points.iter().fold(Vec2::ZERO, |s, p| s + p.to_vec2()) / n;
let (from, to) = (middle(self), middle(o));
// With the points as complex numbers about their middles, the fit is `Σ conj(p) q / Σ |p|²`.
let (mut re, mut im, mut spread) = (0.0, 0.0, 0.0);
for (a, b) in self.points.iter().zip(&o.points) {
let (p, q) = (a.to_vec2() - from, b.to_vec2() - to);
re += p.dot(q);
im += p.cross(q);
spread += p.hypot2();
}
let len = re.hypot(im);
let scale = len / spread;
if !(scale.is_finite() && scale > 0.0) {
return None;
}
let (mut cos, mut sin) = (re / len, im / len);
if cos.abs() < SQUARE {
(cos, sin) = (0.0, sin.signum());
} else if sin.abs() < SQUARE {
(cos, sin) = (cos.signum(), 0.0);
}
let (a, b) = (scale * cos, scale * sin);
Some(Affine::translate(to) * Affine::new([a, b, -b, a, 0.0, 0.0]) * Affine::translate(-from))
}
}
/// How far apart two forms' points may lie, each form in its unit box, for the forms to be the
/// same. For a turned copy this bounds the angle too: a copy turned and then changed by more than
/// a millionth of its larger side is another shape.
const SAME: f64 = 1e-6;
/// A turn whose sine or cosine is below this is taken as a whole number of quarter turns.
const SQUARE: f64 = 1e-9;
/// Global Edit supports paths, compound paths, symbol instances and groups of them; not images,
/// type, linked files, objects with an opacity mask, clipping paths or guides (the help page lists
/// images, text, masks and links as unsupported).
@@ -395,8 +457,16 @@ fn matches(s: &Session, o: &Options) -> Result<Vec<(NodeId, Affine)>> {
}
boards.as_ref().is_none_or(|rs| rs.iter().any(|r| on(*r, b)))
};
// A copy that lies the same way has the size of the selected object when their boxes are as
// wide and as high; a turned copy, when the transform that lays the object on it doesn't scale.
let same_size = |n: &Node, xf: Affine, turned: bool| {
if turned {
close(xf.determinant().abs().sqrt(), 1.0)
} else {
matches!((key_size, size(n)), (Some((w, h)), Some((w2, h2))) if close(w, w2) && close(h, h2))
}
};
let mut out = vec![(*id, Affine::IDENTITY)];
let back = form.frame.inverse();
super::select::walk_selectable(d, st.isolation, |n| {
if n.id == *id || out.len() >= MAX_MEMBERS {
return false;
@@ -413,12 +483,12 @@ fn matches(s: &Session, o: &Options) -> Result<Vec<(NodeId, Affine)>> {
};
// The object count first: it turns most objects down before their form is made.
if n.children().map(Vec::len) == count
&& let Some(f) = Form::of(n).filter(|f| f.same(&form) && supported(n))
&& (!o.size || matches!((key_size, size(n)), (Some((w, h)), Some((w2, h2))) if close(w, w2) && close(h, h2)))
&& let Some((xf, turned)) = Form::of(n).and_then(|f| form.onto(&f)).filter(|_| supported(n))
&& (!o.size || same_size(n, xf, turned))
&& (!o.appearance || same_look(key, n))
&& in_scope(n)
{
out.push((n.id, f.frame * back));
out.push((n.id, xf));
// A group taken is taken whole, as compound paths, symbol instances and the others are.
return false;
}
@@ -464,7 +534,7 @@ fn toggle(s: &mut Session, p: &Value) -> Result<Value> {
let o = s.global_edit_options.with(p, &s.doc()?.doc, C)?;
let members = matches(s, &o)?;
if members.len() < 2 {
return Err(bad(C, "no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again"));
return Err(bad(C, "no similar objects found (reflected copies are not matched); change the Global Edit Options and try again"));
}
let ids: Vec<u64> = members.iter().map(|(id, _)| id.0).collect();
let st = s.doc_mut()?;
@@ -1027,6 +1097,38 @@ mod tests {
assert!(taken > 100 && checked - taken > 100 && asked > 200, "{taken} of {checked}, {asked} selected");
}
#[test]
fn a_form_is_laid_on_a_turned_copy_and_on_no_reflected_or_unmeasurable_one() {
let mut d = Document::new(800.0, 600.0);
let mut path = |points: &[(f64, f64)]| {
let anchors = points.iter().map(|(x, y)| Anchor::corner(Point::new(*x, *y))).collect();
Node::path(d.alloc_id(), PathData::new(vec![SubPath::new(anchors, true)]), Appearance::default())
};
let form = Form::of(&path(&[(0.0, 0.0), (30.0, 0.0), (30.0, 70.0)])).unwrap();
let mut onto = |points: &[(f64, f64)]| Form::of(&path(points)).and_then(|f| form.onto(&f));
let near = |xf: Affine, want: [f64; 6]| xf.as_coeffs().iter().zip(want).all(|(a, b)| (a - b).abs() < 1e-9);
// The same way at twice the size: a move and a scale, no turn.
let (xf, turned) = onto(&[(100.0, 0.0), (160.0, 0.0), (160.0, 140.0)]).unwrap();
assert!(!turned && near(xf, [2.0, 0.0, 0.0, 2.0, 100.0, 0.0]), "{xf:?}");
// A quarter turn, (x, y) → (−y, x), moved by (100, 0); and a turn by 30° at half the size.
let (xf, turned) = onto(&[(100.0, 0.0), (100.0, 30.0), (30.0, 30.0)]).unwrap();
assert!(turned && near(xf, [0.0, 1.0, -1.0, 0.0, 100.0, 0.0]), "{xf:?}");
let by = Affine::rotate(30f64.to_radians()) * Affine::scale(0.5);
let at = |x: f64, y: f64| {
let p = by * Point::new(x, y);
(p.x, p.y)
};
let (xf, turned) = onto(&[at(0.0, 0.0), at(30.0, 0.0), at(30.0, 70.0)]).unwrap();
assert!(turned && near(xf, by.as_coeffs()), "{xf:?}");
// A reflected copy, a copy with an anchor a thousandth of its size away, and points that
// are not numbers or not finite.
assert!(onto(&[(0.0, 0.0), (-30.0, 0.0), (-30.0, 70.0)]).is_none());
assert!(onto(&[at(0.0, 0.0), at(30.0, 0.0), at(30.0, 70.07)]).is_none());
assert!(onto(&[(f64::NAN, 0.0), (30.0, 0.0), (30.0, 70.0)]).is_none());
assert!(onto(&[(0.0, 0.0), (f64::INFINITY, 0.0), (30.0, 70.0)]).is_none());
assert!(onto(&[(0.0, 0.0), (30.0, 0.0)]).is_none(), "another count of anchors");
}
#[test]
fn support_of_empty_degenerate_and_deep_objects_is_decided_as_building_the_form_decides() {
let mut d = Document::new(800.0, 600.0);
+1
View File
@@ -40,6 +40,7 @@ pub use commands::specs;
pub(crate) use data::apply_csv;
#[cfg(test)]
use data::{fmt_value, parse_csv, to_csv};
pub(crate) use edit::{graphs_holding, keep_placed};
#[cfg(test)]
use generate::generate;
#[cfg(test)]
+56 -2
View File
@@ -1,3 +1,5 @@
use vectorcraft_doc::graph::art_bounds;
use super::axis::{MAX_TICK_MARKS, count_given, one_line};
use super::data::apply_data;
use super::generate::{children_bounds, generate};
@@ -17,11 +19,62 @@ fn bound_param(p: &Value, key: &str, cmd: &str) -> Result<Option<Option<f64>>> {
}
}
/// The bounds a redraw of graph `n` measures a move or scale on, as kept at the last generation and as they are now:
/// those of its art without type ([`GraphSpec::placed_art`]), or those of all its art when the graph keeps no such
/// bounds with a width and a height, or has no art without type now.
fn measured(n: &Node) -> Option<(Rect, Rect)> {
let kept = n.extras().graph.as_deref()?;
let c = n.children()?;
let art = kept.placed_art.filter(|p| p.width() > 1e-9 && p.height() > 1e-9).zip(art_bounds(c));
art.or_else(|| kept.placed.zip(children_bounds(c)))
}
/// `r` carried by the change from bounds `from` to bounds `to`: each edge stays at the same place relative to them.
/// `None` when `from` has no width or no height.
fn carried(r: Rect, from: Rect, to: Rect) -> Option<Rect> {
if from.width() <= 1e-9 || from.height() <= 1e-9 {
return None;
}
let (sx, sy) = (to.width() / from.width(), to.height() / from.height());
Some(Rect::new(to.x0 + (r.x0 - from.x0) * sx, to.y0 + (r.y0 - from.y0) * sy, to.x0 + (r.x1 - from.x0) * sx, to.y0 + (r.y1 - from.y0) * sy))
}
/// The graphs holding objects `ids`, each with the bounds of its art without type and of all its art. Give them to
/// [`keep_placed`] after an edit that changes a graph's art without moving the graph.
pub(crate) fn graphs_holding(d: &Document, ids: &[NodeId]) -> Vec<(NodeId, Option<Rect>, Option<Rect>)> {
let mut out: Vec<(NodeId, Option<Rect>, Option<Rect>)> = vec![];
for id in ids {
let Some(chain) = d.ancestry(*id) else { continue };
let holder = chain.iter().rev().skip(1).copied().find(|a| d.node(*a).is_some_and(|n| n.extras().graph.is_some()));
let Some(gid) = holder.filter(|g| !out.iter().any(|(o, ..)| o == g)) else { continue };
let children = d.node(gid).and_then(Node::children);
out.push((gid, children.and_then(|c| art_bounds(c)), children.and_then(|c| children_bounds(c))));
}
out
}
/// After an edit that changed the art of `graphs` (from [`graphs_holding`]) without moving them, set the bounds each
/// keeps so that its next redraw measures the same move or scale as it would have before the edit.
pub(crate) fn keep_placed(d: &mut Document, graphs: &[(NodeId, Option<Rect>, Option<Rect>)]) {
for &(gid, art_was, whole_was) in graphs {
let Some(n) = d.node(gid) else { continue };
let children = n.children();
let (art_now, whole_now) = (children.and_then(|c| art_bounds(c)), children.and_then(|c| children_bounds(c)));
let Some(spec) = d.node_mut(gid).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue };
if let (Some(placed), Some(was), Some(now)) = (spec.placed_art, art_was, art_now) {
spec.placed_art = carried(now, was, placed).or(spec.placed_art);
}
if let (Some(placed), Some(was), Some(now)) = (spec.placed, whole_was, whole_now) {
spec.placed = carried(now, was, placed).or(spec.placed);
}
}
}
/// Regenerate graph `id` from `spec` (adjusting the plot rect for any move/scale since the last
/// generation, which is recorded in the spec's `placed` bounds).
/// generation, which is recorded in the spec's `placed_art` bounds, or `placed` in graphs saved without them).
pub(super) fn regenerate(d: &mut Document, id: NodeId, mut spec: GraphSpec) -> Result<()> {
let n = d.node(id).ok_or(EngineError::NoNode(id))?;
if let (Some(placed), Some(now)) = (n.extras().graph.as_ref().and_then(|g| g.placed), n.children().and_then(|c| children_bounds(c)))
if let Some((placed, now)) = measured(n)
&& placed.width() > 1e-9
&& placed.height() > 1e-9
&& (placed.x0 - now.x0).abs() + (placed.y0 - now.y0).abs() + (placed.x1 - now.x1).abs() + (placed.y1 - now.y1).abs() > 1e-6
@@ -33,6 +86,7 @@ pub(super) fn regenerate(d: &mut Document, id: NodeId, mut spec: GraphSpec) -> R
}
let children = generate(d, &spec);
spec.placed = children_bounds(&children);
spec.placed_art = art_bounds(&children);
let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?;
if let Some(ch) = n.children_mut() {
*ch = children;
+22 -11
View File
@@ -631,19 +631,33 @@ pub(crate) fn capture_label_styles(doc: &mut Document, ids: &[NodeId], change: &
changed
}
/// Apply `change` to the style graph `graph` keeps for its labels of kind `role` (or the default one). Returns whether
/// the kept style changed.
/// Apply `change` to the style graph `graph` keeps for its labels of kind `role` (or the default one). A `change` that
/// sets the fill of legend labels marks it as set on them, so a black one stays black on dark pie wedges. Returns whether
/// the kept style or that mark changed.
fn store_label_style(doc: &mut Document, graph: NodeId, role: Role, change: &dyn Fn(&mut CharStyle)) -> bool {
let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { return false };
let mut style = label_style(&spec.label_styles, role);
// The kept fill can already be the one `change` sets (black, the default): `change` sets the fill when it gives one
// of two different fills something else.
let sets_fill = role == Role::Legend
&& [Paint::None, Paint::solid(Color::BLACK)].into_iter().any(|fill| {
let mut probe = CharStyle { fill: fill.clone(), ..style.clone() };
change(&mut probe);
probe.fill != fill
});
change(&mut style);
let styles = &mut spec.label_styles;
let slot = match role {
Role::Value => &mut spec.label_styles.values,
Role::Category => &mut spec.label_styles.categories,
Role::Legend => &mut spec.label_styles.legend,
Role::Value => &mut styles.values,
Role::Category => &mut styles.categories,
Role::Legend => &mut styles.legend,
};
let changed = slot.as_ref() != Some(&style);
let mut changed = slot.as_ref() != Some(&style);
*slot = Some(style);
if sets_fill && !styles.legend_fill_set {
styles.legend_fill_set = true;
changed = true;
}
changed
}
@@ -872,12 +886,9 @@ fn run_paint_change(was: &Node, now: &Node, fill: bool) -> Option<(CharStyle, Ve
}
/// Does graph `g` pick the fill of its legend labels itself, white on a dark wedge and black on the others: a pie with
/// Legends in Wedges whose legend labels keep no fill of their own?
/// Legends in Wedges whose legend labels keep no fill of their own ([`GraphSpec::picks_legend_fill`])?
pub(super) fn picks_legend_fill(g: &GraphSpec) -> bool {
g.kind == GraphKind::Pie
&& g.legend
&& g.pie_legend_in_wedges
&& !g.label_styles.legend.as_ref().is_some_and(|st| st.fill != Paint::solid(Color::BLACK))
g.picks_legend_fill()
}
/// The graph labels among `roots` and inside them, for an edit that reaches graph labels without naming them.
+271
View File
@@ -5691,6 +5691,151 @@ fn formatting_one_label_keeps_that_kind_of_label() {
assert!(serde_json::to_value(GraphSpec::default()).unwrap().get("labelStyles").is_none());
}
/// Character and paragraph edits that resize a graph's labels leave the plot where it is when the graph is drawn
/// again, and a move or scale of the graph made after them is still followed.
#[test]
fn restyling_a_graphs_labels_leaves_the_plot_in_place() {
let csv = ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6";
let redraw = |s: &mut Session, id: NodeId| s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap();
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
let rect = spec(&s, id).rect;
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("text.setStyle", &json!({"size": 14})).unwrap();
redraw(&mut s, id);
assert_eq!(spec(&s, id).rect, rect, "after a size change to every label");
let c = text_id(&s, id, "Axes", "Q2");
s.execute("text.setFormat", &json!({"ids": [c.0], "leftIndent": 20})).unwrap();
redraw(&mut s, id);
assert_eq!(spec(&s, id).rect, rect, "after an indent on the category labels");
let l = text_id(&s, id, "Legend", "2025");
s.execute("text.setStyle", &json!({"ids": [l.0], "size": 4})).unwrap();
redraw(&mut s, id);
assert_eq!(spec(&s, id).rect, rect, "after a smaller size on the legend labels");
// A move and a scale of the whole graph still carry the plot with them.
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("object.move", &json!({"dx": 40, "dy": -10})).unwrap();
redraw(&mut s, id);
let moved = spec(&s, id).rect;
assert!((moved.x0 - (rect.x0 + 40.0)).abs() < 1e-6 && (moved.y0 - (rect.y0 - 10.0)).abs() < 1e-6, "{moved:?}");
assert!((moved.width() - rect.width()).abs() < 1e-6 && (moved.height() - rect.height()).abs() < 1e-6, "{moved:?}");
s.execute("object.scale", &json!({"sx": 200, "sy": 100, "origin": [moved.x0, moved.y0]})).unwrap();
redraw(&mut s, id);
let scaled = spec(&s, id).rect;
assert!((scaled.width() - 2.0 * rect.width()).abs() < 1e-6 && (scaled.height() - rect.height()).abs() < 1e-6, "{scaled:?}");
assert!((scaled.x0 - moved.x0).abs() < 1e-6 && (scaled.y0 - moved.y0).abs() < 1e-6, "{scaled:?}");
}
/// Moving one label of a graph on its own leaves the plot where it is when the graph is drawn again.
#[test]
fn moving_one_label_leaves_the_plot_in_place() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
let rect = spec(&s, id).rect;
for (grp, text) in [("Legend", "2025"), ("Axes", "Q2")] {
let l = text_id(&s, id, grp, text);
s.execute("select.set", &json!({"ids": [l.0]})).unwrap();
s.execute("object.move", &json!({"dx": 120, "dy": 60})).unwrap();
s.execute("graph.setData", &json!({"id": id.0, "csv": ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"})).unwrap();
assert_eq!(spec(&s, id).rect, rect, "after moving {text}");
}
}
/// Label edits leave the plot in place in graphs whose legend swatches follow the size of the labels at the next
/// drawing: a legend across the top, a legend after a right value axis, and legend labels of several lines. A move of
/// the graph made after them is still followed.
#[test]
fn restyling_the_labels_of_a_graph_whose_legend_follows_them_leaves_the_plot_in_place() {
let csv = ",North|East|Coast,South|West,Inland|Hills|Lakes|Plains\nQ1,3,2,4\nQ2,5,4,1\nQ3,-1,6,2";
for options in [json!({"legendAcrossTop": true}), json!({"valueAxis": "right"}), json!({"valueAxis": "both"}), json!({})] {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("graph.setType", &options).unwrap();
s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap();
let rect = spec(&s, id).rect;
for size in [16, 5] {
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("text.setStyle", &json!({"size": size})).unwrap();
for _ in 0..2 {
s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap();
assert_eq!(spec(&s, id).rect, rect, "{options}: labels at {size} pt");
}
}
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("object.move", &json!({"dx": 30, "dy": 20})).unwrap();
s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap();
let moved = spec(&s, id).rect;
assert!((moved.x0 - (rect.x0 + 30.0)).abs() < 1e-6 && (moved.y0 - (rect.y0 + 20.0)).abs() < 1e-6, "{options}: {moved:?}");
assert!((moved.width() - rect.width()).abs() < 1e-6 && (moved.height() - rect.height()).abs() < 1e-6, "{options}: {moved:?}");
}
}
/// Outlining a graph's labels leaves the plot where it is when the graph is drawn again, and a move of the graph made
/// before them is still followed.
#[test]
fn outlining_a_graphs_labels_leaves_the_plot_in_place() {
let csv = ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6";
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
let rect = spec(&s, id).rect;
s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap();
s.execute("select.set", &json!({"ids": [text_id(&s, id, "Axes", "Q2").0, text_id(&s, id, "Legend", "2025").0]})).unwrap();
s.execute("type.createOutlines", &json!({})).unwrap();
s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap();
assert_eq!(spec(&s, id).rect, rect, "after outlining labels");
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("object.move", &json!({"dx": 30, "dy": 15})).unwrap();
s.execute("select.set", &json!({"ids": [text_id(&s, id, "Axes", "Q1").0, text_id(&s, id, "Legend", "2024").0]})).unwrap();
s.execute("type.createOutlines", &json!({})).unwrap();
s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap();
let moved = spec(&s, id).rect;
assert!((moved.x0 - (rect.x0 + 30.0)).abs() < 1e-6 && (moved.y0 - (rect.y0 + 15.0)).abs() < 1e-6, "{moved:?}");
assert!((moved.width() - rect.width()).abs() < 1e-6 && (moved.height() - rect.height()).abs() < 1e-6, "{moved:?}");
}
/// A graph whose kept bounds of its art without type have no width follows a move with the bounds of all of its art.
#[test]
fn a_graph_whose_art_without_type_kept_no_width_still_follows_a_move() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
let rect = spec(&s, id).rect;
let mut flat = spec(&s, id);
let art = flat.placed_art.unwrap();
flat.placed_art = Some(vectorcraft_geom::Rect::new(art.x0, art.y0, art.x0, art.y1));
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(id).unwrap().extras_mut().graph = Some(Box::new(flat));
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("object.move", &json!({"dx": 25, "dy": 5})).unwrap();
s.execute("graph.setData", &json!({"id": id.0, "csv": ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"})).unwrap();
let moved = spec(&s, id).rect;
assert!((moved.x0 - (rect.x0 + 25.0)).abs() < 1e-6 && (moved.y0 - (rect.y0 + 5.0)).abs() < 1e-6, "{moved:?}");
}
/// A graph saved before it kept the bounds of its art without type follows a move made since with its old bounds.
#[test]
fn a_graph_saved_with_only_its_whole_bounds_still_follows_a_move() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
let rect = spec(&s, id).rect;
let mut old = serde_json::to_value(spec(&s, id)).unwrap();
old.as_object_mut().unwrap().remove("placedArt");
let old: GraphSpec = serde_json::from_value(old).unwrap();
assert_eq!(old.placed_art, None);
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(id).unwrap().extras_mut().graph = Some(Box::new(old));
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("object.move", &json!({"dx": 25, "dy": 5})).unwrap();
s.execute("graph.setData", &json!({"id": id.0, "csv": ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"})).unwrap();
let moved = spec(&s, id).rect;
assert!((moved.x0 - (rect.x0 + 25.0)).abs() < 1e-6 && (moved.y0 - (rect.y0 + 5.0)).abs() < 1e-6, "{moved:?}");
assert!(spec(&s, id).placed_art.is_some(), "the next drawing keeps the bounds of the art without type");
}
/// Each Axes and Legend text after the graph is drawn again, with its object rows (fill or stroke, color, opacity, and
/// for a stroke its weight) and its object opacity.
type RowLooks = Vec<(bool, String, f32, f64)>;
@@ -6361,6 +6506,93 @@ fn pie_names_and_wedge_labels_keep_their_style() {
assert_eq!(fills, ["#00ff00", "#00ff00"], "the kept fill, also on the dark wedge");
}
/// On a pie with Legends in Wedges, a black fill given to the white legend label on the dark wedge is kept for every
/// legend label: the graph draws it black on the dark wedge again, also after a native save and open.
#[test]
fn a_black_fill_set_on_a_wedge_legend_label_stays_black_on_dark_wedges() {
let cases = [("paint.setFill", json!({"color": "#000000"})), ("text.setStyle", json!({"fill": "#000000"})), ("edit.colors.invert", json!({}))];
every_case(cases, |(cmd, params)| {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = make(&mut s, "pie", ",a,b\nP1,1,2");
s.execute("graph.setType", &json!({"id": id.0, "pieLegend": "wedges"})).unwrap();
let legend_fill = |s: &Session, text: &str| {
let NodeKind::Text(t) = label_node(s, id, "Legend", text).kind else { panic!("{text} is not type") };
t.runs[0].style.fill.color().map(|c| c.to_hex()).unwrap_or_default()
};
assert_eq!(legend_fill(&s, "a"), "#ffffff", "white on the black wedge");
let mut p = params.clone();
p["ids"] = json!([text_id(&s, id, "Legend", "a").0]);
s.execute(cmd, &p).unwrap();
assert!(spec(&s, id).label_styles.legend_fill_set, "{cmd}");
s.execute("edit.undo", &json!({})).unwrap();
assert!(!spec(&s, id).label_styles.legend_fill_set, "{cmd}: undone");
assert_eq!(legend_fill(&s, "a"), "#ffffff", "{cmd}: white again after the undo");
s.execute("edit.redo", &json!({})).unwrap();
assert!(spec(&s, id).label_styles.legend_fill_set, "{cmd}: redone");
s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2]]})).unwrap();
assert_eq!((legend_fill(&s, "a"), legend_fill(&s, "b")), ("#000000".into(), "#000000".into()), "{cmd}: black on both wedges");
let dir = vectorcraft_testkit::TempDir::new("graph-wedge-legend");
let path = dir.join("pie.vectorcraft");
s.execute("document.save", &json!({"path": path})).unwrap();
s.execute("document.open", &json!({"path": path})).unwrap();
assert!(spec(&s, id).label_styles.legend_fill_set, "{cmd}: kept through save and open");
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("graph.setData", &json!({"id": id.0, "rows": [[2, 1]]})).unwrap();
assert_eq!(legend_fill(&s, "a"), "#000000", "{cmd}: still black after open");
});
}
/// A size given to the legend labels of a pie with Legends in Wedges keeps the default black fill with it. A color mode
/// change leaves that fill black, so the legend label on the dark wedge is still drawn white. A legend fill that is not
/// black, in a file saved without the mark, is marked as set by the change.
#[test]
fn a_color_mode_change_keeps_the_default_wedge_legend_fill() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = make(&mut s, "pie", ",a,b\nP1,1,2");
s.execute("graph.setType", &json!({"id": id.0, "pieLegend": "wedges"})).unwrap();
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("text.setStyle", &json!({"size": 11})).unwrap();
s.execute("file.documentColorMode", &json!({"mode": "cmyk"})).unwrap();
let styles = spec(&s, id).label_styles;
assert!(!styles.legend_fill_set && !styles.legend_fill_is_own(), "{styles:?}");
s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2]]})).unwrap();
let NodeKind::Text(t) = label_node(&s, id, "Legend", "a").kind else { panic!("a is not type") };
assert_eq!(t.runs[0].style.fill.color().map(|c| c.to_hex()), Some("#ffffff".into()), "white on the black wedge");
let red = Paint::solid(vectorcraft_color::Color::from_hex("#ff0000").unwrap());
let styles = vectorcraft_doc::LabelStyles { legend: Some(vectorcraft_doc::CharStyle { fill: red, ..Default::default() }), ..Default::default() };
assert!(!styles.legend_fill_set && styles.legend_fill_is_own(), "{styles:?}");
s.edit("legacy", |d, _| {
d.node_mut(id).unwrap().extras_mut().graph.as_deref_mut().unwrap().label_styles = styles;
Ok(())
})
.unwrap();
s.execute("file.documentColorMode", &json!({"mode": "rgb"})).unwrap();
assert!(spec(&s, id).label_styles.legend_fill_set, "the converted red is still the labels' own");
}
/// The default black legend fill of a graph that keeps its legend beside the marks stays the default through a color
/// mode change, so turning on Legends in Wedges later still draws the labels on dark wedges white.
#[test]
fn a_color_mode_change_keeps_the_default_legend_fill_of_other_graphs() {
// A pie made here keeps its legend beside the pies.
for kind in ["column", "pie"] {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = make(&mut s, kind, ",a,b\nP1,1,2");
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("text.setStyle", &json!({"size": 11})).unwrap();
let black = Paint::solid(vectorcraft_color::Color::BLACK);
let before = spec(&s, id).label_styles;
assert_eq!(before.legend.as_ref().map(|st| st.fill.clone()), Some(black.clone()), "{kind}: {before:?}");
s.execute("file.documentColorMode", &json!({"mode": "cmyk"})).unwrap();
let after = spec(&s, id).label_styles;
assert_eq!(after.legend.as_ref().map(|st| st.fill.clone()), Some(black), "{kind}: {after:?}");
assert!(!after.legend_fill_set && !after.legend_fill_is_own(), "{kind}: still the default: {after:?}");
}
}
#[test]
fn labels_are_told_apart_by_where_they_come_not_by_their_text() {
let mut s = Session::new();
@@ -6848,3 +7080,42 @@ fn a_graph_opened_from_ai_keeps_its_data_for_editing() {
assert!(notes.iter().any(|w| w.as_str().is_some_and(|w| w.starts_with("graphs are written as their art"))), "{name}: {r}");
}
}
/// A graph opened from a `.ai` keeps the bounds of its art without type, so moving one of its labels on its own
/// leaves the plot where it is at the first redraw.
#[test]
fn a_graph_opened_from_ai_measures_moves_without_its_labels() {
let graph = "%_Gs\n%_100 200 300 100 Gb\n%_5 0 0 14 0 1 0 0 6 44 Gy\n%_10 2 0 0 90 80 0 Gd\n\
%_1 () Ga\n%_0 13 0 0 0 0 0 () GA\n%_2 () Ga\n%_0 14 0 0 0 0 0 () GA\n%_4 () Ga\n%_0 14 0 0 0 0 0 () GA\n\
%_2 2 1 1 Gz\n%_() Gc (East) Gc (Q1) Gc 5 Gc GC\n\
u\nu\n0.6 0 1 0 k\n110 100 m\n110 150 L\n130 150 L\n130 100 L\nf\nU\n%_2 1 0 0 Go\n\
u\n0 0 0 1 K\n100 200 m\n100 100 L\n300 100 L\nS\nU\n\
0 To\n1 0 0 1 100 220 0 Tp\nTP\n0 Tr\n0 0 0 1 k\n/_Helvetica 12 Tf\n(Sales) Tx\nTO\nU\n%_GS\n";
let text = format!(
"%!PS-Adobe-3.0\n%%Creator: a script\n%%BoundingBox: 0 0 400 400\n%AI5_FileFormat 3\n%AI3_Cropmarks: 0 0 400 400\n%%EndComments\n\
%%BeginProlog\n%%EndProlog\n%%BeginSetup\n%%EndSetup\n%AI5_BeginLayer\n1 1 1 1 0 0 0 79 128 255 Lb\n(Graph) Ln\n0 A\n{graph}LB\n\
%AI5_EndLayer--\n%%PageTrailer\n%%Trailer\n%%EOF\n"
);
let dir = vectorcraft_testkit::temp_dir("graph-ai-label");
let path = dir.join("graph.ai");
std::fs::write(&path, text).unwrap();
let mut s = Session::new();
s.execute("document.open", &json!({"path": path})).unwrap();
let (mut graphs, mut labels) = (vec![], vec![]);
s.doc().unwrap().doc.walk(|n| {
if n.extras().graph.is_some() {
graphs.push(n.id);
}
if matches!(&n.kind, NodeKind::Text(t) if t.plain_text() == "Sales") {
labels.push(n.id);
}
});
assert_eq!((graphs.len(), labels.len()), (1, 1));
let (id, label) = (graphs[0], labels[0]);
let before = spec(&s, id);
assert!(before.placed_art.is_some());
s.execute("select.set", &json!({"ids": [label.0]})).unwrap();
s.execute("object.move", &json!({"dx": 80, "dy": -40})).unwrap();
s.execute("graph.setData", &json!({"id": id.0, "csv": ",East\nQ1,8\nQ2,2"})).unwrap();
assert_eq!(spec(&s, id).rect, before.rect);
}
+48 -23
View File
@@ -5,7 +5,7 @@ use serde_json::{Value, json};
use vectorcraft_doc::{Composer, JustificationOptions, NodeKind, Scaling, SingleWordJustify, TextRun};
use vectorcraft_geom::{Rect, Vec2};
use super::{Affine, CommandSpec, NodeId, Result, Session, bad, bool_or, check_switches, cmd, has_doc, index, ok, point_indices, switch_or};
use super::{Affine, CommandSpec, NodeId, Result, Session, bad, bool_or, check_switches, cmd, has_doc, index, ok, switch_or};
use crate::{Clipboard, EngineError};
pub fn specs() -> Vec<CommandSpec> {
@@ -137,19 +137,16 @@ pub(crate) fn named_or_selected(s: &Session, p: &Value, cmd: &str) -> Result<Vec
return artboard_indices(d, p, cmd);
}
if let Some(recorded) = p.get("selectedArtboards") {
return artboard_indices(d, &json!({ "indices": recorded }), cmd);
return listed_artboards(d, recorded, "selectedArtboards", cmd);
}
let mut picked: Vec<u64> = t["selected"]
.as_array()
.into_iter()
.flatten()
.filter_map(Value::as_u64)
.filter(|i| usize::try_from(*i).is_ok_and(|i| i < d.artboards.len()))
.collect();
let mut picked: Vec<usize> =
t["selected"].as_array().into_iter().flatten().filter_map(Value::as_u64).map(index).filter(|i| *i < d.artboards.len()).collect();
if picked.is_empty() {
picked.push(t["active"].as_u64().unwrap_or(0));
picked.push(on_board(d, t["active"].as_u64().map_or(0, index))?);
}
artboard_indices(d, &json!({ "indices": picked }), cmd)
picked.sort_unstable();
picked.dedup();
Ok(picked)
}
/// Rename Artboards: one artboard takes the name, and several take it numbered in artboard order, in
@@ -221,23 +218,51 @@ fn artboard_switch_orientation(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "switched": switched }))
}
/// The one artboard `cmd` acts on: `index`, an artboard of `d`, with the errors of
/// [`artboard_indices`] (`missing index`, `no such artboard`).
/// The one artboard `cmd` acts on: `index`, an artboard of `d`. Left out or null, it is
/// `missing index`; a value that is not a whole number is "`index` must be a whole number"; a
/// whole number past the last artboard is `no such artboard`.
pub(crate) fn artboard_index(d: &vectorcraft_doc::Document, p: &Value, cmd: &str) -> Result<usize> {
let i = p.get("index").ok_or_else(|| bad(cmd, "missing index"))?;
artboard_indices(d, &json!({ "index": i }), cmd)?.into_iter().next().ok_or_else(|| EngineError::Other("no such artboard".into()))
artboard_at(d, p, "index", cmd)?.ok_or_else(|| bad(cmd, "missing index"))
}
/// The artboard of `d` that `key` of `p` names, or `None` when `p` leaves it out or gives null. A
/// value that is not a whole number is "`{key}` must be a whole number", and a whole number past the
/// last artboard is `no such artboard`.
pub(crate) fn artboard_at(d: &vectorcraft_doc::Document, p: &Value, key: &str, cmd: &str) -> Result<Option<usize>> {
super::index_opt(p, key, cmd)?.map(|i| on_board(d, i)).transpose()
}
/// `i` when it is an artboard of `d`, else `no such artboard`.
pub(crate) fn on_board(d: &vectorcraft_doc::Document, i: usize) -> Result<usize> {
match i < d.artboards.len() {
true => Ok(i),
false => Err(EngineError::Other("no such artboard".into())),
}
}
/// The artboards `cmd` acts on: `indices` (several), else `index` (one), each an artboard of `d`,
/// in order and once each.
/// in order and once each. `indices` is read as [`listed_artboards`] reads it, and `index` as
/// [`artboard_index`] reads it.
pub(crate) fn artboard_indices(d: &vectorcraft_doc::Document, p: &Value, cmd: &str) -> Result<Vec<usize>> {
let listed = match (p.get("indices"), p.get("index")) {
(Some(v), _) => point_indices(v),
(None, Some(i)) => point_indices(&json!([i])),
(None, None) => return Err(bad(cmd, "missing index")),
};
let mut indices =
listed.filter(|v| !v.is_empty() && v.iter().all(|i| *i < d.artboards.len())).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
match p.get("indices") {
Some(v) => listed_artboards(d, v, "indices", cmd),
None => Ok(vec![artboard_index(d, p, cmd)?]),
}
}
/// `v`, the value of `key`, as artboards of `d`, in order and once each. A value that is not a list
/// is "`{key}` must be a list of artboard indices", an empty list is "`{key}` must be a non-empty
/// list of artboard indices", and a list with an entry that is not a whole number is "`{key}` must
/// contain whole numbers"; a whole number past the last artboard is `no such artboard`. Every entry
/// is checked for its form before any is checked against the artboards.
fn listed_artboards(d: &vectorcraft_doc::Document, v: &Value, key: &str, cmd: &str) -> Result<Vec<usize>> {
let entries = v.as_array().ok_or_else(|| bad(cmd, format!("`{key}` must be a list of artboard indices")))?;
if entries.is_empty() {
return Err(bad(cmd, format!("`{key}` must be a non-empty list of artboard indices")));
}
let listed = entries.iter().map(Value::as_u64).collect::<Option<Vec<u64>>>();
let listed = listed.ok_or_else(|| bad(cmd, format!("`{key}` must contain whole numbers")))?;
let mut indices = listed.into_iter().map(|i| on_board(d, index(i))).collect::<Result<Vec<usize>>>()?;
indices.sort_unstable();
indices.dedup();
Ok(indices)
@@ -34,6 +34,7 @@ pub(super) const ALLOWED: &[&str] = &[
"addon.contour.set",
"addon.cutting.banner",
"addon.cutting.bridges",
"addon.cutting.nest",
"addon.cutting.weed",
"addon.image.applyColorMask",
"addon.image.colorMask",
+1
View File
@@ -217,6 +217,7 @@ pub const PREF_SPECS: &[PrefSpec] = &[
p!("recentFontsCount", "Type", "Options", "Number of Recent Fonts", int(1, 15)),
p!("missingGlyphProtection", "Type", "Options", "Enable Missing Glyph Protection", bool),
p!("highlightAlternateGlyphs", "Type", "Options", "Highlight Alternate Glyphs", bool),
p!("showCharacterAlternates", "Type", "Options", "Show Character Alternates", bool),
p!("highlightSubstitutedFonts", "Type", "Options", "Highlight Substituted Fonts", bool),
p!("placeholderText", "Type", "Options", "Fill New Type Objects With Placeholder Text", bool),
p!("typedLists", "Type", "Options", "Start Lists From Typed Markers", bool),
+10
View File
@@ -400,6 +400,8 @@ thread_local! {
/// Tests: how many times [`story_of`] and [`ThreadIndex::of`] went through the document's
/// threads on this thread.
static THREAD_SCANS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
/// Tests: how many times [`story_in`] went through a thread's frames on this thread.
static STORY_BUILDS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// Tests: how many times [`story_of`] and [`ThreadIndex::of`] have gone through the document's
@@ -409,6 +411,12 @@ pub(crate) fn thread_scans() -> usize {
THREAD_SCANS.with(std::cell::Cell::get)
}
/// Tests: how many times [`story_in`] has gone through a thread's frames on this thread.
#[cfg(test)]
pub(crate) fn story_builds() -> usize {
STORY_BUILDS.with(std::cell::Cell::get)
}
/// The frames of the thread that `id` is a frame of, in thread order, each area type or type on a
/// path; empty when `id` is in no thread or the thread has fewer than two such frames.
pub(crate) fn story_of(d: &Document, id: NodeId) -> Vec<NodeId> {
@@ -420,6 +428,8 @@ pub(crate) fn story_of(d: &Document, id: NodeId) -> Vec<NodeId> {
/// [`story_of`] for `id` in `thread`, the first of the document's threads that lists it.
pub(crate) fn story_in(d: &Document, thread: &[NodeId], id: NodeId) -> Vec<NodeId> {
#[cfg(test)]
STORY_BUILDS.with(|c| c.set(c.get() + 1));
let frames: Vec<NodeId> = thread.iter().copied().filter(|f| text_of(d, *f).is_some()).collect();
if frames.len() >= 2 && frames.contains(&id) { frames } else { vec![] }
}
+52 -24
View File
@@ -86,7 +86,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Set Text",
[],
None,
"{id?|ids?, text} replace the contents of text objects (keeps the first run's style) → {ids, overflow?: [id…] (area type and type on a path whose text no longer fits; left out when all fits)}",
"{id?|ids?, text} replace the contents of text objects (keeps the first run's style); no undo step when no object changes (each already has the text in one style, every paragraph with the first one's attributes, and no ruby) → {ids, overflow?: [id…] (area type and type on a path whose text no longer fits; left out when all fits)}",
has_doc,
set_text
),
@@ -104,7 +104,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Reshape Type Area",
[],
None,
"{id, anchors: [[subpath, anchor]…], dx, dy} or {items: [{id, anchors}…], dx, dy} move anchors of area type's frame (the type area) by dx, dy points, with their handles; the text reflows at its size, through its thread too (what Direct Selection does dragging a frame corner or edge, or the frame anchors its marquee selected, and the arrow keys nudging those); with items, every listed frame moves in one step",
"{id, anchors: [[subpath, anchor]…], dx, dy} or {items: [{id, anchors}…], dx, dy} move anchors of area type's frame (the type area) by dx, dy points, with their handles; the text reflows at its size, through its thread too (what Direct Selection does dragging a frame corner or edge, or the frame anchors its marquee selected, and the arrow keys nudging those); with items, every listed frame moves in one step; no undo step when no frame changes (dx and dy both 0)",
has_doc,
reshape_area
),
@@ -113,7 +113,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Reshape Type Path",
[],
None,
"{id, anchors: [[subpath, anchor]…], dx, dy} move anchors of type on a path's path by dx, dy points, with their handles; the start and end brackets keep their fractions of the path and the text reflows along it (what Direct Selection does dragging an anchor of the path)",
"{id, anchors: [[subpath, anchor]…], dx, dy} move anchors of type on a path's path by dx, dy points, with their handles; the start and end brackets keep their fractions of the path and the text reflows along it (what Direct Selection does dragging an anchor of the path); no undo step when the path does not change (dx and dy both 0)",
has_doc,
reshape_path
),
@@ -122,7 +122,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Move Type Path Handle",
[],
None,
"{id, subpath?, anchor, which: \"in\"|\"out\", x, y, independent?: bool} move a direction handle of an anchor of type on a path's path to (x, y), as path.setHandle moves a path's (independent: that side only, the anchor becomes a corner); the start and end brackets keep their fractions of the path and the text reflows along it (what Direct Selection does dragging a handle of the path)",
"{id, subpath?, anchor, which: \"in\"|\"out\", x, y, independent?: bool} move a direction handle of an anchor of type on a path's path to (x, y), as path.setHandle moves a path's (independent: that side only, the anchor becomes a corner); the start and end brackets keep their fractions of the path and the text reflows along it (what Direct Selection does dragging a handle of the path); no undo step when the handle is already there and the path does not change",
has_doc,
set_path_handle
),
@@ -131,7 +131,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Fit Headline",
["Type"],
None,
"{ids?|id?} track the first line of area type so it fills the frame width → {tracking}",
"{ids?|id?} track the first line of area type so it fills the frame width; no undo step when the first line already has that tracking → {tracking}",
has_selection,
fit_headline
),
@@ -140,7 +140,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Character",
[],
None,
"{ids?|id?, font?, style? (font alone: the style is kept when the new family has it, else its closest), size?: pt 0.1..1296 (outside it: an error), leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour|\"none\", stroke?: colour|\"none\" (characters without a stroke weight get 1 pt; #rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt (the character stroke's weight, in document points as stroke.set), features?: [\"dlig\", \"-liga\", \"salt=2\", …] OpenType (swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take the font's alternate number as `tag=N`, N 0–65535: \"salt=2\" draws the second alternate of each glyph that has one, \"salt\" is \"salt=1\", \"-salt\" and \"salt=0\" are off), featureChanges?: [\"tnum\", \"-onum\", \"salt=2\", …] (OpenType features turned on, or with - off, on top of each character's own, a value replacing the feature's last one; its other features are kept), start?: byte, end?: byte} (size/leading are document points after object scaling; with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text) → {ids, overflow?: [id…] (area type and type on a path whose text no longer fits; left out when all fits), warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}",
"{ids?|id?, font?, style? (font alone: the style is kept when the new family has it, else its closest), size?: pt 0.1..1296 (outside it: an error), leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour|\"none\", stroke?: colour|\"none\" (characters without a stroke weight get 1 pt; #rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt (the character stroke's weight, in document points as stroke.set), features?: [\"dlig\", \"-liga\", \"salt=2\", …] OpenType (swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take the font's alternate number as `tag=N`, N 0–65535: \"salt=2\" draws the second alternate of each glyph that has one, \"salt\" is \"salt=1\", \"-salt\" and \"salt=0\" are off), featureChanges?: [\"tnum\", \"-onum\", \"salt=2\", …] (OpenType features turned on, or with - off, on top of each character's own, a value replacing the feature's last one; its other features are kept), start?: byte, end?: byte} (size/leading are document points after object scaling; with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text; no undo step when no character, paragraph or graph label style changes) → {ids, overflow?: [id…] (area type and type on a path whose text no longer fits; left out when all fits), warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}",
has_doc,
set_style
),
@@ -149,7 +149,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Ruby…",
[],
None,
"{ids?|id?, start?: byte, end?: byte, text?: reading, position?: above|below (vertical type: right|left), align?: start|center|end, size?: 1–100 (percent of the base text's size, default 50), overhang?: bool (default true), remove?: bool} set the ruby (furigana) over the selected text's bytes in `start..end` (without them: all its text): the reading over the base characters, above them in horizontal type and right of them in vertical type; `remove` takes the ruby off the range instead → {ids, ruby: [{start, end, text, position, align, sizePercent, overhang}…] (the first object's ruby)}",
"{ids?|id?, start?: byte, end?: byte, text?: reading, position?: above|below (vertical type: right|left), align?: start|center|end, size?: 1–100 (percent of the base text's size, default 50), overhang?: bool (default true), remove?: bool} set the ruby (furigana) over the selected text's bytes in `start..end` (without them: all its text): the reading over the base characters, above them in horizontal type and right of them in vertical type; `remove` takes the ruby off the range instead; no undo step when the ruby does not change (the same reading and options again, or `remove` where there is none) → {ids, ruby: [{start, end, text, position, align, sizePercent, overhang}…] (the first object's ruby)}",
has_selection,
ruby
),
@@ -265,8 +265,8 @@ fn fractional_widths(s: &mut Session, p: &Value) -> Result<Value> {
}
/// Type › Ruby: set or remove the ruby (furigana) over a byte range of the selected type in one
/// undo step. Without `start`/`end` the range is all the text; a new reading replaces the ruby
/// over the range. The reply lists the first object's ruby (start/end in bytes of its plain text).
/// undo step (none when nothing changes). Without `start`/`end` the range is all the text; a new
/// reading replaces the ruby over the range. The reply lists the first object's ruby (start/end in bytes of its plain text).
fn ruby(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "type.ruby";
let ids = text_targets(s, p, C)?;
@@ -293,7 +293,8 @@ fn ruby(s: &mut Session, p: &Value) -> Result<Value> {
}
let overhang = switch_or(p, "overhang", true, C)?;
let mut first: Option<Value> = None;
s.edit("Ruby", |d, _| {
// The same reading again, or a removal where there is no ruby, is no undo step.
edit_if_nodes_changed(s, "Ruby", &ids, |d| {
for id in &ids {
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else {
return Err(EngineError::NoNode(*id));
@@ -320,7 +321,7 @@ fn ruby(s: &mut Session, p: &Value) -> Result<Value> {
first = Some(serde_json::to_value(&t.ruby).unwrap_or(Value::Null));
}
}
Ok(())
Ok(((), false))
})?;
Ok(json!({"ids": ids.iter().map(|id| id.0).collect::<Vec<_>>(), "ruby": first.unwrap_or(Value::Null)}))
}
@@ -622,6 +623,8 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result<Value> {
if texts.is_empty() {
return Err(EngineError::Other("Create Outlines: select text objects".into()));
}
// Outlining a graph's labels does not move or scale its plot at the next redraw.
let graphs = super::graph::graphs_holding(d, &texts);
let before = (!super::dimension::reached(d, &roots).is_empty()).then(|| d.clone());
let ended = super::dimension::end_links(d, &roots);
let mut new_sel: Vec<NodeId> = roots.iter().copied().filter(|r| !texts.contains(r)).collect();
@@ -749,12 +752,26 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result<Value> {
}
// Outlined frames of threaded text leave their threads with the text they show.
super::threads::leave_threads(d, &threaded);
super::graph::keep_placed(d, &graphs);
sel.set(new_sel);
Ok(out)
})?;
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
}
/// [`Session::edit`] for a type command that may set what the type already has: `f` runs on a
/// copy of the document, and it becomes an undo step only when one of the objects `ids` differs
/// afterwards or `f` returns true for a change of its own elsewhere (a graph's kept label styles).
/// When nothing changed, the document, its undo and redo steps and its saved state stay as they
/// were. Only the objects are compared, not the whole document, whose embedded images can be large.
fn edit_if_nodes_changed<T>(s: &mut Session, label: &str, ids: &[NodeId], f: impl FnOnce(&mut Document) -> Result<(T, bool)>) -> Result<T> {
super::colorcmds::edit_if_changed(s, label, |before, d| {
let (v, other) = f(d)?;
let changed = other || ids.iter().any(|id| before.node(*id) != d.node(*id));
Ok((v, changed))
})
}
pub(crate) fn text_targets(s: &Session, p: &Value, cmd: &str) -> Result<Vec<NodeId>> {
let ids = targets(s, p)?;
let t = text_ids(&s.doc()?.doc, &ids);
@@ -849,7 +866,8 @@ pub(crate) fn style_chars(t: &mut TextObject, range: Option<TextRange>, mut f: i
fn set_text(s: &mut Session, p: &Value) -> Result<Value> {
let text = str_param(p, "text").ok_or_else(|| bad("text.setText", "missing `text`"))?.to_string();
let ids = text_targets(s, p, "text.setText")?;
s.edit("Typing", |d, _| {
// The text the type already has is no undo step.
edit_if_nodes_changed(s, "Typing", &ids, |d| {
for id in &ids {
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
let style = t.first_style();
@@ -860,7 +878,7 @@ fn set_text(s: &mut Session, p: &Value) -> Result<Value> {
t.ruby.clear();
refresh_bounds(t);
}
Ok(())
Ok(((), false))
})?;
Ok(with_overflow(s, &ids, json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() })))
}
@@ -933,7 +951,8 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
let protect = s.prefs.missing_glyph_protection && (font.is_some() || style.is_some());
// The fonts the styled characters are set in, for the warning.
let mut set = std::collections::BTreeSet::new();
s.edit("Character", |d, _| {
// Attributes the characters already have are no undo step.
edit_if_nodes_changed(s, "Character", &ids, |d| {
for id in &ids {
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
let size = size.map(|v| local_type_value(t, v, (0.1, 1296.0), false, C)).transpose()?;
@@ -1008,8 +1027,9 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
}
// Graph labels keep a change made to all of their text through the graph being drawn again (sizes as given,
// not in a scaled label's own units).
let mut labels_changed = false;
if range.is_none() {
super::graph::capture_label_styles(d, &ids, &|st| {
labels_changed = super::graph::capture_label_styles(d, &ids, &|st| {
if let Some(f) = &font {
st.font_family = f.clone();
st.font_version = None;
@@ -1052,7 +1072,7 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
}
});
}
Ok(())
Ok(((), labels_changed))
})?;
let mut out = with_overflow(s, &ids, json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }));
if let Some(w) = super::fonts::set_font_warning(set) {
@@ -1350,14 +1370,16 @@ fn reshape_area(s: &mut Session, p: &Value) -> Result<Value> {
items.push((id, refs));
}
let d = Vec2::new(f64_req(p, "dx", C)?, f64_req(p, "dy", C)?);
s.edit("Reshape Type Area", |doc, _| {
// Anchors moved by no distance are no undo step.
let ids: Vec<NodeId> = items.iter().map(|(id, _)| *id).collect();
edit_if_nodes_changed(s, "Reshape Type Area", &ids, |doc| {
for (id, refs) in &items {
let Some(NodeKind::Text(t)) = doc.node_mut(*id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(*id)) };
if !reshape_area_with(t, |t| t.move_area_anchors(refs, d)) {
return Err(bad(C, "no such frame anchor, or the frame would reach past the canvas"));
}
}
Ok(())
Ok(((), false))
})?;
ok()
}
@@ -1377,7 +1399,8 @@ fn reshape_path(s: &mut Session, p: &Value) -> Result<Value> {
if !on_path || node.is_some_and(|n| n.extras().perspective.is_some()) {
return Err(bad(C, "not type on a path outside perspective"));
}
s.edit("Reshape Type Path", |doc, _| {
// Anchors moved by no distance are no undo step.
edit_if_nodes_changed(s, "Reshape Type Path", &[id], |doc| {
let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) };
let before = t.kind.clone();
let within = |t: &TextObject| {
@@ -1388,7 +1411,7 @@ fn reshape_path(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(C, "no such path anchor, or the path would reach past the canvas"));
}
refresh_bounds(t);
Ok(())
Ok(((), false))
})?;
ok()
}
@@ -1406,7 +1429,8 @@ fn set_path_handle(s: &mut Session, p: &Value) -> Result<Value> {
if !on_path || node.is_some_and(|n| n.extras().perspective.is_some()) {
return Err(bad(C, "not type on a path outside perspective"));
}
s.edit("Reshape Type Path", |doc, _| {
// A handle moved to where it is is no undo step.
edit_if_nodes_changed(s, "Reshape Type Path", &[id], |doc| {
let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) };
let before = t.kind.clone();
let within = |t: &TextObject| {
@@ -1417,7 +1441,7 @@ fn set_path_handle(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(C, "no such path anchor, or the path would reach past the canvas"));
}
refresh_bounds(t);
Ok(())
Ok(((), false))
})?;
ok()
}
@@ -1428,6 +1452,8 @@ mod block;
pub(crate) use block::needed_room;
#[cfg(test)]
mod block_tests;
#[cfg(test)]
mod noop_tests;
/// Width of the first line and of the space it can fill, with `tracking` on the first paragraph.
/// The third value is where the first line ends in the plain text.
@@ -1469,7 +1495,9 @@ fn fit_headline(s: &mut Session, p: &Value) -> Result<Value> {
plans.push((*id, (lo * 10.0).floor() / 10.0));
}
let first = plans.first().map(|p| p.1).ok_or_else(|| bad(C, "select area type whose first line can fit its frame"))?;
s.edit("Fit Headline", |d, _| {
// A headline that already has its fitted tracking is no undo step.
let fitted: Vec<NodeId> = plans.iter().map(|(id, _)| *id).collect();
edit_if_nodes_changed(s, "Fit Headline", &fitted, |d| {
for (id, tr) in &plans {
if let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) {
let end = t.plain_text().find('\n').unwrap_or(usize::MAX).min(vectorcraft_text::edit::runs_len(&t.runs));
@@ -1477,7 +1505,7 @@ fn fit_headline(s: &mut Session, p: &Value) -> Result<Value> {
refresh_bounds(t);
}
}
Ok(())
Ok(((), false))
})?;
Ok(json!({ "tracking": first }))
}
+143
View File
@@ -0,0 +1,143 @@
use super::*;
/// The document and the number of undo steps, to compare before and after a command that sets
/// what the type already has.
fn state(s: &Session) -> (std::sync::Arc<Document>, usize) {
let st = s.doc().unwrap();
(st.doc.clone(), st.history.undo.len())
}
/// Assert that `cmd` with `p` leaves the document and its undo history as they were.
fn records_nothing(s: &mut Session, cmd: &str, p: Value) {
let before = state(s);
s.execute(cmd, &p).unwrap();
let after = state(s);
assert!(*after.0 == *before.0, "{cmd} {p} changed the document");
assert_eq!(after.1, before.1, "{cmd} {p} recorded an undo step");
}
/// Assert that `cmd` with `p` changes the document in exactly one undo step.
fn records_one_step(s: &mut Session, cmd: &str, p: Value) {
let before = state(s);
s.execute(cmd, &p).unwrap();
let after = state(s);
assert!(*after.0 != *before.0, "{cmd} {p} left the document as it was");
assert_eq!(after.1, before.1 + 1, "{cmd} {p} did not record one undo step");
}
fn session_with(text: &str, area: Option<Value>) -> (Session, u64) {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 600, "height": 400})).unwrap();
let mut p = json!({"x": 10, "y": 20, "text": text, "size": 24});
if let Some(a) = area {
p["area"] = a;
}
let id = s.execute("text.create", &p).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [id]})).unwrap();
(s, id)
}
/// Ruby set again with the same reading and options, or removed where there is none, records no
/// undo step; a step that does change the ruby still undoes in one.
#[test]
fn ruby_that_changes_nothing_records_no_undo_step() {
let (mut s, id) = session_with("漢字です", None);
records_nothing(&mut s, "type.ruby", json!({"remove": true}));
let p = json!({"text": "かんじ", "start": 0, "end": 6, "position": "below", "size": 60});
records_one_step(&mut s, "type.ruby", p.clone());
records_nothing(&mut s, "type.ruby", p);
s.execute("type.ruby", &json!({"remove": true})).unwrap();
records_nothing(&mut s, "type.ruby", json!({"remove": true, "start": 0, "end": 6}));
s.execute("edit.undo", &json!({})).unwrap();
let NodeKind::Text(t) = &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind else { panic!("text") };
assert_eq!(t.ruby.len(), 1, "undo brings the ruby back");
}
/// `text.setStyle` (the Character panel) given the attributes the characters already have
/// records no undo step, with a range too.
#[test]
fn character_attributes_already_set_record_no_undo_step() {
let (mut s, _) = session_with("Headline", None);
records_nothing(&mut s, "text.setStyle", json!({"size": 24}));
s.execute("text.setStyle", &json!({"tracking": 50, "fill": "#ff0000"})).unwrap();
records_nothing(&mut s, "text.setStyle", json!({"tracking": 50, "fill": "#ff0000"}));
records_nothing(&mut s, "text.setStyle", json!({"tracking": 50, "start": 0, "end": 4}));
s.execute("text.setStyle", &json!({"justify": "center"})).unwrap();
records_nothing(&mut s, "text.setStyle", json!({"justify": "center"}));
records_one_step(&mut s, "text.setStyle", json!({"tracking": 80, "start": 0, "end": 4}));
}
/// `text.setStyle` on a graph label that leaves the label's own text as it is but changes the
/// style its kind of label keeps still records one undo step; given again, it records none.
#[test]
fn a_kept_graph_label_style_change_alone_records_one_undo_step() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let csv = ",2024,2025\nQ1,3,2\nQ2,5,4";
let graph = NodeId(
s.execute("graph.create", &json!({"type": "column", "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"]
.as_u64()
.unwrap(),
);
let doc = s.doc().unwrap().doc.clone();
let legend = doc.node(graph).unwrap().children().unwrap().iter().find(|c| c.name.as_deref() == Some("Legend")).unwrap();
let (label, size) = legend
.children()
.unwrap()
.iter()
.find_map(|c| match &c.kind {
NodeKind::Text(t) if t.plain_text() == "2025" => Some((c.id, t.runs[0].style.size)),
_ => None,
})
.unwrap();
let kept = |s: &Session| s.doc().unwrap().doc.node(graph).unwrap().extras().graph.as_deref().unwrap().label_styles.legend.clone();
assert!(kept(&s).is_none());
let p = json!({"ids": [label.0], "size": size});
let before = state(&s);
s.execute("text.setStyle", &p).unwrap();
let after = state(&s);
assert!(after.0.node(label) == before.0.node(label), "the label's own text changed");
assert!(kept(&s).is_some(), "the legend keeps the style");
assert_eq!(after.1, before.1 + 1, "the kept style change recorded no undo step");
records_nothing(&mut s, "text.setStyle", p);
}
/// `text.setText` with the text the type already has records no undo step; new text records one.
#[test]
fn the_same_text_records_no_undo_step() {
let (mut s, _) = session_with("Same words", None);
records_nothing(&mut s, "text.setText", json!({"text": "Same words"}));
records_one_step(&mut s, "text.setText", json!({"text": "Other words"}));
}
/// Fit Headline run again on a headline it already fitted records no undo step; the first run
/// records one.
#[test]
fn fit_headline_again_records_no_undo_step() {
let (mut s, _) = session_with("HEADLINE\nbody text", Some(json!({"width": 300, "height": 120})));
let start = state(&s);
let tracking = s.execute("text.fitHeadline", &json!({})).unwrap()["tracking"].clone();
let fitted = state(&s);
assert!(*fitted.0 != *start.0 && fitted.1 == start.1 + 1, "the first Fit Headline did not record one undo step");
let before = state(&s);
assert_eq!(s.execute("text.fitHeadline", &json!({})).unwrap()["tracking"], tracking);
let after = state(&s);
assert!(*after.0 == *before.0 && after.1 == before.1, "a second Fit Headline recorded an undo step");
}
/// Reshaping area type or type on a path by no distance, or moving a path handle to where it is,
/// records no undo step; a reshape that moves something records one.
#[test]
fn reshaping_type_by_nothing_records_no_undo_step() {
let (mut s, id) = session_with("Boxed words", Some(json!({"width": 200, "height": 100})));
records_nothing(&mut s, "text.reshapeArea", json!({"id": id, "anchors": [[0, 2]], "dx": 0, "dy": 0}));
records_one_step(&mut s, "text.reshapeArea", json!({"id": id, "anchors": [[0, 2]], "dx": 20, "dy": 10}));
let path =
s.execute("path.create", &json!({"anchors": [{"x": 100, "y": 300, "in": [50, 300], "out": [150, 300]}, {"x": 400, "y": 300}]})).unwrap();
let on_path = s.execute("text.createInPath", &json!({"path": path["id"], "mode": "onPath", "text": "Along the curve"})).unwrap()["id"].clone();
records_nothing(&mut s, "text.reshapePath", json!({"id": on_path, "anchors": [[0, 1]], "dx": 0, "dy": 0}));
records_one_step(&mut s, "text.reshapePath", json!({"id": on_path, "anchors": [[0, 1]], "dx": 0, "dy": 20}));
let handle = json!({"id": on_path, "anchor": 0, "which": "out", "x": 100, "y": 250});
records_one_step(&mut s, "text.setPathHandle", handle.clone());
records_nothing(&mut s, "text.setPathHandle", handle);
}
+4
View File
@@ -111,6 +111,9 @@ pub struct Prefs {
pub recent_fonts_count: u32,
pub missing_glyph_protection: bool,
pub highlight_alternate_glyphs: bool,
/// Type › Show Character Alternates: with a Type tool and one character selected, the canvas
/// shows that character's alternate glyphs beside it (on by default).
pub show_character_alternates: bool,
/// Type › Highlight Substituted Fonts: on screen, text drawn in a substitute for a missing
/// font is highlighted, in every document (on by default).
pub highlight_substituted_fonts: bool,
@@ -442,6 +445,7 @@ impl Default for Prefs {
recent_fonts_count: 10,
missing_glyph_protection: true,
highlight_alternate_glyphs: false,
show_character_alternates: true,
highlight_substituted_fonts: true,
placeholder_text: true,
typed_lists: true,
+6 -4
View File
@@ -415,14 +415,16 @@ impl Session {
/// Measure the type not measured yet in open document `index`, which is in the back, in its own
/// composite fonts, lent for the measuring, so its type has the bounds it will have when the
/// document comes to the front. The front document stays the one whose composite fonts are
/// registered. The names of the document's composite fonts its type was measured in are kept,
/// so its coming to the front does not measure that type again.
/// document comes to the front: type naming a composite font the document does not define is
/// measured as in a missing font, also when the front document defines one of that name, and
/// also when the document defines none. The front document stays the one whose composite fonts
/// are registered. The names of the document's composite fonts its type was measured in are
/// kept, so its coming to the front does not measure that type again.
fn measure_back_document(&mut self, index: usize) {
let Some(st) = self.docs.get(index) else { return };
let fonts = st.doc.composite_fonts.clone();
let db = vectorcraft_text::FontDb::global();
let lent = db.lend_composite_fonts(&fonts);
let lent = db.lend_document_composite_fonts(&fonts);
// A name another loan holds with a different definition is not lent: type set in it is
// measured again when the document comes to the front.
let own: HashSet<String> =
+36
View File
@@ -301,6 +301,42 @@ fn an_export_preset_sets_the_shared_settings() {
assert_eq!(run(&mut s, "document.exportSettings", json!({}))["settings"]["prefix"], "p_");
}
/// `openLocation` (Open Location after Export) is remembered in the document's export settings,
/// outside presets; null removes it, and only true or false is taken.
#[test]
fn open_location_is_remembered_in_the_export_settings() {
let mut s = session();
let open = |s: &mut Session| run(s, "document.exportSettings", json!({}))["settings"]["openLocation"].clone();
let r = run(&mut s, "assets.settings.set", json!({"openLocation": false}));
assert_eq!(open(&mut s), false);
assert!(r["settings"].get("openLocation").is_none(), "{r}");
// Create Sub-folders, on for a document without settings, stays on.
assert_eq!(r["settings"]["subfolders"], true, "{r}");
run(&mut s, "document.exportPresets.save", json!({"name": "Kept", "settings": {"formats": [{"format": "svg"}]}}));
run(&mut s, "assets.settings.set", json!({"exportPreset": "Kept"}));
assert_eq!(open(&mut s), false, "a preset leaves it as it is");
run(&mut s, "assets.settings.set", json!({"openLocation": null}));
assert_eq!(open(&mut s), Value::Null);
for bad in [json!({"openLocation": "yes"}), json!({"openLocation": 1}), json!({"subfolders": "no"})] {
assert!(s.execute("assets.settings.set", &bad).is_err(), "{bad}");
}
}
/// Create Sub-folders turned off on its own ends a remembered folder per format or item, so Export
/// for Screens opens with it off too; given with an export preset, the preset's folders stay.
#[test]
fn create_sub_folders_off_alone_ends_a_folder_per_format_or_item() {
let mut s = session();
let folders = |s: &mut Session| run(s, "document.exportSettings", json!({}))["settings"]["folders"].clone();
run(&mut s, "document.exportPresets.save", json!({"name": "Items", "settings": {"formats": [{"format": "svg"}], "folders": "item"}}));
for preset in ["Logo Package", "Items"] {
run(&mut s, "assets.settings.set", json!({"exportPreset": preset, "subfolders": false}));
assert_ne!(folders(&mut s), "none", "{preset}");
run(&mut s, "assets.settings.set", json!({"subfolders": false}));
assert_eq!(folders(&mut s), "none", "{preset}");
}
}
/// An export preset applied to the shared settings keeps its folders, with Create Sub-folders on
/// for a folder per row; with Create Sub-folders off every file goes into the folder itself,
/// mobile and density rows too.
+70
View File
@@ -3018,6 +3018,76 @@ fn live_paint_expand_keeps_the_faces_and_edges_that_draw() {
assert_eq!(rows(&n, lower_stroke), rows(&before, lower_stroke));
}
/// `livePaint.info` lists every fill and stroke row of each face and edge, bottom first, and says
/// which rows draw by Expand's rule, while `fill`, `stroke` and `width` stay the top rows.
#[test]
fn live_paint_info_lists_every_appearance_row() {
use vectorcraft_doc::{FillLayer, StrokeLayer};
let fill = |p: Paint| AppearanceItem::Fill(FillLayer::new(p));
let stroke = |p: Paint, w: f64| AppearanceItem::Stroke(StrokeLayer::new(p, w));
let red = || Paint::solid(Color::rgb(1.0, 0.0, 0.0));
let blue = || Paint::solid(Color::rgb(0.0, 0.0, 1.0));
let green = || Paint::solid(Color::rgb(0.0, 1.0, 0.0));
let hidden = |mut i: AppearanceItem| {
match &mut i {
AppearanceItem::Fill(f) => f.visible = false,
AppearanceItem::Stroke(st) => st.visible = false,
}
i
};
let faded = |mut i: AppearanceItem, o: f32| {
match &mut i {
AppearanceItem::Fill(f) => f.opacity = o,
AppearanceItem::Stroke(st) => st.opacity = o,
}
i
};
let mut s = session();
let g = live(&mut s);
// A drawn lower fill and a half-opaque stroke under an unpainted top fill.
let lower = set_live_part(&mut s, g, Some((25.0, 50.0)), None, vec![fill(red()), faded(stroke(blue(), 2.0), 0.5), fill(Paint::None)], 1.0);
let quiet = set_live_part(&mut s, g, Some((125.0, 50.0)), None, vec![hidden(fill(red())), faded(fill(red()), 0.0)], 1.0);
let bare = set_live_part(&mut s, g, Some((75.0, 50.0)), None, vec![], 1.0);
// Two strokes with a fill between them.
let two = set_live_part(&mut s, g, None, Some((0.0, 50.0)), vec![stroke(red(), 4.0), fill(green()), stroke(blue(), 2.0)], 1.0);
let thin = set_live_part(&mut s, g, None, Some((100.0, 50.0)), vec![stroke(red(), 0.0)], 1.0);
let clear_edge = set_live_part(&mut s, g, None, Some((75.0, 0.0)), vec![stroke(red(), 2.0)], 0.0);
let hidden_edge = set_live_part(&mut s, g, None, Some((150.0, 50.0)), vec![stroke(red(), 2.0)], 1.0);
s.edit("t", |d, _| {
d.node_mut(hidden_edge).unwrap().visible = false;
Ok(())
})
.unwrap();
let parts = [lower, quiet, bare, two, thin, clear_edge, hidden_edge];
assert_eq!(parts.iter().collect::<std::collections::HashSet<_>>().len(), parts.len());
let info = s.execute("livePaint.info", &json!({"group": g.0})).unwrap();
let part = |key: &str, id: NodeId| info[key].as_array().unwrap().iter().find(|p| p["id"] == id.0).unwrap().clone();
let fill_row = |i: usize, p: Paint, o: f64, v: bool, drawn: bool| json!({"index": i, "kind": "fill", "paint": p.label(), "opacity": o, "visible": v, "drawn": drawn});
let stroke_row = |i: usize, p: Paint, o: f64, v: bool, w: f64, drawn: bool| json!({"index": i, "kind": "stroke", "paint": p.label(), "opacity": o, "visible": v, "width": w, "drawn": drawn});
let f = part("faces", lower);
assert_eq!(f["fill"], json!(Paint::None.label()), "fill stays the top fill");
assert!(f["bounds"].is_array());
assert_eq!(
f["appearance"],
json!([fill_row(0, red(), 1.0, true, true), stroke_row(1, blue(), 0.5, true, 2.0, true), fill_row(2, Paint::None, 1.0, true, false)])
);
assert_eq!(part("faces", quiet)["appearance"], json!([fill_row(0, red(), 1.0, false, false), fill_row(1, red(), 0.0, true, false)]));
assert_eq!(part("faces", bare)["appearance"], json!([]));
let e = part("edges", two);
assert_eq!((e["stroke"].clone(), e["width"].clone()), (json!(blue().label()), json!(2.0)), "stroke and width stay the top stroke's");
assert_eq!(
e["appearance"],
json!([stroke_row(0, red(), 1.0, true, 4.0, true), fill_row(1, green(), 1.0, true, true), stroke_row(2, blue(), 1.0, true, 2.0, true)])
);
assert_eq!(part("edges", thin)["appearance"], json!([stroke_row(0, red(), 1.0, true, 0.0, false)]));
for id in [clear_edge, hidden_edge] {
assert_eq!(part("edges", id)["appearance"], json!([stroke_row(0, red(), 1.0, true, 2.0, false)]), "a part that is not shown draws no row");
}
}
/// Merge carries a face's whole appearance when one of its rows draws, and carries nothing from a
/// face that draws nothing.
#[test]
+32 -7
View File
@@ -1034,14 +1034,39 @@ fn in_place_pastes_put_a_copy_on_each_artboard_named() {
// `artboard` wins over `artboards`.
let ids = ids_of(&s.execute(cmd, &json!({"artboard": 2, "artboards": [0, 3]})).unwrap());
assert_eq!(doc(&s).bounds_of(&ids, false).unwrap().origin(), Point::new(boards[2].x0 + 10.0, boards[2].y0 + 5.0), "{cmd}");
// A list that is empty, names an artboard the document lacks or names one twice, or isn't a
// list: an error, and nothing pasted.
let before = doc(&s).clone();
for bad in [json!([]), json!([0, 4]), json!([1, 1]), json!([0, -1]), json!(2), json!("0,1")] {
assert!(s.execute(cmd, &json!({"artboards": bad})).is_err(), "{cmd} with artboards {bad}");
// A list that is empty, names an artboard the document lacks or names one twice, holds a
// value that is not a whole number, or isn't a list, and an `artboard` that is not a whole
// number or not an artboard: an error naming the fault, and nothing pasted. Both keys are
// checked when both are given.
let (before, undo) = (doc(&s).clone(), s.doc().unwrap().history.undo.len());
let (not_list, missing, not_whole) = (
"`artboards` must be a list of artboard indices",
"`artboards` names an artboard the document does not have",
"`artboards` must contain whole numbers",
);
let cases = [
(json!({"artboards": []}), not_list),
(json!({"artboards": 2}), not_list),
(json!({"artboards": "0,1"}), not_list),
(json!({"artboards": [0, 4]}), missing),
(json!({"artboards": [1, 1]}), "`artboards` names an artboard twice"),
(json!({"artboards": [0, -1]}), not_whole),
(json!({"artboards": [0, 1.5]}), not_whole),
(json!({"artboards": [4, "1"]}), not_whole),
(json!({"artboard": 1, "artboards": [7]}), missing),
(json!({"artboard": 1, "artboards": ["1"]}), not_whole),
(json!({"artboard": 4}), "`artboard` must be the index of an artboard of the document"),
(json!({"artboard": -1}), "`artboard` must be a whole number"),
(json!({"artboard": 1.5}), "`artboard` must be a whole number"),
(json!({"artboard": "1"}), "`artboard` must be a whole number"),
(json!({"artboard": null}), "`artboard` must be a whole number"),
(json!({"artboard": "1", "artboards": [9]}), "`artboard` must be a whole number"),
];
for (p, want) in cases {
let e = s.execute(cmd, &p).unwrap_err().to_string();
assert_eq!(e, format!("invalid parameters for `{cmd}`: {want}"), "{cmd} {p}");
}
assert!(s.execute(cmd, &json!({"artboard": 1, "artboards": [7]})).is_err(), "{cmd}: a bad list with `artboard`");
assert_eq!(doc(&s), &before, "{cmd}");
assert_eq!((doc(&s), s.doc().unwrap().history.undo.len()), (&before, undo), "{cmd}");
}
}
+254
View File
@@ -2023,6 +2023,260 @@ fn smoothing_leaves_paths_out_of_reach_and_straight_segments_alone() {
assert_eq!(path(&s, line), before);
}
/// The turn, in degrees, of `sp` at each of its anchors: round a closed path, and 0 at an open
/// path's first and last anchors.
fn smooth_anchor_turns(sp: &vectorcraft_geom::SubPath) -> Vec<f64> {
let (n, segs) = (sp.anchors.len(), sp.segment_count());
(0..n)
.map(|i| {
if segs == 0 || !sp.closed && (i == 0 || i == n - 1) {
return 0.0;
}
let (a, b) = (sp.segment((i + segs - 1) % segs), sp.segment(i % segs));
let tin = [a.p3 - a.p2, a.p3 - a.p1, a.p3 - a.p0].into_iter().find(|d| d.hypot() > 1e-9).unwrap_or_default();
let tout = [b.p1 - b.p0, b.p2 - b.p0, b.p3 - b.p0].into_iter().find(|d| d.hypot() > 1e-9).unwrap_or_default();
tin.cross(tout).atan2(tin.dot(tout)).abs().to_degrees()
})
.collect()
}
/// The turn of `sp` at its anchor on `at`, `None` when no anchor is there.
fn smooth_turn_at(sp: &vectorcraft_geom::SubPath, at: Point) -> Option<f64> {
let i = sp.anchors.iter().position(|a| a.p.distance(at) < 1e-9)?;
smooth_anchor_turns(sp).get(i).copied()
}
/// An open polyline with a corner at (300, 300) whose angle inside is `inside` degrees: a 100 pt
/// leg along y = 300 from the left, then a 100 pt leg turning by 180 - `inside` degrees, each leg cut
/// into `per_leg` segments. Returns the path, the corner, and a drag along the path from 40 pt
/// before the corner to 40 pt after it.
fn smooth_corner_path(s: &mut Session, inside: f64, per_leg: usize) -> (NodeId, Point, Vec<Point>) {
let v = Point::new(300.0, 300.0);
let d = kurbo::Vec2::from_angle(-(180.0 - inside).to_radians());
let back = kurbo::Vec2::new(-100.0, 0.0);
let legs = per_leg as f64;
let mut pts: Vec<Point> = (0..per_leg).map(|k| v + back * (1.0 - k as f64 / legs)).collect();
pts.extend((0..=per_leg).map(|k| v + d * (100.0 * k as f64 / legs)));
let anchors: Vec<_> = pts.iter().map(|p| json!({"x": p.x, "y": p.y})).collect();
let id = smooth_test_path(s, &anchors, false);
let mut drag: Vec<Point> = (0..8).map(|k| v - kurbo::Vec2::new(40.0 - 5.0 * f64::from(k), 0.0)).collect();
drag.extend((0..=8).map(|k| v + d * (5.0 * f64::from(k))));
(id, v, drag)
}
/// A Smooth drag over a corner keeps it where the path turns there by more than 57.5° (an angle
/// inside the corner under 122.5°), whether each leg is one segment or ten and at any Fidelity. A
/// wider corner is not kept: when the drag changes the path, no anchor is left turning as sharply
/// there, and a leg of ten segments at the default Fidelity always rounds it.
#[test]
fn the_smooth_tool_keeps_corners_sharper_than_its_threshold() {
let mut wrong = vec![];
for inside in [30.0f64, 60.0, 90.0, 120.0, 122.4, 122.6, 125.0, 135.0, 150.0, 170.0] {
let kept = inside < 122.5;
for per_leg in [1, 10] {
for fidelity in [0.5, 2.5, 10.0] {
let mut s = session();
let (id, v, drag) = smooth_corner_path(&mut s, inside, per_leg);
let before = path(&s, id);
let pts: Vec<_> = drag.iter().map(|p| json!([p.x, p.y])).collect();
s.execute("path.smoothRegion", &json!({"points": pts, "radius": 12, "fidelity": fidelity})).unwrap();
let p = path(&s, id);
let turn = 180.0 - inside;
let at = smooth_turn_at(&p.subpaths[0], v);
let changed = p != before;
let rounded = changed && at.is_none_or(|t| t < turn - 1.0);
let right = if kept { at.is_some_and(|t| (t - turn).abs() < 0.01) } else { rounded || !changed && per_leg == 1 };
let most = smooth_anchor_turns(&p.subpaths[0]).into_iter().fold(0.0, f64::max);
if !right || !kept && most > 57.5 || !kept && per_leg == 10 && fidelity == 2.5 && !rounded {
wrong.push(format!(
"{inside}° inside, {per_leg} segments a leg, Fidelity {fidelity}: turn {at:?}, changed {changed}, sharpest {most}°"
));
}
}
}
}
assert!(wrong.is_empty(), "{wrong:#?}");
}
/// Three Smooth drags over a 90° and a 120° corner leave the corner where it was and as sharp, while
/// the first one smooths a wobble on the same leg.
#[test]
fn smooth_drags_again_and_again_keep_a_corner_in_place() {
for inside in [90.0f64, 120.0] {
let mut s = session();
let v = Point::new(300.0, 300.0);
let d = kurbo::Vec2::from_angle(-(180.0 - inside).to_radians());
let mut anchors: Vec<_> = (0..=18).map(|k| json!({"x": 180.0 + 5.0 * f64::from(k), "y": if k % 2 == 1 { 301.5 } else { 300.0 }})).collect();
anchors.push(json!({"x": v.x, "y": v.y}));
let c = v + d * 100.0;
anchors.push(json!({"x": c.x, "y": c.y}));
let id = smooth_test_path(&mut s, &anchors, false);
let mut drag = smooth_drag_along(300.0, 175.0, 300.0);
drag.extend((1..=8).map(|k| v + d * (5.0 * f64::from(k))));
let mut before = path(&s, id);
for pass in 1..=3 {
smooth_pass(&mut s, &drag);
let p = path(&s, id);
let at = smooth_turn_at(&p.subpaths[0], v);
assert!(at.is_some_and(|t| (t - (180.0 - inside)).abs() < 0.01), "{inside}°, pass {pass}: {at:?}");
if pass == 1 {
assert!(p.anchor_count() < before.anchor_count(), "{inside}°: the wobble is smoothed: {} anchors", p.anchor_count());
assert!(total_turning(&p) < total_turning(&before), "{inside}°: the path turns less");
}
before = p;
}
}
}
/// A Smooth drag along a line with 1 pt jags, each turning by 90°, smooths them all: none is kept
/// as a corner, since the line hardly turns over the tool's reach.
#[test]
fn smoothing_keeps_no_small_jag_as_a_corner() {
let mut s = session();
let anchors: Vec<_> = (0..=200).map(|i| json!({"x": 100.0 + f64::from(i), "y": if i % 2 == 1 { 201.0 } else { 200.0 }})).collect();
let id = smooth_test_path(&mut s, &anchors, false);
assert!(smooth_anchor_turns(&path(&s, id).subpaths[0]).iter().skip(1).take(199).all(|t| (t - 90.0).abs() < 1e-6));
smooth_pass(&mut s, &smooth_drag_along(200.5, 90.0, 310.0));
let p = path(&s, id);
let sp = &p.subpaths[0];
assert!(sp.anchors.len() < 50, "the jags are smoothed: {} anchors", sp.anchors.len());
let most = smooth_anchor_turns(sp).into_iter().fold(0.0, f64::max);
assert!(most <= 57.5, "a jag is kept, turning {most}°");
}
/// A closed path with 90° corners at (200, 200), where it starts, and at (400, 200), 75° ones at
/// (400, 400) and (200, 400), a 30° bend at (300, 426.79), and a top side jagged by 1 pt. With
/// `from_bend` the path starts at the bend instead.
fn smooth_house(s: &mut Session, from_bend: bool) -> (NodeId, [Point; 4], Point) {
let corners = [Point::new(200.0, 200.0), Point::new(400.0, 200.0), Point::new(400.0, 400.0), Point::new(200.0, 400.0)];
let bend = Point::new(300.0, 400.0 + 100.0 / 75f64.to_radians().tan());
let mut anchors = vec![json!({"x": 200.0, "y": 200.0})];
anchors.extend((1..40).map(|k| json!({"x": 200.0 + 5.0 * f64::from(k), "y": if k % 2 == 1 { 201.0 } else { 200.0 }})));
for p in [corners[1], corners[2], bend, corners[3]] {
anchors.push(json!({"x": p.x, "y": p.y}));
}
if from_bend {
let at_bend = anchors.len() - 2;
anchors.rotate_left(at_bend);
}
(smooth_test_path(s, &anchors, true), corners, bend)
}
/// A Smooth drag round the whole of a closed path, and one along its top side only, keep its corners,
/// the one where the path starts included, and smooth the jags; the path still starts where it did,
/// at a corner or at the bend, which the refit keeps, and the bend out of the second drag's reach
/// stays as it was.
#[test]
fn smoothing_a_closed_path_keeps_its_corners() {
let turns: Vec<f64> = {
let mut s = session();
let (id, corners, _) = smooth_house(&mut s, false);
corners.iter().map(|c| smooth_turn_at(&path(&s, id).subpaths[0], *c).unwrap()).collect()
};
for from_bend in [false, true] {
let mut s = session();
let (id, corners, bend) = smooth_house(&mut s, from_bend);
let before = path(&s, id);
let mut ring: Vec<Point> = vec![];
for (a, b) in [(corners[0], corners[1]), (corners[1], corners[2]), (corners[2], bend), (bend, corners[3]), (corners[3], corners[0])] {
let n = (a.distance(b) / 5.0).ceil();
ring.extend((0..n as usize).map(|k| a.lerp(b, k as f64 / n)));
}
ring.push(corners[0]);
smooth_pass(&mut s, &ring);
let p = path(&s, id);
let sp = &p.subpaths[0];
assert!(sp.closed && sp.anchors.len() < before.subpaths[0].anchors.len(), "{} anchors", sp.anchors.len());
assert_eq!(sp.anchors[0].p, before.subpaths[0].anchors[0].p, "the path still starts where it did");
for (c, t) in corners.iter().zip(&turns) {
let at = smooth_turn_at(sp, *c);
assert!(at.is_some_and(|a| (a - t).abs() < 0.01), "the corner at {c:?} turning {t}° is kept: {at:?}");
}
}
let mut s = session();
let (id, corners, bend) = smooth_house(&mut s, false);
let before = path(&s, id);
smooth_pass(&mut s, &smooth_drag_along(200.0, 190.0, 410.0));
let p = path(&s, id);
let sp = &p.subpaths[0];
assert!(sp.closed && sp.anchors.len() < before.subpaths[0].anchors.len(), "the top is smoothed: {} anchors", sp.anchors.len());
for (c, t) in corners.iter().zip(&turns) {
let at = smooth_turn_at(sp, *c);
assert!(at.is_some_and(|a| (a - t).abs() < 0.01), "part of the path: the corner at {c:?} turning {t}° is kept: {at:?}");
}
assert!(smooth_turn_at(sp, bend).is_some_and(|t| (t - 30.0).abs() < 0.01), "the bend out of reach stays");
}
/// A Smooth drag over an anchor whose curved legs meet at a corner keeps the corner, taking the turn
/// from the directions the curves arrive and leave along, a handle of no length included.
#[test]
fn smoothing_keeps_a_corner_between_curves() {
use kurbo::ParamCurve;
let v = Point::new(200.0, 300.0);
let with_in = json!({"x": 200.0, "y": 300.0, "in": [170.0, 270.0], "out": [230.0, 270.0]});
let no_in = json!({"x": 200.0, "y": 300.0, "out": [230.0, 270.0]});
for (corner, first_out) in [(with_in, [130.0, 260.0]), (no_in, [170.0, 230.0])] {
let mut s = session();
let anchors = [json!({"x": 100.0, "y": 300.0, "out": first_out}), corner, json!({"x": 300.0, "y": 300.0, "in": [270.0, 260.0]})];
let id = smooth_test_path(&mut s, &anchors, false);
let before = path(&s, id);
let turn = smooth_turn_at(&before.subpaths[0], v).unwrap();
assert!(turn > 80.0, "{turn}");
let (a, b) = (before.subpaths[0].segment(0), before.subpaths[0].segment(1));
let mut drag: Vec<Point> = (0..=10).map(|k| a.eval(0.6 + 0.04 * f64::from(k))).collect();
drag.extend((1..=10).map(|k| b.eval(0.04 * f64::from(k))));
smooth_pass(&mut s, &drag);
let at = smooth_turn_at(&path(&s, id).subpaths[0], v);
assert!(at.is_some_and(|t| (t - turn).abs() < 0.01), "the corner turning {turn}° is kept: {at:?}");
}
}
/// A corner at the first anchor of the stretch a drag smooths, just out of its reach, keeps its
/// place and its turn.
#[test]
fn a_corner_at_the_end_of_the_smoothed_stretch_stays() {
let mut s = session();
let mut anchors = vec![json!({"x": 100.0, "y": 300.0}), json!({"x": 200.0, "y": 300.0})];
anchors.extend((1..=20).map(|k| json!({"x": if k % 2 == 1 { 201.0 } else { 200.0 }, "y": 300.0 - 5.0 * f64::from(k)})));
let id = smooth_test_path(&mut s, &anchors, false);
let before = path(&s, id);
let turn = smooth_turn_at(&before.subpaths[0], Point::new(200.0, 300.0)).unwrap();
let drag: Vec<Point> = (0..=19).map(|k| Point::new(200.0, 287.0 - 5.0 * f64::from(k))).collect();
smooth_pass(&mut s, &drag);
let p = path(&s, id);
let sp = &p.subpaths[0];
assert_ne!(p, before, "the jagged leg is smoothed");
assert_eq!(sp.anchors[0], before.subpaths[0].anchors[0]);
let at = smooth_turn_at(sp, Point::new(200.0, 300.0));
assert!(at.is_some_and(|t| (t - turn).abs() < 0.01), "the corner turning {turn}° stays: {at:?}");
}
/// A Smooth drag over corners it keeps, with nothing else to smooth, changes nothing and adds no
/// undo step: an open path's corner and a square's four corners.
#[test]
fn a_smooth_drag_that_only_meets_corners_adds_no_undo_step() {
let mut s = session();
let (id, v, drag) = smooth_corner_path(&mut s, 90.0, 1);
let before = path(&s, id);
let undo = s.doc().unwrap().history.undo.len();
assert_eq!(smooth_pass_ids(&mut s, &drag), json!([]), "a corner at {v:?}");
assert_eq!(path(&s, id), before);
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
let square = [(100.0, 100.0), (200.0, 100.0), (200.0, 200.0), (100.0, 200.0)].map(|(x, y)| json!({"x": x, "y": y}));
let id = smooth_test_path(&mut s, &square, true);
let before = path(&s, id);
let undo = s.doc().unwrap().history.undo.len();
let mut ring = smooth_drag_along(100.0, 100.0, 200.0);
ring.extend((1..=20).map(|k| Point::new(200.0, 100.0 + 5.0 * f64::from(k))));
ring.extend(smooth_drag_along(200.0, 100.0, 200.0).into_iter().rev().skip(1));
ring.extend((1..=20).map(|k| Point::new(100.0, 200.0 - 5.0 * f64::from(k))));
assert_eq!(smooth_pass_ids(&mut s, &ring), json!([]), "a square");
assert_eq!(path(&s, id), before);
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
}
#[test]
fn join_tool_joins_two_lines() {
let mut s = session();
+164 -8
View File
@@ -4,7 +4,7 @@
use serde_json::{Value, json};
use vectorcraft_doc::{Node, NodeKind};
use vectorcraft_geom::{Affine, PathData, Rect};
use vectorcraft_geom::{Affine, PathData, Point, Rect};
use crate::*;
@@ -320,16 +320,16 @@ fn global_edit_needs_one_supported_object() {
let t = NodeId(s.execute("text.create", &json!({"x": 50, "y": 500, "text": "Hi"})).unwrap()["id"].as_u64().unwrap());
sel(&mut s, &[t]);
assert!(matches!(s.execute("select.globalEdit", &json!({})), Err(EngineError::Disabled(..))), "type");
let lone = rect(&mut s, 0.0, 500.0, 30.0, 70.0);
// A copy turned a quarter turn (70 × 30) is not taken: rotated copies are not matched, and the
// message says so.
let turned = rect(&mut s, 200.0, 500.0, 30.0, 70.0);
sel(&mut s, &[turned]);
s.execute("object.rotate", &json!({"angle": 90})).unwrap();
// A reflected copy is not taken, and the message says so.
let lone = kite(&mut s, 0.0, 500.0, 1.0);
let mirrored = kite(&mut s, 200.0, 500.0, 1.0);
sel(&mut s, &[mirrored]);
s.execute("object.reflect", &json!({"axis": "vertical"})).unwrap();
sel(&mut s, &[lone]);
let e = s.execute("select.globalEdit", &json!({})).unwrap_err().to_string();
assert!(e.contains("no similar objects") && e.contains("rotated"), "{e}");
assert!(e.contains("no similar objects") && e.contains("reflected copies are not matched") && !e.contains("rotated"), "{e}");
assert!(s.doc().unwrap().global_edit.is_none());
assert!(crate::cmd::globaledit::highlights(&s).is_empty(), "and it is not highlighted");
// A group holding type is not offered, though another such group exists.
let group = |s: &mut Session, x: f64| {
let p = rect(s, x, 100.0, 10.0, 10.0);
@@ -851,3 +851,159 @@ fn groups_nested_as_deep_as_a_document_allows_are_edited_together() {
s.execute("paint.setFill", &json!({"color": "#12ab34"})).unwrap();
assert_eq!(fill(&s, other_inner), "#12ab34");
}
/// A four-sided path with no symmetry, `k` times the size, its box's top left near (`x`, `y`): a
/// 30 × 70 rectangle whose first anchor is moved.
fn kite(s: &mut Session, x: f64, y: f64, k: f64) -> NodeId {
let id = rect(s, x, y, 30.0 * k, 70.0 * k);
s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 0]]})).unwrap();
s.execute("path.moveAnchors", &json!({"dx": 10.0 * k, "dy": -20.0 * k})).unwrap();
id
}
/// Rotate `id` by `deg` (counter-clockwise) about the centre of its box → that rotation.
fn rotate(s: &mut Session, id: NodeId, deg: f64) -> Affine {
let c = bounds(s, id).center();
sel(s, &[id]);
s.execute("object.rotate", &json!({"angle": deg})).unwrap();
Affine::rotate_about(-deg.to_radians(), c)
}
/// A kite at the origin side of the page, a copy of it turned a quarter turn, and a copy at half
/// the size turned by 30° → the three and the transforms that lay the first on the other two.
fn turned_kites(s: &mut Session) -> ([NodeId; 3], [Affine; 2]) {
let a = kite(s, 0.0, 100.0, 1.0);
let (b, c) = (kite(s, 200.0, 100.0, 1.0), kite(s, 400.0, 100.0, 0.5));
let quarter = rotate(s, b, 90.0) * Affine::translate((200.0, 0.0));
let small = rotate(s, c, 30.0) * Affine::translate((400.0, 100.0)) * Affine::scale(0.5) * Affine::translate((0.0, -100.0));
assert!(placed(s, a, b, quarter) && placed(s, a, c, small), "the copies as made");
([a, b, c], [quarter, small])
}
#[test]
fn start_global_edit_takes_rotated_copies_and_an_edit_reaches_each_turned_by_its_angle() {
let mut s = session();
let ([a, b, c], [quarter, small]) = turned_kites(&mut s);
sel(&mut s, &[a]);
assert_eq!(crate::cmd::globaledit::highlights(&s).len(), 2, "both are highlighted before the start");
assert_eq!(s.execute("select.globalEditOptions", &json!({})).unwrap()["count"], 3);
assert_eq!(start(&mut s, a, json!({})), [a, b, c]);
let before = (*s.doc().unwrap().doc).clone();
let steps = s.doc().unwrap().history.undo.len();
// An anchor moved on the selected kite moves on each copy along the copy's own directions.
s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 1]]})).unwrap();
s.execute("path.moveAnchors", &json!({"dx": 12, "dy": 5})).unwrap();
assert!(placed(&s, a, b, quarter), "the quarter-turned copy");
assert!(placed(&s, a, c, small), "the smaller copy turned by 30°");
assert!(!placed(&s, a, b, Affine::translate((200.0, 0.0))), "the copy is still turned");
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "one undo step");
crate::tests_large_edits::undo_gives_back(&mut s, &before);
// A move of the whole kite to the right moves the quarter-turned copy up (counter-clockwise).
sel(&mut s, &[a]);
let was = bounds(&s, b);
s.execute("object.move", &json!({"dx": 10, "dy": 0})).unwrap();
let now = bounds(&s, b);
assert!((now.x0 - was.x0).abs() < 1e-6 && (now.y0 - (was.y0 - 10.0)).abs() < 1e-6, "{was:?} → {now:?}");
assert!(placed(&s, a, b, quarter) && placed(&s, a, c, small), "each copy lies on the kite as before");
}
#[test]
fn a_rotated_copy_leads_an_edit_as_any_other_member_does() {
let mut s = session();
let ([a, b, c], [quarter, small]) = turned_kites(&mut s);
// Started on the turned copy, and edited from the smaller one.
assert_eq!(start(&mut s, b, json!({})), [b, a, c]);
s.execute("select.anchors", &json!({"id": c.0, "anchors": [[0, 2]]})).unwrap();
s.execute("path.moveAnchors", &json!({"dx": 3, "dy": -4})).unwrap();
assert!(placed(&s, a, b, quarter) && placed(&s, a, c, small));
// A fill reaches the turned copies as it is.
s.execute("paint.setFill", &json!({"color": "#12ab34"})).unwrap();
assert_eq!((fill(&s, a), fill(&s, b)), ("#12ab34".to_string(), "#12ab34".to_string()));
}
#[test]
fn size_takes_a_rotated_copy_of_the_same_size_and_other_shapes_stay_out() {
let mut s = session();
let ([a, b, c], _) = turned_kites(&mut s);
// A quarter-turned copy is 70 × 30 by its box: it has the size of the 30 × 70 kite all the same.
assert_eq!(start(&mut s, a, json!({"size": true})), [a, b], "not the half-size copy {c}");
s.execute("select.globalEdit", &json!({})).unwrap();
// A copy turned and then stretched, and one whose anchor was moved, are other shapes.
let stretched = kite(&mut s, 0.0, 300.0, 1.0);
rotate(&mut s, stretched, 30.0);
s.execute("object.scale", &json!({"sx": 150, "sy": 100})).unwrap();
let bent = kite(&mut s, 200.0, 300.0, 1.0);
rotate(&mut s, bent, 45.0);
s.execute("select.anchors", &json!({"id": bent.0, "anchors": [[0, 2]]})).unwrap();
s.execute("path.moveAnchors", &json!({"dx": 1, "dy": 0})).unwrap();
// A copy turned by half a turn and by a full turn in two steps.
let half = kite(&mut s, 400.0, 300.0, 1.0);
rotate(&mut s, half, 180.0);
let full = kite(&mut s, 600.0, 300.0, 1.0);
rotate(&mut s, full, 135.0);
rotate(&mut s, full, 225.0);
assert_eq!(start(&mut s, a, json!({})), [a, b, c, half, full], "not the stretched {stretched} nor the bent {bent}");
}
#[test]
fn a_square_whose_anchors_were_turned_is_a_rotated_copy() {
let mut s = session();
let [a, b, ..] = squares(&mut s);
let turn = rotate(&mut s, b, 90.0) * Affine::translate((300.0, 0.0));
assert_eq!(start(&mut s, a, json!({})).len(), 3, "the turned square is still taken, with the small one");
// The first anchor of the turned square lies at another corner, and that is the one that moves.
s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 0]]})).unwrap();
s.execute("path.moveAnchors", &json!({"dx": 10, "dy": 30})).unwrap();
assert!(placed(&s, a, b, turn));
assert!(!placed(&s, a, b, Affine::translate((300.0, 0.0))));
}
#[test]
fn rotated_groups_are_like_the_group_and_an_edit_inside_one_reaches_the_others_turned() {
let mut s = session();
let (g, [sq, dot, _]) = card(&mut s, 0.0, 1.0);
let (copy, [csq, cdot, cbar]) = card(&mut s, 300.0, 1.0);
let turn = rotate(&mut s, copy, 90.0) * Affine::translate((300.0, 0.0));
// The same three objects, each turned in place: not the group turned as a whole.
let (apart, objects) = card(&mut s, 500.0, 1.0);
for id in objects {
rotate(&mut s, id, 90.0);
}
assert_eq!(start(&mut s, g, json!({})), [g, copy], "not the group {apart} whose objects were turned one by one");
sel(&mut s, &[dot]);
s.execute("paint.setFill", &json!({"color": "#12ab34"})).unwrap();
assert_eq!(fill(&s, cdot), "#12ab34");
sel(&mut s, &[sq]);
s.execute("object.setBounds", &json!({"height": 20, "reference": 0})).unwrap();
assert!(placed(&s, sq, csq, turn), "the square in the turned group is halved along the group's own direction");
assert_eq!(kids(&s, copy), [csq, cdot, cbar], "each object keeps its id");
s.execute("select.globalEdit", &json!({})).unwrap();
// An object inside one group takes the object at its place in the turned group, as the
// objects of rotated copies of a group are edited together.
assert_eq!(start(&mut s, sq, json!({})), [sq, csq]);
}
#[test]
fn a_rotated_symbol_instance_is_taken_and_a_reflected_one_is_not() {
let mut s = session();
let r = rect(&mut s, 0.0, 0.0, 40.0, 40.0);
let first = NodeId(s.execute("symbol.new", &json!({"ids": [r.0], "name": "Tile"})).unwrap()["id"].as_u64().unwrap());
let place =
|s: &mut Session, x: f64| NodeId(s.execute("symbol.place", &json!({"name": "Tile", "x": x, "y": 300})).unwrap()["id"].as_u64().unwrap());
let (turned, mirrored) = (place(&mut s, 200.0), place(&mut s, 400.0));
sel(&mut s, &[turned]);
s.execute("object.rotate", &json!({"angle": 30, "origin": [220, 320]})).unwrap();
sel(&mut s, &[mirrored]);
s.execute("object.reflect", &json!({"axis": "vertical", "origin": [420, 320]})).unwrap();
assert_eq!(start(&mut s, first, json!({})), [first, turned], "not the reflected instance {mirrored}");
let xf = |s: &Session, id: NodeId| match node(s, id).kind {
NodeKind::SymbolInstance { xf, .. } => xf,
_ => panic!("not an instance"),
};
let was = xf(&s, turned);
s.execute("object.move", &json!({"dx": 10, "dy": 0})).unwrap();
// The instance turned by 30° counter-clockwise moves along its own direction.
let moved = Affine::rotate((-30f64).to_radians()) * Point::new(10.0, 0.0);
let (now, want) = (xf(&s, turned).as_coeffs(), (Affine::translate(moved.to_vec2()) * was).as_coeffs());
assert!(now.iter().zip(want).all(|(p, q)| (p - q).abs() < 1e-6), "{now:?} vs {want:?}");
}
+16 -5
View File
@@ -79,18 +79,29 @@ fn the_source_stays_as_it_is() {
assert!(s.doc().unwrap().is_dirty(), "the new document is unsaved");
}
/// An index that names no state, or is not a whole number, is an error and changes nothing: no
/// document, no step and no Untitled number is used up.
/// An index that names no state (`no history state N`), or is not a whole number ("`index` must be
/// a whole number"), is an error and changes nothing: no document, no step and no Untitled number
/// is used up.
#[test]
fn a_bad_index_is_an_error_and_changes_nothing() {
let mut s = three_rectangles_one_undone();
let source = s.doc().unwrap().doc.clone();
let untitled = s.peek_untitled();
let bad: [Value; 8] = [json!(4), json!(99), json!(-1), json!(1.5), json!("1"), json!(true), json!([1]), json!(u64::MAX)];
for index in bad {
let bad: [(Value, bool); 8] = [
(json!(4), true),
(json!(99), true),
(json!(u64::MAX), true),
(json!(-1), false),
(json!(1.5), false),
(json!("1"), false),
(json!(true), false),
(json!([1]), false),
];
for (index, whole) in bad {
let e = s.execute(CMD, &json!({ "index": index })).unwrap_err();
assert!(matches!(e, EngineError::BadParams { .. }), "{index}: {e}");
assert!(e.to_string().contains(&format!("no history state {index}")), "{e}");
let want = if whole { format!("no history state {index}") } else { "`index` must be a whole number".to_string() };
assert_eq!(e.to_string(), format!("invalid parameters for `{CMD}`: {want}"), "{index}");
assert_eq!(s.documents().len(), 1, "{index}");
assert_eq!(steps(&s), (2, 1), "{index}");
assert!(Arc::ptr_eq(&s.doc().unwrap().doc, &source), "{index}");
+10 -7
View File
@@ -611,15 +611,18 @@ fn edit_copy_and_cut_read_the_recorded_artboards() {
s.execute("select.none", &json!({})).unwrap();
assert!(matches!(s.execute(id, &json!({})), Err(crate::EngineError::Disabled(..))), "{id}");
let before = documents(&s);
for bad in [
json!({"selectedArtboards": [9]}),
json!({"selectedArtboards": []}),
json!({"selectedArtboards": "1"}),
json!({"selectedArtboards": null}),
json!({"selectedArtboards": [1], "art": "yes"}),
for (bad, want) in [
(json!({"selectedArtboards": [9]}), "no such artboard"),
(json!({"selectedArtboards": []}), "`selectedArtboards` must be a non-empty list of artboard indices"),
(json!({"selectedArtboards": "1"}), "`selectedArtboards` must be a list of artboard indices"),
(json!({"selectedArtboards": null}), "`selectedArtboards` must be a list of artboard indices"),
(json!({"selectedArtboards": [1, "0"]}), "`selectedArtboards` must contain whole numbers"),
(json!({"selectedArtboards": [1, -1]}), "`selectedArtboards` must contain whole numbers"),
(json!({"selectedArtboards": [1], "art": "yes"}), "`art` must be true or false"),
] {
let err = s.execute(id, &bad).unwrap_err().to_string();
assert!(err.contains(id) || err.contains("no such artboard"), "{id} {bad}: {err}");
assert!(err.ends_with(want), "{id} {bad}: {err}");
assert!(err.contains(id) || want == "no such artboard", "{id} {bad}: {err}");
assert_eq!(documents(&s), before, "{id} {bad}");
assert!(s.clipboard.artboards.is_empty(), "{id} {bad}");
}
+13 -5
View File
@@ -1979,9 +1979,10 @@ fn fit_to_artwork_bounds_fits_each_artboard_to_its_own_art() {
assert_eq!(boards(&s), [Rect::new(100.0, 100.0, 1050.0, 280.0), was[1], was[2]], "the first artboard by default");
}
/// Artboard Options and Fit to Selected Art fail with `no such artboard` on an `index` that names
/// no artboard (past the last, negative, fractional, a string or null), and the artboards stay as
/// they were. Artboard Options needs an `index`; Fit to Selected Art without one fits the first
/// Artboard Options and Fit to Selected Art fail with `no such artboard` on a whole-number `index`
/// past the last artboard, with "`index` must be a whole number" on one that is negative,
/// fractional or a string, and with `missing index` on a null one, and the artboards stay as they
/// were. Artboard Options needs an `index`; Fit to Selected Art without one fits the first
/// artboard.
#[test]
fn artboard_options_and_fit_to_selected_art_refuse_an_index_that_names_no_artboard() {
@@ -1992,9 +1993,16 @@ fn artboard_options_and_fit_to_selected_art_refuse_an_index_that_names_no_artboa
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
let boards = |s: &Session| s.doc().unwrap().doc.artboards.iter().map(|a| a.rect).collect::<Vec<_>>();
let (was, n) = (boards(&s), undo_len(&s));
for index in [json!(99), json!(-1), json!(1.5), json!("1"), Value::Null] {
for (index, error) in [
(json!(99), None),
(json!(-1), Some("`index` must be a whole number")),
(json!(1.5), Some("`index` must be a whole number")),
(json!("1"), Some("`index` must be a whole number")),
(Value::Null, Some("missing index")),
] {
for (cmd, p) in [("artboard.setProps", json!({"index": index, "width": 300})), ("artboard.fitToSelection", json!({"index": index}))] {
assert_eq!(s.execute(cmd, &p).unwrap_err().to_string(), "no such artboard", "{cmd} {p}");
let want = error.map_or_else(|| "no such artboard".to_string(), |e| format!("invalid parameters for `{cmd}`: {e}"));
assert_eq!(s.execute(cmd, &p).unwrap_err().to_string(), want, "{cmd} {p}");
}
}
let e = s.execute("artboard.setProps", &json!({"width": 300})).unwrap_err().to_string();
+21
View File
@@ -257,6 +257,27 @@ fn shadows_under_a_groups_or_an_objects_transform_expand_appearance_as_the_canva
}
}
/// Each copy of a Transform effect draws the drop shadow listed before the effect under itself,
/// scaled with it, and Expand Appearance keeps those shadows: the square (20..40) has its shadow
/// 20 pt to its right, and its copy scaled 200 % and moved 80 pt down (10..50 across, 90..130
/// down) has its own 40 pt to its right.
#[test]
fn each_copys_shadow_expands_as_the_canvas_draws_it() {
let mut s = session();
run(&mut s, "shape.rectangle", json!({"x": 20, "y": 20, "width": 20, "height": 20}));
run(&mut s, "paint.setFill", json!({"color": "#ff0000"}));
run(&mut s, "paint.setStroke", json!({"none": true}));
let shadow = json!({"x": 20, "y": 0, "blur": 0, "opacity": 100, "mode": "normal", "color": "#00ff00"});
run(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow", "params": shadow}));
run(&mut s, "effect.apply", json!({"effect": "distort.transform", "params": {"scaleH": 200, "scaleV": 200, "moveV": 80, "copies": 1}}));
let before = render(&s);
assert_eq!(before.pixel(70, 110), [0, 255, 0, 255], "the copy's shadow");
run(&mut s, "effect.expandAppearance", json!({}));
let after = render(&s);
let diff = differing(&before, &after, 64);
assert_eq!(diff, 0, "{diff} pixels differ");
}
/// A group's Transform effect runs after its member's own Offset Path in Expand Appearance as on
/// the canvas: the 100 pt square offset to 120 pt and scaled by half expands 60 pt wide.
#[test]
+103 -5
View File
@@ -492,9 +492,11 @@ fn new_and_renamed_artboards_trim_their_names() {
assert_eq!(names(&s), ["Back", "Artboard 2", "Artboard 3", "Artboard 4"]);
}
/// Duplicate, Copy and Cut Artboard and Move Artboard Up/Down fail with `no such artboard` on an
/// `index` that names no artboard (past the last, negative, fractional, a string or null), and the
/// artboards, the art and the clipboard stay as they were.
/// Duplicate, Copy and Cut Artboard and Move Artboard Up/Down fail with `no such artboard` on a
/// whole-number `index` past the last artboard, with "`index` must be a whole number" on one that
/// is negative, fractional, a string, true or a list, and with `missing index` on a null one,
/// instead of acting on the Artboard tool's active artboard. The artboards, the art and the
/// clipboard stay as they were.
#[test]
fn artboard_copies_refuse_an_index_that_names_no_artboard() {
let mut s = session();
@@ -502,10 +504,22 @@ fn artboard_copies_refuse_an_index_that_names_no_artboard() {
s.execute("artboard.new", &json!({})).unwrap();
s.set_tool_options(Some("artboard"), json!({"active": 1}).as_object().unwrap());
let (was, n) = (boards(&s), s.doc().unwrap().history.undo.len());
for index in [json!(99), json!(-1), json!(1.5), json!("1"), serde_json::Value::Null] {
let not_whole = Some("`index` must be a whole number");
let cases = [
(json!(99), None),
(json!(u64::MAX), None),
(json!(-1), not_whole),
(json!(1.5), not_whole),
(json!("1"), not_whole),
(json!(true), not_whole),
(json!([1]), not_whole),
(serde_json::Value::Null, Some("missing index")),
];
for (index, error) in cases {
for cmd in ["artboard.duplicate", "artboard.copy", "artboard.cut", "artboard.reorder"] {
let p = if cmd == "artboard.reorder" { json!({"index": index, "to": 0}) } else { json!({"index": index}) };
assert_eq!(s.execute(cmd, &p).unwrap_err().to_string(), "no such artboard", "{cmd} {p}");
let want = error.map_or_else(|| "no such artboard".to_string(), |e| format!("invalid parameters for `{cmd}`: {e}"));
assert_eq!(s.execute(cmd, &p).unwrap_err().to_string(), want, "{cmd} {p}");
}
}
let st = s.doc().unwrap();
@@ -513,6 +527,90 @@ fn artboard_copies_refuse_an_index_that_names_no_artboard() {
assert!(s.clipboard.nodes.is_empty() && s.clipboard.artboards.is_empty(), "nothing copied");
}
/// The artboard commands that take `indices` (and `selectedArtboards`, a replay of the journal)
/// name the fault: a value that is not a list, an empty list and a list with an entry that is not a
/// whole number each have their own message naming the key, and a whole number past the last
/// artboard is `no such artboard`, also when another entry is malformed after it. The artboards,
/// the art, the undo history and the clipboard stay as they were.
#[test]
fn artboard_lists_name_the_fault_and_the_key() {
let mut s = session();
rect(&mut s);
s.execute("artboard.new", &json!({})).unwrap();
let (was, n) = (boards(&s), s.doc().unwrap().history.undo.len());
let cmds = [
("artboard.duplicate", json!({})),
("artboard.copy", json!({})),
("artboard.cut", json!({})),
("artboard.rename", json!({"name": "A"})),
("artboard.switchOrientation", json!({})),
("artboard.delete", json!({})),
("artboard.fitToArt", json!({})),
("artboard.move", json!({"dx": 10, "dy": 0})),
("artboard.align", json!({"horizontal": "left"})),
];
for key in ["indices", "selectedArtboards"] {
let cases = [
(json!(null), format!("`{key}` must be a list of artboard indices")),
(json!(1), format!("`{key}` must be a list of artboard indices")),
(json!("0,1"), format!("`{key}` must be a list of artboard indices")),
(json!([]), format!("`{key}` must be a non-empty list of artboard indices")),
(json!([0, -1]), format!("`{key}` must contain whole numbers")),
(json!([0, 1.5]), format!("`{key}` must contain whole numbers")),
(json!(["1"]), format!("`{key}` must contain whole numbers")),
(json!([null]), format!("`{key}` must contain whole numbers")),
(json!([9, "1"]), format!("`{key}` must contain whole numbers")),
(json!([0, 9]), "no such artboard".to_string()),
];
for (cmd, base) in &cmds {
if key == "selectedArtboards" && matches!(*cmd, "artboard.delete" | "artboard.fitToArt" | "artboard.move") {
continue;
}
for (value, want) in &cases {
let mut p = base.clone();
p[key] = value.clone();
let want = if want.starts_with('`') { format!("invalid parameters for `{cmd}`: {want}") } else { want.clone() };
assert_eq!(s.execute(cmd, &p).unwrap_err().to_string(), want, "{cmd} {p}");
}
}
}
let st = s.doc().unwrap();
assert_eq!((boards(&s), st.doc.layers[0].children().unwrap().len(), st.history.undo.len()), (was, 1, n));
assert!(s.clipboard.nodes.is_empty() && s.clipboard.artboards.is_empty(), "nothing copied");
}
/// Align, Distribute and Distribute Spacing for artboards name the key that holds a malformed
/// artboard to align to (`artboard` or `activeArtboard`), not `index`; a null one is left out, and
/// a whole number past the last artboard is `no such artboard`.
#[test]
fn artboard_align_names_the_key_of_a_malformed_reference() {
let mut s = session();
s.execute("artboard.new", &json!({})).unwrap();
s.execute("artboard.new", &json!({})).unwrap();
let cmds = [
("artboard.align", json!({"indices": [0, 1, 2], "horizontal": "left", "to": "artboard"})),
("artboard.distribute", json!({"indices": [0, 1, 2], "horizontal": "left", "to": "artboard"})),
("artboard.distributeSpacing", json!({"indices": [0, 1, 2], "to": "artboard"})),
];
for (cmd, base) in &cmds {
for key in ["artboard", "activeArtboard"] {
let (was, n) = (boards(&s), s.doc().unwrap().history.undo.len());
for value in [json!(-1), json!(1.5), json!("1"), json!(true)] {
let mut p = base.clone();
p[key] = value;
let want = format!("invalid parameters for `{cmd}`: `{key}` must be a whole number");
assert_eq!(s.execute(cmd, &p).unwrap_err().to_string(), want, "{cmd} {p}");
}
let mut p = base.clone();
p[key] = json!(9);
assert_eq!(s.execute(cmd, &p).unwrap_err().to_string(), "no such artboard", "{cmd} {p}");
assert_eq!((boards(&s), s.doc().unwrap().history.undo.len()), (was, n), "{cmd} {key}: nothing moved");
p[key] = json!(null);
s.execute(cmd, &p).unwrap();
}
}
}
/// Artboard Options, Move Artboard, Duplicate Artboards, Copy Artboard and Cut Artboard refuse an
/// option given as text or as another value that is not true or false, such as "true", and
/// Artboard Options and Move Artboard a position, size or move given as text, such as "10". The
+20
View File
@@ -1978,6 +1978,26 @@ fn highlight_alternate_glyphs_preference_is_off_by_default() {
assert!(!s.prefs.highlight_alternate_glyphs);
}
/// Preferences › Type › Show Character Alternates: on by default, listed in the Type category,
/// saved with the other preferences, and a value that is not true or false is refused.
#[test]
fn show_character_alternates_preference_is_on_by_default_and_saved() {
let mut s = Session::new();
assert!(s.prefs.show_character_alternates, "on by default");
let spec = crate::cmd::prefscmds::PREF_SPECS.iter().find(|p| p.key == "showCharacterAlternates").expect("listed");
assert_eq!((spec.category, spec.label), ("Type", "Show Character Alternates"));
assert_eq!(s.execute("prefs.get", &json!({"key": "showCharacterAlternates"})).unwrap(), json!(true));
set_pref(&mut s, "showCharacterAlternates", json!(false));
assert!(!s.prefs.show_character_alternates);
assert!(s.execute("prefs.set", &json!({"key": "showCharacterAlternates", "value": "yes"})).is_err(), "not a boolean");
assert!(!s.prefs.show_character_alternates, "a refused value changes nothing");
let saved: Prefs = serde_json::from_value(serde_json::to_value(&s.prefs).unwrap()).unwrap();
assert!(!saved.show_character_alternates);
// Preferences saved before the key existed read it as on.
let old: Prefs = serde_json::from_value(json!({"highlightAlternateGlyphs": true})).unwrap();
assert!(old.show_character_alternates);
}
/// Preferences › Type › Highlight Substituted Fonts: on by default, listed in the Type category,
/// and saved with the other preferences.
/// <https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/edit-text-without-replacing-missing-fonts.html>