mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-15 14:49:18 +00:00
Merge main into feat/live-paint-bucket-multi-click
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() }))
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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))));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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, ¶ms).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:?}");
|
||||
}
|
||||
|
||||
@@ -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") }))
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})?;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 ----------
|
||||
|
||||
Reference in New Issue
Block a user