Merge main into fix/save-unused-option-notes

This commit is contained in:
dotCooCoo
2026-11-13 16:56:43 -08:00
276 changed files with 19837 additions and 1688 deletions
+20 -3
View File
@@ -20,7 +20,9 @@ use vectorcraft_doc::{Node, NodeId, OrientedBox};
use vectorcraft_geom::{Affine, Point, Rect};
use vectorcraft_render::effects;
use super::super::appearance::series_children_in_paint_order;
use super::super::edit::selected_roots;
use super::super::graph::capture_series_object_opacity;
use super::super::menucmds::whole_or;
use super::super::object::{choice, object_bounds_transform, scaling, transform_node, transform_patterns};
use super::super::typecmd::refresh_bounds_of;
@@ -90,7 +92,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Randomize Opacity",
["Object", "Plug-ins", "Arrange Tools"],
None,
"{min?: % 0..100 (30), max?: % 0..100 (100; not less than min), seed?: whole number (0; the same seed, objects and range give the same opacities), ids?: [id…] (default: the selection)} give each selected object (the top-level ones) its own opacity, picked at random and evenly from min to max, as one undo step; it is the object's opacity, as the Transparency panel sets it → {count}",
"{min?: % 0..100 (30), max?: % 0..100 (100; not less than min), seed?: whole number (0; the same seed, objects and range give the same opacities), ids?: [id…] (default: the selection)} give each selected object (the top-level ones) its own opacity, picked at random and evenly from min to max, as one undo step; it is the object's opacity, as the Transparency panel sets it. On paths directly in a graph series whose opacity changes, the graph stores the opacity and blend mode of the frontmost of them for its next redraw (docs/mcp.md) → {count}",
has_selection,
randomize_opacity
),
@@ -99,7 +101,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Sort Objects",
["Object", "Plug-ins", "Arrange Tools"],
None,
"{by?: \"width\" | \"height\" | \"area\" (width × height) | \"x\" (left edge) | \"y\" (top edge) | \"opacity\" (%) | \"stacking\" (stacking order, from the back) (\"area\"), apply?: one of the same (\"stacking\"), order?: \"ascending\" (small to large) | \"descending\" (large to small) | \"random\" (\"ascending\"), distribute?: false (true: the values step evenly from the smallest to the largest; false: the objects trade the values they have), seed?: whole number (0; the order \"random\" picks), scaleStrokes?: the Scale Strokes & Effects preference (width, height and area), ids?: [id…] (default: the selection)} sort the selected objects (the top-level ones, at most 20000) by the attribute `by` and give them the values of `apply` in that order, as one undo step: the first in order gets the smallest value. Sizes and places are measured and set as object.setBounds measures them (Use Preview Bounds), a width, height or area about each object's centre (an area in proportion); stacking order always trades places, and only among the objects in the same layer or group. A run that changes nothing makes no step → {count}",
"{by?: \"width\" | \"height\" | \"area\" (width × height) | \"x\" (left edge) | \"y\" (top edge) | \"opacity\" (%) | \"stacking\" (stacking order, from the back) (\"area\"), apply?: one of the same (\"stacking\"), order?: \"ascending\" (small to large) | \"descending\" (large to small) | \"random\" (\"ascending\"), distribute?: false (true: the values step evenly from the smallest to the largest; false: the objects trade the values they have), seed?: whole number (0; the order \"random\" picks), scaleStrokes?: the Scale Strokes & Effects preference (width, height and area), ids?: [id…] (default: the selection)} sort the selected objects (the top-level ones, at most 20000) by the attribute `by` and give them the values of `apply` in that order, as one undo step: the first in order gets the smallest value. Sizes and places are measured and set as object.setBounds measures them (Use Preview Bounds), a width, height or area about each object's centre (an area in proportion); stacking order always trades places, and only among the objects in the same layer or group. With apply \"opacity\", on paths directly in a graph series whose opacity changes, the graph stores the opacity and blend mode of the frontmost of them for its next redraw (docs/mcp.md). A run that changes nothing makes no step → {count}",
has_multi,
sort
),
@@ -396,17 +398,30 @@ fn randomize_opacity(s: &mut Session, p: &Value) -> Result<Value> {
}
let (lo, hi) = ((min / 100.0) as f32, (max / 100.0) as f32);
s.edit("Randomize Opacity", |d, _| {
let mut changed = vec![];
for id in &ids {
let o = rng.range(lo, hi);
if let Some(n) = d.node_mut(*id) {
if let Some(n) = d.node_mut(*id)
&& n.opacity != o
{
n.opacity = o;
changed.push(*id);
}
}
keep_series_opacity(d, &changed);
Ok(())
})?;
Ok(json!({ "count": ids.len() }))
}
/// Store on their graphs the opacity just given to the paths among `changed` (objects whose opacity changed) that are
/// directly in a graph series, with their blend mode, so that every path of the series has them when the graph is drawn
/// again; the frontmost of them in a series sets them.
fn keep_series_opacity(d: &mut vectorcraft_doc::Document, changed: &[NodeId]) {
let marks = series_children_in_paint_order(d, changed);
capture_series_object_opacity(d, &marks);
}
/// An object Sort Objects sorts: its layer or group, its place in it, its box as
/// `object.setBounds` measures it and its opacity (%).
struct Sorted {
@@ -510,6 +525,8 @@ fn sort(s: &mut Session, p: &Value) -> Result<Value> {
n.opacity = (v / 100.0) as f32;
}
}
let changed: Vec<NodeId> = wanted.iter().filter(|(o, v)| (o.opacity - v).abs() >= 1e-6).map(|(o, _)| o.id).collect();
keep_series_opacity(d, &changed);
Ok(())
})?;
return Ok(count);
+2 -31
View File
@@ -10,7 +10,7 @@ use vectorcraft_geom::{PathData, Point, Rect};
use vectorcraft_pathops::addons as po;
use super::super::super::edit::outermost;
use super::super::super::layer::empty_artboards;
use super::super::super::layer::{empty_artboards, remove_artboards};
use crate::Result;
/// Sine of the turn below which an anchor on a straight run is collinear.
@@ -508,35 +508,6 @@ fn artboards(d: &mut Document, sel: &mut Selection, scope: &Scope) -> Result<Out
if empty.is_empty() {
return Ok(Outcome::default());
}
// `empty` is in artboard order: one walk drops them and the others keep their order.
let mut gone: HashSet<u32> = HashSet::with_capacity(empty.len());
let mut i = 0;
d.artboards.retain(|a| {
let keep = empty.binary_search(&i).is_err();
i += 1;
if !keep {
gone.insert(a.id);
}
keep
});
count_artboard_pass();
for &id in &gone {
d.retire_artboard_id(id);
}
d.retain_guides(sel, |g| !g.artboard.is_some_and(|a| gone.contains(&a)));
count_artboard_pass();
remove_artboards(d, sel, &empty)?;
Ok(Outcome { count: empty.len(), ids: vec![], artboards: empty })
}
#[cfg(test)]
thread_local! {
/// Tests: how many walks [`artboards`] made over the artboards and the ruler guides on this
/// thread.
pub(super) static ARTBOARD_PASSES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// Tests: count one walk over the artboards or the ruler guides.
fn count_artboard_pass() {
#[cfg(test)]
ARTBOARD_PASSES.set(ARTBOARD_PASSES.get() + 1);
}
@@ -502,7 +502,7 @@ fn removing_many_selected_objects_looks_at_each_root_once_a_pass() {
#[test]
fn deleting_many_empty_artboards_walks_the_artboards_and_guides_once() {
use super::checks::ARTBOARD_PASSES;
use crate::cmd::layer::ARTBOARD_PASSES;
let mut s = session();
rect(&mut s, 200.0, 20.0, 80.0, 60.0);
let (keep, gone) = s
+34 -7
View File
@@ -206,11 +206,31 @@ struct Source {
text: String,
}
#[cfg(test)]
thread_local! {
/// Tests: how many times [`thread_index`] went through the document's threads on this thread.
static THREAD_SCANS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// Each object the document's text threads list, with the index of the first thread that lists it.
fn thread_index(d: &Document) -> std::collections::HashMap<NodeId, usize> {
#[cfg(test)]
THREAD_SCANS.with(|c| c.set(c.get() + 1));
let mut out = std::collections::HashMap::new();
for (i, thread) in d.text_threads.iter().enumerate() {
for id in thread {
out.entry(*id).or_insert(i);
}
}
out
}
impl Source {
/// Horizontal point or area type that isn't threaded and whose text all fits; None otherwise.
fn of(d: &Document, id: NodeId) -> Option<Self> {
/// Horizontal point or area type that isn't threaded (`threaded` false) and whose text all
/// fits; None otherwise.
fn of(d: &Document, id: NodeId, threaded: bool) -> Option<Self> {
let t = text_of(d, id)?;
if t.vertical || matches!(t.kind, TextKind::OnPath { .. }) || d.text_threads.iter().any(|th| th.contains(&id)) {
if t.vertical || matches!(t.kind, TextKind::OnPath { .. }) || threaded {
return None;
}
let lay = layout(t);
@@ -376,8 +396,10 @@ fn break_text(s: &mut Session, p: &Value) -> Result<Value> {
};
let d = &s.doc()?.doc;
let (mut items, mut skipped, mut count) = (vec![], vec![], 0);
for id in texts_under(d, &checked_ids_or_selection(s, p, C)?)? {
let (Some(src), Some(node)) = (Source::of(d, id), d.node(id)) else {
let ids = texts_under(d, &checked_ids_or_selection(s, p, C)?)?;
let threads = thread_index(d);
for id in ids {
let (Some(src), Some(node)) = (Source::of(d, id, threads.contains_key(&id)), d.node(id)) else {
skipped.push(id);
continue;
};
@@ -405,7 +427,8 @@ fn split_text(s: &mut Session, _: &Value) -> Result<Value> {
return Err(EngineError::Other("Click in type with a Type tool to split it".into()));
};
let d = &s.doc()?.doc;
let (Some(src), Some(node)) = (Source::of(d, id), d.node(id).filter(|_| d.is_editable(id))) else {
let threaded = d.text_threads.iter().any(|th| th.contains(&id));
let (Some(src), Some(node)) = (Source::of(d, id, threaded), d.node(id).filter(|_| d.is_editable(id))) else {
return Err(EngineError::Other("Select horizontal point or area type whose text fits".into()));
};
let text = &src.text;
@@ -821,13 +844,17 @@ fn remove_breaks(s: &mut Session, p: &Value) -> Result<Value> {
let ids = texts_under(d, &checked_ids_or_selection(s, p, C)?)?;
// A threaded story whose frames are all among them is read as one text, once.
let named: std::collections::HashSet<NodeId> = ids.iter().copied().collect();
let threads = thread_index(d);
let mut done = std::collections::HashSet::new();
let mut out: Vec<(NodeId, Vec<(usize, bool)>)> = vec![];
for &id in &ids {
if !done.insert(id) {
continue;
}
let story = crate::cmd::threads::story_of(d, id);
let story = match threads.get(&id).and_then(|&i| d.text_threads.get(i)) {
Some(thread) => crate::cmd::threads::story_in(d, thread, id),
None => vec![],
};
if !story.is_empty() && story.iter().all(|f| named.contains(f)) {
done.extend(story.iter().copied());
out.extend(story_cuts(d, &story, brk));
@@ -158,6 +158,62 @@ fn break_text_refuses_what_it_cannot_break() {
assert!(matches!(r, Err(EngineError::BadParams { .. })), "{r:?}");
}
/// Two area frames threaded together, with `story` flowing through them → (first, second).
fn threaded(s: &mut Session, story: &str) -> (NodeId, NodeId) {
let a = text(s, json!({"x": 10, "y": 300, "text": story, "size": 12, "area": {"width": 150, "height": 60}}));
let b = NodeId(s.execute("shape.rectangle", &json!({"x": 200, "y": 300, "width": 150, "height": 60})).unwrap()["id"].as_u64().unwrap());
select(s, &[a, b]);
s.execute("text.thread.create", &json!({})).unwrap();
(a, b)
}
fn thread_scans() -> usize {
super::THREAD_SCANS.with(|c| c.get())
}
/// Threaded frames in the selection stay as they are and are reported as skipped; the other type
/// is broken. The document's threads are gone through once for the whole selection, not once
/// per object.
#[test]
fn break_text_skips_threaded_frames_and_reads_the_threads_once() {
let mut s = session();
let (a, b) = threaded(&mut s, "Threaded story text");
let mut plain = vec![];
for i in 0..6 {
plain.push(text(&mut s, json!({"x": 20, "y": 30 + 30 * i, "text": "Two words", "size": 14})));
}
let was = glyphs(&s, &plain);
let frames = (obj(&s, a), obj(&s, b));
let before = doc_json(&s);
let steps = undo_steps(&s);
let mut all = vec![a, b];
all.extend(&plain);
select(&mut s, &all);
let scans = thread_scans();
let r = s.execute("addon.cleanup.breakText", &json!({"by": "words"})).unwrap();
assert_eq!(thread_scans() - scans, 1, "the threads are gone through once");
assert_eq!(ids(&json!({"ids": r["skipped"]})), [a, b]);
assert_eq!((obj(&s, a), obj(&s, b)), frames, "the threaded frames stay as they are");
let pieces = ids(&r);
assert_eq!(pieces.len(), 12);
assert_eq!(s.doc().unwrap().selection.objects.len(), 12, "the pieces are selected");
assert_same_places(&was, &glyphs(&s, &pieces));
assert_eq!(undo_steps(&s), steps + 1);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(doc_json(&s), before);
}
#[test]
fn split_text_refuses_a_threaded_frame() {
let mut s = session();
let (a, _) = threaded(&mut s, "Threaded story text");
select(&mut s, &[a]);
edit_text(&mut s, a, 9, 9);
let before = doc_json(&s);
assert!(other(s.execute("addon.cleanup.splitText", &json!({}))).contains("point or area type"));
assert_eq!(doc_json(&s), before);
}
#[test]
fn combine_puts_glyph_pieces_back_together_where_they_were() {
let mut s = session();
@@ -300,6 +356,29 @@ fn remove_breaks_with_ids_null_in_the_characters_a_type_tool_selected() {
assert_eq!(obj(&s, id).plain_text(), "a b\nc");
}
/// Remove Breaks reads a threaded story whose frames are all selected as one text and each other
/// object on its own, going through the document's threads once for the whole selection.
#[test]
fn remove_breaks_reads_the_threads_once() {
let mut s = session();
let (a, b) = threaded(&mut s, "one\ntwo");
let mut plain = vec![];
for i in 0..6 {
plain.push(text(&mut s, json!({"x": 20, "y": 30 + 30 * i, "text": "a\nb", "size": 12})));
}
let mut all = vec![a, b];
all.extend(&plain);
select(&mut s, &all);
let scans = thread_scans();
let r = s.execute("addon.cleanup.removeBreaks", &json!({"kind": "paragraph"})).unwrap();
assert_eq!(thread_scans() - scans, 1, "the threads are gone through once");
assert_eq!(r["removed"], 7);
assert_eq!(obj(&s, a).plain_text(), "one two");
for id in plain {
assert_eq!(obj(&s, id).plain_text(), "a b");
}
}
#[test]
fn split_at_the_cursor_and_around_a_selection() {
for (anchor, caret, want) in [(6, 6, &["Split", "here please"][..]), (6, 10, &["Split", "here", "please"][..])] {
+14 -8
View File
@@ -79,7 +79,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Apply Fill to Stroke",
["Object", "Plug-ins", "Color Effects"],
None,
"{ids?|id?, skipNone?: bool} each target's top stroke takes its own top fill, as it is (a colour in its model, a global or spot colour linked at its tint, a gradient or pattern with its placement, None); a stroke given paint at width 0 gets 1 pt, as Swap Fill and Stroke does. Type: each run's stroke takes its own fill, and while the Type tool has characters selected (no ids), only those. A graph's series marks and labels keep the paint when the graph is drawn again. skipNone: leave alone a target whose fill is None. A target whose stroke already has that paint is left as it is, and when nothing changes no undo step is recorded. The default stroke takes the default fill the same way → {objects}",
"{ids?|id?, skipNone?: bool} each target's top stroke takes its own top fill, as it is (a colour in its model, a global or spot colour linked at its tint, a gradient or pattern with its placement, None); a stroke given paint at width 0 gets 1 pt, as Swap Fill and Stroke does. Type: each run's stroke takes its own fill, and while the Type tool has characters selected (no ids), only those. A graph's labels keep the paint when the graph is drawn again. On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible stroke when the command wrote that row (exceptions: docs/mcp.md). skipNone: leave alone a target whose fill is None. A target whose stroke already has that paint is left as it is, and when nothing changes no undo step is recorded. The default stroke takes the default fill the same way → {objects}",
has_doc,
|s, p| copy_paint(s, p, true)
),
@@ -226,6 +226,8 @@ fn copy_paint(s: &mut Session, p: &Value, to_stroke: bool) -> Result<Value> {
if !ids.is_empty() {
let before = s.doc()?.doc.clone();
super::super::overprint::edit_counted(s, label, |d| {
// Each object with the row the paint went to, for the paint a graph stores for its series.
let mut written = Vec::new();
for id in &ids {
let Some(node) = d.node_mut(*id) else { continue };
if let NodeKind::Text(t) = &mut node.kind {
@@ -239,19 +241,23 @@ fn copy_paint(s: &mut Session, p: &Value, to_stroke: bool) -> Result<Value> {
let (from, to) = if to_stroke { (ap.fill_paint(), ap.stroke_paint()) } else { (ap.stroke_paint(), ap.fill_paint()) };
// A missing item reads as None: copying None onto it would add an item that
// changes nothing.
if (skip_none && from.is_none()) || from == to {
if skip_none && from.is_none() {
continue;
}
if to_stroke {
restore_unpainted_stroke(ap, None, &from);
ap.set_stroke(from);
} else {
ap.set_fill(from);
if from != to {
if to_stroke {
restore_unpainted_stroke(ap, None, &from);
ap.set_stroke(from);
} else {
ap.set_fill(from);
}
}
// A row that already had the paint counts as written with it, as for `paint.setFill`.
written.extend(ap.items.iter().rposition(|it| it.is_fill() != to_stroke).map(|at| (*id, at)));
}
objects = super::super::appearance::count_changed(d, &before, &ids);
// The graph keeps the paint its series marks and labels now have.
let series = super::super::graph::capture_series_paints(d, &ids, !to_stroke);
let series = super::super::appearance::capture_series_paint_writes(&before, d, &ids, &written);
let labels = super::super::graph::capture_label_styles(d, &ids, &copy_run);
Ok(objects + usize::from(series || labels))
})?;
+4 -2
View File
@@ -1,9 +1,10 @@
//! Cutting Tools (`addon.cutting.*`), a built-in add-on that prepares art for cutting. Stencil
//! 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`]). Its submenu (Object › Plug-ins › Cutting Tools) and dialogs are
//! the UI's.
//! 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.
mod banner;
mod stencil;
mod weed;
@@ -12,5 +13,6 @@ use super::super::CommandSpec;
pub fn specs() -> Vec<CommandSpec> {
let mut v = stencil::specs();
v.extend(weed::specs());
v.extend(banner::specs());
v
}
@@ -0,0 +1,228 @@
//! Banner Hems and Grommets (`addon.cutting.banner`), a command of the built-in Cutting Tools
//! add-on for finishing a printed banner: on each chosen side of the banner it adds a hem line,
//! where the edge is folded back and sewn (<https://en.wikipedia.org/wiki/Hem>), and grommet
//! marks, where rings are set into the material to hang it
//! (<https://en.wikipedia.org/wiki/Grommet>), spread evenly at most a set spacing apart. The
//! banner is the selected art's bounds, or each artboard.
#[cfg(test)]
mod tests;
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::Paint;
use vectorcraft_color::swatch::REGISTRATION;
use vectorcraft_doc::{Appearance, Node};
use vectorcraft_geom::{Point, Rect, shapes};
use super::super::super::edit::selected_roots;
use super::super::super::paint::swatch_solid;
use super::super::super::pathops::length_given;
use super::super::super::{CommandSpec, bad, cmd, has_doc, switch_or, text_param};
use super::weed::outside_clips;
use crate::{Result, Session};
/// Banner Hems and Grommets' command.
pub const BANNER: &str = "addon.cutting.banner";
/// The sides of a banner, in the order the grommets go round it.
const SIDES: [&str; 4] = ["top", "right", "bottom", "left"];
/// The largest hem, inset or spacing, in points.
const MAX_DISTANCE: f64 = 100_000.0;
/// The most grommets one run adds.
const MAX_GROMMETS: usize = 100_000;
/// Two grommet centers closer than this are one grommet (the corner that two sides share).
const SAME: f64 = 1e-6;
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
"addon.cutting.banner",
"Banner Hems and Grommets…",
["Object", "Plug-ins", "Cutting Tools"],
None,
"{hem?: pt 0..100000 (72: from each edge to its hem line; 0: no hem lines), grommets?: bool (true), spacing?: pt more than 0 and at most 100000 (1728: the most the grommets on a side are apart), grommetInset?: pt 0..100000 (36: from each edge to the grommets' centers), diameter?: pt 0.01..1000 (27: each grommet mark's circle), top?, right?, bottom?, left?: bool (true: the sides that get a hem and grommets), weight?: pt 0.01..1000 (0.3), swatch?: a color swatch the marks are stroked in (\"[Registration]\"), allArtboards?: bool (default: true when nothing is selected)} mark the hems and grommets of a banner, the selected art's bounds or each artboard: a hem line `hem` inside each chosen edge, running the edge's whole length, and grommet marks (a circle with a cross at its center) `grommetInset` inside each chosen edge, one at each end of the side and the rest spread evenly between them, as few as keep them at most `spacing` apart. One group named Banner Marks per banner, stroked with no fill, on top of the current layer, selected, as one undo step → {id: the first group, ids, hems (hem lines), grommets (grommet marks)}",
has_doc,
banner
)]
}
/// The distance `p` gives for `key` (0..[`MAX_DISTANCE`]), or `default`.
fn distance(p: &Value, key: &str, default: f64) -> Result<f64> {
match length_given(p, key, BANNER)? {
None => Ok(default),
Some(v) if (0.0..=MAX_DISTANCE).contains(&v) => Ok(v),
Some(_) => Err(bad(BANNER, format!("`{key}` must be from 0 to {MAX_DISTANCE} pt"))),
}
}
/// The length `p` gives for `key` (0.01..1000), or `default`.
fn size(p: &Value, key: &str, default: f64) -> Result<f64> {
match length_given(p, key, BANNER)? {
None => Ok(default),
Some(v) if (0.01..=1000.0).contains(&v) => Ok(v),
Some(_) => Err(bad(BANNER, format!("`{key}` must be from 0.01 to 1000 pt"))),
}
}
/// Which sides are on, in [`SIDES`] order.
type Sides = [bool; 4];
/// Whether marks `depth` inside a banner's near and far edges, `len` apart, fit: they stay inside
/// the banner, and when both edges are chosen the two sets stay apart.
fn fits(depth: f64, len: f64, near: bool, far: bool) -> bool {
match (near, far) {
(true, true) => 2.0 * depth < len,
(true, false) | (false, true) => depth < len,
(false, false) => true,
}
}
/// The hem lines of banner `r`: one `hem` inside each chosen edge, from one end of the edge to the
/// other.
fn hem_lines(r: Rect, hem: f64, sides: Sides) -> Vec<(Point, Point)> {
if hem <= 0.0 {
return vec![];
}
let lines = [
(Point::new(r.x0, r.y0 + hem), Point::new(r.x1, r.y0 + hem)),
(Point::new(r.x1 - hem, r.y0), Point::new(r.x1 - hem, r.y1)),
(Point::new(r.x0, r.y1 - hem), Point::new(r.x1, r.y1 - hem)),
(Point::new(r.x0 + hem, r.y0), Point::new(r.x0 + hem, r.y1)),
];
lines.into_iter().zip(sides).filter(|(_, on)| *on).map(|(l, _)| l).collect()
}
/// The grommet centers of banner `r`: on each chosen side, `inset` inside the edge, one at each end
/// (`inset` from the edges across it) and the rest spread evenly between them, as few as keep them
/// at most `spacing` apart. They go round the banner clockwise from the top left; a corner that two
/// chosen sides share has one grommet. `None` when there would be more than `limit`.
fn grommet_centers(r: Rect, inset: f64, spacing: f64, sides: Sides, limit: usize) -> Option<Vec<Point>> {
let (l, t, rt, b) = (r.x0 + inset, r.y0 + inset, r.x1 - inset, r.y1 - inset);
// Each side from its start to its end, clockwise.
let runs = [
(Point::new(l, t), Point::new(rt, t)),
(Point::new(rt, t), Point::new(rt, b)),
(Point::new(rt, b), Point::new(l, b)),
(Point::new(l, b), Point::new(l, t)),
];
let mut out: Vec<Point> = vec![];
for ((a, z), on) in runs.into_iter().zip(sides) {
if !on {
continue;
}
let gaps = ((z - a).hypot() / spacing - 1e-9).ceil().max(1.0);
if !gaps.is_finite() || gaps > limit as f64 {
return None;
}
let gaps = gaps as usize;
for i in 0..=gaps {
let p = a.lerp(z, i as f64 / gaps as f64);
// A side starts where the one before it ends, and the last one ends where the first
// starts.
let shared = |q: Option<&Point>| q.is_some_and(|q| (*q - p).hypot() < SAME);
if !shared(out.last()) && !shared(out.first()) {
out.push(p);
}
if out.len() > limit {
return None;
}
}
}
Some(out)
}
fn banner(s: &mut Session, p: &Value) -> Result<Value> {
let hem = distance(p, "hem", 72.0)?;
let inset = distance(p, "grommetInset", 36.0)?;
let spacing = match length_given(p, "spacing", BANNER)? {
None => 1728.0,
Some(v) if v > 0.0 && v <= MAX_DISTANCE => v,
Some(_) => return Err(bad(BANNER, format!("`spacing` must be more than 0 and at most {MAX_DISTANCE} pt"))),
};
let diameter = size(p, "diameter", 27.0)?;
let weight = size(p, "weight", 0.3)?;
let with_grommets = switch_or(p, "grommets", true, BANNER)?;
let mut sides = [true; 4];
for (on, key) in sides.iter_mut().zip(SIDES) {
*on = switch_or(p, key, true, BANNER)?;
}
if !sides.contains(&true) {
return Err(bad(BANNER, "turn on at least one side"));
}
if hem == 0.0 && !with_grommets {
return Err(bad(BANNER, "with no hem and no grommets there is nothing to mark"));
}
let swatch = text_param(p, "swatch", BANNER)?.unwrap_or(REGISTRATION);
let stroke = swatch_solid(&s.doc()?.doc, swatch, None).map_err(|e| bad(BANNER, e))?;
let all = switch_or(p, "allArtboards", s.doc()?.selection.is_empty(), BANNER)?;
let banners: Vec<Rect> = if all {
s.doc()?.doc.artboards.iter().map(|a| a.rect).collect()
} else {
let roots = selected_roots(s)?;
if roots.is_empty() {
return Err(bad(BANNER, "select the banner, or mark every artboard"));
}
s.doc()?.doc.bounds_of(&roots, false).into_iter().collect()
};
if banners.is_empty() {
return Err(bad(BANNER, "there is no banner to mark"));
}
let [top, right, bottom, left] = sides;
let mut marks = vec![];
let mut total = 0;
for r in &banners {
let (w, h) = (r.width(), r.height());
if !(w.is_finite() && h.is_finite()) || w <= 0.0 || h <= 0.0 {
return Err(bad(BANNER, "the banner has no width or height"));
}
// A hem line must stay inside the banner and apart from the hem line on the far side.
if hem > 0.0 && !(fits(hem, h, top, bottom) && fits(hem, w, left, right)) {
return Err(bad(BANNER, "the hem is too deep for the banner"));
}
let centers = if with_grommets {
// The grommets on a side run from `inset` past one end to `inset` before the other,
// stay inside the banner and stay apart from the grommets on the far side.
let runs_fit = (!(top || bottom) || 2.0 * inset <= w) && (!(left || right) || 2.0 * inset <= h);
if !(runs_fit && fits(inset, h, top, bottom) && fits(inset, w, left, right)) {
return Err(bad(BANNER, "the grommet inset is too large for the banner"));
}
grommet_centers(*r, inset, spacing, sides, MAX_GROMMETS - total)
.ok_or_else(|| bad(BANNER, format!("the spacing makes more than {MAX_GROMMETS} grommets")))?
} else {
vec![]
};
total += centers.len();
marks.push((hem_lines(*r, hem, sides), centers));
}
let parent = outside_clips(&s.doc()?.doc, s.doc()?.target_parent()?);
let ap = Appearance::basic(Paint::None, stroke, weight);
let half = diameter / 2.0;
let (ids, hems) = s.edit("Banner Hems and Grommets", |d, sel| {
let (mut ids, mut hems) = (vec![], 0);
for (lines, centers) in &marks {
let mut children = vec![];
for (a, b) in lines {
let mut n = Node::path(d.alloc_id(), shapes::line(*a, *b), ap.clone());
n.name = Some("Hem".into());
children.push(Arc::new(n));
}
hems += lines.len();
for c in centers {
let ring = Node::path(d.alloc_id(), shapes::ellipse(Rect::new(c.x - half, c.y - half, c.x + half, c.y + half)), ap.clone());
let across = Node::path(d.alloc_id(), shapes::line(Point::new(c.x - half, c.y), Point::new(c.x + half, c.y)), ap.clone());
let down = Node::path(d.alloc_id(), shapes::line(Point::new(c.x, c.y - half), Point::new(c.x, c.y + half)), ap.clone());
let mut g = Node::group(d.alloc_id(), vec![Arc::new(ring), Arc::new(across), Arc::new(down)]);
g.name = Some("Grommet".into());
children.push(Arc::new(g));
}
let mut g = Node::group(d.alloc_id(), children);
g.name = Some("Banner Marks".into());
ids.push(g.id);
d.insert(parent, usize::MAX, g)?;
}
sel.set(ids.iter().copied());
Ok((ids, hems))
})?;
let first = ids.first().map(|i| i.0);
Ok(json!({ "id": first, "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "hems": hems, "grommets": total }))
}
@@ -0,0 +1,263 @@
//! Tests for `addon.cutting.banner`: hem lines and grommet marks on the sides of a banner.
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::Paint;
use vectorcraft_doc::{Appearance, Node, NodeKind};
use vectorcraft_geom::{Point, Rect, shapes};
use super::{grommet_centers, hem_lines};
use crate::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
s
}
fn id_of(v: &Value) -> NodeId {
NodeId(v["id"].as_u64().unwrap())
}
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
id_of(&s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap())
}
fn node(s: &Session, id: NodeId) -> Node {
s.doc().unwrap().doc.node(id).cloned().unwrap()
}
/// Runs Banner Hems and Grommets with `p` and gives the first group it made.
fn banner(s: &mut Session, p: Value) -> (Value, Node) {
let r = s.execute("addon.cutting.banner", &p).unwrap();
let g = node(s, id_of(&r));
(r, g)
}
fn round(p: Point) -> (f64, f64) {
let r = |v: f64| (v * 1000.0).round() / 1000.0;
(r(p.x), r(p.y))
}
/// The hem lines in group `g` as their two ends.
fn hems(g: &Node) -> Vec<((f64, f64), (f64, f64))> {
g.children()
.unwrap()
.iter()
.filter(|c| c.name.as_deref() == Some("Hem"))
.map(|c| match &c.kind {
NodeKind::Path { path, .. } => {
let a: Vec<Point> = path.anchors().map(|(_, _, a)| a.p).collect();
assert_eq!(a.len(), 2, "a hem line is one straight segment");
(round(a[0]), round(a[1]))
}
other => panic!("not a path: {other:?}"),
})
.collect()
}
/// The grommet marks in group `g` as their centers.
fn grommets(g: &Node) -> Vec<(f64, f64)> {
g.children().unwrap().iter().filter(|c| c.name.as_deref() == Some("Grommet")).map(|c| round(c.geometric_bounds().unwrap().center())).collect()
}
#[test]
fn hems_and_grommets_go_round_a_selected_banner() {
let mut s = session();
rect(&mut s, 0.0, 0.0, 400.0, 200.0);
let undo = s.doc().unwrap().history.undo.len();
let (r, g) = banner(&mut s, json!({"hem": 20, "grommetInset": 10, "spacing": 150, "diameter": 8}));
assert_eq!(g.name.as_deref(), Some("Banner Marks"));
assert_eq!(
hems(&g),
vec![((0.0, 20.0), (400.0, 20.0)), ((380.0, 0.0), (380.0, 200.0)), ((0.0, 180.0), (400.0, 180.0)), ((20.0, 0.0), (20.0, 200.0))]
);
// 380 pt along the top and bottom need 3 gaps at most 150 apart, 180 down the sides 2; the
// corners are shared.
assert_eq!(
grommets(&g),
vec![
(10.0, 10.0),
(136.667, 10.0),
(263.333, 10.0),
(390.0, 10.0),
(390.0, 100.0),
(390.0, 190.0),
(263.333, 190.0),
(136.667, 190.0),
(10.0, 190.0),
(10.0, 100.0)
]
);
assert_eq!(r["hems"], 4);
assert_eq!(r["grommets"], 10);
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
assert_eq!(s.doc().unwrap().selection.objects, [g.id]);
}
#[test]
fn a_grommet_is_a_circle_with_a_cross_stroked_in_registration() {
let mut s = session();
rect(&mut s, 0.0, 0.0, 100.0, 100.0);
let (_, g) = banner(&mut s, json!({"hem": 0, "grommetInset": 10, "diameter": 6, "weight": 0.5}));
let first = g.children().unwrap().iter().find(|c| c.name.as_deref() == Some("Grommet")).cloned().unwrap();
assert_eq!(first.geometric_bounds(), Some(Rect::new(7.0, 7.0, 13.0, 13.0)));
let parts = first.children().unwrap();
assert_eq!(parts.len(), 3, "a circle and two lines");
for c in parts.iter() {
assert!(matches!(c.kind, NodeKind::Path { .. }), "a path");
assert_eq!(c.appearance.fill_paint(), Paint::None);
assert!(c.appearance.stroke_paint().is_registration());
assert_eq!(c.appearance.stroke_width(), 0.5);
}
}
#[test]
fn every_artboard_is_marked_when_nothing_is_selected() {
let mut s = session();
s.execute("artboard.new", &json!({"x": 900, "y": 0, "width": 200, "height": 150})).unwrap();
s.execute("select.none", &json!({})).unwrap();
let r = s.execute("addon.cutting.banner", &json!({"grommets": false})).unwrap();
let ids: Vec<NodeId> = r["ids"].as_array().unwrap().iter().map(|v| NodeId(v.as_u64().unwrap())).collect();
assert_eq!(ids.len(), 2);
let second = node(&s, ids[1]);
assert_eq!(hems(&second)[0], ((900.0, 72.0), (1100.0, 72.0)), "the default hem is 72 pt");
assert!(grommets(&second).is_empty());
assert_eq!(r["grommets"], 0);
}
#[test]
fn only_the_chosen_sides_are_marked() {
let mut s = session();
rect(&mut s, 0.0, 0.0, 300.0, 100.0);
let (_, g) = banner(&mut s, json!({"hem": 10, "grommetInset": 5, "spacing": 1000, "right": false, "bottom": false, "left": false}));
assert_eq!(hems(&g), vec![((0.0, 10.0), (300.0, 10.0))]);
assert_eq!(grommets(&g), vec![(5.0, 5.0), (295.0, 5.0)]);
}
#[test]
fn units_are_read_and_bad_params_are_refused() {
let mut s = session();
rect(&mut s, 0.0, 0.0, 400.0, 400.0);
let (_, g) = banner(&mut s, json!({"hem": "1 in", "grommetInset": "0.5 in", "spacing": "10 in"}));
assert_eq!(hems(&g)[0], ((0.0, 72.0), (400.0, 72.0)));
assert_eq!(grommets(&g)[0], (36.0, 36.0));
let before = s.doc().unwrap().history.undo.len();
for p in [
json!({"hem": -1}),
json!({"hem": "x"}),
json!({"spacing": 0}),
json!({"diameter": 0}),
json!({"weight": 1e9}),
json!({"top": "yes"}),
json!({"top": false, "right": false, "bottom": false, "left": false}),
json!({"hem": 0, "grommets": false}),
json!({"hem": 250}),
json!({"grommetInset": 250}),
json!({"spacing": 0.001}),
json!({"swatch": "no such swatch"}),
] {
assert!(s.execute("addon.cutting.banner", &p).is_err(), "{p} is refused");
}
assert_eq!(s.doc().unwrap().history.undo.len(), before, "a refused run changes nothing");
}
#[test]
fn hems_and_grommets_on_one_side_may_go_past_the_middle() {
let mut s = session();
rect(&mut s, 0.0, 0.0, 300.0, 100.0);
let one = json!({"right": false, "bottom": false, "left": false, "spacing": 1000});
let with = |extra: Value| {
let mut p = one.clone();
p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
p
};
let (_, g) = banner(&mut s, with(json!({"hem": 60, "grommetInset": 70})));
assert_eq!(hems(&g), vec![((0.0, 60.0), (300.0, 60.0))]);
assert_eq!(grommets(&g), vec![(70.0, 70.0), (230.0, 70.0)]);
for p in [with(json!({"hem": 100})), with(json!({"hem": 0, "grommetInset": 100})), with(json!({"grommetInset": 151}))] {
assert!(s.execute("addon.cutting.banner", &p).is_err(), "{p} reaches the far edge or past the ends");
}
// A grommet inset of half the width puts the grommets of a side on one point: one grommet.
let mut s = session();
rect(&mut s, 0.0, 0.0, 100.0, 300.0);
let (_, g) = banner(&mut s, with(json!({"hem": 0, "grommetInset": 50, "spacing": 10})));
assert_eq!(grommets(&g), vec![(50.0, 50.0)]);
}
#[test]
fn hems_and_grommets_on_opposite_sides_stay_apart() {
let mut s = session();
rect(&mut s, 0.0, 0.0, 300.0, 100.0);
let pair = |p: Value| {
let mut q = json!({"right": false, "left": false, "spacing": 1000});
q.as_object_mut().unwrap().extend(p.as_object().unwrap().clone());
q
};
// Half the height would put the top and bottom marks on the same line.
let refused = [
pair(json!({"hem": 50})),
pair(json!({"hem": 0, "grommetInset": 50})),
json!({"hem": 0, "grommetInset": 150, "top": false, "bottom": false}),
];
for p in refused {
assert!(s.execute("addon.cutting.banner", &p).is_err(), "{p} is refused");
}
let (_, g) = banner(&mut s, pair(json!({"hem": 49.5, "grommetInset": 49.5})));
assert_eq!(hems(&g), vec![((0.0, 49.5), (300.0, 49.5)), ((0.0, 50.5), (300.0, 50.5))]);
assert_eq!(grommets(&g), vec![(49.5, 49.5), (250.5, 49.5), (250.5, 50.5), (49.5, 50.5)]);
}
#[test]
fn the_marks_go_outside_a_clip_group_edited_in_isolation() {
let mut s = session();
let parent = s.doc().unwrap().target_parent().unwrap();
let (g, art) = s
.edit("clip group", |d, _| {
let clip = Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(100.0, 100.0, 150.0, 140.0)), Appearance::default()));
let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 400.0, 200.0)), Appearance::default());
let art_id = art.id;
let mut g = Node::group(d.alloc_id(), vec![clip, Arc::new(art)]);
if let NodeKind::Group { clip, .. } = &mut g.kind {
*clip = true;
}
let id = g.id;
d.insert(parent, usize::MAX, g)?;
Ok((id, art_id))
})
.unwrap();
s.execute("object.isolate", &json!({"id": g.0})).unwrap();
s.execute("select.set", &json!({"ids": [art.0]})).unwrap();
let (_, marks) = banner(&mut s, json!({"hem": 20, "grommets": false}));
// The clipping path would hide marks inside the clip group.
assert_eq!(s.doc().unwrap().doc.parent_of(marks.id), parent);
assert_eq!(hems(&marks)[0], ((0.0, 20.0), (400.0, 20.0)), "the selected art is the banner");
}
#[test]
fn all_artboards_marks_the_artboards_even_with_a_selection() {
let mut s = session();
rect(&mut s, 10.0, 10.0, 100.0, 100.0);
let (_, g) = banner(&mut s, json!({"allArtboards": true, "grommets": false}));
assert_eq!(hems(&g)[0], ((0.0, 72.0), (800.0, 72.0)));
}
#[test]
fn an_empty_document_has_no_banner_to_mark_when_artboards_are_off() {
let mut s = session();
assert!(s.execute("addon.cutting.banner", &json!({"allArtboards": false})).is_err());
}
#[test]
fn a_side_shorter_than_the_spacing_has_a_grommet_at_each_end() {
let r = Rect::new(0.0, 0.0, 100.0, 50.0);
let c = grommet_centers(r, 0.0, 1000.0, [true, false, false, false], 10).unwrap();
assert_eq!(c, vec![Point::new(0.0, 0.0), Point::new(100.0, 0.0)]);
// Spacing that divides the side exactly does not add a grommet, even when the division
// rounds up (2.1 / 0.3 is 7.000000000000001).
let c = grommet_centers(Rect::new(0.0, 0.0, 2.1, 1.0), 0.0, 0.3, [true, false, false, false], 100).unwrap();
assert_eq!(c.len(), 8);
assert!(grommet_centers(r, 0.0, 1.0, [true; 4], 10).is_none(), "over the limit");
assert!(hem_lines(r, 0.0, [true; 4]).is_empty());
}
+1 -1
View File
@@ -72,7 +72,7 @@ fn lines_across(length: f64, count: Option<usize>, spacing: Option<f64>) -> Resu
/// Where the Weeding group goes: `parent`, or, when `parent` is inside a clip group or clipping
/// sublayer (a group edited in isolation), the container around the outermost of them. A clipping
/// path would hide the lines and the box, which lie outside the art.
fn outside_clips(d: &Document, parent: Option<NodeId>) -> Option<NodeId> {
pub(super) fn outside_clips(d: &Document, parent: Option<NodeId>) -> Option<NodeId> {
let clips = |id: NodeId| d.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Group { clip: true, .. } | NodeKind::Layer { clip: true, .. }));
let (mut at, mut out) = (parent, parent);
while let Some(p) = at {
+19 -6
View File
@@ -2,8 +2,8 @@
//! the Extend Path tool's extension, the Measure & Inspect tool's readouts, the Anchor Inspector's
//! anchor edits, Split / Add Points at Crossings, Slide Anchor Along Path, the Corner Tool's corners
//! ([`corners`]), the Parametric Shape Tool's shapes ([`shape`]), the Shared Edge Merge Tool's
//! merges with Keep Shared Points Together ([`shared_edge`]), Divide into Dashes and Expand Dashes
//! into Paths.
//! merges with Keep Shared Points Together ([`shared_edge`]), Divide into Dashes, Expand Dashes
//! into Paths and Trim at Crossings ([`trim`]).
//! Its tools, panels and submenu (Object › Plug-ins › Path Precision Tools) are the tools crate's
//! and the UI's.
@@ -14,6 +14,7 @@ mod shape;
mod shared_edge;
#[cfg(test)]
mod tests;
mod trim;
use std::collections::{BTreeMap, BTreeSet};
@@ -143,7 +144,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add Points at Crossings",
["Object", "Plug-ins", "Path Precision Tools"],
None,
"{cut?: bool (false: add anchors where the paths cross; true: also cut them there), selfCrossings?: bool (false: where a path crosses itself too), ids?: [id…] (default: the selection)} where the paths under the objects cross one another, as one undo step; at most 10000 segments → {ids, crossings (anchors placed)}",
"{cut?: bool (false: add anchors where the paths cross; true: also cut them there), selfCrossings?: bool (false: where a path crosses itself too, a curved segment that loops over itself included), ids?: [id…] (default: the selection)} where the paths under the objects cross one another, as one undo step; at most 10000 segments → {ids, crossings (anchors placed)}",
has_selection,
crossings
),
@@ -178,6 +179,7 @@ pub fn specs() -> Vec<CommandSpec> {
v.extend(shape::specs());
v.extend(corners::specs());
v.extend(shared_edge::specs());
v.extend(trim::specs());
v
}
@@ -609,7 +611,7 @@ fn remove_keep_shape(s: &mut Session, _: &Value) -> Result<Value> {
fn picked_segments(segments: Option<&BTreeSet<(usize, usize)>>, si: usize, sp: &SubPath, anchors: &BTreeSet<usize>) -> BTreeSet<usize> {
let n = sp.anchors.len().max(1);
match segments.filter(|set| !set.is_empty()) {
Some(set) => set.iter().filter(|(s, _)| *s == si).map(|(_, k)| *k).collect(),
Some(set) => super::super::path::subpath_refs(set, si).collect(),
None => (0..sp.segment_count()).filter(|k| anchors.contains(k) && anchors.contains(&((k + 1) % n))).collect(),
}
}
@@ -681,6 +683,18 @@ pub(super) fn close(s: &mut Session, p: &Value) -> Result<Value> {
// ---------- Split / Add Points at Crossings ----------
/// The crossing places `on` of one path, as `(segment, t)` for each of its `subpaths` subpaths in
/// the order they come in `on`. A place on a subpath past the last is left out.
fn places_by_subpath(on: &[(usize, usize, f64)], subpaths: usize) -> Vec<Vec<(usize, f64)>> {
let mut by_sub = vec![vec![]; subpaths];
for &(si, seg, t) in on {
if let Some(here) = by_sub.get_mut(si) {
here.push((seg, t));
}
}
by_sub
}
fn crossings(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "addon.pathtools.crossings";
let (cut, self_crossings) = (switch_or(p, "cut", false, C)?, switch_or(p, "selfCrossings", false, C)?);
@@ -702,8 +716,7 @@ fn crossings(s: &mut Session, p: &Value) -> Result<Value> {
}
let mut subs = vec![];
let mut marks = vec![];
for (si, sp) in path.subpaths.iter().enumerate() {
let here: Vec<(usize, f64)> = on.iter().filter(|(s, ..)| *s == si).map(|&(_, seg, t)| (seg, t)).collect();
for (sp, here) in path.subpaths.iter().zip(places_by_subpath(&on, path.subpaths.len())) {
let (out, m) = pt::insert_at(sp, &here);
count += m.len();
subs.push(out);
+176 -1
View File
@@ -6,7 +6,7 @@ use std::collections::BTreeSet;
use serde_json::{Value, json};
use vectorcraft_doc::{LiveCorners, LiveShape, NodeId};
use vectorcraft_geom::shapes::CornerKind;
use vectorcraft_geom::{AnchorKind, PathData, Point};
use vectorcraft_geom::{AnchorKind, PathData, Point, SubPath};
use vectorcraft_tools::{PointerEvent, PointerKind};
use crate::{Session, ViewInfo};
@@ -275,6 +275,152 @@ fn crossings_add_anchors_or_cut_in_one_step() {
refuses(&mut s, "addon.pathtools.crossings", &[json!({"cut": "yes"}), json!({"ids": [123456]})]);
}
/// A selected path with an open polyline subpath through each of `subs`; an empty list makes an
/// empty subpath.
fn compound(s: &mut Session, subs: &[&[(f64, f64)]]) -> NodeId {
let id = line(s, &[(0.0, 0.0), (1.0, 0.0)], false);
let pd =
PathData::new(subs.iter().map(|pts| SubPath::polyline(&pts.iter().map(|&(x, y)| Point::new(x, y)).collect::<Vec<_>>(), false)).collect());
s.edit("Test", |d, _| {
super::set_path(d, id, pd);
Ok(())
})
.unwrap();
select(s, &[id]);
id
}
/// The anchor points of each subpath of `pd`, rounded to a millionth.
fn points(pd: &PathData) -> Vec<Vec<(f64, f64)>> {
let r = |v: f64| (v * 1e6).round() / 1e6;
pd.subpaths.iter().map(|sp| sp.anchors.iter().map(|a| (r(a.p.x), r(a.p.y))).collect()).collect()
}
/// A path's subpaths: A (0), an empty one (1), C (2) and D (3, the last).
const SUBS: [&[(f64, f64)]; 4] =
[&[(0.0, 0.0), (100.0, 0.0), (200.0, 0.0)], &[], &[(0.0, 50.0), (100.0, 50.0)], &[(0.0, 100.0), (100.0, 100.0), (200.0, 100.0)]];
/// Direct-select `segments` of `id` and the anchors `anchors`.
fn select_segments(s: &mut Session, id: NodeId, anchors: &[(usize, usize)], segments: &[(usize, usize)]) {
s.select(|_, sel| {
sel.set([id]);
sel.anchors.insert(id, anchors.iter().copied().collect());
sel.segments.insert(id, segments.iter().copied().collect());
})
.unwrap();
}
#[test]
fn picked_segments_reads_only_its_own_subpath() {
let set: BTreeSet<(usize, usize)> = [(0, 0), (0, 2), (1, 1), (3, 0), (3, 1)].into();
let sp = SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(1.0, 0.0), Point::new(2.0, 0.0), Point::new(3.0, 0.0)], false);
let all: BTreeSet<usize> = (0..4).collect();
let picked = |si| super::picked_segments(Some(&set), si, &sp, &all).into_iter().collect::<Vec<_>>();
assert_eq!([picked(0), picked(1), picked(2), picked(3), picked(4)], [vec![0, 2], vec![1], vec![], vec![0, 1], vec![]]);
// Without direct-selected segments, the segments between two selected anchors.
let anchors: BTreeSet<usize> = [0, 1, 3].into();
for none in [None, Some(&BTreeSet::new())] {
assert_eq!(super::picked_segments(none, 0, &sp, &anchors).into_iter().collect::<Vec<_>>(), [0]);
}
}
#[test]
fn places_by_subpath_keeps_the_order_and_leaves_out_missing_subpaths() {
let on = [(3, 1, 0.75), (0, 0, 0.5), (3, 1, 0.25), (7, 0, 0.5), (0, 1, 0.1), (3, 0, 0.5), (0, 0, 0.2)];
let by_sub = super::places_by_subpath(&on, 4);
assert_eq!(by_sub, [vec![(0, 0.5), (1, 0.1), (0, 0.2)], vec![], vec![], vec![(1, 0.75), (1, 0.25), (0, 0.5)]]);
// The same places as filtering the whole list for each subpath.
for (si, here) in by_sub.iter().enumerate() {
let filtered: Vec<(usize, f64)> = on.iter().filter(|(s, ..)| *s == si).map(|&(_, seg, t)| (seg, t)).collect();
assert_eq!(*here, filtered);
}
assert!(super::places_by_subpath(&on, 0).is_empty());
}
#[test]
fn add_points_adds_to_the_selected_segments_of_each_subpath() {
let mut s = session();
let c = compound(&mut s, &SUBS);
let before = path(&s, c);
// The first segment of A and the second of D; a segment of C without its anchors selected.
select_segments(&mut s, c, &[(0, 0), (0, 1), (0, 2), (3, 0), (3, 1), (3, 2)], &[(0, 0), (2, 0), (3, 1)]);
let n = steps(&s);
assert_eq!(s.execute("addon.pathtools.addPoints", &json!({})).unwrap()["added"], json!(2));
let want = vec![
vec![(0.0, 0.0), (50.0, 0.0), (100.0, 0.0), (200.0, 0.0)],
vec![],
vec![(0.0, 50.0), (100.0, 50.0)],
vec![(0.0, 100.0), (100.0, 100.0), (150.0, 100.0), (200.0, 100.0)],
];
let after = path(&s, c);
assert_eq!(points(&after), want);
assert_eq!(steps(&s), n + 1);
let st = s.doc().unwrap();
assert_eq!((st.selection.objects.clone(), st.selection.anchors.get(&c), st.selection.segments.get(&c)), (vec![c], None, None));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(path(&s, c), before);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(path(&s, c), after);
// Without selected segments: those between two selected anchors, here on the last subpath only.
for segments in [None, Some(BTreeSet::new())] {
s.execute("edit.undo", &json!({})).unwrap();
s.select(|_, sel| {
sel.set([c]);
sel.anchors.insert(c, [(0, 0), (3, 1), (3, 2)].into());
sel.segments.remove(&c);
if let Some(set) = segments.clone() {
sel.segments.insert(c, set);
}
})
.unwrap();
assert_eq!(s.execute("addon.pathtools.addPoints", &json!({})).unwrap()["added"], json!(1));
assert_eq!(
points(&path(&s, c)),
[points(&before)[..3].to_vec(), vec![vec![(0.0, 100.0), (100.0, 100.0), (150.0, 100.0), (200.0, 100.0)]]].concat()
);
}
}
#[test]
fn crossings_add_anchors_on_each_subpath_of_a_compound_path() {
let mut s = session();
// A and the last subpath cross both lines; the empty subpath and C, which ends short of them, do not.
let c = compound(&mut s, &[&[(0.0, 50.0), (200.0, 50.0)], &[], &[(0.0, 150.0), (40.0, 150.0)], &[(0.0, 250.0), (400.0, 250.0)]]);
let q = line(&mut s, &[(100.0, 0.0), (100.0, 300.0)], false);
let r = line(&mut s, &[(150.0, 0.0), (150.0, 300.0)], false);
select(&mut s, &[c, q, r]);
let n = steps(&s);
assert_eq!(s.execute("addon.pathtools.crossings", &json!({})).unwrap()["crossings"], json!(8));
let want = vec![
vec![(0.0, 50.0), (100.0, 50.0), (150.0, 50.0), (200.0, 50.0)],
vec![],
vec![(0.0, 150.0), (40.0, 150.0)],
vec![(0.0, 250.0), (100.0, 250.0), (150.0, 250.0), (400.0, 250.0)],
];
assert_eq!(points(&path(&s, c)), want);
assert_eq!(points(&path(&s, q)), [vec![(100.0, 0.0), (100.0, 50.0), (100.0, 250.0), (100.0, 300.0)]]);
assert_eq!(steps(&s), n + 1);
s.execute("edit.undo", &json!({})).unwrap();
// Cut: the compound path keeps its pieces as subpaths, in order; each line becomes three paths.
let out = ids(&s.execute("addon.pathtools.crossings", &json!({"cut": true})).unwrap());
assert_eq!(out.len(), 7);
assert_eq!(out[0], c);
let pieces = vec![
vec![(0.0, 50.0), (100.0, 50.0)],
vec![(100.0, 50.0), (150.0, 50.0)],
vec![(150.0, 50.0), (200.0, 50.0)],
vec![],
vec![(0.0, 150.0), (40.0, 150.0)],
vec![(0.0, 250.0), (100.0, 250.0)],
vec![(100.0, 250.0), (150.0, 250.0)],
vec![(150.0, 250.0), (400.0, 250.0)],
];
assert_eq!(points(&path(&s, c)), pieces);
let qs: Vec<Vec<Vec<(f64, f64)>>> = out[1..4].iter().map(|id| points(&path(&s, *id))).collect();
assert_eq!(qs, [[vec![(100.0, 0.0), (100.0, 50.0)]], [vec![(100.0, 50.0), (100.0, 250.0)]], [vec![(100.0, 250.0), (100.0, 300.0)]]]);
assert_eq!(steps(&s), n + 1);
}
// ---------- Parametric Shape Tool ----------
/// The parametric shape object `id` is now, as the tool reads it.
@@ -887,6 +1033,35 @@ fn divide_into_dashes_divides_only_the_selected_segments() {
assert_eq!(layer, pieces, "in the path's place, in order");
}
#[test]
fn divide_into_dashes_divides_the_selected_segments_of_each_subpath() {
let mut s = session();
let c = compound(&mut s, &SUBS);
let before = path(&s, c);
// The first segment of A and the second of D; a segment of C without its anchors selected.
select_segments(&mut s, c, &[(0, 0), (0, 1), (0, 2), (3, 0), (3, 1), (3, 2)], &[(0, 0), (2, 0), (3, 1)]);
let n = steps(&s);
let r = s.execute("addon.pathtools.divideDashes", &json!({"dash": 25})).unwrap();
assert_eq!(steps(&s), n + 1);
assert_eq!(r["dashes"], json!(8));
let made: Vec<Vec<Vec<(f64, f64)>>> = ids(&r).iter().map(|id| points(&path(&s, *id))).collect();
let piece = |x0: f64, x1: f64, y: f64| vec![vec![(x0, y), (x1, y)]];
let mut want = vec![vec![vec![], vec![(0.0, 50.0), (100.0, 50.0)]]];
want.extend([(0.0, 25.0), (25.0, 50.0), (50.0, 75.0), (75.0, 100.0)].map(|(a, b)| piece(a, b, 0.0)));
want.push(vec![vec![(100.0, 0.0), (200.0, 0.0)]]);
want.push(vec![vec![(0.0, 100.0), (100.0, 100.0)]]);
want.extend([(100.0, 125.0), (125.0, 150.0), (150.0, 175.0), (175.0, 200.0)].map(|(a, b)| piece(a, b, 100.0)));
assert_eq!(made, want, "what stays of the path, then each subpath's pieces in order");
assert_eq!(s.doc().unwrap().selection.objects, ids(&r));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(path(&s, c), before);
// With no segments selected: those between two selected anchors, here on the last subpath only.
select_segments(&mut s, c, &[(0, 0), (3, 1), (3, 2)], &[]);
let r = s.execute("addon.pathtools.divideDashes", &json!({"dash": 25})).unwrap();
assert_eq!((ids(&r).len(), r["dashes"].clone()), (6, json!(4)));
assert_eq!(points(&path(&s, ids(&r)[0])), points(&before)[..3]);
}
#[test]
fn divide_into_dashes_refuses_bad_params_and_too_many_pieces() {
let mut s = session();
@@ -0,0 +1,321 @@
//! Trim at Crossings: remove the stretch of a path between the places where other paths, or the
//! path itself, cross it. The command takes the stretch at a point, every stretch a rectangle
//! touches, or every stretch a line through some points crosses. It works on every path that can
//! be edited, selected or not.
use std::collections::HashSet;
use serde_json::{Value, json};
use vectorcraft_doc::{Document, NodeId, NodeKind};
use vectorcraft_geom::kurbo::{Line, PathSeg};
use vectorcraft_geom::{PathData, Point, Rect, SubPath, hit};
use vectorcraft_pathops::addons::pathtools as pt;
use super::super::super::draw2::replace_with_pieces;
use super::super::super::pathops::Beside;
use super::super::super::{CommandSpec, bad, cmd, finite_numbers, has_doc, number_or};
use crate::{Result, Session};
#[cfg(test)]
mod tests;
/// The undo step's name.
const LABEL: &str = "Trim at Crossings";
/// How near `at` a path counts as under it by default (pt), and the most.
const TOLERANCE: f64 = 3.0;
const MAX_TOLERANCE: f64 = 100.0;
/// Most points of `points`.
const MAX_POINTS: usize = 100_000;
/// The farthest coordinate the command takes.
const MAX_COORD: f64 = 1e7;
pub(super) fn specs() -> Vec<CommandSpec> {
vec![cmd!(
"addon.pathtools.trim",
"Trim at Crossings",
[],
None,
"{at: [x, y], tolerance?: pt 0..100 (3; how near `at` a path counts as under it)} or {rect: [x, y, width, height]} or {points: [[x, y]…] (2 to 100000 points)} remove stretches of paths, a stretch running between two neighboring places where the path is crossed by another path or by itself, or from such a place to an open end: `at` removes the stretch of the nearest path at that point, `rect` every stretch the rectangle touches, `points` every stretch the line through the points crosses. Give exactly one of the three. Every path that can be edited takes part, selected or not, compound path members included; hidden and locked paths, guides and clipping paths (compound ones' members included) are neither trimmed nor counted as crossing; in isolation mode only paths inside the isolated object are trimmed. A path that ends on another marks a place on the other; paths that lie along each other don't cross. A closed path that loses a stretch becomes open, an open path that loses a middle stretch becomes two paths with its appearance in its place in the stacking order, and a path nothing crosses is removed whole; a live shape becomes a plain path. One undo step, none when nothing is removed → {ids (the paths left of those trimmed), removed (stretches)}",
has_doc,
trim
)]
}
/// What picks the stretches to remove.
enum Pick {
/// The stretch of the nearest path within the tolerance of the point.
At(Point, f64),
/// Every stretch the rectangle touches.
Rect(Rect),
/// Every stretch the line through the points crosses.
Line(Vec<Point>, Rect),
}
fn pick(p: &Value) -> Result<Pick> {
const C: &str = "addon.pathtools.trim";
let given = |key: &str| p.get(key).filter(|v| !v.is_null());
let within = |v: &[f64]| v.iter().all(|x| x.abs() <= MAX_COORD);
match (given("at"), given("rect"), given("points")) {
(Some(at), None, None) => {
let [x, y] = finite_numbers(at).filter(|v| within(v)).ok_or_else(|| bad(C, "`at` must be a point [x, y]"))?;
let tolerance = number_or(p, "tolerance", TOLERANCE, C)?;
if !(0.0..=MAX_TOLERANCE).contains(&tolerance) {
return Err(bad(C, "`tolerance` must be from 0 to 100 pt"));
}
Ok(Pick::At(Point::new(x, y), tolerance))
}
(None, Some(rect), None) => {
let [x, y, w, h] = finite_numbers(rect).filter(|v| within(v)).ok_or_else(|| bad(C, "`rect` must be [x, y, width, height]"))?;
Ok(Pick::Rect(Rect::new(x, y, x + w, y + h).abs()))
}
(None, None, Some(points)) => {
let list = points.as_array().ok_or_else(|| bad(C, "`points` must be a list of [x, y] points"))?;
if !(2..=MAX_POINTS).contains(&list.len()) {
return Err(bad(C, "`points` must hold from 2 to 100000 points"));
}
let pts = list
.iter()
.map(|v| {
finite_numbers(v)
.filter(|xy| within(xy))
.map(|[x, y]| Point::new(x, y))
.ok_or_else(|| bad(C, format!("bad point {v}: expected [x, y]")))
})
.collect::<Result<Vec<Point>>>()?;
let bounds = pts.iter().fold(None, |r: Option<Rect>, q| Some(r.map_or(Rect::from_points(*q, *q), |r| r.union_pt(*q))));
Ok(Pick::Line(pts, bounds.unwrap_or_default()))
}
_ => Err(bad(C, "give exactly one of `at`, `rect` and `points`")),
}
}
/// A path that takes part: its id, geometry and control-point box.
type Part<'a> = (NodeId, &'a PathData, Rect);
/// The paths that take part, bottom to top: the ones that can be edited, without guides and
/// clipping paths. The clipping path of a clip group or a clipped layer is left out whole, the
/// members of a compound clipping path included.
fn parts(d: &Document) -> Vec<Part<'_>> {
let editable = d.editable();
let mut masks = HashSet::new();
d.walk(|n| {
if n.clips()
&& let Some(first) = n.children().and_then(|c| c.first())
{
first.walk(&mut |m| {
masks.insert(m.id);
});
}
});
let mut out = vec![];
d.walk(|n| {
if let NodeKind::Path { path, guide: false, clipping: false, .. } = &n.kind
&& !masks.contains(&n.id)
&& let Some(b) = path.control_bounds()
&& editable(n.id)
{
out.push((n.id, path, b));
}
});
out
}
/// Most box tests one command makes between a line's pieces and the paths' segments.
const MAX_LINE_TESTS: usize = 60_000_000;
/// Most tests one command makes between the boxes of the paths to trim and of the other paths.
const MAX_BOX_TESTS: usize = 50_000_000;
/// Does the line through `pts` cross `sp`? Each piece of the line compared costs one of `budget`;
/// `None` once it is spent.
fn crossed_by_line(sp: &SubPath, pts: &[Point], budget: &mut usize) -> Option<bool> {
let segs: Vec<_> = (0..sp.segment_count())
.map(|i| {
let c = sp.segment(i);
(c, Rect::from_points(c.p0, c.p1).union_pt(c.p2).union_pt(c.p3).inflate(1e-9, 1e-9))
})
.collect();
let Some(reach) = segs.iter().map(|s| s.1).reduce(|a, b| a.union(b)) else { return Some(false) };
for w in pts.windows(2) {
let [a, b] = w else { continue };
let piece = Rect::from_points(*a, *b);
*budget = budget.checked_sub(1)?;
if !reach.overlaps(piece) {
continue;
}
for (c, r) in &segs {
*budget = budget.checked_sub(1)?;
if r.overlaps(piece) && !PathSeg::Cubic(*c).intersect_line(Line::new(*a, *b)).is_empty() {
return Some(true);
}
}
}
Some(false)
}
/// Does the rectangle or line of `pick` touch the outline of `piece`? Never for `Pick::At`.
fn touches(piece: &SubPath, pick: &Pick, budget: &mut usize) -> Result<bool> {
match pick {
Pick::At(..) => Ok(false),
Pick::Rect(r) => Ok(hit::intersects_rect(&PathData::single(piece.clone()), *r)),
Pick::Line(pts, _) => crossed_by_line(piece, pts, budget)
.ok_or_else(|| bad("addon.pathtools.trim", "the line is too long, or the paths it passes too many, to check")),
}
}
/// `path` without the stretches `pick` takes (`on`: the place of `Pick::At` on it): its subpaths
/// then, and how many stretches went. `None` when none does. A rectangle or a line that touches no
/// part of `path` leaves it out before its crossings are looked for.
fn trimmed(
path: &PathData,
others: &[&PathData],
pick: &Pick,
on: Option<pt::Place>,
budget: &mut usize,
work: &mut pt::Work,
) -> Result<Option<(Vec<SubPath>, usize)>> {
const C: &str = "addon.pathtools.trim";
let reached = match pick {
Pick::At(..) => true,
Pick::Rect(r) => hit::intersects_rect(path, *r),
Pick::Line(..) => {
let mut any = false;
for sp in &path.subpaths {
if touches(sp, pick, budget)? {
any = true;
break;
}
}
any
}
};
if !reached {
return Ok(None);
}
let places =
pt::crossings_on_within(path, others, work).ok_or_else(|| bad(C, "the paths are too many or too tangled to find where they cross"))?;
let mut subs = vec![];
let mut removed = 0;
for (si, sp) in path.subpaths.iter().enumerate() {
// The places come in subpath order: this subpath's are one run of them.
let run = places.partition_point(|p| p.0 < si)..places.partition_point(|p| p.0 <= si);
let here: Vec<(usize, f64)> = places.get(run).unwrap_or_default().iter().map(|&(_, seg, t)| (seg, t)).collect();
let spans = pt::spans(sp, &here);
let marks: Vec<bool> = match pick {
Pick::At(..) => {
let at = on.filter(|(s, ..)| *s == si).and_then(|(_, seg, t)| pt::span_at(sp, &spans, seg, t));
(0..spans.len()).map(|i| Some(i) == at).collect()
}
_ => spans.iter().map(|s| touches(&pt::slice(sp, *s), pick, budget)).collect::<Result<_>>()?,
};
match pt::without_spans(sp, &spans, &marks) {
Some(pieces) => {
removed += marks.iter().filter(|m| **m).count();
subs.extend(pieces);
}
None => subs.push(sp.clone()),
}
}
Ok((removed > 0).then_some((subs, removed)))
}
fn trim(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "addon.pathtools.trim";
let pick = pick(p)?;
let st = s.doc()?;
let d = &st.doc;
let parts = parts(d);
// In isolation mode only the isolated object's paths are trimmed; paths outside it still cross them.
let root = crate::cmd::ordinary_isolation(st);
let in_scope = |id: NodeId| root.is_none_or(|r| id == r || crate::cmd::inside_isolation(d, r, id));
// The paths to trim, each with the place `at` falls on.
let targets: Vec<(usize, Option<pt::Place>)> = match &pick {
Pick::At(at, tolerance) => {
let mut best: Option<(usize, pt::Place, f64)> = None;
for (i, (id, path, b)) in parts.iter().enumerate() {
if !in_scope(*id) {
continue;
}
let b = b.inflate(*tolerance, *tolerance);
if !(b.x0 <= at.x && at.x <= b.x1 && b.y0 <= at.y && at.y <= b.y1) {
continue;
}
// Of paths equally near, the one on top.
if let Some((si, seg, t, _, dist)) = path.nearest(*at)
&& dist <= *tolerance
&& best.is_none_or(|b| dist <= b.2)
{
best = Some((i, (si, seg, t), dist));
}
}
best.map(|(i, on, _)| (i, Some(on))).into_iter().collect()
}
Pick::Rect(r) | Pick::Line(_, r) => {
parts.iter().enumerate().filter(|(_, (id, _, b))| in_scope(*id) && b.overlaps(*r)).map(|(i, _)| (i, None)).collect()
}
};
// Each path to trim has its box tested against every other path's.
if targets.len().saturating_mul(parts.len()) > MAX_BOX_TESTS {
return Err(bad(C, "the rectangle or line reaches too many paths to check"));
}
let mut plan: Vec<(NodeId, Vec<PathData>)> = vec![];
let mut removed = 0;
let mut budget = MAX_LINE_TESTS;
// One budget of crossing work for every path the command trims.
let mut work = pt::Work::default();
for (i, on) in targets {
let Some((id, path, b)) = parts.get(i) else { continue };
let reach = b.inflate(1e-3, 1e-3);
let others: Vec<&PathData> =
parts.iter().enumerate().filter(|(j, (_, _, ob))| *j != i && reach.overlaps(*ob)).map(|(_, (_, o, _))| *o).collect();
let Some((subs, n)) = trimmed(path, &others, &pick, on, &mut budget, &mut work)? else { continue };
removed += n;
// A path of one subpath becomes a path per piece; the pieces of one with more stay its subpaths.
let pieces = match (path.subpaths.len(), subs.is_empty()) {
(_, true) => vec![],
(1, false) => subs.into_iter().map(PathData::single).collect(),
_ => vec![PathData::new(subs)],
};
plan.push((*id, pieces));
}
if plan.is_empty() {
return Ok(json!({ "ids": [], "removed": 0 }));
}
let ids = s.edit(LABEL, |d, sel| {
let trimmed: Vec<NodeId> = plan.iter().map(|(id, _)| *id).collect();
// The compound paths that may lose their last member.
let mut compounds: Vec<NodeId> = trimmed
.iter()
.filter_map(|id| d.parent_of(*id))
.filter(|c| matches!(d.node(*c).map(|n| &n.kind), Some(NodeKind::Compound { .. })))
.collect();
compounds.dedup();
let mut beside = Beside::replacing(d, &trimmed);
let mut all = vec![];
for (id, pieces) in plan {
let selected = sel.contains(id);
sel.anchors.remove(&id);
sel.segments.remove(&id);
let made = replace_with_pieces(d, &mut beside, id, pieces)?;
// The pieces of a selected path are selected; what was not selected stays so.
if made.is_empty() {
sel.remove(id);
} else if selected {
for n in &made {
if !sel.contains(*n) {
sel.add(*n);
}
}
}
all.extend(made);
}
beside.finish(d)?;
for c in compounds {
if d.node(c).is_some_and(|n| n.children().is_some_and(|ch| ch.is_empty())) {
d.remove(c)?;
sel.remove(c);
}
}
Ok(all)
})?;
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "removed": removed }))
}
@@ -0,0 +1,527 @@
//! Trim at Crossings: the stretch at a point, the stretches a rectangle touches or a line crosses,
//! one undo step, and what takes no part.
use serde_json::{Value, json};
use vectorcraft_doc::{NodeId, NodeKind};
use vectorcraft_geom::{PathData, Point};
use crate::Session;
const TRIM: &str = "addon.pathtools.trim";
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
s
}
fn line(s: &mut Session, pts: &[(f64, f64)], closed: bool) -> NodeId {
let anchors: Vec<Value> = pts.iter().map(|(x, y)| json!({"x": x, "y": y})).collect();
let id = NodeId(s.execute("path.create", &json!({"anchors": anchors, "closed": closed})).unwrap()["id"].as_u64().unwrap());
s.execute("select.none", &json!({})).unwrap();
id
}
/// A line across at y = 100 from x = 0 to 300, and two uprights across it at x = 100 and x = 200.
fn across_and_uprights(s: &mut Session) -> (NodeId, NodeId, NodeId) {
let h = line(s, &[(0.0, 100.0), (300.0, 100.0)], false);
let v1 = line(s, &[(100.0, 50.0), (100.0, 150.0)], false);
let v2 = line(s, &[(200.0, 50.0), (200.0, 150.0)], false);
(h, v1, v2)
}
fn path(s: &Session, id: NodeId) -> PathData {
s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().clone()
}
fn exists(s: &Session, id: NodeId) -> bool {
s.doc().unwrap().doc.node(id).is_some()
}
/// The first and last anchor of the path's only subpath.
fn ends(s: &Session, id: NodeId) -> (Point, Point) {
let p = path(s, id);
assert_eq!(p.subpaths.len(), 1);
(p.subpaths[0].anchors.first().unwrap().p, p.subpaths[0].anchors.last().unwrap().p)
}
fn near(p: Point, x: f64, y: f64) -> bool {
p.distance(Point::new(x, y)) < 1e-6
}
fn steps(s: &Session) -> usize {
s.doc().unwrap().history.undo.len()
}
fn ids(v: &Value) -> Vec<NodeId> {
v["ids"].as_array().unwrap().iter().map(|x| NodeId(x.as_u64().unwrap())).collect()
}
/// The ids of the first layer's objects, bottom to top.
fn stack(s: &Session) -> Vec<NodeId> {
s.doc().unwrap().doc.layers[0].children().unwrap().iter().map(|n| n.id).collect()
}
fn set_flags(s: &mut Session, id: NodeId, locked: bool, visible: bool) {
s.edit("flags", |d, _| {
let n = d.node_mut(id).unwrap();
n.locked = locked;
n.visible = visible;
Ok(())
})
.unwrap();
}
#[test]
fn a_point_between_two_crossings_removes_that_stretch_and_splits_the_path() {
let mut s = session();
let (h, v1, v2) = across_and_uprights(&mut s);
let look = s.doc().unwrap().doc.node(h).unwrap().appearance.clone();
let (before, n) = (stack(&s), steps(&s));
let r = s.execute(TRIM, &json!({"at": [150, 101]})).unwrap();
assert_eq!(r["removed"], json!(1));
let made = ids(&r);
assert_eq!(made.len(), 2, "{r}");
assert_eq!(made[0], h, "the first piece keeps the id");
let (a, b) = (ends(&s, made[0]), ends(&s, made[1]));
assert!(near(a.0, 0.0, 100.0) && near(a.1, 100.0, 100.0), "{a:?}");
assert!(near(b.0, 200.0, 100.0) && near(b.1, 300.0, 100.0), "{b:?}");
// Both pieces look as the path did and stand where it stood, under the uprights.
assert!(made.iter().all(|id| s.doc().unwrap().doc.node(*id).unwrap().appearance == look));
assert_eq!(stack(&s), [made[0], made[1], v1, v2]);
assert_eq!(before, [h, v1, v2]);
// The uprights are as they were, nothing got selected, and it is one undo step.
assert_eq!((path(&s, v1).anchor_count(), path(&s, v2).anchor_count()), (2, 2));
assert!(s.doc().unwrap().selection.is_empty());
assert_eq!(steps(&s), n + 1);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(stack(&s), before);
assert!(near(ends(&s, h).1, 300.0, 100.0));
}
#[test]
fn an_end_stretch_runs_to_the_open_end_and_the_rest_stays_one_path() {
let mut s = session();
let (h, ..) = across_and_uprights(&mut s);
let r = s.execute(TRIM, &json!({"at": [40, 100]})).unwrap();
assert_eq!((ids(&r), r["removed"].clone()), (vec![h], json!(1)));
let e = ends(&s, h);
assert!(near(e.0, 100.0, 100.0) && near(e.1, 300.0, 100.0), "{e:?}");
// No anchor is added at the crossing the rest runs through.
assert_eq!(path(&s, h).anchor_count(), 2);
// A point on no path within the tolerance removes nothing and makes no step.
let n = steps(&s);
assert_eq!(s.execute(TRIM, &json!({"at": [250, 110]})).unwrap(), json!({"ids": [], "removed": 0}));
assert_eq!(s.execute(TRIM, &json!({"at": [250, 110], "tolerance": 0})).unwrap()["removed"], json!(0));
assert_eq!(steps(&s), n);
assert_eq!(s.execute(TRIM, &json!({"at": [250, 110], "tolerance": 12})).unwrap()["removed"], json!(1));
assert!(near(ends(&s, h).1, 200.0, 100.0));
}
#[test]
fn a_closed_path_that_loses_a_stretch_opens() {
let mut s = session();
let sq = NodeId(s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap());
s.execute("select.none", &json!({})).unwrap();
line(&mut s, &[(50.0, 150.0), (250.0, 150.0)], false);
// The top half of the square's outline, between the two places the line crosses it.
let r = s.execute(TRIM, &json!({"at": [150, 100]})).unwrap();
assert_eq!((ids(&r), r["removed"].clone()), (vec![sq], json!(1)));
let p = path(&s, sq);
assert_eq!(p.subpaths.len(), 1);
assert!(!p.subpaths[0].closed);
let pts: Vec<Point> = p.subpaths[0].anchors.iter().map(|a| a.p).collect();
assert_eq!(pts.len(), 4, "{pts:?}");
assert!(pts.iter().all(|q| q.y >= 150.0 - 1e-6), "only the lower half is left: {pts:?}");
assert!(pts.iter().any(|q| near(*q, 100.0, 200.0)) && pts.iter().any(|q| near(*q, 200.0, 200.0)));
// It is a plain path now.
assert!(matches!(&s.doc().unwrap().doc.node(sq).unwrap().kind, NodeKind::Path { live: None, .. }));
}
#[test]
fn a_rectangle_removes_every_stretch_it_touches_as_one_step() {
let mut s = session();
let (h, v1, v2) = across_and_uprights(&mut s);
let h2 = line(&mut s, &[(0.0, 120.0), (300.0, 120.0)], false);
let n = steps(&s);
// Over the middle of both lines across, touching neither upright.
let r = s.execute(TRIM, &json!({"rect": [140, 90, 20, 40]})).unwrap();
assert_eq!(r["removed"], json!(2));
assert_eq!(ids(&r).len(), 4);
assert_eq!(steps(&s), n + 1, "one undo step");
assert!(near(ends(&s, h).1, 100.0, 100.0) && near(ends(&s, h2).1, 100.0, 120.0));
assert_eq!((path(&s, v1).anchor_count(), path(&s, v2).anchor_count()), (2, 2));
s.execute("edit.undo", &json!({})).unwrap();
assert!(near(ends(&s, h).1, 300.0, 100.0) && near(ends(&s, h2).1, 300.0, 120.0));
// A rectangle given from its far corner is the same one; one over empty space removes nothing.
assert_eq!(s.execute(TRIM, &json!({"rect": [160, 130, -20, -40]})).unwrap()["removed"], json!(2));
let n = steps(&s);
assert_eq!(s.execute(TRIM, &json!({"rect": [300, 200, 50, 50]})).unwrap()["removed"], json!(0));
assert_eq!(steps(&s), n);
// An upright's stretches: above the upper line, between the two, below the lower. A rectangle
// over its top end takes the first only.
assert_eq!(s.execute(TRIM, &json!({"rect": [95, 45, 10, 10]})).unwrap()["removed"], json!(1));
assert!(near(ends(&s, v1).0, 100.0, 100.0) && near(ends(&s, v1).1, 100.0, 150.0));
}
#[test]
fn a_line_through_points_removes_the_stretches_it_crosses() {
let mut s = session();
let (h, v1, v2) = across_and_uprights(&mut s);
let n = steps(&s);
// Down through the middle of the line across, then right through the lower end of the second upright.
let r = s.execute(TRIM, &json!({"points": [[150, 80], [150, 130], [250, 130]]})).unwrap();
assert_eq!(r["removed"], json!(2));
assert_eq!(steps(&s), n + 1);
assert!(near(ends(&s, h).1, 100.0, 100.0));
assert!(near(ends(&s, v2).0, 200.0, 50.0) && near(ends(&s, v2).1, 200.0, 100.0));
assert_eq!(path(&s, v1).anchor_count(), 2);
}
#[test]
fn locked_and_hidden_paths_are_not_trimmed_and_do_not_count_as_crossing() {
let mut s = session();
let (h, v1, v2) = across_and_uprights(&mut s);
set_flags(&mut s, v1, true, true);
set_flags(&mut s, v2, false, false);
let n = steps(&s);
// On the locked upright, and a rectangle over the hidden one's top end: nothing goes.
assert_eq!(s.execute(TRIM, &json!({"at": [100, 60]})).unwrap()["removed"], json!(0));
assert_eq!(s.execute(TRIM, &json!({"rect": [195, 45, 10, 10]})).unwrap()["removed"], json!(0));
assert_eq!(steps(&s), n);
assert_eq!((path(&s, v1).anchor_count(), path(&s, v2).anchor_count()), (2, 2));
// Neither marks a place on the line across: with nothing else crossing it, it goes whole.
assert_eq!(s.execute(TRIM, &json!({"at": [150, 100]})).unwrap(), json!({"ids": [], "removed": 1}));
assert!(!exists(&s, h) && exists(&s, v1) && exists(&s, v2));
// A guide doesn't count either.
s.execute("edit.undo", &json!({})).unwrap();
set_flags(&mut s, v1, false, true);
s.execute("select.set", &json!({"ids": [v1.0]})).unwrap();
s.execute("view.guides.make", &json!({})).unwrap();
s.execute("select.none", &json!({})).unwrap();
s.execute(TRIM, &json!({"at": [150, 100]})).unwrap();
assert!(!exists(&s, h));
}
#[test]
fn a_path_of_two_subpaths_loses_the_stretch_on_the_subpath_under_the_point() {
let mut s = session();
// Lines of different lengths, so the places on one fall at other parameters than on the other.
let line_at = |x0: f64, x1: f64, y: f64| vectorcraft_geom::SubPath::polyline(&[Point::new(x0, y), Point::new(x1, y)], false);
let two = s
.edit("two", |d, _| {
let layer = d.layers[0].id;
let id = d.alloc_id();
let both = PathData::new(vec![line_at(0.0, 300.0, 100.0), line_at(50.0, 250.0, 120.0)]);
d.insert(Some(layer), 0, vectorcraft_doc::Node::path(id, both, Default::default()))?;
Ok(id)
})
.unwrap();
line(&mut s, &[(100.0, 50.0), (100.0, 150.0)], false);
line(&mut s, &[(200.0, 50.0), (200.0, 150.0)], false);
// The middle of the lower subpath goes; the upper one stays whole, both in the one path.
assert_eq!(s.execute(TRIM, &json!({"at": [150, 120]})).unwrap()["removed"], json!(1));
let p = path(&s, two);
let spans: Vec<(Point, Point)> = p.subpaths.iter().map(|sp| (sp.anchors[0].p, sp.anchors.last().unwrap().p)).collect();
assert_eq!(spans.len(), 3, "{spans:?}");
assert!(near(spans[0].0, 0.0, 100.0) && near(spans[0].1, 300.0, 100.0), "{spans:?}");
assert!(near(spans[1].0, 50.0, 120.0) && near(spans[1].1, 100.0, 120.0), "{spans:?}");
assert!(near(spans[2].0, 200.0, 120.0) && near(spans[2].1, 250.0, 120.0), "{spans:?}");
}
#[test]
fn a_clipping_path_is_not_trimmed_and_does_not_count_as_crossing() {
let mut s = session();
let (h, v1, _) = across_and_uprights(&mut s);
s.edit("clip", |d, _| {
if let NodeKind::Path { clipping, .. } = &mut d.node_mut(v1).unwrap().kind {
*clipping = true;
}
Ok(())
})
.unwrap();
assert_eq!(s.execute(TRIM, &json!({"at": [100, 60]})).unwrap()["removed"], json!(0));
assert_eq!(path(&s, v1).anchor_count(), 2);
// The line across is crossed by the second upright only: its stretch from the start goes.
assert_eq!(s.execute(TRIM, &json!({"at": [150, 100]})).unwrap()["removed"], json!(1));
assert!(near(ends(&s, h).0, 200.0, 100.0) && near(ends(&s, h).1, 300.0, 100.0));
// Type over the rest of it takes no part either: nothing crosses the rest, and it goes whole.
let t = NodeId(s.execute("text.create", &json!({"x": 220, "y": 115, "text": "HHHH", "size": 40})).unwrap()["id"].as_u64().unwrap());
s.execute("select.none", &json!({})).unwrap();
assert_eq!(s.execute(TRIM, &json!({"rect": [215, 70, 70, 50]})).unwrap(), json!({"ids": [], "removed": 1}));
assert!(!exists(&s, h) && exists(&s, t));
}
#[test]
fn the_members_of_a_compound_clipping_path_are_not_trimmed_and_do_not_count_as_crossing() {
let mut s = session();
let (h, v1, _) = across_and_uprights(&mut s);
let extra = line(&mut s, &[(250.0, 250.0), (260.0, 260.0)], false);
s.execute("select.set", &json!({"ids": [v1.0, extra.0]})).unwrap();
let compound = NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap());
s.execute("select.set", &json!({"ids": [h.0, compound.0]})).unwrap();
s.execute("object.clippingMask.make", &json!({})).unwrap();
s.execute("select.none", &json!({})).unwrap();
let n = steps(&s);
// A click on the clipping path's member changes nothing, and the rectangle over it neither.
assert_eq!(s.execute(TRIM, &json!({"at": [100, 60]})).unwrap()["removed"], json!(0));
assert_eq!(s.execute(TRIM, &json!({"rect": [245, 245, 20, 20]})).unwrap()["removed"], json!(0));
assert_eq!((steps(&s), path(&s, v1).anchor_count(), path(&s, extra).anchor_count()), (n, 2, 2));
// The clipped line across is crossed by the second upright only: its stretch from the start goes.
assert_eq!(s.execute(TRIM, &json!({"at": [150, 100]})).unwrap()["removed"], json!(1));
assert!(near(ends(&s, h).0, 200.0, 100.0) && near(ends(&s, h).1, 300.0, 100.0));
}
#[test]
fn in_isolation_mode_only_the_isolated_objects_paths_are_trimmed() {
let mut s = session();
let (h, v1, v2) = across_and_uprights(&mut s);
s.execute("select.set", &json!({"ids": [v1.0, v2.0]})).unwrap();
let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
s.execute("object.isolate", &json!({"id": g.0})).unwrap();
s.execute("select.none", &json!({})).unwrap();
// The line across is outside the group: a click or a rectangle on it leaves it whole.
assert_eq!(s.execute(TRIM, &json!({"at": [150, 100]})).unwrap()["removed"], json!(0));
assert_eq!(s.execute(TRIM, &json!({"rect": [140, 90, 20, 20]})).unwrap()["removed"], json!(0));
assert!(near(ends(&s, h).0, 0.0, 100.0) && near(ends(&s, h).1, 300.0, 100.0));
// It still crosses the uprights: a rectangle over the tops of both removes them down to it.
assert_eq!(s.execute(TRIM, &json!({"rect": [90, 40, 120, 20]})).unwrap()["removed"], json!(2));
assert!(near(ends(&s, v1).0, 100.0, 100.0) && near(ends(&s, v2).0, 200.0, 100.0));
assert!(near(ends(&s, h).0, 0.0, 100.0) && near(ends(&s, h).1, 300.0, 100.0));
// An isolated path is trimmed itself.
s.execute("object.exitIsolation", &json!({})).unwrap();
s.execute("object.isolate", &json!({"id": h.0})).unwrap();
assert_eq!(s.execute(TRIM, &json!({"at": [150, 100]})).unwrap()["removed"], json!(1));
assert!(near(ends(&s, h).1, 100.0, 100.0));
}
#[test]
fn a_rectangle_over_too_many_paths_to_check_is_refused() {
let mut s = session();
// 7,072 short lines apart from each other: each of them against every other is past the limit.
s.edit("many", |d, _| {
let layer = d.layers[0].id;
for i in 0..7072 {
let (x, y) = ((i % 100) as f64 * 3.0, (i / 100) as f64 * 3.0);
let sp = vectorcraft_geom::SubPath::polyline(&[Point::new(x, y), Point::new(x + 1.0, y)], false);
let id = d.alloc_id();
let top = d.node(layer).unwrap().children().unwrap().len();
d.insert(Some(layer), top, vectorcraft_doc::Node::path(id, PathData::single(sp), Default::default()))?;
}
Ok(())
})
.unwrap();
let n = steps(&s);
let err = s.execute(TRIM, &json!({"rect": [-10, -10, 400, 300]})).unwrap_err().to_string();
assert!(err.contains("too many paths"), "{err}");
assert_eq!((steps(&s), stack(&s).len()), (n, 7072));
// A rectangle over a few of them is checked.
assert_eq!(s.execute(TRIM, &json!({"rect": [-1, -1, 5, 2]})).unwrap()["removed"], json!(2));
}
#[test]
fn a_path_too_large_to_search_is_refused_only_when_touched() {
let mut s = session();
let (h, ..) = across_and_uprights(&mut s);
// A border of 10,001 segments round all of them, more than a path to trim may have.
let mut border: Vec<(f64, f64)> = (0..=10_001).map(|i| (-500.0 + i as f64 * 0.2, -500.0)).collect();
border.push((-500.0, 1500.0));
let b = line(&mut s, &border, true);
// A rectangle and a line over the middle of the line across reach inside the border's box only.
assert_eq!(s.execute(TRIM, &json!({"rect": [140, 90, 20, 20]})).unwrap()["removed"], json!(1));
assert!(near(ends(&s, h).1, 100.0, 100.0));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.execute(TRIM, &json!({"points": [[150, 80], [150, 120]]})).unwrap()["removed"], json!(1));
// A rectangle on the border is refused, and changes nothing.
let n = steps(&s);
let err = s.execute(TRIM, &json!({"rect": [-510, 0, 20, 20]})).unwrap_err().to_string();
assert!(err.contains("too many or too tangled"), "{err}");
assert_eq!((steps(&s), path(&s, b).anchor_count()), (n, 10_003));
}
#[test]
fn a_path_nothing_crosses_goes_whole_open_or_closed() {
let mut s = session();
let open = line(&mut s, &[(0.0, 10.0), (50.0, 10.0), (50.0, 40.0)], false);
let closed = line(&mut s, &[(100.0, 10.0), (150.0, 10.0), (150.0, 40.0)], true);
let n = steps(&s);
assert_eq!(s.execute(TRIM, &json!({"at": [25, 10]})).unwrap(), json!({"ids": [], "removed": 1}));
assert_eq!(s.execute(TRIM, &json!({"at": [125, 10]})).unwrap(), json!({"ids": [], "removed": 1}));
assert!(!exists(&s, open) && !exists(&s, closed));
assert_eq!(steps(&s), n + 2);
}
#[test]
fn a_path_that_ends_on_another_marks_a_place_and_paths_along_each_other_do_not() {
let mut s = session();
let h = line(&mut s, &[(0.0, 100.0), (300.0, 100.0)], false);
let stub = line(&mut s, &[(150.0, 100.0), (150.0, 160.0)], false);
// The stub ends on the line: the line's stretch stops there, and the stub is one stretch.
s.execute(TRIM, &json!({"at": [50, 100]})).unwrap();
assert!(near(ends(&s, h).0, 150.0, 100.0) && near(ends(&s, h).1, 300.0, 100.0));
assert_eq!(s.execute(TRIM, &json!({"at": [150, 130]})).unwrap()["removed"], json!(1));
assert!(!exists(&s, stub));
// A second line along the first doesn't mark it: the lower one goes whole.
let along = line(&mut s, &[(200.0, 100.0), (260.0, 100.0)], false);
s.execute(TRIM, &json!({"rect": [270, 90, 10, 20]})).unwrap();
assert!(!exists(&s, h) && exists(&s, along));
}
#[test]
fn a_self_crossing_path_loses_its_loop() {
let mut s = session();
// A bow: its first and third segments cross at (50, 50).
let bow = line(&mut s, &[(0.0, 0.0), (100.0, 100.0), (100.0, 0.0), (0.0, 100.0)], false);
let r = s.execute(TRIM, &json!({"at": [100, 50]})).unwrap();
assert_eq!(r["removed"], json!(1));
let made = ids(&r);
assert_eq!(made.len(), 2);
assert_eq!(made[0], bow);
assert!(near(ends(&s, made[0]).1, 50.0, 50.0) && near(ends(&s, made[1]).0, 50.0, 50.0));
}
#[test]
fn compound_path_members_are_trimmed_and_an_emptied_compound_path_goes() {
let mut s = session();
let a = line(&mut s, &[(0.0, 100.0), (300.0, 100.0)], false);
let b = line(&mut s, &[(100.0, 50.0), (100.0, 150.0)], false);
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
let compound = NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap());
s.execute("select.none", &json!({})).unwrap();
let members = |s: &Session| s.doc().unwrap().doc.node(compound).map(|n| n.children().unwrap().len());
assert_eq!(members(&s), Some(2));
// The members cross each other: a middle stretch splits one member in the compound path.
assert_eq!(s.execute(TRIM, &json!({"at": [100, 75]})).unwrap()["removed"], json!(1));
assert_eq!(members(&s), Some(2));
assert_eq!(s.execute(TRIM, &json!({"at": [100, 125]})).unwrap()["removed"], json!(1));
assert_eq!(members(&s), Some(1));
assert_eq!(s.execute(TRIM, &json!({"at": [200, 100]})).unwrap()["removed"], json!(1));
assert_eq!(members(&s), None, "the emptied compound path is removed");
assert!(stack(&s).is_empty());
}
#[test]
fn the_selection_stays_as_it_was_and_the_pieces_of_a_selected_path_are_selected() {
let mut s = session();
let (_, v1, _) = across_and_uprights(&mut s);
s.execute("select.set", &json!({"ids": [v1.0]})).unwrap();
s.execute(TRIM, &json!({"at": [150, 100]})).unwrap();
assert_eq!(s.doc().unwrap().selection.objects, [v1], "the trimmed path was not selected");
let mut s = session();
let (h, ..) = across_and_uprights(&mut s);
s.execute("select.set", &json!({"ids": [h.0]})).unwrap();
let made = ids(&s.execute(TRIM, &json!({"at": [150, 100]})).unwrap());
assert_eq!(made.len(), 2);
let sel = &s.doc().unwrap().selection;
assert!(made.iter().all(|id| sel.contains(*id)), "{:?}", sel.objects);
// The second piece now ends on an upright: nothing crosses it, and it goes whole.
s.execute("select.set", &json!({"ids": [made[1].0]})).unwrap();
assert_eq!(s.execute(TRIM, &json!({"at": [250, 100]})).unwrap(), json!({"ids": [], "removed": 1}));
assert!(s.doc().unwrap().selection.is_empty(), "a removed path leaves the selection");
}
#[test]
fn many_paths_far_from_the_trimmed_one_cost_no_crossing_search() {
let mut s = session();
let (h, ..) = across_and_uprights(&mut s);
// 3,000 short lines far below, each crossing its neighbor.
s.edit("many", |d, _| {
let layer = d.layers[0].id;
for i in 0..3000 {
let x = (i % 100) as f64 * 3.0;
let y = 1000.0 + (i / 100) as f64 * 3.0;
let sp = vectorcraft_geom::SubPath::polyline(
&[Point::new(x, y), Point::new(x + 5.0, y + 5.0), Point::new(x + 5.0, y), Point::new(x, y + 5.0)],
false,
);
let id = d.alloc_id();
let top = d.node(layer).unwrap().children().unwrap().len();
d.insert(Some(layer), top, vectorcraft_doc::Node::path(id, PathData::single(sp), Default::default()))?;
}
Ok(())
})
.unwrap();
assert_eq!(s.execute(TRIM, &json!({"at": [150, 100]})).unwrap()["removed"], json!(1));
assert!(near(ends(&s, h).1, 100.0, 100.0));
// A rectangle over all of them takes every stretch it touches.
let r = s.execute(TRIM, &json!({"rect": [-10, 990, 400, 200]})).unwrap();
assert!(r["removed"].as_u64().unwrap() >= 3000, "{}", r["removed"]);
assert_eq!(stack(&s).len(), 4, "the line across in two pieces and the uprights");
}
#[test]
fn the_tool_trims_on_release_as_one_step_and_escape_drops_a_drag() {
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
let mut s = session();
let (h, v1, _) = across_and_uprights(&mut s);
let h2 = line(&mut s, &[(0.0, 120.0), (300.0, 120.0)], false);
let view = crate::ViewInfo { smart_guides: false, ..crate::ViewInfo::default() };
s.select_tool("trimAtCrossings", view).unwrap();
assert_eq!(s.tool_id(), "trimAtCrossings");
let n = steps(&s);
// A drag over the middle of both lines across: nothing changes until the release.
s.pointer(&PointerEvent::new(PointerKind::Down, 140.0, 90.0), view).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 160.0, 130.0), view).unwrap();
assert!(near(ends(&s, h).1, 300.0, 100.0));
s.pointer(&PointerEvent::new(PointerKind::Up, 160.0, 130.0), view).unwrap();
assert!(near(ends(&s, h).1, 100.0, 100.0) && near(ends(&s, h2).1, 100.0, 120.0));
assert_eq!(steps(&s), n + 1, "one undo step for the drag");
// Escape during a drag: the release removes nothing.
s.pointer(&PointerEvent::new(PointerKind::Down, 90.0, 40.0), view).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 110.0, 60.0), view).unwrap();
s.tool_key(ToolKey::Escape, Mods::default(), view).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 110.0, 60.0), view).unwrap();
assert_eq!(steps(&s), n + 1);
assert!(near(ends(&s, v1).0, 100.0, 50.0));
// A click on the upright's top stretch removes it.
s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 70.0), view).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 100.0, 70.0), view).unwrap();
assert!(near(ends(&s, v1).0, 100.0, 100.0));
assert_eq!(steps(&s), n + 2);
}
#[test]
fn a_line_too_long_to_check_against_the_paths_is_refused() {
let mut s = session();
// A zigzag of 1,000 segments, and a line of 100,000 points inside its box that crosses none.
let zigzag: Vec<(f64, f64)> = (0..=1000).map(|i| (i as f64 * 0.3, (i % 2) as f64 * 200.0)).collect();
let z = line(&mut s, &zigzag, false);
let (before, n) = (s.doc().unwrap().doc.clone(), steps(&s));
let points: Vec<Value> = (0..100_000).map(|i| json!([150.0 + (i % 2) as f64 * 0.01, 150.0])).collect();
let err = s.execute(TRIM, &json!({ "points": points })).unwrap_err().to_string();
assert!(err.contains("too long"), "{err}");
assert_eq!(steps(&s), n);
assert!(*s.doc().unwrap().doc == *before);
// A short line there is checked, and removes nothing.
assert_eq!(s.execute(TRIM, &json!({"points": [[150.0, 150.0], [150.01, 150.0]]})).unwrap()["removed"], json!(0));
assert!(exists(&s, z));
}
#[test]
fn bad_params_are_refused_and_change_nothing() {
let mut s = session();
across_and_uprights(&mut s);
let (before, n) = (s.doc().unwrap().doc.clone(), steps(&s));
for bad in [
json!({}),
json!({"at": null}),
json!({"at": [150]}),
json!({"at": [150, "y"]}),
json!({"at": [150, 1e308]}),
json!({"at": [150, 100], "tolerance": -1}),
json!({"at": [150, 100], "tolerance": 101}),
json!({"at": [150, 100], "tolerance": "3"}),
json!({"at": [150, 100], "rect": [140, 90, 20, 40]}),
json!({"rect": [140, 90, 20]}),
json!({"rect": "all"}),
json!({"rect": [0, 0, 1e9, 5]}),
json!({"points": [[150, 80]]}),
json!({"points": [[150, 80], [150]]}),
json!({"points": "x"}),
json!({"points": [[150, 80], [150, 130]], "at": [1, 2]}),
] {
assert!(s.execute(TRIM, &bad).is_err(), "{bad}");
}
assert_eq!(steps(&s), n);
assert!(*s.doc().unwrap().doc == *before);
}
+158 -29
View File
@@ -48,7 +48,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add New Fill",
["Window", "Appearance"],
Some("Cmd+/"),
"{ids?|id?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack, on an image right below its Contents row, with its top fill's paint (type without one: its characters' fill; else the default fill). With a fill row active (appearance.setActiveItem), no `ids`, no `id` and no `target: \"contents\"`, the new fill takes that row's paint, goes right above it, and becomes the active row; appearance.duplicateItem copies a whole row. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
"{ids?|id?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack, on an image right below its Contents row, with its top fill's paint (type without one: its characters' fill; else the default fill). With a fill row active (appearance.setActiveItem), no `ids`, no `id` and no `target: \"contents\"`, the new fill takes that row's paint, goes right above it, and becomes the active row; appearance.duplicateItem copies a whole row. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind",
has_doc,
|s, p| add_item(s, p, true)
),
@@ -57,7 +57,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add New Stroke",
["Window", "Appearance"],
Some("Cmd+Alt+/"),
"{ids?|id?, target?} add a stroke on top of each selected object's own stack, on an image right below its Contents row, with its top stroke's paint (else the default stroke) and that stroke's weight when it has paint, at least 1 pt. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
"{ids?|id?, target?} add a stroke on top of each selected object's own stack, on an image right below its Contents row, with its top stroke's paint (else the default stroke) and that stroke's weight when it has paint, at least 1 pt. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind",
has_doc,
|s, p| add_item(s, p, false)
),
@@ -66,7 +66,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Clear Appearance",
["Window", "Appearance"],
None,
"{ids?|id?, target?} leave each object one empty fill and stroke (None; a group none of its own) and reset its opacity and blend mode. On a path directly in a graph series (a series group's id stands for those paths and the group), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md)",
"{ids?|id?, target?} leave each object one empty fill and stroke (None; a group none of its own) and reset its opacity and blend mode. On a path directly in a graph series (a series group's id stands for those paths and the group), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind",
has_doc,
clear_appearance
),
@@ -75,7 +75,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Reduce to Basic Appearance",
["Window", "Appearance"],
None,
"{ids?|id?, target?} keep only one visible fill and stroke (without ids: the Appearance panel's active row in place of the topmost one of its kind; else the topmost), without their own opacity, blend mode or effects (a group keeps no rows it lacked), and drop the object's effects. On a path directly in a graph series (a series group's id stands for those paths and the group), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
"{ids?|id?, target?} keep only one visible fill and stroke (without ids: the Appearance panel's active row in place of the topmost one of its kind; else the topmost), without their own opacity, blend mode or effects (a group keeps no rows it lacked), and drop the object's effects. On a path directly in a graph series (a series group's id stands for those paths and the group), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind",
has_doc,
reduce_basic
),
@@ -84,7 +84,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Appearance Item",
[],
None,
"{index: paint-order item index, ids?|id? (default: the selection), target?: \"object\"|\"contents\", opacity?: 0..100, blend?: name, visible?: bool, weight?: pt (strokes), color?|none?|swatch?|gradient? (as paint.setFill), tint?: 0..100, keepModel?: bool (both as paint.setFill), aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} edit one fill/stroke of each target object's own appearance stack. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
"{index: paint-order item index, ids?|id? (default: the selection), target?: \"object\"|\"contents\", opacity?: 0..100, blend?: name, visible?: bool, weight?: pt (strokes), color?|none?|swatch?|gradient? (as paint.setFill), tint?: 0..100, keepModel?: bool (both as paint.setFill), aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} edit one fill/stroke of each target object's own appearance stack. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind",
has_doc,
set_item
),
@@ -93,7 +93,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Remove Item",
[],
None,
"{index | indices: [..] (paint-order item indices), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} remove those fills/strokes from each target object's own stack. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
"{index | indices: [..] (paint-order item indices), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} remove those fills/strokes from each target object's own stack. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind",
has_doc,
remove_item
),
@@ -111,7 +111,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Duplicate Item",
["Window", "Appearance"],
None,
"{index | indices: [..] (paint-order item indices), to?: paint-order index of the copy (with several, of the lowest copy: they land together in their order; default: each right above its item; not with aligned rows of a mixed selection), contents?: count (with `to`: how many items paint below the Contents (Characters) row afterwards; default: it stays between the same items), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} copy fills/strokes with their effects, as Alt-dragging rows places them. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
"{index | indices: [..] (paint-order item indices), to?: paint-order index of the copy (with several, of the lowest copy: they land together in their order; default: each right above its item; not with aligned rows of a mixed selection), contents?: count (with `to`: how many items paint below the Contents (Characters) row afterwards; default: it stays between the same items), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} copy fills/strokes with their effects, as Alt-dragging rows places them. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind",
has_doc,
duplicate_item
),
@@ -120,7 +120,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Reorder Appearance Item",
[],
None,
"{from: paint-order item index | \"contents\", indices?: [..] (several items instead of from: they move as one block in their order, the lowest landing at `to`; a block landing next to the Contents row keeps its lowest item's side), to, contents?: count, ids?|id?, target?, aligned?: bool (refused when the targets' appearances differ)} move fill/stroke `from` to paint-order index `to`; on a group, layer or type the Contents (Characters) row stays between the same items (a moved item landing next to it keeps its side) unless `contents` says how many items paint below it afterwards. from \"contents\": move that row so `to` items paint below the members (characters) and the rest above. On a path directly in a graph series (a series group's id stands for those paths, except with from \"contents\", which moves the group's own row), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {index: where the item landed | contents: the row's slot}",
"{from: paint-order item index | \"contents\", indices?: [..] (several items instead of from: they move as one block in their order, the lowest landing at `to`; a block landing next to the Contents row keeps its lowest item's side), to, contents?: count, ids?|id?, target?, aligned?: bool (refused when the targets' appearances differ)} move fill/stroke `from` to paint-order index `to`; on a group, layer or type the Contents (Characters) row stays between the same items (a moved item landing next to it keeps its side) unless `contents` says how many items paint below it afterwards. from \"contents\": move that row so `to` items paint below the members (characters) and the rest above. On a path directly in a graph series (a series group's id stands for those paths, except with from \"contents\", which moves the group's own row), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind → {index: where the item landed | contents: the row's slot}",
has_doc,
move_item
),
@@ -129,7 +129,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Eyedropper",
[],
None,
"{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes using effective character dimensions while preserving target transforms) from `source` to ids (default: highlighted characters while editing type, after Select All in a frame of threaded text the text of the story's frames, leaving out `source`, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art); with an object of a dynamic symbol instance selected by Direct Selection and no ids, reverse, append or defaultsOnly, that object takes the source's fill and stroke (solid, none, gradient or pattern) for this instance only, as the Eyedropper Options allow, and a source that is not a path, compound path or type, or options that pick up neither color, are refused. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md) → {ids}",
"{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes using effective character dimensions while preserving target transforms) from `source` to ids (default: highlighted characters while editing type, after Select All in a frame of threaded text the text of the story's frames, leaving out `source`, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art); with an object of a dynamic symbol instance selected by Direct Selection and no ids, reverse, append or defaultsOnly, that object takes the source's fill and stroke (solid, none, gradient or pattern) for this instance only, as the Eyedropper Options allow, and a source that is not a path, compound path or type, or options that pick up neither color, are refused. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md); on a graph label, the graph stores, for every label of its kind, the character attributes the pick gives all of its text, its object opacity and blend mode, and with `append` its topmost visible fill and stroke rows → {ids}",
has_doc,
copy_from
),
@@ -147,7 +147,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Show All Hidden Attributes",
["Window", "Appearance"],
None,
"{ids?|id?, target?} make every hidden fill, stroke and effect (the object's and each item's) of each selected object visible again; errors when nothing is hidden. On a path directly in a graph series (a series group's id stands for those paths and the group), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {ids}",
"{ids?|id?, target?} make every hidden fill, stroke and effect (the object's and each item's) of each selected object visible again; errors when nothing is hidden. On a path directly in a graph series (a series group's id stands for those paths and the group), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind → {ids}",
has_doc,
show_all_hidden
),
@@ -165,7 +165,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Move Appearance",
[],
None,
"{source: id, target: id, copy?: bool} give `target` (a layer, group or object) the appearance (fills, strokes, effects) and transparency (opacity, blend mode, isolation, knockout) of `source`, as dragging a target circle onto another in the Layers panel does; the source is left with a cleared appearance (as appearance.clear) unless `copy` (Alt-drag). Opacity masks stay; placed gradients keep their place relative to each object's bounds. On a path directly in a graph series (a series group's id stands for those paths; as `source`, when the group has no effect, opacity, blend mode, isolation, knockout or style link of its own, the topmost one is read and a move clears them all; as `target` each takes the appearance and the group is cleared, a `source` among them keeps its look, and a `source` that is the group's only path is an error), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md) → {source, target}",
"{source: id, target: id, copy?: bool} give `target` (a layer, group or object) the appearance (fills, strokes, effects) and transparency (opacity, blend mode, isolation, knockout) of `source`, as dragging a target circle onto another in the Layers panel does; the source is left with a cleared appearance (as appearance.clear) unless `copy` (Alt-drag). Opacity masks stay; placed gradients keep their place relative to each object's bounds. On a path directly in a graph series (a series group's id stands for those paths; as `source`, when the group has no effect, opacity, blend mode, isolation, knockout or style link of its own, the topmost one is read and a move clears them all; as `target` each takes the appearance and the group is cleared, a `source` among them keeps its look, and a `source` that is the group's only path is an error), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind → {source, target}",
has_doc,
transfer
),
@@ -419,6 +419,27 @@ pub(crate) fn appearance_targets(s: &Session, p: &Value) -> Result<Vec<NodeId>>
if targets_contents(p)? { leaf_targets(s, &roots) } else { Ok(roots) }
}
/// The refusal of an appearance edit whose targets are all the art of graph designs drawn in graph series.
const DESIGN_ART: &str = "the art of a graph design in a graph series is drawn again from the design: edit the design to change it";
/// The [`appearance_targets`] whose rows and effects a command edits: the art of graph designs drawn in graph series
/// ([`super::graph::is_design_art`]) is left out, as the graph draws it again from the design. A command whose targets
/// are all such art is refused.
pub(crate) fn edit_targets(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
let ids = appearance_targets(s, p)?;
without_design_art(&s.doc()?.doc, ids, "appearance")
}
/// `ids` without the art of graph designs drawn in graph series; `cmd` is refused when `ids` held nothing else.
pub(crate) fn without_design_art(d: &Document, mut ids: Vec<NodeId>, cmd: &str) -> Result<Vec<NodeId>> {
let given = ids.len();
ids.retain(|id| !super::graph::is_design_art(d, *id));
if given > 0 && ids.is_empty() {
return Err(bad(cmd, DESIGN_ART));
}
Ok(ids)
}
/// `ids` with each graph series group that has no fill or stroke row of its own replaced by the paths directly inside
/// it ([`super::graph::series_group_marks`]). A series group with rows, as a document saved earlier can hold, stays:
/// the Appearance panel lists those rows and edits them by index.
@@ -453,17 +474,17 @@ pub(crate) fn look_holder<'a>(d: &'a Document, n: &'a Node) -> &'a Node {
look_holders(d, n.id).last().and_then(|id| d.node(*id)).unwrap_or(n)
}
/// Objects whose fill and stroke rows a command edits: the [`appearance_targets`], with a graph series group standing
/// for the paths directly inside it ([`rowless_series_marks`]), so the graph can store the edit.
/// Objects whose fill and stroke rows a command edits: the [`edit_targets`], with a graph series group standing for the
/// paths directly inside it ([`rowless_series_marks`]), so the graph can store the edit.
pub(crate) fn row_targets(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
let ids = appearance_targets(s, p)?;
let ids = edit_targets(s, p)?;
Ok(rowless_series_marks(&s.doc()?.doc, &ids))
}
/// Objects whose whole appearance a command edits: the [`row_targets`], then each graph series group they stand for,
/// which can hold effects and a graphic style link of its own.
pub(crate) fn whole_targets(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
let own = appearance_targets(s, p)?;
let own = edit_targets(s, p)?;
let mut ids = rowless_series_marks(&s.doc()?.doc, &own);
let groups = stood_for(&own, &ids);
ids.extend(groups);
@@ -545,15 +566,21 @@ pub(crate) fn edit_items_then(
let per = item.per_target(s, ids);
super::overprint::edit_counted(s, label, |d| {
let mut edited = Vec::new();
// The objects whose rows `f` edited: on type, no row means its characters.
let mut rows = Vec::new();
for (id, item) in ids.iter().zip(&per) {
let Some(item) = item else { continue };
let Some(n) = d.node_mut(*id) else { continue };
let index = item.resolve(&n.appearance, fill, cmd)?;
f(n, index)?;
edited.push(*id);
if index.is_some() {
rows.push(*id);
}
}
let more = after(&before, d, &edited)?;
Ok(count_changed(d, &before, &edited) + usize::from(more))
let labels = super::graph::capture_label_rows(d, &rows);
Ok(count_changed(d, &before, &edited) + usize::from(more || labels))
})?;
Ok(())
}
@@ -690,6 +717,7 @@ fn add_item(s: &mut Session, p: &Value, fill: bool) -> Result<Value> {
let mut rows = top_paint_changed(&before, d, &ids);
rows.extend(painted_rows(d, added));
super::graph::capture_series_top_paints(d, &rows);
super::graph::capture_label_rows(d, &ids);
Ok(())
})?;
// A fill added above the active row becomes the active row; otherwise the rows above the new
@@ -712,7 +740,8 @@ fn clear_appearance(s: &mut Session, p: &Value) -> Result<Value> {
let kept = super::graph::capture_series_opacity(d, &both_kinds(&ids));
let painted = super::graph::capture_series_top_paints(d, &top_paint_changed(&before, d, &ids));
let object = super::graph::capture_series_object_opacity(d, &ids);
Ok(count_changed(d, &before, &ids) + usize::from(kept || painted || object))
let labels = super::graph::capture_label_rows(d, &ids);
Ok(count_changed(d, &before, &ids) + usize::from(kept || painted || object || labels))
})?;
s.active_appearance_item = None;
ok()
@@ -798,12 +827,45 @@ pub(super) fn capture_series_writes(
/// and objects the edit left as they were keep what the graph stores. When the edit changes several marks of one series,
/// the frontmost one sets what the graph stores, whatever order `ids` are in. Returns whether the stored paint changed.
pub(crate) fn capture_series_changes(before: &vectorcraft_doc::Document, d: &mut vectorcraft_doc::Document, ids: &[NodeId]) -> bool {
let marks = series_children_in_paint_order(d, ids);
!marks.is_empty() && capture_series_writes(before, d, &marks, (false, false), (false, false), false, false)
}
/// Each of `ids` directly in a series group, from the back to the front: a later mark's capture replaces an earlier
/// one's, so the frontmost one sets what the graph stores.
pub(crate) fn series_children_in_paint_order(d: &vectorcraft_doc::Document, ids: &[NodeId]) -> Vec<NodeId> {
// Only objects directly in a series group can be marks: other edits skip the search of the document for graphs.
let in_series = |id: &NodeId| d.parent_of(*id).and_then(|p| d.node(p)).is_some_and(|g| g.extras().series_index.is_some());
let mut marks: Vec<NodeId> = ids.iter().copied().filter(in_series).collect();
// A later mark's capture replaces an earlier one's: take them in paint order.
marks.sort_by_cached_key(|id| d.index_path(*id));
!marks.is_empty() && capture_series_writes(before, d, &marks, (false, false), (false, false), false, false)
marks
}
/// Store on the graph the paint that an edit of `ids` (from `before` to `d`) leaves on the topmost visible fill and stroke
/// of the series marks among them: a paint or stroke weight that changed, and the paint of each row the edit wrote
/// (`written`: an object with the index of the row) when that row is the topmost visible one of its kind and has paint.
/// A row written under that one, and a hidden one, keep what the graph stores. When the edit reaches several marks of one
/// series, the frontmost one sets what the graph stores, whatever order `ids` are in. Returns whether the stored paint
/// changed.
pub(crate) fn capture_series_paint_writes(
before: &vectorcraft_doc::Document,
d: &mut vectorcraft_doc::Document,
ids: &[NodeId],
written: &[(NodeId, usize)],
) -> bool {
// Only objects directly in a series group can be marks: other edits skip the comparison of paints and the search of
// the document for graphs.
let in_series = |id: &NodeId| d.parent_of(*id).and_then(|p| d.node(p)).is_some_and(|g| g.extras().series_index.is_some());
let mut marks: Vec<NodeId> = ids.iter().copied().filter(in_series).collect();
// A later mark's capture replaces an earlier one's: take them in paint order, each with all of its rows.
marks.sort_by_cached_key(|id| d.index_path(*id));
let mut rows = Vec::new();
for id in &marks {
rows.extend(top_paint_changed(before, d, &[*id]));
let shown = written.iter().filter(|(w, _)| w == id).filter_map(|(_, at)| Some((*id, top_row_kind(&d.node(*id)?.appearance, *at)?)));
rows.extend(painted_rows(d, shown.collect::<Vec<_>>()));
}
super::graph::capture_series_top_paints(d, &rows)
}
/// The kind of the row at `at` (fill `true`, stroke `false`) when it is the topmost visible row of that kind in `ap`.
@@ -828,6 +890,11 @@ fn transfer(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(C, "`source` and `target` are the same object"));
}
let copy = switch_or(p, "copy", false, C)?;
// The art of a graph design in a series is read as a source, but neither takes an appearance nor gives one up.
without_design_art(&s.doc()?.doc, vec![target], C)?;
if !copy {
without_design_art(&s.doc()?.doc, vec![source], C)?;
}
// A graph series group stands for the paths directly inside it: as the source, the topmost of them is read and a
// move clears them all; as the target, each of them other than a source takes the appearance.
let sources = look_holders(&s.doc()?.doc, source);
@@ -884,8 +951,10 @@ fn transfer(s: &mut Session, p: &Value) -> Result<Value> {
rows.extend(painted_rows(d, both_kinds(&targets)));
let painted = super::graph::capture_series_top_paints(d, &rows);
let object = super::graph::capture_series_object_opacity(d, &changed);
// The targets last: on two labels of one kind, the target's rows are the ones the kind keeps.
let labels = super::graph::capture_label_rows(d, &changed);
let all: Vec<NodeId> = from.iter().chain(&targets).chain(&group).copied().collect();
Ok(count_changed(d, &before, &all) + usize::from(kept || painted || object))
Ok(count_changed(d, &before, &all) + usize::from(kept || painted || object || labels))
})?;
s.active_appearance_item = None;
Ok(json!({ "source": source.0, "target": target.0 }))
@@ -941,7 +1010,8 @@ fn reduce_basic(s: &mut Session, p: &Value) -> Result<Value> {
}
let kept = super::graph::capture_series_opacity(d, &both_kinds(&ids));
let painted = super::graph::capture_series_top_paints(d, &top_paint_changed(&before, d, &ids));
Ok(count_changed(d, &before, &ids) + usize::from(kept || painted))
let labels = super::graph::capture_label_rows(d, &ids);
Ok(count_changed(d, &before, &ids) + usize::from(kept || painted || labels))
})?;
s.active_appearance_item = None;
ok()
@@ -1016,7 +1086,8 @@ fn set_item(s: &mut Session, p: &Value) -> Result<Value> {
let mut rows = top_paint_changed(&before, d, &ids);
rows.extend(painted_rows(d, written));
let painted = super::graph::capture_series_top_paints(d, &rows);
Ok(count_changed(d, &before, &ids) + usize::from(kept || painted))
let labels = super::graph::capture_label_rows(d, &ids);
Ok(count_changed(d, &before, &ids) + usize::from(kept || painted || labels))
})?;
if let Some(pa) = &paint {
s.remember_paint(pa);
@@ -1062,7 +1133,8 @@ fn remove_item(s: &mut Session, p: &Value) -> Result<Value> {
}
let kept = super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &ids));
let painted = super::graph::capture_series_top_paints(d, &top_paint_changed(&before, d, &ids));
Ok(count_changed(d, &before, &ids) + usize::from(kept || painted))
let labels = super::graph::capture_label_rows(d, &ids);
Ok(count_changed(d, &before, &ids) + usize::from(kept || painted || labels))
})?;
// The rows removed from the active row's object, which need not come first in `ids`.
let active = s.active_appearance_item.as_ref().and_then(|a| a.objects.first().copied());
@@ -1146,6 +1218,7 @@ fn duplicate_item(s: &mut Session, p: &Value) -> Result<Value> {
let mut rows = top_paint_changed(&before, d, &ids);
rows.extend(painted_rows(d, added));
super::graph::capture_series_top_paints(d, &rows);
super::graph::capture_label_rows(d, &ids);
Ok(())
})?;
// Rows at or above each copy move up by one (by the block, for copies placed at `to`).
@@ -1185,6 +1258,7 @@ fn show_all_hidden(s: &mut Session, p: &Value) -> Result<Value> {
}
super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &shown));
super::graph::capture_series_top_paints(d, &top_paint_changed(&before, d, &shown));
super::graph::capture_label_rows(d, &shown);
Ok(shown)
})?;
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
@@ -1197,7 +1271,7 @@ fn move_item(s: &mut Session, p: &Value) -> Result<Value> {
let aligned = aligned(p, C)?;
if p.get("from").and_then(Value::as_str) == Some("contents") {
// A graph series group's own Contents row, not its paths, which have none.
let ids = appearance_targets(s, p)?;
let ids = edit_targets(s, p)?;
if ids.is_empty() {
return Err(bad(C, "select objects or give ids"));
}
@@ -1230,7 +1304,8 @@ fn move_item(s: &mut Session, p: &Value) -> Result<Value> {
}
let kept = super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &ids));
let painted = super::graph::capture_series_top_paints(d, &top_paint_changed(&before, d, &ids));
Ok(count_changed(d, &before, &ids) + usize::from(kept || painted))
let labels = super::graph::capture_label_rows(d, &ids);
Ok(count_changed(d, &before, &ids) + usize::from(kept || painted || labels))
})?;
// The moved row stays the active one; the rows it passed shift by one.
s.remap_appearance_item(&ids, |a| {
@@ -1275,7 +1350,8 @@ fn move_items(s: &mut Session, p: &Value, ids: &[NodeId], to: usize) -> Result<V
}
let kept = super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, ids));
let painted = super::graph::capture_series_top_paints(d, &top_paint_changed(&before, d, ids));
Ok(count_changed(d, &before, ids) + usize::from(kept || painted))
let labels = super::graph::capture_label_rows(d, ids);
Ok(count_changed(d, &before, ids) + usize::from(kept || painted || labels))
})?;
let m = idx.len();
let landed = first_landed.unwrap_or(to);
@@ -1307,7 +1383,9 @@ fn move_contents(s: &mut Session, ids: &[NodeId], to: usize) -> Result<Value> {
slot.get_or_insert(n.appearance.contents_at());
}
slot.ok_or_else(|| bad("appearance.moveItem", "only groups, layers, type and images have a Contents (Characters) row"))?;
Ok(count_changed(d, &before, ids))
// A graph label's kind keeps where its rows paint relative to the characters.
let labels = super::graph::capture_label_rows(d, ids);
Ok(count_changed(d, &before, ids) + usize::from(labels))
})?;
Ok(json!({ "contents": slot }))
}
@@ -1395,6 +1473,7 @@ fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
(Some(clicked), leaf_targets(s, &selected)?)
};
targets.retain(|id| Some(*id) != source);
let mut targets = without_design_art(&s.doc()?.doc, targets, C)?;
let node = match source {
Some(id) => Arc::new(s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?.clone()),
None => s.eyedropper_sample.clone().ok_or_else(|| bad(C, "select the object to copy from"))?,
@@ -1462,9 +1541,46 @@ fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
if (targets.is_empty() && ranges.is_empty()) || attrs.is_empty() {
return Ok(finish(s, &[]));
}
// What a graph keeps for the kind of a label whose whole text took the pick: the attributes the pick gave its
// characters, with a picked gradient fitted to each label, and the picked paragraph attributes.
let source_rows = picked.placed(None);
let kept_text = |id: NodeId, st: &mut CharStyle| {
Picked::run(st, &source_rows, &attrs, characters.get(&id));
if attrs.appearance.fill.color {
st.fill = super::gradient::unplaced(&st.fill);
}
if attrs.appearance.stroke.color {
st.stroke = super::gradient::unplaced(&st.stroke);
}
};
let kept_para = picked.text.as_ref().filter(|_| attrs.paragraph).map(|(_, para)| para);
// Only a pick that applies character attributes or the paints, overprint and stroke options characters take changes
// what a kind keeps for its characters.
let (f, k) = (attrs.appearance.fill, attrs.appearance.stroke);
let to_characters = (attrs.character && picked.text.is_some())
|| f.color
|| f.overprint
|| k.color
|| k.overprint
|| k.weight
|| k.cap
|| k.join
|| k.miter
|| k.dash;
let kept_text: Option<super::graph::LabelTextChange> = to_characters.then_some(&kept_text);
if !ranges.is_empty() {
let ids: Vec<NodeId> = ranges.iter().map(|(f, _)| *f).collect();
let before = s.doc()?.doc.clone();
// The labels whose whole text is highlighted.
let whole: Vec<NodeId> = ranges
.iter()
.filter(|(id, range)| match (range, before.node(*id).map(|n| &n.kind)) {
(None, _) => true,
(Some((start, end)), Some(NodeKind::Text(t))) => *start == 0 && *end >= vectorcraft_text::edit::runs_len(&t.runs),
_ => false,
})
.map(|(id, _)| *id)
.collect();
super::overprint::edit_counted(s, "Eyedropper", |d| {
for &(id, range) in &ranges {
let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) };
@@ -1477,7 +1593,8 @@ fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
super::typecmd::refresh_bounds(t);
}
check_text_bounds(d)?;
Ok(count_changed(d, &before, &ids))
let labels = super::graph::capture_label_text(d, &whole, kept_text, kept_para)?;
Ok(count_changed(d, &before, &ids) + usize::from(labels))
})?;
return Ok(finish(s, &ids));
}
@@ -1532,7 +1649,17 @@ fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
// A pick of the object's transparency writes it on every target.
let objects = if attrs.appearance.transparency { targets.clone() } else { object_opacity_changed(&before, d, &targets) };
let object = super::graph::capture_series_object_opacity(d, &objects);
Ok(count_changed(d, &before, &targets) + usize::from(kept || painted || object))
// Graph labels keep their new rows, characters and paragraphs per kind of label.
// On type, only `append` adds rows and a transparency pick changes the object's opacity and blend mode; the other
// attributes go to the characters.
let rows = if append {
super::graph::capture_label_rows(d, &targets)
} else {
attrs.appearance.transparency && super::graph::capture_label_object_opacity(d, &targets)
};
// `append` adds rows and leaves the characters as they were.
let text = !append && super::graph::capture_label_text(d, &targets, kept_text, kept_para)?;
Ok(count_changed(d, &before, &targets) + usize::from(kept || painted || object || rows || text))
})?;
Ok(finish(s, &targets))
}
@@ -1565,6 +1692,7 @@ fn copied_character_styles<'a>(source: &Node, targets: impl Iterator<Item = &'a
local.baseline_shift /= scale.points;
local.tcy_up_down /= scale.points;
local.tcy_left_right /= scale.points;
local.warichu_gap /= scale.points;
local.h_scale /= scale.horizontal;
// As with dimensional type commands, reject overflow and underflow before layout.
if !copied_dimensions_fit(&effective, &local) {
@@ -1583,6 +1711,7 @@ fn copied_dimensions_fit(from: &CharStyle, to: &CharStyle) -> bool {
(from.baseline_shift, to.baseline_shift),
(from.tcy_up_down, to.tcy_up_down),
(from.tcy_left_right, to.tcy_left_right),
(from.warichu_gap, to.warichu_gap),
(from.h_scale, to.h_scale),
]
.iter()
+5
View File
@@ -666,6 +666,11 @@ fn map_graph_colors(g: &mut vectorcraft_doc::GraphSpec, f: &mut dyn FnMut(&Color
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() {
for paint in [&mut r.paint.fill, &mut r.paint.stroke].into_iter().flatten() {
map_paint_colors(paint, f);
}
}
}
/// `f` applied to solid, gradient-stop and freeform-control colors, with any swatch link.
@@ -536,6 +536,80 @@ fn a_document_replaced_in_the_back_is_measured_when_the_front_one_closes() {
assert_eq!(actual.cached_bounds, Some(expected));
}
/// The type of document `index` with id `id`, wherever its tab is.
fn text_in(s: &Session, index: usize, id: u64) -> vectorcraft_doc::TextObject {
match &s.documents()[index].doc.node(NodeId(id)).unwrap().kind {
NodeKind::Text(t) => (**t).clone(),
_ => panic!("not text"),
}
}
/// A document replaced while in the back has its type measured in its own composite fonts at once,
/// while the front document stays in front with its own definition registered; coming to the front
/// does not measure that type again.
#[test]
fn a_document_replaced_in_the_back_is_measured_before_it_comes_to_the_front() {
let (bytes, id, width) = full_roman_file("CF Pane");
let mut s = session();
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Pane")]})).unwrap();
assert!(s.replace_document(0, vectorcraft_format::load(&bytes).unwrap()));
assert_eq!(s.active_index(), Some(1));
let front = vectorcraft_text::FontDb::global().composite_font("CF Pane").unwrap();
assert_eq!(front.parts.get(vectorcraft_doc::CharClass::Roman).size, 50.0);
let measured = text_in(&s, 0, id);
assert!((measured.cached_bounds.unwrap().width() - width).abs() < 1e-6);
let node = s.documents()[0].doc.node(NodeId(id)).unwrap().geometric_bounds().unwrap();
assert!((node.width() - width).abs() < 1e-6);
let doc = std::sync::Arc::as_ptr(&s.documents()[0].doc);
assert!(s.set_active(0));
assert!(std::ptr::eq(doc, std::sync::Arc::as_ptr(&s.documents()[0].doc)));
assert_eq!(text(&s, id).cached_bounds, measured.cached_bounds);
}
/// 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.
#[test]
fn a_document_replaced_in_the_back_measures_a_font_it_lacks_again_in_front() {
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);
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
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.set_active(0));
assert!(vectorcraft_text::FontDb::global().composite_font("CF Lacked").is_none());
let actual = text(&s, id);
let expected = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &actual).bounds;
assert_eq!(actual.cached_bounds, Some(expected));
}
/// Text holding an inline graphic whose symbol's art is type in a composite font, in a document
/// replaced in the back, is measured with the graphic at the size the document's own definition
/// gives it before it comes to the front.
#[test]
fn a_document_replaced_in_the_back_measures_inline_graphics_in_its_own_definition() {
let mut s = session();
let mut full = font("CF Back Inline");
full["parts"]["roman"]["size"] = json!(100);
s.execute("type.compositeFonts.set", &json!({"fonts": [full]})).unwrap();
let art =
s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab12", "size": 20, "font": "CF Back Inline"})).unwrap()["id"].as_u64().unwrap();
s.execute("symbol.new", &json!({"name": "CF Back Inline Mark", "ids": [art]})).unwrap();
let id = s.execute("text.create", &json!({"x": 10, "y": 150, "text": "x", "size": 20})).unwrap()["id"].as_u64().unwrap();
s.execute("text.insertInline", &json!({"id": id, "at": 0, "symbol": "CF Back Inline Mark"})).unwrap();
let width = text(&s, id).cached_bounds.unwrap().width();
let bytes = vectorcraft_format::save_file(&s.doc().unwrap().doc);
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Back Inline")]})).unwrap();
assert!(s.replace_document(0, vectorcraft_format::load(&bytes).unwrap()));
assert!((text_in(&s, 0, id).cached_bounds.unwrap().width() - width).abs() < 1e-6);
}
/// A failed batch takes back the composite fonts it set, in the font database too.
#[test]
fn a_failed_batch_takes_back_its_composite_fonts() {
+360 -55
View File
@@ -18,7 +18,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Document Info",
["Window", "Document Info"],
None,
"{selectionOnly?, category?: document|objects|graphicStyles|brushes|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages|repeats (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts (the type in opacity masks and in the art of symbols and patterns included; with selectionOnly: the selected type's), images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), brushNames (the brushes the art's drawn strokes use, each once; with selectionOnly: those the selected objects' strokes use), brushes: [{name, type: calligraphic|scatter|art|pattern|bristle|null}] (those brushes with their types; null: not in the Brushes panel), panelBrushNames (the Brushes panel's brushes), repeatObjects: [{id, name, type: radial|grid|mirror, instances, rows?, cols?}] (the Repeat objects, those in groups and other repeats included; with selectionOnly: those in the selection), spotObjects: [{id, name, spotColors}] (the objects filled or stroked with a spot color swatch, as a solid color or in the stops of a gradient with a stop above opacity 0, at a tint above 0, and grayscale images colorized with one, each once, by the rules patternObjects follows, with the spot colors they use; Registration and process colors are not spot colors; objects.spots counts them), spotColorNames and spotColors (the spot colors those objects use), patternObjects: [{id, name, patterns}] (the objects filled or stroked with a pattern, each once, those in groups and type whose drawn characters use one included, hidden fills and strokes, fills and strokes at an opacity of 0, strokes with no width, guides, compound path members' own fills and strokes, images' fills and strokes without a shape and characters that draw nothing left out, with the patterns they use, which objects.patterns counts; with selectionOnly: those in the selection), patternNames (the patterns those objects use), gradientObjects: [{id, name, gradients}] (the objects filled or stroked with a gradient, by the same rules, with the gradients they use: a linked gradient swatch's name, else Linear gradient, Radial gradient or Freeform gradient; objects.gradients counts them), gradientNames (the gradients those objects use), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its license lets it be embedded)}. An image linked to a file outside the automation read roots has link null, and its Linked Images row names the file alone. brushNames, brushes, spotObjects, patternObjects and gradientObjects leave out hidden objects, objects at an opacity of 0 and the objects inside a hidden group or layer or one at an opacity of 0 (with selectionOnly too), their characters' paints and colorized images included; objects still counts them as paths, groups and the other kinds",
"{selectionOnly?, category?: document|objects|graphicStyles|brushes|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages|repeats (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document: {…, units (English), unitId: the units' Preferences › Units id (points, millimeters, feetInches, …), rasterEffectsPpi}, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts (the type in opacity masks and in the art of symbols and patterns included; with selectionOnly: the selected type's), fontDetails: [{font, match: exact|style|missingWithFallback|missing, family, style, file, embedding: allowed|editing|previewPrint|forbidden|null}] (each font of fonts, in order: how it was found, the face shown (null when none), that face's file name (null for a built-in face) and what its license allows), images, imageObjects: [{name, width, height, linked, file}] (the image objects in the order Linked Images and Embedded Images list them; file: what a linked image's row shows, null for an embedded one), swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), brushNames (the brushes the art's drawn strokes use, each once; with selectionOnly: those the selected objects' strokes use), brushes: [{name, type: calligraphic|scatter|art|pattern|bristle|null}] (those brushes with their types; null: not in the Brushes panel), panelBrushNames (the Brushes panel's brushes), repeatObjects: [{id, name, type: radial|grid|mirror, instances, rows?, cols?}] (the Repeat objects, those in groups and other repeats included; with selectionOnly: those in the selection), spotObjects: [{id, name, spotColors}] (the objects filled or stroked with a spot color swatch, as a solid color or in the stops of a gradient that color a part of it above opacity 0 (a stop at opacity 0 counts only where it mixes with a stop above opacity 0 over a stretch of some width), at a tint above 0, and grayscale images colorized with one, each once, by the rules patternObjects follows, with the spot colors they use; Registration and process colors are not spot colors; objects.spots counts them), spotColorNames and spotColors (the spot colors those objects use), patternObjects: [{id, name, patterns}] (the objects filled or stroked with a pattern, each once, those in groups and type whose drawn characters use one included, hidden fills and strokes, fills and strokes at an opacity of 0, strokes with no width, guides, compound path members' own fills and strokes, images' fills and strokes without a shape and characters that draw nothing left out, with the patterns they use, which objects.patterns counts; with selectionOnly: those in the selection), patternNames (the patterns those objects use), gradientObjects: [{id, name, gradients, kinds}] (the objects filled or stroked with a gradient, by the same rules, with the gradients they use: a linked gradient swatch's name, else its kind's name, such as Linear gradient; kinds gives each one's kind, linear|radial|conic|diamond|freeform, when it is named by its kind, else null; objects.gradients counts them), gradientNames (the gradients those objects use), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its license lets it be embedded)}. An image linked to a file outside the automation read roots has link null, and its Linked Images row names the file alone. brushNames, brushes, spotObjects, patternObjects and gradientObjects leave out hidden objects, objects at an opacity of 0 and the objects inside a hidden group or layer or one at an opacity of 0 (with selectionOnly too), their characters' paints and colorized images included; objects still counts them as paths, groups and the other kinds",
has_doc,
info
),
@@ -85,17 +85,65 @@ pub struct Section {
/// What a font's `fsType` lets a copy of it do, read as exports read it ([`Embedding`]): the
/// most permissive usage bit wins, and bitmap-only fonts may not be embedded.
pub fn embedding_label(fs_type: u16) -> &'static str {
if Embedding::from_fs_type(fs_type) == Embedding::Forbidden {
"embedding not allowed"
} else if fs_type & 0x0008 != 0 {
"embedding for editing"
} else if fs_type & 0x0004 != 0 {
"embedding for preview and print"
} else {
"embedding allowed"
match embedding_id(fs_type) {
"forbidden" => "embedding not allowed",
"editing" => "embedding for editing",
"previewPrint" => "embedding for preview and print",
_ => "embedding allowed",
}
}
/// [`embedding_label`] as the `embedding` value of a `fontDetails` entry: `forbidden`, `editing`,
/// `previewPrint` or `allowed`.
pub fn embedding_id(fs_type: u16) -> &'static str {
if Embedding::from_fs_type(fs_type) == Embedding::Forbidden {
"forbidden"
} else if fs_type & 0x0008 != 0 {
"editing"
} else if fs_type & 0x0004 != 0 {
"previewPrint"
} else {
"allowed"
}
}
/// A font as Font Details lists it: its `fontDetails` entry and its row. The row gives the file
/// the font is drawn from (`built in` for a face with no file) and whether its license lets it be
/// embedded; for a style the family lacks, the face shown in its place; for a font that is not
/// installed, the face shown instead, or `missing`. The entry holds the same facts as values
/// (`match`: `exact`, `style`, `missingWithFallback` or `missing`), so the panel can show them in
/// the interface language.
fn font_detail(used: &super::fonts::UsedFont) -> (Value, (String, String)) {
let db = vectorcraft_text::FontDb::global();
let (family, style, version) = used;
let label = super::fonts::font_label(used);
// Found by any of its names (in the version the type names); a style the family lacks shows in
// another.
let resolved = db.resolve(family, style).map(|(f, m)| (db.face_version(family, style, version.as_deref()).unwrap_or(f), m));
let (entry, detail) = match resolved {
Some((f, m)) if m != vectorcraft_text::FontMatch::Missing => {
let file = f.path().and_then(|p| p.file_name()).map(|n| n.to_string_lossy().into_owned());
let found = format!("{}; {}", file.as_deref().unwrap_or("built in"), embedding_label(f.fs_type()));
let substituted = m == vectorcraft_text::FontMatch::Style;
let entry = json!({
"font": label,
"match": if substituted { "style" } else { "exact" },
"family": f.family,
"style": f.style,
"file": file,
"embedding": embedding_id(f.fs_type()),
});
(entry, if substituted { format!("substituted: shown in {} {} ({found})", f.family, f.style) } else { found })
}
Some((f, _)) => (
json!({ "font": label, "match": "missingWithFallback", "family": f.family, "style": f.style, "file": null, "embedding": null }),
format!("missing: shown in {} {}", f.family, f.style),
),
None => (json!({ "font": label, "match": "missing", "family": null, "style": null, "file": null, "embedding": null }), "missing".into()),
};
(entry, (label, detail))
}
/// An image object as Document Info lists it: (name, width, height, linked file).
type ImageRow = (String, u32, u32, Option<String>);
@@ -131,14 +179,15 @@ fn repeat_entry(d: &Document, n: &Node, spec: &RepeatSpec) -> (Value, (String, S
}
/// The Document Info categories of `d` (only `category` when given) from `info` (what
/// `document.info` reports), its fonts, its image objects, the rows of the brushes its art uses, the
/// rows of its Repeat objects and the rows of its objects filled or stroked with a spot color, with a
/// pattern and with a gradient.
/// `document.info` reports), its fonts and their Font Details rows, its image objects, the rows of
/// the brushes its art uses, the rows of its Repeat objects and the rows of its objects filled or
/// stroked with a spot color, with a pattern and with a gradient.
#[allow(clippy::too_many_arguments)]
fn sections(
d: &Document,
info: &Value,
fonts: &BTreeSet<super::fonts::UsedFont>,
font_details: &[(String, String)],
images: &[ImageRow],
brushes: &[(String, String)],
repeats: &[(String, String)],
@@ -192,34 +241,7 @@ fn sections(
"gradients" => gradient_objects.to_vec(),
"symbols" => names("symbols"),
"fonts" => fonts.iter().map(|f| (super::fonts::font_label(f), String::new())).collect(),
"fontDetails" => {
let db = vectorcraft_text::FontDb::global();
fonts
.iter()
.map(|used| {
let (family, style, version) = used;
// Found by any of its names (in the version the type names); a style the
// family lacks shows in another.
let resolved =
db.resolve(family, style).map(|(f, m)| (db.face_version(family, style, version.as_deref()).unwrap_or(f), m));
let detail = match resolved {
Some((f, m)) if m != vectorcraft_text::FontMatch::Missing => {
let file =
f.path().and_then(|p| p.file_name()).map_or_else(|| "built in".into(), |n| n.to_string_lossy().into_owned());
let found = format!("{file}; {}", embedding_label(f.fs_type()));
if m == vectorcraft_text::FontMatch::Style {
format!("substituted: shown in {} {} ({found})", f.family, f.style)
} else {
found
}
}
Some((f, _)) => format!("missing: shown in {} {}", f.family, f.style),
None => "missing".into(),
};
(super::fonts::font_label(used), detail)
})
.collect()
}
"fontDetails" => font_details.to_vec(),
"linkedImages" => {
images.iter().filter_map(|(name, w, h, link)| Some((name.clone(), format!("{w} × {h} px — {}", link.as_ref()?)))).collect()
}
@@ -277,6 +299,39 @@ fn gradient_label(p: &Paint) -> Option<String> {
}
}
/// Where [`gradient_label`]'s name for gradient paint `p` comes from: `swatch:<name>` for a
/// gradient linked to a swatch, else `kind:<kind>` (`linear`, `radial`, `conic`, `diamond` or
/// `freeform`). The two prefixes keep a swatch's name apart from a kind's name it reads like.
fn gradient_source(p: &Paint) -> Option<String> {
match p {
Paint::Gradient(g) => Some(match g.swatch.as_deref().filter(|s| !s.is_empty()) {
Some(swatch) => format!("swatch:{swatch}"),
None => format!("kind:{}", g.gradient.kind.label().to_ascii_lowercase()),
}),
_ => None,
}
}
/// For each of `names` (an object's [`gradient_label`]s), the kind it names when only a kind
/// gives that name: a name one of `sources` (the object's [`gradient_source`]s) gives as a kind's
/// and none gives as a swatch's, else null. A swatch named like a kind keeps its name even where
/// a gradient of that kind is listed under the same name.
fn gradient_kinds(names: &[String], sources: &[String]) -> Vec<Value> {
names
.iter()
.map(|name| {
if sources.iter().any(|s| s.strip_prefix("swatch:") == Some(name.as_str())) {
return Value::Null;
}
sources
.iter()
.filter_map(|s| s.strip_prefix("kind:"))
.find(|k| vectorcraft_color::GradientKind::parse(k).is_some_and(|kind| *name == format!("{} gradient", kind.label())))
.map_or(Value::Null, |k| json!(k))
})
.collect()
}
/// The names of `d`'s spot color swatches: the swatches whose prints go to a plate of their own
/// ([`vectorcraft_doc::inks::inks`]). A name held by several swatches is the first one's, as
/// [`Document::swatch`] finds it, and the Registration swatch prints on every plate, so it is not
@@ -289,10 +344,46 @@ fn spot_swatches(d: &Document) -> HashSet<&str> {
first.into_iter().filter(|&(name, spot)| spot && name != vectorcraft_color::swatch::REGISTRATION).map(|(name, _)| name).collect()
}
/// Which of gradient `g`'s stops color some part of it that draws, by the stretches
/// [`vectorcraft_color::Gradient::sample`] mixes in, for stops in any order: the pair of stop `k`
/// and stop `k + 1` covers the offsets above every offset of stops `0..=k` up to stop `k + 1`'s,
/// and both its stops count when that stretch has positive width and either stop is above opacity
/// 0. The first stop also counts when it is above opacity 0, since it colors the offsets up to its
/// own, and so does the last stop, since it colors the offsets above every stop's. Any other stop,
/// opaque or not, counts only through a pair: a stop out of order whose pairs have no width
/// (another stop before it reaches past both ends) colors nothing. A freeform gradient draws from
/// its points, so each of its stops counts while any stop is above opacity 0.
fn drawn_stops(g: &vectorcraft_color::Gradient) -> Vec<bool> {
let stops = &g.stops;
if g.kind == vectorcraft_color::GradientKind::Freeform {
let any = stops.iter().any(|s| s.opacity > 0.0);
return vec![any; stops.len()];
}
let mut drawn = vec![false; stops.len()];
if let (Some(first), Some(d)) = (stops.first(), drawn.first_mut()) {
*d = first.opacity > 0.0;
}
if let (Some(last), Some(d)) = (stops.last(), drawn.last_mut()) {
*d |= last.opacity > 0.0;
}
// `f32::max` skips a NaN offset, which the sampler never picks either.
let mut reach = f32::NEG_INFINITY;
for k in 0..stops.len().saturating_sub(1) {
reach = reach.max(stops[k].offset);
let (a, b) = (&stops[k], &stops[k + 1]);
if b.offset > reach && (a.opacity > 0.0 || b.opacity > 0.0) {
drawn[k] = true;
drawn[k + 1] = true;
}
}
drawn
}
/// The spot colors paint `p` prints (`spots`: [`spot_swatches`]): the spot swatch a solid color is
/// linked to, or those a gradient's stops are linked to, each once, in the order of the stops. A
/// link at a tint of 0 prints no ink, a gradient whose stops are all at an opacity of 0 draws
/// nothing, and a color that only looks like a spot color is not linked to one.
/// linked to, or those a gradient's drawn stops ([`drawn_stops`]) are linked to, each once, in the
/// order of the stops. A link at a tint of 0 prints no ink, a stop that colors only parts of a
/// gradient at an opacity of 0 prints none, and a color that only looks like a spot color is not
/// linked to one.
fn spot_labels(spots: &HashSet<&str>, p: &Paint) -> Vec<String> {
let mut out: Vec<String> = vec![];
let mut add = |swatch: &Option<String>, tint: f32| {
@@ -306,7 +397,10 @@ fn spot_labels(spots: &HashSet<&str>, p: &Paint) -> Vec<String> {
};
match p {
Paint::Solid { swatch, tint, .. } => add(swatch, *tint),
Paint::Gradient(g) if g.gradient.stops.iter().any(|s| s.opacity > 0.0) => g.gradient.stops.iter().for_each(|s| add(&s.swatch, s.tint)),
Paint::Gradient(g) => {
let drawn = drawn_stops(&g.gradient);
g.gradient.stops.iter().zip(drawn).filter(|&(_, d)| d).for_each(|(s, _)| add(&s.swatch, s.tint));
}
_ => {}
}
out
@@ -447,9 +541,11 @@ fn node_and_drawn<'a>(d: &'a Document, path: &[usize]) -> Option<(&'a Node, bool
/// The spot colors, the patterns and the gradients (`spots`: [`spot_swatches`]) that object `n`
/// paints, each once: those of its own fills and strokes ([`own_paints`], `members` included), of
/// its drawn characters and, for a grayscale or 1-bit image, of the color it is colorized with.
fn object_paints(d: &Document, spots: &HashSet<&str>, members: &HashSet<*const Node>, n: &Node) -> [Vec<String>; 3] {
/// The fourth list gives where each gradient comes from: a swatch or a kind ([`gradient_source`]).
fn object_paints(d: &Document, spots: &HashSet<&str>, members: &HashSet<*const Node>, n: &Node) -> [Vec<String>; 4] {
let (spot, pattern, gradient) = (|p: &Paint| spot_labels(spots, p), one_label(pattern_label), one_label(gradient_label));
let labels: [&PaintNames<'_>; 3] = [&spot, &pattern, &gradient];
let gradient_from = one_label(gradient_source);
let labels: [&PaintNames<'_>; 4] = [&spot, &pattern, &gradient, &gradient_from];
let painted_runs = painted_runs(d, spots, n);
let mut used = labels.map(|label| own_paints(d, n, members, painted_runs.as_ref(), label));
// The characters' own paints: an inline graphic draws no outline and does not take its run's
@@ -615,8 +711,10 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(k) = k {
*counts.entry(k).or_default() += 1;
}
// The spot colors, the patterns and the gradients this object paints, each once; none when it does not draw.
let [used_spots, used_patterns, used_gradients] = if drawn { object_paints(d, &spot_swatches, &members, n) } else { Default::default() };
// The spot colors, the patterns and the gradients this object paints, each once, and where each gradient comes from;
// none when it does not draw.
let [used_spots, used_patterns, used_gradients, used_sources] =
if drawn { object_paints(d, &spot_swatches, &members, n) } else { Default::default() };
// Objects counts the objects that Spot Color Objects, Pattern Objects and Gradient Objects list.
if !used_spots.is_empty() {
let name = n.display_name();
@@ -636,7 +734,8 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
let name = n.display_name();
gradients.extend(used_gradients.iter().cloned());
gradient_rows.push((name.clone(), used_gradients.join(", ")));
gradient_objects.push(json!({ "id": n.id.0, "name": name, "gradients": used_gradients }));
let kinds = gradient_kinds(&used_gradients, &used_sources);
gradient_objects.push(json!({ "id": n.id.0, "name": name, "gradients": used_gradients, "kinds": kinds }));
*counts.entry("gradients").or_default() += 1;
}
if n.extras().mask.is_some() {
@@ -671,6 +770,14 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
fonts.extend(super::fonts::art_texts_of(d).into_iter().flat_map(|(_, t, _)| t.runs.iter().map(|r| super::fonts::font_of(&r.style))));
}
let font_names: Vec<String> = fonts.iter().map(super::fonts::font_label).collect();
let (font_entries, font_rows): (Vec<Value>, Vec<(String, String)>) = fonts.iter().map(font_detail).unzip();
// The image objects in walk order, as Linked Images and Embedded Images list them: `file` is a
// linked image's row value (its path, or its file name alone when the request may not read it),
// and null for an embedded one.
let image_objects: Vec<Value> = image_rows
.iter()
.map(|(name, w, h, file)| json!({ "name": name, "width": w, "height": h, "linked": file.is_some(), "file": file }))
.collect();
// Each brush the art uses with its type, found by name in the Brushes panel as the renderer finds it (the first of two
// brushes with one name); a brush the panel does not hold draws as a plain stroke and has no type. A row names the type
// as the New Brush dialog does (`Art Brush`), and the panel translates these values when it shows them.
@@ -688,7 +795,9 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
"objects": counts,
"pathLength": path_length,
"fonts": font_names,
"fontDetails": font_entries,
"images": images,
"imageObjects": image_objects,
"symbols": symbols,
"spotColors": spot,
"spotColorNames": spot,
@@ -710,7 +819,7 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
});
// The document's name: its file's, else its title.
out["document"]["name"] = json!(st.title());
let sections = sections(d, &out, &fonts, &image_rows, &brush_rows, &repeat_rows, &spot_rows, &pattern_rows, &gradient_rows, category);
let sections = sections(d, &out, &fonts, &font_rows, &image_rows, &brush_rows, &repeat_rows, &spot_rows, &pattern_rows, &gradient_rows, category);
if text {
return Ok(json!({ "text": report_text(&st.title(), selection_only, &sections) }));
}
@@ -724,6 +833,7 @@ fn document_summary(d: &Document) -> Value {
"title": d.title,
"colorMode": match d.color_mode { ColorMode::Rgb => "RGB", ColorMode::Cmyk => "CMYK" },
"units": d.units.label(),
"unitId": d.units.key(),
"artboards": d.artboards.iter().map(|a| json!({ "name": a.name, "width": a.rect.width() / u, "height": a.rect.height() / u })).collect::<Vec<_>>(),
"rasterEffectsPpi": d.raster_effects_ppi,
})
@@ -1250,11 +1360,11 @@ mod tests {
assert_eq!(
i["gradientObjects"],
json!([
{"id": a, "name": "Both", "gradients": ["Ramp", "Linear gradient"]},
{"id": b, "name": "<Rectangle>", "gradients": ["Radial gradient"]},
{"id": f, "name": "<Rectangle>", "gradients": ["Freeform gradient"]},
{"id": t, "name": "Type", "gradients": ["Ramp"]},
{"id": bar, "name": "Bar", "gradients": ["Ramp"]},
{"id": a, "name": "Both", "gradients": ["Ramp", "Linear gradient"], "kinds": [null, "linear"]},
{"id": b, "name": "<Rectangle>", "gradients": ["Radial gradient"], "kinds": ["radial"]},
{"id": f, "name": "<Rectangle>", "gradients": ["Freeform gradient"], "kinds": ["freeform"]},
{"id": t, "name": "Type", "gradients": ["Ramp"], "kinds": [null]},
{"id": bar, "name": "Bar", "gradients": ["Ramp"], "kinds": [null]},
])
);
assert_eq!(i["gradientNames"], json!(["Freeform gradient", "Linear gradient", "Radial gradient", "Ramp"]));
@@ -1493,6 +1603,95 @@ mod tests {
assert_eq!(spots(&mut s), (vec![], None, None));
}
/// Spot Color Objects leaves out a spot color linked only from gradient stops at an opacity of 0
/// whose stretches all lead to other stops at an opacity of 0 or have no width, while another
/// part of the gradient draws: an inner stop, an end stop and a stop at the offset of an opaque
/// stop. A clear stop next to an opaque stop tints the stretch between them, so it stays listed.
/// Type whose characters draw such a gradient follows the same rule, with and without Selection
/// Only, and the report writes the same rows.
#[test]
fn spot_color_objects_skip_spot_stops_only_in_clear_stretches() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
let ink = |c: f64| json!({"model": "cmyk", "c": c, "m": 0.5, "y": 0.0, "k": 0.0});
for (k, name) in ["Ink", "Near", "Far", "Edge", "Seam"].into_iter().enumerate() {
s.execute("swatch.new", &json!({"name": name, "color": ink(0.2 * k as f64), "spot": true})).unwrap();
}
let stop = |offset: f64, swatch: Option<&str>, opacity: f64| match swatch {
Some(sw) => json!({"offset": offset, "color": "#000", "swatch": sw, "opacity": opacity}),
None => json!({"offset": offset, "color": "#fff", "opacity": opacity}),
};
let fill = |s: &mut Session, id: &Value, stops: Value| {
s.execute("paint.setFill", &json!({"ids": [id], "gradient": {"kind": "linear", "stops": stops}})).unwrap();
s.execute("paint.setStroke", &json!({"ids": [id], "none": true})).unwrap();
};
let square =
|s: &mut Session, x: f64| s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone();
// Near borders the opaque Ink stop; Far sits between clear stops; Edge ends the ramp after a
// clear stop.
let ramp = square(&mut s, 0.0);
fill(
&mut s,
&ramp,
json!([stop(0.0, Some("Ink"), 1.0), stop(0.4, Some("Near"), 0.0), stop(0.6, Some("Far"), 0.0), stop(1.0, Some("Edge"), 0.0)]),
);
s.execute("layer.setProps", &json!({"id": ramp, "name": "Ramp"})).unwrap();
// Seam meets the opaque half only at one offset, where nothing between them has width.
let seam = square(&mut s, 20.0);
fill(&mut s, &seam, json!([stop(0.0, None, 0.0), stop(0.5, Some("Seam"), 0.0), stop(0.5, None, 1.0), stop(1.0, None, 1.0)]));
let t = s.execute("text.create", &json!({"x": 0, "y": 200, "text": "Hi"})).unwrap()["id"].clone();
fill(&mut s, &t, json!([stop(0.0, Some("Ink"), 1.0), stop(0.5, None, 0.0), stop(1.0, Some("Far"), 0.0)]));
s.execute("layer.setProps", &json!({"id": t, "name": "Type"})).unwrap();
let check = |s: &mut Session, params: Value| {
let i = s.execute("document.info", &params).unwrap();
let listed: Vec<(String, Value)> =
i["spotObjects"].as_array().unwrap().iter().map(|o| (o["name"].as_str().unwrap().to_string(), o["spotColors"].clone())).collect();
assert_eq!(listed, vec![("Ramp".to_string(), json!(["Ink", "Near"])), ("Type".to_string(), json!(["Ink"]))], "{params}");
assert_eq!((i["spotColorNames"].clone(), i["objects"]["spots"].as_u64()), (json!(["Ink", "Near"]), Some(2)), "{params}");
let rows = i["sections"].as_array().unwrap().iter().find(|c| c["id"] == "spotColors").unwrap()["rows"].clone();
assert_eq!(rows, json!([["Ramp", "Ink, Near"], ["Type", "Ink"]]), "{params}");
};
check(&mut s, json!({}));
s.execute("select.set", &json!({"ids": [ramp, seam, t]})).unwrap();
check(&mut s, json!({"selectionOnly": true}));
let text = s.execute("document.info", &json!({"category": "spotColors", "format": "text"})).unwrap()["text"].as_str().unwrap().to_string();
assert!(text.contains("\nSPOT COLOR OBJECTS\nRamp: Ink, Near\nType: Ink\n"), "{text}");
assert!(!["Far", "Edge", "Seam"].iter().any(|n| text.contains(n)), "{text}");
}
/// A gradient's stops print where they color a stretch that draws, for stops in any order: the
/// sampler mixes stop `k` with stop `k + 1` above every offset of the stops before it, so an
/// out-of-order stop's pair can have no width.
#[test]
fn drawn_stops_follow_the_sampled_stretches() {
use vectorcraft_color::{Color, Gradient, GradientKind, GradientStop};
let stop = |offset: f32, opacity: f32| GradientStop { opacity, ..GradientStop::new(offset, Color::BLACK) };
let g = |stops: Vec<GradientStop>| Gradient::new(GradientKind::Linear, stops);
// In order: the clear stop next to the opaque one, not the one after it.
assert_eq!(drawn_stops(&g(vec![stop(0.0, 1.0), stop(0.5, 0.0), stop(1.0, 0.0)])), vec![true, true, false]);
// Out of order: the pair (0.8, 0.3) has no width, so the clear stop at 0.3 mixes only with
// the clear stop at 0.8 before it, and the pair (0.3, 1.0) is sampled above 0.8.
assert_eq!(drawn_stops(&g(vec![stop(0.0, 0.0), stop(0.8, 0.0), stop(0.3, 0.0), stop(1.0, 1.0)])), vec![false, false, true, true]);
// An opaque stop out of order: the pairs (0, 0.8) and (0.7, 1) cover every offset, so the
// pairs next to the stop at 0.3 have no width and it colors nothing.
let out_of_order = |o: f32| g(vec![stop(0.0, 0.0), stop(0.8, o), stop(0.3, 1.0), stop(0.7, 0.0), stop(1.0, 0.0)]);
assert_eq!(drawn_stops(&out_of_order(0.0)), vec![false; 5]);
// With the stop at 0.8 opaque, the pairs next to it with width are drawn.
assert_eq!(drawn_stops(&out_of_order(1.0)), vec![true, true, false, false, false]);
// The last stop colors the offsets above every stop's, even when it is out of order.
assert_eq!(drawn_stops(&g(vec![stop(0.0, 0.0), stop(0.6, 0.0), stop(0.5, 1.0)])), vec![false, false, true]);
// The first stop colors the offsets up to its own.
assert_eq!(drawn_stops(&g(vec![stop(0.0, 1.0), stop(0.0, 0.0), stop(1.0, 0.0)])), vec![true, false, false]);
// One stop, every stop clear, and no stops at all.
assert_eq!(drawn_stops(&g(vec![stop(0.5, 1.0)])), vec![true]);
assert_eq!(drawn_stops(&g(vec![stop(0.0, 0.0), stop(1.0, 0.0)])), vec![false, false]);
assert!(drawn_stops(&g(vec![])).is_empty());
// A freeform gradient draws from its points: its stops count while any is above opacity 0.
let mut free = g(vec![stop(0.0, 1.0), stop(0.5, 0.0), stop(1.0, 0.0)]);
free.kind = GradientKind::Freeform;
assert_eq!(drawn_stops(&free), vec![true, true, true]);
}
/// Brushes, Spot Color Objects, Pattern Objects and Gradient Objects leave out a hidden object,
/// an object at an opacity of 0 and the objects inside a hidden layer or a group at an opacity of
/// 0, with Selection Only too: their fills, strokes, characters and colorized pixels paint no
@@ -1672,6 +1871,112 @@ mod tests {
assert_eq!(embedding_label(0x0003), "embedding not allowed");
assert_eq!(embedding_label(0x0006), "embedding for preview and print");
assert_eq!(embedding_label(0x000c), "embedding for editing");
// `fontDetails` names each permission by id.
assert_eq!(
[0, 0x0002, 0x0104, 0x0008, 0x0200, 0x000c].map(embedding_id),
["allowed", "forbidden", "previewPrint", "editing", "forbidden", "editing"]
);
}
/// `fontDetails` gives each Font Details row's facts as values, in the rows' order: how the
/// font was found, the face shown, its file (null for a built-in face) and its embedding
/// permission. The rows and the report stay in English.
#[test]
fn info_exposes_structured_font_details_without_changing_english_rows() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
s.execute("text.create", &json!({"x": 10, "y": 20, "text": "Hi"})).unwrap();
s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Heavy", "font": "Source Sans 3", "style": "Black Wide"})).unwrap();
s.execute("text.create", &json!({"x": 10, "y": 80, "text": "Gone", "font": "Qx Absent Family"})).unwrap();
let i = s.execute("document.info", &json!({"category": "fontDetails"})).unwrap();
let (entries, rows) = (i["fontDetails"].as_array().unwrap(), i["sections"][0]["rows"].as_array().unwrap());
assert_eq!(entries.iter().map(|e| e["font"].clone()).collect::<Vec<_>>(), rows.iter().map(|r| r[0].clone()).collect::<Vec<_>>());
let at = |prefix: &str| entries.iter().position(|e| e["font"].as_str().unwrap().starts_with(prefix)).unwrap();
let regular = at("Source Sans 3 Regular");
assert_eq!(
entries[regular],
json!({"font": "Source Sans 3 Regular", "match": "exact", "family": "Source Sans 3", "style": "Regular", "file": null, "embedding": "allowed"})
);
assert_eq!(rows[regular][1], "built in; embedding allowed");
let (wide, e) = (at("Source Sans 3 Black Wide"), &entries[at("Source Sans 3 Black Wide")]);
assert_eq!((e["match"].as_str(), e["family"].as_str()), (Some("style"), Some("Source Sans 3")), "{e}");
let file = e["file"].as_str().unwrap_or("built in");
let fs_type = [("allowed", 0), ("forbidden", 0x0002), ("previewPrint", 0x0004), ("editing", 0x0008)]
.into_iter()
.find_map(|(id, bits)| (e["embedding"] == id).then_some(bits))
.unwrap();
let found = format!("{file}; {}", embedding_label(fs_type));
assert_eq!(rows[wide][1], format!("substituted: shown in Source Sans 3 {} ({found})", e["style"].as_str().unwrap()));
let (gone, e) = (at("Qx Absent Family"), &entries[at("Qx Absent Family")]);
assert!(e["file"].is_null() && e["embedding"].is_null(), "{e}");
match e["match"].as_str() {
Some("missingWithFallback") => {
assert_eq!(rows[gone][1], format!("missing: shown in {} {}", e["family"].as_str().unwrap(), e["style"].as_str().unwrap()))
}
Some("missing") => assert_eq!((rows[gone][1].as_str(), e["family"].is_null()), (Some("missing"), true)),
m => panic!("{m:?}"),
}
let text = s.execute("document.info", &json!({"category": "fontDetails", "format": "text"})).unwrap()["text"].as_str().unwrap().to_string();
assert!(text.contains("Source Sans 3 Regular: built in; embedding allowed\n"), "{text}");
}
/// `document.unitId` names the document's units as Preferences › Units does, beside the English
/// `units`, and `imageObjects` lists the image objects in the order Embedded Images and Linked
/// Images list them, with the file a linked one shows (null for an embedded one).
#[test]
fn info_exposes_ordered_images_and_unit_id() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
s.edit("Units", |d, _| {
d.units = vectorcraft_doc::Unit::Millimeters;
Ok(())
})
.unwrap();
for x in [0.0, 50.0] {
let r = s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 20, "height": 10})).unwrap()["id"].clone();
s.execute("select.set", &json!({"ids": [r]})).unwrap();
s.execute("object.rasterize", &json!({"ppi": 72})).unwrap();
}
let i = s.execute("document.info", &json!({})).unwrap();
assert_eq!((i["document"]["units"].as_str(), i["document"]["unitId"].as_str()), (Some("Millimeters"), Some("millimeters")));
let section = |id: &str| i["sections"].as_array().unwrap().iter().find(|s| s["id"] == id).unwrap()["rows"].as_array().unwrap().clone();
assert!(section("document").iter().any(|r| *r == json!(["Units", "Millimeters"])));
let embedded = section("embeddedImages");
let objects = i["imageObjects"].as_array().unwrap();
assert_eq!(objects.len(), 2, "{objects:?}");
for (o, r) in objects.iter().zip(&embedded) {
assert_eq!((o["linked"].clone(), o["file"].clone(), o["name"].clone()), (json!(false), Value::Null, r[0].clone()), "{o}");
assert_eq!(r[1], format!("{} × {} px", o["width"], o["height"]));
}
assert!(section("linkedImages").is_empty());
}
/// A `gradientObjects` entry's `kinds` gives the kind of each gradient listed by its kind
/// rather than by a swatch, and null for a swatch's name, even one that reads like a kind and
/// one an object's gradient of that kind shares; a type object's characters count as its own.
#[test]
fn gradient_kinds_tell_kinds_from_swatch_names() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
let ramp = |kind: &str| json!({"kind": kind, "stops": [{"offset": 0, "color": "#000"}, {"offset": 1, "color": "#fff"}]});
s.execute("swatch.new", &json!({"name": "Radial gradient", "gradient": ramp("linear")})).unwrap();
let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone();
s.execute("paint.setFill", &json!({"ids": [r], "swatch": "Radial gradient"})).unwrap();
s.execute("paint.setStroke", &json!({"ids": [r], "gradient": ramp("conic")})).unwrap();
let t = s.execute("text.create", &json!({"x": 10, "y": 40, "text": "Hi"})).unwrap()["id"].clone();
s.execute("paint.setFill", &json!({"ids": [t], "gradient": ramp("diamond")})).unwrap();
// The swatch's name and a radial gradient's kind name are listed once, as the name.
let both = s.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone();
s.execute("paint.setFill", &json!({"ids": [both], "swatch": "Radial gradient"})).unwrap();
s.execute("paint.setStroke", &json!({"ids": [both], "gradient": ramp("radial")})).unwrap();
let i = s.execute("document.info", &json!({})).unwrap();
let entry = |id: &Value| i["gradientObjects"].as_array().unwrap().iter().find(|e| e["id"] == *id).unwrap().clone();
assert_eq!(
(entry(&r)["gradients"].clone(), entry(&r)["kinds"].clone()),
(json!(["Radial gradient", "Conic gradient"]), json!([null, "conic"]))
);
assert_eq!((entry(&t)["gradients"].clone(), entry(&t)["kinds"].clone()), (json!(["Diamond gradient"]), json!(["diamond"])));
assert_eq!((entry(&both)["gradients"].clone(), entry(&both)["kinds"].clone()), (json!(["Radial gradient"]), json!([null])));
}
#[test]
+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)} 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 → {ids, before, after}",
has_selection,
smooth_region
),
+27 -12
View File
@@ -15,15 +15,27 @@ const SMOOTH_SPREAD: f64 = 1.0 / 3.0;
/// 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.
fn refit_options(tol: f64, sigma: f64) -> SimplifyOptions {
SimplifyOptions { tolerance: tol, corner_angle_deg: 179.0, straight_lines: false, smooth: sigma, smooth_moved_only: true }
/// smoothing moved it, keeping 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 {
SimplifyOptions {
tolerance: tol,
corner_angle_deg: 179.0,
straight_lines: false,
smooth: sigma,
smooth_moved_only: true,
free_start: free[0],
free_end: free[1],
}
}
/// Smooth and refit the anchors of an open run `a[lo..=hi]` keeping its end points and outer handles.
fn refit_run(anchors: &[Anchor], tol: f64, sigma: f64) -> Vec<Anchor> {
/// `free` says whether its first and last anchors are ends of an open path: the curve leaving or
/// arriving at such an end can turn.
fn refit_run(anchors: &[Anchor], tol: f64, sigma: f64, free: [bool; 2]) -> Vec<Anchor> {
let piece = PathData::single(SubPath::new(anchors.to_vec(), false));
let fitted = po::simplify_with(&piece, &refit_options(tol, sigma));
let fitted = po::simplify_with(&piece, &refit_options(tol, sigma, free));
let Some(mut out) = fitted.subpaths.into_iter().next().map(|s| s.anchors).filter(|a| a.len() >= 2) else { return anchors.to_vec() };
let (f, l) = (anchors[0], anchors[anchors.len() - 1]);
let last = out.len() - 1;
@@ -228,7 +240,7 @@ fn smooth_subpath(sp: &SubPath, pts: &[Point], radius: f64, share: usize, tol: f
if sp.closed {
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));
let f = po::simplify_with(&PathData::single(sp.clone()), &refit_options(tol, sigma, [false; 2]));
return f.subpaths.into_iter().next();
};
// Rotate so an untouched anchor comes first, duplicate it at the end and treat as open.
@@ -239,14 +251,14 @@ 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);
let mut res = smooth_open(&open.anchors, &m, near, tol, sigma, false);
let l = res.pop()?;
if let Some(r0) = res.first_mut() {
r0.h_in = l.h_in;
}
return Some(SubPath::new(res, true));
}
Some(SubPath::new(smooth_open(&sp.anchors, &marked, near, tol, sigma), false))
Some(SubPath::new(smooth_open(&sp.anchors, &marked, near, tol, sigma, true), false))
}
/// The segment `c`, which ends in the drag's reach (`near`) and starts outside it, split where it
@@ -278,8 +290,10 @@ fn split_at_reach(c: CubicBez, near: &dyn Fn(Point) -> bool) -> Option<(CubicBez
/// `anchors` with each run of `marked` ones smoothed by a Gaussian of spread `sigma` and refitted
/// within `tol`, together with the stretch of the segments leading into the run that is in the
/// 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).
fn smooth_open(anchors: &[Anchor], marked: &[bool], near: &dyn Fn(Point) -> bool, tol: f64, sigma: f64) -> Vec<Anchor> {
/// 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> {
let n = anchors.len();
let mut runs = vec![];
let mut i = 0;
@@ -319,7 +333,8 @@ fn smooth_open(anchors: &[Anchor], marked: &[bool], near: &dyn Fn(Point) -> bool
head = Some(Anchor { h_out: far.p1, ..*outer });
*outer = Anchor::with_handles(far.p3, far.p2, reach.p1);
}
let new = refit_run(&piece, tol, sigma);
let free = [open && lo == 0 && head.is_none(), open && hi == n - 1 && tail.is_none()];
let new = refit_run(&piece, tol, sigma, free);
out.splice(lo..=hi, head.into_iter().chain(new).chain(tail));
}
out
@@ -329,7 +344,7 @@ pub(super) fn smooth_region(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.smoothRegion";
let pts = points_param(p, "points", C)?;
let radius = number_or(p, "radius", 12.0, C)?.max(0.0);
let tol = number_or(p, "fidelity", 2.5, C)?.clamp(vectorcraft_tools::draw2::SMOOTH_MIN_FIDELITY, 100.0);
let tol = number_or(p, "fidelity", 2.5, C)?.clamp(vectorcraft_tools::draw2::SMOOTH_MIN_TOLERANCE, vectorcraft_tools::draw2::SMOOTH_MAX_TOLERANCE);
let sigma = radius * SMOOTH_SPREAD;
let st = s.doc()?;
let mut plan = vec![];
+7 -6
View File
@@ -13,7 +13,7 @@ use vectorcraft_doc::{Document, Effect, Node, NodeKind, StrokeLayer};
use vectorcraft_geom::{FillRule, PathData};
use vectorcraft_render::effects;
use super::appearance::{ItemTarget, aligned, appearance_targets, index_param, item_indices, item_target, item_target_at};
use super::appearance::{ItemTarget, aligned, appearance_targets, edit_targets, index_param, item_indices, item_target, item_target_at};
use super::expand::has_seeded;
use super::rasterfx;
use super::{CommandSpec, EngineError, NodeId, Result, Session, always, bad, check_switches, cmd, fileio, has_doc, str_param, switch_or};
@@ -49,7 +49,7 @@ fn effect_specs() -> Vec<CommandSpec> {
"Apply Effect",
[],
None,
"{effect: id (see effect.list, e.g. \"stylize.dropShadow\", \"distort.roughen\", \"warp.arc\", or \"plugin.<id>\" for an installed effect plug-in), params?: {…} (missing keys take the dialog defaults), item?: appearance item index|null (apply to that fill/stroke only; omitted: the Appearance panel's active item, else the whole object), ids?|id?: [..] (layers too), target?: \"object\"|\"contents\" (contents: the objects inside groups and layers)} append a live effect to each selected object's appearance (a group's or layer's apply to its members as one piece: one combined shadow; a Pathfinder effect, which combines a group's contents, groups several loose selected objects first, in the same undo step) → {ids, index, item, grouped?}",
"{effect: id (see effect.list, e.g. \"stylize.dropShadow\", \"distort.roughen\", \"warp.arc\", or \"plugin.<id>\" for an installed effect plug-in), params?: {…} (missing keys take the dialog defaults), item?: appearance item index|null (apply to that fill/stroke only; omitted: the Appearance panel's active item, else the whole object), ids?|id?: [..] (layers too), target?: \"object\"|\"contents\" (contents: the objects inside groups and layers)} append a live effect to each selected object's appearance (a group's or layer's apply to its members as one piece: one combined shadow; a Pathfinder effect, which combines a group's contents, groups several loose selected objects first, in the same undo step); the art of a graph design in a graph series is left out, and objects that are all such art are an error (docs/mcp.md) → {ids, index, item, grouped?}",
has_doc,
apply
),
@@ -298,7 +298,7 @@ fn ids_json(ids: &[NodeId]) -> Value {
/// edits its equal item, or its equal object-level effect, each target matched on its own whatever
/// the other targets have; in a target without them, `item` and `index` stand for themselves.
pub(crate) fn effect_targets(s: &Session, p: &Value, item: ItemTarget, index: usize, cmd: &str) -> Result<Vec<(NodeId, Option<usize>, usize)>> {
let roots = appearance_targets(s, p)?;
let roots = edit_targets(s, p)?;
let d = &s.doc()?.doc;
let per = item.per_target(s, &roots);
let first = roots.first().and_then(|f| d.node(*f));
@@ -453,7 +453,7 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
}
let item = item_target(s, p, C)?;
if effects::is_three_d(id) {
let targets = appearance_targets(s, p)?;
let targets = edit_targets(s, p)?;
for (target, at) in targets.iter().copied().zip(item.per_target(s, &targets)) {
let d = &s.doc()?.doc;
let n = d.node(target).ok_or(EngineError::NoNode(target))?;
@@ -492,8 +492,9 @@ fn loose_for_pathfinder(s: &Session, p: &Value, id: &str) -> Result<Option<Vec<N
if !effects::is_pathfinder(id) || ["ids", "id", "item", "target"].iter().any(|k| p.get(k).is_some()) {
return Ok(None);
}
let roots = super::appearance::subject_roots(s)?;
let d = &s.doc()?.doc;
// The art of a graph design in a series stays where the graph draws it.
let roots = super::appearance::without_design_art(d, super::appearance::subject_roots(s)?, "appearance")?;
let grouped = roots.iter().any(|r| d.node(*r).is_some_and(|n| matches!(n.kind, NodeKind::Group { clip: false, .. } | NodeKind::Layer { .. })));
Ok((roots.len() >= 2 && !grouped).then_some(roots))
}
@@ -606,7 +607,7 @@ fn move_effect(s: &mut Session, p: &Value) -> Result<Value> {
let src = item_target_at(s, p, "fromItem", C)?;
let dst = if p.get("toItem").is_some() { item_target_at(s, p, "toItem", C)? } else { src };
let copy = switch_or(p, "copy", false, C)?;
let roots = appearance_targets(s, p)?;
let roots = edit_targets(s, p)?;
// Where an aligned effect would go in each of several different stacks isn't one place.
if aligned(p, C)? && super::appearance::appearances_differ(&s.doc()?.doc, &roots) {
return Err(bad(C, "effects of objects whose appearances differ can't be moved together"));
+2 -2
View File
@@ -7,7 +7,7 @@ use serde_json::{Map, Value, json};
use vectorcraft_doc::{Effect, Node};
use vectorcraft_render::effects;
use super::super::appearance::{appearance_targets, index_param, item_target};
use super::super::appearance::{edit_targets, index_param, item_target};
use super::super::brushsym::{normalize, selection_art, store_symbol};
use super::super::{CommandSpec, EngineError, NodeId, Result, Session, bad, cmd, has_doc, has_selection, str_param};
use super::{effect_targets, ids_json};
@@ -95,7 +95,7 @@ fn map_graphic(s: &mut Session, p: &Value) -> Result<Value> {
effect_targets(s, p, item, index, C)?
} else {
let mut out = vec![];
for id in appearance_targets(s, p)? {
for id in edit_targets(s, p)? {
let (item, index) = d.node(id).and_then(first_three_d).ok_or_else(|| bad(C, format!("object {} needs a 3D effect", id.0)))?;
out.push((id, item, index));
}
+1 -1
View File
@@ -123,7 +123,7 @@ mod formats;
#[cfg(not(target_arch = "wasm32"))]
pub(crate) use access::list_dir;
pub use access::{write_file_with, write_save_file, write_save_file_with};
pub use access::{check_linked_scripts, write_file_with, write_save_file, write_save_file_with};
#[cfg(all(test, not(target_arch = "wasm32")))]
mod tests_readonly;
+18
View File
@@ -121,6 +121,24 @@ pub(crate) fn naming_in_message(msg: &str, path: &str, name: &str) -> String {
out
}
/// The error for an SVG of a document that links a JavaScript file outside the read roots.
pub(crate) const SCRIPT_OUTSIDE: &str = "a linked JavaScript file is outside the read roots";
/// `cmd` may write `st`'s document in format `format` (a format id): any format but SVG and SVGZ,
/// and those too unless read roots are in force and the document links a JavaScript file outside
/// them (`svg_scripts`, a relative path taken from the document's folder; a URL is not checked),
/// which the file's `<script>` element would name. The error names no file or folder.
pub fn check_linked_scripts(cmd: &str, format: &str, st: &crate::DocState) -> Result<()> {
if !matches!(format, "svg" | "svgz") || crate::file_access::current().is_none() {
return Ok(());
}
let doc_path = st.path.as_deref();
if st.doc.svg_scripts.iter().any(|f| crate::cmd::links::script_hidden(f, doc_path)) {
return Err(bad(cmd, SCRIPT_OUTSIDE));
}
Ok(())
}
/// `path` checked for writing against the automation roots in force.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn may_write(path: &str) -> Result<()> {
File diff suppressed because one or more lines are too long
+5 -1
View File
@@ -12,7 +12,8 @@ use crate::DocState;
use super::super::{Result, Session, bad, check_switches, edit, number_or, switch_or, text_param};
use super::encode::single_artboard;
use super::{
ARTBOARD_PARAMS, Format, default_name, encode_all, encode_with_warnings, merge, with_black_output, writable, write_encoded, write_or_return,
ARTBOARD_PARAMS, Format, check_linked_scripts, default_name, encode_all, encode_with_warnings, merge, with_black_output, writable, write_encoded,
write_or_return,
};
/// The keys the export commands take for every format, beside the path, the format and the
@@ -52,6 +53,7 @@ pub(super) fn export_reads(f: &Format, legacy: bool, key: &str) -> bool {
pub(super) fn serialize(s: &mut Session, p: &Value) -> Result<Value> {
let f = writable("document.serialize", Some(text_param(p, "format", "document.serialize")?.unwrap_or("vectorcraft")), None)?;
check_linked_scripts("document.serialize", f.id, s.doc()?)?;
let unused = unused_option_notes("document.serialize", f, p);
let expanded = expand_presets(s, "document.serialize", p)?;
let p = with_black_output(s, &expanded);
@@ -95,6 +97,7 @@ pub(super) fn export(s: &mut Session, p: &Value) -> Result<Value> {
super::check_not_lossy_overwrite(s.doc()?, path, p, "document.export")?;
}
let f = writable("document.export", text_param(p, "format", "document.export")?, path)?;
check_linked_scripts("document.export", f.id, s.doc()?)?;
let unused = unused_option_notes("document.export", f, p);
let expanded = expand_presets(s, "document.export", p)?;
let p = with_black_output(s, &expanded);
@@ -354,6 +357,7 @@ pub(super) fn export_selection(s: &mut Session, p: &Value) -> Result<Value> {
super::check_not_lossy_overwrite(s.doc()?, path, p, C)?;
}
let f = writable(C, text_param(p, "format", C)?, path)?;
check_linked_scripts(C, f.id, s.doc()?)?;
let unused = unused_option_notes(C, f, p);
let expanded = expand_presets(s, C, p)?;
let p = with_black_output(s, &expanded);
+1 -1
View File
@@ -133,7 +133,7 @@ pub const AI_OPTIONS: &[FormatOption] = &[
name: "outlineText",
ty: "boolean",
default: "false",
description: "with aiVersion 7, type as its outlines instead of text (which apps reading the file set in the fonts it names: they need them installed); type 8-bit text can't hold (characters outside Windows Latin-1, vertical type, ruby, inline graphics, gradient or pattern fills) and type with rotated characters are always outlines",
description: "with aiVersion 7, type as its outlines instead of text (which apps reading the file set in the fonts it names: they need them installed); type 8-bit text can't hold (characters outside Windows Latin-1, vertical type, ruby, warichu, inline graphics, gradient or pattern fills) and type with rotated characters are always outlines",
},
];
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -880,6 +880,7 @@ pub(crate) fn job_for(s: &Session, st: &DocState, plan: SavePlan) -> Result<Save
fn job_for_stamped(s: &Session, st: &DocState, plan: SavePlan, stamped: Option<i64>) -> Result<SaveJob> {
let cmd = plan.mode.command();
super::check_linked_scripts(cmd, plan.format.id, st)?;
let mut own = doc_to_save(st, plan.format);
// The file the document is saved as (else its own) titles it if New's name is all it has.
let file = plan.path.as_deref().filter(|_| plan.retargets()).or(st.path.as_deref());
@@ -1297,7 +1298,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {
"Save Document",
[],
None,
"{path?, format?: vectorcraft|template|pdf|svg|svgz|ai|eps (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings name an option given flat or in options that the file doesn't read: a legacy .ai reads only its own options, and other .ai files don't read outlineText, hiddenObjects, transparentImages or preserveEditing), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), precision?: 0..9 (native: round path anchors and handles to this many decimal places, a smaller file; colors, transforms and type stay exact; default: exact, and a later save without it writes exactly again), separateArtboards?: false (native and .ai: also save each artboard of `range` to <name>-<artboard>.<ext> beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: true (native: also carry a PDF of every artboard; false omits it) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file and EPS (below): a .ai file is a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly, on the active artboard (the app's, else the one the document opened on; with editingData: false, the first); separateArtboards writes more files, one per artboard. EPS takes the options of document.exportEps: by default one file whose bounding box fits the visible art (the first artboard when no art shows), carrying the whole editable document; useArtboards: true (also when artboard, artboards or range names artboards; file.formatOptions then reports it true) makes the chosen artboard the page, drawn with its background color (without Use Artboards, only the first artboard's page when no art shows has its color), and with two or more chosen the file at the path is the central file of the visible art (again the first artboard when none shows) and each artboard is also written to <name>_<artboard>.eps beside it, holding that artboard; selectedOnly is refused; its PostScript appearance flattens transparency, as EPS requires. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF/EPS saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified. Under automation read roots, a reply leaves out the document's own file outside them when no path is given (files and linked then name the files by their file names), and a suggested folder outside them. A key that no save format takes, beside the path or in options, and an unknown key inside a PDF option's object or svg, are refused. A terminal filesystem permission/read-only refusal returns READ_ONLY_LOCATION: followed by the reason; recovery-copy failures keep their original error. Non-UI callers never open a save panel",
"{path?, format?: vectorcraft|template|pdf|svg|svgz|ai|eps (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings name an option given flat or in options that the file doesn't read: a legacy .ai reads only its own options, and other .ai files don't read outlineText, hiddenObjects, transparentImages or preserveEditing), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), precision?: 0..9 (native: round path anchors and handles to this many decimal places, a smaller file; colors, transforms and type stay exact; default: exact, and a later save without it writes exactly again), separateArtboards?: false (native and .ai: also save each artboard of `range` to <name>-<artboard>.<ext> beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: true (native: also carry a PDF of every artboard; false omits it) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file and EPS (below): a .ai file is a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly, on the active artboard (the app's, else the one the document opened on; with editingData: false, the first); separateArtboards writes more files, one per artboard. EPS takes the options of document.exportEps: by default one file whose bounding box fits the visible art (the first artboard when no art shows), carrying the whole editable document; useArtboards: true (also when artboard, artboards or range names artboards; file.formatOptions then reports it true) makes the chosen artboard the page, drawn with its background color (without Use Artboards, only the first artboard's page when no art shows has its color), and with two or more chosen the file at the path is the central file of the visible art (again the first artboard when none shows) and each artboard is also written to <name>_<artboard>.eps beside it, holding that artboard; selectedOnly is refused; its PostScript appearance flattens transparency, as EPS requires. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF/EPS saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified. Under automation read roots, a reply leaves out the document's own file outside them when no path is given (files and linked then name the files by their file names), and a suggested folder outside them; an svg or svgz save (a document whose own format is SVG or SVGZ included) is refused, before any file is written, while the document links a JavaScript file outside them (svgInteractivity; a URL is not checked), also by file.saveAs and file.saveCopy. A key that no save format takes, beside the path or in options, and an unknown key inside a PDF option's object or svg, are refused. A terminal filesystem permission/read-only refusal returns READ_ONLY_LOCATION: followed by the reason; recovery-copy failures keep their original error. Non-UI callers never open a save panel",
has_doc,
|s, p| save(s, SaveMode::Save, p)
),
+4
View File
@@ -1105,6 +1105,10 @@ pub(crate) fn export(s: &mut Session, p: &Value, asked: &Value) -> Result<Value>
o.one_source()?;
// `<filename>`: the document's file, as Save names it, or its title before it is saved.
let st = s.doc()?;
// Before the folder is made or any row is written.
for r in &rows {
super::check_linked_scripts(C, r.format.id, st)?;
}
let naming = Naming::of(&o, st.path.as_deref().unwrap_or(&st.doc.title))?;
let selection = o.selection_mode()?;
let selected = if selection.is_some() { selected_artwork(s)? } else { vec![] };
+64
View File
@@ -103,6 +103,70 @@ fn use_artboards_false_writes_one_page_of_the_arts_bounds() {
}
}
/// 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
/// art's bounds and the Summary's export, with one such warning per page. PDF/A files and `.ai`
/// saves keep the warning without the option's name. Pages written in points (the option on, PDF
/// 1.4, PDF/X, a page too narrow for a `UserUnit` above 1) and a page too long and narrow for
/// 14,400 units have other warnings, which don't name it.
#[test]
fn a_page_with_a_user_unit_names_the_option_that_writes_it_in_points() {
use super::pdf::LONG_PAGES_IN_POINTS;
const SMALLER: &str = "so apps that ignore UserUnit show it that many times smaller";
let mut s = Session::new();
s.execute("file.new", &json!({"width": 20_000, "height": 1_000})).unwrap();
s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 19_000, "height": 500})).unwrap();
let warnings = |v: &Value| -> Vec<String> {
v["warnings"].as_array().unwrap().iter().filter_map(|w| w.as_str()).filter(|w| w.contains("UserUnit")).map(str::to_string).collect()
};
let named = |v: &Value| warnings(v).iter().any(|w| w.contains(LONG_PAGES_IN_POINTS));
for p in [
json!({"longPagesInPoints": false}),
json!({"longPagesInPoints": false, "compatibility": "1.5"}),
json!({"longPagesInPoints": false, "compatibility": "2.0"}),
json!({"longPagesInPoints": false, "useArtboards": false}),
] {
let mut q = p.clone();
q["includeDocument"] = json!(true);
for v in [export(&mut s, p.clone()), s.execute("document.export", &as_export(&p)).unwrap(), s.execute("document.pdfSettings", &q).unwrap()] {
let page: Vec<String> = warnings(&v).into_iter().filter(|w| w.starts_with("the page of artboard")).collect();
assert_eq!(page.len(), 1, "{p}: {v}");
assert!(page[0].ends_with(&format!("{SMALLER}{LONG_PAGES_IN_POINTS}")), "{p}: {page:?}");
assert_eq!(warnings(&v).iter().filter(|w| w.contains("apps that ignore UserUnit")).count(), 1, "{p}: {v}");
}
}
for p in [
json!({"longPagesInPoints": true}),
json!({"longPagesInPoints": false, "compatibility": "1.4"}),
json!({"longPagesInPoints": false, "preset": "PDF/X-4:2010"}),
] {
assert!(!named(&export(&mut s, p.clone())), "{p}");
}
for p in
[json!({"longPagesInPoints": false, "standard": "pdfA2b"}), json!({"longPagesInPoints": false, "standard": "pdfA2b", "compatibility": "1.4"})]
{
let v = export(&mut s, p.clone());
assert!(warnings(&v).iter().any(|w| w.ends_with(SMALLER)) && !named(&v), "{p}: {v}");
}
let ai = s.execute("document.export", &json!({"format": "ai", "longPagesInPoints": false})).unwrap();
assert!(warnings(&ai).iter().any(|w| w.ends_with(SMALLER)) && !named(&ai), "{ai}");
// The art's bounds are within 14,400 pt.
s.execute("select.all", &json!({})).unwrap();
s.execute("edit.clear", &json!({})).unwrap();
s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 500, "height": 500})).unwrap();
assert!(!named(&export(&mut s, json!({"longPagesInPoints": false, "useArtboards": false}))));
// A page 3 pt high keeps a UserUnit of 1, so it is written in points either way; a page
// 85,040 × 17 pt gets a UserUnit above 1 that leaves its long side past 14,400 units.
for (w, h) in [(20_000, 3), (85_040, 17)] {
let mut s = Session::new();
s.execute("file.new", &json!({"width": w, "height": h})).unwrap();
s.execute("shape.rectangle", &json!({"x": 100, "y": 0, "width": w - 1_000, "height": h})).unwrap();
let v = export(&mut s, json!({"longPagesInPoints": false}));
assert!(!named(&v), "{w} × {h}: {v}");
}
}
/// A PDF of the art's bounds carries no Preserve Editing data, through `document.exportPdf`,
/// `document.export` and the Summary's export in memory, and says so in a warning: reopened, its
/// editing data would replace the document's artboards with the one around the art. A PDF of the
+1 -1
View File
@@ -19,7 +19,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add Freeform Point",
[],
None,
"{at: [x,y] (document coordinates), color? (default: the gradient's colour there; #rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), opacity?: 0..1 (or 0..100), spread?: 0..1 (or 0..100 %; the radius of pure colour around the point, a fraction of half the object's larger side; default 0), line?: true (join it to the selected point: extends the line ending there, else starts one), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\"} add a point to the freeform gradient (an unplaced one keeps its automatic points first) and select it. On a path directly in a graph series, the graph also stores the edited gradient when it is on the topmost visible fill or stroke, as for the other paint.freeform edits (docs/mcp.md) → {index}",
"{at: [x,y] (document coordinates), color? (default: the gradient's colour there; #rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), opacity?: 0..1 (or 0..100), spread?: 0..1 (or 0..100 %; the radius of pure colour around the point, a fraction of half the object's larger side; default 0), line?: true (join it to the selected point: extends the line ending there, else starts one), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\"} add a point to the freeform gradient (an unplaced one keeps its automatic points first) and select it. On a path directly in a graph series, the graph also stores the edited gradient when it is on the topmost visible fill or stroke, as for the other paint.freeform edits (docs/mcp.md); on a graph label's row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind → {index}",
has_doc,
add_point
),
+34 -10
View File
@@ -49,6 +49,10 @@
//! member with another order, and any other change to the list (items moved, or several added
//! or removed at once), takes the model's whole list of fills and strokes.
//!
//! A path directly in a graph series that takes an edit from another member (a graph's paths are
//! members only while the graph is isolated) also has what changed stored on its graph, so that
//! every path of the series takes it when the graph is drawn again (docs/mcp.md).
//!
//! Members that are hidden or locked at the time are left as they are. An edit that would take any
//! member past the canvas (beyond [`vectorcraft_doc::MAX_COORD`]) is refused whole, as one that
//! takes the selection there is. Whenever the document is put back (Undo, Redo, a cancelled drag
@@ -432,16 +436,24 @@ fn can_toggle(s: &Session) -> std::result::Result<(), String> {
let [id] = st.selection.objects.as_slice() else {
return Err(if st.selection.is_empty() { "nothing selected" } else { "select one object" }.into());
};
// A group's contents are checked when Global Edit starts: measuring them here would walk
// every object in it each time the menu or the Properties panel asks. Other objects are
// measured ([`supported`]), not reduced to their form: the Control bar asks every frame.
match st.doc.node(*id) {
Some(n) if n.extras().mask.is_none() && plain_group(n) => Ok(()),
Some(n) if supported(n) => Ok(()),
_ => Err(UNSUPPORTED.into()),
}
// The object, a group's contents included, is measured ([`supported`]), not reduced to its
// form, and the answer is kept until the document's revision moves: the Control bar asks
// every frame, and a group's walk takes time with the anchors in it.
let ok = match st.global_edit_support.get() {
Some((rev, key, ok)) if rev == st.revision && key == *id => ok,
_ => {
let ok = st.doc.node(*id).is_some_and(supported);
st.global_edit_support.set(Some((st.revision, *id, ok)));
ok
}
};
if ok { Ok(()) } else { Err(UNSUPPORTED.into()) }
}
/// [`can_toggle`]'s answer for the one selected object: the revision and the object it was found
/// for, and whether Global Edit supports it.
pub type SupportCache = std::cell::Cell<Option<(u64, NodeId, bool)>>;
fn toggle(s: &mut Session, p: &Value) -> Result<Value> {
if s.doc()?.global_edit.is_some() {
let st = s.doc_mut()?;
@@ -778,9 +790,22 @@ pub(crate) fn propagate(before: &Document, d: &mut Document, ge: &GlobalEdit, se
}
super::object::keep_compound_aligned(d, *id, Scaling::default());
}
keep_series_changes(before, d, &edited);
edited
}
/// Store on their graphs what the members `edited`, and the objects in them, took on the paths directly in a graph
/// series, for the graph's next redraw. The edited member is left to the edit's own command.
fn keep_series_changes(before: &Document, d: &mut Document, edited: &[NodeId]) {
let mut taken = vec![];
for id in edited {
if let Some(n) = d.node(*id) {
n.walk(&mut |c: &Node| taken.push(c.id));
}
}
super::appearance::capture_series_changes(before, d, &taken);
}
/// The outlines of the members of the active document's Global Edit that are not selected, hold
/// no selected object and sit inside no selected object, in their layer's colour: the canvas
/// highlights them while Global Edit is on. While it is off, with one supported object selected,
@@ -989,8 +1014,7 @@ mod tests {
continue;
}
asked += 1;
let today = n.extras().mask.is_none() && plain_group(&n) || by_form(&n);
assert_eq!(can_toggle(&s).is_ok(), today, "seed {seed}");
assert_eq!(can_toggle(&s).is_ok(), by_form(&n), "seed {seed}");
match s.execute(C, &json!({})) {
Ok(_) => {
assert!(by_form(&n), "seed {seed}");
+2 -2
View File
@@ -24,7 +24,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Gradient",
["Window", "Gradient"],
None,
"{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical, sweeps the stops once around the center, clockwise from the vector, with a seam where the last stop meets the first; diamond runs the stops from the center out to a square with a corner at the end of the vector; freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), method?: classic|perceptual|hue-clockwise|hue-counterclockwise (how the colours between the stops mix: in display RGB, perceptually (OKLab) so the steps look even, or around the hue wheel: hue, saturation and brightness each change evenly from one stop to the next, the hue angle falling with hue-clockwise (red, magenta, blue, cyan, green, yellow) and rising with hue-counterclockwise; two stops whose hues are less than half a color level apart keep that hue; a stop whose RGB numbers are less than half a color level apart is gray and takes the hue of the stop it mixes with, and black that stop's saturation too; these two methods are VectorCraft's own, and SVG and PDF exports draw their gradients as RGB images, CMYK and spot color stops included; SVG exports draw a Perceptual gradient as an image, and PDF exports do too unless it is linear, radial, conic or diamond, has spot color stops, every other stop is paper white or written in CMYK or gray (an export that converts colors to a CMYK destination profile writes RGB and Lab stops in CMYK, and so does a CMYK document unless the export converts colors to an RGB destination profile), it is not a dithered fill and its curve does not have too many stops: then PDF writes tints of one spot ink as a Separation shading in that ink, and spot inks mixed with other spot, CMYK or gray inks as a DeviceN shading; freeform gradients mix their own way), spread?: pad|repeat|reflect (linear, radial, conic and diamond: what the gradient paints past the ends of its stops: pad carries the end colors on; repeat and reflect run the stops `repeats` times along the vector (around the circle, for a conic) and on past both ends, reflect mirroring every other run; an error on a freeform gradient), repeats?: 1..100 (a whole number: how many times repeat and reflect run the stops along the vector; VectorCraft's own limit; pad keeps it unused), stops?: [{offset 0..1, color? (needed without swatch; #rrggbbaa and #rgba are refused), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg counter-clockwise (0: the gradient runs left to right; radial, conic and diamond: the vector turns about the center), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep and is an error for the other kinds), reverse?: bool (the same ramp the other way round: the stops in reverse order, and a hue method turned the other way unless `method` is given too), dither?: bool (fills only: a linear, radial, conic or diamond gradient fill is drawn with fine noise that hides banding, and as pixels in SVG and PDF exports), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient. On a path directly in a graph series, the graph also stores the gradient when it is on the topmost visible fill or stroke (docs/mcp.md)",
"{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical, sweeps the stops once around the center, clockwise from the vector, with a seam where the last stop meets the first; diamond runs the stops from the center out to a square with a corner at the end of the vector; freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), method?: classic|perceptual|hue-clockwise|hue-counterclockwise (how the colours between the stops mix: in display RGB, perceptually (OKLab) so the steps look even, or around the hue wheel: hue, saturation and brightness each change evenly from one stop to the next, the hue angle falling with hue-clockwise (red, magenta, blue, cyan, green, yellow) and rising with hue-counterclockwise; two stops whose hues are less than half a color level apart keep that hue; a stop whose RGB numbers are less than half a color level apart is gray and takes the hue of the stop it mixes with, and black that stop's saturation too; these two methods are VectorCraft's own, and SVG and PDF exports draw their gradients as RGB images, CMYK and spot color stops included; SVG exports draw a Perceptual gradient as an image, and PDF exports do too unless it is linear, radial, conic or diamond, has spot color stops, every other stop is paper white or written in CMYK or gray (an export that converts colors to a CMYK destination profile writes RGB and Lab stops in CMYK, and so does a CMYK document unless the export converts colors to an RGB destination profile), it is not a dithered fill and its curve does not have too many stops: then PDF writes tints of one spot ink as a Separation shading in that ink, and spot inks mixed with other spot, CMYK or gray inks as a DeviceN shading; freeform gradients mix their own way), spread?: pad|repeat|reflect (linear, radial, conic and diamond: what the gradient paints past the ends of its stops: pad carries the end colors on; repeat and reflect run the stops `repeats` times along the vector (around the circle, for a conic) and on past both ends, reflect mirroring every other run; an error on a freeform gradient), repeats?: 1..100 (a whole number: how many times repeat and reflect run the stops along the vector; VectorCraft's own limit; pad keeps it unused), stops?: [{offset 0..1, color? (needed without swatch; #rrggbbaa and #rgba are refused), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg counter-clockwise (0: the gradient runs left to right; radial, conic and diamond: the vector turns about the center), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep and is an error for the other kinds), reverse?: bool (the same ramp the other way round: the stops in reverse order, and a hue method turned the other way unless `method` is given too), dither?: bool (fills only: a linear, radial, conic or diamond gradient fill is drawn with fine noise that hides banding, and as pixels in SVG and PDF exports), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient. On a path directly in a graph series, the graph also stores the gradient when it is on the topmost visible fill or stroke (docs/mcp.md); on a graph label's row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind",
has_doc,
edit_gradient
),
@@ -33,7 +33,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Gradient Vector",
[],
None,
"{start?: [x,y], end?: [x,y] (document coordinates; both or neither: omitted, the vector stays), aspect?: % (radial, conic and diamond: the extent's height / width across the vector, a negative one mirroring a conic's sweep and an error for the other kinds; default: kept), focal?: [x,y] (document coordinates) | null (radial: the focal point, where the first stop sits, pulled inside the extent ellipse; null centres it; default: it keeps its place in the ellipse; an error on a conic or diamond gradient), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (alias: index; omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\", index?: appearance item index (as item)} set the gradient vector, aspect ratio and focal point (solid paints become the default gradient; type objects set it on their runs, in text space). On a path directly in a graph series, the graph also stores the gradient with its placement when it is on the topmost visible fill or stroke (docs/mcp.md)",
"{start?: [x,y], end?: [x,y] (document coordinates; both or neither: omitted, the vector stays), aspect?: % (radial, conic and diamond: the extent's height / width across the vector, a negative one mirroring a conic's sweep and an error for the other kinds; default: kept), focal?: [x,y] (document coordinates) | null (radial: the focal point, where the first stop sits, pulled inside the extent ellipse; null centres it; default: it keeps its place in the ellipse; an error on a conic or diamond gradient), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (alias: index; omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\", index?: appearance item index (as item)} set the gradient vector, aspect ratio and focal point (solid paints become the default gradient; type objects set it on their runs, in text space). On a path directly in a graph series, the graph also stores the gradient with its placement when it is on the topmost visible fill or stroke (docs/mcp.md); on a graph label's row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind, with the gradient fitted to each label",
has_doc,
set_gradient_geom
),
+4 -3
View File
@@ -45,9 +45,10 @@ use generate::generate;
#[cfg(test)]
use series::default_series_color;
pub(crate) use series::{
capture_label_anti_alias, capture_label_paragraphs, capture_label_styles, capture_series_object_opacity, capture_series_opacity,
capture_series_paints, capture_series_top_paints, paint_targets, series_group_marks, series_group_marks_where, series_marks_in,
series_object_opacity_differs, shown_paint,
LabelTextChange, capture_label_anti_alias, capture_label_object_opacity, capture_label_paragraphs, capture_label_rows, capture_label_styles,
capture_label_text, capture_series_object_opacity, capture_series_opacity, capture_series_top_paints, is_design_art,
label_object_opacity_differs, paint_targets, series_group_marks, series_group_marks_where, series_marks_in, series_object_opacity_differs,
shown_paint,
};
#[cfg(test)]
+48 -2
View File
@@ -73,6 +73,44 @@ pub(super) fn label_anti_alias(styles: &vectorcraft_doc::LabelStyles, role: Role
kept.unwrap_or_default()
}
/// The object rows labels of `role` keep, if any.
fn kept_rows(styles: &vectorcraft_doc::LabelStyles, role: Role) -> Option<&vectorcraft_doc::LabelRows> {
match role {
Role::Value => styles.values_rows.as_ref(),
Role::Category => styles.categories_rows.as_ref(),
Role::Legend => styles.legend_rows.as_ref(),
}
}
/// Give label `n` the object rows `kept` keeps: a fill row and a stroke row for the kept paints, in their kept order and
/// place relative to the characters, and the object opacity and blend mode. An opacity read from a file is held to 0..1
/// and a weight to 0..1000 pt; one that is not a number is left out (a stroke then takes 1 pt).
fn apply_label_rows(n: &mut Node, kept: &vectorcraft_doc::LabelRows) {
use vectorcraft_doc::{AppearanceItem, FillLayer, StrokeLayer};
let rows = &kept.paint;
let unit = |o: Option<f32>| o.filter(|o| o.is_finite()).map(|o| o.clamp(0.0, 1.0));
let fill = rows.fill.as_ref().map(|paint| {
let mut f = FillLayer::new(paint.clone());
f.opacity = unit(rows.fill_opacity).unwrap_or(1.0);
f.blend = rows.fill_blend.unwrap_or_default();
AppearanceItem::Fill(f)
});
let stroke = rows.stroke.as_ref().map(|paint| {
let width = rows.stroke_width.filter(|w| w.is_finite()).map_or(1.0, |w| w.clamp(0.0, 1000.0));
let mut st = StrokeLayer::new(paint.clone(), width);
st.opacity = unit(rows.stroke_opacity).unwrap_or(1.0);
st.blend = rows.stroke_blend.unwrap_or_default();
AppearanceItem::Stroke(st)
});
let items: Vec<AppearanceItem> = if kept.stroke_under { [stroke, fill] } else { [fill, stroke] }.into_iter().flatten().collect();
let mut ap = Appearance::clone(&n.appearance);
ap.items = items;
ap.set_contents_at(usize::from(kept.below.unwrap_or(0)));
n.appearance = ap.into();
n.opacity = unit(rows.opacity).unwrap_or(1.0);
n.blend = rows.blend.unwrap_or_default();
}
/// How big labels of `role` are set: a kept style's size, else 9 pt. The labels' spacing follows it.
pub(super) fn label_size(styles: &vectorcraft_doc::LabelStyles, role: Role) -> f64 {
label_style(styles, role).size
@@ -90,7 +128,15 @@ impl Gen<'_> {
}
pub(super) fn text(&mut self, role: Role, at: Point, s: &str, justify: Justify) -> Arc<Node> {
let t = self.text_object(role, at, s, justify);
Arc::new(Node::new(self.d.alloc_id(), NodeKind::Text(Box::new(t))))
self.label_node(role, t)
}
/// Label text `t` of `role` as a node, with the object rows its kind keeps.
fn label_node(&mut self, role: Role, t: TextObject) -> Arc<Node> {
let mut n = Node::new(self.d.alloc_id(), NodeKind::Text(Box::new(t)));
if let Some(rows) = kept_rows(self.styles, role) {
apply_label_rows(&mut n, rows);
}
Arc::new(n)
}
/// Label `s` of `role` set at `at` and laid out, not yet in the document.
fn text_object(&self, role: Role, at: Point, s: &str, justify: Justify) -> TextObject {
@@ -121,7 +167,7 @@ impl Gen<'_> {
t.xf = Affine::translate((0.0, half - (first + last) / 2.0)) * t.xf;
}
}
Arc::new(Node::new(self.d.alloc_id(), NodeKind::Text(Box::new(t))))
self.label_node(role, t)
}
pub(super) fn group(&mut self, name: &str, children: Vec<Arc<Node>>) -> Arc<Node> {
let mut g = Node::group(self.d.alloc_id(), children);
+198 -45
View File
@@ -131,7 +131,7 @@ pub(crate) fn paint_targets(doc: &Document, ids: &[NodeId]) -> Vec<NodeId> {
}
let mut out = Vec::new();
for id in ids {
// The series' own marks: the art of a marker design inside it keeps the design's paint (painting it would
// The series' own marks: the art of a graph design inside it keeps the design's paint (painting it would
// last only until the graph is drawn again), so edit the design to change it.
match members.get(id) {
Some(r) if r.group == *id => {
@@ -139,7 +139,7 @@ pub(crate) fn paint_targets(doc: &Document, ids: &[NodeId]) -> Vec<NodeId> {
out.extend(group.children().into_iter().flatten().filter(|m| matches!(m.kind, NodeKind::Path { .. })).map(|m| m.id));
}
}
Some(r) if doc.parent_of(*id) != Some(r.group) => {}
Some(r) if doc.parent_of(*id) != Some(r.group) || doc.node(*id).is_some_and(|n| !matches!(n.kind, NodeKind::Path { .. })) => {}
_ => out.push(*id),
}
}
@@ -157,18 +157,11 @@ pub(crate) fn series_group_marks(doc: &Document, ids: &[NodeId]) -> Vec<NodeId>
/// [`series_group_marks`] for the series groups `stands` accepts; the others pass through.
pub(crate) fn series_group_marks_where(doc: &Document, ids: &[NodeId], stands: impl Fn(&Node) -> bool) -> Vec<NodeId> {
// A series group is a child of a graph with one of the graph's series indexes ([`series_members`]). Each id is
// looked up through its parent, with no pass over the document: the panels call this while they draw.
let is_series_group = |n: &Node| {
let Some(index) = n.extras().series_index.and_then(|i| usize::try_from(i).ok()) else { return false };
let graph = doc.parent_of(n.id).and_then(|parent| doc.node(parent)).and_then(|parent| parent.extras().graph.as_deref());
graph.is_some_and(|spec| index < series_count(spec))
};
let mut seen = std::collections::HashSet::new();
let mut out = Vec::new();
for id in ids {
match doc.node(*id) {
Some(group) if is_series_group(group) && stands(group) => {
Some(group) if is_series_group(doc, group) && stands(group) => {
let marks: Vec<NodeId> =
group.children().into_iter().flatten().filter(|m| matches!(m.kind, NodeKind::Path { .. })).map(|m| m.id).collect();
if marks.is_empty() { out.push(*id) } else { out.extend(marks) }
@@ -180,27 +173,24 @@ pub(crate) fn series_group_marks_where(doc: &Document, ids: &[NodeId], stands: i
out
}
/// Copy the paint just applied to generated series art into that series' fill or stroke. Called
/// inside the same document edit, so the visible paint and future regeneration undo together.
/// Gradients, patterns and swatch links are kept as [`Paint`], not reduced to a solid colour.
/// Returns whether a series' captured fill or stroke changed.
pub(crate) fn capture_series_paints(doc: &mut Document, ids: &[NodeId], fill: bool) -> bool {
let members = series_members(doc);
if members.is_empty() {
return false;
/// Whether `n` is a graph series group: a child of a graph with one of the graph's series indexes ([`series_members`]).
/// It is looked up through its parent, with no pass over the document: the panels call this while they draw.
fn is_series_group(doc: &Document, n: &Node) -> bool {
let Some(index) = n.extras().series_index.and_then(|i| usize::try_from(i).ok()) else { return false };
let graph = doc.parent_of(n.id).and_then(|parent| doc.node(parent)).and_then(|parent| parent.extras().graph.as_deref());
graph.is_some_and(|spec| index < series_count(spec))
}
/// Whether `id` is the art of a graph design drawn in a graph series (Object › Graph › Column or Marker): an object
/// inside a series group other than a path directly in it. The graph draws that art again from the design.
pub(crate) fn is_design_art(doc: &Document, id: NodeId) -> bool {
let Some(chain) = doc.ancestry(id) else { return false };
let Some(at) = chain.iter().position(|a| doc.node(*a).is_some_and(|n| is_series_group(doc, n))) else { return false };
match chain.len() - at {
1 => false,
2 => doc.node(id).is_some_and(|n| !matches!(n.kind, NodeKind::Path { .. })),
_ => true,
}
let captures: Vec<_> = ids
.iter()
.filter_map(|id| {
// Only a series' own marks set its paint, not the art of a marker design.
let r = members.get(id).filter(|r| doc.parent_of(*id) == Some(r.group))?;
let node = doc.node(*id)?;
let paint = node.appearance.paint_at(None, fill)?.clone();
let width = (!fill).then(|| node.appearance.stroke_width());
Some((r.graph, r.index, fill, paint, width))
})
.collect();
store_series_paints(doc, captures)
}
/// The paint of the topmost visible fill (`true`) or stroke (`false`) of `node`, and a stroke's weight (0 without paint).
@@ -215,7 +205,8 @@ pub(crate) fn shown_paint(node: &Node, fill: bool) -> (Paint, f64) {
/// Copy the paint of the topmost visible fill (`true`) or stroke (`false`), and a stroke's weight, of generated series
/// art just edited (`edited`: each object with the kind of row, later entries replacing earlier ones) into that series'
/// fill or stroke; art left without such a row keeps no paint. A series that draws Filled Lines bands stores no stroke: its
/// fill or stroke; art left without such a row keeps no paint. Gradients, patterns and swatch links are kept as
/// [`Paint`], not reduced to a solid color. A series that draws Filled Lines bands stores no stroke: its
/// bands keep their own outline. Called inside the same document edit, so both undo together. Returns whether a series'
/// captured fill or stroke changed.
pub(crate) fn capture_series_top_paints(doc: &mut Document, edited: &[(NodeId, bool)]) -> bool {
@@ -268,13 +259,35 @@ fn series_draws_bands(g: &GraphSpec, i: usize) -> bool {
}
}
/// Store each capture (graph, series index, fill or stroke, paint, stroke weight) in its series. Returns whether a
/// series' captured fill or stroke changed.
fn store_series_paints(doc: &mut Document, captures: Vec<(NodeId, usize, bool, Paint, Option<f64>)>) -> bool {
/// Each graph's stored series paints before an edit's first capture into it. A later capture can put back what an earlier
/// one changed, so an edit compares what it leaves with these ([`settle_series_paints`]).
type SeriesPaintsBefore = HashMap<NodeId, Vec<vectorcraft_doc::SeriesPaint>>;
/// What an edit gave the characters of graph label `id`, applied to a character style ([`capture_label_text`]).
pub(crate) type LabelTextChange<'a> = &'a dyn Fn(NodeId, &mut CharStyle);
/// Does any graph in `before` now store other series paints than it did before the edit's first capture? Slots the
/// captures added past the old end and left empty are removed again, so they do not count as a change.
fn settle_series_paints(doc: &mut Document, before: SeriesPaintsBefore) -> bool {
let mut changed = false;
for (graph, was) in before {
let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue };
let empty = vectorcraft_doc::SeriesPaint::default();
if spec.series_paints.get(was.len()..).is_some_and(|added| added.iter().all(|slot| *slot == empty)) {
spec.series_paints.truncate(was.len());
}
changed |= spec.series_paints != was;
}
changed
}
/// Store each capture (graph, series index, fill or stroke, paint, stroke weight) in its series. Returns whether a
/// series' captured fill or stroke differs from what the graph stored before the first capture.
fn store_series_paints(doc: &mut Document, captures: Vec<(NodeId, usize, bool, Paint, Option<f64>)>) -> bool {
let mut before = SeriesPaintsBefore::new();
for (graph, index, fill, paint, width) in captures {
let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue };
let was = spec.series_paints.clone();
before.entry(graph).or_insert_with(|| spec.series_paints.clone());
// `index` is below the graph's series count (`series_members`).
if spec.series_paints.len() <= index {
spec.series_paints.resize(index + 1, vectorcraft_doc::SeriesPaint::default());
@@ -286,9 +299,8 @@ fn store_series_paints(doc: &mut Document, captures: Vec<(NodeId, usize, bool, P
slot.stroke = Some(paint);
slot.stroke_width = width;
}
changed |= spec.series_paints != was;
}
changed
settle_series_paints(doc, before)
}
/// Copy the opacity and blend mode of the topmost visible fill (`true`) or stroke (`false`) of generated series art just
@@ -315,14 +327,14 @@ pub(crate) fn capture_series_opacity(doc: &mut Document, edited: &[(NodeId, bool
Some((r.graph, r.index, *fill, (opacity < 1.0).then_some(opacity), (blend != BlendMode::Normal).then_some(blend)))
})
.collect();
let mut changed = false;
let mut before = SeriesPaintsBefore::new();
for (graph, index, fill, kept, blend) in captures {
let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue };
// A fully opaque Normal row on a series the graph keeps nothing for leaves the graph as it is.
if kept.is_none() && blend.is_none() && spec.series_paints.len() <= index {
continue;
}
let was = spec.series_paints.clone();
before.entry(graph).or_insert_with(|| spec.series_paints.clone());
// `index` is below the graph's series count (`series_members`).
if spec.series_paints.len() <= index {
spec.series_paints.resize(index + 1, vectorcraft_doc::SeriesPaint::default());
@@ -333,32 +345,30 @@ pub(crate) fn capture_series_opacity(doc: &mut Document, edited: &[(NodeId, bool
} else {
(slot.stroke_opacity, slot.stroke_blend) = (kept, blend);
}
changed |= spec.series_paints != was;
}
changed
settle_series_paints(doc, before)
}
/// Copy the object opacity and blend mode of generated series art just edited (`edited`) into that series' object opacity
/// and blend mode. Called inside the same document edit, so both undo together. Returns whether a series' kept object
/// opacity or blend mode changed.
pub(crate) fn capture_series_object_opacity(doc: &mut Document, edited: &[NodeId]) -> bool {
let mut changed = false;
let mut before = SeriesPaintsBefore::new();
for (graph, index, opacity, blend) in object_opacity_captures(doc, edited) {
let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue };
// A fully opaque Normal object on a series the graph keeps nothing for leaves the graph as it is.
if opacity.is_none() && blend.is_none() && spec.series_paints.len() <= index {
continue;
}
let was = spec.series_paints.clone();
before.entry(graph).or_insert_with(|| spec.series_paints.clone());
// `index` is below the graph's series count (`series_members`).
if spec.series_paints.len() <= index {
spec.series_paints.resize(index + 1, vectorcraft_doc::SeriesPaint::default());
}
let Some(slot) = spec.series_paints.get_mut(index) else { continue };
(slot.opacity, slot.blend) = (opacity, blend);
changed |= spec.series_paints != was;
}
changed
settle_series_paints(doc, before)
}
/// Would [`capture_series_object_opacity`] change what the graph stores for `edited`? Reads the document without copying
@@ -528,6 +538,149 @@ pub(crate) fn capture_label_paragraphs(
Ok(changed)
}
/// The object rows graph label `n` shows ([`vectorcraft_doc::LabelRows`]): the paint, weight, opacity and blend mode of
/// its topmost visible fill and stroke row, its object opacity and blend mode, and where those rows paint. A placed
/// gradient is kept without its placement, so it fits each label it lands on. `None`: no visible row, opaque and Normal.
pub(crate) fn label_rows(n: &Node) -> Option<vectorcraft_doc::LabelRows> {
use vectorcraft_doc::AppearanceItem;
let items = &n.appearance.items;
let top = |fill: bool| items.iter().rposition(|it| it.is_fill() == fill && it.visible());
let opacity = |o: f32| (o != 1.0).then_some(o);
let blend = |b: BlendMode| (b != BlendMode::Normal).then_some(b);
let unplaced = crate::cmd::gradient::unplaced;
let mut rows = vectorcraft_doc::SeriesPaint { opacity: opacity(n.opacity), blend: blend(n.blend), ..Default::default() };
let (fill, stroke) = (top(true), top(false));
if let Some(f) = fill.and_then(|i| items.get(i)) {
(rows.fill, rows.fill_opacity, rows.fill_blend) = (Some(unplaced(f.paint())), opacity(f.opacity()), blend(f.blend()));
}
if let Some(AppearanceItem::Stroke(st)) = stroke.and_then(|i| items.get(i)) {
(rows.stroke, rows.stroke_width, rows.stroke_opacity, rows.stroke_blend) =
(Some(unplaced(&st.paint)), Some(st.width), opacity(st.opacity), blend(st.blend));
}
let contents = n.appearance.contents_at();
let below = [fill, stroke].into_iter().flatten().filter(|i| *i < contents).fold(0u8, |k, _| k + 1);
let rows = vectorcraft_doc::LabelRows {
paint: rows,
below: (below > 0).then_some(below),
stroke_under: matches!((fill, stroke), (Some(f), Some(s)) if s < f),
};
(rows != vectorcraft_doc::LabelRows::default()).then_some(rows)
}
/// Keep the object rows ([`label_rows`]) of graph labels `ids` for every label of their kind, as
/// [`capture_label_styles`] keeps a character format, even when a label already showed them: the last label of a kind
/// in the order of `ids` sets what that kind keeps. Objects other than type are left out before the graph's labels are
/// looked up. Called inside the same edit, so they undo together. Returns whether kept rows changed.
pub(crate) fn capture_label_rows(doc: &mut Document, ids: &[NodeId]) -> bool {
store_label_rows(doc, ids, false)
}
/// Keep the object opacity and blend mode of graph labels `ids` for every label of their kind, as [`capture_label_rows`]
/// keeps them, and leave the kept fill and stroke rows as they are. Returns whether what the graph keeps changed.
pub(crate) fn capture_label_object_opacity(doc: &mut Document, ids: &[NodeId]) -> bool {
store_label_rows(doc, ids, true)
}
/// Would [`capture_label_object_opacity`] change what a graph keeps for labels `ids`? Reads the document without copying
/// it.
pub(crate) fn label_object_opacity_differs(doc: &Document, ids: &[NodeId]) -> bool {
label_rows_of(doc, ids).into_iter().any(|(graph, role, rows)| {
let Some(spec) = doc.node(graph).and_then(|n| n.extras().graph.as_deref()) else { return false };
let slot = match role {
Role::Value => &spec.label_styles.values_rows,
Role::Category => &spec.label_styles.categories_rows,
Role::Legend => &spec.label_styles.legend_rows,
};
*slot != kept_label_rows(slot, rows, true)
})
}
/// [`capture_label_rows`], or with `object_only` [`capture_label_object_opacity`].
fn store_label_rows(doc: &mut Document, ids: &[NodeId], object_only: bool) -> bool {
let mut changed = false;
for (graph, role, rows) in label_rows_of(doc, ids) {
let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue };
let slot = match role {
Role::Value => &mut spec.label_styles.values_rows,
Role::Category => &mut spec.label_styles.categories_rows,
Role::Legend => &mut spec.label_styles.legend_rows,
};
let kept = kept_label_rows(slot, rows, object_only);
changed |= *slot != kept;
*slot = kept;
}
changed
}
/// What a kind keeps after a label of it shows `rows`: those rows, or with `object_only` what `slot` keeps with the
/// label's object opacity and blend mode.
fn kept_label_rows(
slot: &Option<vectorcraft_doc::LabelRows>,
rows: Option<vectorcraft_doc::LabelRows>,
object_only: bool,
) -> Option<vectorcraft_doc::LabelRows> {
if !object_only {
return rows;
}
let mut kept = slot.clone().unwrap_or_default();
let shown = rows.unwrap_or_default();
(kept.paint.opacity, kept.paint.blend) = (shown.paint.opacity, shown.paint.blend);
(kept != vectorcraft_doc::LabelRows::default()).then_some(kept)
}
/// The graph, kind and rows ([`label_rows`]) of the last label of each kind among `ids`.
fn label_rows_of(doc: &Document, ids: &[NodeId]) -> Vec<(NodeId, Role, Option<vectorcraft_doc::LabelRows>)> {
// Graph labels are type: without type among `ids`, the graph's labels are not looked up.
if !ids.iter().any(|id| doc.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Text(_)))) {
return vec![];
}
let labels = graph_labels(doc);
let mut picked: Vec<(NodeId, Role, Option<vectorcraft_doc::LabelRows>)> = vec![];
for (&(g, role), n) in ids.iter().rev().filter_map(|id| Some((labels.get(id)?, doc.node(*id)?))) {
if !picked.iter().any(|(pg, pr, _)| *pg == g && *pr == role) {
picked.push((g, role, label_rows(n)));
}
}
picked
}
/// Keep what an edit gave all of the text of graph labels `ids` for every label of their kind: `change`, when given (the
/// character attributes the edit applied to label `id`), on the kind's kept style, as [`capture_label_styles`] keeps a
/// character format, and `para`, when given, as the kind's paragraph attributes ([`capture_label_paragraphs`]).
/// Attributes the edit did not apply stay as the kind keeps them. Returns whether a kept style or set of attributes
/// changed.
pub(crate) fn capture_label_text(
doc: &mut Document,
ids: &[NodeId],
change: Option<LabelTextChange>,
para: Option<&vectorcraft_doc::ParaStyle>,
) -> Result<bool> {
if (change.is_none() && para.is_none()) || !ids.iter().any(|id| doc.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Text(_)))) {
return Ok(false);
}
// The last label of each kind sets what the kind keeps.
let labels = graph_labels(doc);
let mut last: Vec<((NodeId, Role), NodeId)> = vec![];
for id in ids.iter().rev() {
if let Some(kind) = labels.get(id).filter(|k| !last.iter().any(|(l, _)| l == *k)) {
last.push((*kind, *id));
}
}
let mut changed = false;
for id in last.iter().rev().map(|(_, id)| id) {
if let Some(change) = change {
changed |= capture_label_styles(doc, &[*id], &|st| change(*id, st));
}
if let Some(para) = para {
changed |= capture_label_paragraphs(doc, &[*id], &|p| {
*p = para.clone();
Ok(())
})?;
}
}
Ok(changed)
}
/// The graphs and kinds of label among `ids`, each once.
fn label_kinds(doc: &Document, ids: &[NodeId]) -> Vec<(NodeId, Role)> {
if ids.is_empty() {
+910 -8
View File
@@ -373,8 +373,8 @@ fn removing_a_series_marks_fill_forgets_its_kept_opacity() {
assert_eq!(spec(&s, id).series_paints[0].fill_opacity, None);
}
/// An opacity set on a row added to a graph design's copy inside a series is not kept for the series: the graph draws
/// the design again from the design itself.
/// A row or a row opacity is not given to a graph design's copy inside a series, and the graph keeps no opacity for the
/// series: the graph draws the design again from the design itself.
#[test]
fn an_opacity_on_a_design_copy_in_a_series_is_not_kept() {
let mut s = Session::new();
@@ -384,11 +384,143 @@ fn an_opacity_on_a_design_copy_in_a_series_is_not_kept() {
s.execute("graph.column", &json!({"id": id.0, "design": "Square"})).unwrap();
let copy = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].clone();
assert!(matches!(copy.kind, NodeKind::Group { .. }));
s.execute("appearance.addFill", &json!({"ids": [copy.id.0]})).unwrap();
s.execute("transparency.set", &json!({"ids": [copy.id.0], "item": 0, "opacity": 40})).unwrap();
assert!(s.execute("appearance.addFill", &json!({"ids": [copy.id.0]})).is_err());
assert!(s.execute("transparency.set", &json!({"ids": [copy.id.0], "item": 0, "opacity": 40})).is_err());
assert!(spec(&s, id).series_paints.first().is_none_or(|p| p.fill_opacity.is_none()));
}
/// A column graph whose series 0 draws the design "Square": the graph, series 0's first copy of the design, a path
/// inside that copy, and the first mark of series 1, drawn plain.
fn design_art() -> (Session, NodeId, NodeId, NodeId, NodeId) {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
save_design(&mut s, "Square");
s.execute("graph.column", &json!({"id": id.0, "seriesIndexes": [0], "design": "Square"})).unwrap();
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let copy = series(&n, 0).children().unwrap()[0].clone();
assert!(matches!(copy.kind, NodeKind::Group { .. }));
let mut art = vec![];
copy.walk(&mut |m| {
if matches!(m.kind, NodeKind::Path { .. }) {
art.push(m.id);
}
});
let mark = series(&n, 1).children().unwrap()[0].clone();
assert!(matches!(mark.kind, NodeKind::Path { .. }));
(s, id, copy.id, art[0], mark.id)
}
/// `cmd` with `p` is refused as an edit of graph design art, and the document keeps its undo history.
fn refused_on_design_art(s: &mut Session, cmd: &str, p: serde_json::Value) {
let steps = s.doc().unwrap().history.undo.len();
let e = s.execute(cmd, &p).unwrap_err().to_string();
assert!(e.contains("drawn again from the design"), "{cmd}: {e}");
assert_eq!(s.doc().unwrap().history.undo.len(), steps, "{cmd}");
}
/// The commands that give rows, effects or graphic styles refuse the art of a graph design inside a series, given by
/// id (the design's copy or a path inside it) or selected: the graph draws that art again from the design.
#[test]
fn appearance_commands_refuse_a_graph_designs_art_in_a_series() {
let (mut s, _, copy, art, _) = design_art();
let (rect, _) = blue_style(&mut s, "Blue", false);
let graphic = s.execute("shape.rectangle", &json!({"x": 700, "y": 700, "width": 10, "height": 10})).unwrap()["id"].clone();
s.execute("threeD.addGraphic", &json!({"name": "Mark", "ids": [graphic]})).unwrap();
let fill = top_row(&s, art, true);
for ids in [json!([copy.0]), json!([art.0])] {
refused_on_design_art(
&mut s,
"graphicStyle.addFromLibrary",
json!({"library": "painted-sketched", "name": "Wet Ink", "apply": true, "ids": ids}),
);
for cmd in ["appearance.addFill", "appearance.addStroke", "appearance.clear", "appearance.reduceToBasic", "appearance.showAllHidden"] {
refused_on_design_art(&mut s, cmd, json!({"ids": ids}));
}
refused_on_design_art(&mut s, "appearance.moveItem", json!({"ids": ids, "from": "contents", "to": 0}));
refused_on_design_art(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow", "ids": ids}));
refused_on_design_art(&mut s, "appearance.addEffect", json!({"effect": "stylize.dropShadow", "ids": ids}));
refused_on_design_art(&mut s, "effect.apply", json!({"effect": "threeD.extrude", "item": null, "ids": ids}));
refused_on_design_art(&mut s, "effect.move", json!({"ids": ids, "from": 0, "to": 0}));
refused_on_design_art(&mut s, "effect.remove", json!({"ids": ids, "index": 0}));
refused_on_design_art(&mut s, "threeD.mapGraphic", json!({"ids": ids, "symbol": "Mark"}));
refused_on_design_art(&mut s, "graphicStyle.apply", json!({"name": "Blue", "ids": ids}));
}
for cmd in ["appearance.removeItem", "appearance.duplicateItem"] {
refused_on_design_art(&mut s, cmd, json!({"ids": [art.0], "index": fill}));
}
refused_on_design_art(&mut s, "appearance.setItem", json!({"ids": [art.0], "index": fill, "color": "#00ff00"}));
refused_on_design_art(&mut s, "appearance.moveItem", json!({"ids": [art.0], "from": fill, "to": 0}));
refused_on_design_art(&mut s, "transparency.set", json!({"ids": [art.0], "item": fill, "opacity": 40}));
refused_on_design_art(&mut s, "appearance.copyFrom", json!({"source": rect.0, "ids": [art.0]}));
refused_on_design_art(&mut s, "appearance.transfer", json!({"source": rect.0, "target": art.0, "copy": true}));
refused_on_design_art(&mut s, "appearance.transfer", json!({"source": art.0, "target": rect.0}));
// Selected, and through the contents of the design's copy.
s.execute("select.set", &json!({"ids": [art.0]})).unwrap();
refused_on_design_art(&mut s, "appearance.addFill", json!({}));
refused_on_design_art(&mut s, "graphicStyle.addFromLibrary", json!({"library": "painted-sketched", "name": "Wet Ink", "apply": true}));
refused_on_design_art(&mut s, "appearance.addStroke", json!({"ids": [copy.0], "target": "contents"}));
// A Pathfinder effect does not group the selected paths of the design's copy.
let mut paths = vec![];
s.doc().unwrap().doc.node(copy).unwrap().walk(&mut |m| {
if matches!(m.kind, NodeKind::Path { .. }) {
paths.push(m.id.0);
}
});
assert_eq!(paths.len(), 2);
s.execute("select.set", &json!({"ids": paths})).unwrap();
refused_on_design_art(&mut s, "effect.apply", json!({"effect": "pathfinder.add"}));
// Read as a source, the art gives its look.
s.execute("appearance.transfer", &json!({"source": art.0, "target": rect.0, "copy": true})).unwrap();
}
/// Given the art of a graph design in a series with other objects, the commands edit the other objects only.
#[test]
fn appearance_commands_edit_the_objects_given_with_a_graph_designs_art() {
let (mut s, id, copy, art, mark) = design_art();
let (rect, _) = blue_style(&mut s, "Blue", false);
let rows = |s: &Session, id: NodeId| s.doc().unwrap().doc.node(id).unwrap().appearance.clone();
let (copy_was, art_was, mark_was) = (rows(&s, copy), rows(&s, art), rows(&s, mark));
s.execute("appearance.addFill", &json!({"ids": [copy.0, mark.0]})).unwrap();
assert_eq!((rows(&s, copy), rows(&s, art)), (copy_was.clone(), art_was.clone()));
assert_eq!(rows(&s, mark).items.len(), mark_was.items.len() + 1);
s.execute("edit.undo", &json!({})).unwrap();
s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [art.0, mark.0]})).unwrap();
assert_eq!(rows(&s, art), art_was);
assert_eq!(rows(&s, mark).effects.len(), 1);
s.execute("edit.undo", &json!({})).unwrap();
s.execute("appearance.copyFrom", &json!({"source": rect.0, "ids": [art.0, mark.0]})).unwrap();
assert_eq!(rows(&s, art), art_was);
assert_ne!(rows(&s, mark), mark_was);
s.execute("edit.undo", &json!({})).unwrap();
// The graph's contents: series 1's marks take the fill, the design's art does not.
s.execute("appearance.addFill", &json!({"ids": [id.0], "target": "contents"})).unwrap();
assert_eq!((rows(&s, copy), rows(&s, art)), (copy_was, art_was));
assert_eq!(rows(&s, mark).items.len(), mark_was.items.len() + 1);
}
/// The paint commands leave the art of a graph design in a series out, a copy of the design given by id with an item
/// included.
#[test]
fn paint_targets_leave_a_graph_designs_copy_out() {
let (s, _, copy, art, mark) = design_art();
let d = &s.doc().unwrap().doc;
assert_eq!(super::paint_targets(d, &[copy, art, mark]), [mark]);
}
/// The art of a marker design in a series is refused too.
#[test]
fn effect_commands_refuse_a_marker_designs_art() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
save_design(&mut s, "Pill");
let id = graph(&mut s, "line");
s.execute("graph.marker", &json!({"id": id.0, "seriesIndexes": [1], "design": "Pill"})).unwrap();
let marker = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[1].clone();
assert!(matches!(marker.kind, NodeKind::Group { .. }), "the design's art");
refused_on_design_art(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow", "ids": [marker.id.0]}));
}
/// A series' kept opacity goes on its generated marks only: a column design made of one path keeps its own opacity.
#[test]
fn a_column_design_keeps_its_own_opacity_under_a_series_opacity() {
@@ -572,6 +704,124 @@ fn an_object_opacity_a_series_mark_already_shows_replaces_the_kept_opacity() {
assert!(spec(&s, id).series_paints.iter().all(|p| p.opacity.is_none()));
}
/// Randomize Opacity on marks of one series stores the opacity of the frontmost of them on the graph in the same undo
/// step, and every mark of the series has it when the graph is drawn again.
#[test]
fn a_random_opacity_on_series_marks_survives_regeneration() {
let (mut s, id, _, marks) = column_marks();
s.execute("addon.arrange.randomizeOpacity", &json!({"ids": [marks[1].0, marks[0].0], "min": 30, "max": 60, "seed": 7})).unwrap();
let front = s.doc().unwrap().doc.node(marks[1]).unwrap().opacity;
assert!(front < 1.0);
assert_eq!(spec(&s, id).series_paints[0].opacity, Some(front));
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap();
assert!(transparency_of(&s, id, 0).iter().all(|t| t.0 == front), "{:?}", transparency_of(&s, id, 0));
assert!(transparency_of(&s, id, 1).iter().all(|t| t.0 == 1.0), "{:?}", transparency_of(&s, id, 1));
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert!(spec(&s, id).series_paints.is_empty(), "undo takes back the opacities and what the graph stored");
}
/// Sort Objects giving series marks new opacities stores the opacity of the frontmost of them on the graph, in place of
/// the one another mark left there.
#[test]
fn an_opacity_sort_objects_gives_series_marks_survives_regeneration() {
let (mut s, id, _, marks) = column_marks();
s.execute("object.setProps", &json!({"id": marks[2].0, "opacity": 30})).unwrap();
assert_eq!(spec(&s, id).series_paints[0].opacity, Some(0.3));
// From left to right the three columns get 30%, 65% and 100%: the frontmost, the rightmost, is fully opaque.
let ids = [marks[0].0, marks[1].0, marks[2].0];
s.execute("addon.arrange.sort", &json!({"ids": ids, "by": "x", "apply": "opacity", "distribute": true})).unwrap();
let opacities: Vec<f32> = marks[..3].iter().map(|m| s.doc().unwrap().doc.node(*m).unwrap().opacity).collect();
assert_eq!(opacities, [0.3, 0.65, 1.0]);
assert_eq!(spec(&s, id).series_paints[0].opacity, None);
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap();
assert!(transparency_of(&s, id, 0).iter().all(|t| t.0 == 1.0), "{:?}", transparency_of(&s, id, 0));
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(spec(&s, id).series_paints[0].opacity, Some(0.3), "undo takes back the sort and what the graph stored");
}
/// Sort Objects stores the opacity of the frontmost series mark whose opacity it changes: a mark in front that keeps its
/// opacity leaves the stored value to the marks it changed.
#[test]
fn an_opacity_sort_objects_stores_the_frontmost_mark_it_changes() {
let (mut s, id, _, marks) = column_marks();
s.execute("object.setProps", &json!({"id": marks[0].0, "opacity": 60})).unwrap();
s.execute("object.setProps", &json!({"id": marks[1].0, "opacity": 30})).unwrap();
assert_eq!(spec(&s, id).series_paints[0].opacity, Some(0.3));
// From left to right the columns trade 60%, 30% and 100% for 30%, 60% and 100%: the frontmost keeps its 100%.
let ids = [marks[0].0, marks[1].0, marks[2].0];
s.execute("addon.arrange.sort", &json!({"ids": ids, "by": "x", "apply": "opacity"})).unwrap();
let opacities: Vec<f32> = marks[..3].iter().map(|m| s.doc().unwrap().doc.node(*m).unwrap().opacity).collect();
assert_eq!(opacities, [0.3, 0.6, 1.0]);
assert_eq!(spec(&s, id).series_paints[0].opacity, Some(0.6));
}
/// An object opacity, blend mode, fill, stroke and weight that Global Edit gives a mark of another series from the mark
/// that was edited are stored on the graph for that series in the same undo step, and every mark of the series has them
/// when the graph is drawn again.
#[test]
fn what_global_edit_gives_series_marks_survives_regeneration() {
let (mut s, id, _, _) = column_marks();
// Equal values: the first column of each series has the same shape.
s.execute("graph.setData", &json!({"rows": [[3, 3], [5, 2]]})).unwrap();
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let (first, other) = (series(&n, 0).children().unwrap()[0].id, series(&n, 1).children().unwrap()[0].id);
s.execute("object.isolate", &json!({"id": id.0})).unwrap();
s.execute("select.set", &json!({"ids": [first.0]})).unwrap();
let r = s.execute("select.globalEdit", &json!({})).unwrap();
assert!(r["ids"].as_array().unwrap().contains(&json!(other.0)), "{r}");
let steps = s.doc().unwrap().history.undo.len();
s.execute("transparency.set", &json!({"ids": [first.0], "opacity": 40, "blend": "multiply"})).unwrap();
s.execute("paint.setFill", &json!({"ids": [first.0], "color": "#e8573f"})).unwrap();
s.execute("paint.setStroke", &json!({"ids": [first.0], "color": "#1f4e9c"})).unwrap();
s.execute("stroke.set", &json!({"ids": [first.0], "weight": 5})).unwrap();
let mark = s.doc().unwrap().doc.node(other).unwrap().clone();
assert_eq!((mark.opacity, mark.blend), (0.4, BlendMode::Multiply), "Global Edit reaches the other series' mark");
let kept = spec(&s, id).series_paints[1].clone();
assert_eq!((kept.opacity, kept.blend), (Some(0.4), Some(BlendMode::Multiply)));
assert_eq!(kept.fill, Some(mark.appearance.fill_paint()));
assert_eq!((kept.stroke, kept.stroke_width), (Some(mark.appearance.stroke_paint()), Some(5.0)));
s.execute("object.exitIsolation", &json!({})).unwrap();
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap();
let normal = BlendMode::Normal;
assert!(transparency_of(&s, id, 1).iter().all(|t| *t == (0.4, BlendMode::Multiply, normal, normal)), "{:?}", transparency_of(&s, id, 1));
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
assert!(series(&n, 1).children().unwrap().iter().all(|m| m.appearance.fill_paint() == mark.appearance.fill_paint()));
while s.doc().unwrap().history.undo.len() > steps {
s.execute("edit.undo", &json!({})).unwrap();
}
assert!(spec(&s, id).series_paints.iter().all(|p| *p == vectorcraft_doc::SeriesPaint::default()), "{:?}", spec(&s, id).series_paints);
}
/// With a whole series group picked and Global Edit on, the other series groups of the same form take the edit of its
/// marks, and the graph stores it for their series too.
#[test]
fn what_global_edit_gives_the_marks_of_another_series_group_survives_regeneration() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
// Series without names draw no legend, so each series group holds only its columns, laid out like the other's.
let csv = ",,\nQ1,3,3\nQ2,5,5";
let made = s.execute("graph.create", &json!({"type": "column", "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap();
let id = NodeId(made["id"].as_u64().unwrap());
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let (first, other) = (series(&n, 0).clone(), series(&n, 1).id);
s.execute("object.isolate", &json!({"id": id.0})).unwrap();
s.execute("select.set", &json!({"ids": [first.id.0]})).unwrap();
let r = s.execute("select.globalEdit", &json!({})).unwrap();
assert!(r["ids"].as_array().unwrap().contains(&json!(other.0)), "{r}");
let marks: Vec<u64> = first.children().unwrap().iter().map(|m| m.id.0).collect();
s.execute("paint.setFill", &json!({"ids": marks, "color": "#e8573f"})).unwrap();
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let given = series(&n, 1).children().unwrap()[0].appearance.fill_paint();
assert_eq!(given, series(&n, 0).children().unwrap()[0].appearance.fill_paint(), "Global Edit reaches the other group");
assert_eq!(spec(&s, id).series_paints[1].fill, Some(given.clone()));
s.execute("object.exitIsolation", &json!({})).unwrap();
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap();
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
assert!(series(&n, 1).children().unwrap().iter().all(|m| m.appearance.fill_paint() == given));
}
/// A blend mode set on the top fill or stroke row of one series mark is kept by the graph and drawn on every mark of
/// the series again; Normal forgets it.
#[test]
@@ -1381,7 +1631,7 @@ fn removing_a_filled_line_bands_stroke_leaves_its_color() {
let (mut s, id, band) = band_graph();
let was = series_parts(&s, id, 0)[0].appearance.fill_paint();
s.execute("paint.setStroke", &json!({"ids": [band.0], "none": true})).unwrap();
assert_eq!(spec(&s, id).series_paints[0].stroke, Some(Paint::None));
assert!(spec(&s, id).series_paints.first().is_none_or(|p| p.stroke.is_none()));
assert_eq!(redrawn_band(&mut s, id).0, was);
}
@@ -1627,8 +1877,8 @@ fn a_paint_on_a_design_copy_or_the_other_kind_of_row_is_not_kept() {
s.execute("graph.column", &json!({"id": id.0, "design": "Square"})).unwrap();
let copy = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].clone();
assert!(matches!(copy.kind, NodeKind::Group { .. }));
s.execute("appearance.addFill", &json!({"ids": [copy.id.0]})).unwrap();
s.execute("appearance.setItem", &json!({"ids": [copy.id.0], "index": 0, "color": "#0000ff"})).unwrap();
assert!(s.execute("appearance.addFill", &json!({"ids": [copy.id.0]})).is_err());
assert!(s.execute("appearance.setItem", &json!({"ids": [copy.id.0], "index": 0, "color": "#0000ff"})).is_err());
assert!(spec(&s, id).series_paints.first().is_none_or(|p| p.fill.is_none()));
// A path inside the design's copy is a member of the series too, and its paint is not kept either.
let copy = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].clone();
@@ -1639,7 +1889,7 @@ fn a_paint_on_a_design_copy_or_the_other_kind_of_row_is_not_kept() {
}
});
let art = paths[0];
s.execute("appearance.setItem", &json!({"ids": [art.0], "index": top_row(&s, art, true), "color": "#00ff00"})).unwrap();
assert!(s.execute("appearance.setItem", &json!({"ids": [art.0], "index": top_row(&s, art, true), "color": "#00ff00"})).is_err());
assert!(spec(&s, id).series_paints.first().is_none_or(|p| p.fill.is_none()));
let mut s = Session::new();
@@ -2209,6 +2459,257 @@ fn swapping_onto_a_hidden_top_fill_leaves_the_stored_fill() {
assert_eq!(spec(&s, id).series_paints[0].fill, red);
}
/// A column graph whose series 0 stores a red fill and a red 2 pt stroke set on its second mark, while the topmost fill
/// (over a visible one) and the green stroke of its first mark are hidden: the graph and the first mark.
fn hidden_top_rows() -> (Session, NodeId, NodeId) {
let (mut s, id, _, marks) = column_marks();
s.execute("appearance.addFill", &json!({"ids": [marks[0].0]})).unwrap();
let (_, upper) = fill_rows(&s, marks[0]);
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": upper, "visible": false})).unwrap();
let stroke = top_row(&s, marks[0], false);
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#00ff00", "weight": 3, "visible": false})).unwrap();
s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": top_row(&s, marks[1], true), "color": "#ff0000"})).unwrap();
s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": top_row(&s, marks[1], false), "color": "#ff0000", "weight": 2})).unwrap();
(s, id, marks[0])
}
/// `paint.setFill`, `paint.setStroke`, `paint.none`, Apply Fill to Stroke and Apply Stroke to Fill write the topmost row
/// of the kind even when it is hidden. The mark shows what it showed before, and the series keeps the paint it stored.
#[test]
fn a_paint_set_on_a_hidden_top_row_leaves_the_stored_paint() {
let cases = [
("paint.setFill", json!({"color": "#0000ff"})),
("paint.setFill", json!({"none": true})),
("paint.setStroke", json!({"color": "#0000ff"})),
("paint.setStroke", json!({"none": true})),
("paint.none", json!({})),
("addon.color.fillToStroke", json!({})),
("addon.color.strokeToFill", json!({})),
];
every_case(cases, |(cmd, params)| {
let (mut s, id, mark) = hidden_top_rows();
let was = spec(&s, id).series_paints;
let red = was[0].fill.clone().unwrap();
assert_eq!((was[0].stroke.clone(), was[0].stroke_width), (Some(red.clone()), Some(2.0)));
let mut p = params.clone();
if *cmd == "paint.none" {
s.execute("select.set", &json!({"ids": [mark.0]})).unwrap();
} else {
p["ids"] = json!([mark.0]);
}
let before = s.doc().unwrap().doc.node(mark).unwrap().clone();
s.execute(cmd, &p).unwrap();
assert_ne!(*s.doc().unwrap().doc.node(mark).unwrap(), before, "the command wrote the hidden row");
assert_eq!(spec(&s, id).series_paints, was);
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap();
assert!(marks_of(&s, id, 0).iter().all(|m| *m == (red.clone(), red.clone(), 2.0)), "{:?}", marks_of(&s, id, 0));
});
}
/// `paint.setFill` with `item` on the visible fill under a hidden topmost one changes what the mark shows, and the series
/// stores it. On a fill under a visible topmost one, the series keeps the paint of the topmost one.
#[test]
fn a_paint_set_on_a_row_under_the_top_one_is_stored_only_when_it_shows() {
let (mut s, id, mark) = hidden_top_rows();
let (lower, _) = fill_rows(&s, mark);
s.execute("paint.setFill", &json!({"ids": [mark.0], "item": lower, "color": "#0000ff"})).unwrap();
let blue = s.doc().unwrap().doc.node(mark).unwrap().appearance.paint_at(Some(lower), true).cloned();
assert!(blue.as_ref().is_some_and(|p| !p.is_none()));
assert_eq!(spec(&s, id).series_paints[0].fill, blue);
s.execute("edit.undo", &json!({})).unwrap();
assert_ne!(spec(&s, id).series_paints[0].fill, blue);
// The first mark has a green fill over another one, and the series stores the red fill of the second mark.
let (mut s, id, _, marks) = column_marks();
s.execute("appearance.addFill", &json!({"ids": [marks[0].0]})).unwrap();
let (lower, upper) = fill_rows(&s, marks[0]);
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": upper, "color": "#00ff00"})).unwrap();
s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": top_row(&s, marks[1], true), "color": "#ff0000"})).unwrap();
let red = Some(fill_of(&s, marks[1]));
assert_ne!(red, Some(fill_of(&s, marks[0])));
assert_eq!(spec(&s, id).series_paints[0].fill, red);
s.execute("paint.setFill", &json!({"ids": [marks[0].0], "item": lower, "color": "#0000ff"})).unwrap();
assert_ne!(s.doc().unwrap().doc.node(marks[0]).unwrap().appearance.paint_at(Some(lower), true), red.as_ref());
assert_eq!(spec(&s, id).series_paints[0].fill, red);
s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap();
assert!(marks_of(&s, id, 0).iter().all(|m| Some(&m.0) == red.as_ref()), "{:?}", marks_of(&s, id, 0));
}
/// A library gradient applied to a selected mark goes to the topmost fill as `paint.setFill` puts it there: the series
/// stores it from a visible row, and keeps its stored paint when that row is hidden.
#[test]
fn a_library_gradient_on_a_series_mark_is_stored_only_from_a_row_it_shows() {
for hidden in [false, true] {
let (mut s, id, mark) = hidden_top_rows();
if !hidden {
s.execute("appearance.showAllHidden", &json!({"ids": [mark.0]})).unwrap();
}
let lib = s.execute("library.create", &json!({})).unwrap()["id"].as_str().unwrap().to_string();
let rect = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 40, "height": 20})).unwrap()["id"].clone();
let stops = json!([{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]);
s.execute("paint.setFill", &json!({"gradient": {"kind": "linear", "stops": stops}, "ids": [rect]})).unwrap();
s.execute("select.set", &json!({"ids": [rect]})).unwrap();
s.execute("library.add", &json!({"library": lib, "kind": "fillGradient"})).unwrap();
s.execute("select.set", &json!({"ids": [mark.0]})).unwrap();
let was = spec(&s, id).series_paints;
s.execute("library.use", &json!({"library": lib, "kind": "fillGradient", "item": "Gradient"})).unwrap();
let fill = fill_of(&s, mark);
assert!(matches!(fill, Paint::Gradient(_)), "{hidden}");
let now = spec(&s, id).series_paints;
if hidden {
assert_eq!(now, was);
} else {
assert_eq!(now[0].fill, Some(fill));
}
}
}
/// A mark's fill and stroke rows already have the paint a command writes, while the series stores the paint of another
/// mark. `paint.setFill`, `paint.setStroke`, Apply Fill to Stroke and Apply Stroke to Fill leave the mark as it was, and
/// the series stores the mark's paint for the kind of row the command wrote, and only for that kind.
#[test]
fn writing_the_paint_a_mark_already_has_stores_it_for_the_series() {
let cases = [
("paint.setFill", json!({"color": "#ff0000"}), true),
("paint.setStroke", json!({"color": "#ff0000"}), false),
("addon.color.fillToStroke", json!({}), false),
("addon.color.strokeToFill", json!({}), true),
("addon.color.fillToStroke", json!({"skipNone": true}), false),
];
every_case(cases, |(cmd, params, fill)| {
let (mut s, id, _, marks) = column_marks();
for (mark, hex, weight) in [(marks[0], "#ff0000", 3), (marks[1], "#0000ff", 2)] {
s.execute("appearance.setItem", &json!({"ids": [mark.0], "index": top_row(&s, mark, true), "color": hex})).unwrap();
s.execute("appearance.setItem", &json!({"ids": [mark.0], "index": top_row(&s, mark, false), "color": hex, "weight": weight})).unwrap();
}
let was = spec(&s, id).series_paints;
let (red, blue) = (fill_of(&s, marks[0]), fill_of(&s, marks[1]));
assert_eq!((was[0].fill.clone(), was[0].stroke.clone(), was[0].stroke_width), (Some(blue.clone()), Some(blue.clone()), Some(2.0)));
let before = s.doc().unwrap().doc.node(marks[0]).unwrap().clone();
let mut p = params.clone();
p["ids"] = json!([marks[0].0]);
s.execute(cmd, &p).unwrap();
assert_eq!(*s.doc().unwrap().doc.node(marks[0]).unwrap(), before, "the mark is as it was");
let now = spec(&s, id).series_paints;
let expected = if *fill { (Some(red), Some(blue), Some(2.0)) } else { (Some(blue), Some(red), Some(3.0)) };
assert_eq!((now[0].fill.clone(), now[0].stroke.clone(), now[0].stroke_width), expected);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(spec(&s, id).series_paints, was);
});
}
/// Apply Fill to Stroke and Apply Stroke to Fill on a mark store the paint they give its topmost visible row, and every
/// mark of the series has it after the graph is drawn again. With `skipNone`, a mark without the source paint keeps what
/// the series stores.
#[test]
fn applying_one_paint_kind_to_the_other_on_a_series_mark_is_kept() {
let (mut s, id, marks) = stored_red_stroke();
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": top_row(&s, marks[0], true), "color": "#0000ff"})).unwrap();
let blue = fill_of(&s, marks[0]);
s.execute("addon.color.fillToStroke", &json!({"ids": [marks[0].0]})).unwrap();
let kept = spec(&s, id).series_paints;
assert_eq!((kept[0].fill.clone(), kept[0].stroke.clone()), (Some(blue.clone()), Some(blue.clone())));
s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap();
assert!(marks_of(&s, id, 0).iter().all(|m| (&m.0, &m.1) == (&blue, &blue)), "{:?}", marks_of(&s, id, 0));
let (mut s, id, marks) = stored_red_stroke();
let red = spec(&s, id).series_paints[0].stroke.clone();
s.execute("addon.color.strokeToFill", &json!({"ids": [marks[1].0]})).unwrap();
assert_eq!(spec(&s, id).series_paints[0].fill, red);
s.execute("paint.setFill", &json!({"ids": [marks[0].0], "none": true})).unwrap();
s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": top_row(&s, marks[1], true), "color": "#0000ff"})).unwrap();
let was = spec(&s, id).series_paints;
s.execute("addon.color.fillToStroke", &json!({"ids": [marks[0].0], "skipNone": true})).unwrap();
assert_eq!(spec(&s, id).series_paints, was);
}
/// When one paint command writes several marks of a series with different paints, the frontmost mark sets what the
/// graph stores, whatever order the ids are given in and whatever the marks' ids are.
#[test]
fn a_paint_command_on_several_marks_of_a_series_stores_the_frontmost_ones_paint() {
every_case([[0, 1, 2], [2, 1, 0], [1, 2, 0]], |order| {
let (mut s, id, _, marks) = column_marks();
// The mark made last goes to the back, so the marks' ids are not in paint order.
s.execute("select.set", &json!({"ids": [marks[2].0]})).unwrap();
s.execute("object.arrange.sendToBack", &json!({})).unwrap();
let drawn: Vec<NodeId> = series_parts(&s, id, 0).iter().map(|m| m.id).filter(|m| marks[..3].contains(m)).collect();
assert_eq!(drawn, [marks[2], marks[0], marks[1]]);
for (mark, hex) in [(marks[0], "#ff0000"), (marks[1], "#00ff00"), (marks[2], "#0000ff")] {
s.execute("appearance.setItem", &json!({"ids": [mark.0], "index": top_row(&s, mark, true), "color": hex})).unwrap();
}
s.execute("addon.color.fillToStroke", &json!({"ids": order.map(|i| marks[i].0)})).unwrap();
assert_eq!(spec(&s, id).series_paints[0].stroke, Some(fill_of(&s, marks[1])), "{order:?}");
});
// The frontmost mark loses its stroke and a mark under it takes one: the series stores the frontmost mark's.
let (mut s, id, marks) = stored_red_stroke();
s.execute("appearance.setItem", &json!({"ids": [marks[2].0], "index": top_row(&s, marks[2], false), "color": "#00ff00", "weight": 2})).unwrap();
s.execute("paint.setFill", &json!({"ids": [marks[2].0], "none": true})).unwrap();
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": top_row(&s, marks[0], true), "color": "#0000ff"})).unwrap();
s.execute("addon.color.fillToStroke", &json!({"ids": [marks[0].0, marks[2].0]})).unwrap();
assert!(!s.doc().unwrap().doc.node(marks[0]).unwrap().appearance.stroke_paint().is_none());
assert_eq!(spec(&s, id).series_paints[0].stroke, Some(Paint::None));
}
/// Apply Fill to Stroke on two marks whose strokes already have their fills, while the series stores the front mark's
/// stroke: the back mark's stroke is taken first and the front mark's after it, so the graph ends up storing what it
/// stored before. The document and its undo history stay as they are.
#[test]
fn a_paint_command_that_leaves_a_series_stored_paint_as_it_was_records_no_undo_step() {
let (mut s, id, _, marks) = column_marks();
for (mark, hex, weight) in [(marks[0], "#ff0000", 3), (marks[1], "#0000ff", 2)] {
s.execute("appearance.setItem", &json!({"ids": [mark.0], "index": top_row(&s, mark, true), "color": hex})).unwrap();
s.execute("appearance.setItem", &json!({"ids": [mark.0], "index": top_row(&s, mark, false), "color": hex, "weight": weight})).unwrap();
}
let drawn: Vec<NodeId> = series_parts(&s, id, 0).iter().map(|m| m.id).filter(|m| marks[..2].contains(m)).collect();
assert_eq!(drawn, [marks[0], marks[1]], "the second mark is in front");
let was = s.doc().unwrap().doc.clone();
let blue = fill_of(&s, marks[1]);
assert_eq!(spec(&s, id).series_paints[0].stroke, Some(blue));
let undo = s.doc().unwrap().history.undo.len();
s.execute("addon.color.fillToStroke", &json!({"ids": [marks[1].0, marks[0].0]})).unwrap();
assert_eq!(s.doc().unwrap().doc, was);
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
}
/// A stroke set on a Filled Lines band with `paint.setStroke` or Apply Fill to Stroke is not stored: the band keeps its
/// color and its thin black outline when the graph is drawn again. A fill set with `paint.setFill` is stored.
#[test]
fn a_paint_command_stroke_on_a_filled_line_band_does_not_repaint_it() {
every_case([("paint.setStroke", json!({"color": "#ff0000"})), ("addon.color.fillToStroke", json!({}))], |(cmd, params)| {
let (mut s, id, band) = band_graph();
let was = series_parts(&s, id, 0)[0].appearance.fill_paint();
let mut p = params.clone();
p["ids"] = json!([band.0]);
s.execute(cmd, &p).unwrap();
assert!(!s.doc().unwrap().doc.node(band).unwrap().appearance.stroke_paint().is_none());
assert!(spec(&s, id).series_paints.first().is_none_or(|p| p.stroke.is_none()));
assert_eq!(redrawn_band(&mut s, id), (was, Paint::solid(vectorcraft_color::Color::BLACK), 0.25));
});
let (mut s, id, band) = band_graph();
s.execute("paint.setFill", &json!({"ids": [band.0], "color": "#0000ff"})).unwrap();
let blue = s.doc().unwrap().doc.node(band).unwrap().appearance.fill_paint();
assert_eq!(redrawn_band(&mut s, id), (blue, Paint::solid(vectorcraft_color::Color::BLACK), 0.25));
}
/// `paint.setStroke` with None on a marker that shows no stroke changes nothing, and the line keeps the stroke the series
/// stores. On the line itself it stores the empty stroke.
#[test]
fn removing_the_stroke_of_a_mark_without_one_leaves_the_stored_stroke() {
let (mut s, id, line, marker) = line_and_marker();
s.execute("paint.setStroke", &json!({"ids": [line.0], "color": "#ff0000"})).unwrap();
let red = s.doc().unwrap().doc.node(line).unwrap().appearance.stroke_paint();
assert_eq!(spec(&s, id).series_paints[0].stroke, Some(red.clone()));
s.execute("paint.setStroke", &json!({"ids": [marker.0], "none": true})).unwrap();
assert_eq!(spec(&s, id).series_paints[0].stroke, Some(red.clone()));
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap();
assert_eq!(line_parts(&s, id).0.appearance.stroke_paint(), red);
let line = line_parts(&s, id).0.id;
s.execute("paint.setStroke", &json!({"ids": [line.0], "none": true})).unwrap();
assert_eq!(spec(&s, id).series_paints[0].stroke, Some(Paint::None));
}
/// Run `case` on each of `cases`, and fail naming every case that failed, not only the first one.
fn every_case<T: std::fmt::Debug>(cases: impl IntoIterator<Item = T>, case: impl Fn(&T)) {
let failed: Vec<T> = cases.into_iter().filter(|c| std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| case(c))).is_err()).collect();
@@ -4828,6 +5329,407 @@ fn formatting_one_label_keeps_that_kind_of_label() {
assert!(serde_json::to_value(GraphSpec::default()).unwrap().get("labelStyles").is_none());
}
/// 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)>;
fn redrawn_label_rows(s: &mut Session, id: NodeId) -> Vec<(String, RowLooks, f32)> {
s.execute("graph.setData", &json!({"id": id.0, "csv": ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"})).unwrap();
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let mut out = vec![];
for g in ["Axes", "Legend"] {
for c in group(&n, g).children().unwrap() {
if let NodeKind::Text(t) = &c.kind {
let rows = c
.appearance
.items
.iter()
.map(|it| {
let w = match it {
vectorcraft_doc::AppearanceItem::Stroke(st) => st.width,
vectorcraft_doc::AppearanceItem::Fill(_) => 0.0,
};
(it.is_fill(), it.paint().color().map(|c| c.to_hex()).unwrap_or_default(), it.opacity(), w)
})
.collect();
out.push((t.plain_text(), rows, c.opacity));
}
}
}
out
}
fn rows_of(looks: &[(String, RowLooks, f32)], text: &str) -> (RowLooks, f32) {
looks.iter().find(|(t, _, _)| t == text).map(|(_, r, o)| (r.clone(), *o)).unwrap_or_else(|| panic!("no label {text}"))
}
/// Fill and stroke rows given to one label with the Appearance commands are kept for every label of its kind when the
/// graph is drawn again, through save and open, and undo with the change.
#[test]
fn appearance_rows_on_one_label_are_kept_for_its_kind() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
// A category label: a red fill row at 50%.
let c = text_id(&s, id, "Axes", "Q2");
s.execute("appearance.addFill", &json!({"ids": [c.0]})).unwrap();
s.execute("appearance.setItem", &json!({"ids": [c.0], "index": 0, "color": "#ff0000", "opacity": 50})).unwrap();
// A value axis number: a 2 pt blue stroke row.
let v = text_id(&s, id, "Axes", "4");
s.execute("appearance.addStroke", &json!({"ids": [v.0]})).unwrap();
s.execute("appearance.setItem", &json!({"ids": [v.0], "index": 0, "color": "#0000ff", "weight": 2})).unwrap();
let looks = redrawn_label_rows(&mut s, id);
assert_eq!(rows_of(&looks, "Q1"), (vec![(true, "#ff0000".into(), 0.5, 0.0)], 1.0), "every category label: {looks:?}");
assert_eq!(rows_of(&looks, "0"), (vec![(false, "#0000ff".into(), 1.0, 2.0)], 1.0), "every value axis number: {looks:?}");
assert_eq!(rows_of(&looks, "2024"), (vec![], 1.0), "the legend keeps none: {looks:?}");
// One undo takes the data change back, the next the stroke's color and weight together with what the graph keeps.
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
let kept = spec(&s, id).label_styles.values_rows.unwrap();
assert_ne!(kept.paint.stroke.and_then(|p| p.color()).map(|c| c.to_hex()), Some("#0000ff".into()));
assert_eq!(kept.paint.stroke_width, Some(1.0));
s.execute("edit.redo", &json!({})).unwrap();
let path = vectorcraft_testkit::temp_dir("graph-label-rows").join("labels.vectorcraft");
s.execute("document.save", &json!({"path": path})).unwrap();
s.execute("document.open", &json!({"path": path})).unwrap();
let g = spec(&s, id);
assert_eq!(g.label_styles.categories_rows.unwrap().paint.fill_opacity, Some(0.5));
assert_eq!(g.label_styles.values_rows.unwrap().paint.stroke_width, Some(2.0));
assert!(serde_json::to_value(vectorcraft_doc::LabelStyles::default()).unwrap().as_object().unwrap().is_empty());
}
/// Removing a label's rows, clearing its appearance or hiding its row leaves its kind with what the label then shows.
#[test]
fn removing_a_labels_rows_changes_what_its_kind_keeps() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
let c = text_id(&s, id, "Axes", "Q2");
s.execute("appearance.addFill", &json!({"ids": [c.0]})).unwrap();
s.execute("appearance.setItem", &json!({"ids": [c.0], "index": 0, "color": "#ff0000"})).unwrap();
s.execute("appearance.removeItem", &json!({"ids": [c.0], "index": 0})).unwrap();
assert_eq!(spec(&s, id).label_styles.categories_rows, None, "no row left");
s.execute("appearance.addFill", &json!({"ids": [c.0]})).unwrap();
s.execute("appearance.setItem", &json!({"ids": [c.0], "index": 0, "color": "#ff0000", "visible": false})).unwrap();
assert_eq!(spec(&s, id).label_styles.categories_rows, None, "a hidden row shows nothing");
s.execute("appearance.showAllHidden", &json!({"ids": [c.0]})).unwrap();
let looks = redrawn_label_rows(&mut s, id);
assert_eq!(rows_of(&looks, "Q3").0, vec![(true, "#ff0000".into(), 1.0, 0.0)], "{looks:?}");
let c = text_id(&s, id, "Axes", "Q1");
s.execute("appearance.clear", &json!({"ids": [c.0]})).unwrap();
let looks = redrawn_label_rows(&mut s, id);
assert_eq!(rows_of(&looks, "Q3").0, vec![(true, String::new(), 1.0, 0.0), (false, String::new(), 1.0, 1.0)], "{looks:?}");
}
/// A fill row painted with `paint.setFill {item}` is kept with the label's rows, and its characters keep their own fill.
#[test]
fn painting_a_labels_row_keeps_the_row_and_not_its_characters() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
let l = text_id(&s, id, "Legend", "2025");
s.execute("appearance.addFill", &json!({"ids": [l.0]})).unwrap();
s.execute("paint.setFill", &json!({"ids": [l.0], "item": 0, "color": "#00ff00"})).unwrap();
let g = spec(&s, id);
assert_eq!(g.label_styles.legend_rows.and_then(|r| r.paint.fill).and_then(|p| p.color()).map(|c| c.to_hex()), Some("#00ff00".into()));
assert_eq!(g.label_styles.legend, None, "the characters keep the default fill");
let looks = label_looks(&mut s, id);
assert!(looks.iter().find(|(t, _, _)| t == "2024").is_some_and(|(_, _, fill)| fill == "#000000"), "{looks:?}");
}
/// Moving or copying an appearance onto a label (Layers panel drag) keeps the source's rows and object opacity for the
/// label's kind.
#[test]
fn an_appearance_moved_onto_a_label_is_kept_for_its_kind() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
let r = NodeId(s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 30, "height": 40})).unwrap()["id"].as_u64().unwrap());
s.execute("paint.setFill", &json!({"ids": [r.0], "color": "#12ab34"})).unwrap();
s.execute("transparency.set", &json!({"ids": [r.0], "opacity": 40})).unwrap();
let v = text_id(&s, id, "Axes", "2");
s.execute("appearance.transfer", &json!({"source": r.0, "target": v.0, "copy": true})).unwrap();
let looks = redrawn_label_rows(&mut s, id);
let (rows, opacity) = rows_of(&looks, "6");
assert_eq!((rows.first().map(|r| r.1.clone()), opacity), (Some("#12ab34".into()), 0.4), "{looks:?}");
}
/// The Eyedropper onto a label keeps the characters' style, the paragraph attributes and the appended rows it gave the
/// label for every label of its kind.
#[test]
fn the_eyedropper_on_a_label_is_kept_for_its_kind() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
let src = NodeId(s.execute("text.create", &json!({"x": 10, "y": 10, "text": "x", "size": 14})).unwrap()["id"].as_u64().unwrap());
s.execute("paint.setFill", &json!({"ids": [src.0], "color": "#00ff00"})).unwrap();
let v = text_id(&s, id, "Axes", "4");
let steps = s.doc().unwrap().history.undo.len();
s.execute("appearance.copyFrom", &json!({"source": src.0, "ids": [v.0]})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "one step");
let looks = label_looks(&mut s, id);
assert!(looks.iter().any(|(t, size, fill)| t == "0" && *size == 14.0 && fill == "#00ff00"), "every value axis number: {looks:?}");
assert!(looks.iter().any(|(t, size, fill)| t == "Q1" && *size == 9.0 && fill == "#000000"), "other labels: {looks:?}");
// Appended rows (Shift+Alt-click) go with the label's rows.
let r = NodeId(s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 30, "height": 40})).unwrap()["id"].as_u64().unwrap());
s.execute("paint.setFill", &json!({"ids": [r.0], "color": "#abcdef"})).unwrap();
let c = text_id(&s, id, "Axes", "Q1");
s.execute("appearance.copyFrom", &json!({"source": r.0, "ids": [c.0], "append": true})).unwrap();
let looks = redrawn_label_rows(&mut s, id);
assert!(rows_of(&looks, "Q3").0.iter().any(|(fill, hex, _, _)| *fill && hex == "#abcdef"), "{looks:?}");
}
/// Kept rows read from a saved graph's label styles are held to the ranges the controls keep to, and a place for the
/// Characters row past the kept rows puts every row below the characters.
#[test]
fn kept_label_rows_from_a_file_stay_in_range() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
let paint = |hex: &str| serde_json::to_value(Paint::solid(vectorcraft_color::Color::from_hex(hex).unwrap())).unwrap();
let saved = json!({"legendRows": {
"fill": paint("#ff0000"), "fillOpacity": 7.0,
"stroke": paint("#0000ff"), "strokeWidth": 1e9, "strokeOpacity": -2.0,
"opacity": -3.0, "below": 9,
}});
let styles: vectorcraft_doc::LabelStyles = serde_json::from_value(saved).unwrap();
s.edit("rows", |d, _| {
d.node_mut(id).unwrap().extras_mut().graph.as_deref_mut().unwrap().label_styles = styles;
Ok(())
})
.unwrap();
let looks = redrawn_label_rows(&mut s, id);
assert_eq!(rows_of(&looks, "2024"), (vec![(true, "#ff0000".into(), 1.0, 0.0), (false, "#0000ff".into(), 0.0, 1000.0)], 0.0), "{looks:?}");
assert_eq!(label_node(&s, id, "Legend", "2024").appearance.contents_at(), 2);
}
/// Label `text` of group `grp` of graph `id`.
fn label_node(s: &Session, id: NodeId, grp: &str, text: &str) -> vectorcraft_doc::Node {
s.doc().unwrap().doc.node(text_id(s, id, grp, text)).unwrap().clone()
}
/// An edit that leaves a label showing what it showed still sets what its kind keeps.
#[test]
fn a_label_already_showing_a_row_still_sets_what_its_kind_keeps() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
let c = text_id(&s, id, "Axes", "Q2");
s.execute("appearance.addFill", &json!({"ids": [c.0]})).unwrap();
s.execute("appearance.setItem", &json!({"ids": [c.0], "index": 0, "color": "#ff0000"})).unwrap();
redrawn_label_rows(&mut s, id);
let a = text_id(&s, id, "Axes", "Q1");
s.execute("appearance.setItem", &json!({"ids": [a.0], "index": 0, "color": "#0000ff"})).unwrap();
// Q3 shows the red the graph drew it with.
let b = text_id(&s, id, "Axes", "Q3");
s.execute("appearance.setItem", &json!({"ids": [b.0], "index": 0, "color": "#ff0000"})).unwrap();
let looks = redrawn_label_rows(&mut s, id);
assert_eq!(rows_of(&looks, "Q1").0, vec![(true, "#ff0000".into(), 1.0, 0.0)], "{looks:?}");
}
/// Paint given to a label's characters leaves the rows its kind keeps as they are.
#[test]
fn paint_on_a_labels_characters_leaves_the_rows_its_kind_keeps() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
let c = text_id(&s, id, "Axes", "Q2");
s.execute("appearance.addFill", &json!({"ids": [c.0]})).unwrap();
s.execute("appearance.setItem", &json!({"ids": [c.0], "index": 0, "color": "#ff0000"})).unwrap();
redrawn_label_rows(&mut s, id);
let a = text_id(&s, id, "Axes", "Q1");
s.execute("appearance.setItem", &json!({"ids": [a.0], "index": 0, "color": "#0000ff"})).unwrap();
// Q3 still shows red; its characters take green.
let b = text_id(&s, id, "Axes", "Q3");
s.execute("paint.setFill", &json!({"ids": [b.0], "color": "#00ff00"})).unwrap();
s.execute("paint.setStroke", &json!({"ids": [b.0], "color": "#00ff00"})).unwrap();
// The Eyedropper from type, transparency included, onto its characters.
let src = NodeId(s.execute("text.create", &json!({"x": 10, "y": 10, "text": "x", "size": 14})).unwrap()["id"].as_u64().unwrap());
s.execute("appearance.copyFrom", &json!({"source": src.0, "ids": [b.0]})).unwrap();
let looks = redrawn_label_rows(&mut s, id);
assert_eq!(rows_of(&looks, "Q1").0, vec![(true, "#0000ff".into(), 1.0, 0.0)], "{looks:?}");
}
/// When labels of one kind are edited together, the last one in the order of `ids` sets what the kind keeps.
#[test]
fn the_last_label_of_a_kind_edited_together_sets_what_it_keeps() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
let (a, b) = (text_id(&s, id, "Axes", "Q1"), text_id(&s, id, "Axes", "Q2"));
for (l, color) in [(a, "#ff0000"), (b, "#0000ff")] {
s.execute("appearance.addFill", &json!({"ids": [l.0]})).unwrap();
s.execute("appearance.setItem", &json!({"ids": [l.0], "index": 0, "color": color})).unwrap();
}
let kept = |s: &Session| spec(s, id).label_styles.categories_rows.unwrap().paint.fill.unwrap().color().unwrap().to_hex();
s.execute("appearance.addFill", &json!({"ids": [a.0, b.0]})).unwrap();
assert_eq!(kept(&s), "#0000ff");
s.execute("appearance.addFill", &json!({"ids": [b.0, a.0]})).unwrap();
assert_eq!(kept(&s), "#ff0000");
}
/// A label's object opacity and blend mode set without a row (`transparency.set` without `item`) are kept for its kind.
#[test]
fn a_labels_object_opacity_and_blend_mode_are_kept_for_its_kind() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
// Another value axis number sets the kind's fill row; the label below has none, and the kind keeps the row.
let u = text_id(&s, id, "Axes", "2");
s.execute("appearance.addFill", &json!({"ids": [u.0]})).unwrap();
s.execute("appearance.setItem", &json!({"ids": [u.0], "index": 0, "color": "#0000ff"})).unwrap();
let v = text_id(&s, id, "Axes", "4");
s.execute("transparency.set", &json!({"ids": [v.0], "opacity": 50, "blend": "multiply"})).unwrap();
let looks = redrawn_label_rows(&mut s, id);
assert_eq!(rows_of(&looks, "0"), (vec![(true, "#0000ff".into(), 1.0, 0.0)], 0.5), "{looks:?}");
assert_eq!(label_node(&s, id, "Axes", "0").blend, BlendMode::Multiply);
assert_eq!(label_node(&s, id, "Axes", "Q1").blend, BlendMode::Normal);
// Another label of the kind sets them again.
let w = text_id(&s, id, "Axes", "2");
s.execute("transparency.set", &json!({"ids": [w.0], "opacity": 50, "blend": "screen"})).unwrap();
assert_eq!(label_node(&s, id, "Axes", "0").blend, BlendMode::Multiply);
let looks = redrawn_label_rows(&mut s, id);
assert_eq!(rows_of(&looks, "0").1, 0.5, "{looks:?}");
assert_eq!(label_node(&s, id, "Axes", "0").blend, BlendMode::Screen);
}
/// Stroke options given to a label's stroke row, a fill row painted as the Appearance panel's active row, and a row's
/// blend mode are kept for the label's kind.
#[test]
fn stroke_options_and_the_active_row_on_a_label_are_kept_for_its_kind() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
let v = text_id(&s, id, "Axes", "4");
s.execute("appearance.addStroke", &json!({"ids": [v.0]})).unwrap();
s.execute("stroke.set", &json!({"ids": [v.0], "item": 0, "weight": 3})).unwrap();
let c = text_id(&s, id, "Axes", "Q2");
s.execute("appearance.addFill", &json!({"ids": [c.0]})).unwrap();
s.execute("select.set", &json!({"ids": [c.0]})).unwrap();
s.execute("appearance.setActiveItem", &json!({"index": 0})).unwrap();
s.execute("paint.setFill", &json!({"color": "#00ffff"})).unwrap();
s.execute("transparency.set", &json!({"item": 0, "blend": "multiply"})).unwrap();
let looks = redrawn_label_rows(&mut s, id);
assert_eq!(rows_of(&looks, "0").0.first().map(|r| r.3), Some(3.0), "{looks:?}");
assert_eq!(rows_of(&looks, "Q1").0.first().map(|r| r.1.clone()), Some("#00ffff".into()), "{looks:?}");
let q1 = label_node(&s, id, "Axes", "Q1");
assert_eq!(q1.appearance.items.first().map(|it| it.blend()), Some(BlendMode::Multiply));
let NodeKind::Text(t) = &q1.kind else { panic!("not type") };
assert_eq!(t.runs[0].style.fill.color().map(|c| c.to_hex()), Some("#000000".into()), "the characters keep their fill");
}
/// A row below the Characters row stays below the characters when the graph is drawn again.
#[test]
fn a_kept_row_keeps_its_place_below_the_characters() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
let c = text_id(&s, id, "Axes", "Q2");
s.execute("appearance.addStroke", &json!({"ids": [c.0]})).unwrap();
s.execute("appearance.addFill", &json!({"ids": [c.0]})).unwrap();
// The stroke below the characters, the fill above them.
s.execute("appearance.moveItem", &json!({"ids": [c.0], "from": "contents", "to": 1})).unwrap();
let look = |ap: &vectorcraft_doc::Appearance| (ap.contents_at(), ap.items.iter().map(|it| it.is_fill()).collect::<Vec<_>>());
assert_eq!(look(&label_node(&s, id, "Axes", "Q2").appearance), (1, vec![false, true]));
redrawn_label_rows(&mut s, id);
assert_eq!(look(&label_node(&s, id, "Axes", "Q1").appearance), (1, vec![false, true]));
}
/// The Eyedropper keeps for a label's kind only the attributes it applied: a fill the label had of its own stays out.
#[test]
fn the_eyedropper_keeps_only_what_it_applied_for_a_labels_kind() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
let src = NodeId(s.execute("text.create", &json!({"x": 10, "y": 10, "text": "x", "size": 14})).unwrap()["id"].as_u64().unwrap());
s.execute("paint.setFill", &json!({"ids": [src.0], "color": "#00ff00"})).unwrap();
let v = text_id(&s, id, "Axes", "4");
// A fill of the label's own, as the generator gives labels on dark pie wedges.
s.edit("own fill", |d, _| {
if let NodeKind::Text(t) = &mut d.node_mut(v).unwrap().kind {
t.runs.iter_mut().for_each(|r| r.style.fill = Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 1.0)));
}
Ok(())
})
.unwrap();
// A pick of transparency alone leaves the kind's character style unset.
let transparency = json!({"character": false, "paragraph": false, "appearance": {"fill": false, "stroke": false}});
s.execute("appearance.copyFrom", &json!({"source": src.0, "ids": [v.0], "apply": transparency})).unwrap();
assert_eq!(spec(&s, id).label_styles.values, None);
s.execute("appearance.copyFrom", &json!({"source": src.0, "ids": [v.0], "apply": {"appearance": false}})).unwrap();
let looks = label_looks(&mut s, id);
assert!(looks.iter().any(|(t, size, fill)| t == "0" && *size == 14.0 && fill == "#000000"), "{looks:?}");
}
/// Paint given to all of a label's text highlighted with the Type tool is kept for its kind, as for the whole label.
#[test]
fn paint_on_a_labels_highlighted_text_is_kept_for_its_kind() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
let v = text_id(&s, id, "Axes", "4");
s.select_tool("type", crate::ViewInfo::default()).unwrap();
s.set_tool_option("textSelection", &json!({"id": v.0, "start": 0, "end": 1}));
s.execute("paint.setFill", &json!({"color": "#ff8800"})).unwrap();
s.select_tool("selection", crate::ViewInfo::default()).unwrap();
let looks = label_looks(&mut s, id);
assert!(looks.iter().any(|(t, _, fill)| t == "0" && fill == "#ff8800"), "{looks:?}");
}
/// Paint given to the whole graph without a row is kept for the characters of every kind of label.
#[test]
fn paint_on_the_whole_graph_is_kept_for_every_kind_of_label() {
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("paint.setFill", &json!({"color": "#ff00ff"})).unwrap();
let looks = label_looks(&mut s, id);
for t in ["0", "Q1", "2024"] {
assert!(looks.iter().any(|(x, _, fill)| x == t && fill == "#ff00ff"), "{t}: {looks:?}");
}
}
/// A gradient placed on one label's characters or on its row is kept without that placement, so it fits each label of
/// the kind when the graph is drawn again.
#[test]
fn a_placed_gradient_on_a_label_is_kept_to_fit_each_label() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
let gradient = json!({"start": [30, 60], "end": [90, 60]});
let v = text_id(&s, id, "Axes", "4");
s.execute("paint.setFill", &json!({"ids": [v.0], "gradient": gradient})).unwrap();
let c = text_id(&s, id, "Axes", "Q2");
s.execute("appearance.addFill", &json!({"ids": [c.0]})).unwrap();
s.execute("paint.setFill", &json!({"ids": [c.0], "item": 0, "gradient": gradient})).unwrap();
let placed = |p: &Paint| matches!(p, Paint::Gradient(g) if g.geom.is_some());
let g = spec(&s, id);
assert!(g.label_styles.values.is_some_and(|st| matches!(st.fill, Paint::Gradient(_)) && !placed(&st.fill)));
assert!(g.label_styles.categories_rows.and_then(|r| r.paint.fill).is_some_and(|f| matches!(f, Paint::Gradient(_)) && !placed(&f)));
}
/// A graphic style applied to a label is kept with the label's rows for its kind.
#[test]
fn a_graphic_style_on_a_label_is_kept_for_its_kind() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
let r = NodeId(s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 30, "height": 40})).unwrap()["id"].as_u64().unwrap());
s.execute("paint.setFill", &json!({"ids": [r.0], "color": "#ff8000"})).unwrap();
s.execute("graphicStyle.new", &json!({"id": r.0, "name": "Orange"})).unwrap();
let v = text_id(&s, id, "Axes", "4");
s.execute("graphicStyle.apply", &json!({"name": "Orange", "ids": [v.0]})).unwrap();
let looks = redrawn_label_rows(&mut s, id);
assert!(rows_of(&looks, "0").0.iter().any(|(fill, hex, _, _)| *fill && hex == "#ff8000"), "{looks:?}");
// A label still linked to the style follows a redefinition, and its kind keeps the new look.
let v = text_id(&s, id, "Axes", "4");
s.execute("graphicStyle.apply", &json!({"name": "Orange", "ids": [v.0]})).unwrap();
s.execute("paint.setFill", &json!({"ids": [r.0], "color": "#0080ff"})).unwrap();
s.execute("graphicStyle.redefine", &json!({"name": "Orange", "id": r.0})).unwrap();
let looks = redrawn_label_rows(&mut s, id);
assert!(rows_of(&looks, "0").0.iter().any(|(fill, hex, _, _)| *fill && hex == "#0080ff"), "{looks:?}");
}
#[test]
fn pie_names_and_wedge_labels_keep_their_style() {
let mut s = Session::new();
+40 -7
View File
@@ -994,20 +994,53 @@ fn artboard_delete(s: &mut Session, p: &Value) -> Result<Value> {
ok()
}
/// Delete artboards `indices` (sorted) of `d` with their guides; at least one must be left.
fn remove_artboards(d: &mut Document, sel: &mut vectorcraft_doc::Selection, indices: &[usize]) -> Result<()> {
/// Delete artboards `indices` (sorted, each once) of `d` with their guides; at least one must be
/// left. The other artboards keep their order, no later artboard takes a deleted one's id, and
/// `sel` forgets the deleted guides. One walk over the artboards and one over the ruler guides,
/// however many go. Delete Artboard, Delete Empty Artboards, Cut Artboard and Artwork Cleanup's
/// Empty Artboards.
pub(crate) fn remove_artboards(d: &mut Document, sel: &mut vectorcraft_doc::Selection, indices: &[usize]) -> Result<()> {
if indices.iter().any(|i| *i >= d.artboards.len()) {
return Err(EngineError::Other("no such artboard".into()));
}
if indices.len() >= d.artboards.len() {
return Err(EngineError::Other("a document needs at least one artboard".into()));
}
// The last first, so the others keep their places; their guides go with them.
for &i in indices.iter().rev() {
let id = d.artboards.remove(i).id;
d.retire_artboard_id(id);
d.retain_guides(sel, |g| g.artboard != Some(id));
if indices.is_empty() {
return Ok(());
}
let mut gone: HashSet<u32> = HashSet::with_capacity(indices.len());
let mut i = 0;
d.artboards.retain(|a| {
let keep = indices.binary_search(&i).is_err();
i += 1;
if !keep {
gone.insert(a.id);
}
keep
});
count_artboard_pass();
for &id in &gone {
d.retire_artboard_id(id);
}
d.retain_guides(sel, |g| !g.artboard.is_some_and(|a| gone.contains(&a)));
count_artboard_pass();
Ok(())
}
#[cfg(test)]
thread_local! {
/// Tests: how many walks [`remove_artboards`] made over the artboards and the ruler guides on
/// this thread.
pub(crate) static ARTBOARD_PASSES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// Tests: count one walk over the artboards or the ruler guides.
fn count_artboard_pass() {
#[cfg(test)]
ARTBOARD_PASSES.set(ARTBOARD_PASSES.get() + 1);
}
/// The artboards Delete Empty Artboards deletes: those with no artwork
/// ([`Document::artboard_has_art`]) that aren't locked, but artboard 1 when every artboard is one
/// of them (a document keeps one, as Delete Artboards keeps artboard 1).
+1 -1
View File
@@ -251,7 +251,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Object Properties",
[],
None,
"{ids?|id?, name?, visible?, locked? (visible: false or locked: true deselects every selected object hidden or locked after the call, the key object included), opacity?: 0..100, blend?: \"Multiply\"…, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, data?: {key: \"value\" | null (removes it)} (the object's own data, SVG data-* attributes: {pivot: \"100,180\"} is data-pivot; document.node → attrs.data)}. On a path directly in a graph series, the graph also stores the object's opacity and blend mode (exceptions: docs/mcp.md)",
"{ids?|id?, name?, visible?, locked? (visible: false or locked: true deselects every selected object hidden or locked after the call, the key object included), opacity?: 0..100, blend?: \"Multiply\"…, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, data?: {key: \"value\" | null (removes it)} (the object's own data, SVG data-* attributes: {pivot: \"100,180\"} is data-pivot; document.node → attrs.data)}. On a path directly in a graph series, the graph also stores the object's opacity and blend mode (exceptions: docs/mcp.md); on a graph label, the graph stores the label's opacity and blend mode for every label of its kind",
has_doc,
set_props
),
+5 -3
View File
@@ -65,10 +65,11 @@ pub(super) fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
})
};
let d = &s.doc()?.doc;
// A graph keeps the object opacity and blend mode set on a mark of a series for the whole series, so a value a mark
// already has still replaces the one another mark left on the graph.
// A graph keeps the object opacity and blend mode set on a mark of a series for the whole series, and set on a label
// for its kind of label, so a value a mark or label already has still replaces the one another left on the graph.
let transparency = opacity.is_some() || blend.is_some();
let stores = || transparency && crate::cmd::graph::series_object_opacity_differs(d, &ids);
let stores =
|| transparency && (crate::cmd::graph::series_object_opacity_differs(d, &ids) || crate::cmd::graph::label_object_opacity_differs(d, &ids));
if ids.iter().all(|id| d.node(*id).is_some_and(has_values)) && !stores() {
// Hidden or locked objects can't stay selected.
if hides {
@@ -124,6 +125,7 @@ pub(super) fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
}
if transparency {
crate::cmd::graph::capture_series_object_opacity(d, &ids);
crate::cmd::graph::capture_label_object_opacity(d, &ids);
}
// Hidden or locked objects can't stay selected.
if hides {
+3 -2
View File
@@ -135,9 +135,7 @@ pub(super) fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
.map_err(|_| bad("object.setLiveShape", format!("`kind` must be \"round\", \"invertedRound\" or \"chamfer\", not {v}")))
})
.transpose()?;
let radius = p.get("radius").and_then(Value::as_f64);
let relative_radii = p.get("relativeRadii").and_then(Value::as_bool);
let sides = p.get("sides").and_then(Value::as_u64);
// A number that must be `what` (None when absent).
let number = |k: &str, ok: fn(f64) -> bool, what: &str| -> Result<Option<f64>> {
match p.get(k) {
@@ -151,6 +149,9 @@ pub(super) fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
};
let angle = |k: &str| Ok::<_, EngineError>(number(k, |_| true, "an angle in degrees")?.map(|a| a.rem_euclid(360.0)));
let length = |k: &str| number(k, |x| x > 0.0 && x <= crate::MAX_COORD, "a length in points above 0");
let radius = number("radius", |r| r >= 0.0, "a radius of 0 or more")?;
// A polygon's side count: a whole number from 3 to 1000.
let sides = number("sides", |n| n.fract() == 0.0 && (3.0..=1000.0).contains(&n), "a whole number from 3 to 1000")?.map(|n| n as u64);
// An ellipse's pie (Ellipse Properties: Pie Start and End Angle, degrees; Invert Pie swaps them).
let (pie_start, pie_end) = (angle("pieStart")?, angle("pieEnd")?);
let invert = p.get("invertPie").and_then(Value::as_bool) == Some(true);
+53 -13
View File
@@ -21,7 +21,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Fill",
[],
None,
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical: the stops sweep once around the center `start`, clockwise from the vector; diamond: the stops run from the center `start` out to a square with a corner at `end`), method?: classic|perceptual|hue-clockwise|hue-counterclockwise (as paint.editGradient; default classic), spread?: pad|repeat|reflect (as paint.editGradient; default pad), repeats?: 1..100 (as paint.editGradient; default 1), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg counter-clockwise (0: the gradient runs left to right), start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep, counterclockwise, and is an error for the other kinds; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits; an error with a conic or diamond), dither?: bool (linear, radial, conic and diamond fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear, radial, conic or diamond `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white; a colorized image whose link is missing takes a solid color as its new colorize color, and refuses a process color while its color is a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern) → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}",
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical: the stops sweep once around the center `start`, clockwise from the vector; diamond: the stops run from the center `start` out to a square with a corner at `end`), method?: classic|perceptual|hue-clockwise|hue-counterclockwise (as paint.editGradient; default classic), spread?: pad|repeat|reflect (as paint.editGradient; default pad), repeats?: 1..100 (as paint.editGradient; default 1), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg counter-clockwise (0: the gradient runs left to right), start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep, counterclockwise, and is an error for the other kinds; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits; an error with a conic or diamond), dither?: bool (linear, radial, conic and diamond fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear, radial, conic or diamond `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white; a colorized image whose link is missing takes a solid color as its new colorize color, and refuses a process color while its color is a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern). On a path directly in a graph series, the graph also stores the paint of the topmost visible fill when the command wrote that row or changed it (exceptions: docs/mcp.md); on a graph label, the graph stores the paint for every label of its kind, as a character fill when the characters took it and with the label's rows when a row did → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}",
has_doc,
|s, p| set_paint(s, p, true)
),
@@ -30,7 +30,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Stroke",
[],
None,
"{…as paint.setFill, item?: the stroke item to set} same as paint.setFill, for the stroke; with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's stroke changes for this instance only (solid, none, gradient or pattern)",
"{…as paint.setFill, item?: the stroke item to set} same as paint.setFill, for the stroke; with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's stroke changes for this instance only (solid, none, gradient or pattern). On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible stroke when the command wrote that row or changed it (exceptions: docs/mcp.md); on a graph label, as paint.setFill",
has_doc,
|s, p| { set_paint(s, p, false) }
),
@@ -64,16 +64,24 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(json!({ "fillActive": s.fill_active }))
}
),
cmd!("paint.none", "None", [], Some("/"), "{} set the active proxy (fill or stroke) to None → {objects}", has_doc, |s, _| {
let f = s.fill_active;
set_paint(s, &json!({"none": true}), f)
}),
cmd!(
"paint.none",
"None",
[],
Some("/"),
"{} set the active proxy (fill or stroke) to None, as paint.setFill or paint.setStroke with none does → {objects}",
has_doc,
|s, _| {
let f = s.fill_active;
set_paint(s, &json!({"none": true}), f)
}
),
cmd!(
"transparency.set",
"Transparency",
["Window", "Transparency"],
None,
"{ids?|id?, opacity?: 0..100, blend?: name, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, item?: appearance item index|null, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} for `ids`, the selection, or the object whose opacity mask is being edited; opacity and blend go to the targeted fill/stroke item (omitted: the Appearance panel's active item, else the objects; a gradient mesh point last clicked with the Mesh tool or Direct Selection takes the opacity itself). With an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, opacity and blend change that object for this instance only (isolate, knockout, knockoutShape, aligned and target are refused). On a path directly in a graph series (for a fill or stroke item, a series group's id stands for those paths), the graph also stores the opacity and blend mode of the object and of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
"{ids?|id?, opacity?: 0..100, blend?: name, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, item?: appearance item index|null, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} for `ids`, the selection, or the object whose opacity mask is being edited; opacity and blend go to the targeted fill/stroke item (omitted: the Appearance panel's active item, else the objects; a gradient mesh point last clicked with the Mesh tool or Direct Selection takes the opacity itself). With an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, opacity and blend change that object for this instance only (isolate, knockout, knockoutShape, aligned and target are refused). On a path directly in a graph series (for a fill or stroke item, a series group's id stands for those paths), the graph also stores the opacity and blend mode of the object and of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores the label's object opacity and blend mode for every label of its kind, and with `item` its topmost visible fill and stroke rows too",
has_doc,
transparency
),
@@ -467,6 +475,14 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool)
let ids: Vec<NodeId> = ranges.iter().map(|(f, _, _)| *f).collect();
// When the type comes out as it was, the document and its undo history stay as they are.
let before = s.doc()?.doc.clone();
// The graph labels whose whole text takes the paint: a graph keeps it for their kind of label.
let whole: Vec<NodeId> = ranges
.iter()
.filter(|(id, a, b)| {
*a == 0 && matches!(before.node(*id).map(|n| &n.kind), Some(NodeKind::Text(t)) if *b >= vectorcraft_text::edit::runs_len(&t.runs))
})
.map(|(id, _, _)| *id)
.collect();
super::overprint::edit_counted(s, if fill { "Fill Color" } else { "Stroke Color" }, |d| {
for &(id, a, b) in &ranges {
let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) };
@@ -483,7 +499,17 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool)
});
super::typecmd::refresh_bounds(t);
}
Ok(super::appearance::count_changed(d, &before, &ids))
let labels = super::graph::capture_label_styles(d, &whole, &|st| {
if fill {
st.fill = unplaced(&paint);
} else {
if !paint.is_none() {
run_stroke_weight(st);
}
st.stroke = unplaced(&paint);
}
});
Ok(super::appearance::count_changed(d, &before, &ids) + usize::from(labels))
})?;
if focus {
s.fill_active = fill;
@@ -523,6 +549,12 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool)
vec![]
};
let label = if fill { "Fill Color" } else { "Stroke Color" };
// Each object whose appearance took the paint, with the row it went to, for the paint a graph stores for its series
// (a series mark is a path, so it always has a row here).
let written = std::cell::RefCell::new(Vec::new());
// The type objects whose characters took the paint (no row targeted): only those change what a graph's labels keep
// for their characters; a row edit is kept with the label's rows.
let characters = std::cell::RefCell::new(Vec::new());
edit_items_then(
s,
&ids,
@@ -531,6 +563,9 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool)
label,
fill,
|n, index| {
if index.is_none() && matches!(n.kind, NodeKind::Text(_)) {
characters.borrow_mut().push(n.id);
}
if index.is_none() && colorized.contains(&n.id) {
n.extras_mut().colorize = paint.color().map(|_| Box::new(paint.clone()));
return Ok(());
@@ -581,18 +616,23 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool)
if !fill {
super::object::snap_if_aligned(n);
}
let at = match index {
Some(_) => n.appearance.item_of_kind(index, fill),
None => n.appearance.items.iter().rposition(|it| it.is_fill() == fill),
};
written.borrow_mut().extend(at.map(|at| (n.id, at)));
Ok(())
},
|_, d, changed| {
let series = super::graph::capture_series_paints(d, changed, fill);
let labels = super::graph::capture_label_styles(d, changed, &|st| {
|before, d, changed| {
let series = super::appearance::capture_series_paint_writes(before, d, changed, &written.borrow());
let labels = super::graph::capture_label_styles(d, &characters.borrow(), &|st| {
if fill {
st.fill = paint.clone();
st.fill = unplaced(&paint);
} else {
if !paint.is_none() {
run_stroke_weight(st);
}
st.stroke = paint.clone();
st.stroke = unplaced(&paint);
}
});
Ok(series || labels)
File diff suppressed because one or more lines are too long
+39 -2
View File
@@ -8,7 +8,9 @@
//! (`…settings as file.new`), or, naming none, any key (the options of the format an export picks). Objects inside a value aren't
//! described key by key, so only the top level is checked, except in the options of an export, a
//! save or Export for Screens: those are checked against the options of the formats the command
//! can write, in `marks`, `bleed` and the other objects too ([`check_export_options`]).
//! can write, in `marks`, `bleed` and the other objects too ([`check_export_options`]); and in the
//! objects of an array whose keys `MEMBER_KEYS` lists, such as the `items` of
//! `object.setLiveShape`.
//!
//! [`Session::execute`] answers a key the description doesn't name with a parameter error
//! ([`check_params`]), for every caller (the app, the control channel, MCP, the CLI) and for the
@@ -269,6 +271,41 @@ pub(crate) fn unknown(id: &str, key: &str, keys: &[&str]) -> crate::EngineError
)
}
/// Commands whose params hold an array of objects with keys of their own: the command, the key of
/// the array and the keys each of its objects takes.
const MEMBER_KEYS: &[(&str, &str, &[&str])] = &[("object.setLiveShape", "items", &["id", "corners"])];
/// Each object in the arrays [`MEMBER_KEYS`] names for command `id` has only the keys it lists, or
/// the parameter error naming the first other one with its place (`items[0].corner`), and the
/// nearest accepted key or the accepted ones. A member that isn't an object is the command's to
/// report.
fn check_member_keys(id: &str, params: &Value) -> Result<()> {
for (_, list, keys) in MEMBER_KEYS.iter().filter(|(c, ..)| *c == id) {
let Some(members) = params.get(*list).and_then(Value::as_array) else { continue };
for (i, member) in members.iter().enumerate() {
let Value::Object(given) = member else { continue };
let Some(key) = given.keys().find(|k| !keys.contains(&k.as_str())) else { continue };
let at = |k: &str| format!("{list}[{i}].{k}");
let near = nearest(key, keys);
let key = at(&shown_key(key));
return Err(bad(
id,
match near {
Some(near) => {
let near = at(near);
format!("unknown parameter `{key}`; did you mean `{near}`?")
}
None => {
let keys = keys.iter().map(|k| at(k)).collect::<Vec<_>>().join(", ");
format!("unknown parameter `{key}`; the command takes: {keys}")
}
},
));
}
}
Ok(())
}
/// `params` of built-in effect `effect`, as command `id` was given them (#1592): only keys the
/// effect takes (those of its dialog defaults and of its params description), each of the type
/// the effect reads ([`check_effect_types`]), a `color` the effect can draw ([`check_effect_color`])
@@ -526,7 +563,6 @@ pub const UNCHECKED: &[&str] = &[
"object.rasterize",
"object.repeat.options",
"object.setBounds",
"object.setLiveShape",
"object.setOverprint",
"object.setProps",
"object.slice.divide",
@@ -833,6 +869,7 @@ fn check_params_in(id: &str, params: &Value, in_batch: bool) -> Result<()> {
return Ok(());
}
check_keys(id, spec.params, params)?;
check_member_keys(id, params)?;
if in_batch {
crate::schemas::check(spec, params, false)?;
}
+25
View File
@@ -314,6 +314,15 @@ fn set_handle(s: &mut Session, p: &Value) -> Result<Value> {
let out = str_param(p, "which") != Some("in");
let pos = Point::new(f64_req(p, "x", "path.setHandle")?, f64_req(p, "y", "path.setHandle")?);
let independent = switch_or(p, "independent", false, "path.setHandle")?;
// A handle already there, on an anchor already of the kind it would get, changes nothing and
// records no undo step.
if let Some(a) = anchor_of(s, id, si, ai)? {
let mut b = a;
b.set_handle(out, pos, independent);
if b == a {
return ok();
}
}
s.edit("Reshape", |d, _| {
let path = path_mut(d, id)?;
let a = path.anchor_mut(si, ai).ok_or_else(|| EngineError::Other("no such anchor".into()))?;
@@ -330,6 +339,12 @@ fn move_anchor(s: &mut Session, p: &Value) -> Result<Value> {
let si = usize_opt(p, "subpath", "path.moveAnchor")?.unwrap_or(0);
let ai = p.get("anchor").and_then(Value::as_u64).map(index).ok_or_else(|| bad("path.moveAnchor", "missing anchor"))?;
let pos = Point::new(f64_req(p, "x", "path.moveAnchor")?, f64_req(p, "y", "path.moveAnchor")?);
// An anchor already at (x, y) changes nothing and records no undo step.
if let Some(a) = anchor_of(s, id, si, ai)?
&& a.transform(vectorcraft_geom::Affine::translate(pos - a.p)) == a
{
return ok();
}
s.edit("Move", |d, _| {
let path = path_mut(d, id)?;
let a = path.anchor_mut(si, ai).ok_or_else(|| EngineError::Other("no such anchor".into()))?;
@@ -345,6 +360,16 @@ fn move_anchor(s: &mut Session, p: &Value) -> Result<Value> {
ok()
}
/// Anchor `ai` of subpath `si` of path `id` in the active document, when `id` is a path that has it.
/// Busy while a script runs, as [`Session::edit`] is.
fn anchor_of(s: &Session, id: NodeId, si: usize, ai: usize) -> Result<Option<Anchor>> {
if s.script_running() {
return Err(EngineError::Busy);
}
let d = &s.doc()?.doc;
Ok(d.node(id).and_then(|n| n.path_data()).and_then(|path| path.subpaths.get(si)).and_then(|sp| sp.anchors.get(ai)).copied())
}
fn set_anchors(s: &mut Session, p: &Value) -> Result<Value> {
let id = object_param(p, "id", "path.setAnchors")?.ok_or_else(|| bad("path.setAnchors", "missing id"))?;
let subs = p.get("subpaths").and_then(Value::as_array).ok_or_else(|| bad("path.setAnchors", "missing subpaths"))?;
+124 -20
View File
@@ -803,35 +803,59 @@ fn offset_path(s: &mut Session, p: &Value) -> Result<Value> {
let miter = number_given(p, "miterLimit", C)?.unwrap_or(4.0).clamp(1.0, 500.0);
let roots = selected_roots(s)?;
let ids = s.edit("Offset Path", |d, sel| {
let (mut out, mut above) = (vec![], vec![]);
let mut paths = false;
// Each selected root, whether it is a group, and the style of each of its leaf paths; the
// paths themselves go to `sources` in the same order.
let (mut roots_found, mut sources) = (vec![], vec![]);
for id in &roots {
let Some(n) = d.node(*id).cloned() else { continue };
let mut lv = vec![];
leaves(&n, &mut lv);
let Some(n) = d.node(*id) else { continue };
let mut styles = vec![];
each_leaf(n, &mut |l| {
if !matches!(l.kind, NodeKind::Text(_))
&& let Some((path, rule)) = node_path(l)
{
styles.push(Style::of(l));
sources.push((path, rule, matches!(l.kind, NodeKind::Compound { .. })));
}
});
roots_found.push((*id, matches!(n.kind, NodeKind::Group { .. }), styles));
}
let paths = !sources.is_empty();
let offset = |(path, rule, compound): &(PathData, FillRule, bool)| {
let normalized;
let base = if *compound && path.is_closed() {
normalized = po::normalize(path, *rule);
&normalized
} else {
path
};
match po::try_offset_path(base, delta, join, miter) {
Ok(p) => Ok(Some(p)),
Err(po::PathOpsError::NonFinite) => Ok(None),
Err(e) => Err(e),
}
};
// The offsets are worked out together; the nodes are made here, in selection order, so
// the ids, the stacking and the first error are those of one path after another.
let (offsets, _) = ordered_map(&sources, offset_threads(), offset, std::result::Result::is_err);
let mut offsets = offsets.into_iter();
let (mut out, mut above) = (vec![], vec![]);
for (id, group, styles) in roots_found {
let mut made = vec![];
for l in lv.iter().filter(|l| !matches!(l.kind, NodeKind::Text(_))) {
let Some((path, rule)) = node_path(l) else { continue };
paths = true;
let base = if matches!(l.kind, NodeKind::Compound { .. }) && path.is_closed() { po::normalize(&path, rule) } else { path };
let off = match po::try_offset_path(&base, delta, join, miter) {
Ok(p) => p,
Err(po::PathOpsError::NonFinite) => continue,
Err(po::PathOpsError::TooComplex) => {
for style in &styles {
match offsets.next().flatten() {
Some(Ok(Some(off))) if !off.is_empty() => made.push(shape_node_styled(d, off, style)),
Some(Err(po::PathOpsError::TooComplex)) => {
return Err(EngineError::Other("Offset Path: the path is too complex to offset".into()));
}
Err(e) => return Err(EngineError::Other(format!("Offset Path: {e}"))),
};
if off.is_empty() {
continue;
Some(Err(e)) => return Err(EngineError::Other(format!("Offset Path: {e}"))),
_ => {}
}
made.push(shape_node(d, off, Some(l)));
}
if made.is_empty() {
continue;
}
let new = if made.len() == 1
&& !matches!(n.kind, NodeKind::Group { .. })
&& !group
&& let Some(one) = made.pop()
{
one
@@ -840,7 +864,7 @@ fn offset_path(s: &mut Session, p: &Value) -> Result<Value> {
Node::group(gid, made.into_iter().map(Arc::new).collect())
};
out.push(new.id);
above.push((*id, vec![new]));
above.push((id, vec![new]));
}
if out.is_empty() && paths {
return Err(EngineError::Other("Offset Path: the offset leaves nothing of the selected paths (an inset larger than the shapes)".into()));
@@ -855,6 +879,86 @@ fn offset_path(s: &mut Session, p: &Value) -> Result<Value> {
Ok(ids_json(&ids))
}
/// The threads [`offset_path`] works on: the logical CPUs, or one on the web.
fn offset_threads() -> usize {
#[cfg(target_arch = "wasm32")]
{
1
}
#[cfg(not(target_arch = "wasm32"))]
{
std::thread::available_parallelism().map_or(1, std::num::NonZero::get)
}
}
/// `f` of each of `items`, in their order. The items are split into runs whose sizes differ by
/// at most one, as many as `threads` or the items allow and at least one. This thread works
/// on the first run, and a thread of its own works on each other run (this thread does when that
/// one can't start). Once `stop` accepts a result, the items after it are left out (`None`); every
/// item before the first such result has its result. Returns the results and the number of
/// threads that worked on them.
pub(crate) fn ordered_map<T: Sync, R: Send>(
items: &[T],
threads: usize,
f: impl Fn(&T) -> R + Sync,
stop: impl Fn(&R) -> bool + Sync,
) -> (Vec<Option<R>>, usize) {
use std::sync::atomic::{AtomicUsize, Ordering};
let mut out: Vec<Option<R>> = std::iter::repeat_with(|| None).take(items.len()).collect();
let stopped = AtomicUsize::new(usize::MAX);
let run = |from: usize, its: &[T], out: &mut [Option<R>]| {
for (i, (item, slot)) in its.iter().zip(out).enumerate() {
let at = from + i;
if at > stopped.load(Ordering::Relaxed) {
return;
}
let r = f(item);
if stop(&r) {
stopped.fetch_min(at, Ordering::Relaxed);
}
*slot = Some(r);
}
};
let runs = threads.clamp(1, items.len().max(1));
if runs == 1 {
run(0, items, &mut out);
return (out, 1);
}
// Run `k` holds the items from `start(k)` up to `start(k + 1)`.
let start = |k: usize| k * items.len() / runs;
let mut unstarted = vec![];
let used = std::thread::scope(|s| {
let mut rest = out.as_mut_slice();
let mut first = None;
let mut used = 1;
for k in 0..runs {
let (from, to) = (start(k), start(k + 1));
let Some((o, tail)) = std::mem::take(&mut rest).split_at_mut_checked(to - from) else { break };
rest = tail;
let its = items.get(from..to).unwrap_or_default();
if k == 0 {
first = Some((o, its));
continue;
}
let run = &run;
match std::thread::Builder::new().spawn_scoped(s, move || run(from, its, o)) {
Ok(_) => used += 1,
Err(_) => unstarted.push(k),
}
}
if let Some((o, its)) = first {
run(0, its, o);
}
used
});
for (from, to) in unstarted.into_iter().map(|k| (start(k), start(k + 1))) {
if let (Some(o), Some(its)) = (out.get_mut(from..to), items.get(from..to)) {
run(from, its, o);
}
}
(out, used)
}
// ---------- Outline Stroke ----------
/// Can Outline Stroke turn `st` into filled art?
@@ -32,6 +32,7 @@ pub(super) const ALLOWED: &[&str] = &[
"addon.color.strokeToFill",
"addon.color.swatchLegend",
"addon.contour.set",
"addon.cutting.banner",
"addon.cutting.bridges",
"addon.cutting.weed",
"addon.image.applyColorMask",
@@ -58,6 +59,7 @@ pub(super) const ALLOWED: &[&str] = &[
"addon.pathtools.shape.set",
"addon.pathtools.slideAnchor",
"addon.pathtools.smooth",
"addon.pathtools.trim",
"addon.pen.append",
"addon.pen.close",
"addon.pen.closeJoin",
+14 -2
View File
@@ -576,13 +576,25 @@ pub fn store(s: &Session) -> Option<Arc<dyn RecoveryStore>> {
}
#[cfg(not(target_arch = "wasm32"))]
{
let folder = Some(s.prefs.recovery_folder.trim()).filter(|f| !f.is_empty()).map(str::to_string).or_else(|| s.recovery.default_folder.clone());
folder.map(|f| Arc::new(FolderStore::new(f)) as Arc<dyn RecoveryStore>)
folder(s).map(|(f, _)| Arc::new(FolderStore::new(f)) as Arc<dyn RecoveryStore>)
}
#[cfg(target_arch = "wasm32")]
None
}
/// The folder copies go to now, and whether the `recoveryFolder` preference names it (else it is
/// the folder the app sets). `None` when they go to a store the host installed (browser storage)
/// or nowhere.
pub fn folder(s: &Session) -> Option<(String, bool)> {
if s.recovery.store.is_some() || cfg!(target_arch = "wasm32") {
return None;
}
match s.prefs.recovery_folder.trim() {
"" => s.recovery.default_folder.clone().map(|f| (f, false)),
set => Some((set.to_string(), true)),
}
}
fn has_store(s: &Session) -> std::result::Result<(), String> {
match store(s) {
Some(_) => Ok(()),
+1 -1
View File
@@ -22,7 +22,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Stroke Options",
["Window", "Stroke"],
None,
"{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|LineArrow|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null|{custom: name} (a custom arrowhead of the arrowheads file, stroke.arrowheads.list)|{Custom: {name, path}} (a custom head with its art, as document.inspect reports one; refused when the path fills no area, parts thinner than a millionth of a head unit counting as none, or has more than 4096 anchors, more than 4096 subpaths or a point more than 64 head units from the tip) (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), widthPoints?, gradientMode? (as document.inspect reports them in strokeOptions, so those read back unchanged; ignored here: a custom profile is set with the Width tool, gradientMode with paint.editGradient), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does). On a path directly in a graph series, the graph also stores a weight it changes on the topmost visible stroke (docs/mcp.md) → {objects: how many objects took the stroke options}",
"{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|LineArrow|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null|{custom: name} (a custom arrowhead of the arrowheads file, stroke.arrowheads.list)|{Custom: {name, path}} (a custom head with its art, as document.inspect reports one; refused when the path fills no area, parts thinner than a millionth of a head unit counting as none, or has more than 4096 anchors, more than 4096 subpaths or a point more than 64 head units from the tip) (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), widthPoints?, gradientMode? (as document.inspect reports them in strokeOptions, so those read back unchanged; ignored here: a custom profile is set with the Width tool, gradientMode with paint.editGradient), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does). On a path directly in a graph series, the graph also stores a weight it changes on the topmost visible stroke (docs/mcp.md); on a graph label's stroke row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind → {objects: how many objects took the stroke options}",
has_doc,
stroke_set
),
+6 -3
View File
@@ -31,7 +31,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Apply Graphic Style",
["Window", "Graphic Styles"],
None,
"{name, add?: bool, ids?|id? (layers too), target?: \"object\"|\"contents\"} give the objects (default: the selection; a group or layer itself, its fills and strokes painting its members and its effects applying to them as one piece; target contents: the objects inside instead) the style's appearance, opacity, blend mode, isolate and knockout, and link them to it (placed gradients land at the same place relative to each object's bounds); add: true (Alt-click) adds its fills, strokes and effects on top of the existing appearance instead and unlinks. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (with `add`: of the kinds of row the style shows) and, without `add`, the object's opacity and blend mode (exceptions: docs/mcp.md). With nothing selected (and no ids) the next object drawn takes the style instead (see appearance.newArt) → {newArt: true}",
"{name, add?: bool, ids?|id? (layers too), target?: \"object\"|\"contents\"} give the objects (default: the selection; a group or layer itself, its fills and strokes painting its members and its effects applying to them as one piece; target contents: the objects inside instead) the style's appearance, opacity, blend mode, isolate and knockout, and link them to it (placed gradients land at the same place relative to each object's bounds); add: true (Alt-click) adds its fills, strokes and effects on top of the existing appearance instead and unlinks. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (with `add`: of the kinds of row the style shows) and, without `add`, the object's opacity and blend mode (exceptions: docs/mcp.md); on a graph label, the graph stores the label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind. With nothing selected (and no ids) the next object drawn takes the style instead (see appearance.newArt) → {newArt: true}",
has_doc,
style_apply
),
@@ -68,7 +68,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Redefine Graphic Style",
["Window", "Appearance"],
None,
"{name?, id?} replace the style (default: the one the object was last linked to) with the look of object `id` or the first selected object (a graph series group with no fill, stroke, effect, opacity, blend mode, isolation, knockout or style link of its own: the topmost path directly in it); the objects still linked to it update, and that object becomes linked. A linked graph series group passes the style to the paths directly in it, other than the source and paths linked to the style whose look was edited, and is left cleared (as appearance.clear) and unlinked. On a linked path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md) → {name}",
"{name?, id?} replace the style (default: the one the object was last linked to) with the look of object `id` or the first selected object (a graph series group with no fill, stroke, effect, opacity, blend mode, isolation, knockout or style link of its own: the topmost path directly in it); the objects still linked to it update, and that object becomes linked. A linked graph series group passes the style to the paths directly in it, other than the source and paths linked to the style whose look was edited, and is left cleared (as appearance.clear) and unlinked. On a linked path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md); on a graph label, the graph stores the label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind → {name}",
has_doc,
style_redefine
),
@@ -332,7 +332,7 @@ fn style_apply(s: &mut Session, p: &Value) -> Result<Value> {
let i = style_index(&s.doc()?.doc, name)?;
let add = bool_or(p, "add", false);
let chars = s.prefs.override_char_color;
let own = super::appearance::appearance_targets(s, p)?;
let own = super::appearance::edit_targets(s, p)?;
let ids = super::appearance::rowless_series_marks(&s.doc()?.doc, &own);
// The series groups their paths stand for: a style that replaces the appearance clears what they hold themselves,
// and a style added on top unlinks them.
@@ -374,6 +374,8 @@ fn style_apply(s: &mut Session, p: &Value) -> Result<Value> {
super::appearance::clear_stood_for(d, &groups);
super::appearance::capture_series_writes(&before, d, &ids, (true, true), (true, true), false, true);
}
// A label keeps its new rows for its kind of label.
super::graph::capture_label_rows(d, &ids);
Ok(())
})?;
ok()
@@ -506,6 +508,7 @@ fn style_redefine(s: &mut Session, p: &Value) -> Result<Value> {
apply_style(d, *nid, &g, &mut shared, chars);
}
super::appearance::capture_series_writes(&before, d, &follow_marks, (true, true), (true, true), false, true);
super::graph::capture_label_rows(d, &follow_marks);
set_link(d, &[src], Some(id));
Ok(())
})?;
+6 -4
View File
@@ -161,11 +161,13 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "graphicStyle.addFromLibrary";
let (info, lib) = library_param(s, p, C)?;
let (apply, add) = (switch_or(p, "apply", false, C)?, switch_or(p, "add", false, C)?);
if p.get("ids").is_some_and(|v| !v.is_null()) {
let ids_given = p.get("ids").is_some_and(|v| !v.is_null());
if ids_given {
checked_ids_param(s, p, "ids", C)?;
} else if apply {
// A selection that can't take a style is refused before any style is added.
super::appearance::appearance_targets(s, &json!({}))?;
}
if apply {
// Objects that can't take a style are refused before any style is added.
super::appearance::edit_targets(s, &if ids_given { json!({"ids": p["ids"]}) } else { json!({}) })?;
}
let mut names = names_param(p, C)?;
names.extend(text_param(p, "name", C)?.map(str::to_string));
File diff suppressed because one or more lines are too long
+23 -2
View File
@@ -215,6 +215,11 @@ pub fn char_attrs(v: &CharStyle) -> Map<String, Value> {
("tateChuYoko", json!(v.tate_chu_yoko)),
("tcyUpDown", json!(v.tcy_up_down)),
("tcyLeftRight", json!(v.tcy_left_right)),
("warichu", json!(v.warichu)),
("warichuLines", json!(v.warichu_lines)),
("warichuGap", json!(v.warichu_gap)),
("warichuScale", json!(v.warichu_scale)),
("warichuAlign", json!(v.warichu_align)),
("akiLeft", json!(v.aki_left)),
("akiRight", json!(v.aki_right)),
("standardVerticalRoman", json!(v.standard_vertical_roman)),
@@ -573,6 +578,19 @@ fn attrs_param(d: &Document, p: &Value, kind: Kind, verb: &str, kept: Option<&Ma
return Err(bad(&c, format!("`{key}` must be from {lo} to {hi}")));
}
}
let lines = vectorcraft_doc::text::WARICHU_LINES;
if changed("warichuLines") && !lines.contains(&st.warichu_lines) {
return Err(bad(&c, format!("`warichuLines` must be a whole number from {} to {}", lines.start(), lines.end())));
}
let scale = vectorcraft_doc::text::WARICHU_SCALE;
for (key, value, lo, hi) in [
("warichuGap", st.warichu_gap, 0.0, vectorcraft_doc::text::MAX_WARICHU_GAP),
("warichuScale", st.warichu_scale, *scale.start(), *scale.end()),
] {
if changed(key) && !(lo..=hi).contains(&value) {
return Err(bad(&c, format!("`{key}` must be from {lo} to {hi}")));
}
}
// A language of the list, stored as its tag (as text.setFormat takes it).
if let Some(l) = &st.language
&& kept.and_then(|k| k.get("language")) != m.get("language")
@@ -1057,12 +1075,12 @@ fn unused(s: &mut Session, kind: Kind) -> Result<Value> {
/// The styles of `kind` in `src` that load, with their source names so that art in the file keeps
/// referring to them: not the Normal style, and not one whose attributes don't read, whose sizes,
/// scales, leading, shifts, Insert Aki or stroke width are out of the range `charStyle.new` takes, whose
/// scales, leading, shifts, Insert Aki, Warichu Settings or stroke width are out of the range `charStyle.new` takes, whose
/// indents, spacing, Hyphenation or Justification options or tab stops are out of the range
/// `paraStyle.new` takes, or whose name is empty, which are skipped. → (the styles, the names in
/// `src` of those skipped)
fn loadable(src: &Document, kind: Kind) -> (Vec<TextStyleDef>, Vec<String>) {
const RANGED: [&str; 18] = [
const RANGED: [&str; 21] = [
"size",
"h_scale",
"v_scale",
@@ -1072,6 +1090,9 @@ fn loadable(src: &Document, kind: Kind) -> (Vec<TextStyleDef>, Vec<String>) {
"akiRight",
"tcyUpDown",
"tcyLeftRight",
"warichuLines",
"warichuGap",
"warichuScale",
"stroke_width",
"left_indent",
"right_indent",
+4 -3
View File
@@ -824,8 +824,8 @@ fn load_all_styles_loads_both_kinds_in_one_step() {
assert_eq!(s.doc().unwrap().doc, before);
}
/// A loaded character style whose size, scale, leading, shift or stroke width is out of the range
/// `charStyle.new` takes is skipped.
/// A loaded character style whose size, scale, leading, shift, warichu Line Gap or stroke width is
/// out of the range `charStyle.new` takes is skipped.
#[test]
fn loaded_styles_out_of_range_are_skipped() {
let (mut s, _) = session_with_text("Title");
@@ -834,11 +834,12 @@ fn loaded_styles_out_of_range_are_skipped() {
("Flat", json!({"h_scale": 0.5})),
("Shifted", json!({"baseline_shift": 5000.0})),
("Thick", json!({"stroke_width": -2.0})),
("Gapped", json!({"warichuGap": 500.0})),
("Fine", json!({"size": 12.0, "h_scale": 50.0})),
];
let data = styles_file(&file, &[]);
let r = s.execute("charStyle.load", &json!({"dataBase64": data, "name": "Other.vectorcraft"})).unwrap();
assert_eq!(r, json!({"added": ["Fine"], "existing": [], "skipped": ["Huge", "Flat", "Shifted", "Thick"]}));
assert_eq!(r, json!({"added": ["Fine"], "existing": [], "skipped": ["Huge", "Flat", "Shifted", "Thick", "Gapped"]}));
assert_eq!(listed(&mut s, "charStyle"), [NORMAL_CHAR, "Fine"]);
}
+29 -2
View File
@@ -17,7 +17,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Make",
["Object", "Text Wrap"],
None,
"{offset?: pt (6), invert?: bool, ids?} area type below the selected objects (same layer) wraps around them → {ids}",
"{offset?: pt (6), invert?: bool, ids?} area type below the selected objects (same layer) wraps around them; a negative offset (to -1000) lets the text that far into them → {ids}",
has_selection,
make
),
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Text Wrap Options…",
["Object", "Text Wrap"],
None,
"{offset?: pt, invert?: bool, ids?} set the options of the selected wrap objects; no options → the current {offset, invert}",
"{offset?: pt, invert?: bool, ids?} set the options of the selected wrap objects (offset -1000 to 1000; a negative one lets text into the objects); no options → the current {offset, invert}",
has_selection,
options
),
@@ -186,6 +186,33 @@ mod tests {
assert_eq!(lines(&s, t), plain);
}
/// A negative offset from Text Wrap Options lets the lines beside a wrap object run that far
/// into it, the options report it, and one undo puts back the offset before it.
#[test]
fn text_wrap_options_negative_offset_reflows_and_round_trips() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 600, "height": 600})).unwrap();
let t = s
.execute("text.create", &json!({"x": 0, "y": 0, "text": "word ".repeat(200), "size": 12, "area": {"width": 400, "height": 400}}))
.unwrap()["id"]
.as_u64()
.unwrap();
let r = s.execute("shape.rectangle", &json!({"x": -50, "y": 50, "width": 250, "height": 100})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [r]})).unwrap();
s.execute("object.textWrap.make", &json!({})).unwrap();
let beside = |s: &Session| lines(s, t).into_iter().filter(|l| l.0 > 1.0).map(|l| l.0).collect::<Vec<_>>();
assert!(!beside(&s).is_empty() && beside(&s).iter().all(|x| (x - 206.0).abs() < 1e-6), "{:?}", beside(&s));
assert_eq!(s.execute("object.textWrap.options", &json!({"offset": -20})).unwrap()["offset"], json!(-20.0));
assert!(!beside(&s).is_empty() && beside(&s).iter().all(|x| (x - 180.0).abs() < 1e-6), "{:?}", beside(&s));
assert_eq!(s.execute("object.textWrap.options", &json!({})).unwrap()["offset"], json!(-20.0));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.execute("object.textWrap.options", &json!({})).unwrap()["offset"], json!(6.0));
assert!(beside(&s).iter().all(|x| (x - 206.0).abs() < 1e-6), "{:?}", beside(&s));
// An offset past -1000 is kept at -1000: the whole object lets text through.
assert_eq!(s.execute("object.textWrap.options", &json!({"offset": -5000})).unwrap()["offset"], json!(-1000.0));
assert!(lines(&s, t).iter().all(|l| l.0 < 1.0 && l.1 > 300.0));
}
#[test]
fn text_flows_on_both_sides_of_a_centred_object() {
let mut s = Session::new();
+152 -42
View File
@@ -68,11 +68,8 @@ pub fn specs() -> Vec<CommandSpec> {
fn has_thread_selection(s: &Session) -> std::result::Result<(), String> {
has_selection(s)?;
let st = s.active().ok_or("no document open")?;
if st.selection.objects.iter().any(|id| st.doc.text_threads.iter().any(|t| t.contains(id))) {
Ok(())
} else {
Err("no threaded text selected".into())
}
let selected: Ids = st.selection.objects.iter().copied().collect();
if st.doc.text_threads.iter().flatten().any(|id| selected.contains(id)) { Ok(()) } else { Err("no threaded text selected".into()) }
}
/// The text object `id` when it can be a frame of a thread: area type or type on a path.
@@ -87,6 +84,43 @@ fn text_of(d: &Document, id: NodeId) -> Option<&TextObject> {
thread_local! {
/// Tests: how many threads' stories [`reflow`] copied on this thread.
static COPIED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
/// Tests: how many frames [`leave_threads`] passed to [`continued_marks`] on this thread.
static MARK_VISITS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
/// Tests: how many lookups and insertions [`Ids`] made on this thread.
static ID_LOOKS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// A set of node ids, for the threaded-text commands to test whether a frame is selected, already
/// listed or in a thread in the same time however many ids the set holds.
#[derive(Clone, Default)]
struct Ids(std::collections::HashSet<NodeId>);
impl Ids {
fn contains(&self, id: &NodeId) -> bool {
#[cfg(test)]
ID_LOOKS.with(|c| c.set(c.get() + 1));
self.0.contains(id)
}
/// Add `id` to the set; whether it was not in it already.
fn insert(&mut self, id: NodeId) -> bool {
#[cfg(test)]
ID_LOOKS.with(|c| c.set(c.get() + 1));
self.0.insert(id)
}
}
impl FromIterator<NodeId> for Ids {
fn from_iter<I: IntoIterator<Item = NodeId>>(ids: I) -> Self {
Self(ids.into_iter().collect())
}
}
/// [`vectorcraft_text::thread::continued`] for the frames of a thread that [`leave_threads`] marks.
fn continued_marks(frames: &[&TextObject]) -> Vec<bool> {
#[cfg(test)]
MARK_VISITS.with(|c| c.set(c.get() + frames.len()));
vectorcraft_text::thread::continued(frames)
}
/// Re-flow every thread with a frame that changed between `before` and `doc` (structural sharing
@@ -361,20 +395,74 @@ pub(crate) fn mark_continued(doc: &mut Document) {
}
}
#[cfg(test)]
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_of`] and [`ThreadIndex::of`] have gone through the document's
/// threads on this thread.
#[cfg(test)]
pub(crate) fn thread_scans() -> usize {
THREAD_SCANS.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> {
#[cfg(test)]
THREAD_SCANS.with(|c| c.set(c.get() + 1));
let Some(thread) = d.text_threads.iter().find(|t| t.contains(&id)) else { return vec![] };
story_in(d, thread, id)
}
/// [`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> {
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![] }
}
/// A document's text threads looked up by frame, built in one pass over them, for a command that
/// asks [`ThreadIndex::threaded`] of each of many objects.
pub(crate) struct ThreadIndex<'a> {
doc: &'a Document,
/// Each object the threads list, with the index of the first thread that lists it.
first: std::collections::HashMap<NodeId, usize>,
/// Each thread's count of entries that are area type or type on a path.
live: Vec<usize>,
}
impl<'a> ThreadIndex<'a> {
pub(crate) fn of(doc: &'a Document) -> Self {
#[cfg(test)]
THREAD_SCANS.with(|c| c.set(c.get() + 1));
let mut first = std::collections::HashMap::new();
let mut live = Vec::with_capacity(doc.text_threads.len());
for (i, thread) in doc.text_threads.iter().enumerate() {
for id in thread {
first.entry(*id).or_insert(i);
}
live.push(thread.iter().filter(|id| text_of(doc, **id).is_some()).count());
}
Self { doc, first, live }
}
/// Whether `id` is a frame of a thread: [`story_of`] is not empty for it.
pub(crate) fn threaded(&self, id: NodeId) -> bool {
self.first.get(&id).and_then(|i| self.live.get(*i)).is_some_and(|n| *n >= 2) && text_of(self.doc, id).is_some()
}
}
/// `ids` followed by the other frames of the threads they are in, each once, in thread order.
pub(crate) fn with_threads(d: &Document, ids: &[NodeId]) -> Vec<NodeId> {
let given: Ids = ids.iter().copied().collect();
let mut listed = given.clone();
let mut out = ids.to_vec();
for thread in d.text_threads.iter().filter(|t| t.iter().any(|id| ids.contains(id))) {
for thread in d.text_threads.iter().filter(|t| t.iter().any(|id| given.contains(id))) {
for id in thread {
if !out.contains(id) && text_of(d, *id).is_some() {
if text_of(d, *id).is_some() && listed.insert(*id) {
out.push(*id);
}
}
@@ -399,13 +487,14 @@ pub(crate) fn frames(d: &Document) -> Vec<(NodeId, TextObject)> {
/// threaded and the frames before it end inside one. A threaded frame whose mark changes lays out
/// its first line again, so its thread re-flows after the edit.
pub(crate) fn leave_threads(d: &mut Document, was: &[(NodeId, TextObject)]) {
let gone: Vec<(NodeId, &TextObject, Option<NodeId>)> =
was.iter().filter(|(id, _)| text_of(d, *id).is_none()).map(|(id, t)| (*id, t, kept_type(d, *id))).collect();
// Each frame that left, with the type that takes its place.
let gone: std::collections::HashMap<NodeId, (&TextObject, Option<NodeId>)> =
was.iter().filter(|(id, _)| text_of(d, *id).is_none()).map(|(id, t)| (*id, (t, kept_type(d, *id)))).collect();
if gone.is_empty() {
return;
}
let find = |id: &NodeId| gone.iter().find(|(g, _, _)| g == id);
let mut stayed = vec![];
let find = |id: &NodeId| gone.get(id);
let mut changed = vec![];
for i in 0..d.text_threads.len() {
let thread = d.text_threads[i].clone();
if !thread.iter().any(|id| find(id).is_some()) {
@@ -414,28 +503,33 @@ pub(crate) fn leave_threads(d: &mut Document, was: &[(NodeId, TextObject)]) {
let keep: Vec<NodeId> = thread
.iter()
.filter_map(|id| match find(id) {
Some((_, _, kept)) => *kept,
Some((_, kept)) => *kept,
None => Some(*id),
})
.collect();
if let (Some((_, last, None)), Some(stay)) = (thread.last().and_then(find), keep.last()) {
if let (Some((last, None)), Some(stay)) = (thread.last().and_then(find), keep.last()) {
take_overflow(d, *stay, last);
}
stayed.extend(keep.iter().copied());
changed.push(keep.clone());
d.text_threads[i] = keep;
}
d.text_threads.retain(|t| t.len() >= 2);
let marks: Vec<(NodeId, bool)> = stayed
.iter()
.map(|id| {
let on = d.text_threads.iter().find(|t| t.contains(id)).is_some_and(|t| {
let live: Vec<(NodeId, &TextObject)> = t.iter().filter_map(|i| Some((*i, text_of(d, *i)?))).collect();
let frames: Vec<&TextObject> = live.iter().map(|(_, f)| *f).collect();
live.iter().zip(vectorcraft_text::thread::continued(&frames)).any(|((i, _), on)| on && i == id)
});
(*id, on)
})
.collect();
// One pass over each changed thread: a frame left alone continues nothing, and the area type and
// type on a path of a thread that stays take their marks in thread order.
let mut marks: Vec<(NodeId, bool)> = vec![];
for keep in &changed {
if keep.len() < 2 {
marks.extend(keep.iter().map(|id| (*id, false)));
continue;
}
let live: Vec<(NodeId, &TextObject)> = keep.iter().filter_map(|i| Some((*i, text_of(d, *i)?))).collect();
let frames: Vec<&TextObject> = live.iter().map(|(_, f)| *f).collect();
let mut on: std::collections::HashMap<NodeId, bool> = std::collections::HashMap::new();
for ((id, _), mark) in live.iter().zip(continued_marks(&frames)) {
*on.entry(*id).or_default() |= mark;
}
marks.extend(keep.iter().map(|id| (*id, on.get(id).copied().unwrap_or(false))));
}
for (id, on) in marks {
if text_of(d, id).is_some_and(|t| t.first_para_continues != on)
&& let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind)
@@ -450,21 +544,23 @@ pub(crate) fn leave_threads(d: &mut Document, was: &[(NodeId, TextObject)]) {
/// thread with frames outside `art`, to the text it shows. When such a frame leaves its thread,
/// [`leave_threads`] puts the text it has no room for after the text of the last frame that stays.
pub(crate) fn keep_shown_text(d: &Document, art: &mut Node) {
let mut inside = vec![];
art.walk(&mut |n| inside.push(n.id));
let last: Vec<NodeId> = d
let mut inside = Ids::default();
art.walk(&mut |n| {
inside.insert(n.id);
});
let last: Ids = d
.text_threads
.iter()
.filter(|t| t.iter().any(|id| !inside.contains(id)))
.filter_map(|t| t.last().copied().filter(|id| inside.contains(id)))
.collect();
if !last.is_empty() {
if !last.0.is_empty() {
cut_to_shown(art, &last);
}
}
/// Cut the area type and type on a path `ids` in `n` to the text each one shows.
fn cut_to_shown(n: &mut Node, ids: &[NodeId]) {
fn cut_to_shown(n: &mut Node, ids: &Ids) {
if ids.contains(&n.id)
&& let NodeKind::Text(t) = &mut n.kind
&& !matches!(t.kind, TextKind::Point)
@@ -567,21 +663,35 @@ fn create(s: &mut Session, _: &Value) -> Result<Value> {
*d.node_mut(*id).ok_or(EngineError::NoNode(*id))? = f;
}
}
// Existing threads containing any of these frames merge into the new one, keeping order.
// Existing threads containing any of these frames merge into the new one, keeping order:
// each frame takes in the first thread not yet taken in that lists it.
let mut threads: Vec<Option<Vec<NodeId>>> = std::mem::take(&mut d.text_threads).into_iter().map(Some).collect();
let mut listing: std::collections::HashMap<NodeId, Vec<usize>> = std::collections::HashMap::new();
for (i, t) in threads.iter().enumerate() {
for id in t.iter().flatten() {
listing.entry(*id).or_default().push(i);
}
}
let mut order: Vec<NodeId> = vec![];
let mut listed = Ids::default();
for id in &ids {
match d.text_threads.iter().position(|t| t.contains(id)) {
Some(i) => {
for x in d.text_threads.remove(i) {
if !order.contains(&x) {
let thread = listing.get(id).and_then(|at| at.iter().find_map(|i| threads[*i].take()));
match thread {
Some(thread) => {
for x in thread {
if listed.insert(x) {
order.push(x);
}
}
}
None if !order.contains(id) => order.push(*id),
None => {}
None => {
if listed.insert(*id) {
order.push(*id);
}
}
}
}
d.text_threads = threads.into_iter().flatten().collect();
d.text_threads.push(order);
// Force a re-flow of the new thread on this edit.
touch(d, ids[0]);
@@ -706,11 +816,12 @@ fn touch(d: &mut Document, id: NodeId) {
}
fn release(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
let ids: Ids = selected_roots(s)?.into_iter().collect();
s.edit("Release Threaded Text", |d, _| {
// The released frames' text joins the rest of the story (they become empty).
for thread in d.text_threads.clone() {
let (out, keep): (Vec<NodeId>, Vec<NodeId>) = thread.iter().partition(|i| ids.contains(i));
for i in 0..d.text_threads.len() {
let thread = d.text_threads[i].clone();
let (out, keep): (Vec<NodeId>, Vec<NodeId>) = thread.iter().partition(|id| ids.contains(id));
if out.is_empty() {
continue;
}
@@ -750,7 +861,6 @@ fn release(s: &mut Session, _: &Value) -> Result<Value> {
t.first_para_continues = false;
refresh_bounds(t);
}
let i = d.text_threads.iter().position(|t| *t == thread).unwrap_or(0);
d.text_threads[i] = keep;
}
d.text_threads.retain(|t| t.len() >= 2);
@@ -760,7 +870,7 @@ fn release(s: &mut Session, _: &Value) -> Result<Value> {
}
fn remove(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
let ids: Ids = selected_roots(s)?.into_iter().collect();
s.edit("Remove Threading", |d, _| {
let (gone, kept): (Vec<Vec<NodeId>>, Vec<Vec<NodeId>>) =
std::mem::take(&mut d.text_threads).into_iter().partition(|t| t.iter().any(|i| ids.contains(i)));
+262
View File
@@ -245,6 +245,28 @@ fn a_frame_left_alone_by_a_dropped_repeat_stops_continuing() {
assert_eq!(t.plain_text(), "Tail");
}
/// [`ThreadIndex::threaded`] answers as [`story_of`] does, by the first thread that lists an
/// object, also for threads listed more than once, with point type, other art and missing ids.
#[test]
fn thread_index_matches_story_of() {
let (mut s, a, b) = setup();
s.execute("text.thread.create", &json!({})).unwrap();
let c = s.execute("text.create", &json!({"x": 400, "y": 10, "text": "C", "area": {"width": 150, "height": 60}})).unwrap()["id"].as_u64().unwrap();
let p = s.execute("text.create", &json!({"x": 10, "y": 250, "text": "Point"})).unwrap()["id"].as_u64().unwrap();
let r = s.execute("shape.rectangle", &json!({"x": 400, "y": 200, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap();
let mut d = (*s.doc().unwrap().doc).clone();
let [fa, fb, fc, fp, fr, fx] = [a, b, c, p, r, 9_999_999].map(NodeId);
// a's first thread has one frame beside point type, c's has one beside a rectangle, and b's
// lists a twice.
d.text_threads = vec![vec![fp, fa], vec![fa, fb, fa], vec![fc, fr], vec![fc, fb], vec![fx, fb]];
let index = ThreadIndex::of(&d);
let got: Vec<(bool, bool)> = [fa, fb, fc, fp, fr, fx].iter().map(|id| (index.threaded(*id), !story_of(&d, *id).is_empty())).collect();
let want: Vec<(bool, bool)> = [false, true, false, false, false, false].iter().map(|t| (*t, *t)).collect();
assert_eq!(got, want);
d.text_threads.clear();
assert!(!ThreadIndex::of(&d).threaded(fb));
}
#[test]
fn edits_copy_only_the_stories_of_threads_whose_frames_changed() {
let (mut s, a, b) = setup();
@@ -1053,6 +1075,246 @@ fn a_frame_that_starts_its_thread_after_outlining_has_its_first_line_indent() {
}
}
/// Whether frame `id` of `d` is marked as continuing a paragraph.
fn marked(d: &Document, id: NodeId) -> bool {
text_of(d, id).unwrap().first_para_continues
}
/// The marks of `thread`'s frames, each found from the frames before it: a frame continues a
/// paragraph when the last of them with text does not end with a paragraph break. A frame left
/// alone continues nothing.
fn marks_by_scan(d: &Document, thread: &[NodeId]) -> Vec<bool> {
(0..thread.len())
.map(|k| {
thread.len() >= 2
&& thread[..k]
.iter()
.rev()
.map(|id| text_of(d, *id).unwrap().plain_text())
.find(|t| !t.is_empty())
.is_some_and(|t| !t.ends_with('\n'))
})
.collect()
}
/// When frames leave their threads (from the start, the middle or the end of a thread, outlined or
/// with their type under a new id, leaving a thread with one frame), each frame that stays takes
/// the mark found from the frames before it, and a thread that lost no frame keeps its marks.
#[test]
fn frames_that_stay_after_others_leave_their_threads_take_the_marks_of_their_place() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
let mut frame = |text: &str| {
NodeId(new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": text, "area": {"width": 150, "height": 60}})).unwrap()))
};
let [a1, a2, a3, a4, a5] = ["One", "", "Two\n", "Three", "Four"].map(&mut frame);
let [b1, b2] = ["Alpha", "Beta"].map(&mut frame);
let [c1, c2] = ["x", "y"].map(&mut frame);
let [d1, d2, d3, d4] = ["M", "N\n", "O", "P"].map(&mut frame);
let rect = NodeId(new_id(s.execute("shape.rectangle", &json!({"x": 400, "y": 400, "width": 20, "height": 20})).unwrap()));
let mut d = (*s.doc().unwrap().doc).clone();
d.text_threads = vec![vec![a1, a2, a3, a4, a5], vec![b1, b2], vec![c1, c2], vec![d1, d2, d3, d4]];
// Every mark is the opposite of what it becomes, or (in the thread that loses no frame) wrong.
for id in [a1, a2, a3, a4, a5, b1, b2, c1, d1, d2, d3, d4] {
if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) {
t.first_para_continues = true;
}
}
let shown: Vec<(NodeId, String)> = d.text_threads.iter().flatten().map(|id| (*id, text_of(&d, *id).unwrap().plain_text())).collect();
let was = frames(&d);
// a1, b2 and d3 become a rectangle; a4 becomes a group holding its type under a new id.
let outline = d.node(rect).unwrap().kind.clone();
for id in [a1, b2, d3] {
d.node_mut(id).unwrap().kind = outline.clone();
}
let mut kept = d.node(a4).unwrap().clone();
kept.id = d.alloc_id();
let new_a4 = kept.id;
let layer = d.parent_of(a4);
d.remove(a4).unwrap();
d.insert(layer, usize::MAX, Node::group(a4, vec![std::sync::Arc::new(kept)])).unwrap();
leave_threads(&mut d, &was);
assert_eq!(d.text_threads, vec![vec![a2, a3, new_a4, a5], vec![c1, c2], vec![d1, d2, d4]]);
for thread in [&[a2, a3, new_a4, a5][..], &[b1], &[d1, d2, d4]] {
let want = marks_by_scan(&d, thread);
assert_eq!(thread.iter().map(|id| marked(&d, *id)).collect::<Vec<_>>(), want, "{thread:?}");
}
assert_eq!([a2, a3, new_a4, a5, b1, d1, d2, d4].map(|id| marked(&d, id)), [false, false, false, true, false, false, true, false]);
assert_eq!([c1, c2].map(|id| marked(&d, id)), [true, false], "a thread that lost no frame keeps its marks");
assert!(!matches!(d.node(a4).unwrap().kind, NodeKind::Text(_)), "the group in the frame's place is not type");
for (id, before) in shown {
let now = if id == a4 { new_a4 } else { id };
if let Some(t) = text_of(&d, now) {
assert_eq!(t.plain_text(), before, "frame {id:?} keeps its text");
}
}
}
/// The frames that stay in a long thread after one leaves are marked in one pass over the thread.
#[test]
fn a_long_thread_is_marked_in_one_pass_when_a_frame_leaves() {
const FRAMES: usize = 256;
let mut s = Session::new();
s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap();
let first = NodeId(new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": "w0", "area": {"width": 150, "height": 60}})).unwrap()));
let rect = NodeId(new_id(s.execute("shape.rectangle", &json!({"x": 400, "y": 200, "width": 20, "height": 20})).unwrap()));
let mut d = (*s.doc().unwrap().doc).clone();
let layer = d.parent_of(first);
let model = d.node(first).unwrap().clone();
let mut thread = vec![first];
for k in 1..FRAMES {
let mut n = model.clone();
n.id = d.alloc_id();
if let NodeKind::Text(t) = &mut n.kind {
t.runs.truncate(1);
t.runs[0].text = format!("w{k}");
t.first_para_continues = true;
}
thread.push(d.insert(layer, usize::MAX, n).unwrap());
}
d.text_threads = vec![thread.clone()];
let was = frames(&d);
let out = thread[FRAMES / 2];
d.node_mut(out).unwrap().kind = d.node(rect).unwrap().kind.clone();
MARK_VISITS.with(|c| c.set(0));
leave_threads(&mut d, &was);
let visits = MARK_VISITS.with(std::cell::Cell::get);
let rest: Vec<NodeId> = thread.iter().copied().filter(|id| *id != out).collect();
assert_eq!(d.text_threads, vec![rest.clone()]);
assert!(visits <= FRAMES, "{visits} frames visited to mark a thread of {} frames", rest.len());
assert_eq!(rest.iter().map(|id| marked(&d, *id)).collect::<Vec<_>>(), marks_by_scan(&d, &rest));
}
/// `count` threads of `per` area type frames each, made in one edit, each frame holding a word.
fn many_threads(count: usize, per: usize) -> (Session, Vec<Vec<NodeId>>) {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap();
let first = NodeId(new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": "w", "area": {"width": 150, "height": 60}})).unwrap()));
let threads = s
.edit("Make", |d, _| {
let layer = d.parent_of(first);
let model = d.node(first).unwrap().clone();
let mut threads = vec![];
for _ in 0..count {
let mut thread = vec![];
for _ in 0..per {
let mut n = model.clone();
n.id = d.alloc_id();
thread.push(d.insert(layer, usize::MAX, n).unwrap());
}
threads.push(thread);
}
d.text_threads = threads.clone();
Ok(threads)
})
.unwrap();
assert_eq!(s.doc().unwrap().doc.text_threads, threads);
(s, threads)
}
/// Select the first frame of each of `threads`.
fn select_firsts(s: &mut Session, threads: &[Vec<NodeId>]) {
let ids: Vec<u64> = threads.iter().map(|t| t[0].0).collect();
s.execute("select.set", &json!({ "ids": ids })).unwrap();
}
/// Reset the count of [`Ids`] lookups, run `f`, and give its result with the lookups it made.
fn looks<T>(f: impl FnOnce() -> T) -> (T, usize) {
ID_LOOKS.with(|c| c.set(0));
let out = f();
(out, ID_LOOKS.with(std::cell::Cell::get))
}
/// Whether Release Selection and Remove Threading are available is found in one pass over the
/// threads' frames, however many frames are selected.
#[test]
fn thread_commands_are_offered_after_one_pass_over_the_threads() {
let (mut s, made) = many_threads(300, 3);
let (threads, loose) = made.split_at(150);
s.edit("Unthread", |d, _| {
d.text_threads = threads.to_vec();
Ok(())
})
.unwrap();
// 450 frames out of any thread, then the last frame of the last thread.
let mut ids: Vec<u64> = loose.iter().flatten().map(|id| id.0).collect();
ids.push(threads[149][2].0);
s.execute("select.set", &json!({ "ids": ids })).unwrap();
let (offered, n) = looks(|| has_thread_selection(&s));
assert!(offered.is_ok());
assert!(n <= 2 * (451 + 450), "{n} id lookups");
s.execute("select.set", &json!({ "ids": ids[..450] })).unwrap();
assert!(has_thread_selection(&s).is_err(), "frames out of any thread are no threaded text");
let rect = new_id(s.execute("shape.rectangle", &json!({"x": 400, "y": 200, "width": 20, "height": 20})).unwrap());
s.execute("select.set", &json!({"ids": [rect]})).unwrap();
assert!(has_thread_selection(&s).is_err(), "a rectangle is no threaded text");
}
/// The frames of the selected frames' threads are listed with one lookup per frame.
#[test]
fn the_frames_threaded_with_a_selection_are_listed_in_one_pass() {
let (s, threads) = many_threads(150, 3);
let firsts: Vec<NodeId> = threads.iter().map(|t| t[0]).collect();
let d = &s.doc().unwrap().doc;
let (all, n) = looks(|| with_threads(d, &firsts));
let mut want = firsts.clone();
want.extend(threads.iter().flat_map(|t| t[1..].iter().copied()));
assert_eq!(all, want);
assert!(n <= 4 * (150 + 450), "{n} id lookups");
}
/// The last frames of many threads copied out of them are found with a few lookups per frame and
/// per object copied.
#[test]
fn the_last_frames_copied_out_of_many_threads_are_found_in_one_pass() {
let (s, threads) = many_threads(150, 3);
let d = &s.doc().unwrap().doc;
let lasts: Vec<std::sync::Arc<Node>> = threads.iter().map(|t| std::sync::Arc::new(d.node(t[2]).unwrap().clone())).collect();
let mut art = Node::group(NodeId(u64::MAX), lasts);
let before = art.clone();
let ((), n) = looks(|| keep_shown_text(d, &mut art));
assert!(n <= 4 * (151 + 450), "{n} id lookups");
assert_eq!(art, before, "each copied frame already shows all of its word");
}
/// Threading one frame of each of many threads merges them in one pass, in the order of the
/// selection.
#[test]
fn creating_a_thread_from_many_threads_merges_them_in_one_pass() {
let (mut s, threads) = many_threads(150, 3);
select_firsts(&mut s, &threads);
let (made, n) = looks(|| s.execute("text.thread.create", &json!({})));
made.unwrap();
assert!(n <= 8 * (150 + 450), "{n} id lookups");
assert_eq!(s.doc().unwrap().doc.text_threads, vec![threads.concat()]);
}
/// Release Selection on one frame of each of many threads finds the selected frames with one
/// lookup per frame.
#[test]
fn releasing_frames_of_many_threads_takes_one_pass() {
let (mut s, threads) = many_threads(150, 3);
select_firsts(&mut s, &threads);
let (released, n) = looks(|| s.execute("text.thread.releaseSelection", &json!({})));
released.unwrap();
assert!(n <= 2 * 450, "{n} id lookups");
let rest: Vec<Vec<NodeId>> = threads.iter().map(|t| t[1..].to_vec()).collect();
assert_eq!(s.doc().unwrap().doc.text_threads, rest);
}
/// Remove Threading on the last frame of each of many threads finds the selected frames with one
/// lookup per frame.
#[test]
fn removing_the_threads_of_many_frames_takes_one_pass() {
let (mut s, threads) = many_threads(150, 3);
let lasts: Vec<u64> = threads.iter().map(|t| t[2].0).collect();
s.execute("select.set", &json!({ "ids": lasts })).unwrap();
let (removed, n) = looks(|| s.execute("text.thread.remove", &json!({})));
removed.unwrap();
assert!(n <= 2 * 450, "{n} id lookups");
assert!(s.doc().unwrap().doc.text_threads.is_empty());
}
/// The text that a thread's last frame has no room for is measured as the frame lays it out,
/// without the first-line indent of the paragraph it continues: when the frame is outlined, the
/// frame before it takes only that text.
+2 -2
View File
@@ -1225,8 +1225,8 @@ fn area_options(s: &mut Session, p: &Value) -> Result<Value> {
.map(|(id, t)| merged(&t.area, t.dynamic_text).map(|o| (id, o)))
.collect::<Result<_>>()?;
// Dynamic Text's Block is refused for threaded frames, as `type.dynamicText` refuses it.
let frames: Vec<String> =
planned.iter().filter(|(id, (_, block))| *block && !super::threads::story_of(d, *id).is_empty()).map(|(id, _)| id.0.to_string()).collect();
let threads = super::threads::ThreadIndex::of(d);
let frames: Vec<String> = planned.iter().filter(|(id, (_, block))| *block && threads.threaded(*id)).map(|(id, _)| id.0.to_string()).collect();
if !frames.is_empty() {
let frames = frames.join(", ");
return Err(bad(C, format!("area type in a thread cannot use Dynamic Text: {frames}")));
+102
View File
@@ -1663,6 +1663,108 @@ fn tate_chu_yoko_settings_are_character_attributes_saved_only_when_set() {
assert_eq!(offsets(&s), before);
}
/// Warichu and Warichu Settings (Character panel menu, East Asian options): character attributes
/// saved only when they differ from their defaults (off, 2 lines, no gap, 50%, Auto). Both
/// commands set them in one undo step, a call that changes nothing records none, a bad value is
/// refused naming the key before anything changes, the file and character styles keep them, and
/// SVG and PDF write type with warichu as outlines.
#[test]
fn warichu_is_a_character_attribute_set_saved_and_exported() {
use vectorcraft_doc::WarichuAlign;
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "ab cdef g"})).unwrap()["id"].as_u64().unwrap();
let runs = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
NodeKind::Text(t) => t.runs.clone(),
_ => panic!("text"),
};
let settings = |s: &Session| {
runs(s)
.iter()
.map(|r| (r.text.clone(), r.style.warichu, r.style.warichu_lines, r.style.warichu_gap, r.style.warichu_scale, r.style.warichu_align))
.collect::<Vec<_>>()
};
let saved = serde_json::to_value(&runs(&s)[0].style).unwrap();
assert!(["warichu", "warichuLines", "warichuGap", "warichuScale", "warichuAlign"].iter().all(|k| saved.get(*k).is_none()), "{saved}");
let plain_svg = vectorcraft_svg::export(&s.doc().unwrap().doc, &Default::default());
let plain_pdf = vectorcraft_pdf::export(&s.doc().unwrap().doc, &vectorcraft_pdf::PdfOptions::uncompressed()).unwrap();
let text_ops = |pdf: &[u8]| {
let s = String::from_utf8_lossy(pdf);
s.contains("Tj") || s.contains("TJ")
};
assert!(plain_svg.contains("<text") && text_ops(&plain_pdf));
// text.setFormat with the values the characters already have changes nothing and records no
// undo step.
s.execute("select.set", &json!({"ids": [id]})).unwrap();
let undo = s.doc().unwrap().history.undo.len();
s.execute("text.setFormat", &json!({"warichu": false, "warichuLines": 2, "warichuAlign": "auto"})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
s.execute("select.set", &json!({"ids": []})).unwrap();
let undo = s.doc().unwrap().history.undo.len();
let p = json!({"id": id, "start": 3, "end": 7, "warichu": true, "warichuLines": 3, "warichuGap": 2.5, "warichuScale": 40, "warichuAlign": "justifyAll"});
s.execute("text.setRangeStyle", &p).unwrap();
let want = vec![
("ab ".to_string(), false, 2, 0.0, 50.0, WarichuAlign::Auto),
("cdef".to_string(), true, 3, 2.5, 40.0, WarichuAlign::JustifyAll),
(" g".to_string(), false, 2, 0.0, 50.0, WarichuAlign::Auto),
];
assert_eq!(settings(&s), want);
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
let saved = serde_json::to_value(&runs(&s)[1].style).unwrap();
assert_eq!(
[&saved["warichu"], &saved["warichuLines"], &saved["warichuGap"], &saved["warichuScale"], &saved["warichuAlign"]],
[&json!(true), &json!(3), &json!(2.5), &json!(40.0), &json!("justifyAll")]
);
// Saved and reopened as they were.
let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
let NodeKind::Text(t) = &back.node(NodeId(id)).unwrap().kind else { panic!("text") };
assert_eq!(
t.runs.iter().map(|r| (r.style.warichu, r.style.warichu_lines, r.style.warichu_align)).collect::<Vec<_>>(),
[(false, 2, WarichuAlign::Auto), (true, 3, WarichuAlign::JustifyAll), (false, 2, WarichuAlign::Auto)]
);
// A character style captured from the characters carries them.
let attrs = super::super::textstyles::char_attrs(&runs(&s)[1].style);
assert_eq!(
(attrs["warichu"].clone(), attrs["warichuLines"].clone(), attrs["warichuAlign"].clone()),
(json!(true), json!(3), json!("justifyAll"))
);
// Exports draw the stacked lines as outlines.
let svg = vectorcraft_svg::export(&s.doc().unwrap().doc, &Default::default());
assert!(!svg.contains("<text"), "{svg}");
let pdf = vectorcraft_pdf::export(&s.doc().unwrap().doc, &vectorcraft_pdf::PdfOptions::uncompressed()).unwrap();
assert!(!text_ops(&pdf));
// Undo takes the whole change back.
s.execute("edit.undo", &json!({})).unwrap();
assert!(settings(&s).iter().all(|r| !r.1 && r.2 == 2));
s.execute("edit.redo", &json!({})).unwrap();
// text.setFormat sets the selected objects' characters.
s.execute("select.set", &json!({"ids": [id]})).unwrap();
s.execute("text.setFormat", &json!({"warichuScale": 70})).unwrap();
assert!(settings(&s).iter().all(|r| r.4 == 70.0));
let before = settings(&s);
for (cmd, p, want) in [
("text.setFormat", json!({"warichuLines": 7}), "`warichuLines` must be a whole number from 1 to 6"),
("text.setFormat", json!({"warichuLines": 2.5}), "`warichuLines` must be a whole number from 1 to 6"),
("text.setFormat", json!({"warichuScale": 5}), "`warichuScale` must be from 10 to 100"),
("text.setFormat", json!({"warichuGap": -1}), "`warichuGap` must be from 0 to 100"),
("text.setRangeStyle", json!({"id": id, "warichuGap": "2"}), "`warichuGap` must be a number"),
("text.setRangeStyle", json!({"id": id, "warichu": "yes"}), "`warichu` must be true or false"),
(
"text.setRangeStyle",
json!({"id": id, "warichuAlign": "middle"}),
"`warichuAlign` must be one of \"auto\", \"left\", \"center\", \"right\", \"justifyLeft\", \"justifyCenter\", \"justifyRight\", \"justifyAll\"",
),
] {
let e = s.execute(cmd, &p).unwrap_err();
assert!(matches!(&e, EngineError::BadParams { msg, .. } if msg == want), "{cmd} {p}: {e}");
}
assert_eq!(settings(&s), before);
}
/// Insert Aki Left and Right (Character panel, East Asian options): character attributes in
/// thousandths of an em, 0 by default and saved only when not 0. Both commands set either side
/// alone, keeping the other side of every run, in one undo step; a value that is not a number or
+1
View File
@@ -220,6 +220,7 @@ pub(crate) fn block_fit(t: &mut TextObject) -> bool {
st.baseline_shift *= f;
st.tcy_up_down *= f;
st.tcy_left_right *= f;
st.warichu_gap *= f;
st.leading = Some((st.size + gap).max(min_leading));
if let Some(art) = &mut piece.inline {
art.baseline_shift *= f;
+10 -13
View File
@@ -7,6 +7,7 @@ use vectorcraft_doc::{AreaFit, CharStyle, Document, Justify, LockedArtboards, No
use vectorcraft_geom::{Affine, Rect, shapes};
use super::object::choice;
use super::threads::ThreadIndex;
use super::typecmd::{area_text, block_gaps, needed_room, refresh_bounds};
use super::{
CommandSpec, EngineError, Result, Session, bad, checked_ids_param, cmd, find_command, has_clipboard, has_doc, has_selection, number_or,
@@ -556,7 +557,7 @@ fn path_center_refusal(d: &Document, ids: &[NodeId]) -> Option<String> {
let open = open.join(", ");
return Some(format!("type on an open or compound path cannot be centered on its top or bottom: {open}"));
}
let frames = threaded_ids(d, ids);
let frames = threaded_ids(&ThreadIndex::of(d), ids);
(!frames.is_empty()).then(|| format!("type on a path in a thread cannot be centered on its top or bottom: {frames}"))
}
@@ -783,11 +784,6 @@ fn path_options(s: &mut Session, p: &Value) -> Result<Value> {
options(s)
}
/// Whether type `id` is a frame of a thread.
fn threaded(d: &Document, id: NodeId) -> bool {
!super::threads::story_of(d, id).is_empty()
}
/// Type on a path or area type: the type Dynamic Text works on.
fn dynamic_type(n: Option<&Node>) -> Option<&TextObject> {
path_text(n).or_else(|| area_text(n))
@@ -820,17 +816,18 @@ fn has_dynamic_type(s: &Session) -> std::result::Result<(), String> {
/// path first; None when none is.
fn threaded_refusal(d: &Document, ids: &[NodeId]) -> Option<String> {
let (paths, areas): (Vec<NodeId>, Vec<NodeId>) = ids.iter().copied().partition(|id| path_text(d.node(*id)).is_some());
let frames = threaded_ids(d, &paths);
let threads = ThreadIndex::of(d);
let frames = threaded_ids(&threads, &paths);
if !frames.is_empty() {
return Some(format!("type on a path in a thread cannot use Dynamic Text: {frames}"));
}
let frames = threaded_ids(d, &areas);
let frames = threaded_ids(&threads, &areas);
(!frames.is_empty()).then(|| format!("area type in a thread cannot use Dynamic Text: {frames}"))
}
/// The ids among `ids` of frames of threads, as a list for a message; empty when there are none.
fn threaded_ids(d: &Document, ids: &[NodeId]) -> String {
ids.iter().filter(|id| threaded(d, **id)).map(|id| id.0.to_string()).collect::<Vec<_>>().join(", ")
/// The ids among `ids` of frames of `threads`, as a list for a message; empty when there are none.
fn threaded_ids(threads: &ThreadIndex, ids: &[NodeId]) -> String {
ids.iter().filter(|id| threads.threaded(**id)).map(|id| id.0.to_string()).collect::<Vec<_>>().join(", ")
}
/// Type › Dynamic Text: set `on` (without it, the opposite of the first object's setting, or on
@@ -969,7 +966,7 @@ fn has_preset_type(s: &Session) -> std::result::Result<(), String> {
if ids.is_empty() {
return Err("select point type or type on a path".into());
}
let frames = threaded_ids(d, &ids);
let frames = threaded_ids(&ThreadIndex::of(d), &ids);
if !frames.is_empty() {
return Err(format!("type on a path in a thread cannot use Dynamic Text: {frames}"));
}
@@ -1108,7 +1105,7 @@ fn dynamic_text_preset(s: &mut Session, p: &Value) -> Result<Value> {
if ids.is_empty() {
return Err(bad(C, "select point type or type on a path"));
}
let frames = threaded_ids(d, &ids);
let frames = threaded_ids(&ThreadIndex::of(d), &ids);
if !frames.is_empty() {
return Err(bad(C, format!("type on a path in a thread cannot use Dynamic Text: {frames}")));
}
+40 -11
View File
@@ -21,7 +21,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Free Distort",
[],
None,
"{corners: [[x,y]×4] (TL, TR, BR, BL), from?: [x0,y0,x1,y1] (default: selection bounds), angle?: degrees (counter-clockwise; `from`, or the selection bounds, is then a box in the frame turned by it, as a rotated bounding box is; default 0), ids?} projective warp of anchors and handles; a linked opacity mask's art is warped with its object, an unlinked one stays",
"{corners: [[x,y]×4] (TL, TR, BR, BL), from?: [x0,y0,x1,y1] (default: selection bounds), angle?: degrees (counter-clockwise; `from`, or the selection bounds, is then a box in the frame turned by it, as a rotated bounding box is; default 0), ids?} projective warp of anchors and handles; a linked opacity mask's art is warped with its object, an unlinked one stays; each warped object's bounding box (the objects inside groups and the art of linked masks too) turns as the warp turns the art at its center (object.resetBoundingBox squares it again); corners where the box already stands change nothing and add no undo step",
has_doc,
distort
),
@@ -109,11 +109,18 @@ impl Projective {
/// Warp `n`'s anchors and handles; gradients (pinned first if unplaced) follow the warp's affine
/// approximation at their center. The art of a linked opacity mask is warped with the object it
/// masks; an unlinked one stays where it is.
fn warp_node(n: &mut Node, pr: &Projective) {
/// masks; an unlinked one stays where it is. Each warped object's bounding-box angle follows the
/// warp's affine approximation at that object's own center before the warp (as for the objects
/// with no editable points); an object with no bounds keeps its angle, and a layer has none.
/// Returns `n`'s geometric bounds before the warp ([`Node::geometric_bounds`]; none for a layer),
/// gathered from its children's so that each subtree is measured once.
fn warp_node(n: &mut Node, pr: &Projective) -> Option<Rect> {
n.pin_gradients();
match &mut n.kind {
// The children before these are left out of a group's or compound path's bounds.
let skip = n.children().map_or(0, Vec::len) - n.editing_children().len();
let before = match &mut n.kind {
NodeKind::Path { path, live, .. } => {
let b = path.bounds();
*live = None;
for sp in &mut path.subpaths {
for a in &mut sp.anchors {
@@ -122,24 +129,41 @@ fn warp_node(n: &mut Node, pr: &Projective) {
a.h_out = pr.apply(a.h_out);
}
}
b
}
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => {
for c in children.iter_mut() {
warp_node(Arc::make_mut(c), pr);
}
NodeKind::Layer { children, .. } => {
warp_children(children, pr, |_| false);
None
}
// A clip group is bounded by its clip path.
NodeKind::Group { children, clip: true } => warp_children(children, pr, |i| i == 0),
NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => warp_children(children, pr, |i| i >= skip),
_ => {
// No editable points: the affine approximation (which maps the gradients too).
if let Some(b) = n.geometric_bounds() {
let b = n.geometric_bounds();
if let Some(b) = b {
n.transform(pr.affine_near(b.center()), false);
}
return;
return b;
}
};
if let Some(b) = before {
n.bbox_angle = vectorcraft_doc::orient::transformed_angle(n.bbox_angle, pr.affine_near(b.center()));
}
if let Some(m) = n.extras_if_any().and_then(|x| x.mask.as_mut()).filter(|m| m.linked) {
warp_node(Arc::make_mut(&mut m.art), pr);
}
n.appearance.warp_gradients(&|p| pr.affine_near(p));
before
}
/// Warps each of `children` and returns the union of the pre-warp bounds of those `counts` picks
/// by index.
fn warp_children(children: &mut [Arc<Node>], pr: &Projective, counts: impl Fn(usize) -> bool) -> Option<Rect> {
children.iter_mut().enumerate().fold(None, |acc, (i, c)| {
let b = warp_node(Arc::make_mut(c), pr);
if counts(i) { vectorcraft_geom::union_opt(acc, b) } else { acc }
})
}
fn distort(s: &mut Session, p: &Value) -> Result<Value> {
@@ -178,6 +202,11 @@ fn distort(s: &mut Session, p: &Value) -> Result<Value> {
if !r.width().is_finite() || !r.height().is_finite() || r.width().abs() < 1e-9 || r.height().abs() < 1e-9 {
return Err(bad(C, "cannot distort a zero-size or non-finite bounding box"));
}
let ids_json = json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() });
// Corners that stay where they are change nothing: no edit and no undo step.
if [tl, tr, br, bl] == src.corners() {
return Ok(ids_json);
}
let pr = Projective::from_box(src, [tl, tr, br, bl]);
s.edit("Free Distort", |d, _| {
for id in &ids {
@@ -187,7 +216,7 @@ fn distort(s: &mut Session, p: &Value) -> Result<Value> {
}
Ok(())
})?;
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
Ok(ids_json)
}
// ---------- eyedropper ----------