Merge main into feat/live-paint-bucket-multi-click

This commit is contained in:
dotCooCoo
2026-11-14 15:48:45 -08:00
223 changed files with 13876 additions and 2734 deletions
+1 -1
View File
@@ -827,7 +827,7 @@ pub use docmenu::guide_indexes;
pub(crate) use edit::{settle_interaction, typing_in_progress};
pub(crate) use object::bounds_rewrite;
pub(crate) use object::{keep_aligned_in, refresh_pixel_aligned};
pub use plugin_call::{Location, key_location, location_in, location_keys};
pub use plugin_call::{Location, ParamsPlace, key_location, location_in, location_keys};
pub(crate) use vectorcraft_doc::range::parse_range;
/// A date param in Unix seconds that defaults to the clock: `None` when absent (now, see
+7 -5
View File
@@ -23,7 +23,7 @@ use vectorcraft_render::effects;
use super::super::appearance::series_children_in_paint_order;
use super::super::colorcmds::edit_if_changed;
use super::super::edit::selected_roots;
use super::super::graph::capture_series_object_opacity;
use super::super::graph::{capture_series_object_opacity, series_groups_among};
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;
@@ -93,7 +93,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 (none when no opacity changes); 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}",
"{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 (none when no opacity changes); 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, and on a series group, the group's own (docs/mcp.md) → {count}",
has_selection,
randomize_opacity
),
@@ -102,7 +102,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. 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}",
"{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, and on a series group, the group's own (docs/mcp.md). A run that changes nothing makes no step → {count}",
has_multi,
sort
),
@@ -418,9 +418,11 @@ fn randomize_opacity(s: &mut Session, p: &Value) -> Result<Value> {
/// 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.
/// again; the frontmost of them in a series sets them. A series group among `changed` has its own opacity stored for the
/// group.
fn keep_series_opacity(d: &mut vectorcraft_doc::Document, changed: &[NodeId]) {
let marks = series_children_in_paint_order(d, changed);
let mut marks = series_children_in_paint_order(d, changed);
marks.extend(series_groups_among(d, changed));
capture_series_object_opacity(d, &marks);
}
+7 -10
View File
@@ -72,7 +72,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Remove Breaks",
["Object", "Plug-ins", "Artwork Cleanup"],
None,
"{kind: \"line\"|\"paragraph\", ids?: [id…] (the type they name is taken locked or hidden too)} remove the forced line breaks (line) or paragraph returns (paragraph) in the characters selected with a Type tool (after Select All in a frame of threaded text, in the text of every frame of the story), else in all the text of the selected type, as one undo step; a break between two words becomes a space, the frames of a threaded story selected all together being read as one text → {ids, removed}",
"{kind: \"line\"|\"paragraph\", ids?: [id…] (the type they name is taken locked or hidden too)} remove the forced line breaks (line) or paragraph returns (paragraph) in the characters selected with a Type tool (after Select All in a frame of threaded text, in the text of every frame of the story, locked and hidden frames included), else in all the text of the selected type, as one undo step; a break between two words becomes a space, the frames of a threaded story selected all together being read as one text → {ids, removed}",
has_selection,
remove_breaks
),
@@ -821,9 +821,9 @@ fn apply_cuts(t: &mut TextObject, cuts: &[(usize, bool)], brk: char) -> usize {
}
/// The breaks `brk` in the text of the frames of `story`, read as one text: a break at the end or
/// start of a frame between words of the frames on either side becomes a space. Each editable frame
/// (each frame when `all`) comes with its own breaks, at its own bytes.
fn story_cuts(d: &Document, story: &[NodeId], brk: char, all: bool) -> Vec<(NodeId, Vec<(usize, bool)>)> {
/// start of a frame between words of the frames on either side becomes a space. Each frame comes
/// with its own breaks, at its own bytes, its locked and hidden frames included.
fn story_cuts(d: &Document, story: &[NodeId], brk: char) -> Vec<(NodeId, Vec<(usize, bool)>)> {
let texts: Vec<(NodeId, String)> = story.iter().filter_map(|id| Some((*id, text_of(d, *id)?.plain_text()))).collect();
let joined: String = texts.iter().map(|(_, t)| t.as_str()).collect();
// The breaks in story order, handed to the frames in one pass.
@@ -836,9 +836,7 @@ fn story_cuts(d: &Document, story: &[NodeId], brk: char, all: bool) -> Vec<(Node
while let Some((at, space)) = cuts.next_if(|(at, _)| *at < end) {
own.push((at - start, space));
}
if all || d.is_editable(id) {
out.push((id, own));
}
out.push((id, own));
start = end;
}
out
@@ -859,7 +857,7 @@ fn remove_breaks(s: &mut Session, p: &Value) -> Result<Value> {
let d = &s.doc()?.doc;
// Each type object with the breaks that go from it.
let targets: Vec<(NodeId, Vec<(usize, bool)>)> = match editing {
Some(_) if !story.is_empty() => story_cuts(d, &story, brk, false),
Some(_) if !story.is_empty() => story_cuts(d, &story, brk),
Some((id, a, b)) => vec![(id, a.min(b), a.max(b))]
.into_iter()
.filter(|(id, ..)| d.is_editable(*id))
@@ -886,8 +884,7 @@ fn remove_breaks(s: &mut Session, p: &Value) -> Result<Value> {
};
if !story.is_empty() && story.iter().all(|f| named.contains(f)) {
done.extend(story.iter().copied());
// Every frame is among them, so each one keeps its breaks.
out.extend(story_cuts(d, &story, brk, true));
out.extend(story_cuts(d, &story, brk));
} else if let Some(t) = text_of(d, id) {
// `id` is type, so the story of its thread is the same for each of its frames.
partial.extend(thread);
+6 -4
View File
@@ -498,8 +498,9 @@ pub(crate) fn stood_for(own: &[NodeId], ids: &[NodeId]) -> Vec<NodeId> {
}
/// Clear Appearance on each series group a command gave a whole new appearance through its paths, so the group's own
/// opacity, blend mode, isolation, knockout, effects and style link do not stay on top of it.
pub(crate) fn clear_stood_for(d: &mut Document, groups: &[NodeId]) {
/// opacity, blend mode, isolation, knockout, effects and style link do not stay on top of it. The graph stops keeping the
/// groups' opacity, blend mode, isolation and knockout. Returns whether what a graph keeps changed.
pub(crate) fn clear_stood_for(d: &mut Document, groups: &[NodeId]) -> bool {
for id in groups {
if let Some(n) = d.node_mut(*id) {
clear(n);
@@ -507,6 +508,7 @@ pub(crate) fn clear_stood_for(d: &mut Document, groups: &[NodeId]) {
n.extras_mut().graphic_style = None;
}
}
super::graph::capture_series_object_opacity(d, groups)
}
/// [`whole_targets`] of `cmd`, which needs at least one.
@@ -943,7 +945,7 @@ fn transfer(s: &mut Session, p: &Value) -> Result<Value> {
(src.opacity, src.blend, src.isolate, src.knockout, src.knockout_shape);
super::object::snap_if_aligned(to);
}
clear_stood_for(d, &group);
let forgot = clear_stood_for(d, &group);
for id in &cleared {
if let Some(n) = d.node_mut(*id) {
clear(n);
@@ -961,7 +963,7 @@ fn transfer(s: &mut Session, p: &Value) -> Result<Value> {
// 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 || labels))
Ok(count_changed(d, &before, &all) + usize::from(kept || painted || object || labels || forgot))
})?;
s.active_appearance_item = None;
Ok(json!({ "source": source.0, "target": target.0 }))
+1 -1
View File
@@ -54,7 +54,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Shape Builder",
[],
None,
"{ids?|id?: [id…] (default: selection), points: [[x,y]…] (drag path; one point = click; may be left out with rect), rect?: [x, y, width, height] (Shift-drag marquee from where the drag started, x and y, to x+width and y+height; width and height may be negative: touches every region whose outline crosses the rectangle or has an anchor inside it and the region holding the whole rectangle, the region under x, y comes first and the one under the opposite corner comes next; with points, it also touches the regions along them; for pickColorFrom, x, y is the drag's first point and the opposite corner is its last), erase?: bool (erase mode, Alt: deletes the regions touched, and the pieces of open paths that cut regions and the edges (outline pieces between the points where paths meet; see shapeBuilder.regions) within tolerance, except that a point in a region no more than twice the tolerance across there touches the region; a deleted edge opens its path there, and edges bounding a deleted region go with it; what is left of each path keeps its look, with gradients fitted to its bounds kept where they were on it, its name, opacity mask and attributes, and its fill rule unless merged regions shaped it), splitStroke?: false (In Merge Mode, Clicking Stroke Splits the Path; not with erase or rect: when the one point of a click is within tolerance of a stroke, the path that stroke is on splits in two instead of a merge: the path keeps the rest of it, and a copy of the path holding the run of it from the anchor or crossing point before the click to the one after it, in the path's direction, goes directly above it; both keep the path's gradients where they were on it; the other objects stay as they are; with pickColorFrom \"swatches\" or fill, the run's stroke takes that paint; a path in a Live Paint group or a Shaper Group does not split, and nor does a path the run is all of, such as a lone line, or all but 0.05 pt or less of (a ten-thousandth of the larger side of a path over 500 pt)), tolerance?: pt (2, erase or splitStroke), gap?: pt (0–72; 0: Gap Detection off), openFilledAsClosed?: true (Consider Open Filled Paths as Closed: a filled open path bounds a region as if an invisible edge closed it; false: it cuts the regions it crosses like an unfilled one), pickColorFrom?: \"artwork\" (default: the merged shape takes the look of the front-most object under the first point, else under the last point, else of the front-most object) | \"swatches\" (the current fill, with the rest of the look of the front-most object over the first region the drag touches that an object covers, else of the front-most object), fill?: \"#rrggbb\" | {color|swatch|gradient|none} (overrides pickColorFrom)} replaces the paths it uses where they are; other selected objects (type, hidden or locked objects, clipping paths) stay, and a group goes only when it is left empty → {ids (selected after: each selected object still there, or what took its place), merged, regions, lines, edges (counts deleted, merged or split), split?: id (the run split off, after a split)}; after a split, ids are the roots, with the rest and the run in place of the path when it was one",
"{ids?|id?: [id…] (default: selection), points: [[x,y]…] (drag path; one point = click; may be left out with rect), rect?: [x, y, width, height] (Shift-drag marquee from where the drag started, x and y, to x+width and y+height; width and height may be negative: touches every region whose outline crosses the rectangle or has an anchor inside it and the region holding the whole rectangle, the region under x, y comes first and the one under the opposite corner comes next; with points, it also touches the regions along them; for pickColorFrom, x, y is the drag's first point and the opposite corner is its last), erase?: bool (erase mode, Alt: deletes the regions touched, and the pieces of open paths that cut regions and the edges (outline pieces between the points where paths meet; see shapeBuilder.regions) within tolerance, except that a point in a region no more than twice the tolerance across there touches the region; a deleted edge opens its path there, and edges bounding a deleted region go with it; what is left of each path keeps its look, with gradients fitted to its bounds kept where they were on it, its name, opacity mask and attributes, and its fill rule unless merged regions shaped it), splitStroke?: false (In Merge Mode, Clicking Stroke Splits the Path; not with erase or rect: when the one point of a click is within tolerance of a stroke, the path that stroke is on splits in two instead of a merge: the path keeps the rest of it, and a copy of the path holding the run of it from the anchor or crossing point before the click to the one after it, in the path's direction, goes directly above it; both keep the path's gradients where they were on it; the other objects stay as they are; with pickColorFrom \"swatches\" or fill, the run's stroke takes that paint; a path in a Live Paint group or a Shaper Group does not split, and nor does a path the run is all of, such as a lone line, or all but 0.05 pt or less of (a ten-thousandth of the larger side of a path over 500 pt)), tolerance?: pt (2, erase or splitStroke), gap?: pt (0–72; 0: Gap Detection off), openFilledAsClosed?: true (Consider Open Filled Paths as Closed: a filled open path bounds a region as if an invisible edge closed it, where filled means any fill row is shown, painted and above 0% opacity, whichever row is on top; false: it cuts the regions it crosses like an unfilled one), pickColorFrom?: \"artwork\" (default: the merged shape takes the look of the front-most object under the first point, else under the last point, else of the front-most object) | \"swatches\" (the current fill, with the rest of the look of the front-most object over the first region the drag touches that an object covers, else of the front-most object), fill?: \"#rrggbb\" | {color|swatch|gradient|none} (overrides pickColorFrom)} replaces the paths it uses where they are; other selected objects (type, hidden or locked objects, clipping paths) stay, and a group goes only when it is left empty → {ids (selected after: each selected object still there, or what took its place), merged, regions, lines, edges (counts deleted, merged or split), split?: id (the run split off, after a split)}; after a split, ids are the roots, with the rest and the run in place of the path when it was one",
has_selection_or_ids,
sb_merge
),
@@ -63,7 +63,7 @@ pub(super) fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
&& let Some(&q) = pts.first()
&& let Some((i, run)) = map.stroke_at(&outlines, q, tol)
&& let (Some(leaf), Some(drawn)) = (leaves.get(i), outlines.get(i))
&& let Some(rest) = po::cut_out(drawn, &run, open_filled_as_closed && !leaf.appearance.fill_paint().is_none(), b::outline_snap(drawn))
&& let Some(rest) = po::cut_out(drawn, &run, open_filled_as_closed && leaf.appearance.has_drawn_fill(), b::outline_snap(drawn))
&& rest.length() > b::outline_snap(drawn)
{
// The path keeps the rest of it, and a copy of it with the run under the click goes
+6 -5
View File
@@ -288,9 +288,10 @@ fn trace_library(c: &str, s: &Session, params: &mut tr::TraceParams) -> Result<V
/// The most anchor points an Image Trace may make, a guard for memory: a trace past it is refused
/// with advice instead (#525). A trace keeps about 90 bytes an anchor in the document (measured on
/// code-drawn photos of 2000 × 2000 to 6000 × 4000 pixels), so 8,000,000 anchors are about
/// 0.7 GB, with a peak of about 1 GB while tracing: a 4000 × 3000 photo at High Fidelity Photo
/// makes about 1.9 million, and even at Paths 100, Noise 1 and every tone about 7.9 million. On the
/// web, where a page has at most 4 GB of memory, the limit stays at 2,000,000.
/// 0.7 GB, with a peak of about 1 GB while tracing: a 4000 × 3000 photo at Limited 64 colors,
/// Paths 90, Corners 25 and Noise 4 makes about 1.9 million, and even at Paths 100, Noise 1 and
/// every tone about 7.9 million. On the web, where a page has at most 4 GB of memory, the limit
/// stays at 2,000,000.
#[cfg(not(target_arch = "wasm32"))]
pub const MAX_TRACE_ANCHORS: usize = 8_000_000;
@@ -1139,7 +1140,7 @@ mod trace_limit_tests {
assert_eq!(w, ["Flat Logo traced the image at 946 × 710 pixels, averaged down from 16000 × 12000."]);
}
// A 4000 × 3000 photo at High Fidelity Photo makes about 1.9 million anchors, which must fit;
// the limit still guards memory (about 90 bytes an anchor). Checked when the tests build.
// A 4000 × 3000 photo at Limited 64 colors, Paths 90, Corners 25 and Noise 4 makes about
// 1.9 million anchors, which must fit; the limit still guards memory (about 90 bytes an anchor). Checked when the tests build.
const _: () = assert!(MAX_TRACE_ANCHORS >= 2_000_000 && MAX_TRACE_ANCHORS <= 16_000_000);
}
@@ -70,13 +70,26 @@ pub(super) fn grid_show(s: &mut Session, p: &Value) -> Result<Value> {
None | Some(Value::Null) => None,
Some(_) => Some(switch_or(p, "visible", false, C)?),
};
let g = silent(s, |g| g.visible = want.unwrap_or(!g.visible))?;
// A hidden grid holds no plane for Automatic Plane Positioning.
let g = silent(s, |g| {
g.visible = want.unwrap_or(!g.visible);
if !g.visible {
g.inferred = None;
}
})?;
Ok(json!({ "visible": g.visible }))
}
pub(super) fn plane_set(s: &mut Session, p: &Value) -> Result<Value> {
let plane = str_param(p, "plane").and_then(Plane::parse).ok_or_else(|| bad("perspective.plane.set", "plane must be left|right|ground|none"))?;
silent(s, |g| g.plane = plane)?;
// The new active plane passes through the point Automatic Plane Positioning holds, unless no
// plane is active or that would put the plane behind the viewer.
silent(s, |g| {
g.plane = plane;
if g.inferred.is_some() && (plane == Plane::None || g.validate().is_err()) {
g.inferred = None;
}
})?;
Ok(json!({ "plane": plane.id() }))
}
@@ -138,6 +151,9 @@ pub(super) fn attach(s: &mut Session, p: &Value) -> Result<Value> {
store_grid(d, &g);
Ok(())
})?;
if !ids.is_empty() {
crate::cmd::perspgrid::plane_positioning_used(s, plane)?;
}
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "plane": plane.id() }))
}
@@ -205,8 +221,13 @@ fn persp_edit(
return Err(EngineError::Other("select the objects to transform in perspective".into()));
}
let mut g = grid_of(&s.doc()?.doc);
// An object not in perspective yet attaches to `fallback` where that plane is now.
let attaches = {
let d = &s.doc()?.doc;
fallback.filter(|_| ids.iter().any(|id| g.attachment_of(d, *id).is_none()))
};
let kept = if copy && !stores_attachments(&s.doc()?.doc) { crate::cmd::object::preview_copies(s, ids, false)? } else { None };
s.edit(label, |d, sel| {
let edited = s.edit(label, |d, sel| {
g.adopt_stored_attachments(d);
let targets = match &kept {
// The document becomes the snapshot with the kept copies, not yet mapped, and the loop
@@ -236,7 +257,11 @@ fn persp_edit(
}
store_grid(d, &g);
Ok((targets, first))
})
})?;
if let Some(plane) = attaches {
crate::cmd::perspgrid::plane_positioning_used(s, plane)?;
}
Ok(edited)
}
/// Where the first of `ids` is in perspective (its plane and depth) when `ids` is not empty, every one
@@ -430,6 +455,9 @@ pub(super) fn plane_move(s: &mut Session, p: &Value) -> Result<Value> {
check_numbers(p, &["offset", "by"], C)?;
let old = grid_of(&s.doc()?.doc);
let plane = plane_param(&old, p, C)?;
// A plane that Automatic Plane Positioning holds moves from where it is held, and the move
// places it there for good: the moved grid holds no plane (undoing the move holds it again).
let held = old.inferred;
let from = old.offset(plane);
let to = match (p.get("offset").and_then(Value::as_f64), p.get("by").and_then(Value::as_f64)) {
(Some(o), _) => o,
@@ -440,7 +468,7 @@ pub(super) fn plane_move(s: &mut Session, p: &Value) -> Result<Value> {
if !matches!(objects, "none" | "move" | "copy") {
return Err(bad(C, "objects must be none, move or copy"));
}
let mut g = old.clone();
let mut g = PerspectiveGrid { inferred: None, ..old.clone() };
g.set_offset(plane, to);
g.validate().map_err(|e| bad(C, e))?;
let label = match objects {
@@ -458,8 +486,9 @@ pub(super) fn plane_move(s: &mut Session, p: &Value) -> Result<Value> {
.collect()
};
// A plane moved to where it is, with its objects or without them, moves nothing: when the
// document holds the grid already, the document and its undo history stay as they are.
if to == from && objects != "copy" && grid_kept(&s.doc()?.doc, &g) {
// document holds the grid already, the document and its undo history stay as they are (a
// plane held where it was placed stays held).
if to == from && objects != "copy" && grid_kept(&s.doc()?.doc, &PerspectiveGrid { inferred: held, ..g.clone() }) {
let ids = if objects == "move" { on_plane(&s.doc()?.doc) } else { vec![] };
return Ok(json!({ "plane": plane.id(), "offset": to, "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }));
}
@@ -507,12 +536,19 @@ pub(super) fn plane_match(s: &mut Session, p: &Value) -> Result<Value> {
named_id(p)?.or_else(|| st.selection.objects.first().copied()).ok_or_else(|| EngineError::Other("select an object in perspective".into()))?;
let mut g = grid_of(&st.doc);
let (plane, depth) = g.attachment_of(&st.doc, id).ok_or_else(|| EngineError::Other("the object isn't in perspective".into()))?;
// The moved grid holds no plane for Automatic Plane Positioning (undoing the move holds it
// again).
let held = g.inferred.take();
g.set_offset(plane, depth);
g.plane = plane;
g.validate().map_err(|e| bad("perspective.plane.matchObject", e))?;
// When the plane is at the object and active already, and the document holds that grid, the
// document and its undo history stay as they are.
if grid_kept(&st.doc, &g) {
// document and its undo history stay as they are; a plane held elsewhere goes back to that
// place as view state.
if grid_kept(&st.doc, &PerspectiveGrid { inferred: held, ..g.clone() }) {
if held.is_some() {
silent(s, |g| g.inferred = None)?;
}
return Ok(json!({ "plane": plane.id(), "offset": depth }));
}
s.edit("Move Plane to Match Object", |d, _| {
@@ -573,6 +609,7 @@ pub(super) fn persp_draw(s: &mut Session, p: &Value) -> Result<Value> {
st.history.recost_last(&st.doc);
}
res?;
crate::cmd::perspgrid::plane_positioning_used(s, plane)?;
Ok(json!({ "id": id.0, "plane": plane.id() }))
}
+1 -1
View File
@@ -718,7 +718,7 @@ fn lighting_preset(s: &mut Session, p: &Value) -> Result<Value> {
/// sets its Warp style (`warpStyle`), its 3D type (`threeDType`) or visibility (`visible`), as one
/// undo step. They are checked as `effect.apply` checks them, and a visible 3D effect must still
/// fit; a hidden one has its given values checked. → `{ids}`.
fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
pub(crate) fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "effect.setParams";
let index = index_param(p, "index", C)?;
let mut params = p.get("params").cloned().unwrap_or(Value::Null);
+10 -8
View File
@@ -265,25 +265,27 @@ fn gradient_expands(d: &Document, l: &Node) -> bool {
/// Whether type `t` has a gradient fill that Fill expands once Object has outlined it: one of the
/// paths `type.createOutlines` makes of its glyphs, ruby glyphs and underline and strikethrough
/// bars, each painted as its run, has one ([`gradient_expands`] on that path's own box). Inline
/// graphics and color glyphs become other art. Leaves the document as it is and stops at the
/// first such path.
/// bars, each painted as its run with its unplaced gradients fitted to the layout's bounds, has one
/// ([`gradient_expands`] on that path). Inline graphics and color glyphs become other art. Leaves
/// the document as it is and stops at the first such path.
fn text_gradient_expands(d: &Document, t: &TextObject) -> bool {
if !t.runs.iter().any(|r| matches!(r.style.fill, Paint::Gradient(_))) {
return false;
}
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
let first = t.first_style();
let expands = |run: usize, outline: &BezPath| {
let style = t.runs.get(run).map(|r| &r.style);
if !matches!(style.map_or_else(|| t.first_style().fill, |s| s.fill.clone()), Paint::Gradient(_)) {
let style = t.runs.get(run).map_or(&first, |r| &r.style);
if !matches!(style.fill, Paint::Gradient(_)) {
return false;
}
let path = PathData::from_bezpath(outline).transformed(t.xf);
let path = PathData::from_bezpath(outline);
if path.is_empty() {
return false;
}
let appearance = style.map_or_else(|| t.first_style().appearance(), |s| s.appearance());
gradient_expands(d, &Node::path(NodeId(u64::MAX), path, appearance))
let mut piece = Node::path(NodeId(u64::MAX), path, Appearance::default());
vectorcraft_render::effects::paint_type_piece(&mut piece, style, lay.bounds, t.xf);
gradient_expands(d, &piece)
};
let inline = |gi: usize| lay.inlines.iter().find(|i| i.glyph == gi).is_some_and(|i| t.runs.get(i.run).is_some_and(|r| r.inline.is_some()));
lay.glyphs.iter().enumerate().any(|(gi, g)| !inline(gi) && g.image.is_none() && g.colour.is_none() && expands(g.run, &g.outline))
File diff suppressed because one or more lines are too long
+194 -29
View File
@@ -2,13 +2,13 @@
//! (unknown keys are ignored, so one params object can carry the options of several formats).
use std::borrow::Cow;
use std::collections::HashSet;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use serde::Deserialize;
use serde::de::DeserializeOwned;
use serde_json::Value;
use vectorcraft_doc::{Artboard, Document, Node, NodeKind};
use vectorcraft_doc::{Artboard, Document, Node, NodeKind, SymbolOverride};
use vectorcraft_geom::Rect;
use vectorcraft_render::AntiAlias;
use vectorcraft_render::encode::jpeg::{self, JpegOptions};
@@ -450,31 +450,85 @@ pub fn export_art_bounds(doc: &Document, format: &str) -> Option<Rect> {
/// The bounds of the visible art of `doc` with the objects that `left_out` picks hidden
/// ([`hidden_nodes`]) and the ones in the art of the symbols that instances show taken out
/// ([`without_nodes`]: symbol art is measured with its hidden objects). An instance of a symbol
/// whose art is all picked or hidden is hidden too. The art of a dynamic symbol is measured whole:
/// an instance can paint its objects, and finds them by their places in the art.
/// whose art is all picked or hidden is hidden too. The drawn instances of a dynamic symbol that
/// make the same appearance changes to its art are measured on a copy of the symbol of their own,
/// in which each object is judged with those changes ([`as_drawn`]); the objects taken out of
/// a dynamic symbol's art leave empty groups in their places, by which instances find the objects
/// they change.
fn bounds_without(doc: &Document, left_out: &dyn Fn(&Node) -> bool) -> Option<Rect> {
// The symbols instances show (the first of each name) that aren't dynamic.
// The symbols instances show (the first of each name).
let mut names = HashSet::new();
let shown: Vec<(usize, &vectorcraft_doc::Symbol)> =
doc.symbols.iter().enumerate().filter(|(_, s)| names.insert(s.name.as_str())).filter(|(_, s)| !s.dynamic).collect();
let shown: Vec<usize> = doc.symbols.iter().enumerate().filter(|(_, s)| names.insert(s.name.as_str())).map(|(i, _)| i).collect();
// The sets of appearance changes the drawn instances of each dynamic symbol make to its art;
// `None` for a symbol with more than `MAX_ART_COPIES`, which is measured whole.
let mut sets: HashMap<&str, Option<Vec<Vec<&SymbolOverride>>>> =
shown.iter().filter_map(|i| doc.symbols.get(*i)).filter(|s| s.dynamic).map(|s| (s.name.as_str(), Some(Vec::new()))).collect();
if !sets.is_empty() {
let mut walked = HashSet::new();
for l in &doc.layers {
drawn_changes(doc, l, left_out, true, &mut walked, &mut sets);
}
}
// Each set of changes gets a copy of its symbol, appended after the symbols, which the instances
// that make those changes show instead.
let variant = |symbol: &str, n: &Node| -> Option<String> {
let k = sets.get(symbol)?.as_ref()?.iter().position(|c| *c == own_changes(n))?;
Some(format!("{symbol}\u{0}{k}"))
};
let mut copies: Vec<(usize, &[&SymbolOverride])> = Vec::new();
let remapped_doc = sets.values().flatten().any(|v| !v.is_empty()).then(|| {
let mut d = doc.clone();
for l in &mut d.layers {
if let Some(n) = remapped(l, &variant) {
*l = n;
}
}
for s in &mut d.symbols {
if let Some(n) = remapped(&s.art, &variant) {
s.art = n;
}
}
for i in &shown {
let Some(s) = d.symbols.get(*i).cloned() else { continue };
for (k, c) in sets.get(s.name.as_str()).into_iter().flatten().flatten().enumerate() {
copies.push((d.symbols.len(), c.as_slice()));
d.symbols.push(vectorcraft_doc::Symbol { name: format!("{}\u{0}{k}", s.name), ..s.clone() });
}
}
d
});
let src = remapped_doc.as_ref().unwrap_or(doc);
// The symbols to measure with the changes their instances make (`None` for a static symbol),
// without the dynamic symbols measured whole.
let entries: Vec<(usize, Option<&[&SymbolOverride]>)> = shown
.iter()
.filter_map(|i| src.symbols.get(*i).map(|s| (*i, s)))
.filter(|(_, s)| !s.dynamic || sets.get(s.name.as_str()).is_some_and(Option::is_some))
.map(|(i, s)| (i, s.dynamic.then_some(&[][..])))
.chain(copies.iter().map(|(i, c)| (*i, Some(*c))))
.collect();
// The symbols whose art `left_out` leaves nothing of, those that only hold instances of such
// symbols included.
let mut empty: HashSet<&str> = HashSet::new();
while let Some((_, s)) = shown.iter().find(|(_, s)| !empty.contains(s.name.as_str()) && all_left_out(&s.art, left_out, &empty)) {
while let Some(s) = entries.iter().find_map(|(i, c)| {
src.symbols.get(*i).filter(|s| !empty.contains(s.name.as_str()) && all_left_out(&s.art, left_out, &empty, *c, &mut Vec::new()))
}) {
empty.insert(&s.name);
}
let left_out =
&|n: &Node| left_out(n) || matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if empty.contains(symbol.as_str()) && !has_effects(n));
let layers = hidden_nodes(&doc.layers, left_out);
let symbols: Vec<(usize, Arc<Node>)> = shown
let layers = hidden_nodes(&src.layers, left_out);
let symbols: Vec<(usize, Arc<Node>)> = entries
.iter()
.filter(|(_, s)| !empty.contains(s.name.as_str()))
.filter_map(|(i, s)| without_nodes(&s.art, left_out).map(|art| (*i, art)))
.filter_map(|(i, c)| {
let s = src.symbols.get(*i).filter(|s| !empty.contains(s.name.as_str()))?;
without_nodes(&s.art, left_out, *c, &mut Vec::new()).map(|art| (*i, art))
})
.collect();
if layers.is_none() && symbols.is_empty() {
return vectorcraft_render::encode::art_bounds(doc);
return vectorcraft_render::encode::art_bounds(src);
}
let mut d = doc.clone();
let mut d = remapped_doc.unwrap_or_else(|| doc.clone());
if let Some(layers) = layers {
d.layers = layers;
}
@@ -486,22 +540,126 @@ fn bounds_without(doc: &Document, left_out: &dyn Fn(&Node) -> bool) -> Option<Re
vectorcraft_render::encode::art_bounds(&d)
}
/// The appearance changes instance `n` makes to its symbol's art, without those that change nothing.
fn own_changes(n: &Node) -> Vec<&SymbolOverride> {
n.extras().symbol_overrides.iter().filter(|o| !o.is_empty()).collect()
}
/// `n` with each instance at any depth that `variant` gives another symbol (from the instance's
/// symbol and the instance) made an instance of that symbol; `None` when none is.
fn remapped(n: &Arc<Node>, variant: &dyn Fn(&str, &Node) -> Option<String>) -> Option<Arc<Node>> {
if let NodeKind::SymbolInstance { symbol, .. } = &n.kind {
let name = variant(symbol, n)?;
let mut m = (**n).clone();
if let NodeKind::SymbolInstance { symbol, .. } = &mut m.kind {
*symbol = name;
}
return Some(Arc::new(m));
}
let children = n.children()?;
let mut out: Option<Vec<Arc<Node>>> = None;
for (i, c) in children.iter().enumerate() {
if let Some(new) = remapped(c, variant)
&& let Some(slot) = out.get_or_insert_with(|| children.clone()).get_mut(i)
{
*slot = new;
}
}
out.map(|c| {
let mut m = (**n).clone();
if let Some(children) = m.children_mut() {
*children = c;
}
Arc::new(m)
})
}
/// Whether `n` is hidden, picked by `left_out`, an instance of one of the `empty` symbols, or a
/// layer or a group holding only such objects (its clipping path among them when it clips). A
/// Shaper group, or a layer, group or instance with effects, fills or strokes of its own, is none
/// of these unless it is hidden or picked.
fn all_left_out(n: &Node, left_out: &dyn Fn(&Node) -> bool, empty: &HashSet<&str>) -> bool {
/// of these unless it is hidden or picked. In the art of a dynamic symbol (`changed`, the changes
/// an instance makes to it; `n` is at place `at`), each object is judged with those changes
/// ([`as_drawn`]).
fn all_left_out(n: &Node, left_out: &dyn Fn(&Node) -> bool, empty: &HashSet<&str>, changed: Option<&[&SymbolOverride]>, at: &mut Vec<u32>) -> bool {
let m = as_drawn(n, changed, at);
!n.visible
|| left_out(n)
|| !has_effects(n)
|| left_out(&m)
|| !has_effects(&m)
&& n.extras().shaper.is_none()
&& match &n.kind {
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => children.iter().all(|c| all_left_out(c, left_out, empty)),
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => children.iter().enumerate().all(|(i, c)| {
at.push(i as u32);
let out = all_left_out(c, left_out, empty, changed, at);
at.pop();
out
}),
NodeKind::SymbolInstance { symbol, .. } => empty.contains(symbol.as_str()),
_ => false,
}
}
/// Add the sets of appearance changes that the instances in `n` and its subtree make to the art of
/// the symbols `sets` names, each set once, and those of the instances in the art of the symbols the
/// instances show (`walked`, the symbols already looked in). The instances that `left_out` picks or
/// that are in an object it picks are left out, and with `hidden` (in a layer) the hidden ones and
/// those in a hidden object. A symbol with more than [`MAX_ART_COPIES`] sets gets `None`.
fn drawn_changes<'a>(
doc: &'a Document,
n: &'a Node,
left_out: &dyn Fn(&Node) -> bool,
hidden: bool,
walked: &mut HashSet<&'a str>,
sets: &mut HashMap<&'a str, Option<Vec<Vec<&'a SymbolOverride>>>>,
) {
if hidden && !n.visible || left_out(n) {
return;
}
if let NodeKind::SymbolInstance { symbol, .. } = &n.kind {
let changes = own_changes(n);
if let Some(entry) = sets.get_mut(symbol.as_str())
&& !changes.is_empty()
{
let full = match entry {
Some(list) if !list.contains(&changes) && list.len() < MAX_ART_COPIES => {
list.push(changes);
false
}
Some(list) => !list.contains(&changes),
None => false,
};
if full {
*entry = None;
}
}
if walked.insert(symbol.as_str())
&& let Some(s) = doc.symbols.iter().find(|s| s.name == *symbol)
{
drawn_changes(doc, &s.art, left_out, false, walked, sets);
}
}
for c in n.children().into_iter().flatten() {
drawn_changes(doc, c, left_out, hidden, walked, sets);
}
}
/// The most copies of one dynamic symbol's art [`bounds_without`] measures, one for each set of
/// appearance changes its instances make; a symbol whose instances make more sets is measured whole.
const MAX_ART_COPIES: usize = 64;
/// `n`, the object at place `at` of a symbol's art, with the appearance changes `changed` that an
/// instance makes to it applied in their order; `n` itself for the art of a symbol that is not
/// dynamic (`changed` is `None`) and when no change names `at`.
fn as_drawn<'a>(n: &'a Node, changed: Option<&[&SymbolOverride]>, at: &[u32]) -> Cow<'a, Node> {
let here: Vec<SymbolOverride> =
changed.into_iter().flatten().filter(|o| o.path == at).map(|o| SymbolOverride { path: Vec::new(), ..(*o).clone() }).collect();
if here.is_empty() {
return Cow::Borrowed(n);
}
let mut m = n.clone();
vectorcraft_doc::symbol_overrides::apply(&mut m, &here);
Cow::Owned(m)
}
/// Whether `n` has a visible effect, fill or stroke of its own, which the objects it holds can
/// take part in.
fn has_effects(n: &Node) -> bool {
@@ -511,25 +669,32 @@ fn has_effects(n: &Node) -> bool {
/// `n` without the objects that `left_out` picks in it, when it is a layer or a group, and in the
/// layers and groups in it at any depth; `None` when it picks none of them. A clipping layer or
/// group keeps its clipping path, its first object, and a Shaper group and a layer or group with
/// effects, fills or strokes of its own keep all they hold.
fn without_nodes(n: &Arc<Node>, left_out: &dyn Fn(&Node) -> bool) -> Option<Arc<Node>> {
/// effects, fills or strokes of its own keep all they hold. In the art of a dynamic symbol
/// (`changed`, the changes an instance makes to it, as for [`all_left_out`]; `n` is at place
/// `at`), each object is judged with those changes ([`as_drawn`]), and an object taken out leaves
/// an empty group in its place.
fn without_nodes(n: &Arc<Node>, left_out: &dyn Fn(&Node) -> bool, changed: Option<&[&SymbolOverride]>, at: &mut Vec<u32>) -> Option<Arc<Node>> {
let (NodeKind::Layer { children, clip, .. } | NodeKind::Group { children, clip }) = &n.kind else { return None };
if has_effects(n) || n.extras().shaper.is_some() {
if has_effects(&as_drawn(n, changed, at)) || n.extras().shaper.is_some() {
return None;
}
let mut changed = false;
let mut taken = false;
let kept: Vec<Arc<Node>> = children
.iter()
.enumerate()
.filter_map(|(i, c)| {
if left_out(c) && !(*clip && i == 0) {
changed = true;
return None;
}
Some(without_nodes(c, left_out).inspect(|_| changed = true).unwrap_or_else(|| c.clone()))
at.push(i as u32);
let out = if left_out(&as_drawn(c, changed, at)) && !(*clip && i == 0) {
taken = true;
changed.map(|_| Arc::new(Node::group(c.id, Vec::new())))
} else {
Some(without_nodes(c, left_out, changed, at).inspect(|_| taken = true).unwrap_or_else(|| c.clone()))
};
at.pop();
out
})
.collect();
changed.then(|| {
taken.then(|| {
let mut new = (**n).clone();
if let NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } = &mut new.kind {
*children = kept;
+16 -3
View File
@@ -678,15 +678,28 @@ impl ReadFile {
/// ([`super::importer`]).
pub fn read_for_open(name: &str, bytes: &[u8], path: Option<String>, p: &Value, update_links: bool, low_res_proxy_eps: bool) -> Result<ReadFile> {
let opts = LoadOptions::from_params("document.open", p)?;
let mut loaded = match super::importer::pick(name, bytes, p)? {
Some(plugin) => super::importer::load(name, bytes, &plugin, p)?,
None => load_with(name, bytes, &opts)?,
let mut loaded = if opts.link_pages {
linked_pages(name, bytes, path.as_deref(), &opts)?
} else {
match super::importer::pick(name, bytes, p)? {
Some(plugin) => super::importer::load(name, bytes, &plugin, p)?,
None => load_with(name, bytes, &opts)?,
}
};
let links = crate::cmd::links::resolve(&mut loaded.doc, crate::cmd::links::LinkBase::file(path.as_deref()), update_links, low_res_proxy_eps);
Session::refresh_text_bounds(&mut loaded.doc);
Ok(ReadFile { loaded, opts, path, links })
}
/// The PDF `name` (`bytes`, read from `path`) opened with `linkPages`: its pages as placed documents
/// linked to them ([`super::pdfimport::linked_pages`]), named after the file.
fn linked_pages(name: &str, bytes: &[u8], path: Option<&str>, opts: &LoadOptions) -> Result<Loaded> {
let (mut doc, warnings) = super::pdfimport::linked_pages(name, bytes, path, opts)?;
doc.title = file_name(name);
let format = detect(name, bytes).ok_or_else(|| err(format!("can't open `{}`: not a PDF", file_name(name))))?;
Ok(Loaded { doc, format, warnings, restored: false, converted: false, preview_only: false, editing_data: false, native_pdf_compatible: None })
}
/// The second half of [`open_bytes_with`]: open a file [`read_for_open`] read as the new active
/// document → `document.open`'s result.
pub fn open_read(s: &mut Session, file: ReadFile) -> Result<Value> {
+95 -6
View File
@@ -2,11 +2,15 @@
//! and `file.place` read ([`LoadOptions`]), the pages they import and `document.pdfInfo` (pages,
//! boxes, a thumbnail; one page for a PostScript file) for the Import PDF dialog.
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_doc::{ColorMode, Document};
use vectorcraft_doc::{Artboard, ColorMode, Document, LayerColor, LinkInfo, Node, NodeKind};
use vectorcraft_geom::{Affine, Rect};
use vectorcraft_pdf::{Choice, CropTo, ImportOptions, PdfError, TextAs};
use super::super::colormgmt::Grays;
use super::super::place::document;
use super::super::{Result, Session, bad, check_numbers, number_or, parse_range, text_param};
use super::{err, source};
@@ -47,6 +51,9 @@ pub struct LoadOptions {
/// A PDF or `.ai` saved with Preserve Editing opens from the VectorCraft document it carries;
/// off, it opens as another app would open it, from the editing copy of its art or its pages.
pub native: bool,
/// Each page of a PDF opens as a placed document linked to that page of its file, on an
/// artboard of its own, instead of as editable art (`linkPages`; see [`linked_pages`]).
pub link_pages: bool,
}
impl Default for LoadOptions {
@@ -65,6 +72,7 @@ impl Default for LoadOptions {
trust_editing_data: false,
legacy_names: vectorcraft_eps::NameEncoding::default(),
native: true,
link_pages: false,
}
}
}
@@ -74,8 +82,8 @@ impl LoadOptions {
/// `pages` isn't given), `cropTo` (or `crop`), `password`, `colorMode` (`rgb` | `cmyk`) with `grays`,
/// `textAs` (`text` | `outlines`), `layers` (true | false), `hiddenLayers` (true | false),
/// `editingData` (true | false | `always`),
/// `legacyNameEncoding` (one of [`vectorcraft_eps::NameEncoding::LABELS`]) and `dxf` (the DXF
/// import options).
/// `legacyNameEncoding` (one of [`vectorcraft_eps::NameEncoding::LABELS`]), `linkPages` (true |
/// false) and `dxf` (the DXF import options).
pub fn from_params(cmd: &str, p: &Value) -> Result<Self> {
let pages = match p.get("pages").filter(|v| !v.is_null()).or_else(|| p.get("page").filter(|v| !v.is_null())) {
None => None,
@@ -115,6 +123,10 @@ impl LoadOptions {
.and_then(vectorcraft_eps::NameEncoding::from_label)
.ok_or_else(|| bad(cmd, format!("legacyNameEncoding must be one of {}", vectorcraft_eps::NameEncoding::LABELS.join(", "))))?,
};
let link_pages = match p.get("linkPages").filter(|v| !v.is_null()) {
None => false,
Some(v) => v.as_bool().ok_or_else(|| bad(cmd, "linkPages must be true or false"))?,
};
let dxf = super::dxfimport::options(cmd, p.get("dxf"))?;
Ok(Self {
pages,
@@ -130,14 +142,15 @@ impl LoadOptions {
trust_editing_data,
legacy_names,
native: true,
link_pages,
})
}
/// Does the document read only part of the file, or read it differently (a page range, another
/// box, a password, without its hidden layers, without the VectorCraft document it carries)?
/// Writing it back would lose the rest, so Save doesn't (it asks for a name).
/// box, a password, without its hidden layers, without the VectorCraft document it carries, its
/// pages linked)? Writing it back would lose the rest, so Save doesn't (it asks for a name).
pub fn is_partial(&self) -> bool {
self.pages.is_some() || self.crop != CropTo::default() || self.password.is_some() || !self.layers || !self.native
self.pages.is_some() || self.crop != CropTo::default() || self.password.is_some() || !self.layers || !self.native || self.link_pages
}
/// The PDF import options: `pages` resolved against the file's page count. A PDF-compatible
@@ -201,6 +214,82 @@ fn page_options(page: usize, o: &LoadOptions) -> ImportOptions {
ImportOptions { pages: Some(vec![page]), crop: o.crop, password: o.password.clone(), text_as: o.text_as, layers: false, ..Default::default() }
}
/// `document.open` with `linkPages`: the pages `o` picks of the PDF (or PDF-compatible `.ai` or
/// `.ait`) `name`, read from `path`, as a document holding one placed document per page, linked to
/// that page of the file as File › Place with Link places one ([`document::source`]). Each page
/// gets an artboard of its `o.crop` box (its art's bounds for `bounding`), in a row from the left
/// with the gap a PDF's artboards open with, and a layer named after the page. The document opens
/// in CMYK when most of the pages read in CMYK, unless `o.color_mode` names a mode. Refused without a
/// path, for any other file, with a password (a linked page is read again without one), with
/// `textAs: outlines` (a linked page shows the file's own type) and for a file over
/// [`document::MAX_FILE`] → the document and the notes of reading its pages.
pub(super) fn linked_pages(name: &str, bytes: &[u8], path: Option<&str>, o: &LoadOptions) -> Result<(Document, Vec<String>)> {
const C: &str = "document.open";
let file = super::file_name(name);
if !document::is_pdf(name, bytes) {
return Err(bad(C, format!("linkPages: `{file}` isn't a PDF or a PDF-compatible .ai file")));
}
let path = path.ok_or_else(|| bad(C, "linkPages needs the file's path: linked pages are read from it"))?;
if o.password.is_some() {
return Err(bad(C, "linkPages can't open a file with a password: linked pages are read again without it"));
}
if o.text_as != TextAs::Text {
return Err(bad(C, "linkPages can't be used with textAs: outlines: linked pages show the file's own type"));
}
if bytes.len() > document::MAX_FILE {
return Err(bad(C, format!("linkPages: `{file}` is over {} MB", document::MAX_FILE >> 20)));
}
let count = vectorcraft_pdf::open_pdf(bytes, None).map_err(err)?.page_count();
let picked = match &o.pages {
None => (0..count).collect(),
Some(range) => parse_range(range, count).map_err(|e| err(format!("pages: {e}")))?,
};
let bounding = o.crop == CropTo::Bounding;
let mut doc = Document::new(1.0, 1.0);
let board = doc.artboards.first().cloned().ok_or_else(|| err("a new document has no artboard"))?;
doc.artboards.clear();
doc.layers.clear();
let link = crate::cmd::links::link_info(path, bytes);
let gap = ImportOptions::default().artboard_gap;
// Every page's object keeps the same one copy of the file.
let shared = Arc::new(bytes.to_vec());
let (mut x, mut warnings, mut cmyk_pages) = (0.0, Vec::<String>::new(), 0);
for (i, &index) in picked.iter().enumerate() {
let page = index as u32 + 1;
let s = document::shared_source(name, &shared, Some(path), page, bounding, document::page_box(o.crop), C)?;
cmyk_pages += usize::from(s.color_mode == ColorMode::Cmyk);
let mut node = document::node(&mut doc, &s, LinkInfo { page: Some(page), ..link.clone() }, bounding);
if let NodeKind::PlacedDocument(p) = &mut node.kind {
p.xf = Affine::translate((x, 0.0));
}
node.name = Some(file.clone());
let mut layer = Node::layer(doc.alloc_id(), &format!("Page {page}"), LayerColor::Preset((i % 27) as u8));
if let NodeKind::Layer { children, .. } = &mut layer.kind {
children.push(Arc::new(node));
}
doc.layers.push(Arc::new(layer));
let rect = Rect::new(x, 0.0, x + s.frame.width(), s.frame.height());
doc.artboards.push(Artboard { id: i as u32 + 1, name: format!("Artboard {}", i + 1), rect, ..board.clone() });
x = rect.x1 + gap;
for w in s.warnings {
if !warnings.contains(&w) {
warnings.push(w);
}
}
}
// Pages painted mostly in CMYK read as CMYK documents, as opening them as art does: the
// document opens in CMYK (with the CMYK default swatches) when most of its pages do.
if cmyk_pages * 2 > picked.len() {
doc.color_mode = ColorMode::Cmyk;
let (swatches, groups) = vectorcraft_color::default_swatches(ColorMode::Cmyk.model());
(doc.swatches, doc.swatch_groups) = (swatches.into(), groups.into());
}
if let Some(mode) = o.color_mode.filter(|m| *m != doc.color_mode) {
super::super::colormgmt::set_color_mode(&mut doc, mode, true, None, o.grays);
}
Ok((doc, warnings))
}
const INFO: &str = "document.pdfInfo";
/// `document.pdfInfo`.
@@ -254,6 +254,90 @@ fn a_plot_of_the_arts_bounds_leaves_out_images_in_symbol_art() {
}
}
/// In the art of a dynamic symbol a placed image and a path that paints nothing are left out as
/// well, until an instance paints the path, and a document whose only other visible art is an
/// instance of a dynamic symbol of an image is refused.
#[test]
fn a_plot_of_the_arts_bounds_leaves_out_images_in_dynamic_symbol_art() {
let mut s = two_boards();
let plot = |s: &mut Session| s.execute("document.export", &json!({"format": "hpgl", "useArtboards": false})).map(|v| bytes(&v));
let bounds = |s: &Session| super::export_art_bounds(&s.doc().unwrap().doc, "hpgl").unwrap();
let same = |a: vectorcraft_geom::Rect, b: vectorcraft_geom::Rect| {
[(a.x0, b.x0), (a.y0, b.y0), (a.x1, b.x1), (a.y1, b.y1)].iter().all(|(a, b)| (a - b).abs() < 1e-6)
};
let printing = s.doc().unwrap().doc.layers[0].id;
s.execute("layer.new", &json!({})).unwrap();
let mut png = vec![];
image::DynamicImage::new_rgb8(4, 4).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
let image = s.execute("file.place", &json!({"name": "far.png", "dataBase64": b64(&png), "at": [-600, 800]})).unwrap()["ids"][0].clone();
let square = s.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 20, "height": 20})).unwrap()["id"].clone();
let bare = s.execute("shape.rectangle", &json!({"x": 500, "y": -400, "width": 20, "height": 20})).unwrap()["id"].clone();
s.execute("paint.setStroke", &json!({"ids": [bare], "none": true})).unwrap();
let before = bounds(&s);
let inst = s.execute("symbol.new", &json!({"name": "Mixed", "ids": [image, square, bare], "type": "dynamic"})).unwrap()["id"].clone();
let after = bounds(&s);
assert!(same(before, after), "{before:?} {after:?}");
// A change of the path's blending mode leaves it painting nothing; once the instance paints the
// path, the path is measured.
let art = s.doc().unwrap().doc.symbols[0].art.clone();
let x0 = |c: &std::sync::Arc<vectorcraft_doc::Node>| c.visual_bounds().map_or(f64::MIN, |b| b.x0);
let at = (0..art.children().unwrap().len()).max_by(|a, b| x0(&art.children().unwrap()[*a]).total_cmp(&x0(&art.children().unwrap()[*b]))).unwrap();
s.execute("symbol.instance.setAppearance", &json!({"id": inst, "path": [at], "blend": "multiply"})).unwrap();
let blended = bounds(&s);
assert!(same(before, blended), "{before:?} {blended:?}");
s.execute("symbol.instance.setAppearance", &json!({"id": inst, "path": [at], "stroke": "#000000"})).unwrap();
let painted = bounds(&s);
assert!(painted.x1 > 500.0 && painted.y0 < -390.0, "{painted:?}");
// A later change to the same object, as a file can hold, takes the paint off again.
{
let id = vectorcraft_doc::NodeId(inst.as_u64().unwrap());
let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
let n = d.node_mut(id).unwrap();
let mut overrides = n.extras().symbol_overrides.clone();
overrides.push(vectorcraft_doc::SymbolOverride { path: vec![at as u32], opacity: Some(0.0), ..Default::default() });
n.extras_mut().symbol_overrides = overrides.clone();
let taken_off = bounds(&s);
assert!(same(before, taken_off), "{before:?} {taken_off:?}");
overrides.pop();
let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
d.node_mut(id).unwrap().extras_mut().symbol_overrides = overrides;
}
// Another instance, far above, that doesn't paint the path is measured without it, whether or
// not the first one paints it.
let other = s.execute("symbol.place", &json!({"name": "Mixed", "x": 0, "y": -5000})).unwrap()["id"].clone();
let with_paint = bounds(&s);
s.execute("symbol.instance.reset", &json!({"ids": [inst]})).unwrap();
let without_paint = bounds(&s);
assert!((with_paint.y0 - without_paint.y0).abs() < 1e-6, "{with_paint:?} {without_paint:?}");
s.execute("edit.undo", &json!({})).unwrap();
let undone = bounds(&s);
assert!(same(undone, with_paint), "{undone:?} {with_paint:?}");
s.execute("select.set", &json!({"ids": [other]})).unwrap();
s.execute("edit.clear", &json!({})).unwrap();
s.execute("select.set", &json!({"ids": [inst]})).unwrap();
s.execute("edit.clear", &json!({})).unwrap();
s.execute("layer.setProps", &json!({"id": printing, "visible": false})).unwrap();
let image = s.execute("file.place", &json!({"name": "far.png", "dataBase64": b64(&png), "at": [-600, 800]})).unwrap()["ids"][0].clone();
s.execute("symbol.new", &json!({"name": "Image", "ids": [image], "type": "dynamic"})).unwrap();
let e = plot(&mut s).unwrap_err().to_string();
assert!(e.contains("no visible art"), "{e}");
// An instance that brings a group at 0% opacity back to full opacity still draws nothing when
// the group holds only an image.
let image = s.execute("file.place", &json!({"name": "far.png", "dataBase64": b64(&png), "at": [-600, 800]})).unwrap()["ids"][0].clone();
let group = s.execute("object.group", &json!({"ids": [image]})).unwrap()["id"].clone();
s.execute("transparency.set", &json!({"ids": [group], "opacity": 0})).unwrap();
let inst = s.execute("symbol.new", &json!({"name": "Clear", "ids": [group], "type": "dynamic"})).unwrap()["id"].clone();
let id = vectorcraft_doc::NodeId(inst.as_u64().unwrap());
let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
d.node_mut(id).unwrap().extras_mut().symbol_overrides =
[vec![], vec![0]].into_iter().map(|path| vectorcraft_doc::SymbolOverride { path, opacity: Some(1.0), ..Default::default() }).collect();
let e = plot(&mut s).unwrap_err().to_string();
assert!(e.contains("no visible art"), "{e}");
}
/// A placed image with a Vector Halftone effect is measured when the effect makes dots, and left
/// out when it would take more cells than the effect makes.
#[test]
+4 -1
View File
@@ -865,7 +865,8 @@ pub(crate) fn propagate(before: &Document, d: &mut Document, ge: &GlobalEdit, se
}
/// 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.
/// series, and the transparency a series group among them took, 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 {
@@ -874,6 +875,8 @@ fn keep_series_changes(before: &Document, d: &mut Document, edited: &[NodeId]) {
}
}
super::appearance::capture_series_changes(before, d, &taken);
let groups = super::graph::series_groups_among(d, &taken);
super::graph::capture_series_object_opacity(d, &groups);
}
/// The outlines of the members of the active document's Global Edit that are not selected, hold
+1 -1
View File
@@ -48,7 +48,7 @@ use series::default_series_color;
pub(crate) use series::{
LabelTextChange, capture_label_anti_alias, capture_label_changes, 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, labels_in, paint_targets, series_group_marks, series_group_marks_where, series_marks_in,
label_object_opacity_differs, labels_in, paint_targets, series_group_marks, series_group_marks_where, series_groups_among, series_marks_in,
series_object_opacity_differs, shown_paint,
};
+27 -9
View File
@@ -1,4 +1,4 @@
use vectorcraft_doc::graph::art_bounds;
use vectorcraft_doc::graph::graph_art_without_type as art_bounds;
use super::axis::{MAX_TICK_MARKS, count_given, one_line};
use super::data::apply_data;
@@ -21,12 +21,16 @@ fn bound_param(p: &Value, key: &str, cmd: &str) -> Result<Option<Option<f64>>> {
/// The bounds a redraw of graph `n` measures a move or scale on, as kept at the last generation and as they are now:
/// those of its art without type ([`GraphSpec::placed_art`]), or those of all its art when the graph keeps no such
/// bounds with a width and a height, or has no art without type now.
/// bounds with a width and a height. `None` when the art measured has no width or no height now, or the graph keeps
/// bounds of its art without type and has no such art now.
fn measured(n: &Node) -> Option<(Rect, Rect)> {
let kept = n.extras().graph.as_deref()?;
let c = n.children()?;
let art = kept.placed_art.filter(|p| p.width() > 1e-9 && p.height() > 1e-9).zip(art_bounds(c));
art.or_else(|| kept.placed.zip(children_bounds(c)))
let solid = |r: &Rect| r.width() > 1e-9 && r.height() > 1e-9;
match kept.placed_art.filter(solid) {
Some(placed) => art_bounds(c).filter(solid).map(|now| (placed, now)),
None => kept.placed.zip(children_bounds(c).filter(solid)),
}
}
/// `r` carried by the change from bounds `from` to bounds `to`: each edge stays at the same place relative to them.
@@ -39,14 +43,28 @@ fn carried(r: Rect, from: Rect, to: Rect) -> Option<Rect> {
Some(Rect::new(to.x0 + (r.x0 - from.x0) * sx, to.y0 + (r.y0 - from.y0) * sy, to.x0 + (r.x1 - from.x0) * sx, to.y0 + (r.y1 - from.y0) * sy))
}
/// The graphs holding objects `ids`, each with the bounds of its art without type and of all its art. Give them to
/// [`keep_placed`] after an edit that changes a graph's art without moving the graph.
/// The graphs holding objects `ids`, that are among them or that are inside them, each with the bounds of its art
/// without type and of all its art. Give them to [`keep_placed`] after an edit that changes a graph's art without
/// moving the graph.
pub(crate) fn graphs_holding(d: &Document, ids: &[NodeId]) -> Vec<(NodeId, Option<Rect>, Option<Rect>)> {
let mut out: Vec<(NodeId, Option<Rect>, Option<Rect>)> = vec![];
let is_graph = |n: &Node| n.extras().graph.is_some();
let mut found: Vec<NodeId> = vec![];
for id in ids {
let Some(chain) = d.ancestry(*id) else { continue };
let holder = chain.iter().rev().skip(1).copied().find(|a| d.node(*a).is_some_and(|n| n.extras().graph.is_some()));
let Some(gid) = holder.filter(|g| !out.iter().any(|(o, ..)| o == g)) else { continue };
found.extend(chain.iter().rev().copied().find(|a| d.node(*a).is_some_and(is_graph)));
if let Some(n) = d.node(*id) {
n.walk(&mut |c| {
if is_graph(c) {
found.push(c.id);
}
});
}
}
let mut out: Vec<(NodeId, Option<Rect>, Option<Rect>)> = vec![];
for gid in found {
if out.iter().any(|(o, ..)| *o == gid) {
continue;
}
let children = d.node(gid).and_then(Node::children);
out.push((gid, children.and_then(|c| art_bounds(c)), children.and_then(|c| children_bounds(c))));
}
+2 -2
View File
@@ -6,7 +6,7 @@ use super::labels::{
recolour_text, runs, shadow_of,
};
use super::markers::{MARKER_BUDGET, marker_cost, marker_designs};
use super::series::{picks_legend_fill, series_marks, with_series_gradients, with_series_opacity};
use super::series::{picks_legend_fill, series_marks, with_series_gradients, with_series_group_opacity, with_series_opacity};
use super::{
Arc, BezPath, Color, Document, GraphKind, GraphSpec, Justify, Node, NodeKind, Paint, PathData, Point, Rect, TickLength, ValueAxisSide, shapes,
};
@@ -1005,7 +1005,7 @@ impl<'a> Plot<'a> {
}
}
let name = g.series.get(s).cloned().filter(|n| !n.is_empty()).unwrap_or_else(|| format!("Series {}", s + 1));
let grp = self.b.series(&name, s, with_series_opacity(g, s, with_series_gradients(g, s, items)));
let grp = with_series_group_opacity(g, s, self.b.series(&name, s, with_series_opacity(g, s, with_series_gradients(g, s, items))));
self.b.out.push(grp);
}
self.b.out.append(&mut self.on_top);
+89 -19
View File
@@ -475,15 +475,16 @@ pub(crate) fn capture_series_opacity(doc: &mut Document, edited: &[(NodeId, bool
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.
/// Copy the object transparency of generated series art just edited (`edited`) into what that series keeps: a path
/// directly in a series group sets the paths' object opacity and blend mode, and the series group itself sets the
/// group's opacity, blend mode, isolation, knockout and knockout shape. Called inside the same document edit, so both undo
/// together. Returns whether what a series keeps changed.
pub(crate) fn capture_series_object_opacity(doc: &mut Document, edited: &[NodeId]) -> bool {
let mut before = SeriesPaintsBefore::new();
for (graph, index, opacity, blend) in object_opacity_captures(doc, edited) {
for (graph, index, group, look) 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 {
if look == ObjectOpacity::default() && spec.series_paints.len() <= index {
continue;
}
before.entry(graph).or_insert_with(|| spec.series_paints.clone());
@@ -492,7 +493,12 @@ pub(crate) fn capture_series_object_opacity(doc: &mut Document, edited: &[NodeId
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);
if group {
(slot.group_opacity, slot.group_blend) = (look.opacity, look.blend);
(slot.group_isolate, slot.group_knockout, slot.group_knockout_shape) = (look.isolate, look.knockout, look.knockout_shape);
} else {
(slot.opacity, slot.blend) = (look.opacity, look.blend);
}
}
settle_series_paints(doc, before)
}
@@ -500,20 +506,50 @@ pub(crate) fn capture_series_object_opacity(doc: &mut Document, edited: &[NodeId
/// Would [`capture_series_object_opacity`] change what the graph stores for `edited`? Reads the document without copying
/// it.
pub(crate) fn series_object_opacity_differs(doc: &Document, edited: &[NodeId]) -> bool {
let mut after: HashMap<(NodeId, usize), (Option<f32>, Option<BlendMode>)> = HashMap::new();
let stored = |graph: NodeId, index: usize| {
let mut after: HashMap<(NodeId, usize, bool), ObjectOpacity> = HashMap::new();
let stored = |graph: NodeId, index: usize, group: bool| {
let spec = doc.node(graph).and_then(|n| n.extras().graph.as_deref());
spec.and_then(|g| g.series_paints.get(index)).map_or((None, None), |slot| (slot.opacity, slot.blend))
let slot = spec.and_then(|g| g.series_paints.get(index));
slot.map_or_else(ObjectOpacity::default, |slot| {
if group {
ObjectOpacity {
opacity: slot.group_opacity,
blend: slot.group_blend,
isolate: slot.group_isolate,
knockout: slot.group_knockout,
knockout_shape: slot.group_knockout_shape,
}
} else {
ObjectOpacity { opacity: slot.opacity, blend: slot.blend, ..ObjectOpacity::default() }
}
})
};
for (graph, index, opacity, blend) in object_opacity_captures(doc, edited) {
after.insert((graph, index), (opacity, blend));
for (graph, index, group, look) in object_opacity_captures(doc, edited) {
after.insert((graph, index, group), look);
}
after.into_iter().any(|((graph, index), pair)| stored(graph, index) != pair)
after.into_iter().any(|((graph, index, group), look)| stored(graph, index, group) != look)
}
/// The series and object opacity and blend mode (None for 100% and Normal) of each of `edited` that is a path directly in a
/// series group, in order.
fn object_opacity_captures(doc: &Document, edited: &[NodeId]) -> Vec<(NodeId, usize, Option<f32>, Option<BlendMode>)> {
/// The series groups among `ids`, for an edit that changes only what a group's transparency has beyond its opacity and
/// blend mode (isolation, knockout and knockout shape), which the graph keeps for series groups alone.
pub(crate) fn series_groups_among(doc: &Document, ids: &[NodeId]) -> Vec<NodeId> {
ids.iter().copied().filter(|id| doc.node(*id).is_some_and(|n| n.extras().series_index.is_some())).collect()
}
/// An object's transparency as a graph keeps it: the object opacity and blend mode (None for 100% and Normal), and for a
/// series group its isolation, knockout (None for neutral) and knockout shape. Paths keep the first two only.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
struct ObjectOpacity {
opacity: Option<f32>,
blend: Option<BlendMode>,
isolate: bool,
knockout: Option<vectorcraft_doc::Knockout>,
knockout_shape: bool,
}
/// The graph, series, whether it is the series group itself (`true`) or a path directly in it, and the object
/// transparency of each of `edited` that is one of those, in order.
fn object_opacity_captures(doc: &Document, edited: &[NodeId]) -> Vec<(NodeId, usize, bool, ObjectOpacity)> {
if edited.is_empty() {
return Vec::new();
}
@@ -524,14 +560,48 @@ fn object_opacity_captures(doc: &Document, edited: &[NodeId]) -> Vec<(NodeId, us
edited
.iter()
.filter_map(|id| {
// Only the paths the graph draws again, not the copy of a graph design.
let r = members.get(id).filter(|r| doc.parent_of(*id) == Some(r.group))?;
let node = doc.node(*id).filter(|n| matches!(n.kind, NodeKind::Path { .. }))?;
Some((r.graph, r.index, (node.opacity < 1.0).then_some(node.opacity), (node.blend != BlendMode::Normal).then_some(node.blend)))
let r = members.get(id)?;
let group = r.group == *id;
// Only the series group and the paths the graph draws again, not the copy of a graph design.
let node = doc.node(*id).filter(|n| group || (doc.parent_of(*id) == Some(r.group) && matches!(n.kind, NodeKind::Path { .. })))?;
let mut look = ObjectOpacity {
opacity: (node.opacity < 1.0).then_some(node.opacity),
blend: (node.blend != BlendMode::Normal).then_some(node.blend),
..ObjectOpacity::default()
};
if group {
look.isolate = node.isolate;
look.knockout = (node.knockout != vectorcraft_doc::Knockout::Neutral).then_some(node.knockout);
look.knockout_shape = node.knockout_shape;
}
Some((r.graph, r.index, group, look))
})
.collect()
}
/// Series `i`'s group (`group`) with the object opacity, blend mode, isolation, knockout and knockout shape the graph
/// keeps for it. An opacity read from a file is held to 0..1, and one that is not a number is left out.
pub(super) fn with_series_group_opacity(g: &GraphSpec, i: usize, mut group: Arc<Node>) -> Arc<Node> {
let Some(slot) = g.series_paints.get(i) else { return group };
let opacity = slot.group_opacity.filter(|o| o.is_finite()).map(|o| o.clamp(0.0, 1.0));
if opacity.is_none() && slot.group_blend.is_none() && !slot.group_isolate && slot.group_knockout.is_none() && !slot.group_knockout_shape {
return group;
}
let n = Arc::make_mut(&mut group);
if let Some(o) = opacity {
n.opacity = o;
}
if let Some(b) = slot.group_blend {
n.blend = b;
}
if let Some(k) = slot.group_knockout {
n.knockout = k;
}
n.isolate |= slot.group_isolate;
n.knockout_shape |= slot.group_knockout_shape;
group
}
/// Graph Data › Transpose row/column: categories become series and series categories.
pub(super) fn transpose(g: &mut GraphSpec) {
let cells = g.cells();
+328 -4
View File
@@ -923,6 +923,32 @@ fn what_global_edit_gives_the_marks_of_another_series_group_survives_regeneratio
assert!(series(&n, 1).children().unwrap().iter().all(|m| m.appearance.fill_paint() == given));
}
/// With a whole series group picked and Global Edit on, the opacity and blend mode set on that group reach the other
/// series groups of the same form, and the graph stores them for those groups too.
#[test]
fn the_transparency_global_edit_gives_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).id, series(&n, 1).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}");
s.execute("transparency.set", &json!({"ids": [first.0], "opacity": 40, "blend": "multiply"})).unwrap();
let g = s.doc().unwrap().doc.node(other).unwrap().clone();
assert_eq!((g.opacity, g.blend), (0.4, BlendMode::Multiply), "Global Edit reaches the other group");
let kept = spec(&s, id).series_paints[1].clone();
assert_eq!((kept.group_opacity, kept.group_blend), (Some(0.4), Some(BlendMode::Multiply)));
s.execute("object.exitIsolation", &json!({})).unwrap();
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap();
assert_eq!(group_transparency(&s, id, 1).0, (0.4, BlendMode::Multiply));
}
/// 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]
@@ -2210,8 +2236,8 @@ fn appearance_commands_given_a_series_group_edit_its_marks() {
s.execute("edit.undo", &json!({})).unwrap();
let (rect, blue) = blue_style(&mut s, "Blue", false);
let was = spec(&s, id).series_paints;
s.execute("object.setProps", &json!({"ids": [group.0], "opacity": 50})).unwrap();
let was = spec(&s, id).series_paints;
s.execute("appearance.transfer", &json!({"source": rect.0, "target": group.0, "copy": true})).unwrap();
assert!(series_0_group(&s, id).1);
assert_eq!(s.doc().unwrap().doc.node(group).unwrap().opacity, 1.0, "the appearance replaces the group's own opacity");
@@ -2433,10 +2459,10 @@ fn a_graphic_style_applied_to_a_series_group_styles_its_marks() {
let (mut s, id, marks) = stored_red_stroke();
let (group, _) = series_0_group(&s, id);
let (_, blue) = blue_style(&mut s, "Blue", false);
let was = spec(&s, id).series_paints;
let own = json!({"ids": [group.0], "opacity": 50, "blend": "multiply", "isolate": true, "knockout": "on", "item": null});
s.execute("transparency.set", &own).unwrap();
s.execute("effect.apply", &json!({"ids": [group.0], "effect": "distort.twist"})).unwrap();
let was = spec(&s, id).series_paints;
let g = s.doc().unwrap().doc.node(group).unwrap().clone();
assert!(g.isolate && g.knockout != Default::default() && g.blend != vectorcraft_color::BlendMode::Normal);
s.execute("graphicStyle.apply", &json!({"name": "Blue", "ids": [group.0]})).unwrap();
@@ -2513,6 +2539,161 @@ fn redefining_a_graphic_style_keeps_an_edited_mark_of_a_linked_series_group() {
assert!(marks[1..].iter().all(|m| d.node(*m).unwrap().extras().graphic_style == Some(style.id)));
}
/// Series `index`'s group: its object opacity and blend mode, and those of each path directly inside it.
fn group_transparency(s: &Session, id: NodeId, index: u32) -> ((f32, BlendMode), Vec<(f32, BlendMode)>) {
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let g = series(&n, index);
((g.opacity, g.blend), g.children().unwrap().iter().map(|m| (m.opacity, m.blend)).collect())
}
/// An object opacity and blend mode set on a selected series group are kept by the graph for the group: the series group
/// has them after the graph is drawn again, the graph's data reads them back unchanged after it is written to JSON, its
/// paths and the other series stay opaque and Normal, setting them again records no step, 100% and Normal forget them,
/// and undo takes them back.
#[test]
fn an_opacity_and_blend_mode_on_a_series_group_survive_regeneration() {
let (mut s, id, _, _) = column_marks();
let (group, _) = series_0_group(&s, id);
s.execute("select.set", &json!({"ids": [group.0]})).unwrap();
s.execute("transparency.set", &json!({"opacity": 40, "blend": "multiply"})).unwrap();
let kept = spec(&s, id).series_paints[0].clone();
assert_eq!((kept.group_opacity, kept.group_blend), (Some(0.4), Some(BlendMode::Multiply)));
assert_eq!((kept.opacity, kept.blend), (None, None), "the paths keep their own");
let restored: GraphSpec = serde_json::from_value(serde_json::to_value(spec(&s, id)).unwrap()).unwrap();
assert_eq!(restored, spec(&s, id));
s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap();
let normal = (1.0, BlendMode::Normal);
let (own, paths) = group_transparency(&s, id, 0);
assert_eq!(own, (0.4, BlendMode::Multiply));
assert!(!paths.is_empty() && paths.iter().all(|t| *t == normal), "{paths:?}");
let (own, paths) = group_transparency(&s, id, 1);
assert_eq!(own, normal);
assert!(paths.iter().all(|t| *t == normal), "{paths:?}");
let (group, _) = series_0_group(&s, id);
let undo = s.doc().unwrap().history.undo.len();
s.execute("object.setProps", &json!({"id": group.0, "opacity": 40, "blend": "multiply"})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "the values the group and the graph have already");
s.execute("object.setProps", &json!({"id": group.0, "opacity": 100, "blend": "normal"})).unwrap();
let kept = spec(&s, id).series_paints[0].clone();
assert_eq!((kept.group_opacity, kept.group_blend), (None, None));
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("graph.setData", &json!({"rows": [[2, 2], [3, 4]]})).unwrap();
assert_eq!(group_transparency(&s, id, 0).0, normal);
for _ in 0..4 {
s.execute("edit.undo", &json!({})).unwrap();
}
assert!(spec(&s, id).series_paints.is_empty(), "undo takes back the edit and what the graph stored");
}
/// Commands that clear a series group's own opacity and blend mode also remove what the graph keeps for it: Clear
/// Appearance on the group, a graphic style applied to it and Move Appearance onto it.
#[test]
fn clearing_a_series_groups_opacity_forgets_what_the_graph_keeps() {
let kept = |s: &Session, id: NodeId| {
let slot = spec(s, id).series_paints[0].clone();
(slot.group_opacity, slot.group_blend)
};
let faded = (Some(0.5), Some(BlendMode::Screen));
let (mut s, id, _, _) = column_marks();
let (rect, _) = blue_style(&mut s, "Blue", false);
let (group, _) = series_0_group(&s, id);
s.execute("object.setProps", &json!({"id": group.0, "opacity": 50, "blend": "screen"})).unwrap();
assert_eq!(kept(&s, id), faded);
let commands = [
("appearance.clear", json!({"ids": [group.0]})),
("graphicStyle.apply", json!({"name": "Blue", "ids": [group.0]})),
("appearance.transfer", json!({"source": rect.0, "target": group.0, "copy": true})),
];
for (cmd, params) in commands {
s.execute(cmd, &params).unwrap();
assert_eq!(kept(&s, id), (None, None), "{cmd}");
s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap();
assert_eq!(group_transparency(&s, id, 0).0, (1.0, BlendMode::Normal), "{cmd}");
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(kept(&s, id), faded, "{cmd}");
}
}
/// Redefining a graphic style a series group is linked to clears the group's own opacity and blend mode, and removes
/// what the graph keeps for them.
#[test]
fn redefining_a_style_linked_to_a_series_group_forgets_its_kept_opacity() {
let (mut s, id, _, _) = column_marks();
let (group, _) = series_0_group(&s, id);
let (rect, _) = blue_style(&mut s, "Blue", false);
let style = s.doc().unwrap().doc.graphic_style("Blue").unwrap().clone();
s.edit("Link", |d, _| {
let i = d.graphic_style_index("Blue").unwrap();
(d.graphic_styles[i].opacity, d.graphic_styles[i].blend) = (0.5, BlendMode::Screen);
let g = d.node_mut(group).unwrap();
g.appearance = style.appearance.clone().into();
(g.opacity, g.blend) = (0.5, BlendMode::Screen);
g.extras_mut().graphic_style = Some(style.id);
Ok(())
})
.unwrap();
s.execute("object.setProps", &json!({"id": group.0, "opacity": 50, "blend": "screen"})).unwrap();
let slot = spec(&s, id).series_paints[0].clone();
assert_eq!((slot.group_opacity, slot.group_blend), (Some(0.5), Some(BlendMode::Screen)));
assert_eq!(s.doc().unwrap().doc.node(group).unwrap().extras().graphic_style, Some(style.id), "still linked");
s.execute("appearance.setItem", &json!({"ids": [rect.0], "index": top_row(&s, rect, true), "color": "#00ff00"})).unwrap();
s.execute("graphicStyle.redefine", &json!({"name": "Blue", "id": rect.0})).unwrap();
let slot = spec(&s, id).series_paints.first().cloned().unwrap_or_default();
assert_eq!((slot.group_opacity, slot.group_blend), (None, None));
s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap();
assert_eq!(group_transparency(&s, id, 0).0, (1.0, BlendMode::Normal));
}
/// Isolate Blending, a knockout state and Knockout Shape set on a selected series group are kept by the graph for the
/// group, through the graph being drawn again; the other series' group stays as the graph draws it, and turning them off
/// forgets them.
#[test]
fn isolation_and_knockout_on_a_series_group_survive_regeneration() {
let (mut s, id, _, _) = column_marks();
let (group, _) = series_0_group(&s, id);
s.execute("select.set", &json!({"ids": [group.0]})).unwrap();
s.execute("transparency.set", &json!({"isolate": true, "knockout": "off", "knockoutShape": true})).unwrap();
let slot = spec(&s, id).series_paints[0].clone();
assert_eq!((slot.group_isolate, slot.group_knockout, slot.group_knockout_shape), (true, Some(vectorcraft_doc::Knockout::Off), true));
assert_eq!((slot.group_opacity, slot.group_blend, slot.opacity, slot.blend), (None, None, None, None));
let undo = s.doc().unwrap().history.undo.len();
s.execute("object.setProps", &json!({"id": group.0, "isolate": true})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "the values the group and the graph have already");
s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap();
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let (g, other) = (series(&n, 0), series(&n, 1));
assert_eq!((g.isolate, g.knockout, g.knockout_shape), (true, vectorcraft_doc::Knockout::Off, true));
assert_eq!((other.isolate, other.knockout, other.knockout_shape), (false, vectorcraft_doc::Knockout::Neutral, false));
let (group, _) = series_0_group(&s, id);
s.execute("object.setProps", &json!({"id": group.0, "isolate": false, "knockout": "neutral", "knockoutShape": false})).unwrap();
assert!(spec(&s, id).series_paints.iter().all(|p| *p == vectorcraft_doc::SeriesPaint::default()), "nothing is kept");
}
/// Randomize Opacity and Sort Objects given a series group keep the opacity they give it for the group.
#[test]
fn randomize_and_sort_opacity_on_a_series_group_are_kept() {
let (mut s, id, _, _) = column_marks();
let (group, _) = series_0_group(&s, id);
s.execute("addon.arrange.randomizeOpacity", &json!({"ids": [group.0], "min": 30, "max": 60, "seed": 7})).unwrap();
let given = s.doc().unwrap().doc.node(group).unwrap().opacity;
assert!(given < 1.0);
assert_eq!(spec(&s, id).series_paints[0].group_opacity, Some(given));
s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap();
assert_eq!(group_transparency(&s, id, 0).0, (given, BlendMode::Normal));
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let (first, second) = (series(&n, 0).id, series(&n, 1).id);
s.execute("object.setProps", &json!({"id": second.0, "opacity": 80})).unwrap();
s.execute("addon.arrange.sort", &json!({"ids": [first.0, second.0], "by": "x", "apply": "opacity", "order": "descending"})).unwrap();
let d = &s.doc().unwrap().doc;
let (a, b) = (d.node(first).unwrap().opacity, d.node(second).unwrap().opacity);
assert_eq!((a, b), (0.8, given), "the opacities trade places");
let kept = &spec(&s, id).series_paints;
assert_eq!((kept[0].group_opacity, kept.get(1).and_then(|k| k.group_opacity)), ((a < 1.0).then_some(a), (b < 1.0).then_some(b)));
}
/// Default Fill and Stroke on a series mark stores a white fill and a 1 pt black stroke, both opaque, on the graph.
#[test]
fn default_fill_and_stroke_on_a_series_mark_is_kept() {
@@ -5796,6 +5977,141 @@ fn outlining_a_graphs_labels_leaves_the_plot_in_place() {
let moved = spec(&s, id).rect;
assert!((moved.x0 - (rect.x0 + 30.0)).abs() < 1e-6 && (moved.y0 - (rect.y0 + 15.0)).abs() < 1e-6, "{moved:?}");
assert!((moved.width() - rect.width()).abs() < 1e-6 && (moved.height() - rect.height()).abs() < 1e-6, "{moved:?}");
// A scale of the graph made before outlining is still followed.
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("object.scale", &json!({"sx": 150, "sy": 50, "origin": [moved.x0, moved.y0]})).unwrap();
s.execute("select.set", &json!({"ids": [text_id(&s, id, "Axes", "Q3").0]})).unwrap();
s.execute("type.createOutlines", &json!({})).unwrap();
s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap();
let scaled = spec(&s, id).rect;
assert!((scaled.x0 - moved.x0).abs() < 1e-6 && (scaled.y0 - moved.y0).abs() < 1e-6, "{scaled:?}");
assert!((scaled.width() - 1.5 * rect.width()).abs() < 1e-6 && (scaled.height() - 0.5 * rect.height()).abs() < 1e-6, "{scaled:?}");
}
/// Outline Stroke on a graph's strokes leaves the plot where it is when the graph is drawn again, and a move of the
/// graph made before it is still followed.
#[test]
fn outlining_a_graphs_strokes_leaves_the_plot_in_place() {
let csv = ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6";
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
let rect = spec(&s, id).rect;
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("object.move", &json!({"dx": 20, "dy": 10})).unwrap();
s.execute("object.path.outlineStroke", &json!({})).unwrap();
s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap();
let moved = spec(&s, id).rect;
assert!((moved.x0 - (rect.x0 + 20.0)).abs() < 1e-6 && (moved.y0 - (rect.y0 + 10.0)).abs() < 1e-6, "{moved:?}");
assert!((moved.width() - rect.width()).abs() < 1e-6 && (moved.height() - rect.height()).abs() < 1e-6, "{moved:?}");
}
/// A graph left with only its labels, or with its labels and one vertical line, leaves the plot where it is when it is
/// drawn again after a label edit.
#[test]
fn a_graph_left_with_only_its_labels_leaves_the_plot_in_place() {
for keep_a_line in [false, true] {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
let rect = spec(&s, id).rect;
let (mut art, mut line) = (vec![], None);
s.doc().unwrap().doc.node(id).unwrap().walk(&mut |n| {
if n.children().is_none() && !matches!(n.kind, NodeKind::Text(_)) {
let vertical = n.geometric_bounds().is_some_and(|b| b.width() < 1e-9 && b.height() > 1.0);
if keep_a_line && vertical && line.is_none() {
line = n.geometric_bounds();
} else {
art.push(n.id.0);
}
}
});
assert!(!art.is_empty() && line.is_some() == keep_a_line);
s.execute("select.set", &json!({"ids": art})).unwrap();
s.execute("edit.clear", &json!({})).unwrap();
let left = s.doc().unwrap().doc.node(id).unwrap().clone();
assert_eq!(vectorcraft_doc::graph::graph_art_without_type(left.children().unwrap()), line, "{:#?}", left.children());
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("text.setStyle", &json!({"size": 20})).unwrap();
s.execute("graph.setData", &json!({"id": id.0, "csv": ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"})).unwrap();
assert_eq!(spec(&s, id).rect, rect, "keeping a line: {keep_a_line}");
}
}
/// A graph saved without the bounds of its art without type, left with one vertical line, keeps its plot's size when
/// it is drawn again.
#[test]
fn a_graph_saved_with_only_its_whole_bounds_and_left_with_a_line_keeps_its_plot() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
let rect = spec(&s, id).rect;
let mut old = spec(&s, id);
old.placed_art = None;
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(id).unwrap().extras_mut().graph = Some(Box::new(old));
let (mut art, mut line) = (vec![], None);
s.doc().unwrap().doc.node(id).unwrap().walk(&mut |n| {
if n.children().is_none() {
let vertical = !matches!(n.kind, NodeKind::Text(_)) && n.geometric_bounds().is_some_and(|b| b.width() < 1e-9 && b.height() > 1.0);
if vertical && line.is_none() {
line = Some(n.id);
} else {
art.push(n.id.0);
}
}
});
assert!(line.is_some());
s.execute("select.set", &json!({"ids": art})).unwrap();
s.execute("edit.clear", &json!({})).unwrap();
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("graph.setData", &json!({"id": id.0, "csv": ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"})).unwrap();
assert_eq!(spec(&s, id).rect, rect);
}
/// The bounds a graph measures moves on take a clipping group holding type at the bounds of its clip, so art outside the
/// clip does not change them; a clipping group whose clip is type is measured on its other art.
#[test]
fn a_clipping_group_holding_type_is_measured_on_its_clip() {
use std::sync::Arc;
use vectorcraft_doc::graph::graph_art_without_type as measure;
use vectorcraft_geom::Affine;
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let label: Arc<vectorcraft_doc::Node> =
group(&n, "Legend").children().unwrap().iter().find(|c| matches!(c.kind, NodeKind::Text(_))).unwrap().clone();
let mut paths = vec![];
n.walk(&mut |c| {
if matches!(c.kind, NodeKind::Path { .. }) {
paths.push(c.clone());
}
});
let (clip, art) = (Arc::new(paths[0].clone()), paths[1].clone());
let clipped = |dx: f64, first: Arc<vectorcraft_doc::Node>, second: Arc<vectorcraft_doc::Node>| {
let mut far = art.clone();
far.transform(Affine::translate((dx, 0.0)), false);
let mut g = vectorcraft_doc::Node::group(NodeId(999_999), vec![first, second, Arc::new(far)]);
if let NodeKind::Group { clip, .. } = &mut g.kind {
*clip = true;
}
measure(&[Arc::new(g)])
};
assert_eq!(clipped(500.0, clip.clone(), label.clone()), clip.geometric_bounds());
assert_eq!(clipped(500.0, clip.clone(), label.clone()), clipped(900.0, clip.clone(), label.clone()));
// A clip that is type leaves the other art to measure.
assert_ne!(clipped(500.0, label.clone(), clip.clone()), clipped(900.0, label.clone(), clip.clone()));
// A clip that is a clipping group holding type in its clipped art is measured on its own clip.
let mut inner = vectorcraft_doc::Node::group(NodeId(999_998), vec![clip.clone(), label.clone()]);
if let NodeKind::Group { clip, .. } = &mut inner.kind {
*clip = true;
}
let inner = Arc::new(inner);
assert_eq!(clipped(500.0, inner.clone(), label.clone()), clip.geometric_bounds());
assert_eq!(clipped(500.0, inner.clone(), label.clone()), clipped(900.0, inner.clone(), label.clone()));
// A blend holding type is measured whole, as it is drawn.
let blend = vectorcraft_doc::Node::new(NodeId(999_997), NodeKind::Blend { children: vec![clip.clone(), label.clone()], spec: Box::default() });
assert_eq!(measure(&[Arc::new(blend.clone())]), blend.geometric_bounds());
}
/// A graph whose kept bounds of its art without type have no width follows a move with the bounds of all of its art.
@@ -7113,9 +7429,17 @@ fn a_graph_opened_from_ai_measures_moves_without_its_labels() {
assert_eq!((graphs.len(), labels.len()), (1, 1));
let (id, label) = (graphs[0], labels[0]);
let before = spec(&s, id);
assert!(before.placed_art.is_some());
s.execute("select.set", &json!({"ids": [label.0]})).unwrap();
s.execute("text.setStyle", &json!({"size": 30})).unwrap();
s.execute("object.move", &json!({"dx": 80, "dy": -40})).unwrap();
s.execute("graph.setData", &json!({"id": id.0, "csv": ",East\nQ1,8\nQ2,2"})).unwrap();
assert_eq!(spec(&s, id).rect, before.rect);
let rect = spec(&s, id).rect;
assert_eq!(rect, before.rect, "after restyling and moving a label");
// A move of the whole opened graph is still followed.
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("object.move", &json!({"dx": 15, "dy": 25})).unwrap();
s.execute("graph.setData", &json!({"id": id.0, "csv": ",East\nQ1,8\nQ2,2"})).unwrap();
let moved = spec(&s, id).rect;
assert!((moved.x0 - (rect.x0 + 15.0)).abs() < 1e-6 && (moved.y0 - (rect.y0 + 25.0)).abs() < 1e-6, "{moved:?}");
assert!((moved.width() - rect.width()).abs() < 1e-6 && (moved.height() - rect.height()).abs() < 1e-6, "{moved:?}");
}
+30 -4
View File
@@ -81,7 +81,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Links",
[],
None,
"{show?: all (default)|missing|modified|embedded, sort?: name|kind (file format)|status (missing, modified, ok, embedded); default: stacking order; top first, dcsTransparency?: false (Show DCS Transparency Interactions: mark the images placed from DCS files that are transparent, themselves or a group or layer holding them, or that overlap a visible object with its own transparency; it looks at every object, so it takes longer)} every image object and placed document the document stores, as the Links panel lists them: those in the layers, then those in opacity-mask art and in the art of symbols, graph designs, patterns and stored brushes (one row per stored object, never one per symbol instance, pattern tile or brushed stroke) → {links: [{id, name, linked, status: ok|modified|missing|embedded, format (DCS for an image placed from a DCS file), pixelWidth, pixelHeight, path?, found?: path (found away from its path), page?, preview?: true (showing the saved preview), note?: said of an image past the decode allowance (shown at reduced size, or too large to draw), document?: true (a placed document: pageWidth and pageHeight in pt instead of pixels), dcsTransparency?: true (with dcsTransparency), owner?: {kind: layers|symbol|graphDesign|pattern|brush, name? (of the symbol, graph design, pattern or brush), index? (a brush's place in the Brushes panel, from 0), tile?: side|outerCorner|innerCorner|start|end (a pattern brush's tile), maskOf?: id (in the opacity-mask art of that object)} (an object outside the layer tree)}], missing, modified, embedded}; under automation read roots, path and found are left out when the file they name is outside the roots",
"{show?: all (default)|missing|modified|embedded, sort?: name|kind (file format)|status (missing, modified, ok, embedded)|size (the linked file's size in bytes as Link Info shows it, smallest first; rows without one, embedded images and missing files whose size is not kept, last; equal sizes by name); default: stacking order; top first, dcsTransparency?: false (Show DCS Transparency Interactions: mark the images placed from DCS files that are transparent, themselves or a group or layer holding them, or that overlap a visible object with its own transparency; it looks at every object, so it takes longer)} every image object and placed document the document stores, as the Links panel lists them: those in the layers, then those in opacity-mask art and in the art of symbols, graph designs, patterns and stored brushes (one row per stored object, never one per symbol instance, pattern tile or brushed stroke) → {links: [{id, name, linked, status: ok|modified|missing|embedded, format (DCS for an image placed from a DCS file), pixelWidth, pixelHeight, path?, found?: path (found away from its path), page?, preview?: true (showing the saved preview), note?: said of an image past the decode allowance (shown at reduced size, or too large to draw), document?: true (a placed document: pageWidth and pageHeight in pt instead of pixels), dcsTransparency?: true (with dcsTransparency), owner?: {kind: layers|symbol|graphDesign|pattern|brush, name? (of the symbol, graph design, pattern or brush), index? (a brush's place in the Brushes panel, from 0), tile?: side|outerCorner|innerCorner|start|end (a pattern brush's tile), maskOf?: id (in the opacity-mask art of that object)} (an object outside the layer tree)}], missing, modified, embedded}; under automation read roots, path and found are left out when the file they name is outside the roots",
has_doc,
list
),
@@ -2211,6 +2211,16 @@ fn document_row(p: &PlacedDocument) -> Value {
json!({ "format": pdf.map_or("VectorCraft", |f| f.label), "document": true, "pageWidth": p.width, "pageHeight": p.height })
}
/// The size in bytes of the file that link `l` (of `st`) names, as Link Info shows it: the file's
/// size now, else the size the document keeps of it when the caller may see its path.
fn link_file_size(st: &crate::DocState, l: &LinkInfo) -> Option<u64> {
let doc_path = LinkBase::of(st).doc;
let kept = l.size.filter(|_| path_shown(&l.path, doc_path));
let roots = crate::file_access::current().or_else(|| st.opened_by.clone());
let now = crate::file_access::confine(roots.as_ref(), || fileio::document_path(&l.path, doc_path).ok().as_deref().and_then(file_stamp));
now.map(|s| s.0).or(kept)
}
/// Sort rank of a `links.list` status: missing, modified, ok, embedded.
fn status_rank(status: &str) -> usize {
["missing", "modified", "ok", "embedded"].iter().position(|s| *s == status).unwrap_or(4)
@@ -2277,8 +2287,14 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> {
if !matches!(show, "all" | "missing" | "modified" | "embedded") {
return Err(bad(C, format!("show `{show}`: all, missing, modified or embedded")));
}
let sort = str_param(p, "sort");
if let Some(x) = sort.filter(|x| !matches!(*x, "name" | "kind" | "status" | "size")) {
return Err(bad(C, format!("sort `{x}`: name, kind, status or size")));
}
let interactions = if switch_or(p, "dcsTransparency", false, C)? { dcs_transparency(&st.doc) } else { BTreeSet::new() };
let statuses = statuses(st);
// The file size of each listed row's link, read only to sort by size.
let mut sizes: BTreeMap<u64, u64> = BTreeMap::new();
// The layer tree's objects, top first as the Layers panel lists them, then the others in the
// order they are stored (opacity-mask art, symbols, graph designs, patterns, the art of the
// stored brushes), each once.
@@ -2326,6 +2342,11 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> {
}
let doc_path = LinkBase::of(st).doc;
if let Some(l) = link {
if sort == Some("size")
&& let Some(size) = link_file_size(st, l)
{
sizes.insert(n.id.0, size);
}
note_path(&mut row, "path", &l.path, doc_path);
if let Some(page) = l.page {
row["page"] = json!(page);
@@ -2346,12 +2367,17 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> {
rows.push(row);
}
let text = |r: &Value, k: &str| r[k].as_str().unwrap_or_default().to_lowercase();
match str_param(p, "sort") {
None => {}
match sort {
Some("name") => rows.sort_by_cached_key(|r| text(r, "name")),
Some("kind") => rows.sort_by_cached_key(|r| (text(r, "format"), text(r, "name"))),
Some("status") => rows.sort_by_cached_key(|r| (status_rank(r["status"].as_str().unwrap_or_default()), text(r, "name"))),
Some(x) => return Err(bad(C, format!("sort `{x}`: name, kind or status"))),
// Smallest file first; rows without a file size (embedded images, a missing file whose
// size the document does not keep) come last; equal sizes by name.
Some("size") => rows.sort_by_cached_key(|r| {
let size = r["id"].as_u64().and_then(|id| sizes.get(&id).copied());
(size.is_none(), size.unwrap_or(0), text(r, "name"))
}),
_ => {}
}
let count = |k: &str| counts.get(k).copied().unwrap_or(0);
Ok(json!({ "links": rows, "missing": count("missing"), "modified": count("modified"), "embedded": count("embedded") }))
+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); on a graph label, the graph stores the label's opacity and blend mode for every label of its kind",
"{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, and on a series group the group's opacity, blend mode, isolate, knockout and knockoutShape (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
),
+11 -3
View File
@@ -68,8 +68,14 @@ pub(super) fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
// 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) || crate::cmd::graph::label_object_opacity_differs(d, &ids));
// A series group's isolation, knockout and knockout shape are kept for the group alone.
let group_only = !transparency && ["isolate", "knockout", "knockoutShape"].iter().any(|k| p.get(*k).is_some());
let kept = if group_only { crate::cmd::graph::series_groups_among(d, &ids) } else { ids.clone() };
let stores = || {
(transparency || group_only)
&& (crate::cmd::graph::series_object_opacity_differs(d, &kept)
|| transparency && 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 {
@@ -123,8 +129,10 @@ pub(super) fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
});
}
}
if transparency || group_only {
crate::cmd::graph::capture_series_object_opacity(d, &kept);
}
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.
+1 -1
View File
@@ -81,7 +81,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Transparency",
["Window", "Transparency"],
None,
"{ids?|id?, opacity?: 0..100, blend?: name, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, item?: appearance item index|null, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} for `ids`, the selection, or the object whose opacity mask is being edited; opacity and blend go to the targeted fill/stroke item (omitted: the Appearance panel's active item, else the objects; when every target is a gradient mesh with points picked by the Mesh tool, Direct Selection or the Lasso, those points take the opacity themselves). 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",
"{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; when every target is a gradient mesh with points picked by the Mesh tool, Direct Selection or the Lasso, those points take the opacity themselves). 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, and on a series group without an item, the group's own opacity, blend mode, isolate, knockout and knockoutShape (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
),
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -183,7 +183,7 @@ pub(crate) fn accepted_keys(doc: &'static str) -> Option<Vec<&'static str>> {
/// Does the description `doc` name `key` as a parameter (its own keys, and those of the commands
/// its `…` entries name)?
pub(crate) fn takes_key(doc: &'static str, key: &str) -> bool {
pub fn takes_key(doc: &'static str, key: &str) -> bool {
doc_keys(doc).is_ok_and(|parsed| parsed.keys.contains(&key)) || accepted(doc, 0).is_some_and(|keys| keys.contains(&key))
}
+3
View File
@@ -1188,6 +1188,8 @@ fn outline_stroke(s: &mut Session, _: &Value) -> Result<Value> {
let roots = selected_roots(s)?;
let ids = s.edit("Outline Stroke", |d, sel| {
let before = d.clone();
// Outlining the strokes in a graph does not move or scale its plot at the next redraw.
let graphs = super::graph::graphs_holding(d, &roots);
// A dimension selected with the art it measures keeps its own lines: it is out of the
// document while the art is outlined.
let lifted = super::dimension::lift(d, &roots);
@@ -1207,6 +1209,7 @@ fn outline_stroke(s: &mut Session, _: &Value) -> Result<Value> {
if changed == 0 || (kept && d.layers == before.layers) {
return Err(EngineError::Other("Outline Stroke: no stroked paths selected".into()));
}
super::graph::keep_placed(d, &graphs);
sel.set(new_sel.iter().copied());
Ok(new_sel)
})?;
+122 -8
View File
@@ -7,7 +7,8 @@
//! fields; `perspective.grid.preset` applies one, `perspective.presets.*` manage them.
//!
//! View options (Lock Grid, Lock Station Point, Snap to Grid, Show Rulers) are view state on
//! the grid: they toggle without an undo step, as Show Grid does.
//! the grid: they toggle without an undo step, as Show Grid does. So is the point Automatic Plane
//! Positioning holds the active plane through (`perspective.plane.infer`).
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
@@ -15,12 +16,15 @@ use vectorcraft_doc::{Document, NodeId};
use vectorcraft_geom::{Point, Rect, Vec2};
use vectorcraft_tools::distort::perspective::Plane;
use vectorcraft_tools::distort::perspective::define::{BUILTINS, LETTER, first_artboard, is_builtin};
use vectorcraft_tools::distort::perspective::infer::{HIT_PX, InferSource};
use vectorcraft_tools::distort::perspective::widget::{WidgetCorner, WidgetPlace};
use vectorcraft_tools::distort::perspective::{GridDefinition, PerspectiveGrid};
use vectorcraft_tools::distort::perspective::{GridDefinition, MAX_DEPTH, PerspectiveGrid};
use vectorcraft_tools::{PointerEvent, PointerKind, ToolKey};
use super::distortcmds::{grid_kept, grid_of, silent, store_grid};
use super::{CommandSpec, EngineError, Result, Session, always, bad, bool_or, cmd, has_doc, str_param, switch_or, unique_name};
use super::{
CommandSpec, EngineError, Result, Session, always, bad, bool_or, cmd, finite_numbers, has_doc, number_or, str_param, switch_or, unique_name,
};
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -92,10 +96,19 @@ pub fn specs() -> Vec<CommandSpec> {
"Perspective Grid Options",
[],
None,
"{show?: bool, position?: topLeft|topRight|bottomLeft|bottomRight} the Plane Switching Widget: shown or not, and the corner of the document window it stays in (kept with the preferences; no params reads them) → {show, position}",
"{show?: bool, position?: topLeft|topRight|bottomLeft|bottomRight, anchorPoint?: bool, gridlineIntersection?: bool} the Plane Switching Widget: shown or not, and the corner of the document window it stays in; Automatic Plane Positioning: whether it takes a point from an anchor point of art in perspective and from a gridline intersection (both on by default; see perspective.plane.infer). Kept with the preferences; no params reads them → {show, position, anchorPoint, gridlineIntersection}",
always,
widget_options
),
cmd!(
"perspective.plane.infer",
"Automatic Plane Positioning",
[],
None,
"{at?: [x, y], tolerance?: pt around `at` (default 6), clear?: bool} with `at` on an anchor point of shown art in perspective, or on a gridline intersection of a plane that shows, the active plane moves through that point for now and the other planes hide (the Perspective Selection tool: Shift held over one with no button down). An anchor point within reach wins over an intersection, and perspective.widget.options turns either off. Making another plane active (perspective.plane.set) brings that plane through the same point. The plane goes back to its place when an object is drawn on it or attached to it (perspective.draw, perspective.attach, an object brought into perspective by perspective.move or perspective.transform), when a plane is moved (perspective.plane.move, perspective.plane.matchObject), when the grid hides or no plane is active, when the file is opened again, and with `clear` (Escape with the Perspective Selection tool). View state: not an undo step; no params reads it → {active, plane, offset: where the active plane is, point?: [x, y, z] in the grid's frame, source?: anchor|gridline (what `at` found; absent when nothing was within reach)}",
has_doc,
plane_infer
),
cmd!(
"perspective.grid.lock",
"Lock Grid",
@@ -259,21 +272,32 @@ pub(crate) fn grid_preset(s: &mut Session, p: &Value) -> Result<Value> {
let old = grid_of(&s.doc()?.doc);
let ab = s.doc()?.doc.artboards.first().map(|a| a.rect).ok_or_else(|| EngineError::Other("no artboard".into()))?;
let g = s.perspective_preset_grid(&name, ab).ok_or_else(|| bad(C, format!("no perspective grid preset named `{name}`")))?;
// The objects stay attached and the view options stay as they were.
let g = PerspectiveGrid { attached: old.attached, locked: old.locked, lock_station: old.lock_station, snap: old.snap, rulers: old.rulers, ..g };
// The objects stay attached and the view options stay as they were, and so does a plane held
// by Automatic Plane Positioning while the new grid can hold it.
let mut g = PerspectiveGrid {
attached: old.attached,
locked: old.locked,
lock_station: old.lock_station,
snap: old.snap,
rulers: old.rulers,
inferred: old.inferred,
..g
};
g.keep_positioning_valid();
// When the document holds this grid already, apart from whether it shows and its active plane,
// the preset only sets those two, without an undo step (as perspective.grid.show and
// perspective.plane.set do).
let view = PerspectiveGrid { visible: old.visible, plane: old.plane, ..g.clone() };
let view = PerspectiveGrid { visible: old.visible, plane: old.plane, inferred: old.inferred, ..g.clone() };
if !grid_kept(&s.doc()?.doc, &view) {
s.edit("Perspective Grid Preset", |d, _| {
store_grid(d, &g);
Ok(())
})?;
} else if (old.visible, old.plane) != (g.visible, g.plane) {
} else if (old.visible, old.plane, old.inferred) != (g.visible, g.plane, g.inferred) {
silent(s, |cur| {
cur.visible = g.visible;
cur.plane = g.plane;
cur.inferred = g.inferred;
})?;
}
Ok(g.definition_json())
@@ -460,10 +484,100 @@ fn widget_options(s: &mut Session, p: &Value) -> Result<Value> {
o.position =
v.as_str().and_then(WidgetCorner::parse).ok_or_else(|| bad(C, "position must be topLeft, topRight, bottomLeft or bottomRight"))?;
}
if let Some(v) = p.get("anchorPoint") {
o.anchor_point = v.as_bool().ok_or_else(|| bad(C, "anchorPoint must be true or false"))?;
}
if let Some(v) = p.get("gridlineIntersection") {
o.gridline_intersection = v.as_bool().ok_or_else(|| bad(C, "gridlineIntersection must be true or false"))?;
}
s.prefs.perspective_widget = o;
Ok(json!(o))
}
/// What `perspective.plane.infer` reports for `g`.
fn infer_info(g: &PerspectiveGrid, source: Option<InferSource>) -> Value {
let active = g.inferred.is_some() && g.plane != Plane::None;
let mut v = json!({ "active": active, "plane": g.plane.id(), "offset": g.offset(g.plane) });
if let Some(at) = g.inferred.filter(|_| active) {
v["point"] = json!(at);
}
if let Some(source) = source {
v["source"] = json!(source.id());
}
v
}
fn plane_infer(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "perspective.plane.infer";
let clear = switch_or(p, "clear", false, C)?;
let at = match p.get("at") {
None | Some(Value::Null) => None,
Some(v) => Some(finite_numbers(v).map(|[x, y]| Point::new(x, y)).ok_or_else(|| bad(C, "`at` must be [x, y]"))?),
};
let tol = number_or(p, "tolerance", HIT_PX, C)?;
if !(tol.is_finite() && (0.0..=MAX_DEPTH).contains(&tol)) {
return Err(bad(C, "tolerance must be 0 or more"));
}
if clear && at.is_some() {
return Err(bad(C, "give `at` or `clear`, not both"));
}
let doc = &s.doc()?.doc;
let g = PerspectiveGrid::current(doc);
let mut source = None;
let next = match at {
_ if clear => None,
None => g.inferred,
Some(at) => {
if !g.visible {
return Err(EngineError::Other("the perspective grid is hidden".into()));
}
if g.plane == Plane::None {
return Err(EngineError::Other("no active perspective plane".into()));
}
let targets = s.prefs.perspective_widget.targets();
if !targets.any() {
return Err(EngineError::Other(
"Automatic Plane Positioning is off: turn on anchorPoint or gridlineIntersection (perspective.widget.options)".into(),
));
}
match g.infer_at(doc, at, tol, targets) {
Some((found, point)) => {
source = Some(found);
Some(point)
}
None => g.inferred,
}
}
};
if next == g.inferred {
return Ok(infer_info(&g, source));
}
// A plane through the point stays in front of the viewer.
if next.is_some() {
PerspectiveGrid { inferred: next, ..g }.validate().map_err(|e| bad(C, e))?;
}
let g = silent(s, |g| g.inferred = next)?;
Ok(infer_info(&g, source))
}
/// An object was drawn on, or attached to, `plane`: when Automatic Plane Positioning holds that
/// plane, it goes back to its place as view state, not an undo step (a plane it doesn't hold
/// leaves it as it is). In a drag that happens once the drag is committed
/// ([`Session::commit_interaction`]), so the plane stays put while the object is drawn.
pub(crate) fn plane_positioning_used(s: &mut Session, plane: Plane) -> Result<()> {
let st = s.doc_mut()?;
let g = grid_of(&st.doc);
if g.inferred.is_none() || g.plane != plane {
return Ok(());
}
if let Some(it) = &mut st.interaction {
it.plane_placed = true;
return Ok(());
}
silent(s, |g| g.inferred = None)?;
Ok(())
}
impl Session {
/// The Plane Switching Widget's part of a pointer event: a press on it picks the plane (its
/// drag and release go nowhere); None when the event isn't the widget's.
+40 -4
View File
@@ -20,7 +20,7 @@ use std::sync::Arc;
use vectorcraft_doc::links::hash_bytes;
use vectorcraft_doc::placed_document::{PREVIEW_MAX, PREVIEW_PX_PER_PT, key_for};
use vectorcraft_doc::{Document, ImageBlob, LinkInfo, Node, NodeKind, PageBox, PlacedDocument, PlacementOptions};
use vectorcraft_doc::{ColorMode, Document, ImageBlob, LinkInfo, Node, NodeKind, PageBox, PlacedDocument, PlacementOptions};
use vectorcraft_geom::{Affine, Rect};
use vectorcraft_pdf::CropTo;
@@ -221,22 +221,57 @@ pub(crate) struct Source {
/// What is shown, in the file's coordinates: the artboard, the page's box or the art's bounds.
pub frame: Rect,
pub warnings: Vec<String>,
/// The colour mode the file's art was read in (a PDF page painted mostly in CMYK reads in CMYK).
pub color_mode: ColorMode,
}
/// The VectorCraft document `name` (`bytes`, from `path` when there is one) read for showing
/// artboard `page`, or the PDF or `.ai` file for showing page `page` clipped to its box
/// `page_box` (its art's bounds when `bounding`).
pub(crate) fn source(name: &str, bytes: &[u8], path: Option<&str>, page: u32, bounding: bool, page_box: PageBox, cmd: &str) -> Result<Source> {
read_source(name, bytes, None, path, (page, bounding, page_box), cmd)
}
/// [`source`] of the file whose bytes `bytes` holds, kept by the blob without a copy of its own:
/// several pages of one file read this way share one copy of it.
pub(crate) fn shared_source(
name: &str,
bytes: &Arc<Vec<u8>>,
path: Option<&str>,
page: u32,
bounding: bool,
page_box: PageBox,
cmd: &str,
) -> Result<Source> {
read_source(name, bytes, Some(bytes), path, (page, bounding, page_box), cmd)
}
/// [`source`], the blob keeping `shared` when given (else a copy of `bytes`); `shown` is the page
/// (or artboard), whether the art's bounds are shown, and the page's box.
fn read_source(
name: &str,
bytes: &[u8],
shared: Option<&Arc<Vec<u8>>>,
path: Option<&str>,
shown: (u32, bool, PageBox),
cmd: &str,
) -> Result<Source> {
let (page, bounding, page_box) = shown;
// The same document placed the same way is read once (placing it again is free).
let key = key_for(&hash_bytes(bytes), page, bounding, page_box);
// Resolving its links depends on where the file is and on what the caller may read, so a hit
// needs the same path, and nothing is shared with or from a confined (automation) caller.
let confined = crate::file_access::current().is_some();
if !confined && let Some((_, s)) = lock_memo().iter().find(|(at, s)| s.key == key && at.as_deref() == path).cloned() {
if !confined && let Some((_, mut s)) = lock_memo().iter().find(|(at, s)| s.key == key && at.as_deref() == path).cloned() {
// The same bytes: the copy given is kept instead, so the pages that share it go on sharing.
if let Some(b) = shared {
s.blob.bytes = b.clone();
}
return Ok(s);
}
let a = document_art(name, bytes, path, page, bounding, page_box, cmd)?;
let mut blob = ImageBlob::new(mime(name, bytes), bytes.to_vec());
let bytes = shared.cloned().unwrap_or_else(|| Arc::new(bytes.to_vec()));
let mut blob = ImageBlob { mime: mime(name, &bytes).into(), bytes, proxy: None };
let shown = PlacedDocument {
link: LinkInfo { page: Some(page), ..LinkInfo::new(path.unwrap_or(name)) },
key: key.clone(),
@@ -249,9 +284,10 @@ pub(crate) fn source(name: &str, bytes: &[u8], path: Option<&str>, page: u32, bo
};
let art = group(a.nodes, a.clip);
blob.proxy = preview(&a.doc, &art, a.frame).map(Arc::new);
let color_mode = a.doc.color_mode;
// Drawing it needn't read it again.
vectorcraft_doc::placed_document::prime(&shown, blob.bytes.clone(), (a.doc, art, a.frame));
let s = Source { blob, key, page_box, frame: a.frame, warnings: a.warnings };
let s = Source { blob, key, page_box, frame: a.frame, warnings: a.warnings, color_mode };
if !confined {
let mut m = lock_memo();
if m.len() >= MEMO_SIZE {
+175 -35
View File
@@ -101,12 +101,30 @@ const FILE_WORDS: &[&str] = &[
"clipboard",
];
/// Param keys that name a file or a folder.
const FILE_KEYS: &[&str] =
&["path", "paths", "file", "files", "folder", "folders", "dir", "directory", "destinationFolder", "sourceFolder", "url", "diffPath"];
/// Param keys that name a file or a folder, each by its whole name, the folder preferences
/// `prefs.set {values}` takes included: a command that adds a key naming one adds it here. A key
/// that only ends like one (`alignToPath`, `linksFolder`, `sortPaths`) names none, nor do `folders`
/// (how an export groups its files) and `url` (a web link).
const FILE_KEYS: &[&str] = &[
"path",
"paths",
"file",
"files",
"folder",
"dir",
"directory",
"destinationFolder",
"sourceFolder",
"diffPath",
"fontsFolder",
"pluginsFolder",
"recoveryFolder",
"templatesFolder",
];
/// The keys in [`FILE_KEYS`] that name a folder.
const FOLDER_KEYS: &[&str] = &["folder", "folders", "dir", "directory", "destinationFolder", "sourceFolder"];
const FOLDER_KEYS: &[&str] =
&["folder", "dir", "directory", "destinationFolder", "sourceFolder", "fontsFolder", "pluginsFolder", "recoveryFolder", "templatesFolder"];
/// What a param key that names a place on disk names there.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -132,7 +150,83 @@ pub fn key_location(key: &str) -> Option<Location> {
/// What `key` names on disk in command `spec`: [`key_location`], or `None` when [`NOT_FILES`]
/// names it for `spec` (it names part of the document there).
pub fn location_in(spec: &CommandSpec, key: &str) -> Option<Location> {
if NOT_FILES.contains(&(spec.id, key)) { None } else { key_location(key) }
ParamsPlace::top(Some(spec)).member(key, None).1
}
/// A place in the params of a command. What a key names on disk depends on the command and on the
/// keys above it: [`key_location`], less a key [`NOT_FILES`] names for the command at the top of its
/// params and one at the end of a [`NESTED_NOT_FILES`] key list. A walk of params starts at
/// [`ParamsPlace::top`] and goes down with [`ParamsPlace::member`] and [`ParamsPlace::item`].
#[derive(Clone, Copy, Debug)]
pub struct ParamsPlace {
/// The command whose params these are; `None` for a command that isn't known.
command: Option<&'static str>,
/// Whether this is the top of the params.
top: bool,
/// The keys of a [`NESTED_NOT_FILES`] list still to follow below this place.
trail: Option<&'static [&'static str]>,
}
impl ParamsPlace {
/// The top of the params of `spec`, or of a command that isn't known (`None`), where every key in
/// [`FILE_KEYS`] names a file or a folder.
pub fn top(spec: Option<&CommandSpec>) -> ParamsPlace {
ParamsPlace { command: spec.map(|s| s.id), top: true, trail: None }
}
/// The top of the params of the command with id `id` ([`ParamsPlace::top`]).
pub fn of(id: &str) -> ParamsPlace {
ParamsPlace::top(super::find_command(id))
}
/// The top of the params of the command that object `fields` calls, when it is a call
/// (`{command | id: <command id>, params}`, as a `command.batch` step or a control request is):
/// the place of its `params` member ([`ParamsPlace::member`]).
pub fn carried(fields: &serde_json::Map<String, Value>) -> Option<ParamsPlace> {
if !fields.contains_key("params") {
return None;
}
let id = fields.get("command").or_else(|| fields.get("id")).and_then(Value::as_str)?;
super::find_command(id).map(|spec| ParamsPlace::top(Some(spec)))
}
/// The top of the params of the command that array `items` records, when it is a recorded step
/// (`[command id, params]`): the place of its item 1 ([`ParamsPlace::item`]).
pub fn step(items: &[Value]) -> Option<ParamsPlace> {
match items {
[Value::String(id), Value::Object(_)] => super::find_command(id).map(|spec| ParamsPlace::top(Some(spec))),
_ => None,
}
}
/// The place of member `key` of an object here, with what `key` names on disk there. `carried` is
/// [`ParamsPlace::carried`] of that object: its `params` member is then the top of that command's
/// params, and names no file.
pub fn member(self, key: &str, carried: Option<ParamsPlace>) -> (ParamsPlace, Option<Location>) {
if key == "params"
&& let Some(inner) = carried
{
return (inner, None);
}
let rest = if self.top {
let id = self.command;
NESTED_NOT_FILES.iter().find(|(c, keys)| Some(*c) == id && keys.first() == Some(&key)).map(|(_, keys)| keys.get(1..).unwrap_or_default())
} else {
self.trail.filter(|keys| keys.first() == Some(&key)).map(|keys| keys.get(1..).unwrap_or_default())
};
let document = (self.top && self.command.is_some_and(|id| NOT_FILES.contains(&(id, key)))) || rest.is_some_and(<[&str]>::is_empty);
let place = ParamsPlace { command: self.command, top: false, trail: rest.filter(|keys| !keys.is_empty()) };
(place, if document { None } else { key_location(key) })
}
/// The place of item `index` of an array here. `step` is [`ParamsPlace::step`] of that array: its
/// item 1 is then the top of that command's params.
pub fn item(self, index: usize, step: Option<ParamsPlace>) -> ParamsPlace {
match step {
Some(inner) if index == 1 => inner,
_ => ParamsPlace { command: self.command, top: false, trail: None },
}
}
}
/// The param keys of `spec` that name a file or a folder, with what they name: the keys in
@@ -158,9 +252,6 @@ pub(crate) const NOT_FILES: &[(&str, &str)] = &[
("symbol.instance.setAppearance", "path"),
("symbol.instance.selectPart", "path"),
("object.objectsOnPath.attach", "path"),
("attributes.set", "url"),
("object.slice.options", "url"),
("document.exportForScreens", "folders"),
];
/// Commands and the keys from the top of their params down to a key in [`FILE_KEYS`] that, there,
@@ -251,34 +342,24 @@ pub(crate) fn names_key(text: &str, key: &str) -> bool {
})
}
/// Does `args`, the arguments of a call of command `id`, carry a key in [`FILE_KEYS`] at any depth
/// (in objects and in arrays), such as an effect's `textureImage.path`? Only a key [`NOT_FILES`]
/// names for `id`, at the top level, and one at the end of a [`NESTED_NOT_FILES`] key list for
/// `id`, aren't counted. The walk keeps its own stack, so deep arguments can't overflow the
/// thread's.
/// Does `args`, the arguments of a call of command `id`, carry a key that names a file or a folder at
/// any depth (in objects and in arrays, [`ParamsPlace`]), such as an effect's `textureImage.path`?
/// Only a key [`NOT_FILES`] names for `id`, at the top level, and one at the end of a
/// [`NESTED_NOT_FILES`] key list for `id`, aren't counted. A call or a recorded step inside `args`
/// is read as part of `id`'s arguments, not as the command it names. The walk keeps its own stack,
/// so deep arguments can't overflow the thread's.
fn names_file(id: &str, args: &Value) -> bool {
// Each value, whether it is the params themselves, and the keys of a NESTED_NOT_FILES list
// still to follow below it.
let mut stack: Vec<(&Value, bool, Option<&[&str]>)> = vec![(args, true, None)];
while let Some((v, top, trail)) = stack.pop() {
let mut stack: Vec<(&Value, ParamsPlace)> = vec![(args, ParamsPlace::of(id))];
while let Some((v, place)) = stack.pop() {
match v {
Value::Array(a) => stack.extend(a.iter().map(|x| (x, false, None))),
Value::Array(a) => stack.extend(a.iter().enumerate().map(|(i, x)| (x, place.item(i, None)))),
Value::Object(o) => {
for (k, x) in o {
let k = k.as_str();
let rest = if top {
NESTED_NOT_FILES
.iter()
.find(|(c, keys)| *c == id && keys.first() == Some(&k))
.map(|(_, keys)| keys.get(1..).unwrap_or_default())
} else {
trail.filter(|keys| keys.first() == Some(&k)).map(|keys| keys.get(1..).unwrap_or_default())
};
let document = (top && NOT_FILES.contains(&(id, k))) || rest.is_some_and(<[&str]>::is_empty);
if FILE_KEYS.contains(&k) && !document {
let (below, location) = place.member(k, None);
if location.is_some() {
return true;
}
stack.push((x, false, rest.filter(|keys| !keys.is_empty())));
stack.push((x, below));
}
}
_ => {}
@@ -638,7 +719,13 @@ mod tests {
assert!(names_file("effect.apply", &texture));
assert!(names_file("effect.setParams", &texture));
assert!(names_file("effect.apply", &json!({"params": {"layers": [{"name": "a"}, {"folder": "/tmp"}]}})));
assert!(names_file("select.all", &json!([[[{"url": "file:///etc/hosts"}]]])));
assert!(names_file("select.all", &json!([[[{"file": "file:///etc/hosts"}]]])));
// A web link and an export's grouping name no file.
assert!(!names_file("attributes.set", &json!({"url": "https://example.com/a"})));
assert!(!names_file("object.move", &json!({"opts": {"folders": "rows", "alignToPath": "baseline"}})));
// A call nested in the arguments doesn't vouch for its own keys.
assert!(names_file("object.move", &json!({"opts": {"command": "object.setLiveShape", "params": {"path": "/tmp/x"}}})));
assert!(names_file("object.move", &json!({"opts": ["object.setLiveShape", {"path": "/tmp/x"}]})));
assert!(!names_file("effect.apply", &json!({"effect": "distort.glass", "params": {"distortion": 5, "textureImage": "frosted"}})));
assert!(!names_file("object.move", &json!({"dx": 5, "dy": 0, "ids": [1, 2], "opts": {"copy": false}})));
// `path` is the path text is set on in text.createInPath, but only as its own param.
@@ -647,7 +734,13 @@ mod tests {
assert!(names_file("object.move", &json!({"path": [[0, 0]]})));
// object.setLiveShape takes no `path`; its description says "on any path:" in its prose.
assert!(names_file("object.setLiveShape", &json!({"path": "/tmp/x"})));
// Image Trace's path fidelity setting, only inside its tracing settings.
}
/// Image Trace's path fidelity setting `paths` names no file, but only inside its tracing
/// settings.
#[test]
fn image_trace_paths_name_no_file_only_in_its_settings() {
use serde_json::json;
for id in ["imageTrace.make", "imageTrace.makeAndExpand"] {
assert!(!names_file(id, &json!({"preset": "Default", "params": {"mode": "color", "paths": 50}})));
assert!(!names_file(id, &json!({"savedPreset": {"paths": 50, "corners": 75}})));
@@ -658,7 +751,12 @@ mod tests {
assert!(names_file(id, &json!({"options": {"paths": 50}})));
}
assert!(names_file("object.move", &json!({"params": {"paths": 50}})));
// A custom arrowhead's outline in stroke.set, only where stroke.set takes one.
}
/// A custom arrowhead's outline `path` names no file, but only where stroke.set takes one.
#[test]
fn a_custom_arrowhead_outline_names_no_file_only_in_stroke_set() {
use serde_json::json;
let outline = json!({"subpaths": []});
assert!(!names_file("stroke.set", &json!({"endArrow": {"Custom": {"name": "A", "path": outline}}})));
assert!(!names_file("stroke.set", &json!({"startArrow": {"Custom": {"name": "A", "path": outline}}, "endArrow": "Triangle"})));
@@ -668,7 +766,12 @@ mod tests {
assert!(names_file("stroke.set", &json!({"options": {"endArrow": {"Custom": {"path": "/tmp/x"}}}})));
assert!(names_file("stroke.set", &json!({"endArrow": [{"Custom": {"path": "/tmp/x"}}]})));
assert!(names_file("object.move", &json!({"endArrow": {"Custom": {"path": "/tmp/x"}}})));
// Deep nesting is walked without recursion.
}
/// A file key under deep nesting is found, and the walk doesn't recurse.
#[test]
fn a_file_key_is_found_under_deep_nesting() {
use serde_json::json;
let mut deep = json!({"file": "/tmp/x"});
for _ in 0..10_000 {
deep = Value::Array(vec![deep]);
@@ -726,11 +829,48 @@ mod tests {
assert!(found.iter().all(|(k, l)| (*l == Location::Folder) == FOLDER_KEYS.contains(k)), "{}: {found:?}", spec.id);
assert!(denied(spec).is_some() || found.is_empty(), "{} names {found:?} but a plug-in may call it", spec.id);
}
assert_eq!(key_location("url"), Some(Location::File));
assert_eq!(key_location("url"), None);
assert_eq!(key_location("folders"), None);
assert_eq!(key_location("linksFolder"), None);
assert_eq!(key_location("sourceFolder"), Some(Location::Folder));
assert_eq!(key_location("pathData"), None);
}
/// A key keeps the meaning its command gives it at every depth: a call (`{command, params}`) and a
/// recorded step (`[command id, params]`) carry their command's exceptions down, and a custom
/// arrowhead's outline is part of the document only at its own place in `stroke.set`.
#[test]
fn a_params_place_follows_its_command() {
use serde_json::json;
let at = |place: ParamsPlace, keys: &[&str]| {
let mut place = place;
let mut location = None;
for k in keys {
(place, location) = place.member(k, None);
}
location
};
assert_eq!(at(ParamsPlace::of("text.createInPath"), &["path"]), None);
assert_eq!(at(ParamsPlace::of("text.createInPath"), &["options", "path"]), Some(Location::File));
assert_eq!(at(ParamsPlace::of("stroke.set"), &["endArrow", "Custom", "path"]), None);
assert_eq!(at(ParamsPlace::of("stroke.set"), &["endArrow", "Custom", "file"]), Some(Location::File));
assert_eq!(at(ParamsPlace::of("no.such.command"), &["path"]), Some(Location::File));
assert_eq!(at(ParamsPlace::top(None), &["destinationFolder"]), Some(Location::Folder));
let call = json!({"command": "text.createInPath", "params": {"path": [[0, 0]]}});
let carried = call.as_object().and_then(ParamsPlace::carried);
let (inner, location) = ParamsPlace::of("command.batch").member("params", carried);
assert_eq!(location, None);
assert_eq!(inner.member("path", None).1, None);
assert_eq!(ParamsPlace::carried(&serde_json::Map::new()).map(|_| ()), None);
let unknown = json!({"command": "no.such.command", "params": {}});
assert!(unknown.as_object().and_then(ParamsPlace::carried).is_none());
let step = [json!("text.createInPath"), json!({"path": [[0, 0]]})];
let place = ParamsPlace::top(None).item(1, ParamsPlace::step(&step));
assert_eq!(place.member("path", None).1, None);
assert_eq!(ParamsPlace::top(None).item(0, ParamsPlace::step(&step)).member("path", None).1, Some(Location::File));
assert!(ParamsPlace::step(&[json!("text.createInPath")]).is_none());
}
/// A file key counts as a command's param only where its description takes it: not a word in
/// the prose after the params, in a nested object or in the result. A description that doesn't
/// say which keys it takes counts any `key:` in its text.
@@ -474,6 +474,7 @@ pub(super) const ALLOWED: &[&str] = &[
"perspective.grid.snap",
"perspective.move",
"perspective.nudge",
"perspective.plane.infer",
"perspective.plane.matchObject",
"perspective.plane.move",
"perspective.plane.set",
+111 -26
View File
@@ -3,9 +3,14 @@
//! applied effect names the document's filter: editing the filter changes what every object that
//! uses it draws, and deleting it leaves their effects in place, drawing nothing, until a filter of
//! that name exists again. Each effect holds the definition it draws with, kept in step here.
//!
//! The default filters ([`svg_filter::defaults`]) are listed for every document. Applying or
//! editing one adds a copy to the document under the default's name, and from then on that copy is
//! the filter of that name. Deleting the copy lists the default again.
use serde_json::{Value, json};
use vectorcraft_doc::{Appearance, Document, SvgFilter};
use vectorcraft_render::effects::svg_filter::defaults::{DEFAULT_FILTERS, default_filter};
use vectorcraft_render::effects::{self, svg_filter};
use super::fileio::{absolute_path, read_file_head};
@@ -21,7 +26,7 @@ pub fn specs() -> Vec<CommandSpec> {
"SVG Filters",
[],
None,
"{} → {filters: [{name, xml}…]} the SVG filters the document holds, in the SVG Filters submenu's order",
"{} → {filters: [{name, xml}…], defaults: [{name, xml, inDocument}…]} filters: the SVG filters the document holds, in the order they were added; defaults: the default filters every document lists (VectorCraft's own set), with inDocument true when the document holds a filter of that name, which then stands in the default's place. The SVG Filters submenu and Apply SVG Filter list the defaults first, then the document's other filters",
has_doc,
list
),
@@ -30,7 +35,7 @@ pub fn specs() -> Vec<CommandSpec> {
"New SVG Filter",
[],
None,
"{xml: one SVG <filter> element (its id is its name; 1..64 primitives; at most 64 KB), replace?: the name of the filter it replaces and keeps (Edit SVG Filter)} add an SVG filter to the document, after the others, or replace one, as one undo step; every object whose svg.filter effect names it draws with the new definition; a name another filter has is refused and a document holds at most 64 filters → {name, filters}",
"{xml: one SVG <filter> element (its id is its name; 1..64 primitives; at most 64 KB), replace?: the name of the filter it replaces and keeps (Edit SVG Filter)} add an SVG filter to the document, after the others, or replace one, as one undo step; every object whose svg.filter effect names it draws with the new definition; a name another filter of the document has is refused and a document holds at most 64 filters; replace with the name of a default filter the document does not hold adds the edited copy to the document under that name, and code equal to the default's adds nothing; a new filter may take a default's name, and then stands in its place → {name, filters}",
has_doc,
define
),
@@ -39,7 +44,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Delete SVG Filter",
[],
None,
"{name} remove an SVG filter from the document, as one undo step; objects whose svg.filter effect names it keep the effect, which draws nothing until a filter of that name is defined or imported again → {filters}",
"{name} remove an SVG filter from the document, as one undo step; objects whose svg.filter effect names it keep the effect, which draws nothing until a filter of that name is defined or imported again, or the default of that name is applied; a default filter the document holds no copy of is refused (the defaults are not deleted), and deleting the document's copy of one lists the default again → {filters}",
has_doc,
delete
),
@@ -57,7 +62,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Apply SVG Filter",
[],
None,
"{name, ids?|id?, item?} apply the document's SVG filter `name` (svgFilter.list) to ids or the selection as a live svg.filter effect, as one undo step, like effect.apply → {ids, index, item}",
"{name, ids?|id?, item?, index?} apply the document's SVG filter `name`, or the default filter of that name when the document holds none (svgFilter.list), to ids or the selection as a live svg.filter effect, as one undo step, like effect.apply → {ids, index, item}; with index, the filter takes the place of the applied svg.filter effect at index of item, like effect.setParams → {ids}; a default is copied into the document in the same undo step, which is refused when the document already holds 64 filters",
has_doc,
apply
),
@@ -69,7 +74,10 @@ fn filters_json(d: &Document) -> Value {
}
fn list(s: &mut Session, _: &Value) -> Result<Value> {
Ok(json!({ "filters": filters_json(&s.doc()?.doc) }))
let d = &s.doc()?.doc;
let held = |name: &str| d.svg_filters.iter().any(|f| f.name == name);
let defaults: Vec<Value> = DEFAULT_FILTERS.iter().map(|f| json!({ "name": f.name, "xml": f.xml, "inDocument": held(f.name) })).collect();
Ok(json!({ "filters": filters_json(d), "defaults": defaults }))
}
/// `p`'s `name`, trimmed and not empty.
@@ -132,10 +140,12 @@ fn define(s: &mut Session, p: &Value) -> Result<Value> {
};
svg_filter::check(&xml).map_err(|e| bad(C, e))?;
let filters = &s.doc()?.doc.svg_filters;
let at = match &replace {
Some(r) => Some(filters.iter().position(|f| f.name == *r).ok_or_else(|| bad(C, format!("the document has no SVG filter named {r:?}")))?),
None => None,
};
let at = replace.as_ref().and_then(|r| filters.iter().position(|f| f.name == *r));
// A default the document does not hold: its edited copy is added under the default's name.
let default = replace.as_ref().filter(|_| at.is_none()).and_then(|r| default_filter(r));
if let Some(r) = replace.as_ref().filter(|_| at.is_none() && default.is_none()) {
return Err(bad(C, format!("the document has no SVG filter named {r:?}")));
}
// An edited filter keeps its name, whatever id its new code gives it.
let xml = match &replace {
Some(r) => svg_filter::with_id(&xml, r).ok_or_else(|| bad(C, "give one <filter> element"))?,
@@ -145,22 +155,28 @@ fn define(s: &mut Session, p: &Value) -> Result<Value> {
if filters.iter().enumerate().any(|(i, f)| f.name == name && Some(i) != at) {
return Err(bad(C, format!("the document already has an SVG filter named {name:?}")));
}
let new = SvgFilter { name: name.clone(), xml };
// Nothing to record when the filter, and every object that uses it, is already as given: a
// default saved with its own code stays out of the document.
let as_given = match default {
Some(d) => d.to_filter() == new,
None => at.and_then(|i| filters.get(i)) == Some(&new),
};
if as_given && !out_of_step(&s.doc()?.doc, &new.name, &new.xml) {
return Ok(json!({ "name": name, "filters": filters_json(&s.doc()?.doc) }));
}
if at.is_none() && filters.len() >= SvgFilter::MAX {
return Err(bad(C, format!("a document holds at most {} SVG filters", SvgFilter::MAX)));
}
let new = SvgFilter { name: name.clone(), xml };
// Nothing to record when the filter, and every object that uses it, is already as given.
if at.and_then(|i| filters.get(i)) != Some(&new) || out_of_step(&s.doc()?.doc, &new.name, &new.xml) {
let label = if at.is_some() { "Edit SVG Filter" } else { "New SVG Filter" };
s.edit(label, |d, _| {
update_users(d, &new.name, &new.xml);
match at.and_then(|i| d.svg_filters.get_mut(i)) {
Some(f) => *f = new,
None => d.svg_filters.push(new),
}
Ok(())
})?;
}
let label = if replace.is_some() { "Edit SVG Filter" } else { "New SVG Filter" };
s.edit(label, |d, _| {
update_users(d, &new.name, &new.xml);
match at.and_then(|i| d.svg_filters.get_mut(i)) {
Some(f) => *f = new,
None => d.svg_filters.push(new),
}
Ok(())
})?;
Ok(json!({ "name": name, "filters": filters_json(&s.doc()?.doc) }))
}
@@ -168,6 +184,9 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "svgFilter.delete";
let name = name_param(p, C)?;
if !s.doc()?.doc.svg_filters.iter().any(|f| f.name == name) {
if default_filter(&name).is_some() {
return Err(bad(C, format!("{name:?} is a default SVG filter, and the document holds no copy of it to delete")));
}
return Err(bad(C, format!("the document has no SVG filter named {name:?}")));
}
s.edit("Delete SVG Filter", |d, _| {
@@ -222,13 +241,79 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
fn apply(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "svgFilter.apply";
let name = name_param(p, C)?;
let f = s.doc()?.doc.svg_filters.iter().find(|f| f.name == name).cloned();
let f = f.ok_or_else(|| bad(C, format!("the document has no SVG filter named {name:?}")))?;
let mut q = json!({ "effect": effects::SVG_FILTER, "params": { "name": f.name, "xml": f.xml } });
let filters = &s.doc()?.doc.svg_filters;
// The document's filter of that name, or the default of that name, which the document then takes.
let (f, copied) = match (filters.iter().find(|f| f.name == name), default_filter(&name)) {
(Some(f), _) => (f.clone(), false),
(None, Some(_)) if filters.len() >= SvgFilter::MAX => {
return Err(bad(C, format!("a document holds at most {} SVG filters, and applying a default filter adds it", SvgFilter::MAX)));
}
(None, Some(d)) => (d.to_filter(), true),
(None, None) => return Err(bad(C, format!("the document has no SVG filter named {name:?}"))),
};
// A given `index`, null included, is checked as an index: only a missing one adds an effect.
let replaces = p.get("index").is_some();
let mut q = match replaces {
true => json!({ "index": p["index"], "params": { "name": f.name, "xml": f.xml } }),
false => json!({ "effect": effects::SVG_FILTER, "params": { "name": f.name, "xml": f.xml } }),
};
for key in ["ids", "id", "item"] {
if let Some(v) = p.get(key) {
q[key] = v.clone();
}
}
super::effectcmd::apply(s, &q)
if !copied {
return apply_as_given(s, p, &q, replaces, None);
}
// The effect and the copy are two edits joined into one step. The history keeps room for both
// until they are joined, so the application lets one old step go at most, as any edit does.
let limit = s.doc()?.history.limit;
if let Some(st) = s.active_mut() {
st.history.limit = limit.saturating_add(2);
}
let out = apply_as_given(s, p, &q, replaces, Some(f));
if let Some(st) = s.active_mut() {
st.history.limit = limit;
st.history.trim();
}
out
}
/// [`apply`] once the filter is known: the effect added (`q`) or put in the applied filter's place
/// (`replaces`), and `copy`, a default's copy, added to the document in the same undo step.
fn apply_as_given(s: &mut Session, p: &Value, q: &Value, replaces: bool, copy: Option<SvgFilter>) -> Result<Value> {
const C: &str = "svgFilter.apply";
let newest = s.doc()?.history.step().0;
let out = if replaces {
super::appearance::index_param(p, "index", C)?;
// Only an applied SVG filter gives its place: another effect would take the filter's
// parameters as its own.
let edited = super::effectcmd::set_params_effects(s, q);
if edited.is_empty() || edited.iter().any(|e| e != effects::SVG_FILTER) {
return Err(bad(C, "`index` must be the index of an applied SVG filter"));
}
super::effectcmd::set_params(s, q)?
} else {
super::effectcmd::apply(s, q)?
};
if let Some(f) = copy {
// The copy joins the step that applied it, under that step's name. That step is found by
// its document, not its place: in an undo group both edits join the group's one step.
let h = &s.doc()?.history;
let applied = h.undo.last().filter(|_| h.step().0 != newest).map(|step| (step.label.clone(), step.doc.clone()));
let label = applied.as_ref().map_or_else(|| "SVG Filter".to_string(), |(l, _)| l.clone());
s.edit(&label, |d, _| {
update_users(d, &f.name, &f.xml);
d.svg_filters.push(f);
Ok(())
})?;
let undo = &s.doc()?.history.undo;
let below = undo.len().checked_sub(2);
if let (Some(at), Some((_, doc))) = (below, &applied)
&& undo.get(at).is_some_and(|e| std::sync::Arc::ptr_eq(&e.doc, doc))
{
super::menucmds::squash(s, at, &label);
}
}
Ok(out)
}
File diff suppressed because one or more lines are too long
+1
View File
@@ -228,6 +228,7 @@ pub fn char_attrs(v: &CharStyle) -> Map<String, Value> {
("digits", json!(v.digits)),
("fontHeightReference", json!(v.font_height_reference)),
("language", json!(v.language)),
("glyphForm", json!(v.glyph_form)),
] {
m.insert(k.into(), x);
}
+81 -11
View File
@@ -8,7 +8,7 @@ use serde_json::{Value, json};
use vectorcraft_doc::{Document, NodeId, NodeKind, TextWrap};
use super::typecmd::refresh_bounds;
use super::{CommandSpec, EngineError, Result, Session, bool_or, checked_ids_or_selection, cmd, f64_or, has_selection};
use super::{CommandSpec, EngineError, Result, Session, bad, checked_ids_or_selection, cmd, has_selection, number_or, switch_or};
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -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; a negative offset (to -1000) lets the text that far into them → {ids}",
"{offset?: pt (6), invert?: bool, ids?} area type below the selected objects (same layer) wraps around them; offset -1000 to 1000 (a negative one lets the text that far into them); an offset outside that range or not a number, or an invert that is not true or false, is an error → {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 (offset -1000 to 1000; a negative one lets text into the 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); an offset outside that range or not a number, or an invert that is not true or false, is an error; no options (or null ones) → the current {offset, invert}",
has_selection,
options
),
@@ -37,14 +37,23 @@ pub fn specs() -> Vec<CommandSpec> {
/// The Offset a wrap object takes, in points.
pub const TEXT_WRAP_OFFSET_RANGE: std::ops::RangeInclusive<f64> = -1000.0..=1000.0;
fn wrap_param(p: &Value, base: TextWrap) -> TextWrap {
let (lo, hi) = (*TEXT_WRAP_OFFSET_RANGE.start(), *TEXT_WRAP_OFFSET_RANGE.end());
TextWrap { offset: f64_or(p, "offset", base.offset).clamp(lo, hi), invert: bool_or(p, "invert", base.invert) }
/// The options `p` gives, with `base` for an omitted or null `offset` or `invert`. An `offset` that is
/// not a number or lies outside [`TEXT_WRAP_OFFSET_RANGE`], or an `invert` that is not true or false,
/// is an error. `base`'s offset is kept as it is.
fn wrap_param(p: &Value, base: TextWrap, cmd: &str) -> Result<TextWrap> {
let key = "offset";
let given = p.get(key).is_some_and(|v| !v.is_null());
let offset = number_or(p, key, base.offset, cmd)?;
if given && !TEXT_WRAP_OFFSET_RANGE.contains(&offset) {
let (lo, hi) = (TEXT_WRAP_OFFSET_RANGE.start(), TEXT_WRAP_OFFSET_RANGE.end());
return Err(bad(cmd, format!("`{key}` must be from {lo} to {hi}")));
}
Ok(TextWrap { offset, invert: switch_or(p, "invert", base.invert, cmd)? })
}
fn make(s: &mut Session, p: &Value) -> Result<Value> {
let ids = checked_ids_or_selection(s, p, "object.textWrap.make")?;
let w = wrap_param(p, TextWrap::default());
let w = wrap_param(p, TextWrap::default(), "object.textWrap.make")?;
s.edit("Make Text Wrap", |d, _| {
for id in &ids {
let n = d.node_mut(*id).ok_or(EngineError::NoNode(*id))?;
@@ -86,10 +95,10 @@ fn options(s: &mut Session, p: &Value) -> Result<Value> {
let d = &s.doc()?.doc;
ids.iter().find_map(|i| d.node(*i).and_then(|n| n.extras().wrap)).unwrap_or_default()
};
if p.get("offset").is_none() && p.get("invert").is_none() {
if ["offset", "invert"].iter().all(|k| p.get(k).is_none_or(Value::is_null)) {
return Ok(json!({ "offset": current.offset, "invert": current.invert }));
}
let w = wrap_param(p, current);
let w = wrap_param(p, current, "object.textWrap.options")?;
s.edit("Text Wrap Options", |d, _| {
for id in &ids {
if let Some(n) = d.node_mut(*id)
@@ -212,11 +221,72 @@ mod tests {
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));
// An offset of -1000 lets the text through the whole object.
assert_eq!(s.execute("object.textWrap.options", &json!({"offset": -1000})).unwrap()["offset"], json!(-1000.0));
assert!(lines(&s, t).iter().all(|l| l.0 < 1.0 && l.1 > 300.0));
}
/// An offset that is not a number or lies outside -1000 to 1000 pt, or an invert that is not
/// true or false, is an error for Make and Options, and leaves the options and the history as
/// they were. Omitted and null values take the default, and the range's ends are accepted.
#[test]
fn text_wrap_commands_refuse_bad_offsets_and_inverts_without_editing() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap();
let id = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 80, "height": 80})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [id]})).unwrap();
let wrap = |s: &Session| s.doc().unwrap().doc.node(NodeId(id)).unwrap().extras().wrap;
let undo = |s: &Session| s.doc().unwrap().history.undo.len();
let refused = [
(json!({"offset": "12"}), "`offset` must be a number"),
(json!({"offset": true}), "`offset` must be a number"),
(json!({"offset": [12]}), "`offset` must be a number"),
(json!({"offset": {"pt": 12}}), "`offset` must be a number"),
(json!({"offset": 1000.5}), "`offset` must be from -1000 to 1000"),
(json!({"offset": -1000.5}), "`offset` must be from -1000 to 1000"),
(json!({"offset": 5000}), "`offset` must be from -1000 to 1000"),
(json!({"offset": 12, "invert": "true"}), "`invert` must be true or false"),
(json!({"offset": 12, "invert": 1}), "`invert` must be true or false"),
(json!({"invert": "false"}), "`invert` must be true or false"),
];
let start = undo(&s);
for (p, msg) in &refused {
let e = s.execute("object.textWrap.make", p).unwrap_err().to_string();
assert!(e.contains(msg), "make {p}: {e}");
assert!(wrap(&s).is_none(), "make {p} made a wrap");
assert_eq!(undo(&s), start, "make {p} added history");
}
// Omitted and null values: Make's 6 pt default.
s.execute("object.textWrap.make", &json!({"offset": null, "invert": null})).unwrap();
assert_eq!(wrap(&s), Some(vectorcraft_doc::TextWrap { offset: 6.0, invert: false }));
s.execute("object.textWrap.options", &json!({"offset": 40, "invert": true})).unwrap();
let before = undo(&s);
for (p, msg) in &refused {
let e = s.execute("object.textWrap.options", p).unwrap_err().to_string();
assert!(e.contains(msg), "options {p}: {e}");
assert_eq!(wrap(&s), Some(vectorcraft_doc::TextWrap { offset: 40.0, invert: true }), "options {p}");
assert_eq!(undo(&s), before, "options {p} added history");
}
// Null options read the current ones without an undo step; a null one keeps its value.
assert_eq!(s.execute("object.textWrap.options", &json!({"offset": null, "invert": null})).unwrap(), json!({"offset": 40.0, "invert": true}));
assert_eq!(undo(&s), before);
assert_eq!(
s.execute("object.textWrap.options", &json!({"offset": null, "invert": false})).unwrap(),
json!({"offset": 40.0, "invert": false})
);
// The range's ends are accepted by both commands.
for (command, offset) in
[("object.textWrap.options", 1000), ("object.textWrap.options", -1000), ("object.textWrap.make", 1000), ("object.textWrap.make", -1000)]
{
s.execute(command, &json!({ "offset": offset })).unwrap();
assert_eq!(wrap(&s).unwrap().offset, f64::from(offset), "{command} {offset}");
}
// A stored offset outside the range (from a file) is kept when only Invert Wrap changes.
let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
d.node_mut(NodeId(id)).unwrap().extras_mut().wrap = Some(vectorcraft_doc::TextWrap { offset: 5000.0, invert: false });
assert_eq!(s.execute("object.textWrap.options", &json!({"invert": true})).unwrap(), json!({"offset": 5000.0, "invert": true}));
}
#[test]
fn text_flows_on_both_sides_of_a_centred_object() {
let mut s = Session::new();
+21 -18
View File
@@ -616,6 +616,21 @@ fn scale_text_attributes(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "changed": changed }))
}
/// A path (or compound path) of `outline`, a piece of type `t`'s outline in text space, painted as
/// run `run` and placed in the document as the canvas paints it ([`paint_type_piece`]: unplaced
/// gradients fitted to `bounds`, the layout's bounds, stroke weights scaled by the type's
/// transform); `None` when the piece has no path.
fn type_piece(d: &mut Document, t: &TextObject, bounds: kurbo::Rect, run: usize, outline: &kurbo::BezPath) -> Option<Node> {
let path = PathData::from_bezpath(outline);
if path.is_empty() {
return None;
}
let st = t.runs.get(run).map(|r| r.style.clone()).unwrap_or_else(|| t.first_style());
let mut node = shape_node(d, path, None);
vectorcraft_render::effects::paint_type_piece(&mut node, &st, bounds, t.xf);
Some(node)
}
fn create_outlines(s: &mut Session, _: &Value) -> Result<Value> {
let roots = selected_roots(s)?;
let ids = s.edit("Create Outlines", |d, sel| {
@@ -660,14 +675,9 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result<Value> {
children.push(Arc::new(d.reid(&art)));
break 'glyph;
}
let path = PathData::from_bezpath(&g.outline).transformed(t.xf);
if path.is_empty() {
break 'glyph;
if let Some(node) = type_piece(d, t, lay.bounds, g.run, &g.outline) {
children.push(Arc::new(node));
}
let st = t.runs.get(g.run).map(|r| r.style.clone()).unwrap_or_else(|| t.first_style());
let mut node = shape_node(d, path, None);
node.appearance = st.appearance().into();
children.push(Arc::new(node));
}
run_end.insert(g.run, children.len());
}
@@ -676,13 +686,7 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result<Value> {
// nothing there either).
let mut ruby: Vec<(usize, Arc<Node>)> = vec![];
for g in &lay.ruby {
let path = PathData::from_bezpath(&g.outline).transformed(t.xf);
if path.is_empty() {
continue;
}
let st = t.runs.get(g.run).map(|r| r.style.clone()).unwrap_or_else(|| t.first_style());
let mut node = shape_node(d, path, None);
node.appearance = st.appearance().into();
let Some(node) = type_piece(d, t, lay.bounds, g.run, &g.outline) else { continue };
// After the glyphs of its run and of the runs before it.
let at = run_end.range(..=g.run).map(|(_, end)| *end).max().unwrap_or(0);
ruby.push((at, Arc::new(node)));
@@ -707,10 +711,9 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result<Value> {
// Underline and strikethrough bars become paths of their run's paint too (#847).
for (run, bar) in vectorcraft_text::decorations(&lay, vectorcraft_text::FontDb::global(), t) {
all.extend(bar.iter());
let st = t.runs.get(run).map(|r| r.style.clone()).unwrap_or_else(|| t.first_style());
let mut node = shape_node(d, PathData::from_bezpath(&bar).transformed(t.xf), None);
node.appearance = st.appearance().into();
children.push(Arc::new(node));
if let Some(node) = type_piece(d, t, lay.bounds, run, &bar) {
children.push(Arc::new(node));
}
}
// The object's own fills and strokes (not its characters') paint the whole outline:
// those below the Characters row under the characters, the others over them, with
@@ -1925,6 +1925,55 @@ fn standard_vertical_roman_is_a_character_attribute_saved_only_when_on() {
assert!(runs(&s).iter().all(|r| !r.style.standard_vertical_roman));
}
/// The East Asian glyph form (Glyphs panel menu) is one character attribute, `glyphForm`: one
/// form replaces another, "default" (Revert To Default Forms) takes it away, and it is saved
/// only when set. A call that changes nothing records no undo step, an unknown form is refused,
/// and character styles carry it.
#[test]
fn glyph_form_is_one_character_attribute_saved_only_when_set() {
use vectorcraft_doc::GlyphForm;
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": "亜唖芦"})).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 forms = |s: &Session| runs(s).iter().map(|r| (r.text.clone(), r.style.glyph_form)).collect::<Vec<_>>();
assert!(serde_json::to_value(&runs(&s)[0].style).unwrap().get("glyphForm").is_none());
s.execute("text.setRangeStyle", &json!({"id": id, "start": 3, "end": 6, "glyphForm": "jis78"})).unwrap();
assert_eq!(forms(&s), [("亜".into(), GlyphForm::Default), ("唖".into(), GlyphForm::Jis78), ("芦".into(), GlyphForm::Default)]);
assert_eq!(serde_json::to_value(&runs(&s)[1].style).unwrap()["glyphForm"], "jis78");
// Saved and reopened as it was.
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.text.clone(), r.style.glyph_form)).collect::<Vec<_>>(), forms(&s));
// Another form replaces it: one form at a time.
s.execute("select.set", &json!({"ids": [id]})).unwrap();
s.execute("text.setFormat", &json!({"glyphForm": "fullWidth"})).unwrap();
assert!(runs(&s).iter().all(|r| r.style.glyph_form == GlyphForm::FullWidth));
let n = s.doc().unwrap().history.undo.len();
s.execute("text.setFormat", &json!({"glyphForm": "fullWidth"})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), n, "nothing changed, no undo step");
for bad in [json!("jis2004"), json!(78), json!(true), json!("Traditional")] {
let e = s.execute("text.setFormat", &json!({"glyphForm": bad})).unwrap_err().to_string();
assert!(e.contains("`glyphForm` must be one of \"traditional\""), "{bad}: {e}");
assert!(s.execute("text.setRangeStyle", &json!({"id": id, "glyphForm": bad})).is_err(), "{bad}");
}
assert!(runs(&s).iter().all(|r| r.style.glyph_form == GlyphForm::FullWidth), "a refused call changes nothing");
assert_eq!(super::super::textstyles::char_attrs(&runs(&s)[0].style)["glyphForm"], "fullWidth");
s.execute("text.setFormat", &json!({"glyphForm": "default"})).unwrap();
assert!(runs(&s).iter().all(|r| r.style.glyph_form == GlyphForm::Default));
assert!(serde_json::to_value(&runs(&s)[0].style).unwrap().get("glyphForm").is_none());
// A character style carries the form.
s.execute("charStyle.new", &json!({"name": "Old forms", "attrs": {"glyphForm": "jis83"}})).unwrap();
s.execute("charStyle.apply", &json!({"name": "Old forms", "id": id})).unwrap();
assert!(runs(&s).iter().all(|r| r.style.glyph_form == GlyphForm::Jis83));
assert!(s.execute("charStyle.new", &json!({"name": "Bad", "attrs": {"glyphForm": "jis2004"}})).is_err());
s.execute("edit.undo", &json!({})).unwrap();
assert!(runs(&s).iter().all(|r| r.style.glyph_form == GlyphForm::Default));
}
/// No Break is a character attribute, saved only when on.
#[test]
fn no_break_is_a_character_attribute_saved_only_when_on() {
+314 -18
View File
@@ -30,7 +30,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Smart Punctuation…",
["Type"],
None,
"{quotes?: true (straight quotes become the Document Setup quotes), dashes?: true (-- → en dash, --- → em dash), ellipsis?: true (... → …), scope?: \"selection\"|\"document\"} → {changed}",
"{fiLigatures?: false (ff, fi, ffi → the ligatures U+FB00, U+FB01, U+FB03), flLigatures?: false (ff, fl, ffl → U+FB00, U+FB02, U+FB04), quotes?: true (straight quotes become the Document Setup quotes), spaces?: false (two or more spaces after a period → one), dashes?: true (-- → en dash, --- → em dash), ellipsis?: true (... → …), fractions?: false (1/2, 1/4, 3/4 and the other fractions Unicode encodes as one character, with no digit, slash or decimal point next to them, → ½, ¼, ¾ …), scope?: \"selection\"|\"document\"} the selected type, or every text object with scope document or nothing selected; a ligature or a fraction is written only where the run's font has a glyph for it. Nothing to replace records no undo step → {changed: text objects changed, replaced: {fiLigatures, flLigatures, quotes, spaces, dashes, ellipses, fractions}: how many of each were replaced}",
has_doc,
smart_punctuation
),
@@ -268,24 +268,227 @@ fn change_case(s: &mut Session, p: &Value) -> Result<Value> {
// ---------- Smart Punctuation ----------
#[derive(Default)]
struct PunctState {
prev: Option<char>,
/// What Smart Punctuation replaces: each option of its dialog.
struct Punct {
/// ff, fi and ffi become the ligatures U+FB00, U+FB01 and U+FB03.
fi_ligatures: bool,
/// ff, fl and ffl become the ligatures U+FB00, U+FB02 and U+FB04.
fl_ligatures: bool,
/// Straight quotes become these quotes (the Document Setup quotes).
quotes: Option<Quotes>,
/// Two or more spaces after a period become one.
spaces: bool,
/// `--` becomes an en dash and `---` an em dash.
dashes: bool,
/// `...` becomes an ellipsis.
ellipses: bool,
/// `1/2` and the other fractions [`FRACTIONS`] lists become their one-character forms.
fractions: bool,
}
/// Dashes (`--` en, `---` em), ellipses and, with `quotes`, typographer's quotes.
pub(crate) fn smarten(text: &str, prev: &mut Option<char>, quotes: Option<&Quotes>, dashes: bool, ellipsis: bool) -> String {
let mut s = text.to_string();
if dashes {
s = s.replace("---", "—").replace("--", "–");
/// How many of each replacement Smart Punctuation made.
#[derive(Default)]
struct PunctCounts {
fi_ligatures: usize,
fl_ligatures: usize,
quotes: usize,
spaces: usize,
dashes: usize,
ellipses: usize,
fractions: usize,
}
/// The text before the run being rewritten, across the runs of one text object.
#[derive(Default)]
struct PunctState {
/// The character before the run.
prev: Option<char>,
/// The number of spaces since the last period, while only spaces follow it.
after_period: Option<usize>,
/// An inline graphic comes right before the run.
after_graphic: bool,
}
/// The fractions Expert Fractions writes as one character: every fraction Unicode encodes as a
/// vulgar fraction with a numerator of 1 to 7 and a denominator of 2 to 10.
const FRACTIONS: [(&str, char); 18] = [
("1/2", '½'),
("1/3", '⅓'),
("2/3", '⅔'),
("1/4", '¼'),
("3/4", '¾'),
("1/5", '⅕'),
("2/5", '⅖'),
("3/5", '⅗'),
("4/5", '⅘'),
("1/6", '⅙'),
("5/6", '⅚'),
("1/7", '⅐'),
("1/8", '⅛'),
("3/8", '⅜'),
("5/8", '⅝'),
("7/8", '⅞'),
("1/9", '⅑'),
("1/10", '⅒'),
];
/// `s` with every `from` replaced by `to`, adding the number replaced to `n`.
fn replace_counted(s: String, from: &str, to: &str, n: &mut usize) -> String {
let found = s.matches(from).count();
if found == 0 {
return s;
}
if ellipsis {
s = s.replace("...", "…");
*n += found;
s.replace(from, to)
}
/// Smart Spaces: `text` without the second and later spaces after a period, adding the number of
/// periods whose spaces it shortened to `n`. `after` carries the spaces after a period from one run
/// to the next.
fn smart_spaces(text: &str, after: &mut Option<usize>, n: &mut usize) -> String {
let mut out = String::with_capacity(text.len());
for c in text.chars() {
match (c, *after) {
(' ', Some(k)) => {
*after = Some(k + 1);
if k >= 1 {
if k == 1 {
*n += 1;
}
continue;
}
}
('.', _) => *after = Some(0),
_ => *after = None,
}
out.push(c);
}
match quotes {
Some(q) => q.apply(&s, prev),
out
}
/// The fraction `cs` starts with, and its length in characters: one of [`FRACTIONS`] that no digit,
/// slash or decimal part follows, so `1/25`, `1/2/2026` and `1/2.5` are left as they are. A digit
/// counts in any script.
fn fraction_at(cs: &[char]) -> Option<(usize, char)> {
FRACTIONS.iter().find_map(|(text, f)| {
let len = text.chars().count();
if cs.len() < len || !cs[..len].iter().copied().eq(text.chars()) {
return None;
}
let ends = match &cs[len..] {
[c, ..] if c.is_numeric() || *c == '/' => false,
['.' | ',', d, ..] if d.is_numeric() => false,
_ => true,
};
ends.then_some((len, *f))
})
}
/// Expert Fractions: `text` with each fraction [`fraction_at`] finds, and no digit, slash or
/// decimal point before, written as one character when `covers` it. `prev` is the character before
/// `text`, and `next` the two characters after it (in the next runs), so a fraction at the end of
/// a run is checked against what follows it.
fn expert_fractions(text: &str, prev: Option<char>, next: [Option<char>; 2], covers: &mut impl FnMut(char) -> bool, n: &mut usize) -> String {
let mut cs: Vec<char> = text.chars().collect();
let len_text = cs.len();
cs.extend(next.into_iter().flatten());
let mut out = String::with_capacity(text.len());
let mut i = 0;
while i < len_text {
let before = if i == 0 { prev } else { Some(cs[i - 1]) };
let free = !before.is_some_and(|c| c.is_numeric() || matches!(c, '/' | '.' | ','));
let found = fraction_at(&cs[i..]).filter(|(len, f)| i + len <= len_text && covers(*f));
if free && let Some((len, f)) = found {
out.push(f);
*n += 1;
i += len;
continue;
}
out.push(cs[i]);
i += 1;
}
out
}
/// The ligatures and their letters, longest first, with whether each comes with the ff, fi, ffi
/// option (else with the ff, fl, ffl option). ff comes with both.
const LIGATURES: [(&str, char, bool); 5] =
[("ffi", '\u{FB03}', true), ("ffl", '\u{FB04}', false), ("ff", '\u{FB00}', true), ("fi", '\u{FB01}', true), ("fl", '\u{FB02}', false)];
/// The ligature options: `text` with the letters of each ligature an option `o` asks for written
/// as the ligature, the longest first, when `covers` it.
fn ligatures(text: &str, o: &Punct, covers: &mut impl FnMut(char) -> bool, n: &mut PunctCounts) -> String {
let cs: Vec<char> = text.chars().collect();
let mut out = String::with_capacity(text.len());
let mut i = 0;
while i < cs.len() {
let found = (cs[i] == 'f').then(|| {
LIGATURES.iter().find(|(letters, lig, fi)| {
let on = if *letters == "ff" {
o.fi_ligatures || o.fl_ligatures
} else if *fi {
o.fi_ligatures
} else {
o.fl_ligatures
};
let len = letters.chars().count();
on && cs.len() - i >= len && cs[i..i + len].iter().copied().eq(letters.chars()) && covers(*lig)
})
});
if let Some(&(letters, lig, fi)) = found.flatten() {
out.push(lig);
// ff counts with the ff, fi, ffi option when that option is on.
if fi && (letters != "ff" || o.fi_ligatures) {
n.fi_ligatures += 1;
} else {
n.fl_ligatures += 1;
}
i += letters.chars().count();
continue;
}
out.push(cs[i]);
i += 1;
}
out
}
/// One run's text with the replacements `o` asks for, counted in `n`. Ligatures and fractions are
/// written only where the run's font has a glyph for them. `next` holds the two characters after
/// the run.
fn smarten(text: &str, style: &CharStyle, next: [Option<char>; 2], o: &Punct, st: &mut PunctState, n: &mut PunctCounts) -> String {
let mut s = if o.spaces { smart_spaces(text, &mut st.after_period, &mut n.spaces) } else { text.to_string() };
if o.dashes {
s = replace_counted(s, "---", "—", &mut n.dashes);
s = replace_counted(s, "--", "–", &mut n.dashes);
}
if o.ellipses {
s = replace_counted(s, "...", "…", &mut n.ellipses);
}
// The run's font, looked up once and only for a ligature or a fraction.
let mut face = None;
let mut covers = |c: char| {
face.get_or_insert_with(|| {
vectorcraft_text::FontDb::global().face_version(&style.font_family, &style.font_style, style.font_version.as_deref())
})
.as_ref()
.is_some_and(|f| f.covers(c))
};
if o.fractions && s.contains('/') {
let before = if st.after_graphic { None } else { st.prev };
s = expert_fractions(&s, before, next, &mut covers, &mut n.fractions);
}
st.after_graphic = false;
if (o.fi_ligatures || o.fl_ligatures) && s.contains('f') {
s = ligatures(&s, o, &mut covers, n);
}
match &o.quotes {
Some(q) => {
let out = q.apply(&s, &mut st.prev);
n.quotes += s.chars().zip(out.chars()).filter(|(a, b)| a != b).count();
out
}
None => {
*prev = s.chars().last().or(*prev);
st.prev = s.chars().last().or(st.prev);
s
}
}
@@ -293,12 +496,20 @@ pub(crate) fn smarten(text: &str, prev: &mut Option<char>, quotes: Option<&Quote
fn smart_punctuation(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "type.smartPunctuation";
let (d, e) = (switch_or(p, "dashes", true, C)?, switch_or(p, "ellipsis", true, C)?);
let quotes = switch_or(p, "quotes", true, C)?;
let mut o = Punct {
fi_ligatures: switch_or(p, "fiLigatures", false, C)?,
fl_ligatures: switch_or(p, "flLigatures", false, C)?,
quotes: None,
spaces: switch_or(p, "spaces", false, C)?,
dashes: switch_or(p, "dashes", true, C)?,
ellipses: switch_or(p, "ellipsis", true, C)?,
fractions: switch_or(p, "fractions", false, C)?,
};
let document = choice(p, C, "scope", &["selection", "document"])? == Some("document");
let st = s.doc()?;
// Quotes in the document's style (Document Setup → Type).
let q = quotes.then_some(st.doc.setup.quotes);
o.quotes = quotes.then_some(st.doc.setup.quotes);
let ids = if document || st.selection.is_empty() {
let mut v = vec![];
st.doc.walk(|n| {
@@ -310,8 +521,93 @@ fn smart_punctuation(s: &mut Session, p: &Value) -> Result<Value> {
} else {
text_ids(&st.doc, &st.selection.objects)
};
let n = edit_runs::<PunctState>(s, "Smart Punctuation", &ids, |text, st| smarten(text, &mut st.prev, q.as_ref(), d, e))?;
Ok(json!({ "changed": n }))
let mut n = PunctCounts::default();
let changed = super::overprint::edit_counted(s, "Smart Punctuation", |d| {
let mut changed = 0;
for id in &ids {
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
let mut st = PunctState::default();
let mut any = false;
// The ruby spans' ends, in the plain text as it was. Each run is rewritten in pieces
// between them, so no replacement crosses one, and `moved` records where each end and
// each run's start lands in the new text.
let mut edges: Vec<usize> = t.ruby.iter().flat_map(|r| [r.start, r.end]).collect();
edges.sort_unstable();
edges.dedup();
let mut moved = vec![];
let (mut old_at, mut new_at) = (0, 0);
// Inline graphics are art, not characters to rewrite. Each run sees the first two
// characters of the runs after it, up to the next inline graphic, as they were.
for i in 0..t.runs.len() {
let len = t.runs[i].text.len();
moved.push((old_at, new_at));
if t.runs[i].inline.is_some() {
// A graphic separates the characters on either side of it for Smart Spaces and
// Expert Fractions; quotes still read the character before it.
st.after_period = None;
st.after_graphic = true;
(old_at, new_at) = (old_at + len, new_at + len);
continue;
}
let mut after = t.runs[i + 1..].iter().take_while(|r| r.inline.is_none()).flat_map(|r| r.text.chars());
let following = [after.next(), after.next()];
// Smart Spaces counts no space across a ruby span's end, so it removes no space on
// the other side of one.
if edges.binary_search(&old_at).is_ok() {
st.after_period = None;
}
let r = &mut t.runs[i];
let mut cuts: Vec<usize> =
edges.iter().filter(|&&e| e > old_at && e < old_at + len && r.text.is_char_boundary(e - old_at)).map(|e| e - old_at).collect();
cuts.push(len);
let mut new = String::with_capacity(len);
let mut from = 0;
for cut in cuts {
if from > 0 {
moved.push((old_at + from, new_at + new.len()));
st.after_period = None;
}
let mut after = r.text[cut..].chars().chain(following.into_iter().flatten());
let next = [after.next(), after.next()];
new.push_str(&smarten(&r.text[from..cut], &r.style, next, &o, &mut st, &mut n));
from = cut;
}
(old_at, new_at) = (old_at + len, new_at + new.len());
if new != r.text {
r.text = new;
any = true;
}
}
moved.push((old_at, new_at));
if any {
changed += 1;
// Each ruby end moves with the text before it; an end inside a character keeps its
// distance from the last place recorded before it.
let to_new = |v: usize| {
let k = moved.partition_point(|&(old, _)| old <= v).saturating_sub(1);
moved.get(k).map_or(v, |&(old, new)| new + (v - old.min(v)))
};
for span in &mut t.ruby {
(span.start, span.end) = (to_new(span.start), to_new(span.end));
}
t.normalize_ruby();
refresh_bounds(t);
}
}
Ok(changed)
})?;
Ok(json!({
"changed": changed,
"replaced": {
"fiLigatures": n.fi_ligatures,
"flLigatures": n.fl_ligatures,
"quotes": n.quotes,
"spaces": n.spaces,
"dashes": n.dashes,
"ellipses": n.ellipses,
"fractions": n.fractions,
},
}))
}
// ---------- Area / Point conversion ----------
+4 -4
View File
@@ -222,11 +222,11 @@ pub fn written_elsewhere(path: &str) -> bool {
!cfg!(windows) && foreign && current().is_some()
}
/// Does a command param named `key` hold a file or folder path: `path`, `paths`, `file`, `files`,
/// `folder`, or a name ending in `Path`, `Paths`, `File`, `Files` or `Folder` (`diffPath`,
/// `destinationFolder`)?
/// Does a command param named `key` hold a file or folder path in a command that gives it no other
/// meaning ([`crate::cmd::key_location`]: `path`, `file`, `diffPath`, `destinationFolder` and the
/// like)? [`crate::cmd::ParamsPlace`] tells what it holds in a given command.
pub fn is_path_key(key: &str) -> bool {
matches!(key, "path" | "paths" | "file" | "files" | "folder") || ["Path", "Paths", "File", "Files", "Folder"].iter().any(|end| key.ends_with(end))
crate::cmd::key_location(key).is_some()
}
/// May `path` be written here? Always, without roots in force.
+3
View File
@@ -24,6 +24,9 @@ pub struct Interaction {
/// The perspective transform the previews make (`perspective.transform` params): Transform
/// Again repeats it once the drag is committed.
pub perspective_again: Option<Value>,
/// A preview drew an object on, or attached one to, a plane held by Automatic Plane
/// Positioning: the plane goes back to its place once the drag is committed.
pub(crate) plane_placed: bool,
/// A transform or a perspective edit with `copy` keeps the copies it made of the snapshot here
/// ([`PreviewCopies`]).
pub(crate) copies: Option<Arc<PreviewCopies>>,
+5
View File
@@ -24,6 +24,8 @@ impl Session {
});
// Clicks find an image's own fills and strokes where their effects draw them.
vectorcraft_doc::hit::set_image_paint(vectorcraft_render::effects::image_paint);
// Clicks inside a clip group fall in the region its clipping path's effects give it.
vectorcraft_doc::hit::set_clip_region(vectorcraft_render::effects::reshaped_clip_outline);
// Clicks find a blend's steps and an envelope's distorted art where the canvas draws them,
// evaluated again when the fonts, the hyphenation exceptions or the plug-ins change.
vectorcraft_doc::hit::set_live_art(vectorcraft_render::effects::expand_live, || {
@@ -329,6 +331,8 @@ impl Session {
// Ruler guides a reader left in the saved list become nodes on their layers.
doc.guides_into_nodes();
cmd::dimension::conform(&mut doc);
// A plane held by Automatic Plane Positioning when the file was saved is back in its place.
vectorcraft_tools::distort::perspective::PerspectiveGrid::end_positioning(&mut doc);
self.reset_tool_for_doc_switch();
let mut st = DocState::new(doc, path);
st.history.limit = self.prefs.history_states as usize;
@@ -357,6 +361,7 @@ impl Session {
doc.resolve_inline_art();
doc.guides_into_nodes();
cmd::dimension::conform(&mut doc);
vectorcraft_tools::distort::perspective::PerspectiveGrid::end_positioning(&mut doc);
// In the back, type set in a font named like a composite font is measured again below, in
// the document's own composite fonts, not in the front document's.
let back = self.active != Some(index);
+9
View File
@@ -289,6 +289,7 @@ impl Session {
variables_highlight: st.variables_highlight.clone(),
isolation: st.isolation,
perspective_again: None,
plane_placed: false,
copies: None,
move_box: std::sync::OnceLock::new(),
});
@@ -319,6 +320,7 @@ impl Session {
it.preview_paint = None;
it.color_mode_paints = None;
it.deferred_color_mode = None;
it.plane_placed = false;
if selection_changed {
st.selection.clone_from(&it.selection);
}
@@ -378,6 +380,7 @@ impl Session {
after.clone_from(&paint);
}
let perspective_again = it.perspective_again.take();
let plane_placed = it.plane_placed;
let copy = preview.0 == "object.transform" && preview.1.get("copy").and_then(Value::as_bool).unwrap_or(false);
// Alt pressed or released during a move drag turns it into a copy or back: name it for
// what it did in the end.
@@ -395,6 +398,12 @@ impl Session {
preview.1.get("dy").and_then(Value::as_f64).unwrap_or(0.0),
);
st.keep_interaction(it);
// The drag drew an object on, or attached one to, the plane Automatic Plane Positioning
// held: the plane is back in its place now, as view state (undoing the drag holds it again).
if plane_placed && changed && vectorcraft_tools::distort::perspective::PerspectiveGrid::positioning(&st.doc) {
vectorcraft_tools::distort::perspective::PerspectiveGrid::end_positioning(Arc::make_mut(&mut st.doc));
st.revision += 1;
}
if preview.0 == "object.transform" && changed {
let m = cmd::matrix_param(&preview.1, "matrix");
if let Some(m) = m {
+1
View File
@@ -128,6 +128,7 @@ mod pdffidelity;
mod pdfmarks;
mod pdfpresets;
mod pdfraster;
mod persp_infer;
mod persp_planes;
pub(super) mod persp_select;
mod persp_text;
+139
View File
@@ -1490,6 +1490,93 @@ fn shape_builder_open_filled_paths_cut_without_open_filled_as_closed() {
assert!((area(&s, NodeId(r["merged"].as_u64().unwrap())) - 5000.0).abs() < 1e-6);
}
/// The fill rows of an open path's appearance (bottom first) for the open-path tests below, with
/// a 1 pt black stroke on top: a painted fill under an unpainted top fill, a painted fill that is
/// hidden, one with an opacity of 0, and a painted fill under a hidden painted top fill.
fn open_path_fill_stacks() -> [(&'static str, Vec<AppearanceItem>, bool); 4] {
use vectorcraft_doc::{FillLayer, StrokeLayer};
let fill = |p: Paint, visible: bool, opacity: f32| {
let mut f = FillLayer::new(p);
f.visible = visible;
f.opacity = opacity;
AppearanceItem::Fill(f)
};
let red = || Paint::solid(Color::rgb(1.0, 0.0, 0.0));
let blue = || Paint::solid(Color::rgb(0.0, 0.0, 1.0));
let stroke = || AppearanceItem::Stroke(StrokeLayer::new(Paint::solid(Color::BLACK), 1.0));
[
("a painted fill under an unpainted top fill", vec![fill(red(), true, 1.0), fill(Paint::None, true, 1.0), stroke()], true),
("a hidden painted fill", vec![fill(red(), false, 1.0), stroke()], false),
("a painted fill with an opacity of 0", vec![fill(red(), true, 0.0), stroke()], false),
("a painted fill under a hidden painted top fill", vec![fill(red(), true, 1.0), fill(blue(), false, 1.0), stroke()], true),
]
}
/// Consider Open Filled Paths as Closed: an open path counts as filled when any of its fill rows
/// draws (shown, painted and above 0% opacity), whichever row is on top. The map, a merge inside
/// the area and what a merge keeps of the path follow the rows that draw; with the option off, none
/// of the paths closes.
#[test]
fn shape_builder_closes_open_paths_by_the_fill_rows_that_draw() {
for (what, items, closes) in open_path_fill_stacks() {
let mut s = session();
let r = s.execute("path.create", &json!({"anchors": [{"x": 0, "y": 0}, {"x": 100, "y": 0}, {"x": 100, "y": 100}]})).unwrap();
let p = NodeId(r["id"].as_u64().unwrap());
s.edit("t", |d, _| {
d.node_mut(p).unwrap().appearance.items = items;
Ok(())
})
.unwrap();
s.execute("select.set", &json!({"ids": [p.0]})).unwrap();
let r = s.execute("shapeBuilder.regions", &json!({})).unwrap();
assert_eq!(
(r["regions"].as_array().unwrap().len(), r["lines"].as_array().unwrap().len()),
if closes { (1, 0) } else { (0, 1) },
"{what}: {r}"
);
let r = s.execute("shapeBuilder.regions", &json!({"openFilledAsClosed": false})).unwrap();
assert_eq!((r["regions"].as_array().unwrap().len(), r["lines"].as_array().unwrap().len()), (0, 1), "{what}, option off: {r}");
let n = node(&s, p);
for on in [true, false] {
let inputs = vectorcraft_tools::builder::merge_inputs(&[&n], on);
assert_eq!(inputs.iter().map(|i| i.fill_closes).collect::<Vec<_>>(), vec![closes && on], "{what}, option {on}");
}
let merged = s.execute("shapeBuilder.merge", &json!({"points": [[75, 25]]}));
if closes {
assert!((area(&s, NodeId(merged.unwrap()["merged"].as_u64().unwrap())) - 5000.0).abs() < 1e-6, "{what}");
} else {
assert!(merged.is_err(), "{what}: the open path encloses no region");
}
}
}
/// Clicking Stroke Splits the Path on the middle side of an open U cuts it along the outline its
/// drawn fill closes: with a fill row that draws, what is left runs on from the cut round the
/// implied closing edge (the top side, then the bottom side); without one, what is left is the
/// open path's two ends in path order (the bottom side, then the top side).
#[test]
fn shape_builder_stroke_split_closes_open_paths_by_the_fill_rows_that_draw() {
for (what, items, closes) in open_path_fill_stacks() {
let mut s = session();
let r =
s.execute("path.create", &json!({"anchors": [{"x": 0, "y": 0}, {"x": 100, "y": 0}, {"x": 100, "y": 100}, {"x": 0, "y": 100}]})).unwrap();
let p = NodeId(r["id"].as_u64().unwrap());
s.edit("t", |d, _| {
d.node_mut(p).unwrap().appearance.items = items;
Ok(())
})
.unwrap();
s.execute("select.set", &json!({"ids": [p.0]})).unwrap();
let r = s.execute("shapeBuilder.merge", &json!({"points": [[101, 50]], "splitStroke": true})).unwrap();
let out = ids(&r);
assert_eq!(shapes_of(&s, &out), vec![(false, 4, 200.0), (false, 2, 100.0)], "{what}");
let (rest, _) = outline(&node(&s, out[0]));
let starts: Vec<(f64, f64)> = rest.subpaths.iter().map(|sp| (sp.anchors[0].p.x.round(), sp.anchors[0].p.y.round())).collect();
let order = if closes { vec![(100.0, 100.0), (0.0, 0.0)] } else { vec![(0.0, 0.0), (100.0, 100.0)] };
assert_eq!(starts, order, "{what}");
}
}
/// Pick Color From Color Swatches: the merged shape takes the current fill, not the fill of the
/// object under the first point; setting the current fill without `ids` leaves the selection's.
#[test]
@@ -4607,6 +4694,58 @@ fn shape_builder_uses_the_filled_faces_of_a_live_paint_group() {
assert!((stroked_length(&s, &out) - 600.0).abs() < 1.0, "{}", stroked_length(&s, &out));
}
/// The Shape Builder takes a Live Paint face or edge when any of its fill or stroke rows draws,
/// whichever row is on top: a painted lower row under an unpainted top row counts, and a part
/// whose only painted row is hidden, transparent or (for an edge) 0 pt wide, or whose own opacity
/// is 0, does not.
#[test]
fn shape_builder_takes_the_live_paint_faces_and_edges_that_draw() {
use vectorcraft_doc::{FillLayer, StrokeLayer};
let fill = |p: Paint| AppearanceItem::Fill(FillLayer::new(p));
let stroke = |p: Paint, w: f64| AppearanceItem::Stroke(StrokeLayer::new(p, w));
let red = || Paint::solid(Color::rgb(1.0, 0.0, 0.0));
let hidden = |mut i: AppearanceItem| {
match &mut i {
AppearanceItem::Fill(f) => f.visible = false,
AppearanceItem::Stroke(st) => st.visible = false,
}
i
};
let clear = |mut i: AppearanceItem| {
match &mut i {
AppearanceItem::Fill(f) => f.opacity = 0.0,
AppearanceItem::Stroke(st) => st.opacity = 0.0,
}
i
};
let mut s = session();
let g = live(&mut s);
let lower_fill = set_live_part(&mut s, g, Some((25.0, 50.0)), None, vec![fill(red()), fill(Paint::None)], 1.0);
let hidden_fill = set_live_part(&mut s, g, Some((125.0, 50.0)), None, vec![hidden(fill(red()))], 1.0);
let clear_fill = set_live_part(&mut s, g, Some((75.0, 50.0)), None, vec![clear(fill(red()))], 1.0);
let lower_stroke = set_live_part(&mut s, g, None, Some((0.0, 50.0)), vec![stroke(red(), 2.0), stroke(Paint::None, 1.0)], 1.0);
let hidden_stroke = set_live_part(&mut s, g, None, Some((150.0, 50.0)), vec![hidden(stroke(red(), 2.0))], 1.0);
let thin_stroke = set_live_part(&mut s, g, None, Some((100.0, 50.0)), vec![stroke(red(), 0.0)], 1.0);
let clear_stroke = set_live_part(&mut s, g, None, Some((50.0, 50.0)), vec![clear(stroke(red(), 2.0))], 1.0);
let clear_edge = set_live_part(&mut s, g, None, Some((75.0, 0.0)), vec![stroke(red(), 2.0)], 0.0);
let parts = [lower_fill, hidden_fill, clear_fill, lower_stroke, hidden_stroke, thin_stroke, clear_stroke, clear_edge];
assert_eq!(parts.iter().collect::<std::collections::HashSet<_>>().len(), parts.len());
let d = s.doc().unwrap();
let (_, leaves) = vectorcraft_tools::builder::shape_builder_shapes(&d.doc, &[g], true);
let taken: Vec<NodeId> = leaves.iter().map(|l| l.id).collect();
assert!(taken.contains(&lower_fill) && taken.contains(&lower_stroke), "a painted lower row draws: {taken:?}");
for id in [hidden_fill, clear_fill, hidden_stroke, thin_stroke, clear_stroke, clear_edge] {
assert!(!taken.contains(&id), "{id:?} draws nothing: {taken:?}");
}
// A face with a painted fill whose own opacity is 0 is left out too.
let mut s = session();
let g = live(&mut s);
let clear_face = set_live_part(&mut s, g, Some((75.0, 50.0)), None, vec![fill(red())], 0.0);
let d = s.doc().unwrap();
let (_, leaves) = vectorcraft_tools::builder::shape_builder_shapes(&d.doc, &[g], true);
assert!(!leaves.iter().any(|l| l.id == clear_face), "a face with an opacity of 0 draws nothing");
}
/// An edge that closes on itself is a line around its face, not a second shape over it.
#[test]
fn shape_builder_takes_a_closed_live_paint_edge_as_a_line() {
+68
View File
@@ -119,12 +119,80 @@ fn text_on_top_clips_by_its_glyphs() {
assert_eq!(hit(&s, (b.x0 + b.x1) / 2.0, (b.y0 + b.y1) / 2.0), Some(art));
assert!(b.x1 < 95.0, "{b:?}");
assert_eq!(hit(&s, b.x1 + 3.0, (b.y0 + b.y1) / 2.0), None, "beside the text");
// An effect that does not reshape it, such as a drop shadow, leaves clicks on its frame.
s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "params": {}, "ids": [t.0], "item": null})).unwrap();
assert_eq!(hit(&s, (b.x0 + b.x1) / 2.0, (b.y0 + b.y1) / 2.0), Some(art), "in the counter, with a drop shadow");
// Release gives the text back.
select(&mut s, &[g]);
s.execute("object.clippingMask.release", &json!({})).unwrap();
assert!(matches!(s.doc().unwrap().doc.node(t).unwrap().kind, NodeKind::Text(_)));
}
#[test]
fn a_hidden_member_of_a_clipping_group_leaves_clicks_on_the_type_frame() {
// The clipping path is a group of an "O" and a hidden rectangle with a Transform effect. The
// hidden rectangle does not clip, so clicks inside the clip group still count the type by its
// frame, the counter of the "O" included.
let mut s = session();
let art = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap());
let t = id_of(&s.execute("text.create", &json!({"x": 5, "y": 80, "text": "O", "size": 90})).unwrap());
let b = s.doc().unwrap().doc.node(t).unwrap().geometric_bounds().unwrap();
let r = id_of(&s.execute("shape.rectangle", &json!({"x": 40, "y": 40, "width": 10, "height": 10})).unwrap());
s.execute("effect.apply", &json!({"effect": "distort.transform", "params": {"moveH": 20}, "ids": [r.0], "item": null})).unwrap();
select(&mut s, &[art, t]);
let g = id_of(&s.execute("object.clippingMask.make", &json!({})).unwrap());
// Make Clipping Mask takes no group on top, so the group goes in place of the type.
let mut d = (*s.doc().unwrap().doc).clone();
let mut rect = (*d.remove(r).unwrap()).clone();
rect.visible = false;
let text = d.remove(t).unwrap();
let group = vectorcraft_doc::Node::group(d.alloc_id(), vec![text, Arc::new(rect)]);
d.insert(Some(g), 0, group).unwrap();
let leaf = |x: f64, y: f64| vectorcraft_doc::hit::hit_test(&d, vectorcraft_geom::Point::new(x, y), Default::default()).map(|h| h.leaf);
let middle = ((b.x0 + b.x1) / 2.0, (b.y0 + b.y1) / 2.0);
assert_eq!(leaf(middle.0, middle.1), Some(art), "in the counter, inside the frame");
assert_eq!(leaf(b.x1 + 3.0, middle.1), None, "beside the text");
}
#[test]
fn clicks_follow_the_region_a_clipping_paths_effects_give_it() {
// Art from 10 to 90 pt, clipped by the artboard's left half, which a Transform effect moves
// 50 pt right: the art shows on the right half, as clicks find it.
let mut s = session();
let art = id_of(&s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 80, "height": 80})).unwrap());
let clip = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 100})).unwrap());
select(&mut s, &[art, clip]);
let g = id_of(&s.execute("object.clippingMask.make", &json!({})).unwrap());
s.execute("effect.apply", &json!({"effect": "distort.transform", "params": {"moveH": 50}, "ids": [clip.0], "item": null})).unwrap();
let r = render(&s);
assert!(opaque(&r, 75, 50) && !opaque(&r, 25, 50), "the canvas clips by the moved region");
assert_eq!(hit(&s, 75.0, 50.0), Some(art), "where the effect moved the clipping path");
assert_eq!(hit(&s, 25.0, 50.0), None, "the clipping path's own place");
let d = &s.doc().unwrap().doc;
let at = |x: f64| vectorcraft_doc::hit::objects_at(d, vectorcraft_geom::Point::new(x, 50.0), Default::default(), None);
assert_eq!(at(75.0), [g]);
assert!(at(25.0).is_empty());
// A marquee across the art's edge reaches it within the moved region only.
let marquee = |x0: f64, x1: f64| vectorcraft_doc::hit::marquee(d, vectorcraft_geom::Rect::new(x0, 40.0, x1, 60.0), None, true);
assert_eq!(marquee(85.0, 95.0), [art], "the right edge, in the moved region");
assert!(marquee(5.0, 15.0).is_empty(), "the left edge, in the clipping path's own place");
// The same with a sublayer that clips by its first object.
let mut d = (**d).clone();
let members: Vec<_> = d.node(g).unwrap().children().unwrap().to_vec();
let top = d.layers[0].id;
d.remove(g).unwrap();
let mut sublayer = (*d.layers[0]).clone();
sublayer.id = d.alloc_id();
if let NodeKind::Layer { children, clip, .. } = &mut sublayer.kind {
(*children, *clip) = (members, true);
}
d.insert(Some(top), 0, sublayer).unwrap();
let leaf = |x: f64| vectorcraft_doc::hit::hit_test(&d, vectorcraft_geom::Point::new(x, 50.0), Default::default()).map(|h| h.leaf);
assert_eq!(leaf(75.0), Some(art));
assert_eq!(leaf(25.0), None);
}
#[test]
fn make_refuses_a_top_object_that_cannot_clip() {
let mut s = session();
+36 -1
View File
@@ -248,7 +248,42 @@ fn the_list_shows_each_image_with_its_status() {
assert_eq!(run(&mut s, "links.list", json!({"show": "missing"}))["links"][0]["id"], linked.0);
assert_eq!(run(&mut s, "links.list", json!({"sort": "name"}))["links"][0]["name"], "a.png");
assert!(s.execute("links.list", &json!({"show": "broken"})).is_err());
assert!(s.execute("links.list", &json!({"sort": "size"})).is_err());
assert!(s.execute("links.list", &json!({"sort": "date"})).is_err());
}
#[test]
fn sort_by_size_lists_the_smallest_file_first() {
let dir = Folder::new("list-size");
let files = [("a.png", png(600, 300, RED)), ("b.png", png(10, 10, BLUE)), ("c.png", png(100, 100, RED)), ("d.png", png(10, 10, BLUE))];
let mut s = session();
for (name, bytes) in &files {
let path = dir.file(name);
write(&path, bytes);
place(&mut s, &path);
}
let e = dir.file("e.png");
write(&e, &png(1, 1, RED));
place_embedded(&mut s, &e);
let names = |s: &mut Session| -> Vec<String> {
let l = run(s, "links.list", json!({"sort": "size"}));
l["links"].as_array().unwrap().iter().map(|r| r["name"].as_str().unwrap_or_default().to_string()).collect()
};
// Smallest first, equal sizes by name, the embedded image (no file) last.
let mut want: Vec<(usize, &str)> = files.iter().map(|(n, b)| (b.len(), *n)).collect();
want.sort();
assert!(want[0].0 < want[2].0 && want[2].0 < want[3].0, "{want:?}");
let mut want: Vec<String> = want.iter().map(|(_, n)| n.to_string()).collect();
want.push("e.png".into());
assert_eq!(names(&mut s), want);
// A missing file sorts by the size the document keeps of it.
std::fs::remove_file(dir.file("a.png")).unwrap();
assert_eq!(names(&mut s), want);
// A modified file sorts by its size now.
let bigger = png(900, 900, BLUE);
assert!(bigger.len() > files[0].1.len());
write(&dir.file("c.png"), &bigger);
let got = names(&mut s);
assert_eq!(got[got.len() - 2..], ["c.png".to_string(), "e.png".to_string()]);
}
#[test]
+147 -1
View File
@@ -2234,6 +2234,10 @@ fn type_menu_and_find_commands_refuse_a_param_of_the_wrong_type() {
("type.smartPunctuation", json!({"dashes": "false"}), switch("dashes")),
("type.smartPunctuation", json!({"ellipsis": 0}), switch("ellipsis")),
("type.smartPunctuation", json!({"quotes": "no"}), switch("quotes")),
("type.smartPunctuation", json!({"fiLigatures": 1}), switch("fiLigatures")),
("type.smartPunctuation", json!({"flLigatures": "true"}), switch("flLigatures")),
("type.smartPunctuation", json!({"spaces": "on"}), switch("spaces")),
("type.smartPunctuation", json!({"fractions": [true]}), switch("fractions")),
("type.smartPunctuation", json!({"scope": 5}), scope.clone()),
("type.smartPunctuation", json!({"scope": "page"}), scope),
("type.changeCase", json!({"case": 5}), string("case")),
@@ -3563,12 +3567,154 @@ fn smart_punctuation_with_nothing_to_change_records_no_undo_step() {
for id in [t, r] {
select_with_a_step_to_redo(&mut s, &[id]);
let before = history(&s);
assert_eq!(s.execute("type.smartPunctuation", &json!({})).unwrap(), json!({"changed": 0}), "{id:?}");
let r = s.execute("type.smartPunctuation", &json!({})).unwrap();
assert_eq!(r["changed"], 0, "{id:?}");
assert!(r["replaced"].as_object().unwrap().values().all(|n| n == 0), "{id:?}: {r}");
assert_eq!(history(&s), before, "{id:?}");
}
assert_eq!(plain(&s, t), "“Hi” it’s 1–2 and 3—4…");
}
/// Does the face text `font` draws with have a glyph for every character of `chars`?
fn font_covers(font: &str, chars: &str) -> bool {
let face = vectorcraft_text::FontDb::global().face(font, "Regular");
face.is_some_and(|f| chars.chars().all(|c| f.covers(c)))
}
/// Every option on: the ligatures, Smart Spaces and Expert Fractions, each counted on its own, in
/// one undo step. Fractions next to a digit, a slash or a decimal part are left as they are.
#[test]
fn smart_punctuation_ligatures_spaces_and_fractions_are_counted() {
let mut s = session();
assert!(font_covers("Source Sans 3", "\u{FB00}\u{FB01}\u{FB02}\u{FB03}\u{FB04}½¾"), "Source Sans 3 draws the ligatures and fractions");
let words = "An office's fluffy flow. Then 1/2 and 3/4, not 1/25, 1/2/26 or 1/2.5.";
let t = id_of(&s.execute("text.create", &json!({"x": 50, "y": 50, "text": words, "font": "Source Sans 3"})).unwrap());
sel(&mut s, &[t]);
let undo = history(&s).0;
let all = json!({"fiLigatures": true, "flLigatures": true, "quotes": true, "spaces": true, "dashes": true, "ellipsis": true, "fractions": true});
let r = s.execute("type.smartPunctuation", &all).unwrap();
assert_eq!(plain(&s, t), "An o\u{FB03}ce’s \u{FB02}u\u{FB00}y \u{FB02}ow. Then ½ and ¾, not 1/25, 1/2/26 or 1/2.5.");
let replaced = json!({"fiLigatures": 2, "flLigatures": 2, "quotes": 1, "spaces": 1, "dashes": 0, "ellipses": 0, "fractions": 2});
assert_eq!(r, json!({"changed": 1, "replaced": replaced}));
assert_eq!(history(&s).0, undo + 1);
}
/// The ff, fl, ffl option alone leaves fi and ffi as letters (ffi keeps its ff ligature, counted
/// with this option), and the other options are off unless asked for.
#[test]
fn smart_punctuation_fl_ligatures_alone() {
let mut s = session();
let t = id_of(&s.execute("text.create", &json!({"x": 50, "y": 50, "text": "office fit baffle. 1/2", "font": "Source Sans 3"})).unwrap());
sel(&mut s, &[t]);
let r = s.execute("type.smartPunctuation", &json!({"flLigatures": true})).unwrap();
assert_eq!(plain(&s, t), "o\u{FB00}ice fit ba\u{FB04}e. 1/2");
assert_eq!(r["replaced"], json!({"fiLigatures": 0, "flLigatures": 2, "quotes": 0, "spaces": 0, "dashes": 0, "ellipses": 0, "fractions": 0}));
}
/// A ligature or a fraction the run's font has no glyph for stays as its letters, while the run's
/// other replacements still happen.
#[test]
fn smart_punctuation_skips_ligatures_and_fractions_the_font_lacks() {
let mut s = session();
let mono = "JetBrains Mono";
assert!(!font_covers(mono, "\u{FB01}") && !font_covers(mono, "⅐"), "{mono} has no fi ligature or one-seventh");
let t = id_of(&s.execute("text.create", &json!({"x": 50, "y": 50, "text": "fit 1/7 a--b", "font": mono})).unwrap());
sel(&mut s, &[t]);
let r = s.execute("type.smartPunctuation", &json!({"fiLigatures": true, "fractions": true})).unwrap();
assert_eq!(plain(&s, t), "fit 1/7 a–b");
let counts = (&r["replaced"]["fiLigatures"], &r["replaced"]["fractions"], &r["replaced"]["dashes"]);
assert_eq!(counts, (&json!(0), &json!(0), &json!(1)));
}
/// A fraction at the end of a run is checked against the start of the next run: 1/25 and 1/2.5
/// split after their 1/2 stay as they are, and a 1/2 the next run's space ends is replaced.
#[test]
fn expert_fractions_look_past_the_end_of_a_run() {
let mut s = session();
let t = id_of(&s.execute("text.create", &json!({"x": 50, "y": 50, "text": "1/25 1/2.5 1/2 x", "font": "Source Sans 3"})).unwrap());
for (start, end) in [(0, 3), (5, 8), (11, 14)] {
s.execute("text.setRangeStyle", &json!({"id": t.0, "start": start, "end": end, "size": 30})).unwrap();
}
sel(&mut s, &[t]);
let r = s.execute("type.smartPunctuation", &json!({"fractions": true})).unwrap();
assert_eq!(plain(&s, t), "1/25 1/2.5 ½ x");
assert_eq!(r["replaced"]["fractions"], 1);
}
/// An inline graphic separates the text on either side of it: the space after it is not a second
/// space after the period before it, and the digit before it does not stop a fraction after it.
#[test]
fn an_inline_graphic_separates_smart_spaces_and_fractions() {
let mut s = session();
let r = rect(&mut s, 600.0, 500.0, 10.0, 10.0);
sel(&mut s, &[r]);
s.execute("symbol.new", &json!({"name": "Mark"})).unwrap();
s.execute("edit.clear", &json!({})).unwrap();
let t = id_of(&s.execute("text.create", &json!({"x": 50, "y": 50, "text": "A. B 71/2", "font": "Source Sans 3"})).unwrap());
s.execute("text.insertInline", &json!({"id": t.0, "at": 7, "symbol": "Mark"})).unwrap();
s.execute("text.insertInline", &json!({"id": t.0, "at": 3, "symbol": "Mark"})).unwrap();
let texts = |s: &Session| match node(s, t).kind {
NodeKind::Text(o) => o.runs.iter().map(|r| if r.inline.is_some() { "[g]".to_string() } else { r.text.clone() }).collect::<String>(),
_ => String::new(),
};
assert_eq!(texts(&s), "A. [g] B 7[g]1/2");
sel(&mut s, &[t]);
let r = s.execute("type.smartPunctuation", &json!({"spaces": true, "fractions": true})).unwrap();
assert_eq!(texts(&s), "A. [g] B 7[g]½");
assert_eq!((&r["replaced"]["spaces"], &r["replaced"]["fractions"]), (&json!(0), &json!(1)));
}
/// Smart Spaces keeps one space after each period, also when the spaces start in the next run,
/// and leaves single spaces and spaces after other characters alone.
#[test]
fn smart_spaces_keeps_one_space_after_a_period() {
let mut s = session();
let t = text(&mut s, "A. B. C. D E");
// "C." is in a run of its own, and the spaces after it start the next one.
s.execute("text.setRangeStyle", &json!({"id": t.0, "start": 8, "end": 10, "size": 30})).unwrap();
assert!(matches!(node(&s, t).kind, NodeKind::Text(ref o) if o.runs.len() > 1));
sel(&mut s, &[t]);
let r = s.execute("type.smartPunctuation", &json!({"spaces": true, "quotes": false, "dashes": false, "ellipsis": false})).unwrap();
assert_eq!(plain(&s, t), "A. B. C. D E");
assert_eq!(r["replaced"]["spaces"], 2);
}
/// Ruby stays on the characters it was set on when Smart Punctuation makes the text before them
/// longer (curly quotes) or shorter (Smart Spaces), and no replacement takes in characters on both
/// sides of a ruby span's end: the fi with ruby on its i stays as letters, and a second space after
/// a period with ruby of its own stays.
#[test]
fn smart_punctuation_keeps_ruby_on_its_characters() {
let mut s = session();
let spans = |s: &Session, t| match node(s, t).kind {
NodeKind::Text(o) => o.ruby.iter().map(|r| (r.start, r.end)).collect::<Vec<_>>(),
_ => vec![],
};
let t = id_of(&s.execute("text.create", &json!({"x": 50, "y": 50, "text": "'A'. base fit X. Y", "font": "Source Sans 3"})).unwrap());
sel(&mut s, &[t]);
for (start, end) in [(1, 2), (6, 10), (12, 13), (18, 19)] {
s.execute("type.ruby", &json!({"text": "r", "start": start, "end": end})).unwrap();
}
let r = s.execute("type.smartPunctuation", &json!({"spaces": true, "fiLigatures": true})).unwrap();
assert_eq!(plain(&s, t), "‘A’. base fit X. Y");
assert_eq!((&r["replaced"]["spaces"], &r["replaced"]["fiLigatures"]), (&json!(1), &json!(0)));
let text = plain(&s, t);
let on: Vec<&str> = spans(&s, t).into_iter().map(|(a, b)| &text[a..b]).collect();
assert_eq!(on, ["A", "base", "i", " "]);
}
/// Expert Fractions leaves a fraction next to a digit of another script as it is.
#[test]
fn expert_fractions_skip_fractions_next_to_digits_of_any_script() {
let mut s = session();
let words = "\u{662}1/2 1/2\u{662} 1/2.\u{662} 1/2";
let t = id_of(&s.execute("text.create", &json!({"x": 50, "y": 50, "text": words, "font": "Source Sans 3"})).unwrap());
sel(&mut s, &[t]);
let r = s.execute("type.smartPunctuation", &json!({"fractions": true})).unwrap();
assert_eq!(plain(&s, t), "\u{662}1/2 1/2\u{662} 1/2.\u{662} ½");
assert_eq!(r["replaced"]["fractions"], 1);
}
#[test]
fn convert_point_area_roundtrip() {
let mut s = session();
+72
View File
@@ -775,6 +775,78 @@ fn create_outlines_draws_the_type_objects_own_paint() {
assert!(differing(&before, &render(&session()), 24) > 1000, "the type paints something");
}
/// Selected type "HIH" at 40 pt with ruby, underlined and struck through, its characters filled
/// with `fill` (a `paint.setFill` param) and stroked 2 pt wide, then scaled to 150% and rotated 20
/// degrees (the stroke keeps its 2 pt, which is 4/3 pt in text space).
fn transformed_gradient_type(s: &mut Session, fill: Value) -> NodeId {
let t = run(s, "text.create", json!({"x": 30, "y": 110, "text": "HIH", "size": 40}))["id"].as_u64().unwrap();
run(s, "select.set", json!({"ids": [t]}));
run(s, "type.ruby", json!({"text": "ab"}));
run(s, "text.setFormat", json!({"ids": [t], "underline": true, "strikethrough": true}));
run(s, "paint.setFill", fill);
run(s, "paint.setStroke", json!({"color": "#008000"}));
run(s, "stroke.set", json!({"weight": 2}));
run(s, "object.scale", json!({"ids": [t], "sx": 150, "sy": 150}));
run(s, "object.rotate", json!({"ids": [t], "angle": 20}));
NodeId(t)
}
/// Create Outlines draws what the type drew: a gradient on the characters that has no vector of
/// its own keeps one gradient across every glyph, ruby glyph and bar, fitted to the layout's box,
/// and one with a vector keeps it where it was, both with the type's scale and rotation. The
/// characters' stroke keeps the weight it was drawn at.
#[test]
fn create_outlines_keeps_a_character_gradient_across_the_type() {
let placed = json!({"gradient": {"kind": "linear", "start": [40, 100], "end": [110, 100],
"stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]}});
for fill in [red_blue(), placed] {
let mut s = session();
let t = transformed_gradient_type(&mut s, fill.clone());
let NodeKind::Text(text) = &node(&s, t).kind else { panic!("not type") };
assert!(!text.ruby.is_empty() && text.runs.iter().all(|r| r.style.underline && r.style.strikethrough), "{fill}");
// The characters' stroke is kept in text space and drawn scaled by the type's transform.
let weight = text.runs[0].style.stroke_layer().width;
let drawn = weight * vectorcraft_doc::Scaling::factor(text.xf).unwrap_or(1.0);
assert!((drawn - weight).abs() > 0.1, "the type's transform scales: {weight} {drawn}");
let before = render(&s);
let g = run(&mut s, "type.createOutlines", json!({}))["ids"][0].as_u64().unwrap();
let after = render(&s);
let n = differing(&before, &after, 24);
assert!(n < 60, "{fill}: {n} pixels differ");
assert!(differing(&before, &render(&session()), 24) > 1000, "the type paints something");
// Every glyph, ruby glyph and bar takes the same gradient, placed, and the stroke at the
// weight it was drawn at.
let mut gradients = vec![];
let mut widths = vec![];
node(&s, NodeId(g)).walk(&mut |c: &Node| {
if let Paint::Gradient(gr) = c.appearance.fill_paint() {
gradients.push(gr.clone());
}
widths.extend(c.appearance.stroke().map(|st| st.width));
});
assert!(gradients.len() >= 6, "{fill}: three glyphs, ruby and two bars: {}", gradients.len());
assert!(gradients.iter().all(|gr| gr.is_placed() && *gr == gradients[0]), "{fill}: {gradients:?}");
assert!(widths.len() == gradients.len() && widths.iter().all(|w| (w - drawn).abs() < 1e-9), "{fill}: {drawn} {widths:?}");
}
}
/// Expand outlines the type before its fills expand, so a gradient on its characters that has no
/// vector of its own expands across the whole type, as the canvas draws it, and the query reports
/// it.
#[test]
fn expand_keeps_a_character_gradient_across_the_type() {
let mut s = session();
transformed_gradient_type(&mut s, red_blue());
let info = run(&mut s, "object.expand.info", json!({}));
assert_eq!((info["gradient"].clone(), info["fill"].clone(), info["object"].clone()), (json!(true), json!(true), json!(true)), "{info}");
let before = render(&s);
run(&mut s, "object.expand", json!({}));
let after = render(&s);
assert_eq!(gradients_and_type(&s), (0, 0), "outlined and expanded");
let d = mean_diff(&before, &after);
assert!(d < 0.5, "{d}");
}
/// `object.expand.info` reports the type object's own stroke, pattern fill and gradient fill, which
/// Object turns into paths of its whole outline: a gradient only when that outline's box needs no
/// more runs than are written.
+273
View File
@@ -0,0 +1,273 @@
//! Automatic Plane Positioning: Shift held over an anchor point of art in perspective or over a
//! gridline intersection moves the active plane through it for now (`perspective.plane.infer`),
//! the two Perspective Grid Options that turn the targets on and off, and what ends it.
use serde_json::json;
use vectorcraft_geom::{Point, Vec2};
use vectorcraft_tools::distort::perspective::{PerspectiveGrid, Plane};
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
use crate::tests_persp_select::{attached, bounds_of, drag, grid, persp_session, plane_pts, undo_len};
use crate::*;
fn shift() -> Mods {
Mods { shift: true, ..Default::default() }
}
/// The top anchor of path `id` (on the right wall at depth 0): its page point and its height.
fn top_anchor(s: &Session, id: NodeId) -> (Point, f64) {
let page: Vec<Point> = s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().anchors().map(|(_, _, a)| a.p).collect();
let plane = plane_pts(s, id, Plane::Right, 0.0);
page.into_iter().zip(plane).map(|(p, q)| (p, q.y)).max_by(|a, b| a.1.total_cmp(&b.1)).unwrap()
}
fn hover(s: &mut Session, p: Point, mods: Mods) {
s.pointer(&PointerEvent::new(PointerKind::Move, p.x, p.y).with_mods(mods), ViewInfo::default()).unwrap();
}
fn revision(s: &Session) -> u64 {
s.doc().unwrap().revision
}
#[test]
fn shift_over_an_anchor_holds_the_active_plane_at_its_height_until_an_object_is_drawn() {
let (mut s, id) = persp_session();
let v = ViewInfo::default();
let (anchor, height) = top_anchor(&s, id);
assert!(height > 10.0, "{height}");
s.execute("perspective.plane.set", &json!({"plane": "ground"})).unwrap();
s.select_tool("perspectiveSelection", v).unwrap();
let steps = undo_len(&s);
// Without Shift the pointer only hovers.
hover(&mut s, anchor, Mods::default());
assert_eq!(grid(&s).inferred, None);
hover(&mut s, anchor, shift());
let g = grid(&s);
assert!(g.inferred.is_some());
assert!((g.offset(Plane::Ground) - height).abs() < 1e-6, "{} {height}", g.offset(Plane::Ground));
assert_eq!((g.ground_offset, g.shown_planes(), undo_len(&s)), (0.0, vec![Plane::Ground], steps));
// Held there already, another hover over the anchor changes nothing.
let rev = revision(&s);
hover(&mut s, anchor, shift());
assert_eq!(revision(&s), rev);
// The Rectangle tool draws on the plane where it is held, and the plane stays put for the drag.
s.select_tool("rectangle", v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Down, 420.0, 400.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 470.0, 430.0), v).unwrap();
assert!(grid(&s).inferred.is_some(), "held while the object is drawn");
s.pointer(&PointerEvent::new(PointerKind::Up, 470.0, 430.0), v).unwrap();
let drawn = s.doc().unwrap().selection.objects[0];
assert_ne!(drawn, id);
let (plane, depth) = attached(&s, drawn).unwrap();
assert!(plane == Plane::Ground && (depth - height).abs() < 1e-6, "{plane:?} {depth}");
// Drawn, the plane is back in its place and the other planes show: one undo step for the drag.
let g = grid(&s);
assert_eq!((g.inferred, g.offset(Plane::Ground), g.shown_planes().len(), undo_len(&s)), (None, 0.0, 3, steps + 1));
// Undoing the drag holds the plane again; Escape with the Perspective Selection tool ends that.
s.execute("edit.undo", &json!({})).unwrap();
assert!(grid(&s).inferred.is_some());
s.select_tool("perspectiveSelection", v).unwrap();
s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap();
assert_eq!(grid(&s).inferred, None);
}
#[test]
fn shift_during_a_drag_still_constrains_the_move() {
let (mut s, id) = persp_session();
let v = ViewInfo::default();
s.select_tool("perspectiveSelection", v).unwrap();
let before = plane_pts(&s, id, Plane::Right, 0.0);
let h = grid(&s).homography(Plane::Right).unwrap();
// From the middle of the object, along the wall and a little up.
let start = bounds_of(&before).center();
let (from, to) = (h.apply(start).unwrap(), h.apply(start + Vec2::new(80.0, 20.0)).unwrap());
drag(&mut s, &[from, to, to], shift());
assert_eq!(grid(&s).inferred, None, "with a button down Shift constrains the move");
for (a, b) in before.iter().zip(plane_pts(&s, id, Plane::Right, 0.0)) {
assert!((b - *a - Vec2::new(80.0, 0.0)).hypot() < 1e-6, "{a:?} → {b:?}");
}
}
#[test]
fn a_gridline_intersection_brings_each_plane_made_active_through_it() {
let (mut s, _) = persp_session();
let (g, steps) = (grid(&s), undo_len(&s));
s.execute("perspective.plane.set", &json!({"plane": "left"})).unwrap();
let at = g.to_page(Plane::Left, Point::new(3.0 * g.cell, 2.0 * g.cell)).unwrap();
let r = s.execute("perspective.plane.infer", &json!({"at": [at.x, at.y], "tolerance": 1})).unwrap();
assert_eq!((r["active"].as_bool(), r["source"].as_str(), r["plane"].as_str()), (Some(true), Some("gridline"), Some("left")));
for (got, want) in r["point"].as_array().unwrap().iter().zip([0.0, 2.0 * g.cell, 3.0 * g.cell]) {
assert!((got.as_f64().unwrap() - want).abs() < 1e-6, "{r}");
}
for (plane, want) in [("ground", 2.0 * g.cell), ("right", 3.0 * g.cell), ("left", 0.0)] {
s.execute("perspective.plane.set", &json!({"plane": plane})).unwrap();
let r = s.execute("perspective.plane.infer", &json!({})).unwrap();
assert!((r["offset"].as_f64().unwrap() - want).abs() < 1e-6 && r["active"] == json!(true) && r.get("source").is_none(), "{plane}: {r}");
}
let held = grid(&s);
assert_eq!((held.left_offset, held.right_offset, held.ground_offset, undo_len(&s)), (0.0, 0.0, 0.0, steps));
// Nothing within reach leaves the plane where it is held.
let rev = revision(&s);
let mid = held.to_page(Plane::Left, Point::new(3.5 * g.cell, 2.5 * g.cell)).unwrap();
let r = s.execute("perspective.plane.infer", &json!({"at": [mid.x, mid.y], "tolerance": 0.5})).unwrap();
assert!(r["active"] == json!(true) && r.get("source").is_none() && revision(&s) == rev, "{r}");
// With no active plane nothing is held.
s.execute("perspective.plane.set", &json!({"plane": "none"})).unwrap();
assert_eq!(grid(&s).inferred, None);
assert!(s.execute("perspective.plane.infer", &json!({"at": [at.x, at.y]})).is_err(), "no active plane");
assert_eq!(s.execute("perspective.plane.infer", &json!({})).unwrap()["active"], json!(false));
}
#[test]
fn the_two_options_turn_the_targets_on_and_off_and_are_kept() {
let (mut s, id) = persp_session();
assert!(s.prefs.perspective_widget.anchor_point && s.prefs.perspective_widget.gridline_intersection, "both on by default");
let (anchor, _) = top_anchor(&s, id);
let infer = |s: &mut Session| s.execute("perspective.plane.infer", &json!({"at": [anchor.x, anchor.y], "tolerance": 0.01}));
assert_eq!(infer(&mut s).unwrap()["source"], json!("anchor"));
s.execute("perspective.plane.infer", &json!({"clear": true})).unwrap();
let r = s.execute("perspective.widget.options", &json!({"anchorPoint": false})).unwrap();
assert_eq!((r["anchorPoint"].as_bool(), r["gridlineIntersection"].as_bool(), r["show"].as_bool()), (Some(false), Some(true), Some(true)));
assert!(infer(&mut s).unwrap().get("source").is_none(), "the anchor is no target now");
assert_eq!(grid(&s).inferred, None);
let back: Prefs = serde_json::from_value(serde_json::to_value(&s.prefs).unwrap()).unwrap();
assert!(!back.perspective_widget.anchor_point && back.perspective_widget.gridline_intersection);
s.execute("perspective.widget.options", &json!({"gridlineIntersection": false})).unwrap();
assert!(infer(&mut s).is_err(), "both off");
for bad in [json!({"anchorPoint": "yes"}), json!({"gridlineIntersection": 1})] {
assert!(s.execute("perspective.widget.options", &bad).is_err(), "{bad}");
}
assert!(!s.prefs.perspective_widget.anchor_point, "a refused value changes nothing");
s.execute("prefs.set", &json!({"key": "perspectiveWidget", "value": {"anchorPoint": true}})).unwrap();
assert!(s.prefs.perspective_widget.anchor_point && !s.prefs.perspective_widget.gridline_intersection);
// Preferences saved before the options existed read as both on.
let old: vectorcraft_tools::distort::perspective::widget::WidgetOptions = serde_json::from_value(json!({"show": false})).unwrap();
assert!(old.anchor_point && old.gridline_intersection && !old.show);
}
#[test]
fn bad_params_are_refused_and_leave_the_grid_as_it_is() {
let (mut s, id) = persp_session();
let (anchor, _) = top_anchor(&s, id);
let rev = revision(&s);
for bad in [
json!({"at": "here"}),
json!({"at": [1]}),
json!({"at": [anchor.x, anchor.y], "tolerance": -1}),
json!({"at": [anchor.x, anchor.y], "tolerance": "wide"}),
json!({"at": [anchor.x, anchor.y], "clear": true}),
json!({"clear": "yes"}),
] {
assert!(s.execute("perspective.plane.infer", &bad).is_err(), "{bad}");
}
assert_eq!((grid(&s).inferred, revision(&s)), (None, rev));
// Clearing when nothing is held changes nothing.
assert_eq!(s.execute("perspective.plane.infer", &json!({"clear": true})).unwrap()["active"], json!(false));
assert_eq!(revision(&s), rev);
s.execute("perspective.grid.show", &json!({"visible": false})).unwrap();
assert!(s.execute("perspective.plane.infer", &json!({"at": [anchor.x, anchor.y]})).is_err(), "hidden grid");
}
/// A session whose ground plane is held at the height of the attached rectangle's top anchor.
fn held_session() -> (Session, NodeId, f64) {
let (mut s, id) = persp_session();
let (anchor, height) = top_anchor(&s, id);
s.execute("perspective.plane.set", &json!({"plane": "ground"})).unwrap();
let r = s.execute("perspective.plane.infer", &json!({"at": [anchor.x, anchor.y], "tolerance": 0.01})).unwrap();
assert_eq!(r["source"], json!("anchor"));
(s, id, height)
}
#[test]
fn attaching_an_object_ends_it_and_the_object_sits_where_the_plane_was_held() {
let (mut s, _, height) = held_session();
let r = s.execute("shape.rectangle", &json!({"x": 420, "y": 400, "width": 40, "height": 30})).unwrap();
let flat = NodeId(r["id"].as_u64().unwrap());
s.execute("perspective.attach", &json!({"ids": [flat.0]})).unwrap();
let (plane, depth) = attached(&s, flat).unwrap();
assert!(plane == Plane::Ground && (depth - height).abs() < 1e-6, "{plane:?} {depth}");
assert_eq!((grid(&s).inferred, grid(&s).ground_offset), (None, 0.0));
// An attach of nothing, or to a plane it doesn't hold, leaves it held.
let (mut s, ..) = held_session();
s.execute("perspective.attach", &json!({"ids": []})).unwrap();
assert!(grid(&s).inferred.is_some());
let r = s.execute("shape.rectangle", &json!({"x": 420, "y": 400, "width": 40, "height": 30})).unwrap();
let other = NodeId(r["id"].as_u64().unwrap());
s.execute("perspective.attach", &json!({"ids": [other.0], "plane": "right"})).unwrap();
assert_eq!(attached(&s, other).map(|a| a.0), Some(Plane::Right));
assert!(grid(&s).inferred.is_some());
// A move of an object already in perspective leaves it held; one brought into perspective ends it.
let (mut s, id, height) = held_session();
s.execute("perspective.move", &json!({"ids": [id.0], "from": [460, 400], "to": [470, 400]})).unwrap();
assert!(grid(&s).inferred.is_some());
let r = s.execute("shape.rectangle", &json!({"x": 420, "y": 400, "width": 40, "height": 30})).unwrap();
let flat = NodeId(r["id"].as_u64().unwrap());
s.execute("perspective.move", &json!({"ids": [flat.0], "from": [430, 410], "to": [440, 410]})).unwrap();
let (plane, depth) = attached(&s, flat).unwrap();
assert!(plane == Plane::Ground && (depth - height).abs() < 1e-6, "{plane:?} {depth}");
assert_eq!(grid(&s).inferred, None);
}
#[test]
fn moving_a_plane_hiding_the_grid_and_reopening_the_file_end_it() {
// A moved plane moves from where it is held and is placed there for good, in one undo step;
// undoing the move holds it again.
let (mut s, _, height) = held_session();
let steps = undo_len(&s);
s.execute("perspective.plane.move", &json!({"plane": "ground", "by": 10})).unwrap();
let g = grid(&s);
assert_eq!((g.inferred, undo_len(&s)), (None, steps + 1));
assert!((g.ground_offset - (height + 10.0)).abs() < 1e-6, "{}", g.ground_offset);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!((grid(&s).inferred.is_some(), grid(&s).ground_offset), (true, 0.0));
// A refused move leaves it held.
let (mut s, ..) = held_session();
assert!(s.execute("perspective.plane.move", &json!({"plane": "ground", "offset": 1, "objects": "all"})).is_err());
assert!(grid(&s).inferred.is_some());
// A plane dragged while it is held, then the drag canceled, is held again.
let (mut s, ..) = held_session();
s.begin_interaction("Move Plane").unwrap();
s.preview("perspective.plane.move", &json!({"plane": "ground", "by": 20})).unwrap();
assert_eq!(grid(&s).inferred, None);
s.cancel_interaction().unwrap();
assert!(grid(&s).inferred.is_some());
// Move Plane to Match Object.
let (mut s, id, _) = held_session();
s.execute("perspective.plane.matchObject", &json!({"id": id.0})).unwrap();
assert_eq!((grid(&s).inferred, grid(&s).plane), (None, Plane::Right));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!((grid(&s).inferred.is_some(), grid(&s).plane), (true, Plane::Ground), "undo holds it again");
// With the plane placed at the object already, matching only puts the held plane back there.
s.execute("perspective.plane.set", &json!({"plane": "right"})).unwrap();
assert!(grid(&s).inferred.is_some());
let steps = undo_len(&s);
s.execute("perspective.plane.matchObject", &json!({"id": id.0})).unwrap();
assert_eq!((grid(&s).inferred, grid(&s).offset(Plane::Right), undo_len(&s)), (None, 0.0, steps));
// Hide Grid.
let (mut s, ..) = held_session();
s.execute("perspective.grid.show", &json!({"visible": false})).unwrap();
assert_eq!(grid(&s).inferred, None);
// The grid's definition changing leaves it held, and it is not `perspective.grid.set`'s to set.
let (mut s, ..) = held_session();
let held = grid(&s).inferred;
s.execute("perspective.grid.set", &json!({"cell": 31, "inferred": [1, 2, 3]})).unwrap();
assert_eq!(grid(&s).inferred, held);
// `perspective.grid.set` hiding the grid or choosing no plane ends it.
for patch in [json!({"visible": false}), json!({"plane": "none"})] {
let (mut s, ..) = held_session();
s.execute("perspective.grid.set", &patch).unwrap();
assert_eq!(grid(&s).inferred, None, "{patch}");
}
// Another preset leaves it held, as other changes to the grid's definition do.
let (mut preset, ..) = held_session();
let before = grid(&preset).inferred;
preset.execute("perspective.grid.preset", &json!({"kind": 3})).unwrap();
assert_eq!((grid(&preset).kind, grid(&preset).inferred), (3, before));
// A file saved while a plane is held opens with the plane in its place.
let saved = vectorcraft_format::save(&s.doc().unwrap().doc, false);
let back = vectorcraft_format::load(&saved).unwrap();
assert!(PerspectiveGrid::positioning(&back), "the saved file still holds the point");
s.add_document(back, None);
assert_eq!(grid(&s).inferred, None);
assert_eq!(grid(&s).cell, 31.0);
}
+4 -1
View File
@@ -520,7 +520,10 @@ fn the_plane_widget_stays_on_screen_and_takes_clicks_with_any_tool() {
click(&mut s, q, view);
assert_eq!(grid(&s).plane, Plane::Ground);
// Perspective Grid Options move it to another corner or hide it.
assert_eq!(run(&mut s, "perspective.widget.options", json!({"position": "bottomRight"})), json!({"show": true, "position": "bottomRight"}));
assert_eq!(
run(&mut s, "perspective.widget.options", json!({"position": "bottomRight"})),
json!({"show": true, "position": "bottomRight", "anchorPoint": true, "gridlineIntersection": true})
);
let r = face(&s, view, WidgetCorner::BottomRight, Plane::Left);
assert!(r.x > 100.0 + 450.0 && r.y > 50.0 + 300.0, "{r:?}");
click(&mut s, r, view);
@@ -599,6 +599,100 @@ fn with_link_a_pdf_page_places_linked() {
assert!(near(centre_colour(&s.doc().unwrap().doc), RED));
}
/// The colour at the centre of `r` in `doc`.
fn colour_at(doc: &Document, r: Rect) -> [u8; 3] {
let img = vectorcraft_render::Renderer::new().render_region(doc, r, 1.0, true);
let [red, g, b, _] = img.pixel((r.width() / 2.0) as u32, (r.height() / 2.0) as u32);
[red, g, b]
}
#[test]
fn link_pages_opens_each_picked_pdf_page_as_a_linked_placed_document() {
let dir = Folder::new("open-pdf-link-pages");
let src = dir.file("flyer.pdf");
pdf_source(&src, "pdf", &[RED, BLUE, RED], 0);
let mut s = Session::new();
let r = s.execute("document.open", &json!({"path": src, "linkPages": true, "pages": "2-3"})).unwrap();
assert_eq!((r["format"].as_str(), r["restored"].as_bool()), (Some("pdf"), Some(false)), "{r}");
let st = s.doc().unwrap();
assert_eq!(st.path, None, "Save asks for a name instead of writing over the PDF");
let d = &st.doc;
let boards: Vec<Rect> = d.artboards.iter().map(|a| a.rect).collect();
assert_eq!(boards, [Rect::new(0.0, 0.0, 100.0, 50.0), Rect::new(136.0, 0.0, 236.0, 50.0)]);
let layers: Vec<_> = d.layers.iter().map(|l| l.name.clone().unwrap_or_default()).collect();
assert_eq!(layers, ["Page 2", "Page 3"]);
let mut ids = vec![];
for (layer, (page, board)) in d.layers.iter().zip([(2, boards[0]), (3, boards[1])]) {
let kids = layer.children().unwrap();
assert_eq!(kids.len(), 1);
let p = placed(&s, kids[0].id);
assert_eq!((p.link.path.as_str(), p.link.page, p.bounding), (src.as_str(), Some(page), false), "{p:?}");
assert_eq!(bounds(&s, kids[0].id), board);
ids.push(kids[0].id);
}
assert!(near(colour_at(d, boards[0]), BLUE));
assert!(near(colour_at(d, boards[1]), RED));
let links = s.execute("links.list", &json!({})).unwrap()["links"].clone();
assert_eq!(links.as_array().map(Vec::len), Some(2), "{links}");
assert!(links.as_array().unwrap().iter().all(|row| row["document"] == true && row["status"] == "ok"), "{links}");
// Embed makes a page editable art.
s.execute("links.embed", &json!({"ids": [ids[0].0]})).unwrap();
let d = &s.doc().unwrap().doc;
assert_eq!(d.node(ids[0]).unwrap().kind_label(), "Clip Group");
assert_eq!(bounds(&s, ids[0]), boards[0]);
assert!(near(colour_at(d, boards[0]), BLUE));
// Every page, with the art's bounds as the artboard.
s.execute("document.open", &json!({"path": src, "linkPages": true, "cropTo": "bounding"})).unwrap();
let d = &s.doc().unwrap().doc;
assert_eq!(d.artboards.len(), 3);
assert!(d.layers.iter().flat_map(|l| l.children().unwrap()).all(|n| matches!(&n.kind, NodeKind::PlacedDocument(p) if p.bounding)));
}
#[test]
fn linked_pages_share_one_copy_of_the_file_and_open_in_the_pages_color_mode() {
use vectorcraft_testkit::pdf::{PdfPage, pdf};
let dir = Folder::new("open-pdf-link-pages-cmyk");
let src = dir.file("cmyk.pdf");
let page = || PdfPage::new(100.0, 50.0, "0 0.5 1 0 k 0 0 100 50 re f");
write(&src, &pdf(&[page(), page(), page()], None));
let mut s = Session::new();
s.execute("document.open", &json!({"path": src, "linkPages": true})).unwrap();
let d = &s.doc().unwrap().doc;
assert_eq!(d.color_mode, vectorcraft_doc::ColorMode::Cmyk, "pages painted in CMYK open as a CMYK document");
let blobs: Vec<_> = d.images.values().map(|b| b.bytes.clone()).collect();
assert_eq!(blobs.len(), 3);
assert!(blobs.iter().all(|b| std::sync::Arc::ptr_eq(b, &blobs[0])), "one copy of the file for every page");
s.execute("document.open", &json!({"path": src, "linkPages": true, "colorMode": "rgb"})).unwrap();
assert_eq!(s.doc().unwrap().doc.color_mode, vectorcraft_doc::ColorMode::Rgb, "colorMode wins");
}
#[test]
fn link_pages_is_refused_without_a_path_for_other_files_and_with_a_password_or_outlines() {
let dir = Folder::new("open-pdf-link-pages-refused");
let src = dir.file("flyer.pdf");
pdf_source(&src, "pdf", &[RED, BLUE], 0);
let bytes = std::fs::read(&src).unwrap();
let logo = dir.file("logo.vectorcraft");
source(&logo, 100.0, 50.0, RED, 0);
let mut s = Session::new();
let cases = [
(json!({"name": "flyer.pdf", "dataBase64": vectorcraft_format::base64_encode(&bytes), "linkPages": true}), "needs the file's path"),
(json!({"path": logo, "linkPages": true}), "isn't a PDF"),
(json!({"path": src, "linkPages": true, "password": "secret"}), "password"),
(json!({"path": src, "linkPages": true, "textAs": "outlines"}), "textAs: outlines"),
(json!({"path": src, "linkPages": "yes"}), "linkPages must be true or false"),
(json!({"path": src, "linkPages": true, "pages": "3"}), "pages:"),
];
for (p, says) in cases {
let e = s.execute("document.open", &p).unwrap_err().to_string();
assert!(e.contains(says), "{p}: {e}");
}
assert!(s.documents().is_empty(), "nothing opened");
// Off, the pages open as editable art.
s.execute("document.open", &json!({"path": src, "linkPages": false})).unwrap();
assert!(!s.doc().unwrap().doc.has_placed());
}
#[test]
fn a_changed_pdf_is_modified_and_update_links_reads_it_again() {
let dir = Folder::new("placed-pdf-update");
+176
View File
@@ -715,3 +715,179 @@ fn svg_export_draws_a_filter_svg_1_1_lacks_as_an_image() {
assert_eq!((out.contains("<image"), out.contains("<feBlend")), (image, !image), "{mode}: {out}");
}
}
/// The `defaults` of `svgFilter.list`: each one's name and whether the document holds it.
fn defaults(s: &mut Session) -> Vec<(String, bool)> {
let listed = run(s, "svgFilter.list", json!({}))["defaults"].as_array().cloned().unwrap_or_default();
listed.iter().map(|f| (f["name"].as_str().unwrap().to_string(), f["inDocument"].as_bool().unwrap())).collect()
}
/// Every document lists the default filters without holding them; applying one adds the effect and
/// the document's copy in one undo step.
#[test]
fn a_default_filter_is_listed_and_copied_into_the_document_when_applied() {
use vectorcraft_render::effects::svg_filter::defaults::DEFAULT_FILTERS;
let mut s = session();
let listed = defaults(&mut s);
assert_eq!(listed.len(), DEFAULT_FILTERS.len());
assert!(listed.iter().all(|(_, held)| !held), "a new document holds none of them");
assert!(names(&mut s).is_empty());
let soft = DEFAULT_FILTERS.iter().find(|f| f.name == "Soft Shadow").unwrap();
assert_eq!(run(&mut s, "svgFilter.list", json!({}))["defaults"][2], json!({"name": "Soft Shadow", "xml": soft.xml, "inDocument": false}));
let (a, b) = (rect(&mut s, 10.0), rect(&mut s, 50.0));
run(&mut s, "select.set", json!({"ids": [a.0, b.0]}));
let before = steps(&s);
let r = run(&mut s, "svgFilter.apply", json!({"name": "Soft Shadow"}));
assert_eq!((r["ids"].as_array().unwrap().len(), &r["index"]), (2, &json!(0)));
assert_eq!(steps(&s), before + 1, "the effect and the copy are one step");
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "SVG Filter");
assert_eq!(names(&mut s), ["Soft Shadow"]);
assert_eq!(drawn(&s, a), json!(soft.xml));
assert!(defaults(&mut s).contains(&("Soft Shadow".to_string(), true)));
// Applied again, the document's copy is the filter: nothing more is added.
run(&mut s, "svgFilter.apply", json!({"name": "Soft Shadow", "ids": [a.0]}));
assert_eq!(names(&mut s), ["Soft Shadow"]);
run(&mut s, "edit.undo", json!({}));
run(&mut s, "edit.undo", json!({}));
assert!(names(&mut s).is_empty(), "undo takes the copy back with the effect");
assert!(s.doc().unwrap().doc.node(a).unwrap().appearance.effects.is_empty());
run(&mut s, "edit.redo", json!({}));
assert_eq!((names(&mut s), drawn(&s, b)), (vec!["Soft Shadow".to_string()], json!(soft.xml)));
// Nothing to apply it to: no copy and no step.
let mut s = session();
assert!(s.execute("svgFilter.apply", &json!({"name": "Glow"})).is_err(), "nothing selected");
assert!(names(&mut s).is_empty());
assert_eq!(steps(&s), 0);
assert!(err(&mut s, "svgFilter.apply", json!({"name": "glow", "ids": []})).contains("no SVG filter named \"glow\""), "names match exactly");
}
/// With a full history, applying a default lets one old step go, as any edit does: the copy joins
/// the step that applied it, and one Undo takes both back.
#[test]
fn a_default_applied_with_a_full_history_is_still_one_step() {
let mut s = session();
s.active_mut().unwrap().history.limit = 3;
let a = rect(&mut s, 10.0);
rect(&mut s, 30.0);
rect(&mut s, 50.0);
assert_eq!(steps(&s), 3);
run(&mut s, "svgFilter.apply", json!({"name": "Invert", "ids": [a.0]}));
// The application takes one place in the history: only the oldest step went.
assert_eq!(steps(&s), 3);
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "SVG Filter");
run(&mut s, "edit.undo", json!({}));
assert!(names(&mut s).is_empty(), "the copy is undone with the effect");
assert!(s.doc().unwrap().doc.node(a).unwrap().appearance.effects.is_empty());
}
/// Edit SVG Filter on a default saves the edited copy in the document under the default's name;
/// the document's filter then stands in the default's place, and deleting it lists the default again.
#[test]
fn an_edited_default_is_saved_in_the_document_and_delete_restores_the_default() {
use vectorcraft_render::effects::svg_filter::defaults::default_filter;
let mut s = session();
let glow = default_filter("Glow").unwrap();
// Saved with its own code: nothing to keep, and no undo step.
run(&mut s, "svgFilter.define", json!({"xml": glow.xml, "replace": "Glow"}));
assert_eq!((names(&mut s).len(), steps(&s)), (0, 0));
let edited = r#"<filter id="Renamed"><feGaussianBlur stdDeviation="9"/></filter>"#;
assert_eq!(run(&mut s, "svgFilter.define", json!({"xml": edited, "replace": "Glow"}))["name"], "Glow");
assert_eq!((names(&mut s), steps(&s)), (vec!["Glow".to_string()], 1));
assert_eq!(s.doc().unwrap().history.undo[0].label, "Edit SVG Filter");
let kept = r#"<filter id="Glow"><feGaussianBlur stdDeviation="9"/></filter>"#;
assert_eq!(run(&mut s, "svgFilter.list", json!({}))["filters"][0]["xml"], kept, "it keeps the default's name");
// Applying the name now applies the document's filter.
let a = rect(&mut s, 10.0);
run(&mut s, "svgFilter.apply", json!({"name": "Glow", "ids": [a.0]}));
assert_eq!(drawn(&s, a), json!(kept));
// Deleting the copy leaves the effect drawing nothing, as for any filter, and lists the default.
run(&mut s, "svgFilter.delete", json!({"name": "Glow"}));
assert_eq!(drawn(&s, a), json!(""));
assert!(defaults(&mut s).contains(&("Glow".to_string(), false)));
// The default itself is not deleted.
let before = steps(&s);
assert!(err(&mut s, "svgFilter.delete", json!({"name": "Glow"})).contains("is a default SVG filter"));
assert!(err(&mut s, "svgFilter.delete", json!({"name": "Nope"})).contains("no SVG filter named"));
assert_eq!(steps(&s), before);
// Applied again, the default is copied back, and the object that named it draws with it.
let b = rect(&mut s, 50.0);
run(&mut s, "svgFilter.apply", json!({"name": "Glow", "ids": [b.0]}));
assert_eq!((drawn(&s, a), drawn(&s, b)), (json!(glow.xml), json!(glow.xml)));
assert_eq!(steps(&s), before + 2, "the rectangle and one step for the application");
// A new filter may take a default's name the document does not hold.
let mut s = session();
run(&mut s, "svgFilter.define", json!({"xml": r#"<filter id="Invert"><feOffset dx="1"/></filter>"#}));
assert!(defaults(&mut s).contains(&("Invert".to_string(), true)));
// An unknown name is still refused.
assert!(err(&mut s, "svgFilter.define", json!({"xml": SHIFT, "replace": "Nope"})).contains("no SVG filter named \"Nope\""));
}
/// With `index`, the filter takes the place of an applied SVG filter, a default with its copy in
/// the same undo step; any other effect keeps its place.
#[test]
fn apply_with_an_index_replaces_an_applied_filter() {
let mut s = session();
let a = rect(&mut s, 10.0);
run(&mut s, "svgFilter.define", json!({"xml": SHIFT}));
run(&mut s, "svgFilter.apply", json!({"name": "Shift", "ids": [a.0]}));
run(&mut s, "effect.apply", json!({"effect": "stylize.feather", "ids": [a.0]}));
let before = steps(&s);
let r = run(&mut s, "svgFilter.apply", json!({"name": "Invert", "ids": [a.0], "index": 0, "item": null}));
assert_eq!(r["ids"], json!([a.0]));
assert_eq!(steps(&s), before + 1, "one step");
let fx = s.doc().unwrap().doc.node(a).unwrap().appearance.effects.clone();
assert_eq!((fx.len(), fx[0].params["name"].as_str(), fx[1].id.as_str()), (2, Some("Invert"), "stylize.feather"));
assert_eq!(names(&mut s), ["Shift", "Invert"]);
run(&mut s, "edit.undo", json!({}));
assert_eq!(names(&mut s), ["Shift"]);
assert_eq!(s.doc().unwrap().doc.node(a).unwrap().appearance.effects[0].params["name"], "Shift");
// Not an SVG filter, no effect there, or not an index: refused, and the default is not copied.
let before = steps(&s);
for (index, why) in [
(json!(1), "applied SVG filter"),
(json!(7), "applied SVG filter"),
(json!("x"), "`index`"),
(json!(-1), "`index`"),
(json!(null), "`index`"),
] {
let e = err(&mut s, "svgFilter.apply", json!({"name": "Invert", "ids": [a.0], "index": index, "item": null}));
assert!(e.contains(why), "{index}: {e}");
}
assert_eq!((names(&mut s), steps(&s)), (vec!["Shift".to_string()], before));
}
/// A default needs room in the document: at 64 filters applying one is refused, and a filter the
/// document holds still applies.
#[test]
fn a_full_document_refuses_a_default_it_does_not_hold() {
let mut s = session();
let a = rect(&mut s, 10.0);
for i in 0..64 {
run(&mut s, "svgFilter.define", json!({"xml": format!("<filter id=\"f{i}\"><feOffset/></filter>")}));
}
let before = steps(&s);
assert!(err(&mut s, "svgFilter.apply", json!({"name": "Glow", "ids": [a.0]})).contains("at most 64 SVG filters"));
let edited = r#"<filter id="Glow"><feOffset dx="2"/></filter>"#;
assert!(err(&mut s, "svgFilter.define", json!({"xml": edited, "replace": "Glow"})).contains("at most 64 SVG filters"));
assert_eq!(steps(&s), before);
assert!(s.doc().unwrap().doc.node(a).unwrap().appearance.effects.is_empty());
run(&mut s, "svgFilter.apply", json!({"name": "f3", "ids": [a.0]}));
}
/// A default applied in one document is written to SVG as a filter and read back from the native
/// format with the document's copy.
#[test]
fn an_applied_default_survives_svg_export_and_a_native_round_trip() {
let mut s = session();
let a = rect(&mut s, 10.0);
run(&mut s, "svgFilter.apply", json!({"name": "Grayscale", "ids": [a.0]}));
let out = svg(&mut s);
assert!(out.contains("<feColorMatrix") && out.contains("saturate"), "{out}");
let (back, warnings) = vectorcraft_svg::import_with_report(&out).unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
assert!(back.any_node(|n| n.appearance.effects.iter().any(|e| e.id == "svg.filter")), "SVG import reads the filter back");
let doc = s.doc().unwrap().doc.clone();
let again = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap();
assert_eq!(again.svg_filters, doc.svg_filters);
assert_eq!(again.svg_filters[0].name, "Grayscale");
}
+103 -41
View File
@@ -2265,6 +2265,36 @@ fn remove_breaks_takes_the_whole_story_that_select_all_selected() {
assert!(obj(&s, a).plain_text().contains('\n') && obj(&s, b).plain_text().contains('\n'));
}
#[test]
fn remove_breaks_of_the_whole_story_takes_locked_and_hidden_frames() {
for command in ["object.lock", "object.hide"] {
for held_first in [false, true] {
let (mut s, a, b, story) = threaded_list_edited_in_its_second_frame();
let (held, other) = if held_first { (a, b) } else { (b, a) };
let unrelated = s.execute("text.create", &json!({"x": 10, "y": 500, "text": "One\nTwo", "size": 12})).unwrap()["id"].as_u64().unwrap();
let unrelated = NodeId(unrelated);
s.execute(command, &json!({"ids": [held.0]})).unwrap();
s.execute("select.set", &json!({"ids": [other.0]})).unwrap();
s.select_tool("type", ViewInfo::default()).unwrap();
s.set_tool_option("textSelection", &json!({"editing": other.0, "anchor": 0, "caret": 0}));
assert_eq!(s.execute("select.all", &json!({})).unwrap()["story"], json!([a.0, b.0]));
let case = format!("{command}, held first: {held_first}");
let steps = undo_labels(&s).len();
let r = s.execute("addon.cleanup.removeBreaks", &json!({"kind": "paragraph"})).unwrap();
assert_eq!(undo_labels(&s).len(), steps + 1, "{case}: one undo step");
assert!(r["ids"].as_array().unwrap().contains(&json!(held.0)), "{case}: the held frame is changed");
let joined = format!("{}{}", obj(&s, a).plain_text(), obj(&s, b).plain_text());
assert_eq!(joined, story.trim_end_matches('\n').replace('\n', " "), "{case}: every frame's returns go");
assert_eq!(obj(&s, unrelated).plain_text(), "One\nTwo", "{case}: other type is as it was");
// No return is left: another call fails and adds no undo step.
assert!(s.execute("addon.cleanup.removeBreaks", &json!({"kind": "paragraph"})).is_err());
assert_eq!(undo_labels(&s).len(), steps + 1, "{case}: no undo step when nothing changes");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(format!("{}{}", obj(&s, a).plain_text(), obj(&s, b).plain_text()), story, "{case}: undo restores the text");
}
}
}
#[test]
fn remove_breaks_on_a_selected_threaded_story_puts_a_space_between_its_frames() {
let (mut s, a, b, story) = threaded_list_edited_in_its_second_frame();
@@ -2306,41 +2336,72 @@ fn convert_to_text_takes_the_whole_story_that_select_all_selected() {
}
}
/// Whether no paragraph of type `id` is a list.
fn no_list(s: &Session, id: NodeId) -> bool {
let t = obj(s, id);
(0..t.paragraph_count()).all(|k| t.para_at(k).list.is_none())
}
/// The bullets held as characters in the frames `ids`.
fn bullet_chars(s: &Session, ids: &[NodeId]) -> usize {
ids.iter().map(|id| obj(s, *id).plain_text().matches("\u{2022}\t").count()).sum()
}
#[test]
fn convert_to_text_of_the_whole_story_leaves_a_locked_frame() {
// Over MCP without ids, and as the Paragraph panel sends it, with every frame of the story.
for named in [false, true] {
let (mut s, a, b, _) = threaded_list_edited_in_its_second_frame();
s.execute("object.lock", &json!({"ids": [b.0]})).unwrap();
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
s.set_tool_option("textSelection", &json!({"editing": a.0, "anchor": 0, "caret": 0}));
assert_eq!(s.execute("select.all", &json!({})).unwrap()["story"], json!([a.0, b.0]));
// The first frame ends with a paragraph return, so the paragraph that starts the locked
// frame takes in the first frame's empty last part and starts its text there.
let in_a = obj(&s, a).plain_text();
assert!(in_a.ends_with('\n'));
let own = in_a.split('\n').filter(|piece| !piece.is_empty()).count();
let p = if named { json!({"ids": [a.0, b.0]}) } else { json!({}) };
let r = s.execute("text.convertListToText", &p).unwrap();
assert_eq!(r["ids"], json!([a.0]), "only the editable frame is changed (ids named: {named})");
assert_eq!(r["converted"], json!(own), "only the paragraphs whose text starts in the editable frame convert");
assert!(r["inserted"].as_array().unwrap().iter().all(|i| i["id"] == json!(a.0)));
let (ta, tb) = (obj(&s, a), obj(&s, b));
assert!(ta.plain_text().contains("\u{2022}\t"), "the editable frame is converted");
let markers = ta.plain_text().matches("\u{2022}\t").count() + tb.plain_text().matches("\u{2022}\t").count();
assert_eq!(markers, own, "the locked frame's first paragraph keeps its list");
// The converted text is longer, so the story flows on into the locked frame; its own
// paragraphs stay a list.
let last = tb.paragraph_count() - 2;
assert!(tb.para_at(last).list.is_some(), "the locked frame's paragraphs stay a list (ids named: {named})");
assert!(!tb.plain_text().ends_with("\u{2022}\tAlpha beta.\n"));
assert!(s.tool_options().get("story").is_some(), "the whole story stays selected");
fn convert_to_text_of_the_whole_story_converts_locked_and_hidden_frames() {
// Over MCP without ids, as the Paragraph panel sends it with every frame of the story, and with
// ids naming only the held frame.
for command in ["object.lock", "object.hide"] {
for held_first in [false, true] {
for call in ["none", "story", "held"] {
let (mut s, a, b, story) = threaded_list_edited_in_its_second_frame();
let (held, other) = if held_first { (a, b) } else { (b, a) };
let unrelated =
s.execute("text.create", &json!({"x": 10, "y": 500, "text": "One\nTwo", "size": 12})).unwrap()["id"].as_u64().unwrap();
let unrelated = NodeId(unrelated);
s.execute("text.setFormat", &json!({"ids": [unrelated.0], "list": "bullet"})).unwrap();
let unrelated_before = obj(&s, unrelated);
s.execute(command, &json!({"ids": [held.0]})).unwrap();
s.execute("select.set", &json!({"ids": [other.0]})).unwrap();
s.select_tool("type", ViewInfo::default()).unwrap();
s.set_tool_option("textSelection", &json!({"editing": other.0, "anchor": 0, "caret": 0}));
assert_eq!(s.execute("select.all", &json!({})).unwrap()["story"], json!([a.0, b.0]));
let case = format!("{command}, held first: {held_first}, ids: {call}");
let p = match call {
"none" => json!({}),
"story" => json!({"ids": [a.0, b.0]}),
_ => json!({"ids": [held.0]}),
};
let steps = undo_labels(&s).len();
let r = s.execute("text.convertListToText", &p).unwrap();
assert_eq!(undo_labels(&s).len(), steps + 1, "{case}: one undo step");
assert!(r["ids"].as_array().unwrap().contains(&json!(held.0)), "{case}: the held frame is changed");
assert!(no_list(&s, held), "{case}: the held frame leaves its list");
assert_eq!(obj(&s, unrelated), unrelated_before, "{case}: other type is as it was");
if call == "held" {
// The other frame's paragraphs whose text starts there keep their list.
assert!(!no_list(&s, other), "{case}: the frame not named keeps its list");
continue;
}
assert!(no_list(&s, other), "{case}: the other frame leaves its list");
assert_eq!(bullet_chars(&s, &[a, b]), 12, "{case}: each paragraph's bullet is held once as characters");
assert!(s.tool_options().get("story").is_some(), "{case}: the whole story stays selected");
// Nothing is a list any more: another call changes nothing and adds no undo step.
s.execute("text.convertListToText", &p).unwrap();
assert_eq!(undo_labels(&s).len(), steps + 1, "{case}: no undo step when nothing changes");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(format!("{}{}", obj(&s, a).plain_text(), obj(&s, b).plain_text()), story, "{case}: undo restores the text");
assert!(!no_list(&s, a) && !no_list(&s, b), "{case}: undo restores the lists");
}
}
}
}
#[test]
fn convert_to_text_of_the_whole_story_leaves_a_paragraph_that_starts_in_a_locked_frame() {
for named in [false, true] {
fn convert_to_text_of_the_whole_story_converts_a_paragraph_that_runs_across_a_locked_frame() {
// The paragraph that runs from the first frame into the second starts in the locked frame, or
// ends in it.
for (named, lock_first) in [(false, true), (true, true), (false, false), (true, false)] {
let mut s = session();
let story = format!("Alpha beta.\nAlpha beta.\n{}end.\nAlpha beta.\nAlpha beta.\n", "Gamma delta ".repeat(40));
let a = NodeId(
@@ -2354,22 +2415,23 @@ fn convert_to_text_of_the_whole_story_leaves_a_paragraph_that_starts_in_a_locked
s.execute("text.setFormat", &json!({"ids": [a.0, b.0], "list": "bullet"})).unwrap();
let in_a = obj(&s, a).plain_text();
assert!(!in_a.ends_with('\n') && !obj(&s, b).plain_text().starts_with('\n'), "a paragraph runs on from the first frame into the second");
s.execute("object.lock", &json!({"ids": [a.0]})).unwrap();
s.execute("select.set", &json!({"ids": [b.0]})).unwrap();
let (locked, other) = if lock_first { (a, b) } else { (b, a) };
s.execute("object.lock", &json!({"ids": [locked.0]})).unwrap();
s.execute("select.set", &json!({"ids": [other.0]})).unwrap();
s.select_tool("type", ViewInfo::default()).unwrap();
s.set_tool_option("textSelection", &json!({"editing": b.0, "anchor": 0, "caret": 0}));
s.set_tool_option("textSelection", &json!({"editing": other.0, "anchor": 0, "caret": 0}));
assert_eq!(s.execute("select.all", &json!({})).unwrap()["story"], json!([a.0, b.0]));
let case = format!("ids named: {named}, first frame locked: {lock_first}");
let p = if named { json!({"ids": [a.0, b.0]}) } else { json!({}) };
let r = s.execute("text.convertListToText", &p).unwrap();
assert_eq!(r["ids"], json!([b.0]), "only the editable frame is changed (ids named: {named})");
// The two paragraphs that start in the editable frame, and the empty one that ends the story.
assert_eq!(r["converted"], json!(3), "only the paragraphs that start in the editable frame convert");
assert_eq!(r["ids"], json!([a.0, b.0]), "{case}: both frames are changed");
// The five paragraphs with text, and the empty one that ends the story.
assert_eq!(r["converted"], json!(6), "{case}: every paragraph of the story converts");
let (ta, tb) = (obj(&s, a), obj(&s, b));
assert_eq!(ta.plain_text(), in_a, "the locked frame's text is as it was");
assert!((0..ta.paragraph_count()).all(|k| ta.para_at(k).list.is_some()), "the locked frame stays a list");
assert!(tb.para_at(0).list.is_some(), "the paragraph the locked frame starts stays a list");
assert!(!tb.plain_text().starts_with('\u{2022}'));
assert_eq!(tb.plain_text().matches("\u{2022}\t").count(), 2);
assert!(no_list(&s, a) && no_list(&s, b), "{case}: the story leaves its list");
assert_eq!(bullet_chars(&s, &[a, b]), 5, "{case}: each paragraph's bullet is held once as characters");
assert!(ta.plain_text().contains("\u{2022}\tGamma"), "{case}: the paragraph across the frames takes its bullet where its text starts");
assert!(!tb.plain_text().starts_with('\u{2022}'), "{case}");
}
}
+3 -1
View File
@@ -429,6 +429,7 @@ impl Session {
},
screen: view.screen,
plane_widget: self.prefs.perspective_widget.show.then_some(self.prefs.perspective_widget.position),
plane_targets: self.prefs.perspective_widget.targets(),
precise_cursors: self.precise_cursors(),
global_edit: st.global_edit.as_ref().map_or(&[], |g| g.members.as_slice()),
};
@@ -1543,7 +1544,7 @@ impl Session {
if !std::sync::Arc::ptr_eq(&it.doc, &st.doc) {
return None;
}
let interaction_fields = (it.preview.clone(), it.copies.clone(), it.perspective_again.clone());
let interaction_fields = (it.preview.clone(), it.copies.clone(), it.perspective_again.clone(), it.plane_placed);
(st.doc.clone(), st.selection.clone(), st.revision, self.pending_paint.clone(), interaction_fields)
};
let (overlays, transform_box) = if self.preview(cmd, p).is_ok() {
@@ -1565,6 +1566,7 @@ impl Session {
it.preview = interaction_fields.0;
it.copies = interaction_fields.1;
it.perspective_again = interaction_fields.2;
it.plane_placed = interaction_fields.3;
}
}
self.pending_paint = paint;