mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-15 12:24:19 +00:00
Merge remote-tracking branch 'origin/main' into feat/width-smooth-profile
# Conflicts: # docs/target-app-parity.md
This commit is contained in:
@@ -70,6 +70,7 @@ pub mod pdfcmds;
|
||||
pub mod perspgrid;
|
||||
pub mod place;
|
||||
pub mod plugin;
|
||||
pub(crate) mod plugin_call;
|
||||
pub mod prefscmds;
|
||||
pub mod print;
|
||||
pub(crate) mod printadvanced;
|
||||
@@ -518,17 +519,23 @@ pub(crate) fn checked_ids_param(s: &Session, p: &Value, key: &str, command: &str
|
||||
}
|
||||
|
||||
/// The objects a command acts on: `ids` when given (every one checked, and those inside another
|
||||
/// given one dropped), else the selected objects.
|
||||
/// given one dropped), else (`ids` left out or null) the selected objects.
|
||||
pub(crate) fn checked_ids_or_selection(s: &Session, p: &Value, command: &str) -> Result<Vec<NodeId>> {
|
||||
if p.get("ids").is_some() { Ok(edit::outermost(&s.doc()?.doc, checked_ids_param(s, p, "ids", command)?)) } else { edit::selected_roots(s) }
|
||||
if p.get("ids").is_some_and(|v| !v.is_null()) {
|
||||
Ok(edit::outermost(&s.doc()?.doc, checked_ids_param(s, p, "ids", command)?))
|
||||
} else {
|
||||
edit::selected_roots(s)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn checked_id(s: &Session, value: &Value, command: &str) -> Result<NodeId> {
|
||||
let id = value.as_u64().map(NodeId).ok_or_else(|| bad(command, format!("invalid object id {value}: expected a non-negative integer")))?;
|
||||
if s.doc()?.doc.node(id).is_none() {
|
||||
return Err(bad(command, format!("no such object id {value}")));
|
||||
match s.doc()?.doc.node(id) {
|
||||
None => Err(bad(command, format!("no such object id {value}"))),
|
||||
// The guide commands take ruler guides by index.
|
||||
Some(n) if n.is_ruler_guide() => Err(bad(command, format!("id {value} is a ruler guide, not an object"))),
|
||||
Some(_) => Ok(id),
|
||||
}
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// `v` as exactly `N` finite numbers (`[x, y]`, `[x, y, width, height]`…): `None` when it isn't an
|
||||
@@ -688,10 +695,11 @@ pub(crate) fn leaf_targets(s: &Session, ids: &[NodeId]) -> Result<Vec<NodeId>> {
|
||||
let compound = |id: NodeId| d.parent_of(id).filter(|p| d.node(*p).is_some_and(owns));
|
||||
let mut out = vec![];
|
||||
for id in ids {
|
||||
let Some(n) = d.node(*id) else { continue };
|
||||
// A ruler guide has no appearance to paint, given or in a layer.
|
||||
let Some(n) = d.node(*id).filter(|n| !n.is_ruler_guide()) else { continue };
|
||||
match &n.kind {
|
||||
NodeKind::Group { .. } | NodeKind::Layer { .. } if !n.is_compound_shape() => n.walk(&mut |c| {
|
||||
if !c.is_container() || owns(c) {
|
||||
if (!c.is_container() && !c.is_ruler_guide()) || owns(c) {
|
||||
out.push(c.id)
|
||||
}
|
||||
}),
|
||||
@@ -741,6 +749,7 @@ pub(crate) fn unique_name_from(base: &str, next: &mut std::collections::HashMap<
|
||||
/// The most presets one presets file imports: each is looked up among the saved ones.
|
||||
pub(crate) const MAX_IMPORTED_PRESETS: usize = 1000;
|
||||
|
||||
pub use docmenu::guide_indexes;
|
||||
pub(crate) use edit::{settle_interaction, typing_in_progress};
|
||||
pub(crate) use object::bounds_rewrite;
|
||||
pub(crate) use object::{keep_aligned_in, refresh_pixel_aligned};
|
||||
|
||||
@@ -185,7 +185,7 @@ fn objects<'a>(d: &'a Document, scope: &Scope) -> Vec<(&'a Node, Place)> {
|
||||
}
|
||||
|
||||
fn visit<'a>(n: &'a Node, place: Place, out: &mut Vec<(&'a Node, Place)>) {
|
||||
if !n.visible || n.locked || out.len() >= MAX_OBJECTS {
|
||||
if !n.visible || n.locked || n.is_ruler_guide() || out.len() >= MAX_OBJECTS {
|
||||
return;
|
||||
}
|
||||
let inner = |clip: bool, i: usize, top: bool| Place { clips: clip && i == 0, top, in_compound: false };
|
||||
@@ -216,7 +216,7 @@ fn is_generated(n: &Node) -> bool {
|
||||
}
|
||||
|
||||
fn is_guide_or_clip(n: &Node) -> bool {
|
||||
matches!(n.kind, NodeKind::Path { guide: true, .. } | NodeKind::Path { clipping: true, .. })
|
||||
n.is_guide() || matches!(n.kind, NodeKind::Path { clipping: true, .. })
|
||||
}
|
||||
|
||||
fn is_shape(n: &Node) -> bool {
|
||||
@@ -484,7 +484,7 @@ fn artboards(d: &mut Document, sel: &mut Selection, scope: &Scope) -> Result<Out
|
||||
if i < d.artboards.len() {
|
||||
let id = d.artboards.remove(i).id;
|
||||
d.retire_artboard_id(id);
|
||||
d.retain_guides(sel, |_, g| g.artboard != Some(id));
|
||||
d.retain_guides(sel, |g| g.artboard != Some(id));
|
||||
}
|
||||
}
|
||||
Ok(Outcome { count: empty.len(), ids: vec![], artboards: empty })
|
||||
|
||||
@@ -544,7 +544,11 @@ fn merge(chain: &[&Piece]) -> std::result::Result<TextObject, usize> {
|
||||
for (k, w) in off.windows(2).enumerate() {
|
||||
let (delta, Some(&(s, e))) = (w[1] - w[0], before.get(k)) else { continue };
|
||||
if delta.abs() > 1e-9 {
|
||||
edit::style_range(&mut m.runs, s, e, |st| st.tracking += delta / st.size.max(1e-6) * 1000.0);
|
||||
// Thousandths of the em as the Horizontal Scale draws it (`spacing_scale`).
|
||||
let vertical = m.vertical;
|
||||
edit::style_range(&mut m.runs, s, e, |st| {
|
||||
st.tracking += delta / (st.size * vectorcraft_text::spacing_scale(st, vertical)).max(1e-6) * 1000.0;
|
||||
});
|
||||
if !TRACKING.contains(&edit::style_at(&m.runs, s).tracking) {
|
||||
return Err(k + 1);
|
||||
}
|
||||
@@ -802,7 +806,7 @@ fn remove_breaks(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
// The characters a Type tool has selected (after Select All in threaded text, all the text of
|
||||
// every frame of the story), else all the text of the selected type.
|
||||
let editing = if p.get("ids").is_some() { None } else { editing_range(s)?.filter(|(_, a, b)| a != b) };
|
||||
let editing = if p.get("ids").is_some_and(|v| !v.is_null()) { None } else { editing_range(s)?.filter(|(_, a, b)| a != b) };
|
||||
let story = if editing.is_some() { editing_story(s) } else { vec![] };
|
||||
let d = &s.doc()?.doc;
|
||||
// Each type object with the breaks that go from it.
|
||||
|
||||
@@ -125,6 +125,30 @@ fn break_area_type_into_lines_paragraphs_and_glyphs() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Tracked area type broken into lines or paragraphs stays where it was, however it is aligned:
|
||||
/// a wrap after a hyphen or at a space becomes a line break or a paragraph's end, and the line
|
||||
/// leaves the tracking after its last character out of its width there as it did at the wrap.
|
||||
/// (Words chosen without a kerning pair across the wrap, which the pieces don't keep.)
|
||||
#[test]
|
||||
fn break_tracked_area_type_keeps_its_aligned_lines_in_place() {
|
||||
for justify in ["center", "right"] {
|
||||
for text_of in ["Mode-Mode-Mode", "Wave Wave Wave"] {
|
||||
for by in ["paragraphs", "lines"] {
|
||||
let mut s = session();
|
||||
let id = text(&mut s, json!({"x": 40, "y": 40, "text": text_of, "size": 20, "area": {"width": 90, "height": 200}}));
|
||||
s.execute("text.setRangeStyle", &json!({"id": id.0, "tracking": 200})).unwrap();
|
||||
s.execute("text.setStyle", &json!({"ids": [id.0], "justify": justify})).unwrap();
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &obj(&s, id));
|
||||
assert!(lay.lines.len() > 1, "{text_of}: it wraps");
|
||||
let was = glyphs(&s, &[id]);
|
||||
select(&mut s, &[id]);
|
||||
let pieces = ids(&s.execute("addon.cleanup.breakText", &json!({"by": by})).unwrap());
|
||||
assert_same_places(&was, &glyphs(&s, &pieces));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn break_text_refuses_what_it_cannot_break() {
|
||||
let mut s = session();
|
||||
@@ -176,6 +200,24 @@ fn combine_joins_only_pieces_on_one_line_turned_alike() {
|
||||
assert!(other(s.execute("addon.cleanup.combineText", &json!({}))).contains("combine"));
|
||||
}
|
||||
|
||||
/// Pieces in horizontally scaled type: the tracking that puts each back where it was is measured in
|
||||
/// the em as the Horizontal Scale draws it (`vectorcraft_text::spacing_scale`), as layout spaces it.
|
||||
#[test]
|
||||
fn combine_puts_horizontally_scaled_pieces_back_where_they_were() {
|
||||
let mut s = session();
|
||||
let a = text(&mut s, json!({"x": 50, "y": 100, "text": "Left", "size": 20}));
|
||||
let b = text(&mut s, json!({"x": 160, "y": 100, "text": "Right", "size": 20}));
|
||||
for id in [a, b] {
|
||||
s.execute("text.setRangeStyle", &json!({"id": id.0, "hScale": 200})).unwrap();
|
||||
}
|
||||
let was = glyphs(&s, &[a, b]);
|
||||
select(&mut s, &[a, b]);
|
||||
let r = s.execute("addon.cleanup.combineText", &json!({})).unwrap();
|
||||
assert_eq!(ids(&r), [a]);
|
||||
assert_eq!(obj(&s, a).plain_text(), "Left Right");
|
||||
assert_same_places(&was, &glyphs(&s, &[a]));
|
||||
}
|
||||
|
||||
/// Join Point Text makes one object of stacked one-line pieces: lines top to bottom as paragraphs,
|
||||
/// pieces on a line left to right, each line's baseline where it was (its leading is the distance
|
||||
/// from the line above), in one undo step. A piece turned unlike the top-left one stays.
|
||||
@@ -246,6 +288,18 @@ fn remove_breaks_in_the_characters_a_type_tool_selected() {
|
||||
assert_eq!(obj(&s, id).plain_text(), "a b\nc");
|
||||
}
|
||||
|
||||
/// `ids: null` is `ids` left out: the characters the Type tool selected lose their breaks, and
|
||||
/// the rest of the text keeps them.
|
||||
#[test]
|
||||
fn remove_breaks_with_ids_null_in_the_characters_a_type_tool_selected() {
|
||||
let mut s = session();
|
||||
let id = text(&mut s, json!({"x": 40, "y": 60, "text": "a\nb\nc", "size": 12}));
|
||||
select(&mut s, &[id]);
|
||||
edit_text(&mut s, id, 0, 2);
|
||||
s.execute("addon.cleanup.removeBreaks", &json!({"kind": "paragraph", "ids": null})).unwrap();
|
||||
assert_eq!(obj(&s, id).plain_text(), "a b\nc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_at_the_cursor_and_around_a_selection() {
|
||||
for (anchor, caret, want) in [(6, 6, &["Split", "here please"][..]), (6, 10, &["Split", "here", "please"][..])] {
|
||||
|
||||
@@ -107,7 +107,7 @@ impl Fixes {
|
||||
let white = usize::from(self.remove_white_overprint && self.unset_white(n));
|
||||
let blacks = self.overprint_black > 0.0 && (!self.text_only || matches!(n.kind, NodeKind::Text(_)));
|
||||
let mut counts = (white, usize::from(blacks && black.apply(n, spots)));
|
||||
for c in n.children_mut().into_iter().flatten() {
|
||||
for c in n.children_mut().into_iter().flatten().filter(|c| !c.is_ruler_guide()) {
|
||||
let (w, b) = self.overprint(Arc::make_mut(c), black, spots);
|
||||
counts = (counts.0 + w, counts.1 + b);
|
||||
}
|
||||
|
||||
@@ -12,8 +12,8 @@ use vectorcraft_doc::NodeKind;
|
||||
use vectorcraft_render::effects;
|
||||
|
||||
use super::super::edit::{copy_above, selected_roots};
|
||||
use super::super::pathops::{len_param, num_param};
|
||||
use super::super::{CommandSpec, EngineError, NodeId, Result, Session, bad, cmd, has_selection, str_param};
|
||||
use super::super::pathops::{length_given, number_given};
|
||||
use super::super::{CommandSpec, EngineError, NodeId, Result, Session, bad, cmd, has_selection, text_param};
|
||||
|
||||
const C: &str = "addon.contour.set";
|
||||
/// The live effect that does the offsetting (the one Effect › Path › Offset Path applies).
|
||||
@@ -44,12 +44,12 @@ fn joins_for(corners: Option<&str>) -> Result<&'static str> {
|
||||
}
|
||||
|
||||
fn contour(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let offset = len_param(p, "offset").ok_or_else(|| bad(C, "missing `offset` (a distance in points)"))?;
|
||||
let offset = length_given(p, "offset", C)?.ok_or_else(|| bad(C, "missing `offset` (a distance in points)"))?;
|
||||
if offset.abs() > MAX_OFFSET {
|
||||
return Err(bad(C, "offset out of range"));
|
||||
}
|
||||
let joins = joins_for(str_param(p, "corners"))?;
|
||||
let miter = num_param(p, "miterLimit").unwrap_or(4.0).clamp(1.0, 500.0);
|
||||
let joins = joins_for(text_param(p, "corners", C)?)?;
|
||||
let miter = number_given(p, "miterLimit", C)?.unwrap_or(4.0).clamp(1.0, 500.0);
|
||||
let effect = effects::new_effect(EFFECT, &json!({"offset": offset, "joins": joins, "miterLimit": miter}))
|
||||
.ok_or_else(|| EngineError::Other("Contour: the Offset Path effect is not available".into()))?;
|
||||
let roots = selected_roots(s)?;
|
||||
|
||||
@@ -158,6 +158,39 @@ fn contour_refuses_what_it_cannot_offset() {
|
||||
assert!(err.contains("select paths or shapes"), "{err}");
|
||||
}
|
||||
|
||||
/// A param of the wrong type is an error naming the param, and leaves the document, the selection
|
||||
/// and the undo history as they were. Null keeps a param's default, and `offset` still takes a
|
||||
/// string with a unit.
|
||||
#[test]
|
||||
fn contour_refuses_a_param_of_the_wrong_type() {
|
||||
let mut s = session();
|
||||
rect(&mut s, 100.0, 100.0, 50.0, 40.0);
|
||||
let state = |s: &Session| {
|
||||
let st = s.doc().unwrap();
|
||||
(st.doc.clone(), st.selection.objects.clone(), st.history.undo.len())
|
||||
};
|
||||
let before = state(&s);
|
||||
for (p, want) in [
|
||||
(json!({"offset": true}), "`offset` must be a number, not true"),
|
||||
(json!({"offset": "abc"}), "`offset` must be a number, not \"abc\""),
|
||||
(json!({"offset": [5]}), "`offset` must be a number, not [5]"),
|
||||
(json!({"offset": 5, "corners": 3}), "`corners` must be a string"),
|
||||
(json!({"offset": 5, "miterLimit": "high"}), "`miterLimit` must be a number, not \"high\""),
|
||||
(json!({"offset": 5, "miterLimit": false}), "`miterLimit` must be a number, not false"),
|
||||
] {
|
||||
let r = s.execute("addon.contour.set", &p);
|
||||
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if msg == want), "{p}: {r:?}");
|
||||
assert!(state(&s) == before, "{p} changed the document, the selection or the undo history");
|
||||
}
|
||||
// A null `offset` is a missing one.
|
||||
let err = s.execute("addon.contour.set", &json!({"offset": null})).unwrap_err().to_string();
|
||||
assert!(err.contains("missing `offset`"), "{err}");
|
||||
// Null corners and miter limit keep sharp corners at 4; the offset takes a unit.
|
||||
let r = s.execute("addon.contour.set", &json!({"offset": "1 in", "corners": null, "miterLimit": null})).unwrap();
|
||||
let fx = effects_of(&s, ids_of(&r, "created")[0]);
|
||||
assert_eq!((fx[0].1["offset"].clone(), fx[0].1["joins"].clone(), fx[0].1["miterLimit"].clone()), (json!(72.0), json!("miter"), json!(4.0)));
|
||||
}
|
||||
|
||||
/// The border-with-a-gap recipe: two Contours of one shape (the gap, then the border), baked, then
|
||||
/// the gap taken out of the border with Minus Front, leaves a ring between the two.
|
||||
#[test]
|
||||
|
||||
@@ -164,11 +164,11 @@ fn export_all_open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
/// `n` with each layer in it, and itself, made a group of the same art and clipping (a symbol holds
|
||||
/// no layers).
|
||||
/// no layers, and no ruler guides: a layer's are left out).
|
||||
fn without_layers(n: &Node) -> Node {
|
||||
let mut n = n.clone();
|
||||
if let NodeKind::Layer { children, clip, .. } = &n.kind {
|
||||
n.kind = NodeKind::Group { children: children.clone(), clip: *clip };
|
||||
n.kind = NodeKind::Group { children: children.iter().filter(|c| !c.is_ruler_guide()).cloned().collect(), clip: *clip };
|
||||
}
|
||||
if let Some(children) = n.children_mut() {
|
||||
for c in children.iter_mut().filter(|c| matches!(c.kind, NodeKind::Layer { .. })) {
|
||||
@@ -203,8 +203,10 @@ fn symbols_from_layers(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (mut made, mut skipped) = (vec![], vec![]);
|
||||
for layer in layers.iter().filter_map(|id| d.layers.iter().find(|l| l.id == *id)) {
|
||||
let name = layer.display_name();
|
||||
let roots: Vec<NodeId> = layer.children().map(|c| c.iter().map(|n| n.id).collect()).unwrap_or_default();
|
||||
let art = match layer.children().map(Vec::as_slice).unwrap_or_default() {
|
||||
// The layer's ruler guides stay where they are.
|
||||
let children: Vec<Arc<Node>> = layer.children().into_iter().flatten().filter(|n| !n.is_ruler_guide()).cloned().collect();
|
||||
let roots: Vec<NodeId> = children.iter().map(|n| n.id).collect();
|
||||
let art = match children.as_slice() {
|
||||
[] => None,
|
||||
[one] => Some(without_layers(one)),
|
||||
many => Some(without_layers(&Node::group(NodeId(0), many.to_vec()))),
|
||||
|
||||
@@ -774,8 +774,9 @@ fn divide_dashes(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
continue;
|
||||
}
|
||||
let only = (segs.len() < sp.segment_count()).then_some(&segs);
|
||||
let out = pt::divide_dashes(sp, only, spec, MAX_PIECES.saturating_sub(made))
|
||||
.ok_or_else(|| bad(C, "that makes too many pieces (at most 100000): make the dashes longer"))?;
|
||||
let out = pt::divide_dashes(sp, only, spec, MAX_PIECES.saturating_sub(made)).ok_or_else(|| {
|
||||
bad(C, "that makes too many pieces (at most 100000) or dash steps: simplify the dash pattern or make the dashes longer")
|
||||
})?;
|
||||
made += out.len();
|
||||
for (piece, kind) in out.into_iter().filter(|(_, k)| !(remove_gaps && *k == pt::Piece::Gap)) {
|
||||
dashes += usize::from(kind == pt::Piece::Dash);
|
||||
@@ -825,7 +826,7 @@ fn dashed_paths(d: &Document, roots: &[NodeId]) -> Vec<NodeId> {
|
||||
|
||||
fn expand_dashes(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "addon.pathtools.expandDashes";
|
||||
let too_many = || bad(C, "that makes too many pieces (at most 100000): make the dashes longer");
|
||||
let too_many = || bad(C, "that makes too many pieces (at most 100000) or dash steps: simplify the dash pattern or make the dashes longer");
|
||||
let roots = checked_ids_or_selection(s, p, C)?;
|
||||
let d = &s.doc()?.doc;
|
||||
// Each dashed path and its dashes, as the canvas draws them.
|
||||
|
||||
@@ -31,7 +31,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Edit Parametric Shape",
|
||||
[],
|
||||
None,
|
||||
"{id, kind?, center?, width?, height?, rotation?, count?, a?, b?, radius?} change a parametric shape's numbers (as addon.pathtools.shape.create takes them; a new kind takes its own defaults for the values not given), as one undo step; with none, or the same ones, nothing changes → {id, shape (its numbers now)}",
|
||||
"{id, kind?, center?, width?, height?, rotation?: degrees clockwise (0: upright), count?, a?, b?, radius?} change a parametric shape's numbers (as addon.pathtools.shape.create takes them; a new kind takes its own defaults for the values not given), as one undo step; with none, or the same ones, nothing changes → {id, shape (its numbers now)}",
|
||||
has_doc,
|
||||
set
|
||||
),
|
||||
|
||||
@@ -26,7 +26,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Rectangle",
|
||||
[],
|
||||
None,
|
||||
"{x, y, width, height, radius?: pt, radii?: [tl,tr,br,bl], startAngle?, endAngle?, arcMode?: whole|pie|chord|arc, rotation?: deg} → {id}",
|
||||
"{x, y, width, height, radius?: pt, radii?: [tl,tr,br,bl], startAngle?, endAngle?: degrees (the part of the outline kept, clockwise from its first anchor, the top-left corner; each side an equal share of 360; default 0 and 360), arcMode?: whole|pie|chord|arc, rotation?: deg clockwise about the centre (0: upright)} → {id}",
|
||||
has_doc,
|
||||
rectangle
|
||||
),
|
||||
@@ -35,7 +35,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Ellipse",
|
||||
[],
|
||||
None,
|
||||
"{x, y, width, height, startAngle?, endAngle?, arcMode?: whole|pie|chord|arc, rotation?: deg} → {id}",
|
||||
"{x, y, width, height, startAngle?, endAngle?: degrees (counter-clockwise from 3 o'clock, as object.setLiveShape's pieStart and pieEnd; default 0 and 360), arcMode?: whole|pie|chord|arc, rotation?: deg clockwise about the centre (0: upright)} → {id}",
|
||||
has_doc,
|
||||
ellipse
|
||||
),
|
||||
@@ -44,7 +44,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Polygon",
|
||||
[],
|
||||
None,
|
||||
"{cx, cy, radius, sides=6, rounded=0, randomized=0, startAngle?, endAngle?, arcMode?: whole|pie|chord|arc, rotation?: deg} → {id}",
|
||||
"{cx, cy, radius, sides=6, rounded=0, randomized=0, startAngle?, endAngle?: degrees (the part of the outline kept, clockwise from its first anchor, the top corner; each side an equal share of 360; default 0 and 360), arcMode?: whole|pie|chord|arc, rotation?: deg clockwise (0: a corner straight up)} → {id}",
|
||||
has_doc,
|
||||
polygon
|
||||
),
|
||||
@@ -53,7 +53,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Star",
|
||||
[],
|
||||
None,
|
||||
"{cx, cy, radius1, radius2?, points=5, rounded=0, randomized=0, startAngle?, endAngle?, arcMode?: whole|pie|chord|arc, rotation?: deg} → {id}",
|
||||
"{cx, cy, radius1, radius2?, points=5, rounded=0, randomized=0, startAngle?, endAngle?: degrees (the part of the outline kept, clockwise from its first anchor, the top point; each side an equal share of 360; default 0 and 360), arcMode?: whole|pie|chord|arc, rotation?: deg clockwise (0: a point straight up)} → {id}",
|
||||
has_doc,
|
||||
star
|
||||
),
|
||||
@@ -71,7 +71,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"3D Box",
|
||||
[],
|
||||
None,
|
||||
"{x, y, width, height, angleX=30, angleY=30, angleZ=0, parallelX=false, parallelY=true, parallelZ=false} → {id}",
|
||||
"{x, y, width, height, angleX=30 (degrees about the level axis: positive tips the top away from you), angleY=30 (degrees about the upright axis: positive turns the right side away from you), angleZ=0 (degrees clockwise in the page), parallelX=false, parallelY=true, parallelZ=false} → {id}",
|
||||
has_doc,
|
||||
box3d
|
||||
),
|
||||
@@ -80,7 +80,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Configure Shape",
|
||||
[],
|
||||
None,
|
||||
"{ids?, radius?, radii?: [tl,tr,br,bl], startAngle?, endAngle?, arcMode?: whole|pie|chord|arc, sides?, isStar?, spokeRatio?, rounded?, randomized?, turns?, divergence?, innerRadius?, angleX?, angleY?, angleZ?, parallelX?, parallelY?, parallelZ?, reset?: bool} → {updated}",
|
||||
"{ids?, radius?, radii?: [tl,tr,br,bl], startAngle?, endAngle?: degrees (as addon.shapes.rectangle, ellipse, polygon and star take them: an ellipse's counter-clockwise from 3 o'clock, the others' clockwise along the outline), arcMode?: whole|pie|chord|arc, sides?, isStar?, spokeRatio?, rounded?, randomized?, turns?, divergence?, innerRadius?, angleX?, angleY?: degrees (as addon.shapes.box3d: positive tips the top, or turns the right side, away from you), angleZ?: degrees clockwise in the page, parallelX?, parallelY?, parallelZ?, reset?: bool} → {updated}",
|
||||
has_selection,
|
||||
configure
|
||||
),
|
||||
|
||||
@@ -939,7 +939,10 @@ fn remove_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let painted = super::graph::capture_series_top_paints(d, &top_paint_changed(&before, d, &ids));
|
||||
Ok(count_changed(d, &before, &ids) + usize::from(kept || painted))
|
||||
})?;
|
||||
s.remap_appearance_item(&ids, |a| (!idx.contains(&a)).then(|| a - idx.iter().filter(|i| **i < a).count()));
|
||||
// The rows removed from the active row's object, which need not come first in `ids`.
|
||||
let active = s.active_appearance_item.as_ref().and_then(|a| a.objects.first().copied());
|
||||
let gone = active.and_then(|a| ids.iter().position(|id| *id == a)).and_then(|k| per.get(k)).unwrap_or(&idx);
|
||||
s.remap_appearance_item(&ids, |a| (!gone.contains(&a)).then(|| a - gone.iter().filter(|i| **i < a).count()));
|
||||
ok()
|
||||
}
|
||||
|
||||
@@ -1023,10 +1026,12 @@ fn duplicate_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Rows at or above each copy move up by one (by the block, for copies placed at `to`).
|
||||
let m = idx.len();
|
||||
let landed = active_at.or(first_at);
|
||||
// The rows copied in the active row's object, which need not come first in `ids`.
|
||||
let copied = active.and_then(|a| ids.iter().position(|id| *id == a)).and_then(|k| per.get(k)).unwrap_or(&idx);
|
||||
s.remap_appearance_item(&ids, |a| {
|
||||
Some(match to {
|
||||
Some(_) => a + if landed.is_some_and(|at| at <= a) { m } else { 0 },
|
||||
None => a + idx.iter().filter(|i| **i < a).count(),
|
||||
None => a + copied.iter().filter(|i| **i < a).count(),
|
||||
})
|
||||
});
|
||||
ok()
|
||||
@@ -1184,7 +1189,7 @@ fn target_contents(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.iter()
|
||||
.filter_map(|id| d.node(*id))
|
||||
.filter(|n| matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }))
|
||||
.flat_map(|n| n.children().into_iter().flatten().filter(|c| c.visible && !c.locked).map(|c| c.id))
|
||||
.flat_map(|n| n.children().into_iter().flatten().filter(|c| c.visible && !c.locked && !c.is_ruler_guide()).map(|c| c.id))
|
||||
.collect();
|
||||
if ids.is_empty() {
|
||||
return Err(bad("appearance.targetContents", "select a group or layer with visible, unlocked members"));
|
||||
|
||||
@@ -116,13 +116,15 @@ pub(crate) fn art_under(doc: &vectorcraft_doc::Document, id: NodeId, out: &mut V
|
||||
let Some(n) = doc.node(id) else { return };
|
||||
if n.is_layer() {
|
||||
if let Some(kids) = n.children() {
|
||||
for c in kids {
|
||||
for c in kids.iter().filter(|c| !c.is_ruler_guide()) {
|
||||
art_under(doc, c.id, out);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
out.push(id);
|
||||
if !n.is_ruler_guide() {
|
||||
out.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -213,7 +215,7 @@ fn add_3d(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ids.dedup();
|
||||
for id in &ids {
|
||||
let n = d.node(*id).ok_or_else(|| bad(C, format!("no such object {0}", id.0)))?;
|
||||
if let Err(e) = vectorcraft_render::effects::three_d_meshes(n) {
|
||||
if let Err(e) = vectorcraft_render::effects::three_d_meshes_in(Some(d), n) {
|
||||
let what = n.name.clone().unwrap_or_else(|| format!("#{}", id.0));
|
||||
return Err(bad(C, format!("{what} can't be exported in 3D: {e}")));
|
||||
}
|
||||
@@ -281,7 +283,8 @@ fn export_3d(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
a.nodes.iter().map(|node| d.node(*node).ok_or_else(|| bad(C, format!("no such object {0}", node.0)))).collect::<Result<Vec<_>>>()?;
|
||||
// The asset's objects share one budget of triangles.
|
||||
let what = &a.name;
|
||||
let parts = vectorcraft_render::effects::three_d_meshes_of(nodes).map_err(|e| bad(C, format!("{what} can't be exported in 3D: {e}")))?;
|
||||
let parts =
|
||||
vectorcraft_render::effects::three_d_meshes_of_in(Some(d), nodes).map_err(|e| bad(C, format!("{what} can't be exported in 3D: {e}")))?;
|
||||
let base = Some(safe_file_name(&a.name)).filter(|b| !b.is_empty()).unwrap_or_else(|| "Asset".into());
|
||||
let mut stem = base.clone();
|
||||
let mut k = 1;
|
||||
|
||||
@@ -165,13 +165,14 @@ pub struct BuiltinBrushLibrary {
|
||||
make: fn() -> BrushLibrary,
|
||||
}
|
||||
|
||||
/// The built-in brush libraries, in menu order: original brushes generated in code, one library
|
||||
/// per kind of brush.
|
||||
/// The built-in brush libraries, in menu order: original brushes generated in code.
|
||||
pub const BUILTIN: &[BuiltinBrushLibrary] = &[
|
||||
BuiltinBrushLibrary { id: "art-ribbons", name: "Art Ribbons", make: art_ribbons },
|
||||
BuiltinBrushLibrary { id: "calligraphic-nibs", name: "Calligraphic Nibs", make: calligraphic_nibs },
|
||||
BuiltinBrushLibrary { id: "pattern-borders", name: "Pattern Borders", make: pattern_borders },
|
||||
BuiltinBrushLibrary { id: "scatter-shapes", name: "Scatter Shapes", make: scatter_shapes },
|
||||
BuiltinBrushLibrary { id: "game-trails", name: "Game Trails", make: game_trails },
|
||||
BuiltinBrushLibrary { id: "energy-beams", name: "Energy Beams", make: energy_beams },
|
||||
];
|
||||
|
||||
/// Built-in library `id`, made once.
|
||||
@@ -378,6 +379,73 @@ fn scatter_shapes() -> BrushLibrary {
|
||||
built("Scatter Shapes", doc, brushes)
|
||||
}
|
||||
|
||||
/// Game Trails: art brushes for motion lines and impact streaks (original game HUD / VFX art).
|
||||
fn game_trails() -> BrushLibrary {
|
||||
let speed = {
|
||||
let n = 32;
|
||||
let half = |x: f64| 2.2 * (1.0 - x / 100.0).powf(1.2).max(0.08);
|
||||
let xs = |i: usize| 100.0 * i as f64 / n as f64;
|
||||
let mut pts: Vec<Point> = (0..=n).map(xs).map(|x| Point::new(x, -half(x))).collect();
|
||||
pts.extend((0..n).rev().map(xs).map(|x| Point::new(x, half(x))));
|
||||
PathData::single(SubPath::polyline(&pts, true))
|
||||
};
|
||||
let dash = group(vec![
|
||||
black(shapes::rounded_rectangle(Rect::new(0.0, -1.0, 14.0, 1.0), 1.0)),
|
||||
black(shapes::rounded_rectangle(Rect::new(22.0, -1.0, 36.0, 1.0), 1.0)),
|
||||
black(shapes::rounded_rectangle(Rect::new(44.0, -1.0, 58.0, 1.0), 1.0)),
|
||||
black(shapes::rounded_rectangle(Rect::new(66.0, -1.0, 80.0, 1.0), 1.0)),
|
||||
black(shapes::rounded_rectangle(Rect::new(88.0, -1.0, 100.0, 1.0), 1.0)),
|
||||
]);
|
||||
let burst = {
|
||||
// A wedge that flares from a tip at x=0 to a wide end at x=100.
|
||||
let tip = Point::new(0.0, 0.0);
|
||||
let top = Point::new(100.0, -6.0);
|
||||
let bot = Point::new(100.0, 6.0);
|
||||
PathData::single(SubPath::polyline(&[tip, top, bot], true))
|
||||
};
|
||||
let zigzag = band(100.0, 1.8, 40, |x| {
|
||||
let t = (x / 12.5).floor();
|
||||
let local = (x / 12.5) - t;
|
||||
if (t as i32) % 2 == 0 { -3.0 + 6.0 * local } else { 3.0 - 6.0 * local }
|
||||
});
|
||||
let brushes = vec![
|
||||
art("Speed Line", black(speed), Colorization::Tints),
|
||||
art("Dash Trail", dash, Colorization::Tints),
|
||||
art("Burst Ray", black(burst), Colorization::Tints),
|
||||
art("Zigzag", black(zigzag), Colorization::Tints),
|
||||
];
|
||||
built("Game Trails", Document::new(100.0, 100.0), brushes)
|
||||
}
|
||||
|
||||
/// Energy Beams: art brushes for laser, plasma and charge strokes (original game VFX art).
|
||||
fn energy_beams() -> BrushLibrary {
|
||||
let laser = {
|
||||
let n = 32;
|
||||
let half = |x: f64| 1.6 * (1.0 - (x / 100.0 - 0.5).abs() * 1.6).max(0.12);
|
||||
let xs = |i: usize| 100.0 * i as f64 / n as f64;
|
||||
let mut pts: Vec<Point> = (0..=n).map(xs).map(|x| Point::new(x, -half(x))).collect();
|
||||
pts.extend((0..n).rev().map(xs).map(|x| Point::new(x, half(x))));
|
||||
PathData::single(SubPath::polyline(&pts, true))
|
||||
};
|
||||
let plasma = band(100.0, 2.2, 48, |x| 4.0 * (x / 100.0 * std::f64::consts::PI).sin());
|
||||
let charge = band(100.0, 1.6, 56, |x| 3.5 * (x / 100.0 * std::f64::consts::TAU).sin());
|
||||
let spark = group(vec![
|
||||
black(shapes::rounded_rectangle(Rect::new(0.0, -1.2, 10.0, 1.2), 0.8)),
|
||||
black(shapes::rounded_rectangle(Rect::new(18.0, -0.9, 28.0, 0.9), 0.7)),
|
||||
black(shapes::rounded_rectangle(Rect::new(36.0, -1.4, 48.0, 1.4), 0.9)),
|
||||
black(shapes::rounded_rectangle(Rect::new(56.0, -0.8, 66.0, 0.8), 0.6)),
|
||||
black(shapes::rounded_rectangle(Rect::new(74.0, -1.1, 86.0, 1.1), 0.8)),
|
||||
black(shapes::rounded_rectangle(Rect::new(92.0, -0.7, 100.0, 0.7), 0.5)),
|
||||
]);
|
||||
let brushes = vec![
|
||||
art("Laser Bolt", black(laser), Colorization::Tints),
|
||||
art("Plasma Arc", black(plasma), Colorization::Tints),
|
||||
art("Charge Wave", black(charge), Colorization::Tints),
|
||||
art("Spark Trail", spark, Colorization::Tints),
|
||||
];
|
||||
built("Energy Beams", Document::new(100.0, 100.0), brushes)
|
||||
}
|
||||
|
||||
// ---------- commands ----------
|
||||
|
||||
fn builtin_info(b: &BuiltinBrushLibrary) -> LibraryInfo {
|
||||
@@ -458,7 +526,7 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// nothing to apply is no edit, and a click that only applies brushes the document has is
|
||||
// named for that.
|
||||
let mut probe = (*st.doc).clone();
|
||||
let imported = clip.import_into(&mut probe, &choices, false);
|
||||
let imported = clip.import_into(&mut probe, &choices, false)?;
|
||||
let doc_names: Vec<String> = picked.iter().map(|n| target(&imported.renamed, n)).collect();
|
||||
let paths = if apply { brush_targets(&st.doc, &roots(s, p)?) } else { vec![] };
|
||||
let paint = stroke_paint(s);
|
||||
|
||||
@@ -144,7 +144,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Brush Options…",
|
||||
[],
|
||||
None,
|
||||
"{name, params?: {…fields to change (brush.get shows them; values outside their ranges are clamped). calligraphic: angle −180..180°, roundness 0..100%, size 0..1296 pt, variation: [angle°, roundness%, size pt], modes: [fixed|random|pressure|tilt|bearing|rotation for angle, roundness, size] (pressure goes from value − variation at the lightest pen pressure to value + variation at the heaviest; tilt from value − variation with the pen lying flat to value + variation with it upright; bearing and rotation add variation × the pen's bearing (which way it leans, counter-clockwise from leaning right) or its barrel's counter-clockwise rotation over 180°; the angle turns on past ±180°). scatter: size [a, b] 1..10000%, spacing [a, b] 1..10000%, scatter [a, b] −1000..1000%, rotation [a, b] −180..180°, modes: [fixed|random|pressure|tilt|bearing|rotation for size, spacing, scatter, rotation] (fixed: a; random: between a and b; pressure: a at the lightest pen pressure to b at the heaviest; tilt: a with the pen lying flat to b upright; bearing and rotation: a at −180° to b at 180°), rotation_relative_to_path. art: width 1..1000%, width_mode: null (fixed)|pressure, width_max 1..1000% (pressure: width at the lightest pen pressure to width_max at the heaviest, half way on a stroke drawn without pressure), scale: {mode: proportional|stretch|betweenGuides, start, end: 0..1 of the art length}, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flip_along, flip_across, overlap. pattern: scale 1..10000%, spacing 0..10000%, fit: stretch|addSpace|approximate, flip_along, flip_across, outer_corner and inner_corner: corner tile art (drawn as the corner that joins a side tile on its right to one below it) or null, outer_source and inner_source: what that kind of corner draws without its art: none|autoCentered|autoBetween|autoSliced|autoOverlap (a tile made from the side tile), or null (the other kind of corner's art); side_tile, outer_corner_tile, inner_corner_tile, start_tile, end_tile: a document pattern name, original, or none (side refuses none); corner tiles also take autoCentered|autoBetween|autoSliced|autoOverlap. Each tile also accepts {pattern: name} to select a swatch whose name is a reserved choice. scatter, art and pattern: colorization: {method: none|tints|tintsAndShades|hueShift, key: colour (hueShift: the art colour that becomes the stroke colour; without a key: the solid color the art's fills and strokes use most, black when they use none)}; colorization and the art brush's scale change only the fields given}, newName? (trimmed; blank: the name is kept; a new bristle brush name has at most 31 characters, and a longer name the brush has is kept)} edit a brush definition (strokes using it update) → {name}",
|
||||
"{name, params?: {…fields to change (brush.get shows them; values outside their ranges are clamped). calligraphic: angle −180..180° (counter-clockwise from level), roundness 0..100%, size 0..1296 pt, variation: [angle°, roundness%, size pt], modes: [fixed|random|pressure|tilt|bearing|rotation for angle, roundness, size] (pressure goes from value − variation at the lightest pen pressure to value + variation at the heaviest; tilt from value − variation with the pen lying flat to value + variation with it upright; bearing and rotation add variation × the pen's bearing (which way it leans, counter-clockwise from leaning right) or its barrel's counter-clockwise rotation over 180°; the angle turns on past ±180°). scatter: size [a, b] 1..10000%, spacing [a, b] 1..10000%, scatter [a, b] −1000..1000%, rotation [a, b] −180..180° (counter-clockwise), modes: [fixed|random|pressure|tilt|bearing|rotation for size, spacing, scatter, rotation] (fixed: a; random: between a and b; pressure: a at the lightest pen pressure to b at the heaviest; tilt: a with the pen lying flat to b upright; bearing and rotation: a at −180° to b at 180°), rotation_relative_to_path. art: width 1..1000%, width_mode: null (fixed)|pressure, width_max 1..1000% (pressure: width at the lightest pen pressure to width_max at the heaviest, half way on a stroke drawn without pressure), scale: {mode: proportional|stretch|betweenGuides, start, end: 0..1 of the art length}, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flip_along, flip_across, overlap. pattern: scale 1..10000%, spacing 0..10000%, fit: stretch|addSpace|approximate, flip_along, flip_across, outer_corner and inner_corner: corner tile art (drawn as the corner that joins a side tile on its right to one below it) or null, outer_source and inner_source: what that kind of corner draws without its art: none|autoCentered|autoBetween|autoSliced|autoOverlap (a tile made from the side tile), or null (the other kind of corner's art); side_tile, outer_corner_tile, inner_corner_tile, start_tile, end_tile: a document pattern name, original, or none (side refuses none); corner tiles also take autoCentered|autoBetween|autoSliced|autoOverlap. Each tile also accepts {pattern: name} to select a swatch whose name is a reserved choice. scatter, art and pattern: colorization: {method: none|tints|tintsAndShades|hueShift, key: colour (hueShift: the art colour that becomes the stroke colour; without a key: the solid color the art's fills and strokes use most, black when they use none)}; colorization and the art brush's scale change only the fields given}, newName? (trimmed; blank: the name is kept; a new bristle brush name has at most 31 characters, and a longer name the brush has is kept)} edit a brush definition (strokes using it update) → {name}",
|
||||
has_doc,
|
||||
brush_options
|
||||
),
|
||||
@@ -208,7 +208,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Break Link to Symbol",
|
||||
[],
|
||||
None,
|
||||
"{ids?|id?} expand selected instances into plain art (with their appearance changes to a dynamic symbol), placed through the symbol's 9-slice guides when it has them, with the instance's opacity mask → {ids}",
|
||||
"{ids?|id?} expand selected instances into plain art (with their appearance changes to a dynamic symbol), placed through the symbol's 9-slice guides when it has them, with the instance's opacity mask; a hidden or locked instance's art stays hidden or locked and is not selected → {ids}",
|
||||
has_selection,
|
||||
symbol_break_link
|
||||
),
|
||||
|
||||
@@ -137,7 +137,7 @@ fn path_param(p: &Value, art: &Node, cmd: &str) -> Result<Vec<u32>> {
|
||||
/// stroke has a Convert to Shape or Outline Object effect.
|
||||
fn paint_refusal(n: &Node, key: &str, cmd: &str) -> Option<EngineError> {
|
||||
Some(match &n.kind {
|
||||
NodeKind::Path { guide: true, .. } => bad(cmd, format!("{key} cannot be set on a guide")),
|
||||
NodeKind::Path { guide: true, .. } | NodeKind::RulerGuide { .. } => bad(cmd, format!("{key} cannot be set on a guide")),
|
||||
NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_) | NodeKind::Group { .. } | NodeKind::Layer { .. } => return None,
|
||||
NodeKind::Image(_) => {
|
||||
let effects = if key == "fill" { n.appearance.fill().map(|f| &f.effects) } else { n.appearance.stroke().map(|s| &s.effects) };
|
||||
|
||||
@@ -390,10 +390,11 @@ fn expanded_bound(d: &Document, inst: &Node, own: bool) -> u64 {
|
||||
most.saturating_add(1).saturating_add(mask)
|
||||
}
|
||||
|
||||
/// Replace instances `ids` with their art ([`expanded_instance`], `own` as there). Returns the
|
||||
/// art's ids. Fails before any edit when the instances together would make more than
|
||||
/// Replace instances `ids` with their art ([`expanded_instance`], `own` as there). A hidden or
|
||||
/// locked instance's art stays hidden or locked. Returns each replaced instance's id with its art's
|
||||
/// id. Fails before any edit when the instances together would make more than
|
||||
/// [`MAX_EXPAND_NODES`] objects.
|
||||
fn break_links(d: &mut Document, ids: &[NodeId], own: bool) -> Result<Vec<NodeId>> {
|
||||
fn break_links(d: &mut Document, ids: &[NodeId], own: bool) -> Result<Vec<(NodeId, NodeId)>> {
|
||||
let total = ids.iter().filter_map(|id| d.node(*id)).fold(0u64, |sum, n| sum.saturating_add(expanded_bound(d, n, own)));
|
||||
if total > MAX_EXPAND_NODES as u64 {
|
||||
return Err(EngineError::Other("Too many pieces to make at once".into()));
|
||||
@@ -401,12 +402,14 @@ fn break_links(d: &mut Document, ids: &[NodeId], own: bool) -> Result<Vec<NodeId
|
||||
let mut out = vec![];
|
||||
for id in ids {
|
||||
let Some(inst) = d.node(*id).cloned() else { continue };
|
||||
let Some(art) = expanded_instance(d, &inst, own) else { continue };
|
||||
let Some(mut art) = expanded_instance(d, &inst, own) else { continue };
|
||||
let (par, idx, _) = d.position(*id).ok_or(EngineError::NoNode(*id))?;
|
||||
art.visible &= inst.visible;
|
||||
art.locked |= inst.locked;
|
||||
let aid = art.id;
|
||||
d.remove(*id)?;
|
||||
d.insert(par, idx, art)?;
|
||||
out.push(aid);
|
||||
out.push((*id, aid));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
@@ -491,10 +494,22 @@ pub(super) fn symbol_break_link(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if ids.is_empty() {
|
||||
return Err(EngineError::Other("no symbol instances selected".into()));
|
||||
}
|
||||
let iso = s.doc()?.isolation;
|
||||
let out = s.edit("Break Link to Symbol", |d, sel| {
|
||||
let out = break_links(d, &ids, true)?;
|
||||
sel.set(out.iter().copied());
|
||||
Ok(out)
|
||||
let fresh = d.new_art.get();
|
||||
let made = break_links(d, &ids, true)?;
|
||||
// Art that is hidden or locked, or in a hidden or locked group or layer, is left out of the
|
||||
// selection, as is art on a locked artboard unless the instance it replaces was new art.
|
||||
let locks = d.locked_artboards();
|
||||
sel.set(made.iter().filter_map(|(inst, art)| {
|
||||
let keep = if fresh.contains(inst) {
|
||||
crate::cmd::select::is_selectable_on_any_artboard(d, iso, *art)
|
||||
} else {
|
||||
crate::cmd::select::is_selectable(d, iso, &locks, *art)
|
||||
};
|
||||
keep.then_some(*art)
|
||||
}));
|
||||
Ok(made.into_iter().map(|(_, art)| art).collect::<Vec<_>>())
|
||||
})?;
|
||||
Ok(json!({ "ids": ids_json(&out) }))
|
||||
}
|
||||
|
||||
@@ -210,6 +210,24 @@ pub(crate) fn extract_direct_selection(node: &Node, selection: &Selection) -> Op
|
||||
if pieces.is_empty() { None } else { Some(pieces) }
|
||||
}
|
||||
|
||||
/// Does Copy take `node` unchanged under direct selection, as [`extract_direct_selection`]
|
||||
/// returning `None` means: no anchors or segments of it are picked, it is not a path, or the
|
||||
/// picked ones cover every segment?
|
||||
pub(crate) fn copied_whole(node: &Node, selection: &Selection) -> bool {
|
||||
let seg_refs = selection.segments.get(&node.id);
|
||||
let anchor_refs = selection.anchors.get(&node.id);
|
||||
if seg_refs.is_none() && anchor_refs.is_none() {
|
||||
return true;
|
||||
}
|
||||
let Some(path) = node.path_data() else { return true };
|
||||
let (mut total_segs, mut total_selected) = (0, 0);
|
||||
for (si, sp) in path.subpaths.iter().enumerate() {
|
||||
total_segs += sp.segment_count();
|
||||
total_selected += subpath_selected_segments(sp, si, seg_refs, anchor_refs).len();
|
||||
}
|
||||
total_selected == 0 || total_selected == total_segs
|
||||
}
|
||||
|
||||
impl Clipboard {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.nodes.is_empty() && self.artboards.is_empty() && self.guides.is_empty()
|
||||
@@ -234,15 +252,47 @@ impl Clipboard {
|
||||
let d = &*st.doc;
|
||||
let mut nodes = vec![];
|
||||
let mut source_layers = vec![];
|
||||
// Each compound's bounds, measured once however many of its members are copied.
|
||||
let mut compound_bounds: std::collections::HashMap<NodeId, Option<vectorcraft_geom::Rect>> = std::collections::HashMap::new();
|
||||
for id in roots {
|
||||
let (Some(n), Some(anc)) = (d.node(*id), d.ancestry(*id)) else { continue };
|
||||
let layer_name = anc.iter().rev().skip(1).find_map(|a| d.node(*a).filter(|l| l.is_layer())).and_then(|l| l.name.clone());
|
||||
let compound_parent = anc.iter().rev().skip(1).find_map(|a| d.node(*a).filter(|p| matches!(p.kind, NodeKind::Compound { .. })));
|
||||
// Gradients fitted to the compound's bounds are pinned there, as a split pins them.
|
||||
let gradient_box = compound_parent
|
||||
.filter(|p| p.appearance.has_unplaced_gradient())
|
||||
.and_then(|p| *compound_bounds.entry(p.id).or_insert_with(|| p.geometric_bounds()));
|
||||
// A compound's member or piece copied on its own takes the compound's look (its paint,
|
||||
// transparency, opacity mask, pixel alignment and fill rule), so it pastes as it looked.
|
||||
// It is linked to the compound's graphic style while it still has that style's look
|
||||
// (a gradient pinned to the compound's bounds does not), else to none.
|
||||
let inherit_appearance = |mut piece: Node| {
|
||||
if let Some(parent) = compound_parent
|
||||
&& piece.appearance.items.is_empty()
|
||||
&& let (NodeKind::Compound { rule: compound_rule, .. }, NodeKind::Path { rule, .. }) = (&parent.kind, &mut piece.kind)
|
||||
{
|
||||
*rule = *compound_rule;
|
||||
piece.appearance = parent.appearance.clone();
|
||||
if let Some(bounds) = gradient_box {
|
||||
piece.appearance.pin_gradients(bounds);
|
||||
}
|
||||
piece.opacity = parent.opacity;
|
||||
piece.blend = parent.blend;
|
||||
piece.isolate = parent.isolate;
|
||||
piece.knockout = parent.knockout;
|
||||
piece.knockout_shape = parent.knockout_shape;
|
||||
let (mask, pixel_aligned) = (parent.extras().mask.clone(), parent.extras().pixel_aligned);
|
||||
{
|
||||
let mut extras = piece.extras_mut();
|
||||
extras.mask = mask;
|
||||
extras.pixel_aligned = pixel_aligned;
|
||||
}
|
||||
let link = parent
|
||||
.extras()
|
||||
.graphic_style
|
||||
.and_then(|g| d.graphic_style_by_id(g))
|
||||
.filter(|g| super::style::in_sync(&piece, g))
|
||||
.map(|g| g.id);
|
||||
piece.extras_mut().graphic_style = link;
|
||||
}
|
||||
piece
|
||||
};
|
||||
@@ -272,7 +322,18 @@ impl Clipboard {
|
||||
/// Every object of `src` (each top-level layer's contents) with its resources, for Place and
|
||||
/// pasting outside SVG. Their layers don't count for Paste Remembers Layers.
|
||||
pub fn from_document(src: &Document) -> Self {
|
||||
let nodes = src.layers.iter().flat_map(|l| l.children().into_iter().flatten()).map(|n| (**n).clone()).collect();
|
||||
// Ruler guides are not objects: the ones of `src`'s layers and sublayers stay behind.
|
||||
let nodes = src
|
||||
.layers
|
||||
.iter()
|
||||
.flat_map(|l| l.children().into_iter().flatten())
|
||||
.filter(|n| !n.is_ruler_guide())
|
||||
.map(|n| {
|
||||
let mut n = (**n).clone();
|
||||
n.strip_ruler_guides();
|
||||
n
|
||||
})
|
||||
.collect();
|
||||
Self::gather(src, nodes)
|
||||
}
|
||||
|
||||
|
||||
@@ -21,14 +21,14 @@ use serde_json::{Map, Value, json};
|
||||
use vectorcraft_brush::Brush;
|
||||
use vectorcraft_color::{Color, Paint, Swatch};
|
||||
use vectorcraft_doc::{
|
||||
Appearance, AppearanceItem, Document, GraphicStyle, Node, NodeKind, PatternDef, Symbol, SymbolExportType, SymbolNineSlice, SymbolRegistration,
|
||||
TextStyleDef,
|
||||
Appearance, AppearanceItem, Document, Effect, GraphicStyle, Node, NodeKind, PatternDef, Symbol, SymbolExportType, SymbolNineSlice,
|
||||
SymbolRegistration, TextStyleDef,
|
||||
};
|
||||
|
||||
use super::super::brushsym::{set_natural_size, stored_natural_size};
|
||||
|
||||
use super::super::patterncmds::add_pattern;
|
||||
use super::super::{Result, bad, unique_name};
|
||||
use super::super::{EngineError, Result, bad, unique_name};
|
||||
use super::Clipboard;
|
||||
|
||||
/// A resource kind objects refer to (graphic styles by id, the others by name).
|
||||
@@ -140,6 +140,48 @@ fn node_refs(n: &Node, f: Visit) {
|
||||
});
|
||||
}
|
||||
|
||||
/// The `(name, xml)` of `e` when it is an SVG filter effect.
|
||||
fn filter_of(e: &Effect) -> Option<(String, String)> {
|
||||
let text = |k: &str| e.params.get(k).and_then(Value::as_str).map(str::to_string);
|
||||
if e.id != Appearance::SVG_FILTER {
|
||||
return None;
|
||||
}
|
||||
Some((text("name")?, text("xml")?))
|
||||
}
|
||||
|
||||
/// The SVG filter effects of appearance `a` (its own and its fills' and strokes').
|
||||
fn appearance_filters(a: &Appearance, out: &mut BTreeSet<(String, String)>) {
|
||||
let items = a.items.iter().flat_map(|i| i.effects().iter());
|
||||
out.extend(a.effects.iter().chain(items).filter_map(filter_of));
|
||||
}
|
||||
|
||||
/// The SVG filter effects in the subtree of `n` (opacity-mask art included).
|
||||
fn node_filters(n: &Node, out: &mut BTreeSet<(String, String)>) {
|
||||
n.walk(&mut |m| {
|
||||
appearance_filters(&m.appearance, out);
|
||||
if let Some(mask) = &m.extras().mask {
|
||||
node_filters(&mask.art, out);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The names the SVG filter effects of appearance `a` give (its own and its fills' and strokes').
|
||||
fn appearance_filter_names(a: &Appearance, out: &mut BTreeSet<String>) {
|
||||
let items = a.items.iter().flat_map(|i| i.effects().iter());
|
||||
let named = a.effects.iter().chain(items).filter(|e| e.id == Appearance::SVG_FILTER);
|
||||
out.extend(named.filter_map(|e| e.params.get("name").and_then(Value::as_str)).map(str::to_string));
|
||||
}
|
||||
|
||||
/// The names the SVG filter effects in the subtree of `n` give (opacity-mask art included).
|
||||
fn node_filter_names(n: &Node, out: &mut BTreeSet<String>) {
|
||||
n.walk(&mut |m| {
|
||||
appearance_filter_names(&m.appearance, out);
|
||||
if let Some(mask) = &m.extras().mask {
|
||||
node_filter_names(&mask.art, out);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// A global or spot colour of its own (not a tint of another swatch): what colours link to.
|
||||
fn is_base(w: &Swatch) -> bool {
|
||||
(w.global || w.spot) && matches!(w.paint, Paint::Solid { swatch: None, .. })
|
||||
@@ -527,6 +569,9 @@ impl SwatchChoices {
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct Relink {
|
||||
renames: BTreeMap<Res, BTreeMap<String, String>>,
|
||||
/// An SVG filter effect's `(name, xml)` on the clipboard → its `(name, xml)` in the document
|
||||
/// ([`Clipboard::filter_links`]).
|
||||
filters: BTreeMap<(String, String), (String, String)>,
|
||||
/// Graphic style id on the clipboard → its id in the document. Links to other ids are dropped.
|
||||
styles: BTreeMap<u32, u32>,
|
||||
/// Swatch (its name in the document) → the colour linked colours show at 100 %.
|
||||
@@ -566,6 +611,15 @@ impl Relink {
|
||||
});
|
||||
}
|
||||
fn appearance(&self, a: &mut Appearance) {
|
||||
if !self.filters.is_empty() {
|
||||
let items = a.items.iter_mut().flat_map(|i| i.effects_mut().iter_mut());
|
||||
for e in a.effects.iter_mut().chain(items) {
|
||||
if let Some((name, xml)) = filter_of(e).and_then(|k| self.filters.get(&k)) {
|
||||
e.params["name"] = json!(name);
|
||||
e.params["xml"] = json!(xml);
|
||||
}
|
||||
}
|
||||
}
|
||||
for it in &mut a.items {
|
||||
self.paint(it.paint_mut());
|
||||
if let AppearanceItem::Stroke(l) = it
|
||||
@@ -1001,8 +1055,9 @@ impl Clipboard {
|
||||
}
|
||||
|
||||
/// Add the resources the objects use to `d` (inside the paste's undo step) and work out how
|
||||
/// pasted copies relink to them.
|
||||
pub(crate) fn import_into(&self, d: &mut Document, choices: &SwatchChoices, same_doc: bool) -> Imported {
|
||||
/// pasted copies relink to them. An error, with `d` unchanged, when a pasted SVG filter finds no
|
||||
/// free name ([`Self::filter_links`]).
|
||||
pub(crate) fn import_into(&self, d: &mut Document, choices: &SwatchChoices, same_doc: bool) -> Result<Imported> {
|
||||
self.import_used(d, &self.used(), choices, same_doc, false)
|
||||
}
|
||||
|
||||
@@ -1011,8 +1066,9 @@ impl Clipboard {
|
||||
/// each style, symbol, pattern and brush comes after the styles, symbols, patterns and brushes it uses
|
||||
/// ([`use_order`]), so that it is compared and relinked with their names here, and each loop
|
||||
/// of definitions that use each other is matched on its own ([`loop_groups`]).
|
||||
fn import_used(&self, d: &mut Document, used: &Used, choices: &SwatchChoices, same_doc: bool, in_use_order: bool) -> Imported {
|
||||
fn import_used(&self, d: &mut Document, used: &Used, choices: &SwatchChoices, same_doc: bool, in_use_order: bool) -> Result<Imported> {
|
||||
let mut out = Imported::default();
|
||||
out.relink.filters = self.filter_links(d, same_doc)?;
|
||||
self.import_images(d, used, same_doc, &mut out);
|
||||
self.import_swatches(d, used, choices, same_doc, &mut out);
|
||||
// Brushes whose art shows no symbols or patterns, before the symbols and patterns whose art
|
||||
@@ -1170,8 +1226,89 @@ impl Clipboard {
|
||||
}
|
||||
vectorcraft_brush::store(d, &lib);
|
||||
}
|
||||
out.relinks = !(out.relink.renames.is_empty() && out.relink.styles.is_empty() && out.relink.colors.is_empty() && used.styles.is_empty());
|
||||
out
|
||||
out.relinks = !(out.relink.renames.is_empty()
|
||||
&& out.relink.filters.is_empty()
|
||||
&& out.relink.styles.is_empty()
|
||||
&& out.relink.colors.is_empty()
|
||||
&& used.styles.is_empty());
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// How the pasted SVG filter effects relink ([`Relink::filters`]). Each names the document's
|
||||
/// filter of its name; one that draws another definition than that filter takes the document's
|
||||
/// filter when pasted back into the document it came from, and elsewhere keeps its own under a
|
||||
/// name no filter or effect of the document gives, so editing or deleting the document's filter leaves it alone. When that name would
|
||||
/// make the filter too long to draw, the free name is a shorter start of its name ("Shif"), else
|
||||
/// a free letter, digit or other printable ASCII character; the filter's id is always its name. An error when no free name fits.
|
||||
fn filter_links(&self, d: &Document, same_doc: bool) -> Result<BTreeMap<(String, String), (String, String)>> {
|
||||
let mut found = BTreeSet::new();
|
||||
for n in &self.nodes {
|
||||
node_filters(n, &mut found);
|
||||
}
|
||||
for s in &self.symbols {
|
||||
node_filters(&s.art, &mut found);
|
||||
}
|
||||
for a in self.patterns.iter().flat_map(|p| p.art.iter()) {
|
||||
node_filters(a, &mut found);
|
||||
}
|
||||
for a in self.brushes.iter().flat_map(Brush::art) {
|
||||
node_filters(a, &mut found);
|
||||
}
|
||||
for g in &self.graphic_styles {
|
||||
appearance_filters(&g.appearance, &mut found);
|
||||
}
|
||||
let mut taken: BTreeSet<String> = d.svg_filters.iter().map(|f| f.name.clone()).chain(found.iter().map(|(n, _)| n.clone())).collect();
|
||||
let mut effects_read = false;
|
||||
let mut links = BTreeMap::new();
|
||||
for (name, xml) in found {
|
||||
let Some(held) = d.svg_filters.iter().find(|f| f.name == name).filter(|f| f.xml != xml.trim()) else { continue };
|
||||
let to = if same_doc {
|
||||
(name.clone(), held.xml.clone())
|
||||
} else {
|
||||
// The names the document's effects give are taken too, those whose filter was
|
||||
// deleted included: defining a filter of that name later changes every effect that
|
||||
// names it. They are read at the first rename only.
|
||||
if !effects_read {
|
||||
effects_read = true;
|
||||
for root in d.art_roots() {
|
||||
node_filter_names(root, &mut taken);
|
||||
}
|
||||
for a in vectorcraft_brush::library(d).iter().flat_map(Brush::art) {
|
||||
node_filter_names(a, &mut taken);
|
||||
}
|
||||
for g in &d.graphic_styles {
|
||||
appearance_filter_names(&g.appearance, &mut taken);
|
||||
}
|
||||
}
|
||||
// Free names made from the starts of the name, longest first, then free letters and
|
||||
// digits, then other single printable ASCII characters: the first under which the
|
||||
// filter still draws.
|
||||
let mut ends: Vec<usize> = name.char_indices().map(|(i, _)| i).skip(1).collect();
|
||||
ends.push(name.len());
|
||||
let starts = ends.iter().rev().filter_map(|&end| name.get(..end).map(str::trim_end)).filter(|b| !b.is_empty());
|
||||
let shorter = starts.map(|b| unique_name(b, |n| taken.contains(n)));
|
||||
let single = ('a'..='z')
|
||||
.chain('0'..='9')
|
||||
.chain('A'..='Z')
|
||||
.chain(('!'..='~').filter(|c| !c.is_ascii_alphanumeric()))
|
||||
.map(String::from)
|
||||
.filter(|n| !taken.contains(n));
|
||||
let renamed = shorter.chain(single).find_map(|new| {
|
||||
let drawn = vectorcraft_render::effects::svg_filter::with_id(&xml, &new)
|
||||
.filter(|x| vectorcraft_render::effects::svg_filter::check(x).is_ok_and(|id| id == new))?;
|
||||
Some((new, drawn))
|
||||
});
|
||||
let Some((new, drawn)) = renamed else {
|
||||
return Err(EngineError::Other(format!(
|
||||
"the pasted SVG filter {name:?} draws differently from the document's filter of that name, and no free name fits it"
|
||||
)));
|
||||
};
|
||||
taken.insert(new.clone());
|
||||
(new, drawn)
|
||||
};
|
||||
links.insert((name, xml), to);
|
||||
}
|
||||
Ok(links)
|
||||
}
|
||||
|
||||
/// Image blobs: a different blob under a key the document uses gets a new key.
|
||||
@@ -1286,13 +1423,13 @@ pub(crate) struct LoadedStyle {
|
||||
/// a pattern added under a free name and a pattern follows a style renamed on the way in. A style
|
||||
/// `d` has under its name, or a numbered copy of it ("Body 2"), that is the same once relinked is
|
||||
/// used; any other is added under a free name. → each style of `src` placed, in order, character
|
||||
/// styles first.
|
||||
pub(crate) fn import_text_styles(src: &Document, d: &mut Document, chars: bool, paras: bool) -> Vec<LoadedStyle> {
|
||||
/// styles first. An error, as [`Clipboard::import_into`] gives.
|
||||
pub(crate) fn import_text_styles(src: &Document, d: &mut Document, chars: bool, paras: bool) -> Result<Vec<LoadedStyle>> {
|
||||
let library = OnceCell::new();
|
||||
let used = Used::of_text_styles(&Defs::of(src, &library), chars, paras);
|
||||
let clip = Clipboard::with_used(src, &used, &library, vec![]);
|
||||
let before = [&d.char_styles, &d.para_styles].map(|l| l.iter().map(|s| s.name.clone()).collect::<BTreeSet<_>>());
|
||||
let out = clip.import_used(d, &used, &SwatchChoices::default(), false, true);
|
||||
let out = clip.import_used(d, &used, &SwatchChoices::default(), false, true)?;
|
||||
let mut placed: Vec<LoadedStyle> = vec![];
|
||||
for (para, r, list) in [(false, Res::CharStyle, &clip.char_styles), (true, Res::ParaStyle, &clip.para_styles)] {
|
||||
for s in list {
|
||||
@@ -1302,7 +1439,7 @@ pub(crate) fn import_text_styles(src: &Document, d: &mut Document, chars: bool,
|
||||
placed.push(LoadedStyle { para, name, added });
|
||||
}
|
||||
}
|
||||
placed
|
||||
Ok(placed)
|
||||
}
|
||||
|
||||
/// Swatch `w` in `d` ([`existing`]), else added under a free name (swatches and colour groups
|
||||
|
||||
@@ -462,7 +462,7 @@ fn run_pass(s: &mut Session, p: &Value, cmd: &str, label: &str, pass: Recolor) -
|
||||
pub(crate) fn left_out(d: &Document, iso: Option<NodeId>, roots: &[NodeId]) -> Vec<(NodeId, bool)> {
|
||||
fn visit(n: &Node, hidden: bool, locked: bool, all: &HashSet<NodeId>, board_locked: &dyn Fn(&Node) -> bool, out: &mut Vec<(NodeId, bool)>) {
|
||||
for c in n.children().into_iter().flatten() {
|
||||
if c.is_template() {
|
||||
if c.is_template() || c.is_ruler_guide() {
|
||||
continue;
|
||||
}
|
||||
let (hidden, locked) = (hidden || !c.visible, locked || c.locked);
|
||||
|
||||
@@ -32,7 +32,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Color Settings…",
|
||||
["Edit"],
|
||||
Some("Cmd+Shift+K"),
|
||||
"{rgb?: profile, cmyk?: profile, intent?: \"perceptual\"|\"relative\"|\"saturation\"|\"absolute\", bpc?: bool} set the working spaces → {rgb, cmyk, intent, bpc, profiles: [{name, kind, builtin}]}",
|
||||
"{rgb?: profile, cmyk?: profile, intent?: \"perceptual\"|\"relative\"|\"saturation\"|\"absolute\", bpc?: bool} set the working spaces → {rgb, cmyk, intent, bpc, profiles: [{name, kind, builtin}]}. The desktop app and vectorcraft-cli keep them in `Color Settings.json` next to the preferences and use them again at the next start (not with VECTORCRAFT_NO_PREFS); saved settings naming a profile that is gone leave the defaults",
|
||||
always,
|
||||
color_settings
|
||||
),
|
||||
@@ -192,6 +192,8 @@ fn color_settings(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
st.bpc = switch_or(p, "bpc", st.bpc, C)?;
|
||||
if st != before {
|
||||
cms::set_active(&st).map_err(|e| bad(C, e.to_string()))?;
|
||||
// Saved for the next start when the app attached a settings file (#6335).
|
||||
s.store_color_settings(&st);
|
||||
touch_all(s);
|
||||
}
|
||||
Ok(settings_json(&st))
|
||||
@@ -580,7 +582,7 @@ fn map_more_colors(n: &mut vectorcraft_doc::Node, f: &mut dyn FnMut(&Color, proo
|
||||
}
|
||||
}
|
||||
if let Some(ch) = n.children_mut() {
|
||||
for c in ch.iter_mut() {
|
||||
for c in ch.iter_mut().filter(|c| !c.is_ruler_guide()) {
|
||||
map_more_colors(Arc::make_mut(c), f);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -478,6 +478,32 @@ fn an_opened_file_measures_its_type_in_its_own_composite_fonts() {
|
||||
assert_eq!(actual.cached_bounds, Some(expected));
|
||||
}
|
||||
|
||||
/// Threaded type read from a file flows through its frames in the file's own composite fonts, when
|
||||
/// no open document defines them.
|
||||
#[test]
|
||||
fn an_opened_file_flows_its_threads_in_its_own_composite_fonts() {
|
||||
let mut s = session();
|
||||
s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Threaded")]})).unwrap();
|
||||
let story = "The quick brown fox jumps over the lazy dog and keeps running. ".repeat(4);
|
||||
let a = s
|
||||
.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "size": 20, "font": "CF Threaded", "area": {"width": 150, "height": 60}}))
|
||||
.unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
let b = s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 300})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
|
||||
s.execute("text.thread.create", &json!({})).unwrap();
|
||||
let first = text(&s, a).plain_text();
|
||||
assert!(!first.is_empty() && first.len() < story.len());
|
||||
let bytes = vectorcraft_format::save_file(&s.doc().unwrap().doc);
|
||||
// A new front document without the font: the font database no longer holds it.
|
||||
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
|
||||
assert!(vectorcraft_text::FontDb::global().composite_font("CF Threaded").is_none());
|
||||
let file = crate::cmd::fileio::read_for_open("threaded.vectorcraft", &bytes, None, &json!({}), false, false).unwrap();
|
||||
crate::cmd::fileio::open_read(&mut s, file).unwrap();
|
||||
assert_eq!(text(&s, a).plain_text(), first);
|
||||
}
|
||||
|
||||
/// A document replaced while in the back (File › Revert) is measured in its own composite fonts
|
||||
/// when it comes to the front, not in the front document's.
|
||||
#[test]
|
||||
|
||||
@@ -17,19 +17,43 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Rectangle",
|
||||
[],
|
||||
None,
|
||||
"{x, y, width, height, radius?: pt, rotation?: deg (about centre)} → {id}",
|
||||
"{x, y, width, height, radius?: pt, rotation?: deg clockwise about the centre (0: upright)} → {id}",
|
||||
has_doc,
|
||||
rectangle
|
||||
),
|
||||
cmd!("shape.ellipse", "Ellipse", [], None, "{x, y, width, height, rotation?: deg (about centre)} → {id}", has_doc, ellipse),
|
||||
cmd!("shape.polygon", "Polygon", [], None, "{cx, cy, radius, sides=6, rotation?: deg} → {id}", has_doc, polygon),
|
||||
cmd!("shape.star", "Star", [], None, "{cx, cy, radius1, radius2, points=5, rotation?: deg} → {id}", has_doc, star),
|
||||
cmd!(
|
||||
"shape.ellipse",
|
||||
"Ellipse",
|
||||
[],
|
||||
None,
|
||||
"{x, y, width, height, rotation?: deg clockwise about the centre (0: upright)} → {id}",
|
||||
has_doc,
|
||||
ellipse
|
||||
),
|
||||
cmd!(
|
||||
"shape.polygon",
|
||||
"Polygon",
|
||||
[],
|
||||
None,
|
||||
"{cx, cy, radius, sides=6, rotation?: deg clockwise (0: a corner straight up)} → {id}",
|
||||
has_doc,
|
||||
polygon
|
||||
),
|
||||
cmd!(
|
||||
"shape.star",
|
||||
"Star",
|
||||
[],
|
||||
None,
|
||||
"{cx, cy, radius1, radius2, points=5, rotation?: deg clockwise (0: a point straight up)} → {id}",
|
||||
has_doc,
|
||||
star
|
||||
),
|
||||
cmd!(
|
||||
"shape.flare",
|
||||
"Flare",
|
||||
[],
|
||||
None,
|
||||
"{cx, cy, diameter=100, opacity=50 (%), brightness=30 (%), growth=20 (%), fuzziness=50 (%), rays=15, longest=300 (%), rayFuzziness=100 (%), rotation=0 (deg, turns the rays clockwise), x2?, y2? (ring end; else pathLength at direction), pathLength=300, rings=10, largest=50 (%), direction=45 (deg), seed?} → {id}",
|
||||
"{cx, cy, diameter=100, opacity=50 (%), brightness=30 (%), growth=20 (%), fuzziness=50 (%), rays=15, longest=300 (%), rayFuzziness=100 (%), rotation=0 (deg, turns the rays clockwise), x2?, y2? (ring end; else pathLength at direction), pathLength=300, rings=10, largest=50 (%), direction=45 (deg counter-clockwise from 3 o'clock: where the rings run from the centre), seed?} → {id}",
|
||||
has_doc,
|
||||
flare
|
||||
),
|
||||
@@ -49,7 +73,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Rectangular Grid",
|
||||
[],
|
||||
None,
|
||||
"{x, y, width, height, rows=5, columns=5, rowSkew=0, columnSkew=0 (−500…500 %: + crowds the horizontal dividers toward the top, the vertical ones toward the right), frame=true (the outside as one rectangle, else its four sides), fill=false (the current fill, else none), rotation?: deg (about centre)} → {id}",
|
||||
"{x, y, width, height, rows=5, columns=5, rowSkew=0, columnSkew=0 (−500…500 %: + crowds the horizontal dividers toward the top, the vertical ones toward the right), frame=true (the outside as one rectangle, else its four sides), fill=false (the current fill, else none), rotation?: deg clockwise about the centre (0: upright)} → {id}",
|
||||
has_doc,
|
||||
rect_grid
|
||||
),
|
||||
@@ -58,7 +82,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Polar Grid",
|
||||
[],
|
||||
None,
|
||||
"{x, y, width, height, concentric=5, radial=5, concentricSkew=0, radialSkew=0 (−500…500 %: + crowds the ellipses toward the outside, the radial dividers clockwise of the top one), compound=false (the ellipses pair up into compound-path rings), fill=false (the current fill, else none), rotation?: deg (about centre)} → {id}",
|
||||
"{x, y, width, height, concentric=5, radial=5, concentricSkew=0, radialSkew=0 (−500…500 %: + crowds the ellipses toward the outside, the radial dividers clockwise of the top one), compound=false (the ellipses pair up into compound-path rings), fill=false (the current fill, else none), rotation?: deg clockwise about the centre (0: upright)} → {id}",
|
||||
has_doc,
|
||||
polar_grid
|
||||
),
|
||||
@@ -85,7 +109,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Create Text",
|
||||
[],
|
||||
None,
|
||||
"{x, y, text, rotation?: deg (about x, y), vertical?: bool = false, size?: pt, font?: family, style? (default Regular, or the family's closest style when it has no Regular), color? (#rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), area?: {width, height, fit?: none|autoHeight|shrinkText, fitMinPercent?}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), justify?: \"auto\" (default: the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\" (point type: aligned on x, so \"center\" centres each line on the point and \"right\" ends it there; text.setStyle changes it later), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, bounds: {x, y, width, height} (the new type's box as document.inspect reports it, after justify), overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits), warning?: text (the font or style given isn't available: the type is drawn in the fallback font or the family's closest style)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on",
|
||||
"{x, y, text, rotation?: deg clockwise about x, y (0: level), vertical?: bool = false, size?: pt, font?: family, style? (default Regular, or the family's closest style when it has no Regular), color? (#rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), area?: {width, height, fit?: none|autoHeight|shrinkText, fitMinPercent?}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), justify?: \"auto\" (default: the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\" (point type: aligned on x, so \"center\" centres each line on the point and \"right\" ends it there; text.setStyle changes it later), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, bounds: {x, y, width, height} (the new type's box as document.inspect reports it, after justify), overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits), warning?: text (the font or style given isn't available: the type is drawn in the fallback font or the family's closest style)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on",
|
||||
has_doc,
|
||||
text_create
|
||||
),
|
||||
|
||||
@@ -3,7 +3,9 @@ use super::{CommandSpec, EngineError, Result, Session, cmd, has_doc};
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::Arc;
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::dimension::{ArrowStyle, Dimension, DimensionMode, DimensionOptions, DimensionSource, LineType, TextAlignment, TextPosition};
|
||||
use vectorcraft_doc::dimension::{
|
||||
ArrowStyle, Dimension, DimensionMode, DimensionOptions, DimensionSource, LineType, Orientation, TextAlignment, TextPosition,
|
||||
};
|
||||
use vectorcraft_doc::{Appearance, Arrowhead, CharStyle, ColorMode, Dash, Document, Node, NodeId, NodeKind, TextObject};
|
||||
use vectorcraft_geom::{Affine, BezPath, PathData, Point, Vec2};
|
||||
|
||||
@@ -14,7 +16,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Create Dimension",
|
||||
[],
|
||||
None,
|
||||
"{mode?: linear|angular|radius|diameter, a?: [x,y], b?: [x,y], vertex?: [x,y], source?: {kind: segment, edge: {node,subpath,segment}}|{kind: angle, first: edge, second: edge}|{kind: circle, node}|{kind: arc, edge}|{kind: points, a?, b?, vertex?: {node,subpath,anchor}}, offset?: pt, options?: {units: in|mm|pt|pc|px|cm|m|ft|yd, precision: 0-6, scale: drawing:real, hideUnits, lineWeight, lineType: solid|dashed|dotted, lineColor: [r,g,b], arrowStyle: filled|open|tick|none, arrowScale: %, extensionWeight, extensionType, extensionColor, extensionOffset, extensionLength, hideExtensions, fontFamily, fontStyle, fontSize, textColor, textPosition: away|between, textAlignment: left|center|right, textOffset}} → {id,label}; linked measurements follow source edits",
|
||||
"{mode?: linear|angular|radius|diameter, a?: [x,y], b?: [x,y], vertex?: [x,y], source?: {kind: segment, edge: {node,subpath,segment}}|{kind: angle, first: edge, second: edge}|{kind: circle, node}|{kind: arc, edge}|{kind: points, a?, b?, vertex?: {node,subpath,anchor}, freeA?, freeB?: a point that is not an anchor, {kind: onSegment, edge: {node,subpath,segment}, t: 0..1 (curve parameter)}|{kind: anchors, node, anchors: [{subpath,anchor,part?: point|in|out (the anchor, default, or its in or out handle),weight}] (2 or 3 points of one path whose weights add up to 1), across?: (2 anchors only: times the first-to-second anchor vector (x,y) turned to (-y,x), which points down when the vector points right)} (an end is an anchor or a free point, not both)}, orientation?: aligned|horizontal|vertical (linear only: the distance between the points, or only its horizontal or vertical part; default aligned), offset?: pt (the line's distance from the first point: along the left normal of a → b when aligned, downward when horizontal, leftward when vertical; negative goes the other way), options?: {units: in|mm|pt|pc|px|cm|m|ft|yd, precision: 0-6, scale: drawing:real, hideUnits, lineWeight, lineType: solid|dashed|dotted, lineColor: [r,g,b], arrowStyle: filled|open|tick|none, arrowScale: %, extensionWeight, extensionType, extensionColor, extensionOffset, extensionLength, hideExtensions, fontFamily, fontStyle, fontSize, textColor, textPosition: away|between, textAlignment: left|center|right, textOffset}} → {id,label}; linked measurements follow source edits",
|
||||
has_doc,
|
||||
create
|
||||
),
|
||||
@@ -23,7 +25,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Update Dimensions",
|
||||
[],
|
||||
None,
|
||||
"{ids?: [dimension ids] (default selection), options?: dimension options, offset?: pt} restyle or move existing dimensions → {ids}",
|
||||
"{ids?: [dimension ids] (default selection), options?: dimension options, orientation?: aligned|horizontal|vertical (linear dimensions only; the line stays on the side of the points it was on), offset?: pt (as in dimension.create)} restyle, turn or move existing dimensions → {ids}",
|
||||
has_doc,
|
||||
update
|
||||
),
|
||||
@@ -92,11 +94,36 @@ fn recipe(s: &Session, p: &Value) -> Result<Dimension> {
|
||||
} else {
|
||||
Point::ZERO
|
||||
};
|
||||
let mut d = Dimension { mode, a, b, vertex, offset: distance(p, 30.0)?, length: a.distance(b), options: options(s, p)?, source, moved: None };
|
||||
let orientation = orientation(p, mode)?.unwrap_or_default();
|
||||
let mut d = Dimension {
|
||||
mode,
|
||||
a,
|
||||
b,
|
||||
vertex,
|
||||
offset: distance(p, 30.0)?,
|
||||
length: a.distance(b),
|
||||
options: options(s, p)?,
|
||||
source,
|
||||
orientation,
|
||||
moved: None,
|
||||
};
|
||||
if mode == DimensionMode::Linear {
|
||||
d.length = d.measured();
|
||||
}
|
||||
d.resolve(&s.doc()?.doc).map_err(error)?;
|
||||
Ok(d)
|
||||
}
|
||||
|
||||
/// The `orientation` param, if given: only a linear dimension is horizontal or vertical.
|
||||
fn orientation(p: &Value, mode: DimensionMode) -> Result<Option<Orientation>> {
|
||||
let Some(v) = p.get("orientation") else { return Ok(None) };
|
||||
let orientation: Orientation = serde_json::from_value(v.clone()).map_err(|_| error("Unknown dimension orientation"))?;
|
||||
if orientation != Orientation::Aligned && mode != DimensionMode::Linear {
|
||||
return Err(error("Only linear dimensions are horizontal or vertical"));
|
||||
}
|
||||
Ok(Some(orientation))
|
||||
}
|
||||
|
||||
fn insert(doc: &mut Document, parent: Option<NodeId>, dim: Dimension) -> Result<NodeId> {
|
||||
let id = doc.alloc_id();
|
||||
let mut group = Node::new(id, NodeKind::Group { children: vec![], clip: false });
|
||||
@@ -134,21 +161,38 @@ fn update(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(error("Select dimensions to update"));
|
||||
}
|
||||
let o = if p.get("options").is_some() { Some(options(s, p)?) } else { None };
|
||||
s.edit("Update Dimensions", |doc, _| {
|
||||
// Every dimension is checked before anything changes, and an update that changes none of
|
||||
// them records no undo step.
|
||||
let mut next = Vec::with_capacity(ids.len());
|
||||
{
|
||||
let doc = &s.doc()?.doc;
|
||||
for id in &ids {
|
||||
if !doc.is_editable(*id) {
|
||||
return Err(error("Dimension is locked or hidden"));
|
||||
}
|
||||
let mut dim = dimension_of(doc, *id)?;
|
||||
let old = dimension_of(doc, *id)?;
|
||||
let mut dim = old.clone();
|
||||
if let Some(o) = &o {
|
||||
dim.options = o.clone();
|
||||
}
|
||||
if let Some(orientation) = orientation(p, dim.mode)? {
|
||||
dim.set_orientation(orientation);
|
||||
}
|
||||
dim.offset = distance(p, dim.offset)?;
|
||||
dim.resolve(doc).map_err(error)?;
|
||||
replace(doc, *id, dim)?;
|
||||
if dim != old {
|
||||
next.push((*id, dim));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
if !next.is_empty() {
|
||||
s.edit("Update Dimensions", |doc, _| {
|
||||
for (id, dim) in next {
|
||||
replace(doc, id, dim)?;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(json!({"ids":ids}))
|
||||
}
|
||||
|
||||
@@ -187,9 +231,10 @@ fn apply_to_all(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn replace(doc: &mut Document, id: NodeId, dim: Dimension) -> Result<()> {
|
||||
let old: Vec<NodeId> = doc.node(id).and_then(Node::children).map(|ch| ch.iter().map(|n| n.id).collect()).unwrap_or_default();
|
||||
let children = artwork(doc, &dim, &old)?;
|
||||
let name = format!("Dimension · {}", dim.label().map_err(error)?);
|
||||
if let Some(n) = doc.node_mut(id) {
|
||||
n.kind = NodeKind::Group { children, clip: false };
|
||||
n.name = Some(format!("Dimension · {}", dim.label().map_err(error)?));
|
||||
n.name = Some(name);
|
||||
n.extras_mut().dimension = Some(Box::new(dim));
|
||||
}
|
||||
Ok(())
|
||||
@@ -227,8 +272,9 @@ impl EndedLink {
|
||||
}
|
||||
|
||||
/// The [`reached`] dimensions become plain groups, whose art does not redraw or follow what it
|
||||
/// measured. A command that replaces art calls this before it changes anything, since replacing
|
||||
/// the art a dimension measures would remove the dimension, and then [`restore_unchanged`].
|
||||
/// measured. A command that replaces art calls this before it changes anything and
|
||||
/// [`restore_unchanged`] after, so that a dimension whose own art the command changed stays a
|
||||
/// plain group.
|
||||
pub(crate) fn end_links(doc: &mut Document, roots: &[NodeId]) -> Vec<EndedLink> {
|
||||
let mut ended = Vec::new();
|
||||
for id in reached(doc, roots) {
|
||||
@@ -241,18 +287,156 @@ pub(crate) fn end_links(doc: &mut Document, roots: &[NodeId]) -> Vec<EndedLink>
|
||||
ended
|
||||
}
|
||||
|
||||
/// The dimensions in `ended` whose art is as it was are live again.
|
||||
pub(crate) fn restore_unchanged(doc: &mut Document, ended: Vec<EndedLink>) {
|
||||
/// The dimensions in `ended` whose own art is as it was are live again. One whose measured art the
|
||||
/// command changed from `before` so that the art no longer fits its link stays where it was, no
|
||||
/// longer linked, as [`refresh`] leaves a dimension whose art was deleted.
|
||||
pub(crate) fn restore_unchanged(before: &Document, doc: &mut Document, ended: Vec<EndedLink>) {
|
||||
for e in ended {
|
||||
if doc.node(e.id).and_then(Node::children).is_some_and(|art| *art == e.art)
|
||||
&& let Some(n) = doc.node_mut(e.id)
|
||||
{
|
||||
n.extras_mut().dimension = Some(Box::new(e.recipe));
|
||||
if !doc.node(e.id).and_then(Node::children).is_some_and(|art| *art == e.art) {
|
||||
continue;
|
||||
}
|
||||
let recipe = if e.source_kept(before, doc) { e.recipe } else { unlinked(&e.recipe) };
|
||||
if let Some(n) = doc.node_mut(e.id) {
|
||||
n.extras_mut().dimension = Some(Box::new(recipe));
|
||||
doc.dimensions = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `old` where it was, no longer linked. A move it had on its own and not yet drawn is kept when
|
||||
/// the moved dimension is valid.
|
||||
fn unlinked(old: &Dimension) -> Dimension {
|
||||
let mut unlinked = old.clone();
|
||||
unlinked.source = None;
|
||||
unlinked.moved = None;
|
||||
if let Some(v) = old.moved {
|
||||
let mut shifted = unlinked.clone();
|
||||
(shifted.a, shifted.b, shifted.vertex) = (shifted.a + v, shifted.b + v, shifted.vertex + v);
|
||||
if shifted.validate().is_ok() {
|
||||
unlinked = shifted;
|
||||
}
|
||||
}
|
||||
unlinked
|
||||
}
|
||||
|
||||
/// The whole dimensions under `roots` that are selected together with art they measure (art under
|
||||
/// `roots`), each one a root or held by groups without fills, strokes or effects of their own.
|
||||
/// Object › Expand, Object › Expand Appearance and Object › Path › Outline Stroke leave their
|
||||
/// lines and label as they are. A group with an appearance of its own changes everything it holds
|
||||
/// when it expands, its dimensions included.
|
||||
pub(crate) fn with_their_art(doc: &Document, roots: &[NodeId]) -> Vec<NodeId> {
|
||||
if !doc.dimensions {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut found = Vec::new();
|
||||
for root in roots {
|
||||
let Some(n) = doc.node(*root) else { continue };
|
||||
n.walk(&mut |c| {
|
||||
if let Some(source) = c.extras().dimension.as_ref().and_then(|d| d.source.as_ref()) {
|
||||
found.push((*root, c.id, source.nodes()));
|
||||
}
|
||||
});
|
||||
}
|
||||
let under_roots = |id: NodeId| doc.ancestry(id).is_some_and(|a| a.iter().any(|a| roots.contains(a)));
|
||||
found
|
||||
.into_iter()
|
||||
.filter(|(root, id, _)| {
|
||||
let Some(ancestry) = doc.ancestry(*id) else { return false };
|
||||
let from = ancestry.iter().position(|a| a == root).unwrap_or(ancestry.len());
|
||||
ancestry.get(from..ancestry.len().saturating_sub(1)).unwrap_or_default().iter().all(|a| doc.node(*a).is_some_and(plain_group))
|
||||
})
|
||||
.filter(|(_, _, measured)| measured.iter().any(|m| under_roots(*m)))
|
||||
.map(|(_, id, _)| id)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A group or layer without fills, strokes or effects of its own.
|
||||
fn plain_group(n: &Node) -> bool {
|
||||
matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) && n.appearance.items.is_empty() && n.appearance.effects.is_empty()
|
||||
}
|
||||
|
||||
/// A dimension [`lift`] took out of the document, with its place, the export assets that list it or
|
||||
/// its parts (with their places in the asset list) and its parts' variable bindings.
|
||||
pub(crate) struct Lifted {
|
||||
parent: Option<NodeId>,
|
||||
index: usize,
|
||||
node: Arc<Node>,
|
||||
assets: Vec<(usize, vectorcraft_doc::assets::ExportAsset)>,
|
||||
bindings: Vec<(NodeId, std::collections::BTreeSet<String>)>,
|
||||
}
|
||||
|
||||
impl Lifted {
|
||||
pub(crate) fn id(&self) -> NodeId {
|
||||
self.node.id
|
||||
}
|
||||
}
|
||||
|
||||
/// Takes the [`with_their_art`] dimensions under `roots` out of `doc`, so a command that replaces
|
||||
/// art does not reach them; [`put_back`] puts them back.
|
||||
pub(crate) fn lift(doc: &mut Document, roots: &[NodeId]) -> Vec<Lifted> {
|
||||
let mut lifted = Vec::new();
|
||||
for id in with_their_art(doc, roots) {
|
||||
let Some((parent, index, _)) = doc.position(id) else { continue };
|
||||
let mut parts = std::collections::BTreeSet::new();
|
||||
if let Some(n) = doc.node(id) {
|
||||
n.walk(&mut |c| {
|
||||
parts.insert(c.id);
|
||||
});
|
||||
}
|
||||
let assets = doc.assets.iter().enumerate().filter(|(_, a)| a.nodes.iter().any(|n| parts.contains(n))).map(|(i, a)| (i, a.clone())).collect();
|
||||
let bindings = doc.variables.bindings.iter().filter(|(n, _)| parts.contains(n)).map(|(n, b)| (*n, b.clone())).collect();
|
||||
if let Ok(node) = doc.remove(id) {
|
||||
lifted.push(Lifted { parent, index, node, assets, bindings });
|
||||
}
|
||||
}
|
||||
lifted
|
||||
}
|
||||
|
||||
/// Each dimension [`lift`] took out, as it was, back in its place in `doc`, with its parts back in
|
||||
/// the export assets that listed them and their variable bindings back: edits made while it was out
|
||||
/// let go of them. A dimension whose group is gone goes on top of the first layer.
|
||||
pub(crate) fn put_back(doc: &mut Document, lifted: Vec<Lifted>) {
|
||||
for l in lifted.into_iter().rev() {
|
||||
let parent = if doc.children(l.parent).is_some() { l.parent } else { doc.layers.first().map(|layer| layer.id) };
|
||||
let Ok(ch) = doc.children_mut(parent) else { continue };
|
||||
let i = if parent == l.parent { l.index.min(ch.len()) } else { ch.len() };
|
||||
ch.insert(i, l.node);
|
||||
doc.dimensions = true;
|
||||
for (at, saved) in l.assets {
|
||||
let there: Vec<NodeId> = saved.nodes.iter().copied().filter(|n| doc.node(*n).is_some()).collect();
|
||||
if let Some(a) = doc.assets.iter_mut().find(|a| a.id == saved.id) {
|
||||
for (k, n) in there.iter().enumerate() {
|
||||
if !a.nodes.contains(n) {
|
||||
let k = k.min(a.nodes.len());
|
||||
a.nodes.insert(k, *n);
|
||||
}
|
||||
}
|
||||
} else if !there.is_empty() {
|
||||
let at = at.min(doc.assets.len());
|
||||
doc.assets.insert(at, vectorcraft_doc::assets::ExportAsset { nodes: there, ..saved });
|
||||
}
|
||||
}
|
||||
for (n, b) in l.bindings {
|
||||
doc.variables.bindings.entry(n).or_insert(b);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `doc` without the [`with_their_art`] dimensions under `roots`, to see what a command that leaves
|
||||
/// them alone would change: `doc` itself when there are none.
|
||||
pub(crate) fn without_kept<'a>(doc: &'a Document, roots: &[NodeId]) -> std::borrow::Cow<'a, Document> {
|
||||
let ids = with_their_art(doc, roots);
|
||||
if ids.is_empty() {
|
||||
return std::borrow::Cow::Borrowed(doc);
|
||||
}
|
||||
let mut view = doc.clone();
|
||||
for id in ids {
|
||||
// A dimension already gone (inside another one removed) needs nothing more.
|
||||
let _ = view.remove(id);
|
||||
}
|
||||
std::borrow::Cow::Owned(view)
|
||||
}
|
||||
|
||||
/// Called inside every edit, so dimensions and their undo history stay in step with the art they
|
||||
/// measure. Returns at once in documents without dimensions.
|
||||
pub(crate) fn refresh(before: &Document, doc: &mut Document) -> Result<bool> {
|
||||
@@ -271,17 +455,27 @@ pub(crate) fn refresh(before: &Document, doc: &mut Document) -> Result<bool> {
|
||||
let mut next = old.clone();
|
||||
let moved = next.moved.take();
|
||||
if next.source.as_ref().is_some_and(|s| s.topology_changed(before, doc)) || next.resolve(doc).is_err() {
|
||||
// A deleted/degenerate source has no valid measurement. Remove its annotation in
|
||||
// this same undo step instead of keeping a silently wrong number.
|
||||
doc.remove(id)?;
|
||||
// The art it measured was deleted, or rebuilt with other anchors, or no longer fits
|
||||
// (an ellipse that was a circle): it stays where it was, no longer linked, in this
|
||||
// same undo step, rather than attaching to a different edge.
|
||||
if replace(doc, id, unlinked(&old)).is_err()
|
||||
&& let Some(n) = doc.node_mut(id)
|
||||
{
|
||||
// Its recipe cannot be drawn again: its art stays as it is, as a plain group.
|
||||
n.extras_mut().dimension = None;
|
||||
}
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
next.keep_gap(&old);
|
||||
if let Some(xf) = next.source.as_ref().and_then(|s| s.reflection(before, doc)) {
|
||||
next.mirror_line(&old, xf * old.handle());
|
||||
}
|
||||
// Moved on its own (the art it measures stayed put): it keeps its new place.
|
||||
if let Some(v) = moved
|
||||
&& (next.a, next.b, next.vertex) == (old.a, old.b, old.vertex)
|
||||
{
|
||||
next.place(next.handle() + v);
|
||||
next.place(old.handle() + v);
|
||||
if next.resolve(doc).is_err() {
|
||||
next = old.clone();
|
||||
next.moved = None;
|
||||
@@ -424,17 +618,19 @@ fn artwork(doc: &mut Document, d: &Dimension, old: &[NodeId]) -> Result<Vec<Arc<
|
||||
text_at = d.vertex + dir * if away { r + gap } else { (r - gap).max(o.font_size * 0.5) };
|
||||
} else {
|
||||
let delta = d.b - d.a;
|
||||
let u = delta / delta.hypot();
|
||||
// A linear dimension measures along its direction (a → b, or the horizontal or vertical axis).
|
||||
let u = if d.mode == DimensionMode::Linear { d.direction() } else { delta / delta.hypot() };
|
||||
let n = Vec2::new(-u.y, u.x);
|
||||
let (a, b) = match d.mode {
|
||||
DimensionMode::Radius => (d.a, d.b),
|
||||
DimensionMode::Diameter => (d.a - delta, d.b),
|
||||
_ => (d.a + n * d.offset, d.b + n * d.offset),
|
||||
_ => d.line_ends(),
|
||||
};
|
||||
// The side of the line facing away from the object.
|
||||
// The side of the line facing away from the object: the side the line is on, seen from
|
||||
// the middle of the measured points.
|
||||
let outward = match d.mode {
|
||||
DimensionMode::Linear => {
|
||||
if d.offset < 0.0 {
|
||||
if (a.midpoint(b) - d.a.midpoint(d.b)).dot(n) < 0.0 {
|
||||
-n
|
||||
} else {
|
||||
n
|
||||
@@ -443,8 +639,16 @@ fn artwork(doc: &mut Document, d: &Dimension, old: &[NodeId]) -> Result<Vec<Arc<
|
||||
_ => n,
|
||||
};
|
||||
if d.mode == DimensionMode::Linear && !o.hide_extensions {
|
||||
// Each extension line runs from its point toward the dimension line, which can lie on
|
||||
// different sides of the two points of a horizontal or vertical dimension.
|
||||
for (p, q) in [(d.a, a), (d.b, b)] {
|
||||
paths.push((line(p + outward * o.extension_offset, q + outward * o.extension_length), appearance(o, mode, true, false)));
|
||||
let side = (q - p).dot(n);
|
||||
let toward = match side {
|
||||
s if s < 0.0 => -n,
|
||||
s if s > 0.0 => n,
|
||||
_ => outward,
|
||||
};
|
||||
paths.push((line(p + toward * o.extension_offset, q + toward * o.extension_length), appearance(o, mode, true, false)));
|
||||
}
|
||||
}
|
||||
angle = u.atan2();
|
||||
|
||||
@@ -71,7 +71,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Liquify",
|
||||
[],
|
||||
None,
|
||||
"{tool: warp|twirl|pucker|bloat|scallop|crystallize|wrinkle, points: [[x,y,pressure?]…] (the brush stroke; pen pressure 0..1, default 1), diameter?|width?, height? (pt, default 100), angle?, intensity? (0..1 or %), usePressure?: bool (each point's pressure is the intensity there), detail? (1..10), simplify?: 0..100, simplifyOn?: bool (default true; both for warp, twirl, pucker, bloat), rate? (twirl °), complexity? (0..15), horizontal?, vertical? (wrinkle, 0..1 or %), affectAnchors?, affectIn?, affectOut?: bool (scallop, crystallize, wrinkle; default true), ids?|id? (default: selection, else every path the brush touches)}",
|
||||
"{tool: warp|twirl|pucker|bloat|scallop|crystallize|wrinkle, points: [[x,y,pressure?]…] (the brush stroke; pen pressure 0..1, default 1), diameter?|width?, height? (pt, default 100), angle? (deg clockwise: the brush's width axis from level), intensity? (0..1 or %), usePressure?: bool (each point's pressure is the intensity there), detail? (1..10), simplify?: 0..100, simplifyOn?: bool (default true; both for warp, twirl, pucker, bloat), rate? (twirl °, positive turns clockwise), complexity? (0..15), horizontal?, vertical? (wrinkle, 0..1 or %), affectAnchors?, affectIn?, affectOut?: bool (scallop, crystallize, wrinkle; default true), ids?|id? (default: selection, else every path the brush touches)}",
|
||||
has_doc,
|
||||
liquify
|
||||
),
|
||||
@@ -80,7 +80,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Puppet Warp",
|
||||
[],
|
||||
None,
|
||||
"{id?|ids? (default: selection), pins: [[x,y]…] (current pin positions), moved: [[x,y]…] (targets, same length), angles?: [deg|null…] (same length: the turn the art takes around each pin, as Alt-dragging around a pin does; null leaves it free), expand?: pt, rest?: bool, auto?: bool (as object.puppetWarp.pins reports; ignored)} as-rigid-as-possible mesh warp of anchors and handles. rest: true = the Puppet Warp tool's pins: `pins` are points on the shape the pins started from (object.puppetWarp.pins), each must be on its mesh, the warp replaces the previous one instead of adding to it, and the pins are kept with the document for Undo (an empty list removes them all, leaving the art as it is)",
|
||||
"{id?|ids? (default: selection), pins: [[x,y]…] (current pin positions), moved: [[x,y]…] (targets, same length), angles?: [deg|null…] (same length: the turn the art takes around each pin, clockwise, as Alt-dragging around a pin does; null leaves it free), expand?: pt, rest?: bool, auto?: bool (as object.puppetWarp.pins reports; ignored)} as-rigid-as-possible mesh warp of anchors and handles. rest: true = the Puppet Warp tool's pins: `pins` are points on the shape the pins started from (object.puppetWarp.pins), each must be on its mesh, the warp replaces the previous one instead of adding to it, and the pins are kept with the document for Undo (an empty list removes them all, leaving the art as it is)",
|
||||
has_doc,
|
||||
puppet_warp
|
||||
),
|
||||
@@ -89,7 +89,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Puppet Warp Pins",
|
||||
[],
|
||||
None,
|
||||
"{id?|ids? (default: selection), expand?: pt} → {ids, pins, moved, angles, expand, rest: true, auto} the Puppet Warp pins on the art: pins = where each sits on the shape they started from, moved = where it is now, angles = the turn it holds (deg, null = free); auto: none placed yet (the tool's automatic pins: the centre and the end of each limb). Change moved/angles (or add/remove pins) and pass it back to object.puppetWarp",
|
||||
"{id?|ids? (default: selection), expand?: pt} → {ids, pins, moved, angles, expand, rest: true, auto} the Puppet Warp pins on the art: pins = where each sits on the shape they started from, moved = where it is now, angles = the turn it holds (deg clockwise, null = free); auto: none placed yet (the tool's automatic pins: the centre and the end of each limb). Change moved/angles (or add/remove pins) and pass it back to object.puppetWarp",
|
||||
has_doc,
|
||||
puppet_pins
|
||||
),
|
||||
|
||||
@@ -263,6 +263,22 @@ fn record_again(s: &mut Session, again: Value) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `d` with each of `ids` that is not in perspective attached to `fallback` as [`persp_edit`] attaches
|
||||
/// it; `d` itself when every one is in perspective already, there is no `fallback`, or one of them
|
||||
/// cannot attach.
|
||||
fn with_fallback<'a>(d: &'a Document, ids: &[NodeId], fallback: Option<Plane>) -> std::borrow::Cow<'a, Document> {
|
||||
let mut g = grid_of(d);
|
||||
let loose: Vec<NodeId> = ids.iter().copied().filter(|id| g.attachment_of(d, *id).is_none()).collect();
|
||||
let Some(plane) = fallback.filter(|_| !loose.is_empty()) else { return std::borrow::Cow::Borrowed(d) };
|
||||
let mut out = d.clone();
|
||||
for id in loose {
|
||||
if attach_in(&mut out, &mut g, id, plane, false, None).is_err() {
|
||||
return std::borrow::Cow::Borrowed(d);
|
||||
}
|
||||
}
|
||||
std::borrow::Cow::Owned(out)
|
||||
}
|
||||
|
||||
fn ids_json(ids: &[NodeId]) -> Value {
|
||||
json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() })
|
||||
}
|
||||
@@ -285,9 +301,11 @@ pub(super) fn persp_move(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let constrain = switch_or(p, "constrain", false, C)?;
|
||||
// `snap_fix` uses Snap to Grid's setting for a `snap` that is not true or false.
|
||||
check_switches(p, &["snap"], C)?;
|
||||
// Shift and Snap to Grid read the objects as they are once those not in perspective are attached.
|
||||
let doc = if perp { std::borrow::Cow::Borrowed(&*s.doc()?.doc) } else { with_fallback(&s.doc()?.doc, &ids, fallback) };
|
||||
// Shift: the constrained end point, read on the first object's plane and depth.
|
||||
let snap_to = if constrain && !perp {
|
||||
let doc = &s.doc()?.doc;
|
||||
let doc = &*doc;
|
||||
let g = grid_of(doc);
|
||||
g.plane_bounds(doc, &ids)
|
||||
.and_then(|(plane, depth, _)| {
|
||||
@@ -305,10 +323,10 @@ pub(super) fn persp_move(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let fix = if perp {
|
||||
None
|
||||
} else {
|
||||
let doc = &s.doc()?.doc;
|
||||
crate::cmd::perspgrid::snap_fix(doc, &ids, p, (from, snap_to))
|
||||
.and_then(|(plane, depth, correction)| Some((plane, grid_of(doc).plane_delta(plane, depth, from, to)?, correction)))
|
||||
crate::cmd::perspgrid::snap_fix(&doc, &ids, p, (from, snap_to))
|
||||
.and_then(|(plane, depth, correction)| Some((plane, grid_of(&doc).plane_delta(plane, depth, from, to)?, correction)))
|
||||
};
|
||||
drop(doc);
|
||||
let snapped = move |pl: Plane, raw: vectorcraft_geom::Vec2| {
|
||||
let raw = fix.filter(|f| f.0 == pl).map_or(raw, |f| f.1);
|
||||
let (direction, dv) =
|
||||
|
||||
@@ -370,7 +370,8 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return;
|
||||
}
|
||||
let k = match &n.kind {
|
||||
NodeKind::Layer { .. } => None,
|
||||
// The Guides row counts paths made into guides; ruler guides are not objects.
|
||||
NodeKind::Layer { .. } | NodeKind::RulerGuide { .. } => None,
|
||||
NodeKind::Group { clip: true, .. } => Some("clipGroups"),
|
||||
NodeKind::Group { .. } => Some("groups"),
|
||||
NodeKind::Path { guide: true, .. } => Some("guides"),
|
||||
|
||||
@@ -666,7 +666,7 @@ fn release_guides(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if (index.is_some() || ids.is_some()) && s.menu.guides_locked {
|
||||
return Err(EngineError::Other("guides are locked".into()));
|
||||
}
|
||||
let (rulers, paths): (Vec<usize>, Vec<NodeId>) = if index.is_some() || ids.is_some() {
|
||||
let (rulers, paths): (Vec<NodeId>, Vec<NodeId>) = if index.is_some() || ids.is_some() {
|
||||
let rulers = match index {
|
||||
Some(v) => vec![editable_guide_index(s, Some(v), C)?],
|
||||
None => vec![],
|
||||
@@ -689,10 +689,10 @@ fn release_guides(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let all = guide_paths(&st.doc);
|
||||
let sel: Vec<NodeId> = all.iter().copied().filter(|id| st.selection.contains(*id)).collect();
|
||||
let rulers: Vec<usize> = if s.menu.guides_locked {
|
||||
let rulers: Vec<NodeId> = if s.menu.guides_locked {
|
||||
vec![]
|
||||
} else {
|
||||
st.selection.guides.iter().copied().filter(|i| st.doc.guides.get(*i).is_some_and(|g| st.doc.guide_editable(g))).collect()
|
||||
st.selection.guides.iter().copied().filter(|id| st.doc.ruler_guide(*id).is_some_and(|g| st.doc.guide_editable(&g))).collect()
|
||||
};
|
||||
let editable = st.doc.editable();
|
||||
if sel.is_empty() && rulers.is_empty() { (vec![], all.into_iter().filter(|id| editable(*id)).collect()) } else { (rulers, sel) }
|
||||
@@ -702,8 +702,8 @@ fn release_guides(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let artboards = st.doc.artboards.iter().map(|a| a.rect).reduce(|a, b| a.union(b));
|
||||
let mut lines = Vec::with_capacity(rulers.len());
|
||||
for &i in &rulers {
|
||||
let Some(g) = st.doc.guides.get(i) else { continue };
|
||||
let span = st.doc.guide_span(g).or_else(|| artboards.map(|r| if g.vertical { (r.y0, r.y1) } else { (r.x0, r.x1) }));
|
||||
let Some(g) = st.doc.ruler_guide(i) else { continue };
|
||||
let span = st.doc.guide_span(&g).or_else(|| artboards.map(|r| if g.vertical { (r.y0, r.y1) } else { (r.x0, r.x1) }));
|
||||
let Some((a, b)) = span else { continue };
|
||||
let (a, b) = if g.vertical { (Point::new(g.pos, a), Point::new(g.pos, b)) } else { (Point::new(a, g.pos), Point::new(b, g.pos)) };
|
||||
let parent = match g.layer.filter(|l| st.doc.node(*l).is_some_and(Node::is_layer)) {
|
||||
@@ -729,7 +729,7 @@ fn release_guides(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
d.insert(parent, usize::MAX, Node::path(id, vectorcraft_geom::shapes::line(a, b), look))?;
|
||||
made.push(id);
|
||||
}
|
||||
d.retain_guides(sel, |i, _| !lines.iter().any(|l| l.0 == i));
|
||||
d.retain_guides(sel, |g| !lines.iter().any(|l| Some(l.0) == g.id));
|
||||
sel.set(paths.iter().copied().chain(made));
|
||||
Ok(())
|
||||
})?;
|
||||
@@ -745,12 +745,12 @@ fn lock_guides(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn clear_guides(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let paths = guide_paths(&st.doc);
|
||||
let n = paths.len() + st.doc.guides.len();
|
||||
let n = paths.len() + st.doc.ruler_guides().len();
|
||||
if n == 0 {
|
||||
return Ok(json!({ "count": 0 }));
|
||||
}
|
||||
s.edit("Clear Guides", |d, _| {
|
||||
d.guides.clear();
|
||||
s.edit("Clear Guides", |d, sel| {
|
||||
d.retain_guides(sel, |_| false);
|
||||
let mut gone = Beside::replacing(d, &paths);
|
||||
for id in &paths {
|
||||
if d.node(*id).is_some() {
|
||||
@@ -780,17 +780,29 @@ fn guide_add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Where new art goes (refused when no layer is shown and unlocked), or that group's layer.
|
||||
None => st.target_parent()?.and_then(|p| if st.doc.node(p).is_some_and(Node::is_layer) { Some(p) } else { st.doc.layer_containing(p) }),
|
||||
};
|
||||
let i = s.edit("New Guide", |d, _| {
|
||||
d.guides.push(Guide { artboard, layer, ..Guide::new(vertical, pos) });
|
||||
Ok(d.guides.len() - 1)
|
||||
})?;
|
||||
let id = s.edit("New Guide", |d, _| Ok(d.add_ruler_guide(&Guide { artboard, layer, ..Guide::new(vertical, pos) })))?;
|
||||
let i = guide_indexes(&s.doc()?.doc, &[id]).first().copied();
|
||||
Ok(json!({ "index": i }))
|
||||
}
|
||||
|
||||
/// The ids of the active document's ruler guides by index: the order of `guide.list` (ascending
|
||||
/// id).
|
||||
pub(crate) fn guide_ids(d: &vectorcraft_doc::Document) -> Vec<NodeId> {
|
||||
d.ruler_guides().into_iter().filter_map(|g| g.id).collect()
|
||||
}
|
||||
|
||||
/// The `guide.list` indexes of ruler guides `ids`, in the order of `ids` (an id that is not a ruler
|
||||
/// guide is left out).
|
||||
pub fn guide_indexes(d: &vectorcraft_doc::Document, ids: &[NodeId]) -> Vec<usize> {
|
||||
let all = guide_ids(d);
|
||||
ids.iter().filter_map(|id| all.binary_search(id).ok()).collect()
|
||||
}
|
||||
|
||||
fn guide_list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let row = |(i, g): (usize, &Guide)| {
|
||||
let mut r = json!({ "index": i, "vertical": g.vertical, "pos": g.pos, "selected": st.selection.guides.contains(&i) });
|
||||
let selected = g.id.is_some_and(|id| st.selection.guides.contains(&id));
|
||||
let mut r = json!({ "index": i, "vertical": g.vertical, "pos": g.pos, "selected": selected });
|
||||
if let Some(ab) = st.doc.artboards.iter().position(|a| Some(a.id) == g.artboard) {
|
||||
r["artboard"] = json!(ab);
|
||||
}
|
||||
@@ -801,31 +813,38 @@ fn guide_list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
r["editable"] = json!(st.doc.guide_editable(g));
|
||||
r
|
||||
};
|
||||
Ok(Value::Array(st.doc.guides.iter().enumerate().map(row).collect()))
|
||||
Ok(Value::Array(st.doc.ruler_guides().iter().enumerate().map(row).collect()))
|
||||
}
|
||||
|
||||
/// Ruler guide `index` of the active document (`index` validated).
|
||||
fn guide_index(s: &Session, v: Option<&Value>, cmd: &str) -> Result<usize> {
|
||||
/// The id of ruler guide `index` in `ids` ([`guide_ids`]; `index` validated).
|
||||
fn guide_index_in(ids: &[NodeId], v: Option<&Value>, cmd: &str) -> Result<NodeId> {
|
||||
let i = v.and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad(cmd, "an index must be a whole number"))?;
|
||||
if i >= s.doc()?.doc.guides.len() {
|
||||
return Err(bad(cmd, format!("no guide {i}")));
|
||||
ids.get(i).copied().ok_or_else(|| bad(cmd, format!("no guide {i}")))
|
||||
}
|
||||
|
||||
/// The id of ruler guide `index` of the active document (`index` validated).
|
||||
fn guide_index(s: &Session, v: Option<&Value>, cmd: &str) -> Result<NodeId> {
|
||||
guide_index_in(&guide_ids(&s.doc()?.doc), v, cmd)
|
||||
}
|
||||
|
||||
/// Is ruler guide `id` one that can be moved or deleted: not on a hidden or locked layer?
|
||||
fn check_editable_guide(s: &Session, id: NodeId) -> Result<NodeId> {
|
||||
let d = &s.doc()?.doc;
|
||||
if !d.ruler_guide(id).is_some_and(|g| d.guide_editable(&g)) {
|
||||
return Err(EngineError::Other("the guide is on a hidden or locked layer".into()));
|
||||
}
|
||||
Ok(i)
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// [`guide_index`] of a guide that can be moved or deleted: not on a hidden or locked layer.
|
||||
fn editable_guide_index(s: &Session, v: Option<&Value>, cmd: &str) -> Result<usize> {
|
||||
let i = guide_index(s, v, cmd)?;
|
||||
let d = &s.doc()?.doc;
|
||||
if !d.guides.get(i).is_some_and(|g| d.guide_editable(g)) {
|
||||
return Err(EngineError::Other("the guide is on a hidden or locked layer".into()));
|
||||
}
|
||||
Ok(i)
|
||||
fn editable_guide_index(s: &Session, v: Option<&Value>, cmd: &str) -> Result<NodeId> {
|
||||
check_editable_guide(s, guide_index(s, v, cmd)?)
|
||||
}
|
||||
|
||||
fn guide_select(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let list = p.get("indexes").and_then(Value::as_array).ok_or_else(|| bad("guide.select", "missing indexes"))?;
|
||||
let picked = list.iter().map(|v| guide_index(s, Some(v), "guide.select")).collect::<Result<Vec<_>>>()?;
|
||||
let ids = guide_ids(&s.doc()?.doc);
|
||||
let picked = list.iter().map(|v| guide_index_in(&ids, Some(v), "guide.select")).collect::<Result<Vec<_>>>()?;
|
||||
if bool_or(p, "toggle", false) {
|
||||
// Added to (or taken from) what is selected, the art included.
|
||||
s.select(|_, sel| {
|
||||
@@ -836,12 +855,13 @@ fn guide_select(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
} else {
|
||||
s.select(|_, sel| sel.set_guides(picked))?;
|
||||
}
|
||||
Ok(json!({ "selected": s.doc()?.selection.guides }))
|
||||
let st = s.doc()?;
|
||||
Ok(json!({ "selected": guide_indexes(&st.doc, &st.selection.guides) }))
|
||||
}
|
||||
|
||||
/// Delete ruler guide `index`, else the selected guides, in one undo step → {count}.
|
||||
pub(crate) fn guide_remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let gone: Vec<usize> = match p.get("index") {
|
||||
let gone: Vec<NodeId> = match p.get("index") {
|
||||
Some(v) => vec![editable_guide_index(s, Some(v), "guide.remove")?],
|
||||
None => s.doc()?.selection.guides.clone(),
|
||||
};
|
||||
@@ -850,7 +870,7 @@ pub(crate) fn guide_remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let label = if gone.len() == 1 { "Delete Guide" } else { "Delete Guides" };
|
||||
s.edit(label, |d, sel| {
|
||||
d.retain_guides(sel, |i, _| !gone.contains(&i));
|
||||
d.retain_guides(sel, |g| !g.id.is_some_and(|id| gone.contains(&id)));
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "count": gone.len() }))
|
||||
@@ -866,18 +886,22 @@ fn guide_set_layer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if super::layer::locked_within(d, layer) {
|
||||
return Err(EngineError::Other("the layer or group is locked".into()));
|
||||
}
|
||||
let moving: Vec<usize> = match (p.get("index"), p.get("indexes").and_then(Value::as_array)) {
|
||||
let moving: Vec<NodeId> = match (p.get("index"), p.get("indexes").and_then(Value::as_array)) {
|
||||
(Some(v), _) => vec![editable_guide_index(s, Some(v), C)?],
|
||||
(None, Some(list)) => list.iter().take(10_000).map(|v| editable_guide_index(s, Some(v), C)).collect::<Result<Vec<_>>>()?,
|
||||
(None, Some(list)) => {
|
||||
let ids = guide_ids(&s.doc()?.doc);
|
||||
list.iter().take(10_000).map(|v| check_editable_guide(s, guide_index_in(&ids, Some(v), C)?)).collect::<Result<Vec<_>>>()?
|
||||
}
|
||||
(None, None) => s.doc()?.selection.guides.clone(),
|
||||
};
|
||||
if moving.is_empty() {
|
||||
return Err(bad(C, "no guide selected (or give an index)"));
|
||||
}
|
||||
s.edit("Move Guide to Layer", |d, _| {
|
||||
for i in &moving {
|
||||
if let Some(g) = d.guides.get_mut(*i) {
|
||||
g.layer = Some(layer);
|
||||
// Each keeps its id (and so its index), at the top of the layer.
|
||||
for id in &moving {
|
||||
if d.parent_of(*id) != Some(layer) && d.ruler_guide(*id).is_some() {
|
||||
d.move_node(*id, Some(layer), usize::MAX)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -891,9 +915,7 @@ pub(crate) fn guide_move(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = editable_guide_index(s, Some(v), "guide.move")?;
|
||||
let pos = f64_req(p, "pos", "guide.move")?;
|
||||
s.edit("Move Guide", |d, _| {
|
||||
if let Some(g) = d.guides.get_mut(i) {
|
||||
g.pos = pos;
|
||||
}
|
||||
d.edit_ruler_guide(i, |g| g.pos = pos);
|
||||
Ok(())
|
||||
})?;
|
||||
return ok();
|
||||
@@ -917,18 +939,14 @@ pub(crate) fn guide_move(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.edit(if copy { "Copy Guide" } else { "Move Guide" }, |d, sel| {
|
||||
let mut copies = vec![];
|
||||
for &i in &moving {
|
||||
let Some(g) = d.guides.get_mut(i) else { continue };
|
||||
let moved = g.moved(Vec2::new(dx, dy));
|
||||
if copy {
|
||||
copies.push(moved);
|
||||
copies.extend(d.ruler_guide(i).map(|g| d.add_ruler_guide(&g.moved(Vec2::new(dx, dy)))));
|
||||
} else {
|
||||
*g = moved;
|
||||
d.edit_ruler_guide(i, |g| *g = g.moved(Vec2::new(dx, dy)));
|
||||
}
|
||||
}
|
||||
if copy {
|
||||
let first = d.guides.len();
|
||||
d.guides.extend(copies);
|
||||
sel.set_guides(first..d.guides.len());
|
||||
sel.set_guides(copies);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
@@ -74,7 +74,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Split Path",
|
||||
[],
|
||||
None,
|
||||
"{id, subpath?, segment, t} or {id, subpath?, anchor}: closed paths open there, open paths become two → {ids}",
|
||||
"{id, subpath?, segment, t} or {id, subpath?, anchor}: closed paths open there, open paths become two; one of the two new end points is left selected → {ids}",
|
||||
has_doc,
|
||||
split
|
||||
),
|
||||
@@ -92,7 +92,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Eraser",
|
||||
[],
|
||||
None,
|
||||
"{points: [[x,y]…] or [[x,y,pressure]…] (pen pressure 0..1), size?: pt (10), sizeMode?: \"fixed\" (default) | \"pressure\" (Eraser Tool Options' Size set to Pressure: the tip is size − sizeVariation across where the pen presses lightest and size + sizeVariation where it presses hardest, as path.blob), sizeVariation?: pt (0), angle?: ° (0), roundness?: % (100, kept within 1–100)} erase the stroke of an elliptical tip from paths (selected, or all) → {ids}. The tip is `size` across its long axis, which runs at `angle` counter-clockwise from horizontal; its short axis is `roundness` percent of the long one",
|
||||
"{points: [[x,y]…] or [[x,y,pressure]…] (pen pressure 0..1), size?: pt (10), sizeMode?: \"fixed\" (default) | \"pressure\" (Eraser Tool Options' Size set to Pressure: the tip is size − sizeVariation across where the pen presses lightest and size + sizeVariation where it presses hardest, as path.blob), sizeVariation?: pt (0), angle?: ° (0: the tip's long axis counter-clockwise from level), roundness?: % (100, kept within 1–100)} erase the stroke of an elliptical tip from paths (selected, or all) → {ids}. The tip is `size` across its long axis, which runs at `angle` counter-clockwise from horizontal; its short axis is `roundness` percent of the long one",
|
||||
has_doc,
|
||||
erase_region
|
||||
),
|
||||
|
||||
@@ -200,9 +200,16 @@ pub(super) fn split(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
_ => return Err(EngineError::Other("no such segment".into())),
|
||||
};
|
||||
let mut pieces = sp.cut_at(&std::collections::BTreeSet::from([k]));
|
||||
// The cut leaves one of its two coincident end points selected, as `path.cutAtAnchors`
|
||||
// does, so a Direct Selection drag pulls the path apart there.
|
||||
let pick = |sel: &mut vectorcraft_doc::Selection, id: NodeId, si: usize| {
|
||||
sel.clear();
|
||||
sel.add(id);
|
||||
sel.anchors.entry(id).or_default().insert((si, 0));
|
||||
};
|
||||
if sp.closed {
|
||||
path.subpaths[si] = pieces.pop().ok_or_else(|| EngineError::Other("no such anchor".into()))?;
|
||||
sel.set([id]);
|
||||
pick(sel, id, si);
|
||||
return Ok(vec![id]);
|
||||
}
|
||||
let [left, right] = <[SubPath; 2]>::try_from(pieces).map_err(|_| EngineError::Other("cannot split an open path at its end point".into()))?;
|
||||
@@ -211,12 +218,12 @@ pub(super) fn split(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let node = sibling_with(d, id, PathData::single(right))?;
|
||||
let (parent, idx, _) = d.position(id).ok_or(EngineError::NoNode(id))?;
|
||||
let nid = d.insert(parent, idx + 1, node)?;
|
||||
sel.set([id, nid]);
|
||||
pick(sel, nid, 0);
|
||||
Ok(vec![id, nid])
|
||||
} else {
|
||||
path.subpaths[si] = left;
|
||||
path.subpaths.insert(si + 1, right);
|
||||
sel.set([id]);
|
||||
pick(sel, id, si + 1);
|
||||
Ok(vec![id])
|
||||
}
|
||||
})?;
|
||||
|
||||
@@ -157,7 +157,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Cut",
|
||||
["Edit"],
|
||||
Some("Cmd+X"),
|
||||
"{selectedArtboards?: [n…], art?: bool, lockedAndHidden?: bool (the Artboard tool chosen, as artboard.cut)} (as edit.copy) (selected ruler guides go too; the Artboard tool chosen, or selectedArtboards given: artboard.cut); refused while an object of a symbol instance's art is selected",
|
||||
"{selectedArtboards?: [n…], art?: bool, lockedAndHidden?: bool (the Artboard tool chosen, as artboard.cut)} (as edit.copy) removes the objects it copies, in one undo step: a compound-path member selected on its own leaves the compound's other members in the compound (selected ruler guides go too; the Artboard tool chosen, or selectedArtboards given: artboard.cut); refused while an object of a symbol instance's art is selected",
|
||||
has_selection_guides_or_artboard_tool,
|
||||
cut
|
||||
),
|
||||
@@ -166,7 +166,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Copy",
|
||||
["Edit"],
|
||||
Some("Cmd+C"),
|
||||
"{selectedArtboards?: [n…] (the artboards the Artboard tool had selected, which the journal records for a replay; it runs artboard.copy with any tool chosen), art?: bool (default: the Artboard tool's moveArt option; the journal records it beside selectedArtboards), lockedAndHidden?: bool (the Artboard tool chosen, as artboard.copy)} the selection with what it uses, and the selected ruler guides (pasted on the layer new art goes to, moved as the objects are; alone, where they were); the Artboard tool chosen: artboard.copy; with an object of a symbol instance's art selected, the instance is copied whole → {copied, guides}",
|
||||
"{selectedArtboards?: [n…] (the artboards the Artboard tool had selected, which the journal records for a replay; it runs artboard.copy with any tool chosen), art?: bool (default: the Artboard tool's moveArt option; the journal records it beside selectedArtboards), lockedAndHidden?: bool (the Artboard tool chosen, as artboard.copy)} the selection with what it uses (a compound-path member selected on its own, whole or in part, with Direct or Group Selection, is copied as an ordinary path with the compound's paint, transparency, opacity mask, fill rule and graphic style link (while it has that style's look), a gradient keeping the compound's fit; the compound is copied when it is selected, or when every member is selected whole outside isolation mode), and the selected ruler guides (pasted on the layer new art goes to, moved as the objects are; alone, where they were); the Artboard tool chosen: artboard.copy; with an object of a symbol instance's art selected, the instance is copied whole → {copied, guides}",
|
||||
has_selection_guides_or_artboard_tool,
|
||||
copy
|
||||
),
|
||||
@@ -220,7 +220,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Clear",
|
||||
["Edit"],
|
||||
Some("Delete"),
|
||||
"{ids?, guides?: bool (with ids: also delete the selected ruler guides)} delete the objects `ids`, else the selection (fully selected objects and direct-selected anchors together, else ruler guides); without ids, refused while an object of a symbol instance's art is selected",
|
||||
"{ids?, guides?: bool (with ids: also delete the selected ruler guides)} delete the objects `ids`, else the selection (fully selected objects and direct-selected anchors together, else ruler guides; a compound-path member selected on its own is deleted alone, and a compound left with no members goes too); without ids, refused while an object of a symbol instance's art is selected",
|
||||
has_selection_or_guides,
|
||||
clear
|
||||
),
|
||||
@@ -931,7 +931,11 @@ fn without_inner(doc: &Document, placed: Vec<(NodeId, Option<Vec<usize>>)>) -> i
|
||||
})
|
||||
}
|
||||
|
||||
/// The direct-selection flag and resolved root nodes for Copy and Cut operations.
|
||||
/// The direct-selection flag and resolved root nodes for Copy and Cut operations. A compound-path
|
||||
/// member selected on its own (whole, with Direct or Group Selection, or by some of its anchors or
|
||||
/// segments) is taken as itself, so Copy copies it as an ordinary path and Cut removes it alone. The
|
||||
/// compound is taken whole when it is selected, or when every one of its members is selected whole
|
||||
/// outside isolation mode; in an isolated compound every member is taken as itself.
|
||||
fn copy_cut_targets(s: &Session) -> Result<(bool, Vec<NodeId>)> {
|
||||
let st = s.doc()?;
|
||||
// The Pen and the Curvature tool leave the anchor they placed or edited selected: Copy takes
|
||||
@@ -940,9 +944,19 @@ fn copy_cut_targets(s: &Session) -> Result<(bool, Vec<NodeId>)> {
|
||||
let in_drawing_tool = matches!(s.tool_id(), "pen" | "curvature") && !picked;
|
||||
let direct = !in_drawing_tool && (!st.selection.anchors.is_empty() || !st.selection.segments.is_empty());
|
||||
let in_isolated_compound = st.isolation.is_some_and(|iso| st.doc.node(iso).is_some_and(|n| matches!(n.kind, NodeKind::Compound { .. })));
|
||||
let roots = roots_within(&st.doc, st.selection.in_paint_order(&st.doc), |_compound, member| {
|
||||
in_isolated_compound || (direct && (st.selection.partial(member).is_some() || st.selection.segments.contains_key(&member)))
|
||||
});
|
||||
// A member counts as selected whole when Copy would take it unchanged: no anchors or segments
|
||||
// of it are picked, the picked ones cover every segment, or a drawing tool's own anchor is
|
||||
// all that is picked.
|
||||
let selected: std::collections::HashSet<NodeId> = st.selection.objects.iter().copied().collect();
|
||||
let whole = |m: &vectorcraft_doc::Node| selected.contains(&m.id) && (!direct || super::clipboard::copied_whole(m, &st.selection));
|
||||
// Worked out once per compound: every selected member asks about its compound.
|
||||
let checked = std::cell::RefCell::new(std::collections::HashMap::new());
|
||||
let all_members_whole = |compound: NodeId| {
|
||||
*checked.borrow_mut().entry(compound).or_insert_with(|| {
|
||||
st.doc.node(compound).and_then(|n| n.children()).is_some_and(|members| !members.is_empty() && members.iter().all(|m| whole(m)))
|
||||
})
|
||||
};
|
||||
let roots = roots_within(&st.doc, st.selection.in_paint_order(&st.doc), |compound, _member| in_isolated_compound || !all_members_whole(compound));
|
||||
Ok((direct, roots))
|
||||
}
|
||||
|
||||
@@ -977,8 +991,8 @@ fn copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.selection
|
||||
.guides
|
||||
.iter()
|
||||
.filter_map(|i| st.doc.guides.get(*i))
|
||||
.map(|g| vectorcraft_doc::Guide { layer: None, artboard: None, ..g.clone() })
|
||||
.filter_map(|i| st.doc.ruler_guide(*i))
|
||||
.map(|g| vectorcraft_doc::Guide { layer: None, artboard: None, id: None, ..g })
|
||||
.collect();
|
||||
let roots = if st.selection.is_empty() && !guides.is_empty() { vec![] } else { targets };
|
||||
let mut clip = Clipboard::copy(st, &roots, direct);
|
||||
@@ -990,7 +1004,7 @@ fn copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Guides alone: the artboard the first one belongs to, or crosses, is where in-place pastes
|
||||
// measure from, as the objects' artboard is.
|
||||
if clip.nodes.is_empty()
|
||||
&& let Some(g) = st.selection.guides.first().and_then(|i| st.doc.guides.get(*i))
|
||||
&& let Some(g) = st.selection.guides.first().and_then(|i| st.doc.ruler_guide(*i))
|
||||
{
|
||||
let board = g.artboard.and_then(|id| st.doc.artboards.iter().find(|a| a.id == id)).or_else(|| {
|
||||
st.doc
|
||||
@@ -1016,8 +1030,8 @@ fn cut(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
copy(s, p)?;
|
||||
return clear(s, &json!({}));
|
||||
}
|
||||
// Copy promotes a compound member to its compound. Cut must remove the same objects it
|
||||
// copied, while Clear on its own deletes exactly the selected members.
|
||||
// Cut removes the same objects Copy takes: a compound member selected on its own, or the
|
||||
// whole compound when it is selected or every member is selected whole.
|
||||
let (direct, roots) = copy_cut_targets(s)?;
|
||||
let res = copy(s, p)?;
|
||||
if res.get("copied").and_then(Value::as_u64) == Some(0) {
|
||||
@@ -1039,18 +1053,14 @@ fn cut_direct_selection(s: &mut Session, roots: &[NodeId]) -> Result<Value> {
|
||||
s.edit("Cut", |d, selection| {
|
||||
// The selected ruler guides were copied too (#3226): they go in the same undo step.
|
||||
let gone = selection.guides.clone();
|
||||
d.retain_guides(selection, |i, _| !gone.contains(&i));
|
||||
d.retain_guides(selection, |g| !g.id.is_some_and(|id| gone.contains(&id)));
|
||||
let mut survivors = vec![];
|
||||
let mut pieces = super::pathops::Beside::replacing(d, roots);
|
||||
for id in roots {
|
||||
let Some(node) = d.node(*id) else { continue };
|
||||
let has_direct = sel.anchors.contains_key(id) || sel.segments.contains_key(id);
|
||||
if !has_direct {
|
||||
d.remove(*id)?;
|
||||
continue;
|
||||
}
|
||||
if node.path_data().is_none() {
|
||||
d.remove(*id)?;
|
||||
if !has_direct || node.path_data().is_none() {
|
||||
remove_leaving_no_empty_compound(d, *id)?;
|
||||
continue;
|
||||
}
|
||||
let original = super::path::deletion_source(d, *id)?;
|
||||
@@ -1073,13 +1083,7 @@ fn cut_direct_selection(s: &mut Session, roots: &[NodeId]) -> Result<Value> {
|
||||
}
|
||||
subpaths.retain(|sp| sp.anchors.len() >= 2);
|
||||
if subpaths.is_empty() {
|
||||
let parent = d.parent_of(*id).filter(|p| matches!(d.node(*p).map(|n| &n.kind), Some(NodeKind::Compound { .. })));
|
||||
d.remove(*id)?;
|
||||
if let Some(p) = parent
|
||||
&& d.node(p).is_some_and(|n| n.children().is_some_and(|c| c.is_empty()))
|
||||
{
|
||||
d.remove(p)?;
|
||||
}
|
||||
remove_leaving_no_empty_compound(d, *id)?;
|
||||
} else {
|
||||
survivors.extend(super::path::replace_deleted_pieces(d, &mut pieces, original, subpaths)?);
|
||||
}
|
||||
@@ -1093,6 +1097,19 @@ fn cut_direct_selection(s: &mut Session, roots: &[NodeId]) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Remove object `id`, and the compound path that held it when it was the compound's last member.
|
||||
/// A compound left with one member stays a compound path, as after Clear.
|
||||
fn remove_leaving_no_empty_compound(d: &mut Document, id: NodeId) -> Result<()> {
|
||||
let parent = d.parent_of(id).filter(|p| matches!(d.node(*p).map(|n| &n.kind), Some(NodeKind::Compound { .. })));
|
||||
d.remove(id)?;
|
||||
if let Some(p) = parent
|
||||
&& d.node(p).is_some_and(|n| n.children().is_some_and(|c| c.is_empty()))
|
||||
{
|
||||
d.remove(p)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn clear(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let guides = switch_or(p, "guides", false, "edit.clear")?;
|
||||
let explicit = if p.get("ids").is_some() { Some(checked_ids_param(s, p, "ids", "edit.clear")?) } else { None };
|
||||
@@ -1136,7 +1153,7 @@ fn clear(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
if with_guides {
|
||||
let gone = sel.guides.clone();
|
||||
d.retain_guides(sel, |i, _| !gone.contains(&i));
|
||||
d.retain_guides(sel, |g| !g.id.is_some_and(|id| gone.contains(&id)));
|
||||
}
|
||||
if is_explicit {
|
||||
for id in &ids {
|
||||
@@ -1315,7 +1332,7 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
|
||||
vec![Affine::IDENTITY]
|
||||
};
|
||||
let (ids, imported, artboards, into) = s.edit(mode.label(), |d, sel| {
|
||||
let imported = clip.import_into(d, choices, same_doc);
|
||||
let imported = clip.import_into(d, choices, same_doc)?;
|
||||
// Draw Inside: the clip group of the object drawn into, made on the first paste.
|
||||
let into = inside.map(|t| super::create::draw_inside_group(d, t)).transpose()?;
|
||||
let artboards: Vec<usize> = boards
|
||||
@@ -1421,8 +1438,7 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
|
||||
for g in &clip.guides {
|
||||
let pos = g.pos + if g.vertical { dv.x } else { dv.y };
|
||||
if pos.is_finite() {
|
||||
sel.guides.push(d.guides.len());
|
||||
d.guides.push(vectorcraft_doc::Guide { pos, layer: guide_layer, ..g.clone() });
|
||||
sel.guides.push(d.add_ruler_guide(&vectorcraft_doc::Guide { pos, layer: guide_layer, ..g.clone() }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Expand Appearance",
|
||||
["Object"],
|
||||
None,
|
||||
"{ids?|id?: [..], target?} turn each object's appearance into objects: every fill and stroke becomes an object of its own (a stroke stays a stroke, with its weight, dashes, arrowheads and width profile, on its own copy of the path, which object.expand or object.path.outlineStroke outline; brushed strokes become their brush art) grouped in paint order under the object's id and transparency, geometry effects are baked, raster effects become an embedded image (shadows and outer glows below the art; blur, feather and inner glow replace it; an object whose only raster effect is its own Rasterize becomes the image object.rasterize makes with the effect's options), type with effects or fills of its own is outlined; Crop Marks become a group of the object and its marks; Vector Halftone (addon.color.halftone) becomes a group of its dot paths, one per ink; a group's or layer's own fills and strokes become objects among its members, which are expanded too. An outlined frame of threaded text leaves its thread with the text it shows, and a frame kept as type in a group stays in its thread. A dimension whose art it changes, or one selected with the art it measures when that art is replaced, becomes a plain group of its art. Hidden fills, strokes and effects are dropped. In Safe Mode, an object with a visible plug-in effect (in it, or in a symbol it places) is an error and nothing changes. Enabled when a selected or targeted object's appearance isn't basic (`ids` then pick which objects) → {ids, warning? (an image in the art couldn't be decoded, as object.rasterize says)}",
|
||||
"{ids?|id?: [..], target?} turn each object's appearance into objects: every fill and stroke becomes an object of its own (a stroke stays a stroke, with its weight, dashes, arrowheads and width profile, on its own copy of the path, which object.expand or object.path.outlineStroke outline; brushed strokes become their brush art) grouped in paint order under the object's id and transparency, geometry effects are baked, raster effects become an embedded image (shadows and outer glows below the art; blur, feather and inner glow replace it; an object whose only raster effect is its own Rasterize becomes the image object.rasterize makes with the effect's options), type with effects or fills of its own is outlined; Crop Marks become a group of the object and its marks; Vector Halftone (addon.color.halftone) becomes a group of its dot paths, one per ink; a group's or layer's own fills and strokes become objects among its members, which are expanded too. An outlined frame of threaded text leaves its thread with the text it shows, and a frame kept as type in a group stays in its thread. A dimension whose art it changes becomes a plain group of its art, except one selected with art it measures, directly or in groups without fills, strokes or effects of their own: its lines and label stay as they are, and if the new art no longer fits its link it stays where it was, no longer linked. Hidden fills, strokes and effects are dropped. In Safe Mode, an object with a visible plug-in effect (in it, or in a symbol it places) is an error and nothing changes. Enabled when a selected or targeted object's appearance isn't basic (`ids` then pick which objects) → {ids, warning? (an image in the art couldn't be decoded, as object.rasterize says)}",
|
||||
can_expand,
|
||||
expand_appearance
|
||||
),
|
||||
@@ -109,9 +109,155 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
move_effect
|
||||
),
|
||||
cmd!(
|
||||
query "effect.deferredStatus",
|
||||
"Deferred Effects",
|
||||
[],
|
||||
None,
|
||||
"{} the live-effect plug-in stages the active document left undrawn: while a document is drawn on screen, the plug-in effects of its first view share a 2,000 ms budget (each run also keeps to its own 5,000 ms limit); a stage left out for want of time, or one that times out, fails or has no plug-in, draws its input art unchanged and stays listed until it gives a result (Effect › Refresh Deferred Effects, an edit to its input or parameters, or an export, which runs every stage under the per-call limit), or until its object is deleted or its effect is removed, moved or hidden (or its fill or stroke is hidden) → {budgetMs: 2000, effects: [{objectId (also an object in the art of a symbol, pattern, brush or opacity mask), effectIndex (in the object's effects, or in the fill's or stroke's), item? (the fill or stroke whose effect it is), pluginId, reason: budget|timeout|missing|failed|safeMode}]} (empty: none)",
|
||||
has_doc,
|
||||
deferred_status
|
||||
),
|
||||
cmd!(
|
||||
"effect.refreshDeferred",
|
||||
"Refresh Deferred Effects",
|
||||
["Effect"],
|
||||
None,
|
||||
"{ids?: [object id] (objects of the document; `id` names one; default: every object with a deferred stage)} run the deferred live-effect plug-in stages of those objects again (effect.deferredStatus), sharing a fresh 2,000 ms budget; a stage that gives a result is drawn with it; not an undo step. Enabled when a stage is deferred → {refreshed, stillDeferred (left out for want of time again, or not reached because the object, or a group or layer holding it, is hidden, or with a part of its input too small for the refresh's drawing), failed (timed out, failed or has no plug-in)}",
|
||||
has_deferred,
|
||||
refresh_deferred
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// The active document has deferred live-effect plug-in stages ([`deferred_status`]).
|
||||
fn has_deferred(s: &Session) -> std::result::Result<(), String> {
|
||||
let st = s.active().ok_or("no document open")?;
|
||||
if deferred_effects(&st.doc, &st.plugin_effects).is_empty() { Err("no plug-in effect is deferred".into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
/// The objects the deferred stages of `fx` name, by id, each with whether it is in `d`'s layers.
|
||||
/// An object that is not is looked for in the art `d` draws from elsewhere, where live-effect
|
||||
/// plug-ins run too: the art of its symbols, patterns and brushes, and opacity masks.
|
||||
fn stage_objects(d: &Document, fx: &vectorcraft_plugins::deferred::Deferrals) -> std::collections::HashMap<u64, (Arc<Node>, bool)> {
|
||||
let mut out = std::collections::HashMap::new();
|
||||
let mut rest = Vec::new();
|
||||
for object in fx.list().into_iter().map(|x| x.stage.object) {
|
||||
match d.node_arc(NodeId(object)) {
|
||||
Some(n) => {
|
||||
out.insert(object, (n.clone(), true));
|
||||
}
|
||||
None => rest.push(object),
|
||||
}
|
||||
}
|
||||
if rest.is_empty() {
|
||||
return out;
|
||||
}
|
||||
let brushes = vectorcraft_brush::library(d);
|
||||
let symbols = d.symbols.iter().map(|s| &*s.art);
|
||||
let patterns = d.patterns.iter().flat_map(|p| p.art.iter().map(|a| &**a));
|
||||
let layers = d.layers.iter().map(|a| &**a);
|
||||
let mut look = |n: &Node| {
|
||||
if rest.contains(&n.id.0) {
|
||||
out.entry(n.id.0).or_insert_with(|| (Arc::new(n.clone()), false));
|
||||
}
|
||||
};
|
||||
for a in symbols.chain(patterns).chain(brushes.iter().flat_map(|b| b.art())).chain(layers) {
|
||||
vectorcraft_doc::swatches::walk_with_masks(a, &mut look);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Is plug-in `plugin`'s effect at `stage`'s place in `n` (`None`: no such object), and visible,
|
||||
/// on a visible fill or stroke when it is one's?
|
||||
fn stage_holds(n: Option<&Node>, stage: vectorcraft_plugins::deferred::Stage, plugin: &str) -> bool {
|
||||
let Some(n) = n else { return false };
|
||||
let list = match stage.item {
|
||||
None => Some(n.appearance.effects.as_slice()),
|
||||
Some(i) => n.appearance.items.get(i).filter(|it| it.visible()).map(|it| it.effects().as_slice()),
|
||||
};
|
||||
list.and_then(|l| l.get(stage.effect)).is_some_and(|e| e.visible && e.id == vectorcraft_plugins::effect::effect_id(plugin))
|
||||
}
|
||||
|
||||
/// Drops the records of `fx`'s stages whose object or plug-in effect is no longer at their place
|
||||
/// in `d` ([`stage_holds`]), and gives the objects of the others ([`stage_objects`]).
|
||||
fn prune(d: &Document, fx: &vectorcraft_plugins::deferred::Deferrals) -> std::collections::HashMap<u64, (Arc<Node>, bool)> {
|
||||
let objects = stage_objects(d, fx);
|
||||
fx.retain(|stage, plugin| stage_holds(objects.get(&stage.object).map(|(n, _)| &**n), stage, plugin));
|
||||
objects
|
||||
}
|
||||
|
||||
/// The deferred stages of `d` as `effect.deferredStatus` lists them. The records of stages whose
|
||||
/// object or plug-in effect is no longer at their place in `d` are dropped first.
|
||||
fn deferred_effects(d: &Document, fx: &vectorcraft_plugins::deferred::Deferrals) -> Vec<Value> {
|
||||
prune(d, fx);
|
||||
fx.list()
|
||||
.into_iter()
|
||||
.map(|x| {
|
||||
let mut v = json!({"objectId": x.stage.object, "effectIndex": x.stage.effect, "pluginId": x.plugin, "reason": x.reason.name()});
|
||||
if let Some(i) = x.stage.item {
|
||||
v["item"] = json!(i);
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `effect.deferredStatus`.
|
||||
fn deferred_status(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
Ok(json!({"budgetMs": vectorcraft_plugins::deferred::OPENING_BUDGET_MS, "effects": deferred_effects(&st.doc, &st.plugin_effects)}))
|
||||
}
|
||||
|
||||
/// Draws `n` alone, small, as the canvas would (its live-effect plug-ins run inside the view that
|
||||
/// applies): what matters is that it is drawn, not its pixels. Is every part of it drawn, none left
|
||||
/// out for its size?
|
||||
fn draw_small(renderer: &mut vectorcraft_render::Renderer, doc: &Document, n: &Arc<Node>) -> bool {
|
||||
let Some(b) = n.geometric_bounds() else { return false };
|
||||
let scale = 64.0 / b.width().max(b.height()).max(1.0);
|
||||
let view = vectorcraft_geom::Affine::scale(scale) * vectorcraft_geom::Affine::translate((1.0 - b.x0, 1.0 - b.y0));
|
||||
renderer.stats = Default::default();
|
||||
renderer.render_node(doc, n, 66, 66, view);
|
||||
renderer.stats.culled == 0
|
||||
}
|
||||
|
||||
/// `effect.refreshDeferred`: each object of the document's layers is drawn again alone, small,
|
||||
/// inside the document's view, so its stages are run with the input they were deferred with; a
|
||||
/// hidden object, or one in a hidden layer or group, is not drawn. An object in the art of a
|
||||
/// symbol, pattern, brush or opacity mask runs where that art is used: the visible layers are
|
||||
/// drawn then. A stage input left out of a small drawing for its size stays deferred.
|
||||
fn refresh_deferred(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let (doc, fx) = (st.doc.clone(), st.plugin_effects.clone());
|
||||
let ids = super::named_ids(p)?;
|
||||
let objects = prune(&doc, &fx);
|
||||
if let Some(id) = ids.iter().flatten().find(|id| doc.node(**id).is_none() && !objects.contains_key(&id.0)) {
|
||||
return Err(EngineError::NoNode(*id));
|
||||
}
|
||||
let ids: Option<Vec<u64>> = ids.map(|ids| ids.iter().map(|id| id.0).collect());
|
||||
let counts = fx.refresh(ids.as_deref(), |targets| {
|
||||
let mut renderer = vectorcraft_render::Renderer::new();
|
||||
let mut whole = Vec::new();
|
||||
fx.view(|| {
|
||||
let mut elsewhere = false;
|
||||
for id in targets {
|
||||
match objects.get(id) {
|
||||
Some((n, true)) if doc.is_visible(NodeId(*id)) && draw_small(&mut renderer, &doc, n) => whole.push(*id),
|
||||
Some((_, false)) => elsewhere = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if elsewhere {
|
||||
for layer in doc.layers.iter().filter(|l| l.visible) {
|
||||
draw_small(&mut renderer, &doc, layer);
|
||||
}
|
||||
}
|
||||
});
|
||||
whole
|
||||
});
|
||||
Ok(json!({"refreshed": counts.refreshed, "stillDeferred": counts.still_deferred, "failed": counts.failed}))
|
||||
}
|
||||
|
||||
fn ids_json(ids: &[NodeId]) -> Value {
|
||||
json!(ids.iter().map(|i| i.0).collect::<Vec<_>>())
|
||||
}
|
||||
@@ -282,11 +428,12 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if effects::is_three_d(id) {
|
||||
let targets = appearance_targets(s, p)?;
|
||||
for (target, at) in targets.iter().copied().zip(item.per_target(s, &targets)) {
|
||||
let n = s.doc()?.doc.node(target).ok_or(EngineError::NoNode(target))?;
|
||||
let d = &s.doc()?.doc;
|
||||
let n = d.node(target).ok_or(EngineError::NoNode(target))?;
|
||||
// A new effect goes after the item's others.
|
||||
let on = at.and_then(|at| at.effects_item(&n.appearance, C).ok()).flatten();
|
||||
let on = on.and_then(|i| Some((i, n.appearance.items.get(i)?.effects().len())));
|
||||
effects::validate_three_d_at(n, id, &effect.params, on).map_err(|e| bad(C, e))?;
|
||||
effects::validate_three_d_at_in(Some(d), n, id, &effect.params, on).map_err(|e| bad(C, e))?;
|
||||
if id == effects::REVOLVE && effects::has_revolve(n) {
|
||||
return Err(bad(C, "A profile supports one Revolve effect. Edit the existing effect's options instead."));
|
||||
}
|
||||
@@ -575,7 +722,8 @@ fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
params["name"] = json!(svg_filter_name(s, xml, C)?);
|
||||
}
|
||||
for &(id, target, index) in &targets {
|
||||
let n = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?;
|
||||
let d = &s.doc()?.doc;
|
||||
let n = d.node(id).ok_or(EngineError::NoNode(id))?;
|
||||
let effect = n.appearance.effects_at(target).and_then(|fx| fx.get(index));
|
||||
if warp_id.is_some() && effect.is_some_and(|e| e.id.strip_prefix("warp.").and_then(effects::WarpStyle::from_id).is_none()) {
|
||||
return Err(bad(C, "warpStyle can only edit an applied Warp effect"));
|
||||
@@ -593,7 +741,7 @@ fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
cur.extend(new);
|
||||
}
|
||||
if visible != Some(false) {
|
||||
effects::validate_three_d_at(n, to, &prospective, target.map(|item| (item, index))).map_err(|e| bad(C, e))?;
|
||||
effects::validate_three_d_at_in(Some(d), n, to, &prospective, target.map(|item| (item, index))).map_err(|e| bad(C, e))?;
|
||||
} else {
|
||||
// Hidden, the effect draws nothing, so its object need not fit. The values given are
|
||||
// checked, and after a change of 3D type so is every value it keeps.
|
||||
@@ -672,11 +820,19 @@ fn load_texture(params: &mut Value, cmd: &str) -> Result<()> {
|
||||
/// fills, strokes or effects of its own or such members, other objects with effects, or type with
|
||||
/// fills or strokes of its own?
|
||||
pub(crate) fn expandable(n: &Node) -> bool {
|
||||
expandable_except(n, &[])
|
||||
}
|
||||
|
||||
/// [`expandable`], leaving out the objects `skip` lists and what they hold.
|
||||
pub(crate) fn expandable_except(n: &Node, skip: &[NodeId]) -> bool {
|
||||
if skip.contains(&n.id) {
|
||||
return false;
|
||||
}
|
||||
match &n.kind {
|
||||
NodeKind::Path { clipping: true, .. } | NodeKind::Path { guide: true, .. } => false,
|
||||
NodeKind::Path { .. } | NodeKind::Compound { .. } => !n.appearance.is_basic() || vectorcraft_brush::has_brush(n),
|
||||
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => {
|
||||
!n.appearance.items.is_empty() || !n.appearance.effects.is_empty() || children.iter().any(|c| expandable(c))
|
||||
!n.appearance.items.is_empty() || !n.appearance.effects.is_empty() || children.iter().any(|c| expandable_except(c, skip))
|
||||
}
|
||||
NodeKind::Text(_) => !n.appearance.items.is_empty() || !n.appearance.effects.is_empty(),
|
||||
NodeKind::Image(_) => effects::has_item_geometry(n) || !n.appearance.effects.is_empty(),
|
||||
@@ -686,9 +842,11 @@ pub(crate) fn expandable(n: &Node) -> bool {
|
||||
|
||||
fn can_expand(s: &Session) -> std::result::Result<(), String> {
|
||||
has_doc(s)?;
|
||||
let d = &s.doc().map_err(|e| e.to_string())?.doc;
|
||||
let ids = appearance_targets(s, &Value::Null).map_err(|e| e.to_string())?;
|
||||
if ids.iter().filter_map(|id| d.node(*id)).any(expandable) {
|
||||
let d = &s.doc().map_err(|e| e.to_string())?.doc;
|
||||
// A dimension selected with the art it measures is left as it is.
|
||||
let kept = super::dimension::with_their_art(d, &ids);
|
||||
if ids.iter().filter_map(|id| d.node(*id)).any(|n| expandable_except(n, &kept)) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("select (or target) objects whose appearance isn't basic".into())
|
||||
@@ -731,6 +889,10 @@ fn expand_pieces(
|
||||
let mut seen = std::collections::HashSet::from([id]);
|
||||
let mut expanded = std::collections::HashMap::new();
|
||||
for c in m.children_mut().into_iter().flatten() {
|
||||
// A layer's ruler guides are not art: they keep their ids and expand to nothing.
|
||||
if c.is_ruler_guide() {
|
||||
continue;
|
||||
}
|
||||
let c = Arc::make_mut(c);
|
||||
let mut listed = None;
|
||||
if all || c.id == id {
|
||||
@@ -812,8 +974,15 @@ fn expanded_node(d: &mut Document, mut n: Node, brushes: &[vectorcraft_brush::Br
|
||||
let mut changed = false;
|
||||
// Is `n` in `out` yet?
|
||||
let mut listed_n = false;
|
||||
// A symbol instance's fill tints its art before the adjustment, as the canvas draws it.
|
||||
let symbol_art = match &n.kind {
|
||||
NodeKind::SymbolInstance { symbol, .. } if effects::has_adjustment(&n) => {
|
||||
d.symbols.iter().find(|s| s.name == *symbol).map(|s| vectorcraft_render::instance_art(&s.art, &n))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if effects::has_adjustment(&n)
|
||||
&& let Some(m) = effects::adjust_in_document(d, &n)
|
||||
&& let Some(m) = effects::adjust_in_document(d, &n, symbol_art)
|
||||
{
|
||||
out.push((id, 0));
|
||||
listed_n = true;
|
||||
@@ -919,6 +1088,11 @@ fn expanded_node(d: &mut Document, mut n: Node, brushes: &[vectorcraft_brush::Br
|
||||
// The place of each member in the walk of `n`: after `n` and the members before it.
|
||||
let mut at = 1;
|
||||
for c in n.children_mut().into_iter().flatten() {
|
||||
if c.is_ruler_guide() {
|
||||
// A layer's ruler guide expands to nothing, but the walk of `n` still visits it.
|
||||
at += 1;
|
||||
continue;
|
||||
}
|
||||
let from = out.len();
|
||||
match members.remove(&c.id) {
|
||||
Some(listed) => out.extend(listed),
|
||||
@@ -932,7 +1106,8 @@ fn expanded_node(d: &mut Document, mut n: Node, brushes: &[vectorcraft_brush::Br
|
||||
for (_, place) in &mut out[from..] {
|
||||
*place += at;
|
||||
}
|
||||
at += c.count();
|
||||
// Every node the walk visits, ruler guides in a sublayer too (`Node::count` leaves them out).
|
||||
c.walk(&mut |_| at += 1);
|
||||
}
|
||||
}
|
||||
changed.then_some(n)
|
||||
@@ -966,7 +1141,8 @@ fn expand_appearance(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// In Safe Mode a plug-in effect draws nothing: expanding it would drop the effect and keep the
|
||||
// geometry it hasn't changed.
|
||||
if vectorcraft_plugins::safe_mode::active() {
|
||||
let d = &s.doc()?.doc;
|
||||
// A dimension selected with the art it measures is left as it is.
|
||||
let d = &*super::dimension::without_kept(&s.doc()?.doc, &roots);
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
if roots.iter().filter_map(|id| d.node(*id)).any(|n| holds_plugin_effect(d, n, false, &mut seen)) {
|
||||
return Err(EngineError::Other("Expand Appearance: a plug-in effect in the selection can't be expanded in Safe Mode".into()));
|
||||
@@ -976,20 +1152,24 @@ fn expand_appearance(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let colorized_ok = super::paint::check_colorized(&s.doc()?.doc).is_ok();
|
||||
let ids = s.edit("Expand Appearance", |d, _| {
|
||||
let before = d.clone();
|
||||
let mut ended = super::dimension::end_links(d, &roots);
|
||||
// A dimension selected with the art it measures keeps its own lines and label: it is out
|
||||
// of the document while the art expands.
|
||||
let lifted = super::dimension::lift(d, &roots);
|
||||
let kept = !lifted.is_empty();
|
||||
let ended = super::dimension::end_links(d, &roots);
|
||||
let brushes = vectorcraft_brush::library(d);
|
||||
let mut out = vec![];
|
||||
let threaded = super::threads::frames(d);
|
||||
for id in &roots {
|
||||
expand_node(d, *id, &brushes, &mut out);
|
||||
}
|
||||
// A dimension whose source was replaced stays plain: its link no longer fits that art.
|
||||
ended.retain(|e| e.source_kept(&before, d));
|
||||
super::dimension::restore_unchanged(d, ended);
|
||||
super::dimension::put_back(d, lifted);
|
||||
// A dimension whose source was replaced stays where it was, no longer linked.
|
||||
super::dimension::restore_unchanged(&before, d, ended);
|
||||
// Outlined frames of threaded text leave their threads with the text they show; type that
|
||||
// stays type under a new id takes its frame's place.
|
||||
super::threads::leave_threads(d, &threaded);
|
||||
if out.is_empty() {
|
||||
if out.is_empty() || (kept && d.layers == before.layers) {
|
||||
return Err(EngineError::Other("Expand Appearance: the selection has a basic appearance".into()));
|
||||
}
|
||||
super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad("Expand Appearance", e))?;
|
||||
@@ -1006,6 +1186,9 @@ fn expand_appearance(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
#[cfg(test)]
|
||||
mod lighting_presets_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod three_d_source_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod three_d_type_tests;
|
||||
|
||||
|
||||
@@ -130,7 +130,8 @@ fn solid_effects_apply_save_edit_export_expand_and_undo() {
|
||||
}
|
||||
|
||||
/// #4506: a 3D effect on a group builds a solid from each member, turned about one shared axis;
|
||||
/// the members stay editable, and a group holding what can't become a solid is refused.
|
||||
/// the members stay editable. A group holding a clipping group builds from the art it shows, and a
|
||||
/// group holding what can't become a solid is refused.
|
||||
#[test]
|
||||
fn solid_effects_apply_to_a_group_and_its_members_stay_editable() {
|
||||
for effect in effects::SOLIDS {
|
||||
@@ -151,17 +152,59 @@ fn solid_effects_apply_to_a_group_and_its_members_stay_editable() {
|
||||
s.execute("effect.expandAppearance", &json!({"ids": [g.0]})).unwrap();
|
||||
assert!(!effects::has_solid(&node(&s, g)), "{effect}: expanded");
|
||||
}
|
||||
// A clipping group in the group: refused, nothing changed.
|
||||
// A clipping group in the group: each effect builds from the art it shows.
|
||||
for effect in effects::SOLIDS {
|
||||
let (mut s, _, g) = group_with_a_clipping_group();
|
||||
s.execute("effect.apply", &json!({"effect": effect, "ids": [g.0], "item": null})).unwrap_or_else(|e| panic!("{effect}: {e}"));
|
||||
let n = node(&s, g);
|
||||
assert_eq!(n.children().unwrap().len(), 2, "{effect}: the members stay as they were");
|
||||
let art = effects::solid_art_in(Some(&s.doc().unwrap().doc), &n);
|
||||
assert!(art.is_some_and(|a| a.visual_bounds().is_some()), "{effect}: draws its solid");
|
||||
}
|
||||
// An image in the group: refused, nothing changed.
|
||||
let (mut s, _, g) = group_with_an_image();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let err = s.execute("effect.apply", &json!({"effect": "threeD.extrude", "ids": [g.0], "item": null})).unwrap_err().to_string();
|
||||
assert!(err.contains("Image Trace"), "{err}");
|
||||
assert_eq!(s.doc().unwrap().doc, before, "nothing changed");
|
||||
}
|
||||
|
||||
/// A group of a clipping group (a rectangle clipped by an ellipse) and a rectangle: the session,
|
||||
/// the clipping group and the group.
|
||||
fn group_with_a_clipping_group() -> (Session, NodeId, NodeId) {
|
||||
let (mut s, a) = session_with_rect();
|
||||
let b = s.execute("shape.ellipse", &json!({"x": 120, "y": 120, "width": 60, "height": 60})).unwrap()["id"].clone();
|
||||
s.execute("select.set", &json!({"ids": [a.0, b]})).unwrap();
|
||||
let clip = s.execute("object.clippingMask.make", &json!({})).unwrap()["id"].clone();
|
||||
let clip = NodeId(s.execute("object.clippingMask.make", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
let c = s.execute("shape.rectangle", &json!({"x": 250, "y": 250, "width": 50, "height": 50})).unwrap()["id"].clone();
|
||||
s.execute("select.set", &json!({"ids": [clip, c]})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [clip.0, c]})).unwrap();
|
||||
let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
let err = s.execute("effect.apply", &json!({"effect": "threeD.extrude", "ids": [g.0], "item": null})).unwrap_err().to_string();
|
||||
assert!(err.contains("clipping groups"), "{err}");
|
||||
assert!(node(&s, g).appearance.effects.is_empty());
|
||||
(s, clip, g)
|
||||
}
|
||||
|
||||
/// A group of a rectangle and an image: the session, the image and the group.
|
||||
fn group_with_an_image() -> (Session, NodeId, NodeId) {
|
||||
let (mut s, a) = session_with_rect();
|
||||
let image = s
|
||||
.edit("Place", |d, _| {
|
||||
let id = d.alloc_id();
|
||||
let img = vectorcraft_doc::ImageObject {
|
||||
key: "k".into(),
|
||||
width: 4,
|
||||
height: 4,
|
||||
xf: vectorcraft_geom::Affine::translate((250.0, 250.0)),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
pixels: None,
|
||||
};
|
||||
let (parent, index, _) = d.position(a).unwrap();
|
||||
d.insert(parent, index + 1, vectorcraft_doc::Node::new(id, NodeKind::Image(Box::new(img))))?;
|
||||
Ok(id)
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("select.set", &json!({"ids": [a.0, image.0]})).unwrap();
|
||||
let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
(s, image, g)
|
||||
}
|
||||
|
||||
/// #4715: type extrudes as its glyph outlines, painted with its characters' colour, and stays
|
||||
@@ -339,21 +382,25 @@ fn revolve_on_a_group_shares_one_axis_and_members_stay_editable() {
|
||||
assert!(effects::has_revolve(&node(&s, g)));
|
||||
}
|
||||
|
||||
/// A group holding what can't be swept is refused as a whole, and a clipping group is named.
|
||||
/// A group holding what can't be swept is refused as a whole, and an image is named. A clipping
|
||||
/// group, alone or in a group, sweeps the art it shows.
|
||||
#[test]
|
||||
fn revolve_refuses_a_group_with_a_member_it_can_not_sweep() {
|
||||
let (mut s, a) = session_with_rect();
|
||||
let b = s.execute("shape.ellipse", &json!({"x": 120, "y": 120, "width": 60, "height": 60})).unwrap()["id"].clone();
|
||||
s.execute("select.set", &json!({"ids": [a.0, b]})).unwrap();
|
||||
let clip = s.execute("object.clippingMask.make", &json!({})).unwrap()["id"].clone();
|
||||
let err = s.execute("effect.apply", &json!({"effect": "threeD.revolve", "ids": [clip.as_u64().unwrap()], "item": null})).unwrap_err().to_string();
|
||||
assert!(err.contains("path, compound path, type or group"), "{err}");
|
||||
let c = s.execute("shape.rectangle", &json!({"x": 250, "y": 250, "width": 50, "height": 50})).unwrap()["id"].clone();
|
||||
s.execute("select.set", &json!({"ids": [clip, c]})).unwrap();
|
||||
let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
let err = s.execute("effect.apply", &json!({"effect": "threeD.revolve", "ids": [g.0], "item": null})).unwrap_err().to_string();
|
||||
assert!(err.contains("Revolve on a group") && err.contains("clipping groups"), "{err}");
|
||||
assert!(node(&s, g).appearance.effects.is_empty());
|
||||
let (mut s, image, g) = group_with_an_image();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
for target in [image, g] {
|
||||
let err = s.execute("effect.apply", &json!({"effect": "threeD.revolve", "ids": [target.0], "item": null})).unwrap_err().to_string();
|
||||
assert!(err.contains("Revolve") && err.contains("Image Trace"), "{err}");
|
||||
assert_eq!(s.doc().unwrap().doc, before, "nothing changed");
|
||||
}
|
||||
for clipping_group_itself in [true, false] {
|
||||
let (mut s, clip, g) = group_with_a_clipping_group();
|
||||
let target = if clipping_group_itself { clip } else { g };
|
||||
s.execute("effect.apply", &json!({"effect": "threeD.revolve", "ids": [target.0], "item": null}))
|
||||
.unwrap_or_else(|e| panic!("{clipping_group_itself}: {e}"));
|
||||
let art = effects::revolve_art_in(Some(&s.doc().unwrap().doc), &node(&s, target));
|
||||
assert!(art.is_some_and(|a| a.visual_bounds().is_some()), "{clipping_group_itself}: draws its surface");
|
||||
}
|
||||
}
|
||||
|
||||
/// Scripts from before the Perspective angle give `perspective` as a 0–100 % strength: it is
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
#![allow(clippy::unwrap_used, clippy::panic)]
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{NodeId, NodeKind};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
const TYPES: [&str; 5] = ["threeD.extrude", "threeD.classicExtrude", "threeD.inflate", "threeD.rotate", "threeD.revolve"];
|
||||
|
||||
fn id_of(v: &Value) -> NodeId {
|
||||
NodeId(v["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
|
||||
id_of(&s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap())
|
||||
}
|
||||
|
||||
fn select(s: &mut Session, ids: &[NodeId]) {
|
||||
s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
|
||||
}
|
||||
|
||||
/// A clipping group, a blend and a symbol instance, each made through its command.
|
||||
fn sources(s: &mut Session) -> Vec<(&'static str, NodeId)> {
|
||||
let art = rect(s, 20.0, 20.0, 60.0, 60.0);
|
||||
let clip = rect(s, 40.0, 10.0, 20.0, 80.0);
|
||||
select(s, &[art, clip]);
|
||||
s.execute("object.clippingMask.make", &json!({})).unwrap();
|
||||
let clipped = s.doc().unwrap().selection.objects[0];
|
||||
let a = rect(s, 120.0, 20.0, 20.0, 20.0);
|
||||
let b = rect(s, 200.0, 60.0, 30.0, 30.0);
|
||||
select(s, &[a, b]);
|
||||
let blend = id_of(&s.execute("object.blend.make", &json!({})).unwrap());
|
||||
let art = rect(s, 20.0, 200.0, 50.0, 40.0);
|
||||
let instance = id_of(&s.execute("symbol.new", &json!({"name": "Box", "ids": [art.0]})).unwrap());
|
||||
vec![("clipping group", clipped), ("blend", blend), ("symbol instance", instance)]
|
||||
}
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_three_d_type_applies_to_clipping_groups_blends_and_symbol_instances_in_one_undo_step() {
|
||||
for id in TYPES {
|
||||
let mut s = session();
|
||||
for (what, target) in sources(&mut s) {
|
||||
select(&mut s, &[target]);
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
s.execute("effect.apply", &json!({"effect": id, "item": null})).unwrap_or_else(|e| panic!("{id} on a {what}: {e}"));
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "{id} on a {what}");
|
||||
let n = s.doc().unwrap().doc.node(target).unwrap().clone();
|
||||
assert_eq!(n.appearance.effects.iter().filter(|e| e.id == id).count(), 1, "{id} on a {what}");
|
||||
// The object stays what it is, and the effect draws its solid.
|
||||
assert!(!matches!(n.kind, NodeKind::Path { .. }), "{id} on a {what}");
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let art = vectorcraft_render::effects::revolve_art_in(Some(d), &n).or_else(|| vectorcraft_render::effects::solid_art_in(Some(d), &n));
|
||||
assert!(art.is_some_and(|a| a.visual_bounds().is_some()), "{id} on a {what} draws its solid");
|
||||
// Edited through the dialog's command too.
|
||||
s.execute("effect.setParams", &json!({"index": 0, "item": null, "params": {"rotationY": 20.0}}))
|
||||
.unwrap_or_else(|e| panic!("{id} on a {what}: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_group_holding_an_image_refuses_every_three_d_type_and_names_image_trace() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 20.0, 20.0, 60.0, 60.0);
|
||||
let image = s
|
||||
.edit("Place", |d, _| {
|
||||
let id = d.alloc_id();
|
||||
let img = vectorcraft_doc::ImageObject {
|
||||
key: "k".into(),
|
||||
width: 4,
|
||||
height: 4,
|
||||
xf: vectorcraft_geom::Affine::translate((100.0, 100.0)),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
pixels: None,
|
||||
};
|
||||
let (parent, index, _) = d.position(a).unwrap();
|
||||
d.insert(parent, index + 1, vectorcraft_doc::Node::new(id, NodeKind::Image(Box::new(img))))?;
|
||||
Ok(id)
|
||||
})
|
||||
.unwrap();
|
||||
select(&mut s, &[a, image]);
|
||||
s.execute("object.group", &json!({})).unwrap();
|
||||
for target in [image, s.doc().unwrap().selection.objects[0]] {
|
||||
select(&mut s, &[target]);
|
||||
for id in TYPES {
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let err = s.execute("effect.apply", &json!({"effect": id, "item": null})).unwrap_err().to_string();
|
||||
assert!(err.contains("Object › Image Trace › Make"), "{id}: {err}");
|
||||
assert_eq!(s.doc().unwrap().doc, before, "{id}: nothing changed");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,8 @@
|
||||
//! gradient mesh, or solid strips / concentric ellipses, inside a clip group shaped like the object; a freeform
|
||||
//! gradient becomes a mesh shaped like the object. A pattern fill becomes the tiles it paints inside a clip
|
||||
//! group shaped like the object. Create Gradient Mesh shares the sampling. A dimension whose art
|
||||
//! Expand changes becomes a plain group of that art.
|
||||
//! Expand changes becomes a plain group of that art, except one selected with art it measures,
|
||||
//! which Expand leaves as it is.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
@@ -25,7 +26,7 @@ use super::edit::selected_roots;
|
||||
use super::menucmds::{run_raw, squash};
|
||||
use super::object::as_clipping_path;
|
||||
use super::pathops::{assemble, node_path, outline_strokes, replace_leaves, styled_copy, with_transparency};
|
||||
use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, cmd, f64_or, has_selection, str_param};
|
||||
use super::{CommandSpec, EngineError, Result, Session, bad, cmd, has_selection, number_or, switch_or, text_param};
|
||||
|
||||
const C: &str = "object.expand";
|
||||
/// Expand Gradient To → Specify … Objects: the default and the most.
|
||||
@@ -43,7 +44,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Expand…",
|
||||
["Object"],
|
||||
None,
|
||||
"{object?: true (symbol instances without effects (their fills' and strokes' included) and not in perspective → their art, as Break Link to Symbol places it (the instances that art holds stay instances), type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art, pattern fills → their tiles in a clip group shaped like the object), stroke?: true (strokes → filled outlines, the stroked paths that baking effects leaves included), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step; a dimension whose art an option changes, or one selected with the art it measures when that art is replaced, becomes a plain group of its art → {ids}",
|
||||
"{object?: true (symbol instances without effects (their fills' and strokes' included) and not in perspective → their art, as Break Link to Symbol places it (the instances that art holds stay instances), type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art, pattern fills → their tiles in a clip group shaped like the object), stroke?: true (strokes → filled outlines, the stroked paths that baking effects leaves included), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step; a dimension whose art an option changes becomes a plain group of its art, except one selected with art it measures, directly or in groups without fills, strokes or effects of their own: its lines and label stay as they are, and if the new art no longer fits its link it stays where it was, no longer linked → {ids}",
|
||||
has_selection,
|
||||
expand
|
||||
),
|
||||
@@ -55,7 +56,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"{} what each Expand option would change in the selection → {object, fill, stroke, gradient, appearance, symbolArt?: {fill, stroke, gradient}} (true: something to expand; gradient: a gradient fill that Fill expands, which Expand Gradient To applies to; appearance: appearance attributes Expand Appearance expands first, for which Object › Expand… is dimmed in the menus; symbolArt, when the selection holds symbol instances that Object expands: true when only their art holds it, which Fill and Stroke reach only with Object on)",
|
||||
has_selection,
|
||||
|s, _| {
|
||||
let e = Expandable::of(&s.doc()?.doc, &selected_roots(s)?);
|
||||
let roots = selected_roots(s)?;
|
||||
let e = Expandable::of(&super::dimension::without_kept(&s.doc()?.doc, &roots), &roots);
|
||||
let mut info =
|
||||
json!({"object": e.object(), "fill": e.fill, "stroke": e.stroke, "gradient": e.gradient, "appearance": has_appearance(s)});
|
||||
if e.symbols {
|
||||
@@ -74,7 +76,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
/// (#3089); `object.expand` itself still bakes them.
|
||||
pub fn has_appearance(s: &Session) -> bool {
|
||||
let (Ok(st), Ok(roots)) = (s.doc(), selected_roots(s)) else { return false };
|
||||
roots.iter().filter_map(|r| st.doc.node(*r)).any(super::effectcmd::expandable)
|
||||
// A dimension selected with the art it measures is left as it is.
|
||||
let kept = super::dimension::with_their_art(&st.doc, &roots);
|
||||
roots.iter().filter_map(|r| st.doc.node(*r)).any(|n| super::effectcmd::expandable_except(n, &kept))
|
||||
}
|
||||
|
||||
/// Every placed document in `roots` of `d` (at any depth) replaced by an editable copy of the art it
|
||||
@@ -101,11 +105,11 @@ enum GradientTo {
|
||||
|
||||
impl GradientTo {
|
||||
fn parse(p: &Value) -> Result<Self> {
|
||||
let steps = f64_or(p, "steps", DEFAULT_STEPS as f64).round();
|
||||
let steps = number_or(p, "steps", DEFAULT_STEPS as f64, C)?.round();
|
||||
if !(1.0..=MAX_STEPS as f64).contains(&steps) {
|
||||
return Err(bad(C, format!("steps must be 1..{MAX_STEPS}")));
|
||||
}
|
||||
match str_param(p, "gradient").unwrap_or("objects") {
|
||||
match text_param(p, "gradient", C)?.unwrap_or("objects") {
|
||||
"objects" => Ok(Self::Objects(steps as u32)),
|
||||
"mesh" => Ok(Self::Mesh),
|
||||
_ => Err(bad(C, "gradient must be \"objects\" or \"mesh\"")),
|
||||
@@ -236,14 +240,23 @@ fn expand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn expand_steps(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let object = bool_or(p, "object", true);
|
||||
let fill = bool_or(p, "fill", true);
|
||||
let stroke = bool_or(p, "stroke", true);
|
||||
let object = switch_or(p, "object", true, C)?;
|
||||
let fill = switch_or(p, "fill", true, C)?;
|
||||
let stroke = switch_or(p, "stroke", true, C)?;
|
||||
let to = GradientTo::parse(p)?;
|
||||
let from = s.doc()?.history.undo.len();
|
||||
let mut e = Expandable::of(&s.doc()?.doc, &selected_roots(s)?);
|
||||
// Before anything expands: outlining the art a dimension measures would remove the dimension.
|
||||
// Before anything expands: outlining the art a dimension measures would leave the dimension unlinked.
|
||||
let before = s.doc()?.doc.clone();
|
||||
let selected = s.doc()?.selection.objects.clone();
|
||||
// A dimension selected with the art it measures keeps its own lines and label: it is out of the
|
||||
// document while the art expands, so no step below reaches it.
|
||||
let roots = selected_roots(s)?;
|
||||
let mut lifted = vec![];
|
||||
if !super::dimension::with_their_art(&before, &roots).is_empty() {
|
||||
lifted = s.edit("Expand", |d, _| Ok(super::dimension::lift(d, &roots)))?;
|
||||
}
|
||||
let kept: Vec<NodeId> = lifted.iter().map(super::dimension::Lifted::id).collect();
|
||||
let mut e = Expandable::of(&s.doc()?.doc, &selected_roots(s)?);
|
||||
let mut ended = vec![];
|
||||
end_dimension_links(s, &mut ended)?;
|
||||
let rev0 = s.doc()?.revision;
|
||||
@@ -339,14 +352,25 @@ fn expand_steps(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if s.doc()?.revision == rev0 {
|
||||
return Err(EngineError::Other("Expand: nothing to expand".into()));
|
||||
}
|
||||
if !ended.is_empty() {
|
||||
// A dimension whose source Expand replaced stays plain: its link no longer fits that art.
|
||||
let after = s.doc()?.doc.clone();
|
||||
ended.retain(|e| e.source_kept(&before, &after));
|
||||
s.edit("Expand", |d, _| {
|
||||
super::dimension::restore_unchanged(d, std::mem::take(&mut ended));
|
||||
if !ended.is_empty() || !lifted.is_empty() {
|
||||
s.edit("Expand", |d, sel| {
|
||||
if !lifted.is_empty() {
|
||||
super::dimension::put_back(d, lifted);
|
||||
// The dimensions put back follow what Expand made of the art they measure: a
|
||||
// dimension whose art no longer fits its link stays where it was, no longer linked.
|
||||
super::dimension::refresh(&before, d)?;
|
||||
let mut ids = sel.objects.clone();
|
||||
let again: Vec<NodeId> = kept.iter().copied().filter(|id| selected.contains(id) && !ids.contains(id)).collect();
|
||||
ids.extend(again);
|
||||
sel.set(ids);
|
||||
}
|
||||
// A dimension whose source Expand replaced stays where it was, no longer linked.
|
||||
super::dimension::restore_unchanged(&before, d, ended);
|
||||
Ok(())
|
||||
})?;
|
||||
if !kept.is_empty() && s.doc()?.doc.layers == before.layers {
|
||||
return Err(EngineError::Other("Expand: nothing to expand".into()));
|
||||
}
|
||||
}
|
||||
squash(s, from, "Expand");
|
||||
Ok(json!({ "ids": s.doc()?.selection.objects.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
@@ -510,7 +534,7 @@ fn expand_piece_leaves(
|
||||
}
|
||||
return;
|
||||
}
|
||||
for c in n.children_mut().into_iter().flatten() {
|
||||
for c in n.children_mut().into_iter().flatten().filter(|c| !c.is_ruler_guide()) {
|
||||
expand_piece_leaves(d, Arc::make_mut(c), left, expanded, expand);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1018,6 +1018,65 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Source file names that differ only in case (`a.svg` and `sub/A.svg`) get Export for Screens
|
||||
/// folders of their own: names are compared without case, so the second folder is numbered,
|
||||
/// since Windows and macOS would make `a` and `A` one folder.
|
||||
#[test]
|
||||
fn batch_override_export_numbers_folders_that_differ_only_in_case() {
|
||||
let src = tmp("screens-case-src");
|
||||
std::fs::create_dir_all(src.join("sub")).unwrap();
|
||||
write_svg(&src, "a.svg", "#f00");
|
||||
write_svg(&src.join("sub"), "A.svg", "#0f0");
|
||||
let dest = tmp("screens-case-out");
|
||||
let mut s = Session::new();
|
||||
let r = s
|
||||
.execute(
|
||||
"action.batch",
|
||||
&json!({
|
||||
"folder": src.to_string_lossy(),
|
||||
"includeSubdirs": true,
|
||||
"destinationFolder": dest.to_string_lossy(),
|
||||
"commands": [{"command": "document.exportForScreens", "params": {"formats": [{"format": "svg"}]}}]
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["failed"], 0, "{r}");
|
||||
assert_eq!(file_names(&dest), ["A-2", "a"], "{r}");
|
||||
for (folder, fill) in [("a", "#ff0000"), ("A-2", "#00ff00")] {
|
||||
assert_eq!(file_names(&dest.join(folder)), ["Artboard-1.svg"], "{r}");
|
||||
let svg = std::fs::read_to_string(dest.join(folder).join("Artboard-1.svg")).unwrap();
|
||||
assert!(svg.contains(fill), "{folder}: {svg}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A source file with nothing left of its name without its extension (` .svg`) gets the folder
|
||||
/// `file`, numbered before a source file named `file.svg` takes it.
|
||||
#[test]
|
||||
fn batch_override_export_names_the_folder_of_an_empty_name_file() {
|
||||
let src = tmp("screens-empty-src");
|
||||
write_svg(&src, " .svg", "#f00");
|
||||
write_svg(&src, "file.svg", "#0f0");
|
||||
let dest = tmp("screens-empty-out");
|
||||
let mut s = Session::new();
|
||||
let r = s
|
||||
.execute(
|
||||
"action.batch",
|
||||
&json!({
|
||||
"folder": src.to_string_lossy(),
|
||||
"destinationFolder": dest.to_string_lossy(),
|
||||
"commands": [{"command": "document.exportForScreens", "params": {"formats": [{"format": "svg"}]}}]
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["failed"], 0, "{r}");
|
||||
assert_eq!(file_names(&dest), ["file", "file-2"], "{r}");
|
||||
for (folder, fill) in [("file", "#ff0000"), ("file-2", "#00ff00")] {
|
||||
assert_eq!(file_names(&dest.join(folder)), ["Artboard-1.svg"], "{r}");
|
||||
let svg = std::fs::read_to_string(dest.join(folder).join("Artboard-1.svg")).unwrap();
|
||||
assert!(svg.contains(fill), "{folder}: {svg}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A source file whose name without its extension ends in a period (`a..svg`) gets the folder
|
||||
/// name without it, numbered after the folder of `a.svg`: Windows drops the period from a folder
|
||||
/// name, so `a.` and `a` would be one folder there.
|
||||
|
||||
@@ -88,6 +88,7 @@ impl Builder {
|
||||
show_video_safe_areas: false,
|
||||
video_ruler_pixel_aspect_ratio: 1.0,
|
||||
color: None,
|
||||
guide_label: None,
|
||||
})
|
||||
.collect();
|
||||
if self.doc.artboards.is_empty() {
|
||||
|
||||
@@ -157,7 +157,8 @@ pub(super) fn align_lines(obj: &mut TextObject) {
|
||||
return;
|
||||
}
|
||||
let lines = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), obj).lines;
|
||||
let w = lines.iter().map(|l| l.x1 - l.x0).filter(|w| w.is_finite()).fold(0.0, f64::max);
|
||||
// Each line as wide as VectorCraft aligns it: without the tracking after its last character.
|
||||
let w = lines.iter().map(|l| l.x1 - l.trailing - l.x0).filter(|w| w.is_finite()).fold(0.0, f64::max);
|
||||
if !(w > 0.0 && w < 1e6) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -92,6 +92,9 @@ fn run_steps(
|
||||
if id == "command.batch" {
|
||||
return Err(bad("command.batch", "batches cannot nest"));
|
||||
}
|
||||
if id == "prefs.import" {
|
||||
return Err(bad("command.batch", "`prefs.import` runs on its own, not as a batch step"));
|
||||
}
|
||||
if s.active().is_some() {
|
||||
s.begin_interaction(label)?;
|
||||
}
|
||||
|
||||
@@ -373,6 +373,7 @@ pub(crate) fn with_single_artboard(mut d: Document, rect: Rect, name: &str) -> D
|
||||
show_video_safe_areas: false,
|
||||
video_ruler_pixel_aspect_ratio: 1.0,
|
||||
color: None,
|
||||
guide_label: None,
|
||||
}];
|
||||
d
|
||||
}
|
||||
|
||||
@@ -270,7 +270,9 @@ fn flattened(src: &Document, at: usize, id: NodeId) -> Result<Option<Flat>> {
|
||||
let pt = im.xf.determinant().abs().sqrt();
|
||||
let ppi = if pt.is_finite() && pt > 0.0 { (72.0 / pt).clamp(MIN_PPI, MAX_PPI) } else { MIN_PPI };
|
||||
let area = (bounds.width() * bounds.height()).max(1.0);
|
||||
let scale = (ppi / 72.0).min((super::super::menucmds::MAX_PIXELS / area).sqrt());
|
||||
// No side longer than the renderer draws (rounded up to whole pixels below).
|
||||
let longest = (f64::from(vectorcraft_render::MAX_RASTER_SIDE) - 2.0) / bounds.width().max(bounds.height()).max(1.0);
|
||||
let scale = (ppi / 72.0).min((super::super::menucmds::MAX_PIXELS / area).sqrt()).min(longest);
|
||||
if !(scale.is_finite() && scale > 0.0) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
@@ -43,10 +43,27 @@ impl Loaded {
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// The file in the units of Preferences › Units › General when it records none of its own: a
|
||||
/// PDF, an EPS or `.ai` without ruler units, a raster image, a metafile, an Affinity file or
|
||||
/// what an importer plug-in reads, as the reference app opens such files (#5820). A native
|
||||
/// document (also one that an SVG, PDF, `.ai` or EPS file carries, `restored`), an SVG (the
|
||||
/// units of its root's size, pixels when it gives none), a DXF drawing (the unit it was read
|
||||
/// in) and a `.ai` or EPS with ruler units keep theirs. Only the units the document shows
|
||||
/// change, not its art.
|
||||
pub(crate) fn with_unit_prefs(mut self, prefs: &Prefs) -> Self {
|
||||
let records_units =
|
||||
self.restored || self.doc.units_from_file || matches!(self.format.id, "vectorcraft" | "template" | "svg" | "svgz" | "dxf");
|
||||
if !records_units {
|
||||
self.doc.units = crate::units::pref_unit(&prefs.units_general);
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// An image ready to embed: PNG/JPEG/GIF/WebP keep their bytes, other formats are stored as PNG
|
||||
/// (what browsers, PDF and SVG viewers show).
|
||||
#[derive(Clone)]
|
||||
pub struct RasterImage {
|
||||
/// Content key for `Document::images` (identical files share one blob).
|
||||
pub key: String,
|
||||
@@ -289,10 +306,10 @@ pub(super) fn outlined_if_asked(mut doc: Document, warnings: Vec<String>, opts:
|
||||
(doc, warnings)
|
||||
}
|
||||
|
||||
/// Does `doc` hold no art: only layers, with nothing in them?
|
||||
/// Does `doc` hold no art: only layers, with nothing in them but ruler guides?
|
||||
fn draws_nothing(doc: &Document) -> bool {
|
||||
let mut art = false;
|
||||
doc.walk(|n| art |= !n.is_layer());
|
||||
doc.walk(|n| art |= !n.is_layer() && !n.is_ruler_guide());
|
||||
!art
|
||||
}
|
||||
|
||||
@@ -490,7 +507,10 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
||||
// document keeps its text as saved), and every frame of a thread takes its first frame's
|
||||
// Optical Margin Alignment setting and writing direction. When that changes the direction of a
|
||||
// frame of a restored document, the story flows through that frame's thread again. Threaded
|
||||
// frames keep their size: Auto Size is off for them.
|
||||
// frames keep their size: Auto Size is off for them. The story is laid out in the document's
|
||||
// own composite fonts.
|
||||
let fonts = if doc.text_threads.is_empty() { Default::default() } else { doc.composite_fonts.clone() };
|
||||
let lent = vectorcraft_text::FontDb::global().lend_composite_fonts(&fonts);
|
||||
if !doc.text_threads.is_empty() && !restored {
|
||||
crate::cmd::threads::reflow(&Document::new(1.0, 1.0), &mut doc);
|
||||
} else if !doc.text_threads.is_empty() {
|
||||
@@ -501,6 +521,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
||||
// paragraph's first-line indent and list marker.
|
||||
crate::cmd::threads::mark_continued(&mut doc);
|
||||
}
|
||||
drop(lent);
|
||||
if let Some(mode) = opts.color_mode.filter(|m| *m != doc.color_mode) {
|
||||
super::super::colormgmt::set_color_mode(&mut doc, mode, true, None, opts.grays);
|
||||
}
|
||||
@@ -610,7 +631,8 @@ pub fn open_template(s: &mut Session, name: &str, bytes: &[u8], path: Option<&st
|
||||
/// Make a loaded file the new active document; `links` is what became of its linked files, and
|
||||
/// `opts` are the options it was read with.
|
||||
fn open_loaded(s: &mut Session, loaded: Loaded, links: Resolved, path: Option<String>, opts: &LoadOptions, as_template: bool) -> Result<Value> {
|
||||
let Loaded { mut doc, format, warnings, restored, converted, native_pdf_compatible, .. } = loaded.with_grid_prefs(&s.prefs);
|
||||
let Loaded { mut doc, format, warnings, restored, converted, native_pdf_compatible, .. } =
|
||||
loaded.with_grid_prefs(&s.prefs).with_unit_prefs(&s.prefs);
|
||||
// EPS now has an ordinary Save path. Its preview, partial-program and damaged-editing
|
||||
// fallbacks must ask before overwriting the richer source they could not fully read.
|
||||
let losses: Vec<String> = warnings
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1051,7 +1051,7 @@ fn can_revert(s: &Session) -> std::result::Result<(), String> {
|
||||
fn revert(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let index = s.active_index().ok_or(crate::EngineError::NoDocument)?;
|
||||
let path = s.doc()?.path.clone().ok_or_else(|| bad("file.revert", "the document has never been saved"))?;
|
||||
let Loaded { mut doc, native_pdf_compatible, .. } = load(&path, &read_file(&path)?)?.with_grid_prefs(&s.prefs);
|
||||
let Loaded { mut doc, native_pdf_compatible, .. } = load(&path, &read_file(&path)?)?.with_grid_prefs(&s.prefs).with_unit_prefs(&s.prefs);
|
||||
doc.template = false;
|
||||
// Back to the saved file: its recovery copy has nothing left to recover.
|
||||
let uid = s.doc()?.uid;
|
||||
|
||||
@@ -914,3 +914,34 @@ fn read_path_for_a_named_importer_stops_at_the_limit() {
|
||||
assert!(e.to_string().contains("larger than") && e.to_string().contains("drawing.tuv"), "{e}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #3084, #2096: Export for Screens writes JPGs a strict reader opens, in every colour model and at
|
||||
/// the qualities and scales the reports used. A four-component (CMYK) file has no JFIF segment,
|
||||
/// which describes only one- and three-component images (#3140); RGB and gray files keep it.
|
||||
#[test]
|
||||
fn export_for_screens_jpgs_open_in_every_colour_model() {
|
||||
let mut s = session(100.0, 80.0, 1);
|
||||
s.execute("paint.setFill", &json!({"color": "#cc3300"})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 60, "height": 40})).unwrap();
|
||||
for (model, components) in [("rgb", 3), ("cmyk", 4), ("gray", 1)] {
|
||||
for quality in [100, 80, 50] {
|
||||
let r = s
|
||||
.execute(
|
||||
"document.exportForScreens",
|
||||
&json!({"formats": [{"format": "jpg"}, {"format": "jpg", "scale": 2}], "settings": {"jpg": {"colorModel": model, "quality": quality}}}),
|
||||
)
|
||||
.unwrap();
|
||||
let files = r["files"].as_array().unwrap();
|
||||
assert_eq!(files.len(), 2, "{model} {quality}: {r}");
|
||||
for (f, width) in files.iter().zip([100, 200]) {
|
||||
let bytes = b64(f);
|
||||
let what = format!("{model} q{quality} {}", f["name"]);
|
||||
assert_eq!(bytes.get(..2), Some(&[0xFF, 0xD8][..]), "{what}: starts with SOI");
|
||||
let jfif = bytes.get(2..4) == Some(&[0xFF, 0xE0][..]);
|
||||
assert_eq!(jfif, components != 4, "{what}: JFIF only on one- and three-component files");
|
||||
let image = image::load_from_memory(&bytes).unwrap_or_else(|e| panic!("{what}: {e}"));
|
||||
assert_eq!(image.width(), width, "{what}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,6 +170,28 @@ fn transparency_is_flattened_with_the_flattener_preset() {
|
||||
assert!(e.contains("preset"), "{e}");
|
||||
}
|
||||
|
||||
/// A picture cropped by a clipping path, in a clip group at 80%, over a square that only the
|
||||
/// hidden part of the picture covers: the file prints the square, and its image is as large as
|
||||
/// the crop, with the default preset (a rectangle of image) and with High Resolution (an image
|
||||
/// clipped to its regions).
|
||||
#[test]
|
||||
fn flattening_cropped_art_keeps_the_art_under_its_hidden_part() {
|
||||
use super::tests_pdfx::{Art, CROP, cropped, drawn, lost, shown_image_sizes};
|
||||
let _g = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(|e| e.into_inner());
|
||||
for preset in ["medium", "high"] {
|
||||
let (mut s, _) = cropped("rgb", 400.0, Art::Image { see_through: false });
|
||||
s.execute("transparency.set", &json!({"opacity": 80})).unwrap();
|
||||
let canvas = drawn(&s.doc().unwrap().doc);
|
||||
let opts = json!({"artboards": [0], "flattenerPreset": preset, "previewFormat": "none", "thumbnails": false});
|
||||
let r = s.execute("document.exportEps", &opts).unwrap();
|
||||
// What the file prints, not the document it carries.
|
||||
let back = vectorcraft_eps::import_with(&b64(&r), false).unwrap().document;
|
||||
assert_eq!(lost(&canvas, &drawn(&back)), 0, "{preset}: art went missing");
|
||||
let sizes = shown_image_sizes(&back);
|
||||
assert!(!sizes.is_empty() && sizes.iter().all(|(w, h)| *w <= CROP[2] + 1.0 && *h <= CROP[3] + 1.0), "{preset}: the images show {sizes:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overprints_are_kept_or_discarded() {
|
||||
let mut s = session(100.0, 100.0, 1);
|
||||
|
||||
@@ -187,6 +187,26 @@ fn an_ai_with_an_artboard_past_14400_pt_reopens_at_its_size() {
|
||||
assert_eq!(size(&s), (20_000.0, 10_000.0));
|
||||
}
|
||||
|
||||
/// A page that a PDF 1.3 file can't hold (a side past 32,767 pt that isn't a whole number of
|
||||
/// points) fails the export before the thumbnails, the editing data and the flattening: the editing data is never asked for, and the flattener options, which the
|
||||
/// flattening would refuse, are never read.
|
||||
#[test]
|
||||
fn a_page_too_long_for_pdf_1_3_fails_before_the_flattening() {
|
||||
let doc = vectorcraft_doc::Document::new(40_000.5, 300.0);
|
||||
let p = json!({"compatibility": "1.3", "thumbnails": true, "preserveEditing": true, "flattener": {"balance": 500}});
|
||||
let asked = std::cell::Cell::new(false);
|
||||
let native = || {
|
||||
asked.set(true);
|
||||
Ok(vec![])
|
||||
};
|
||||
let e = super::pdf::encode_carrying("document.exportPdf", &doc, &p, native, || Ok(None)).unwrap_err();
|
||||
assert!(
|
||||
e.to_string().contains("PDF 1.3 files hold real numbers up to 32,767, and the page of artboard 1, 40001 pt long, needs larger ones"),
|
||||
"{e}"
|
||||
);
|
||||
assert!(!asked.get(), "the editing data is not made");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ait_templates_open_their_document_untitled() {
|
||||
let mut s = rich();
|
||||
|
||||
@@ -186,3 +186,516 @@ fn pdfx_1a_flattening_keeps_the_inks_behind_an_image_in_a_cmyk_document() {
|
||||
let ink = image_inks_at(&back, vectorcraft_geom::Point::new(88.0, 50.0)).expect("a CMYK image behind the see-through pixels");
|
||||
assert!(ink.iter().zip(teal).all(|(a, b)| (a - b).abs() <= 1.5 / 255.0), "{ink:?} is not {teal:?}");
|
||||
}
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
|
||||
}
|
||||
|
||||
/// The art a clip group crops in [`cropped`].
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) enum Art {
|
||||
/// A 240 × 160 image, with see-through pixels or without.
|
||||
Image { see_through: bool },
|
||||
/// A rectangle with a gradient fill.
|
||||
Gradient,
|
||||
/// A rectangle with a drop shadow.
|
||||
Shadow,
|
||||
}
|
||||
|
||||
/// Where [`cropped`] puts the cropped art, [x, y, width, height].
|
||||
const FRAME: [f64; 4] = [40.0, 40.0, 240.0, 160.0];
|
||||
/// The part of [`FRAME`] the clipping path of [`cropped`] shows.
|
||||
pub(super) const CROP: [f64; 4] = [50.0, 50.0, 80.0, 60.0];
|
||||
|
||||
/// A `mode` document whose first artboard is `width` wide, with a dark square and, over it, `art`
|
||||
/// filling [`FRAME`] in a clip group that shows [`CROP`] of it. The square lies inside the frame
|
||||
/// and outside the crop: only the hidden part of the art covers it. → the session with the clip
|
||||
/// group selected, and the group's id.
|
||||
pub(super) fn cropped(mode: &str, width: f64, art: Art) -> (Session, u64) {
|
||||
cropped_by(mode, width, art, false)
|
||||
}
|
||||
|
||||
/// [`cropped`], with the art and its clipping path in a clipping layer above the square's layer
|
||||
/// when `layer`. → the session and the clip group's or the layer's id.
|
||||
fn cropped_by(mode: &str, width: f64, art: Art, layer: bool) -> (Session, u64) {
|
||||
let [x, y, w, h] = FRAME;
|
||||
let mut s = Session::new();
|
||||
run(&mut s, "file.new", json!({"width": width, "height": 300, "colorMode": mode}));
|
||||
run(&mut s, "shape.rectangle", json!({"x": 180, "y": 90, "width": 60, "height": 60}));
|
||||
run(&mut s, "paint.setFill", json!({"color": "#203040"}));
|
||||
run(&mut s, "paint.setStroke", json!({"none": true}));
|
||||
let layer = layer.then(|| run(&mut s, "layer.new", json!({}))["id"].as_u64().unwrap());
|
||||
let art = match art {
|
||||
Art::Image { see_through } => {
|
||||
let mut png = vec![];
|
||||
image::RgbaImage::from_fn(240, 160, |x, y| {
|
||||
image::Rgba([200, (x % 256) as u8, (y % 256) as u8, if see_through && x < 60 { 128 } else { 255 }])
|
||||
})
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
let placed =
|
||||
run(&mut s, "file.place", json!({"name": "photo.png", "dataBase64": vectorcraft_format::base64_encode(&png), "rect": FRAME}));
|
||||
placed["ids"][0].clone()
|
||||
}
|
||||
Art::Gradient | Art::Shadow => {
|
||||
let id = run(&mut s, "shape.rectangle", json!({"x": x, "y": y, "width": w, "height": h}))["id"].clone();
|
||||
run(&mut s, "paint.setStroke", json!({"none": true}));
|
||||
if matches!(art, Art::Gradient) {
|
||||
let stops = json!([{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]);
|
||||
run(&mut s, "paint.setFill", json!({"gradient": {"kind": "linear", "stops": stops}}));
|
||||
} else {
|
||||
run(&mut s, "paint.setFill", json!({"color": "#c04020"}));
|
||||
run(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow", "ids": [id]}));
|
||||
}
|
||||
id
|
||||
}
|
||||
};
|
||||
let [x, y, w, h] = CROP;
|
||||
let clip = run(&mut s, "shape.rectangle", json!({"x": x, "y": y, "width": w, "height": h}))["id"].clone();
|
||||
if let Some(layer) = layer {
|
||||
run(&mut s, "layer.clippingMask.toggle", json!({"id": layer}));
|
||||
run(&mut s, "select.set", json!({"ids": [art]}));
|
||||
return (s, layer);
|
||||
}
|
||||
run(&mut s, "select.set", json!({"ids": [art, clip]}));
|
||||
let group = run(&mut s, "object.clippingMask.make", json!({}))["id"].as_u64().unwrap();
|
||||
(s, group)
|
||||
}
|
||||
|
||||
/// `document.exportPdf` with `p`, read back.
|
||||
fn read_back(s: &mut Session, p: &Value) -> vectorcraft_doc::Document {
|
||||
let v = run(s, "document.exportPdf", p.clone());
|
||||
vectorcraft_pdf::import(&vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
/// `doc`'s artboards drawn on white at 72 ppi.
|
||||
pub(super) fn drawn(doc: &vectorcraft_doc::Document) -> vectorcraft_render::Rendered {
|
||||
let pages = doc.artboards.iter().map(|a| a.rect).reduce(|a, b| a.union(b)).unwrap();
|
||||
vectorcraft_render::Renderer::new().render_region(doc, pages, 1.0, true)
|
||||
}
|
||||
|
||||
/// How many pixels show art in `reference` and are blank in `variant`.
|
||||
pub(super) fn lost(reference: &vectorcraft_render::Rendered, variant: &vectorcraft_render::Rendered) -> usize {
|
||||
assert_eq!((reference.width, reference.height), (variant.width, variant.height));
|
||||
let (reference, variant) = (reference.pixels.as_chunks::<4>().0, variant.pixels.as_chunks::<4>().0);
|
||||
reference.iter().zip(variant).filter(|(r, v)| r[..3].iter().any(|c| *c < 200) && v[..3].iter().all(|c| *c >= 250)).count()
|
||||
}
|
||||
|
||||
/// How many pixels of `a` and `b` differ by more than 24 in a channel.
|
||||
fn differ(a: &vectorcraft_render::Rendered, b: &vectorcraft_render::Rendered) -> usize {
|
||||
assert_eq!((a.width, a.height), (b.width, b.height));
|
||||
let (a, b) = (a.pixels.as_chunks::<4>().0, b.pixels.as_chunks::<4>().0);
|
||||
a.iter().zip(b).filter(|(p, q)| p[..3].iter().zip(&q[..3]).any(|(x, y)| x.abs_diff(*y) > 24)).count()
|
||||
}
|
||||
|
||||
/// The width and height of what each of `doc`'s images shows: its frame inside the clipping
|
||||
/// paths around it.
|
||||
pub(super) fn shown_image_sizes(doc: &vectorcraft_doc::Document) -> Vec<(f64, f64)> {
|
||||
use vectorcraft_geom::Rect;
|
||||
fn visit(n: &vectorcraft_doc::Node, clip: Option<Rect>, out: &mut Vec<(f64, f64)>) {
|
||||
let within = |r: Rect| clip.map_or(r, |c| c.intersect(r));
|
||||
if let vectorcraft_doc::NodeKind::Image(im) = &n.kind {
|
||||
let frame = im.xf.transform_rect_bbox(Rect::new(0.0, 0.0, im.width as f64, im.height as f64));
|
||||
let shown = within(frame);
|
||||
out.push((shown.width(), shown.height()));
|
||||
}
|
||||
let clip = if n.clips() { n.visual_bounds().map(within) } else { clip };
|
||||
for c in n.children().into_iter().flatten() {
|
||||
visit(c, clip, out);
|
||||
}
|
||||
}
|
||||
let mut out = vec![];
|
||||
for l in &doc.layers {
|
||||
visit(l, None, &mut out);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Checks that the PDF/X-1a, PDF/X-3 and PDF 1.3 files of `s` (with `extra` options) show all the
|
||||
/// art the file with transparency shows, and that no image in them shows more than [`CROP`]. Adds
|
||||
/// what is wrong to `wrong`.
|
||||
fn check_flat_files(s: &mut Session, extra: &Value, what: &str, wrong: &mut Vec<String>) {
|
||||
let reference = drawn(&read_back(s, extra));
|
||||
for flat in [json!({"standard": "pdfX1a"}), json!({"standard": "pdfX3"}), json!({"compatibility": "1.3", "preserveEditing": false})] {
|
||||
let mut p = flat.clone();
|
||||
super::pdf::merge(&mut p, extra);
|
||||
let back = read_back(s, &p);
|
||||
let lost = lost(&reference, &drawn(&back));
|
||||
if lost > 0 {
|
||||
wrong.push(format!("{what}, {flat}: {lost} pixels of art went missing"));
|
||||
}
|
||||
let sizes = shown_image_sizes(&back);
|
||||
// An image reaches a pixel past its regions, and a group rasterized whole a point past
|
||||
// its visible bounds.
|
||||
if sizes.is_empty() || sizes.iter().any(|(w, h)| *w > CROP[2] + 3.0 || *h > CROP[3] + 3.0) {
|
||||
wrong.push(format!("{what}, {flat}: the images show {sizes:?}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Art cropped by a clipping path, in a PDF file without transparency: the part of the art the
|
||||
/// clipping path hides is not rasterized, so art that lies under the hidden part alone still
|
||||
/// shows. Each case is one way the cropped art comes to be flattened.
|
||||
#[test]
|
||||
fn flattening_cropped_art_for_pdfx_keeps_the_art_under_its_hidden_part() {
|
||||
let _g = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let opaque = Art::Image { see_through: false };
|
||||
type Change = fn(&mut Session, u64);
|
||||
let cases: [(&str, Art, Change); 9] = [
|
||||
("an image with see-through pixels", Art::Image { see_through: true }, |_, _| {}),
|
||||
("a clip group with opacity", opaque, |s, _| {
|
||||
run(s, "transparency.set", json!({"opacity": 80}));
|
||||
}),
|
||||
("a clip group with a blend mode", opaque, |s, _| {
|
||||
run(s, "transparency.set", json!({"blendMode": "multiply"}));
|
||||
}),
|
||||
("a see-through object over the crop", opaque, |s, _| {
|
||||
run(s, "shape.rectangle", json!({"x": 60, "y": 60, "width": 40, "height": 30}));
|
||||
run(s, "paint.setFill", json!({"color": "#ffffff"}));
|
||||
run(s, "transparency.set", json!({"opacity": 60}));
|
||||
}),
|
||||
("a gradient", Art::Gradient, |s, _| {
|
||||
run(s, "transparency.set", json!({"opacity": 80}));
|
||||
}),
|
||||
("a raster effect", Art::Shadow, |_, _| {}),
|
||||
("a clip group inside a clip group", Art::Image { see_through: true }, |s, group| {
|
||||
let outer = run(s, "shape.rectangle", json!({"x": 45, "y": 45, "width": 85, "height": 65}))["id"].clone();
|
||||
run(s, "select.set", json!({"ids": [group, outer]}));
|
||||
run(s, "object.clippingMask.make", json!({}));
|
||||
}),
|
||||
("a clip group inside a group with opacity", opaque, |s, _| {
|
||||
run(s, "object.group", json!({}));
|
||||
run(s, "transparency.set", json!({"opacity": 80}));
|
||||
}),
|
||||
("an even-odd compound clipping path", opaque, |s, group| {
|
||||
run(s, "transparency.set", json!({"opacity": 80}));
|
||||
let doc = s.doc_mut().unwrap();
|
||||
let mut d = (*doc.doc).clone();
|
||||
let clip = d.node(vectorcraft_doc::NodeId(group)).unwrap().children().unwrap()[0].id;
|
||||
let mut ring = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(50.0, 50.0, 130.0, 110.0));
|
||||
ring.subpaths.extend(vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(70.0, 65.0, 110.0, 95.0)).subpaths);
|
||||
if let vectorcraft_doc::NodeKind::Path { path, rule, .. } = &mut d.node_mut(clip).unwrap().kind {
|
||||
(*path, *rule) = (ring, vectorcraft_geom::FillRule::EvenOdd);
|
||||
}
|
||||
doc.doc = d.into();
|
||||
}),
|
||||
];
|
||||
let mut wrong = vec![];
|
||||
for mode in ["cmyk", "rgb"] {
|
||||
for (what, art, change) in cases {
|
||||
let (mut s, group) = cropped(mode, 400.0, art);
|
||||
change(&mut s, group);
|
||||
check_flat_files(&mut s, &json!({}), &format!("{mode}: {what}"), &mut wrong);
|
||||
}
|
||||
}
|
||||
assert!(wrong.is_empty(), "{wrong:#?}");
|
||||
}
|
||||
|
||||
/// The same with the Medium and Low Resolution presets, and with a balance of 0, which rasterizes
|
||||
/// the whole group of the cropped art over the group's visible bounds.
|
||||
#[test]
|
||||
fn flattening_cropped_art_for_pdfx_keeps_the_art_under_its_hidden_part_with_each_preset() {
|
||||
let _g = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut wrong = vec![];
|
||||
for extra in [json!({"flattenerPreset": "medium"}), json!({"flattenerPreset": "low"}), json!({"flattener": {"balance": 0}})] {
|
||||
for (art, name) in [(Art::Image { see_through: true }, "image"), (Art::Gradient, "gradient")] {
|
||||
let (mut s, _) = cropped("cmyk", 400.0, art);
|
||||
run(&mut s, "transparency.set", json!({"opacity": 80}));
|
||||
check_flat_files(&mut s, &extra, &format!("{name}, {extra}"), &mut wrong);
|
||||
}
|
||||
}
|
||||
// A cropped rectangle with a solid fill, rasterized whole.
|
||||
let (mut s, group) = cropped("cmyk", 400.0, Art::Gradient);
|
||||
let art = s.doc().unwrap().doc.node(vectorcraft_doc::NodeId(group)).unwrap().children().unwrap()[1].id.0;
|
||||
run(&mut s, "paint.setFill", json!({"ids": [art], "color": "#c04020"}));
|
||||
run(&mut s, "transparency.set", json!({"ids": [group], "opacity": 80}));
|
||||
check_flat_files(&mut s, &json!({"flattener": {"balance": 0}}), "solid fill, balance 0", &mut wrong);
|
||||
assert!(wrong.is_empty(), "{wrong:#?}");
|
||||
}
|
||||
|
||||
/// The hidden part of cropped art on one artboard lies over the next artboard: that page keeps
|
||||
/// its art.
|
||||
#[test]
|
||||
fn flattening_cropped_art_for_pdfx_keeps_the_next_page_under_its_hidden_part() {
|
||||
let _g = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(|e| e.into_inner());
|
||||
// The crop is on the first artboard, and the square on the second.
|
||||
let (mut s, _) = cropped("cmyk", 150.0, Art::Image { see_through: true });
|
||||
run(&mut s, "artboard.new", json!({"x": 170, "y": 0, "width": 230, "height": 300}));
|
||||
assert_eq!(read_back(&mut s, &json!({"standard": "pdfX1a"})).artboards.len(), 2);
|
||||
let mut wrong = vec![];
|
||||
check_flat_files(&mut s, &json!({}), "two artboards", &mut wrong);
|
||||
assert!(wrong.is_empty(), "{wrong:#?}");
|
||||
}
|
||||
|
||||
/// Art cropped by a clipping layer, over a square in the layer below: the square keeps showing,
|
||||
/// also with a second square in that layer under both the crop and the hidden part, or with a
|
||||
/// see-through rectangle over both in a layer above.
|
||||
#[test]
|
||||
fn flattening_art_of_a_clipping_layer_for_pdfx_keeps_the_art_under_its_hidden_part() {
|
||||
let _g = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(|e| e.into_inner());
|
||||
type Change = fn(&mut Session, u64);
|
||||
let cases: [(&str, Change); 3] = [
|
||||
("alone", |_, _| {}),
|
||||
("over a square under the crop's edge", |s, _| {
|
||||
let below = s.doc().unwrap().doc.layers[0].id.0;
|
||||
run(s, "layer.setCurrent", json!({"id": below}));
|
||||
run(s, "shape.rectangle", json!({"x": 110, "y": 60, "width": 50, "height": 30}));
|
||||
run(s, "paint.setFill", json!({"color": "#406020"}));
|
||||
}),
|
||||
("under a see-through rectangle over the crop's edge", |s, _| {
|
||||
run(s, "layer.new", json!({}));
|
||||
run(s, "shape.rectangle", json!({"x": 110, "y": 60, "width": 50, "height": 30}));
|
||||
run(s, "paint.setFill", json!({"color": "#406020"}));
|
||||
run(s, "transparency.set", json!({"opacity": 60}));
|
||||
}),
|
||||
];
|
||||
let mut wrong = vec![];
|
||||
for mode in ["cmyk", "rgb"] {
|
||||
for art in [Art::Image { see_through: true }, Art::Gradient] {
|
||||
for (what, change) in cases {
|
||||
let (mut s, layer) = cropped_by(mode, 400.0, art, true);
|
||||
run(&mut s, "transparency.set", json!({"opacity": 80}));
|
||||
change(&mut s, layer);
|
||||
check_flat_files(&mut s, &json!({}), &format!("{mode}: a clipping layer {what}"), &mut wrong);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(wrong.is_empty(), "{wrong:#?}");
|
||||
}
|
||||
|
||||
/// Documents in which no clip group hides art export the same bytes as they did when the
|
||||
/// flattener rasterized hidden art too.
|
||||
#[test]
|
||||
fn flat_pdf_files_of_art_that_no_clip_group_hides_are_unchanged() {
|
||||
let _g = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(|e| e.into_inner());
|
||||
use crate::cmd::flatten::with_clips_ignored as before;
|
||||
// Written at one time, so two files differ only by what they draw.
|
||||
let bytes = |s: &mut Session, p: &Value| super::pdf::encode_at("document.exportPdf", &s.doc().unwrap().doc, p, Some(1_700_000_000)).unwrap().0;
|
||||
let files = [
|
||||
json!({"standard": "pdfX1a"}),
|
||||
json!({"standard": "pdfX3"}),
|
||||
json!({"compatibility": "1.3", "preserveEditing": false}),
|
||||
json!({"standard": "pdfX1a", "flattener": {"balance": 0}}),
|
||||
json!({"standard": "pdfX1a", "flattenerPreset": "low"}),
|
||||
];
|
||||
// Transparency without a clip group.
|
||||
let mut plain = session();
|
||||
// A clip group at 80% whose clipping path is as large as its art, over the edge of a square.
|
||||
let mut fitted = Session::new();
|
||||
run(&mut fitted, "file.new", json!({"width": 400, "height": 300, "colorMode": "cmyk"}));
|
||||
run(&mut fitted, "shape.rectangle", json!({"x": 180, "y": 90, "width": 160, "height": 160}));
|
||||
run(&mut fitted, "paint.setFill", json!({"color": "#203040"}));
|
||||
let mut png = vec![];
|
||||
image::RgbaImage::from_fn(240, 160, |x, y| image::Rgba([200, x as u8, y as u8, 255]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
let photo =
|
||||
run(&mut fitted, "file.place", json!({"name": "photo.png", "dataBase64": vectorcraft_format::base64_encode(&png), "rect": FRAME}))["ids"][0]
|
||||
.clone();
|
||||
let [x, y, w, h] = FRAME;
|
||||
let clip = run(&mut fitted, "shape.rectangle", json!({"x": x, "y": y, "width": w, "height": h}))["id"].clone();
|
||||
run(&mut fitted, "select.set", json!({"ids": [photo, clip]}));
|
||||
run(&mut fitted, "object.clippingMask.make", json!({}));
|
||||
run(&mut fitted, "transparency.set", json!({"opacity": 80}));
|
||||
// Cropped art that is not flattened: nothing on it is see-through.
|
||||
let (mut opaque, _) = cropped("cmyk", 400.0, Art::Image { see_through: false });
|
||||
for (what, s) in [("no clip group", &mut plain), ("a clipping path as large as its art", &mut fitted), ("opaque cropped art", &mut opaque)] {
|
||||
for p in &files {
|
||||
assert!(bytes(s, p) == before(|| bytes(s, p)), "{what}, {p}");
|
||||
}
|
||||
}
|
||||
// Art that a clip group hides is what changes.
|
||||
let (mut s, _) = cropped("cmyk", 400.0, Art::Image { see_through: true });
|
||||
assert!(bytes(&mut s, &files[0]) != before(|| bytes(&mut s, &files[0])));
|
||||
}
|
||||
|
||||
/// The options of the PDF files without transparency.
|
||||
const FLAT_FILES: [&str; 3] = [r#"{"standard": "pdfX1a"}"#, r#"{"standard": "pdfX3"}"#, r#"{"compatibility": "1.3", "preserveEditing": false}"#];
|
||||
|
||||
/// [`cropped_by`]'s clipping layer of a gradient at 80%, with a rectangle in the layer below that
|
||||
/// crosses the crop's right edge. → the session and the clipping layer's id.
|
||||
fn clipping_layer_over_a_rectangle(mode: &str) -> (Session, u64) {
|
||||
let (mut s, layer) = cropped_by(mode, 400.0, Art::Gradient, true);
|
||||
run(&mut s, "transparency.set", json!({"opacity": 80}));
|
||||
let below = s.doc().unwrap().doc.layers[0].id.0;
|
||||
run(&mut s, "layer.setCurrent", json!({"id": below}));
|
||||
run(&mut s, "shape.rectangle", json!({"x": 100, "y": 60, "width": 120, "height": 40}));
|
||||
run(&mut s, "paint.setFill", json!({"color": "#406020"}));
|
||||
(s, layer)
|
||||
}
|
||||
|
||||
/// A clipping layer over a layer that doesn't print, and a clipping layer that doesn't print
|
||||
/// over one that does: the files without transparency print what the file with transparency
|
||||
/// prints, and nothing of the layer that doesn't print.
|
||||
#[test]
|
||||
fn flattening_for_pdfx_leaves_out_the_layers_that_do_not_print() {
|
||||
let _g = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut wrong = vec![];
|
||||
for mode in ["cmyk", "rgb"] {
|
||||
for clipping_layer_prints in [true, false] {
|
||||
let (mut s, layer) = clipping_layer_over_a_rectangle(mode);
|
||||
let below = s.doc().unwrap().doc.layers[0].id.0;
|
||||
let off = if clipping_layer_prints { below } else { layer };
|
||||
run(&mut s, "layer.setProps", json!({"id": off, "printable": false}));
|
||||
let reference = drawn(&read_back(&mut s, &json!({})));
|
||||
for flat in FLAT_FILES {
|
||||
let back = drawn(&read_back(&mut s, &serde_json::from_str(flat).unwrap()));
|
||||
let (lost, gained) = (lost(&reference, &back), lost(&back, &reference));
|
||||
if lost + gained > 0 {
|
||||
wrong.push(format!("{mode}, clipping layer prints: {clipping_layer_prints}, {flat}: {lost} pixels lost, {gained} gained"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(wrong.is_empty(), "{wrong:#?}");
|
||||
}
|
||||
|
||||
/// A clipping layer whose clipping path has a fill: the files without transparency show the art
|
||||
/// over the fill, not the fill over the art.
|
||||
#[test]
|
||||
fn flattening_for_pdfx_keeps_the_art_of_a_clipping_layer_over_its_clipping_path_fill() {
|
||||
let _g = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut wrong = vec![];
|
||||
for mode in ["cmyk", "rgb"] {
|
||||
let (mut s, layer) = clipping_layer_over_a_rectangle(mode);
|
||||
let clip = s.doc().unwrap().doc.node(vectorcraft_doc::NodeId(layer)).unwrap().children().unwrap()[0].id.0;
|
||||
run(&mut s, "paint.setFill", json!({"ids": [clip], "color": "#ffdd00"}));
|
||||
let reference = drawn(&read_back(&mut s, &json!({})));
|
||||
for flat in FLAT_FILES {
|
||||
let back = drawn(&read_back(&mut s, &serde_json::from_str(flat).unwrap()));
|
||||
// PDF/X-1a converts an RGB document's colors to CMYK.
|
||||
let converted = mode == "rgb" && flat.contains("pdfX1a");
|
||||
let (lost, differ) = (lost(&reference, &back), if converted { 0 } else { differ(&reference, &back) });
|
||||
if lost > 0 || differ > 100 {
|
||||
wrong.push(format!("{mode}, {flat}: {lost} pixels of art lost, {differ} pixels differ"));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(wrong.is_empty(), "{wrong:#?}");
|
||||
}
|
||||
|
||||
/// Transparent art in a clipping layer whose clipping path is area type holding only a space,
|
||||
/// which hides it: the files without transparency are written, without the art.
|
||||
#[test]
|
||||
fn flattening_for_pdfx_removes_transparent_art_that_a_clipping_layer_hides_entirely() {
|
||||
let _g = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let [x, y, w, h] = FRAME;
|
||||
let mut s = Session::new();
|
||||
run(&mut s, "file.new", json!({"width": 400, "height": 300, "colorMode": "cmyk"}));
|
||||
let layer = run(&mut s, "layer.new", json!({}))["id"].as_u64().unwrap();
|
||||
run(&mut s, "shape.rectangle", json!({"x": x, "y": y, "width": w, "height": h}));
|
||||
let stops = json!([{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]);
|
||||
run(&mut s, "paint.setFill", json!({"gradient": {"kind": "linear", "stops": stops}}));
|
||||
run(&mut s, "transparency.set", json!({"opacity": 80}));
|
||||
run(&mut s, "text.create", json!({"x": 50, "y": 50, "text": " ", "area": {"width": 80, "height": 60}}));
|
||||
run(&mut s, "layer.clippingMask.toggle", json!({"id": layer}));
|
||||
let reference = drawn(&read_back(&mut s, &json!({})));
|
||||
for flat in FLAT_FILES {
|
||||
let p: Value = serde_json::from_str(flat).unwrap();
|
||||
let back = match s.execute("document.exportPdf", &p) {
|
||||
Ok(v) => vectorcraft_pdf::import(&vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap()).unwrap(),
|
||||
Err(e) => panic!("{flat}: {e}"),
|
||||
};
|
||||
assert_eq!(lost(&drawn(&back), &reference), 0, "{flat}: the hidden art prints");
|
||||
}
|
||||
}
|
||||
|
||||
/// Object › Flatten Transparency of art in a clipping layer and art in the layer below that
|
||||
/// reaches past the clipping path: when the layer below doesn't print, the clipping layer has
|
||||
/// opacity, or the clipping path has a fill (see-through too, flattened keeping alpha, in a layer
|
||||
/// with opacity or Multiply mode, or
|
||||
/// over a layer with opacity, overlapping art of a layer with opacity or art an opacity mask
|
||||
/// hides), the group stays in the clipping layer, the art below stays in its layer, and the page
|
||||
/// looks the same.
|
||||
#[test]
|
||||
fn flattening_keeps_the_group_in_the_clipping_layer_when_the_layer_below_differs() {
|
||||
let mut wrong = vec![];
|
||||
let cases = [
|
||||
"the layer below doesn't print",
|
||||
"the clipping path has a fill",
|
||||
"the clipping path has a see-through fill, keeping alpha",
|
||||
"the clipping path has a see-through fill, in a layer with opacity",
|
||||
"the clipping path has a see-through fill, in a layer in Multiply mode",
|
||||
"the clipping path has a see-through fill, over a layer with opacity",
|
||||
"the clipping path has a see-through fill, over art that an opacity mask hides",
|
||||
"the clipping path has a see-through fill, over overlapping art of a layer at 50%",
|
||||
"the clipping path has no fill, in a layer with opacity",
|
||||
];
|
||||
for case in cases {
|
||||
let mut s = Session::new();
|
||||
run(&mut s, "file.new", json!({"width": 400, "height": 300, "colorMode": "rgb"}));
|
||||
let below = s.doc().unwrap().doc.layers[0].id.0;
|
||||
let orange = run(&mut s, "shape.rectangle", json!({"x": 20, "y": 20, "width": 200, "height": 150}))["id"].clone();
|
||||
run(&mut s, "paint.setFill", json!({"color": "#ff8000"}));
|
||||
let upper = run(&mut s, "layer.new", json!({}))["id"].as_u64().unwrap();
|
||||
let blue = run(&mut s, "shape.rectangle", json!({"x": 100, "y": 80, "width": 200, "height": 150}))["id"].clone();
|
||||
run(&mut s, "paint.setFill", json!({"color": "#2040c0"}));
|
||||
run(&mut s, "transparency.set", json!({"opacity": 50}));
|
||||
let clip = run(&mut s, "shape.rectangle", json!({"x": 60, "y": 40, "width": 300, "height": 220}))["id"].clone();
|
||||
run(&mut s, "layer.clippingMask.toggle", json!({"id": upper}));
|
||||
if case.starts_with("the layer below") {
|
||||
run(&mut s, "layer.setProps", json!({"id": below, "printable": false}));
|
||||
} else if !case.contains("no fill") {
|
||||
run(&mut s, "paint.setFill", json!({"ids": [clip], "color": "#88ffcc"}));
|
||||
}
|
||||
let keeping_alpha = case.ends_with("keeping alpha");
|
||||
if case.contains("see-through fill") {
|
||||
run(&mut s, "transparency.set", json!({"ids": [clip], "opacity": 50}));
|
||||
}
|
||||
if case.ends_with("in a layer with opacity") {
|
||||
run(&mut s, "transparency.set", json!({"ids": [upper], "opacity": 80}));
|
||||
}
|
||||
if case.ends_with("over a layer with opacity") {
|
||||
run(&mut s, "transparency.set", json!({"ids": [below], "opacity": 50}));
|
||||
}
|
||||
if case.ends_with("an opacity mask hides") {
|
||||
run(&mut s, "layer.setCurrent", json!({"id": below}));
|
||||
run(&mut s, "select.set", json!({"ids": [orange]}));
|
||||
let group = run(&mut s, "object.group", json!({}))["id"].clone();
|
||||
let mask = run(&mut s, "shape.rectangle", json!({"x": 20, "y": 20, "width": 200, "height": 150}))["id"].clone();
|
||||
run(&mut s, "paint.setFill", json!({"color": "#000000"}));
|
||||
run(&mut s, "select.set", json!({"ids": [group, mask]}));
|
||||
run(&mut s, "transparency.makeOpacityMask", json!({}));
|
||||
}
|
||||
if case.ends_with("Multiply mode") {
|
||||
run(&mut s, "transparency.set", json!({"ids": [upper], "blendMode": "multiply"}));
|
||||
}
|
||||
let mut art = vec![orange.clone()];
|
||||
if case.ends_with("a layer at 50%") {
|
||||
run(&mut s, "layer.setCurrent", json!({"id": below}));
|
||||
art.push(run(&mut s, "shape.rectangle", json!({"x": 80, "y": 60, "width": 120, "height": 120}))["id"].clone());
|
||||
run(&mut s, "paint.setFill", json!({"color": "#20a040"}));
|
||||
run(&mut s, "transparency.set", json!({"ids": [below], "opacity": 50}));
|
||||
}
|
||||
art.push(blue.clone());
|
||||
// The page as it prints.
|
||||
let printed = |d: &vectorcraft_doc::Document| {
|
||||
let mut d = d.clone();
|
||||
if case.starts_with("the layer below") {
|
||||
let mut layer = (*d.layers[0]).clone();
|
||||
layer.visible = false;
|
||||
d.layers[0] = layer.into();
|
||||
}
|
||||
drawn(&d)
|
||||
};
|
||||
let before = printed(&s.doc().unwrap().doc);
|
||||
run(&mut s, "select.set", json!({"ids": art}));
|
||||
run(&mut s, "object.flattenTransparency", json!({"preserveAlpha": keeping_alpha}));
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let layer_of = |id: &Value| id.as_u64().and_then(|id| d.ancestry(vectorcraft_doc::NodeId(id))).map(|a| a[0].0);
|
||||
if layer_of(&orange) != Some(below) {
|
||||
wrong.push(format!("{case}: the art below is in layer {:?}", layer_of(&orange)));
|
||||
}
|
||||
let upper_art = d.node(vectorcraft_doc::NodeId(upper)).and_then(|l| l.children()).map_or(0, Vec::len);
|
||||
if d.node(vectorcraft_doc::NodeId(blue.as_u64().unwrap())).is_some() || upper_art != 2 {
|
||||
wrong.push(format!("{case}: the clipping layer holds {upper_art} objects"));
|
||||
}
|
||||
let after = printed(d);
|
||||
// Flat regions differ from see-through art along their antialiased edges.
|
||||
let (lost, differ) = (lost(&before, &after), differ(&before, &after));
|
||||
if lost > 0 || differ > 1200 {
|
||||
wrong.push(format!("{case}: {lost} pixels of art lost, {differ} pixels differ"));
|
||||
}
|
||||
}
|
||||
assert!(wrong.is_empty(), "{wrong:#?}");
|
||||
}
|
||||
|
||||
@@ -49,9 +49,9 @@ mod presets;
|
||||
mod report;
|
||||
|
||||
pub(crate) use analysis::{outlined_text, see_through_images};
|
||||
#[cfg(test)]
|
||||
pub(crate) use apply::with_every_region;
|
||||
pub(crate) use apply::{expand_transparent_tile_images, flatten_document, flatten_expanded, has_transparent_tile_images};
|
||||
#[cfg(test)]
|
||||
pub(crate) use apply::{with_clips_ignored, with_every_region};
|
||||
pub use commands::specs;
|
||||
pub use options::FlattenOptions;
|
||||
pub use presets::{FlattenerPreset, PRESET_EXTS, PRESET_FORMAT};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::analysis::{Scratch, outlined_text, overlapping, reach};
|
||||
use super::analysis::{Scratch, has_effects, masked, outlined_text, overlapping, reach};
|
||||
use super::compose::{Builder, CLEAR, Composer, Elem, Fill, Kind, Px, WHITE};
|
||||
use super::options::FlattenOptions;
|
||||
use super::{
|
||||
@@ -107,27 +107,83 @@ pub(super) fn plan(src: &Document, roots: &[NodeId], o: &FlattenOptions, render:
|
||||
let art: Vec<Node> = baked.layers[0].children().into_iter().flatten().map(|a| (**a).clone()).collect();
|
||||
let mut sc = Scratch { brushes: vectorcraft_brush::library(&baked), doc: baked.clone() };
|
||||
let plain: Vec<Node> = art.iter().map(|n| sc.plain(n)).collect();
|
||||
let reaches: Vec<Option<Rect>> = plain.iter().map(reach).collect();
|
||||
// Each object as its clip groups and clipping layers show it.
|
||||
let clipped: Vec<Node> = plain.iter().zip(roots).map(|(n, id)| clipped_by_ancestors(src, *id, n, None)).collect();
|
||||
let reaches: Vec<Option<Rect>> = plain.iter().zip(&clipped).map(|(n, c)| reach(n).zip(reach(c)).map(|(a, b)| a.intersect(b))).collect();
|
||||
let see_through: Vec<bool> = plain.iter().map(|n| o.shows_transparency(&baked, n) || o.draws_see_through_image(n)).collect();
|
||||
let cmyk = src.color_mode == ColorMode::Cmyk;
|
||||
let mut out = Plan { flat: vec![], kept: vec![], transparent: roots.iter().zip(&see_through).filter(|(_, t)| **t).map(|(id, _)| *id).collect() };
|
||||
// Baking above stopped early when cancelled; each group is a place to stop (a plan changes nothing).
|
||||
vectorcraft_doc::cancel::check()?;
|
||||
for g in overlapping(&reaches)? {
|
||||
let mut queue = overlapping(&reaches)?;
|
||||
queue.reverse();
|
||||
while let Some(g) = queue.pop() {
|
||||
vectorcraft_doc::cancel::check()?;
|
||||
// A group that stays inside the clip groups and clipping layers of its topmost object
|
||||
// replaces only the objects inside them, over the others, which are flattened on their own.
|
||||
let (g, under) = match inside_and_under(src, roots, &g) {
|
||||
Some((inside, under)) => {
|
||||
queue.push(under.clone());
|
||||
(inside, under)
|
||||
}
|
||||
None => (g, vec![]),
|
||||
};
|
||||
// Composited over white, the group covers what it is drawn over, so it is composited over
|
||||
// the fills of the clipping paths around it and the objects under it in the same kind of
|
||||
// layer. Composited over nothing, it keeps its alpha and they show through.
|
||||
let backdrop = !o.preserve_alpha;
|
||||
let state = g.last().map(|&i| layer_state(src, roots[i]));
|
||||
let top = g.last().map(|&i| roots[i]);
|
||||
// A blend mode of a layer or group around the group alone would blend the art under it
|
||||
// twice.
|
||||
let blends = |i: usize| top.is_some_and(|t| own_layers(src, t, Some(roots[i])).iter().any(|a| a.blend != BlendMode::Normal));
|
||||
// Art under an opacity mask, effects or a knockout group is left out: the group can't draw it
|
||||
// as those do.
|
||||
let drawn_plainly = |i: usize| {
|
||||
own_layers(src, roots[i], top).iter().all(|a| !masked(a) && !has_effects(a) && !matches!(a.knockout, vectorcraft_doc::Knockout::On))
|
||||
};
|
||||
let under_shown = under.iter().copied().filter(|&i| backdrop && Some(layer_state(src, roots[i])) == state && !blends(i) && drawn_plainly(i));
|
||||
let composited: Vec<usize> = under_shown.chain(g.iter().copied()).collect();
|
||||
let below = composited.len() - g.len();
|
||||
let extent = composited.iter().filter_map(|&i| reaches[i]).reduce(|a, b| a.union(b)).unwrap_or_default();
|
||||
let shown = |nodes: &[Node]| -> Vec<Node> {
|
||||
let mut seen = vec![];
|
||||
let (mut under, mut out) = (vec![], vec![]);
|
||||
for (k, &i) in composited.iter().enumerate() {
|
||||
let n = clipped_by_ancestors(src, roots[i], &nodes[i], backdrop.then_some((&mut seen, extent)));
|
||||
if k < below {
|
||||
let groups = own_layers(src, roots[i], top).into_iter().filter(|a| !draws_as_is(a)).collect();
|
||||
under.push((groups, n));
|
||||
} else {
|
||||
out.push(n);
|
||||
}
|
||||
}
|
||||
let mut all = in_groups(under, 0);
|
||||
all.extend(out);
|
||||
all
|
||||
};
|
||||
let ids: Vec<NodeId> = g.iter().map(|&i| roots[i]).collect();
|
||||
let group = g.iter().any(|&i| see_through[i]).then(|| {
|
||||
let plain: Vec<&Node> = g.iter().map(|&i| &plain[i]).collect();
|
||||
flatten_group(&plain, o, cmyk)
|
||||
let plain = shown(&plain);
|
||||
flatten_group(&plain.iter().collect::<Vec<_>>(), o, cmyk)
|
||||
});
|
||||
let Some((regions, area)) = group.flatten() else {
|
||||
out.kept.extend(ids);
|
||||
// Transparent art that its clip groups or clipping layers hide entirely draws nothing:
|
||||
// it is removed.
|
||||
let hides = |i: usize| see_through[i] && reach(&plain[i]).is_some() && reach(&clipped[i]).is_none();
|
||||
let (hidden, left): (Vec<usize>, Vec<usize>) = g.iter().partition(|&&i| hides(i));
|
||||
if !hidden.is_empty() {
|
||||
let roots = hidden.iter().map(|&i| roots[i]).collect();
|
||||
out.flat.push(Flat { roots, regions: vec![], area: None, raster: None, lines: None });
|
||||
}
|
||||
out.kept.extend(left.iter().map(|&i| roots[i]));
|
||||
continue;
|
||||
};
|
||||
let raster = match area.as_ref().filter(|_| render) {
|
||||
Some(a) => {
|
||||
let art: Vec<Node> = g.iter().map(|&i| art[i].clone()).collect();
|
||||
Some(render_raster(&baked, &art, a.rect, a.ppi, a.clip.clone(), o)?)
|
||||
// Drawn as planned: the image can land outside the clip groups and clipping layers
|
||||
// around its art.
|
||||
Some(render_raster(&baked, &shown(&art), a.rect, a.ppi, a.clip.clone(), o)?)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
@@ -140,6 +196,134 @@ pub(super) fn plan(src: &Document, roots: &[NodeId], o: &FlattenOptions, render:
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// `plain` (object `id` of `src` as plain art) inside a clip group for each clip group and
|
||||
/// clipping layer around `id`, innermost first, each clipped by that group's or layer's clipping
|
||||
/// path. `plain` itself when nothing around it clips. With `fills` (clipping paths, and an area),
|
||||
/// the fill of each clipping path not in them yet goes behind the art, inside the area, and the
|
||||
/// path is added to them.
|
||||
fn clipped_by_ancestors(src: &Document, id: NodeId, plain: &Node, mut fills: Option<(&mut Vec<NodeId>, Rect)>) -> Node {
|
||||
let mut out = plain.clone();
|
||||
if !clips_count() {
|
||||
return out;
|
||||
}
|
||||
let ancestry = src.ancestry(id).unwrap_or_default();
|
||||
for pair in ancestry.windows(2).rev() {
|
||||
let Some(above) = src.node(pair[0]).filter(|n| n.clips()) else { continue };
|
||||
let Some(clip) = above.children().and_then(|c| c.first()) else { continue };
|
||||
// The clipping path itself is not clipped art.
|
||||
if clip.id == pair[1] {
|
||||
continue;
|
||||
}
|
||||
// Its outline, unpainted: the group or layer around the object still paints the path.
|
||||
// Nothing to clip by hides the clipped art, as in the compositor.
|
||||
let Some((outline, rule)) = effects::clip_outline(clip) else {
|
||||
out.visible = false;
|
||||
return out;
|
||||
};
|
||||
let mut path = Node::path(clip.id, PathData::from_bezpath(&outline), Appearance::default());
|
||||
if let NodeKind::Path { rule: r, .. } = &mut path.kind {
|
||||
*r = rule;
|
||||
}
|
||||
let mut children = vec![Arc::new(path)];
|
||||
// The renderer paints the fill behind the clipped art.
|
||||
if let Some((seen, r)) = fills.as_mut()
|
||||
&& !seen.contains(&clip.id)
|
||||
&& let Some(fill) = clip.clip_paint().fill
|
||||
{
|
||||
let r = *r;
|
||||
seen.push(clip.id);
|
||||
let mut at = Node::group(above.id, vec![Arc::new(Node::path(clip.id, shapes::rectangle(r), Appearance::default())), Arc::new(fill)]);
|
||||
at.set_clips(true);
|
||||
children.push(Arc::new(at));
|
||||
}
|
||||
children.push(Arc::new(out));
|
||||
let mut g = Node::group(above.id, children);
|
||||
g.set_clips(true);
|
||||
out = g;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether layer or group `n` draws what it holds as it is: opaque, in Normal mode, not isolated.
|
||||
fn draws_as_is(n: &Node) -> bool {
|
||||
n.opacity >= 1.0 && n.blend == BlendMode::Normal && !n.isolate
|
||||
}
|
||||
|
||||
/// `items` (in paint order, each an object and the layers and groups around it, outermost first,
|
||||
/// that don't draw it as it is) with the objects of each such layer or group in one group with its
|
||||
/// opacity, blend mode and isolation, from the `depth`th around them on: they then composite
|
||||
/// together as that layer or group draws them.
|
||||
fn in_groups(items: Vec<(Vec<&Node>, Node)>, depth: usize) -> Vec<Node> {
|
||||
let mut out = vec![];
|
||||
let mut items = items.into_iter().peekable();
|
||||
while let Some((around, n)) = items.next() {
|
||||
let Some(a) = around.get(depth).copied() else {
|
||||
out.push(n);
|
||||
continue;
|
||||
};
|
||||
let mut run = vec![(around, n)];
|
||||
while let Some(next) = items.next_if(|(around, _)| around.get(depth).is_some_and(|b| b.id == a.id)) {
|
||||
run.push(next);
|
||||
}
|
||||
let mut g = Node::group(a.id, in_groups(run, depth + 1).into_iter().map(Arc::new).collect());
|
||||
(g.opacity, g.blend, g.isolate) = (a.opacity, a.blend, a.isolate);
|
||||
out.push(g);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The layers and groups around object `id` of `src` that are not also around `other`, outermost
|
||||
/// first.
|
||||
fn own_layers(src: &Document, id: NodeId, other: Option<NodeId>) -> Vec<&Node> {
|
||||
let mine = src.ancestry(id).unwrap_or_default();
|
||||
let theirs = other.and_then(|t| src.ancestry(t)).unwrap_or_default();
|
||||
let shared = mine.iter().zip(&theirs).take_while(|(a, b)| a == b).count();
|
||||
let own = mine.get(shared..mine.len().saturating_sub(1)).unwrap_or_default();
|
||||
own.iter().filter_map(|a| src.node(*a)).collect()
|
||||
}
|
||||
|
||||
/// The clip groups and clipping layers around object `id` of `d`, innermost first.
|
||||
fn clip_ancestors(d: &Document, id: NodeId) -> Vec<NodeId> {
|
||||
let mut a = d.ancestry(id).unwrap_or_default();
|
||||
a.pop();
|
||||
a.into_iter().rev().filter(|a| d.node(*a).is_some_and(Node::clips)).collect()
|
||||
}
|
||||
|
||||
/// Whether the layers around object `id` of `d` all print, all show, and whether one is a
|
||||
/// template.
|
||||
fn layer_state(d: &Document, id: NodeId) -> (bool, bool, bool) {
|
||||
let mut a = d.ancestry(id).unwrap_or_default();
|
||||
a.pop();
|
||||
a.iter().filter_map(|a| d.node(*a)).fold((true, true, false), |(print, show, template), n| match &n.kind {
|
||||
NodeKind::Layer { printable, template: t, .. } => (print && *printable, show && n.visible, template || *t),
|
||||
_ => (print, show, template),
|
||||
})
|
||||
}
|
||||
|
||||
/// For group `g` (indices of `roots` of `src`, in paint order) whose flattened group can't leave
|
||||
/// the clip groups and clipping layers of its topmost object ([`place_of`]): the objects inside
|
||||
/// them and the objects under them. `None` when the group can leave them, or every object is
|
||||
/// inside them.
|
||||
fn inside_and_under(src: &Document, roots: &[NodeId], g: &[usize]) -> Option<(Vec<usize>, Vec<usize>)> {
|
||||
if !clips_count() || g.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let ids: Vec<NodeId> = g.iter().map(|&i| roots[i]).collect();
|
||||
let top = clip_ancestors(src, *ids.last()?);
|
||||
if top.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let inside = |id: NodeId| {
|
||||
let mine = clip_ancestors(src, id);
|
||||
top.iter().all(|c| mine.contains(c))
|
||||
};
|
||||
if !inside(place_of(src, &ids)?) {
|
||||
return None;
|
||||
}
|
||||
let (inside, under): (Vec<usize>, Vec<usize>) = g.iter().partition(|&&i| inside(roots[i]));
|
||||
(!under.is_empty()).then_some((inside, under))
|
||||
}
|
||||
|
||||
/// The regions and the image area replacing one group of overlapping objects (`plain`: the art as
|
||||
/// composited; `cmyk`: in a CMYK document); `None` when it paints nothing.
|
||||
fn flatten_group(plain: &[&Node], o: &FlattenOptions, cmyk: bool) -> Option<(Regions, Option<Area>)> {
|
||||
@@ -163,8 +347,10 @@ fn flatten_group(plain: &[&Node], o: &FlattenOptions, cmyk: bool) -> Option<(Reg
|
||||
let mut complex: Vec<usize> = vec![];
|
||||
let mut gradients_only = !all;
|
||||
for (k, r) in regions.iter().enumerate() {
|
||||
if all || r.sources.iter().any(|&i| b.kinds[i] != Kind::Vector) {
|
||||
gradients_only &= r.sources.iter().all(|&i| b.kinds[i] == Kind::Gradient);
|
||||
// Art outside the clipping path of its clip group draws nothing there.
|
||||
let shown = |i: &&usize| b.kinds[**i] == Kind::Vector || !(clips_count() && b.hidden(**i, &r.sources));
|
||||
if all || r.sources.iter().filter(shown).any(|&i| b.kinds[i] != Kind::Vector) {
|
||||
gradients_only &= r.sources.iter().filter(shown).all(|&i| b.kinds[i] == Kind::Gradient);
|
||||
complex.push(k);
|
||||
} else if let Some(fill) = region_fill(&elems, &r.sources, o, cmyk) {
|
||||
vector.push((k, fill));
|
||||
@@ -227,6 +413,35 @@ pub(crate) fn with_every_region<T>(f: impl FnOnce() -> T) -> T {
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// Groups flattened on this thread rasterize art where its clip group or clipping layer hides
|
||||
/// it too ([`with_clips_ignored`]).
|
||||
static CLIPS_IGNORED: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
|
||||
}
|
||||
|
||||
/// Whether a region outside the clipping path of a clip group or clipping layer leaves out the
|
||||
/// art it hides there.
|
||||
#[cfg(test)]
|
||||
fn clips_count() -> bool {
|
||||
!CLIPS_IGNORED.with(std::cell::Cell::get)
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
fn clips_count() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// `f` with every group it flattens rasterizing art where its clip group or clipping layer hides
|
||||
/// it too.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn with_clips_ignored<T>(f: impl FnOnce() -> T) -> T {
|
||||
CLIPS_IGNORED.with(|e| e.set(true));
|
||||
let out = f();
|
||||
CLIPS_IGNORED.with(|e| e.set(false));
|
||||
out
|
||||
}
|
||||
|
||||
/// The flat colour of a region covered by `sources` (CMYK in a `cmyk` document); `None` where
|
||||
/// nothing paints it.
|
||||
fn region_fill(elems: &[Elem], sources: &[usize], o: &FlattenOptions, cmyk: bool) -> Option<RegionFill> {
|
||||
@@ -269,6 +484,9 @@ fn region_fill(elems: &[Elem], sources: &[usize], o: &FlattenOptions, cmyk: bool
|
||||
fn render_raster(doc: &Document, art: &[Node], rect: Rect, ppi: f64, clip: Option<PathData>, o: &FlattenOptions) -> Result<Raster> {
|
||||
let mut scale = ppi / 72.0;
|
||||
scale = scale.min((MAX_PIXELS / (rect.width() * rect.height()).max(1.0)).sqrt());
|
||||
// No side longer than the renderer draws: the image grows by a pixel on each side and is
|
||||
// rounded up to whole pixels below.
|
||||
scale = scale.min((f64::from(vectorcraft_render::MAX_RASTER_SIDE) - 4.0) / rect.width().max(rect.height()).max(1.0));
|
||||
let px = 1.0 / scale;
|
||||
let rect = rect.inflate(px, px);
|
||||
let clip = clip.map(|c| Some(po::offset_path(&c, px, po::Join::Miter, 4.0)).filter(|g| !g.is_empty()).unwrap_or(c));
|
||||
@@ -403,7 +621,7 @@ pub(crate) fn flatten_expanded(doc: &Document, o: &FlattenOptions) -> Result<Opt
|
||||
let mut roots = vec![];
|
||||
for l in doc.layers.iter().filter(|l| l.visible && !matches!(l.kind, NodeKind::Layer { template: true, .. })) {
|
||||
let children = l.children().map_or(&[][..], Vec::as_slice);
|
||||
roots.extend(children.iter().skip(usize::from(l.clips())).filter(|c| c.visible).map(|c| c.id));
|
||||
roots.extend(children.iter().skip(usize::from(l.clips())).filter(|c| c.visible && !c.is_ruler_guide()).map(|c| c.id));
|
||||
}
|
||||
let plan = plan(doc, &roots, o, true)?;
|
||||
if plan.flat.is_empty() && !plan.kept.iter().any(|id| doc.node(*id).is_some_and(|n| kept_work(n, o))) {
|
||||
@@ -422,9 +640,22 @@ pub(super) fn apply(d: &mut Document, sel: &mut vectorcraft_doc::Selection, plan
|
||||
// The groups replace their objects in one pass at the end. When an object is listed twice or
|
||||
// lies inside another listed one, each group replaces its objects as it is made instead.
|
||||
let one_pass = separate(d, &plan.flat.iter().flat_map(|f| f.roots.iter().copied()).collect::<Vec<_>>());
|
||||
// A sublayer that becomes flat art gives its ruler guides to the layer that held it.
|
||||
let guides = held_guides(d, plan.flat.iter().flat_map(|f| f.roots.iter().copied()));
|
||||
for f in plan.flat {
|
||||
// A group always has objects.
|
||||
let Some(&top) = f.roots.last() else { continue };
|
||||
let Some(top) = place_of(d, &f.roots) else { continue };
|
||||
// Art hidden entirely is removed.
|
||||
if f.regions.is_empty() && f.area.is_none() {
|
||||
if one_pass {
|
||||
flat.extend(f.roots.iter().map(|r| (*r, vec![])));
|
||||
} else {
|
||||
for r in &f.roots {
|
||||
d.remove(*r)?;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let mut children = vec![];
|
||||
// The image goes under the regions, reaching a pixel past its own: their edges then fall on
|
||||
// matching pixels instead of on what is behind the group.
|
||||
@@ -474,6 +705,9 @@ pub(super) fn apply(d: &mut Document, sel: &mut vectorcraft_doc::Selection, plan
|
||||
if !flat.is_empty() {
|
||||
d.insert_beside(flat, true)?;
|
||||
}
|
||||
for (layer, guide) in guides {
|
||||
d.insert(Some(layer), usize::MAX, guide)?;
|
||||
}
|
||||
let brushes = if o.strokes_to_outlines { vectorcraft_brush::library(d) } else { vec![] };
|
||||
for mut id in plan.kept {
|
||||
if o.text_to_outlines {
|
||||
@@ -494,6 +728,47 @@ pub(super) fn apply(d: &mut Document, sel: &mut vectorcraft_doc::Selection, plan
|
||||
Ok((ids, vector, rasterized))
|
||||
}
|
||||
|
||||
/// The object of `roots` (in paint order) whose place their flattened group takes: the topmost one
|
||||
/// that no clip group or clipping layer clips unless it clips every object of `roots`, else the
|
||||
/// topmost one. The group stays with the topmost object rather than move to a layer that prints,
|
||||
/// shows or is a template where the topmost object's layer differs, out from a clip group or
|
||||
/// clipping layer whose clipping path has a fill, which would then cover it, or out from a layer
|
||||
/// or group with opacity, a blend mode or isolation.
|
||||
fn place_of(d: &Document, roots: &[NodeId]) -> Option<NodeId> {
|
||||
let top = roots.last().copied()?;
|
||||
if !clips_count() || roots.len() < 2 {
|
||||
return Some(top);
|
||||
}
|
||||
let fills = |clip: &NodeId| d.node(*clip).and_then(|n| n.children()?.first().map(|c| c.clip_paint().fill.is_some())).unwrap_or(false);
|
||||
let all: Vec<Vec<NodeId>> = roots.iter().map(|r| clip_ancestors(d, *r)).collect();
|
||||
let above = all.last().map_or(&[][..], Vec::as_slice);
|
||||
let shared = |mine: &Vec<NodeId>| mine.iter().all(|c| all.iter().all(|theirs| theirs.contains(c)));
|
||||
let leaves_fill = |mine: &Vec<NodeId>| above.iter().any(|c| !mine.contains(c) && fills(c));
|
||||
// Out of a layer or group with opacity, a blend mode or isolation, the group would lose it.
|
||||
let leaves_compositing = |r: NodeId| own_layers(d, top, Some(r)).iter().any(|a| !draws_as_is(a));
|
||||
let state = layer_state(d, top);
|
||||
let place =
|
||||
roots.iter().zip(&all).rev().find(|(r, mine)| shared(mine) && !leaves_fill(mine) && layer_state(d, **r) == state && !leaves_compositing(**r));
|
||||
Some(place.map_or(top, |(r, _)| *r))
|
||||
}
|
||||
|
||||
/// The ruler guides of each of `roots` that is a sublayer (in its sublayers too), with the layer
|
||||
/// that holds it, in order. Other objects hold none.
|
||||
fn held_guides(d: &Document, roots: impl IntoIterator<Item = NodeId>) -> Vec<(NodeId, Node)> {
|
||||
let mut out = vec![];
|
||||
for id in roots {
|
||||
let (Some(n), Some(parent)) = (d.node(id), d.parent_of(id)) else { continue };
|
||||
if n.holds_ruler_guides() {
|
||||
n.walk(&mut |c| {
|
||||
if c.is_ruler_guide() {
|
||||
out.push((parent, c.clone()));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// An image of `r` (in a clip group when it is clipped).
|
||||
fn raster_node(d: &mut Document, r: Raster) -> Node {
|
||||
let key = unique_key(d, "flattened");
|
||||
@@ -576,3 +851,30 @@ fn drop_overprints(n: &mut Node) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
use crate::Session;
|
||||
|
||||
#[test]
|
||||
fn a_flattened_sublayer_gives_its_ruler_guides_to_its_layer() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
let layer = s.doc().unwrap().doc.layers[0].id;
|
||||
let sub = NodeId(s.execute("layer.newSublayer", &json!({"parent": layer.0})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("layer.setCurrent", &json!({"id": sub.0})).unwrap();
|
||||
let r = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].clone();
|
||||
s.execute("transparency.set", &json!({"ids": [r], "opacity": 50})).unwrap();
|
||||
let d = Arc::make_mut(&mut s.doc_mut().unwrap().doc);
|
||||
let g = Node::new(d.alloc_id(), NodeKind::RulerGuide { vertical: true, pos: 5.0, artboard: None });
|
||||
let g = d.insert(Some(sub), usize::MAX, g).unwrap();
|
||||
let flat = flatten_document(&s.doc().unwrap().doc, &FlattenOptions::default()).unwrap().unwrap();
|
||||
assert!(flat.node(sub).is_none(), "the sublayer became flat art");
|
||||
assert!(flat.node(g).is_some_and(Node::is_ruler_guide), "its guide is kept");
|
||||
assert_eq!(flat.parent_of(g), Some(layer));
|
||||
assert_eq!(flat.children(Some(layer)).and_then(|c| c.last()).map(|c| c.id), Some(g));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Flatten Transparency…",
|
||||
["Object"],
|
||||
None,
|
||||
"{preset?: \"high\"|\"medium\"|\"low\" or a saved preset's name (see flattener.presets.list; default medium), balance?: 0..100 (raster/vector balance; 0 rasterizes everything), lineArtPpi?: 1..2400, gradientPpi?: 1..2400 (areas only gradients and meshes reach), textToOutlines?, strokesToOutlines?, clipComplexRegions? (clip images to the region outlines, else rectangles), antiAlias?, preserveAlpha? (composite over nothing instead of white), preserveOverprints? (areas showing one paint keep its colour and overprint; false clears overprints), options?: {the same keys}, ids?|id?} overlapping transparent objects become one group of flat-colour regions (CMYK colours composited ink by ink in CMYK documents), plus an image where gradients, patterns, images, masks or raster effects reach; objects without transparency stay; a frame of threaded text that is outlined or flattened leaves its thread with the text it shows → {ids, rasterized: images made, vector: regions made, options, warning? (an image in the art couldn't be decoded, as object.rasterize says)}",
|
||||
"{preset?: \"high\"|\"medium\"|\"low\" or a saved preset's name (see flattener.presets.list; default medium), balance?: 0..100 (raster/vector balance; 0 rasterizes everything), lineArtPpi?: 1..2400, gradientPpi?: 1..2400 (areas only gradients and meshes reach; an image larger than 64 Mpx or 32,768 px a side is made at a lower resolution), textToOutlines?, strokesToOutlines?, clipComplexRegions? (clip images to the region outlines, else rectangles), antiAlias?, preserveAlpha? (composite over nothing instead of white), preserveOverprints? (areas showing one paint keep its colour and overprint; false clears overprints), options?: {the same keys}, ids?|id?} overlapping transparent objects become one group of flat-colour regions (CMYK colours composited ink by ink in CMYK documents), plus an image where gradients, patterns, images, masks or raster effects reach (only where a clip group's or clipping layer's clipping path shows them, over its fill; transparent objects such a path hides entirely are removed); objects without transparency stay; a frame of threaded text that is outlined or flattened leaves its thread with the text it shows → {ids, rasterized: images made, vector: regions made, options, warning? (an image in the art couldn't be decoded, as object.rasterize says)}",
|
||||
has_doc,
|
||||
flatten
|
||||
),
|
||||
|
||||
@@ -53,6 +53,11 @@ enum Body {
|
||||
pub(super) struct Builder {
|
||||
pub(super) shapes: Vec<po::Shape>,
|
||||
pub(super) kinds: Vec<Kind>,
|
||||
/// Per shape, the clipping path of the innermost clip group it is in (a clipping path's own
|
||||
/// entry is the clip group around its group).
|
||||
pub(super) clip_of: Vec<Option<usize>>,
|
||||
/// The clipping path of the clip group being built.
|
||||
pub(super) clip: Option<usize>,
|
||||
/// Building a CMYK document's art.
|
||||
pub(super) cmyk: bool,
|
||||
}
|
||||
@@ -72,9 +77,24 @@ impl Builder {
|
||||
path.bounds()?;
|
||||
self.shapes.push(po::Shape::new(path, rule, self.shapes.len() as u64));
|
||||
self.kinds.push(kind);
|
||||
self.clip_of.push(self.clip);
|
||||
Some(self.shapes.len() - 1)
|
||||
}
|
||||
|
||||
/// Whether a clip group hides shape `shape` in the region `sources` cover (ascending): the
|
||||
/// region lies outside one of the clipping paths around the shape.
|
||||
pub(super) fn hidden(&self, shape: usize, sources: &[usize]) -> bool {
|
||||
let around = |shape: usize| self.clip_of.get(shape).copied().flatten();
|
||||
let mut clip = around(shape);
|
||||
while let Some(c) = clip {
|
||||
if sources.binary_search(&c).is_err() {
|
||||
return true;
|
||||
}
|
||||
clip = around(c);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub(super) fn elem(&mut self, n: &Node) -> Option<Elem> {
|
||||
if !n.visible {
|
||||
return None;
|
||||
@@ -93,7 +113,11 @@ impl Builder {
|
||||
} else {
|
||||
(None, &children[..])
|
||||
};
|
||||
Body::Group { clip, children: rest.iter().filter_map(|c| self.elem(c)).collect() }
|
||||
let outer = self.clip;
|
||||
self.clip = clip.or(outer);
|
||||
let children = rest.iter().filter_map(|c| self.elem(c)).collect();
|
||||
self.clip = outer;
|
||||
Body::Group { clip, children }
|
||||
}
|
||||
NodeKind::Path { guide: true, .. } => return None,
|
||||
NodeKind::Path { .. } | NodeKind::Compound { .. } => {
|
||||
|
||||
@@ -12,7 +12,9 @@ pub(super) fn target_roots(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
|
||||
let mut ids = vec![];
|
||||
for id in targets(s, p)? {
|
||||
match d.node(id) {
|
||||
Some(n) if n.is_layer() => ids.extend(n.children().into_iter().flatten().map(|c| c.id)),
|
||||
// Ruler guides are not art: they stay where they are.
|
||||
Some(n) if n.is_layer() => ids.extend(n.children().into_iter().flatten().filter(|c| !c.is_ruler_guide()).map(|c| c.id)),
|
||||
Some(n) if n.is_ruler_guide() => {}
|
||||
Some(_) => ids.push(id),
|
||||
None => {}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Gradient",
|
||||
["Window", "Gradient"],
|
||||
None,
|
||||
"{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|freeform (freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), method?: classic|perceptual (how the colours between the stops mix: in display RGB, or perceptually (OKLab) so the steps look even; SVG exports draw a Perceptual gradient as an image, and PDF exports do too unless it is linear or radial, has spot color stops, every other stop is paper white or written in CMYK or gray (an export that converts colors to a CMYK destination profile writes RGB and Lab stops in CMYK, and so does a CMYK document unless the export converts colors to an RGB destination profile), it is not a dithered fill and its curve does not have too many stops: then PDF writes tints of one spot ink as a Separation shading in that ink, and spot inks mixed with other spot, CMYK or gray inks as a DeviceN shading; freeform gradients mix their own way), stops?: [{offset 0..1, color? (needed without swatch; #rrggbbaa and #rgba are refused), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg, aspect?: %, reverse?: bool, dither?: bool (fills only: a linear or radial gradient fill is drawn with fine noise that hides banding, and as pixels in SVG and PDF exports), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient",
|
||||
"{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|freeform (freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), method?: classic|perceptual (how the colours between the stops mix: in display RGB, or perceptually (OKLab) so the steps look even; SVG exports draw a Perceptual gradient as an image, and PDF exports do too unless it is linear or radial, has spot color stops, every other stop is paper white or written in CMYK or gray (an export that converts colors to a CMYK destination profile writes RGB and Lab stops in CMYK, and so does a CMYK document unless the export converts colors to an RGB destination profile), it is not a dithered fill and its curve does not have too many stops: then PDF writes tints of one spot ink as a Separation shading in that ink, and spot inks mixed with other spot, CMYK or gray inks as a DeviceN shading; freeform gradients mix their own way), stops?: [{offset 0..1, color? (needed without swatch; #rrggbbaa and #rgba are refused), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg counter-clockwise (0: the gradient runs left to right), aspect?: %, reverse?: bool, dither?: bool (fills only: a linear or radial gradient fill is drawn with fine noise that hides banding, and as pixels in SVG and PDF exports), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient",
|
||||
has_doc,
|
||||
edit_gradient
|
||||
),
|
||||
@@ -80,19 +80,28 @@ fn select_stop(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
/// Whose gradient a selected stop belongs to: the active document, its first selected object
|
||||
/// (None: the default paint), the proxy in front and the Appearance panel's active item.
|
||||
/// Selecting other art, toggling the proxy or picking another item leaves no stop selected.
|
||||
/// (None: the default paint), whether a part of a symbol instance's art is picked, the proxy in
|
||||
/// front and the Appearance panel's active item. Selecting other art, picking a part of the
|
||||
/// instance, toggling the proxy or picking another item leaves no stop selected.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) struct StopOwner {
|
||||
doc: Option<usize>,
|
||||
object: Option<NodeId>,
|
||||
part: bool,
|
||||
fill: bool,
|
||||
item: Option<usize>,
|
||||
}
|
||||
|
||||
impl StopOwner {
|
||||
pub(crate) fn of(s: &Session) -> Self {
|
||||
Self { doc: s.active, object: s.active().and_then(|d| d.selection.objects.first().copied()), fill: s.fill_active, item: s.appearance_item() }
|
||||
let st = s.active();
|
||||
Self {
|
||||
doc: s.active,
|
||||
object: st.and_then(|d| d.selection.objects.first().copied()),
|
||||
part: st.is_some_and(|d| d.selection.symbol_part.is_some()),
|
||||
fill: s.fill_active,
|
||||
item: s.appearance_item(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -43,8 +43,8 @@ use generate::generate;
|
||||
#[cfg(test)]
|
||||
use series::default_series_color;
|
||||
pub(crate) use series::{
|
||||
capture_label_styles, capture_series_object_opacity, capture_series_opacity, capture_series_paints, capture_series_top_paints, paint_targets,
|
||||
series_object_opacity_differs, shown_paint,
|
||||
capture_label_anti_alias, capture_label_paragraphs, capture_label_styles, capture_series_object_opacity, capture_series_opacity,
|
||||
capture_series_paints, capture_series_top_paints, paint_targets, series_object_opacity_differs, shown_paint,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -50,6 +50,10 @@ pub(super) fn parse_csv(csv: &str) -> (Vec<String>, Vec<String>, Vec<Vec<Option<
|
||||
.map(|l| l.trim_end_matches('\r'))
|
||||
.filter(|l| !l.trim().is_empty())
|
||||
.map(|l| {
|
||||
// A lone `""` line is a single blank value (`to_csv` writes it so the row isn't dropped as empty).
|
||||
if l.trim() == "\"\"" {
|
||||
return vec![Cell { text: String::new(), quoted: false }];
|
||||
}
|
||||
let tabs = split_unquoted(l, '\t');
|
||||
let parts = if tabs.len() > 1 { tabs } else { split_unquoted(l, ',') };
|
||||
parts.into_iter().map(cell).collect()
|
||||
|
||||
@@ -2,7 +2,8 @@ use super::axis::{LABEL_SIZE, MAX_CATEGORY_TICKS, MAX_TICK_MARKS, one_line, rada
|
||||
use super::columns::{Allowance, ColumnArt, column_art, column_designs};
|
||||
use super::data::fmt_value;
|
||||
use super::labels::{
|
||||
Gen, Role, SHADOW_BUDGET, SHADOW_OPACITY, blank_label, filled_lines_on, label_size, label_width, polyline, recolour_text, runs, shadow_of,
|
||||
Gen, Role, SHADOW_BUDGET, SHADOW_OPACITY, blank_label, filled_lines_on, label_extra_height, label_shift, label_size, label_width, polyline,
|
||||
recolour_text, runs, shadow_of,
|
||||
};
|
||||
use super::markers::{MARKER_BUDGET, marker_cost, marker_designs};
|
||||
use super::series::{series_marks, with_series_opacity};
|
||||
@@ -901,18 +902,21 @@ impl<'a> Plot<'a> {
|
||||
|
||||
fn legend_label(&mut self, (x, y): (f64, f64), label: &str) -> Arc<Node> {
|
||||
let ls = self.ls;
|
||||
let (dx, dy) = label_shift(&self.g.label_styles, label, self.b.d.setup.system_layout);
|
||||
let (x, y) = (x + dx, y + dy);
|
||||
self.b.label(Role::Legend, Point::new(x + 12.0, y + 7.5 + (ls - LABEL_SIZE) * 0.35), label, Justify::Left, false)
|
||||
}
|
||||
|
||||
/// Add Legend Across Top: swatch and label after swatch and label, in rows as wide as the plot, the last row
|
||||
/// just above it.
|
||||
fn legend_across_top(&mut self) -> Vec<Arc<Node>> {
|
||||
let (g, r, ls) = (self.g, self.r, self.ls);
|
||||
let (g, r, ls, system_layout) = (self.g, self.r, self.ls, self.b.d.setup.system_layout);
|
||||
let entries: Vec<(usize, String)> = (0..self.legend_series()).map(|s| (s, self.legend_text(s))).filter(|(_, l)| !blank_label(l)).collect();
|
||||
let mut rows: Vec<Vec<(usize, String, f64)>> = vec![vec![]];
|
||||
let mut rows: Vec<Vec<(usize, String, f64, f64)>> = vec![vec![]];
|
||||
let mut used = 0.0;
|
||||
for (s, label) in entries {
|
||||
let w = 12.0 + label_width(&g.label_styles, &label) + 12.0;
|
||||
let w = 12.0 + label_width(&g.label_styles, &label, system_layout) + 12.0;
|
||||
let h = ls * 1.8 * label.split('|').count() as f64 + label_extra_height(&g.label_styles, &label, system_layout);
|
||||
// The gap after an entry may run past the plot's edge.
|
||||
if used + w - 12.0 > r.width() && rows.last().is_some_and(|row| !row.is_empty()) {
|
||||
rows.push(vec![]);
|
||||
@@ -920,10 +924,10 @@ impl<'a> Plot<'a> {
|
||||
}
|
||||
used += w;
|
||||
if let Some(row) = rows.last_mut() {
|
||||
row.push((s, label, w));
|
||||
row.push((s, label, w, h));
|
||||
}
|
||||
}
|
||||
let height = |row: &[(usize, String, f64)]| ls * 1.8 * row.iter().map(|(_, l, _)| l.split('|').count()).max().unwrap_or(1) as f64;
|
||||
let height = |row: &[(usize, String, f64, f64)]| row.iter().map(|(.., h)| *h).fold(ls * 1.8, f64::max);
|
||||
// Above the plot and anything drawn over it (radar and stacked-pie labels).
|
||||
let top = self.b.out.iter().chain(self.on_top.iter()).filter_map(|n| n.geometric_bounds()).map(|bb| bb.y0).fold(r.y0, f64::min);
|
||||
let top = self.bars_reach.map_or(top, |b| top.min(b.y0));
|
||||
@@ -931,7 +935,7 @@ impl<'a> Plot<'a> {
|
||||
let mut labels = vec![];
|
||||
for row in &rows {
|
||||
let mut x = r.x0;
|
||||
for (s, label, w) in row {
|
||||
for (s, label, w, _) in row {
|
||||
self.legend_swatch(*s, (x, y));
|
||||
labels.push(self.legend_label((x, y), label));
|
||||
x += w;
|
||||
@@ -954,7 +958,7 @@ impl<'a> Plot<'a> {
|
||||
y += ls * 1.8;
|
||||
continue;
|
||||
}
|
||||
y += ls * 1.8 * label.split('|').count() as f64;
|
||||
y += ls * 1.8 * label.split('|').count() as f64 + label_extra_height(&self.g.label_styles, &label, self.b.d.setup.system_layout);
|
||||
self.legend_swatch(s, (x, row_y));
|
||||
labels.push(self.legend_label((x, row_y), &label));
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ use super::{
|
||||
pub(super) struct Gen<'a> {
|
||||
pub(super) d: &'a mut Document,
|
||||
pub(super) out: Vec<Arc<Node>>,
|
||||
/// The labels' kept character styles.
|
||||
/// The labels' kept character styles and paragraph attributes.
|
||||
pub(super) styles: &'a vectorcraft_doc::LabelStyles,
|
||||
/// What the column designs may still add.
|
||||
pub(super) columns: Allowance,
|
||||
@@ -45,6 +45,34 @@ pub(super) fn label_style(styles: &vectorcraft_doc::LabelStyles, role: Role) ->
|
||||
st
|
||||
}
|
||||
|
||||
/// The paragraph attributes labels of `role` are set with: the kept ones, else the defaults. The caller sets the
|
||||
/// alignment.
|
||||
pub(super) fn label_para(styles: &vectorcraft_doc::LabelStyles, role: Role) -> vectorcraft_doc::ParaStyle {
|
||||
let mut para = kept_para(styles, role).cloned().unwrap_or_default();
|
||||
// A file's attributes within the limits the Paragraph controls keep to (they are copied onto every label).
|
||||
para.keep_spacing_in_range();
|
||||
para
|
||||
}
|
||||
|
||||
/// The paragraph attributes labels of `role` keep, if any.
|
||||
fn kept_para(styles: &vectorcraft_doc::LabelStyles, role: Role) -> Option<&vectorcraft_doc::ParaStyle> {
|
||||
match role {
|
||||
Role::Value => styles.values_para.as_ref(),
|
||||
Role::Category => styles.categories_para.as_ref(),
|
||||
Role::Legend => styles.legend_para.as_ref(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The anti-aliasing method labels of `role` are drawn with: the kept one, else Sharp.
|
||||
pub(super) fn label_anti_alias(styles: &vectorcraft_doc::LabelStyles, role: Role) -> vectorcraft_doc::text::TypeAntiAlias {
|
||||
let kept = match role {
|
||||
Role::Value => styles.values_anti_alias,
|
||||
Role::Category => styles.categories_anti_alias,
|
||||
Role::Legend => styles.legend_anti_alias,
|
||||
};
|
||||
kept.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// How big labels of `role` are set: a kept style's size, else 9 pt. The labels' spacing follows it.
|
||||
pub(super) fn label_size(styles: &vectorcraft_doc::LabelStyles, role: Role) -> f64 {
|
||||
label_style(styles, role).size
|
||||
@@ -61,19 +89,39 @@ impl Gen<'_> {
|
||||
self.path(PathData::from_bezpath(&bp), Paint::None, Paint::solid(Color::BLACK), 0.5)
|
||||
}
|
||||
pub(super) fn text(&mut self, role: Role, at: Point, s: &str, justify: Justify) -> Arc<Node> {
|
||||
let t = self.text_object(role, at, s, justify);
|
||||
Arc::new(Node::new(self.d.alloc_id(), NodeKind::Text(Box::new(t))))
|
||||
}
|
||||
/// Label `s` of `role` set at `at` and laid out, not yet in the document.
|
||||
fn text_object(&self, role: Role, at: Point, s: &str, justify: Justify) -> TextObject {
|
||||
let style = label_style(self.styles, role);
|
||||
let mut t = TextObject::point(at, s, style);
|
||||
t.para = label_para(self.styles, role);
|
||||
t.anti_alias = label_anti_alias(self.styles, role);
|
||||
t.para.justify = justify;
|
||||
t.system_layout = self.d.setup.system_layout;
|
||||
refresh_bounds(&mut t);
|
||||
Arc::new(Node::new(self.d.alloc_id(), NodeKind::Text(Box::new(t))))
|
||||
t
|
||||
}
|
||||
/// A category or series label: a `|` in it starts a new line (Add graph labels). `middle`: the lines are centred
|
||||
/// on `at` (labels beside an axis or in a wedge) instead of running down from it.
|
||||
pub(super) fn label(&mut self, role: Role, at: Point, s: &str, justify: Justify, middle: bool) -> Arc<Node> {
|
||||
let lines = s.split('|').count();
|
||||
let at = if middle { Point::new(at.x, at.y - (lines - 1) as f64 * label_size(self.styles, role) * 1.2 / 2.0) } else { at };
|
||||
self.text(role, at, &s.replace('|', "\n"), justify)
|
||||
// Default leading puts the first and last baselines this far above and below `at`.
|
||||
let half = (lines - 1) as f64 * label_size(self.styles, role) * 1.2 / 2.0;
|
||||
let start = if middle { Point::new(at.x, at.y - half) } else { at };
|
||||
let mut t = self.text_object(role, start, &s.replace('|', "\n"), justify);
|
||||
// Kept leading or paragraph spacing moves the lines: put the middle of the first and last baselines on `at`. An
|
||||
// empty line has no baseline: an empty first or last line leaves the label where the default leading puts it.
|
||||
if middle && kept_para(self.styles, role).is_some() {
|
||||
let ys: Vec<f64> = t.baselines().map(|(a, _)| a.y).collect();
|
||||
let ends_set = s.split('|').next().is_some_and(|l| !l.is_empty()) && s.split('|').next_back().is_some_and(|l| !l.is_empty());
|
||||
let set = s.split('|').filter(|l| !l.is_empty()).count();
|
||||
if let (Some(first), Some(last), true) = (ys.first(), ys.last(), ends_set && ys.len() == set) {
|
||||
t.xf = Affine::translate((0.0, half - (first + last) / 2.0)) * t.xf;
|
||||
}
|
||||
}
|
||||
Arc::new(Node::new(self.d.alloc_id(), NodeKind::Text(Box::new(t))))
|
||||
}
|
||||
pub(super) fn group(&mut self, name: &str, children: Vec<Arc<Node>>) -> Arc<Node> {
|
||||
let mut g = Node::group(self.d.alloc_id(), children);
|
||||
@@ -162,12 +210,47 @@ pub(super) fn shadow_of(d: &mut Document, n: &Node) -> Arc<Node> {
|
||||
Arc::new(d.reid(&c))
|
||||
}
|
||||
|
||||
/// How wide label `s` is set (its widest line), without adding it to a document.
|
||||
pub(super) fn label_width(styles: &vectorcraft_doc::LabelStyles, s: &str) -> f64 {
|
||||
/// The bounds of legend label `s` set from the origin with paragraph attributes `para` and the document's System
|
||||
/// Layout setting `system_layout`, as [`Gen::text`] sets it, without adding it to a document.
|
||||
fn legend_label_bounds(styles: &vectorcraft_doc::LabelStyles, s: &str, para: vectorcraft_doc::ParaStyle, system_layout: bool) -> Option<Rect> {
|
||||
let style = label_style(styles, Role::Legend);
|
||||
let mut t = TextObject::point(Point::ZERO, &s.replace('|', "\n"), style);
|
||||
t.para = para;
|
||||
t.para.justify = Justify::Left;
|
||||
t.system_layout = system_layout;
|
||||
refresh_bounds(&mut t);
|
||||
Node::new(NodeId(0), NodeKind::Text(Box::new(t))).geometric_bounds().map_or(0.0, |b| b.width())
|
||||
Node::new(NodeId(0), NodeKind::Text(Box::new(t))).geometric_bounds()
|
||||
}
|
||||
|
||||
/// How far label `s` reaches right of where it starts (its widest line and any indent), after the legend moves it right
|
||||
/// by [`label_shift`], without adding it to a document.
|
||||
pub(super) fn label_width(styles: &vectorcraft_doc::LabelStyles, s: &str, system_layout: bool) -> f64 {
|
||||
legend_label_bounds(styles, s, label_para(styles, Role::Legend), system_layout)
|
||||
.map_or(0.0, |b| (b.x1 + label_shift(styles, s, system_layout).0).max(0.0))
|
||||
}
|
||||
|
||||
/// The bounds of legend label `s` set with its kept paragraph attributes and with the defaults, when it has kept ones.
|
||||
fn kept_and_default_bounds(styles: &vectorcraft_doc::LabelStyles, s: &str, system_layout: bool) -> Option<(Rect, Rect)> {
|
||||
styles.legend_para.as_ref()?;
|
||||
Some((
|
||||
legend_label_bounds(styles, s, label_para(styles, Role::Legend), system_layout)?,
|
||||
legend_label_bounds(styles, s, vectorcraft_doc::ParaStyle::default(), system_layout)?,
|
||||
))
|
||||
}
|
||||
|
||||
/// How much further left and further up legend label `s` reaches with its kept paragraph attributes than with the
|
||||
/// defaults (a negative indent or negative spacing), so the legend moves it right and down by that much and it stays
|
||||
/// clear of its swatch and of the entries before it.
|
||||
pub(super) fn label_shift(styles: &vectorcraft_doc::LabelStyles, s: &str, system_layout: bool) -> (f64, f64) {
|
||||
kept_and_default_bounds(styles, s, system_layout)
|
||||
.map_or((0.0, 0.0), |(kept, default)| ((default.x0 - kept.x0).max(0.0), (default.y0 - kept.y0).max(0.0)))
|
||||
}
|
||||
|
||||
/// How much further down legend label `s` reaches with its kept paragraph attributes than with the defaults (more
|
||||
/// leading, or space before or after), after the legend moves it down by [`label_shift`], so the legend makes room
|
||||
/// for it.
|
||||
pub(super) fn label_extra_height(styles: &vectorcraft_doc::LabelStyles, s: &str, system_layout: bool) -> f64 {
|
||||
kept_and_default_bounds(styles, s, system_layout).map_or(0.0, |(kept, default)| (kept.y1 + (default.y0 - kept.y0).max(0.0) - default.y1).max(0.0))
|
||||
}
|
||||
|
||||
/// A label with nothing to show (empty, or only `|` line breaks).
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use vectorcraft_color::BlendMode;
|
||||
|
||||
use super::axis::radar_axis;
|
||||
use super::labels::{Role, filled_lines_on, label_style};
|
||||
use super::labels::{Role, filled_lines_on, label_para, label_style};
|
||||
use super::{Arc, CharStyle, Color, Document, GraphKind, GraphSpec, HashMap, Node, NodeId, NodeKind, Paint, Result, Session, Value, bad};
|
||||
|
||||
// ---------- generation ----------
|
||||
@@ -406,23 +406,34 @@ fn graph_labels(doc: &Document) -> HashMap<NodeId, (NodeId, Role)> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Keep a formatting change made to graph labels `ids` (Character or Appearance, on the whole graph or on labels
|
||||
/// picked with Group Selection) for every label of their kind: `change` is applied to the kind's kept style (or
|
||||
/// the default one), so the labels look the same when the graph is drawn again. Called inside the same edit, so
|
||||
/// they undo together. Returns whether a kept style changed.
|
||||
pub(crate) fn capture_label_styles(doc: &mut Document, ids: &[NodeId], change: &dyn Fn(&mut CharStyle)) -> bool {
|
||||
if ids.is_empty() {
|
||||
return false;
|
||||
}
|
||||
/// Keep the anti-aliasing method `method` given to graph labels `ids` for every label of their kind, as
|
||||
/// [`capture_label_styles`] keeps a formatting change. Returns whether a kept method changed.
|
||||
pub(crate) fn capture_label_anti_alias(doc: &mut Document, ids: &[NodeId], method: vectorcraft_doc::text::TypeAntiAlias) -> bool {
|
||||
let labels = graph_labels(doc);
|
||||
if labels.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let mut kinds: Vec<(NodeId, Role)> = ids.iter().filter_map(|id| labels.get(id).copied()).collect();
|
||||
kinds.sort_by_key(|(g, r)| (g.0, *r as u8));
|
||||
kinds.dedup();
|
||||
let mut changed = false;
|
||||
for (graph, role) in kinds {
|
||||
let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue };
|
||||
let slot = match role {
|
||||
Role::Value => &mut spec.label_styles.values_anti_alias,
|
||||
Role::Category => &mut spec.label_styles.categories_anti_alias,
|
||||
Role::Legend => &mut spec.label_styles.legend_anti_alias,
|
||||
};
|
||||
changed |= *slot != Some(method);
|
||||
*slot = Some(method);
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// Keep a formatting change made to graph labels `ids` (Character or Appearance, on the whole graph or on labels
|
||||
/// picked with Group Selection) for every label of their kind: `change` is applied to the kind's kept style (or
|
||||
/// the default one), so the labels look the same when the graph is drawn again. Called inside the same edit, so
|
||||
/// they undo together. Returns whether a kept style changed.
|
||||
pub(crate) fn capture_label_styles(doc: &mut Document, ids: &[NodeId], change: &dyn Fn(&mut CharStyle)) -> bool {
|
||||
let mut changed = false;
|
||||
for (graph, role) in label_kinds(doc, ids) {
|
||||
let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue };
|
||||
let mut style = label_style(&spec.label_styles, role);
|
||||
change(&mut style);
|
||||
@@ -437,6 +448,42 @@ pub(crate) fn capture_label_styles(doc: &mut Document, ids: &[NodeId], change: &
|
||||
changed
|
||||
}
|
||||
|
||||
/// Keep a paragraph change made to all of the text of graph labels `ids` (Paragraph, on the whole graph or on labels
|
||||
/// picked with Group Selection) for every label of their kind, as [`capture_label_styles`] does for character
|
||||
/// formats. Returns whether kept attributes changed, or the error `change` returns for the kept attributes.
|
||||
pub(crate) fn capture_label_paragraphs(
|
||||
doc: &mut Document,
|
||||
ids: &[NodeId],
|
||||
change: &dyn Fn(&mut vectorcraft_doc::ParaStyle) -> Result<()>,
|
||||
) -> Result<bool> {
|
||||
let mut changed = false;
|
||||
for (graph, role) in label_kinds(doc, ids) {
|
||||
let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue };
|
||||
let mut para = label_para(&spec.label_styles, role);
|
||||
change(&mut para)?;
|
||||
let slot = match role {
|
||||
Role::Value => &mut spec.label_styles.values_para,
|
||||
Role::Category => &mut spec.label_styles.categories_para,
|
||||
Role::Legend => &mut spec.label_styles.legend_para,
|
||||
};
|
||||
changed |= slot.as_ref() != Some(¶);
|
||||
*slot = Some(para);
|
||||
}
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
/// The graphs and kinds of label among `ids`, each once.
|
||||
fn label_kinds(doc: &Document, ids: &[NodeId]) -> Vec<(NodeId, Role)> {
|
||||
if ids.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
let labels = graph_labels(doc);
|
||||
let mut kinds: Vec<(NodeId, Role)> = ids.iter().filter_map(|id| labels.get(id).copied()).collect();
|
||||
kinds.sort_by_key(|(g, r)| (g.0, *r as u8));
|
||||
kinds.dedup();
|
||||
kinds
|
||||
}
|
||||
|
||||
/// The series a command on graph `id` (of `count` series) applies to: `seriesIndexes`, else, with no `id` param, a
|
||||
/// selection made only of this graph's series (Group Selection). Empty: the whole graph.
|
||||
pub(super) fn picked_series(s: &Session, p: &Value, id: NodeId, count: usize, cmd: &str) -> Result<Vec<usize>> {
|
||||
|
||||
@@ -4055,6 +4055,45 @@ fn formatting_the_whole_graphs_text_survives_it_being_drawn_again() {
|
||||
assert_eq!(spec(&s, id).label_styles, vectorcraft_doc::LabelStyles::default());
|
||||
}
|
||||
|
||||
/// An anti-aliasing method given to one category label is kept for every category label when the graph is drawn
|
||||
/// again, through save and open, and undoes with the change.
|
||||
#[test]
|
||||
fn a_graph_labels_anti_aliasing_method_survives_it_being_drawn_again() {
|
||||
use vectorcraft_doc::text::TypeAntiAlias;
|
||||
let methods = |s: &Session, id: NodeId| -> Vec<(String, TypeAntiAlias)> {
|
||||
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
|
||||
let mut out = vec![];
|
||||
for g in ["Axes", "Legend"] {
|
||||
for c in group(&n, g).children().unwrap() {
|
||||
if let NodeKind::Text(t) = &c.kind {
|
||||
out.push((t.plain_text(), t.anti_alias));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
};
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
let id = graph(&mut s, "column");
|
||||
let c = text_id(&s, id, "Axes", "Q2");
|
||||
s.execute("text.setFormat", &json!({"ids": [c.0], "antiAlias": "strong"})).unwrap();
|
||||
label_looks(&mut s, id);
|
||||
let drawn = methods(&s, id);
|
||||
let method = |t: &str| drawn.iter().find(|(x, _)| x == t).map(|(_, m)| *m).unwrap();
|
||||
assert_eq!((method("Q1"), method("Q3")), (TypeAntiAlias::Strong, TypeAntiAlias::Strong), "every category label: {drawn:?}");
|
||||
assert_eq!((method("0"), method("2024")), (TypeAntiAlias::Sharp, TypeAntiAlias::Sharp), "other labels: {drawn:?}");
|
||||
// One undo takes the data change back, the next the method and the kept method together.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(spec(&s, id).label_styles.categories_anti_alias, None);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
let path = vectorcraft_testkit::temp_dir("graph-label-anti-alias").join("labels.vectorcraft");
|
||||
s.execute("document.save", &json!({"path": path})).unwrap();
|
||||
s.execute("document.open", &json!({"path": path})).unwrap();
|
||||
assert_eq!(spec(&s, id).label_styles.categories_anti_alias, Some(TypeAntiAlias::Strong));
|
||||
assert!(serde_json::to_value(GraphSpec::default()).unwrap().get("labelStyles").is_none());
|
||||
}
|
||||
|
||||
/// A stroke given to all of a graph's text without a weight is kept at 1 pt, as `paint.setStroke` keeps it.
|
||||
#[test]
|
||||
fn a_stroke_given_to_the_whole_graphs_text_is_kept_at_1_pt() {
|
||||
@@ -4210,3 +4249,252 @@ fn character_formats_are_kept_and_bigger_labels_space_out() {
|
||||
fn csv_leading_byte_order_mark_is_ignored() {
|
||||
assert_eq!(super::parse_csv("\u{feff}1,2\n3,4"), super::parse_csv("1,2\n3,4"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csv_round_trip_keeps_blank_row_in_single_column_graph() {
|
||||
let mut g = GraphSpec::default();
|
||||
g.set_cells(vec![vec![Some(1.0)], vec![None], vec![Some(3.0)]]);
|
||||
let (_, c, r) = super::parse_csv(&g.to_csv());
|
||||
assert!(c.is_empty());
|
||||
assert_eq!(r, g.cells());
|
||||
}
|
||||
|
||||
/// Each generated label's text, paragraph attributes and alignment.
|
||||
fn label_paras(s: &Session, id: NodeId) -> Vec<(String, vectorcraft_doc::ParaStyle)> {
|
||||
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
|
||||
let mut out = vec![];
|
||||
for name in ["Axes", "Legend"] {
|
||||
for c in group(&n, name).children().unwrap() {
|
||||
if let NodeKind::Text(t) = &c.kind {
|
||||
out.push((t.plain_text(), t.para.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_formats_are_kept_when_the_graph_is_drawn_again() {
|
||||
use vectorcraft_doc::{Composer, ParaDirection, text::Kashida};
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
let id = graph(&mut s, "column");
|
||||
let aligned: Vec<_> = label_paras(&s, id).into_iter().map(|(t, p)| (t, p.justify)).collect();
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"composer": "singleLine", "kashida": "long", "direction": "rightToLeft", "wordSpacing": [70, 90, 120]}))
|
||||
.unwrap();
|
||||
s.execute("graph.setData", &json!({"id": id.0, "csv": ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"})).unwrap();
|
||||
let paras = label_paras(&s, id);
|
||||
assert!(!paras.is_empty());
|
||||
for (text, p) in ¶s {
|
||||
assert_eq!(p.composer, Composer::SingleLine, "{text}");
|
||||
assert_eq!(p.kashida, Kashida::Long, "{text}");
|
||||
assert_eq!(p.direction, Some(ParaDirection::RightToLeft), "{text}");
|
||||
assert_eq!(p.justification.word_spacing, [70.0, 90.0, 120.0], "{text}");
|
||||
}
|
||||
// The graph still aligns each label to its place.
|
||||
assert_eq!(paras.iter().map(|(t, p)| (t.clone(), p.justify)).collect::<Vec<_>>(), aligned);
|
||||
// One kind of label: only that kind keeps it, also through save and open.
|
||||
let col = graph(&mut s, "column");
|
||||
let v = text_id(&s, col, "Axes", "4");
|
||||
s.execute("select.set", &json!({"ids": [v.0]})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"composer": "singleLine"})).unwrap();
|
||||
let path = vectorcraft_testkit::temp_dir("graph-label-paras").join("labels.vectorcraft");
|
||||
s.execute("document.save", &json!({"path": path})).unwrap();
|
||||
s.execute("document.open", &json!({"path": path})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [col.0]})).unwrap();
|
||||
s.execute("graph.setData", &json!({"id": col.0, "csv": ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"})).unwrap();
|
||||
let paras = label_paras(&s, col);
|
||||
let composer = |t: &str| paras.iter().find(|(x, _)| x == t).unwrap().1.composer;
|
||||
assert_eq!(composer("0"), Composer::SingleLine, "every value axis number");
|
||||
assert_eq!(composer("Q1"), Composer::default(), "category labels as they were");
|
||||
assert_eq!(composer("2024"), Composer::default(), "legend labels as they were");
|
||||
// Part of a label's text: nothing kept.
|
||||
let q = text_id(&s, col, "Axes", "Q2");
|
||||
s.execute("select.set", &json!({"ids": [q.0]})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"kashida": "short", "start": 0, "end": 1})).unwrap();
|
||||
s.execute("graph.setData", &json!({"id": col.0, "csv": ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"})).unwrap();
|
||||
assert!(label_paras(&s, col).iter().all(|(_, p)| p.kashida == Kashida::default()));
|
||||
// Spacing that would be out of order in the kept attributes is refused, even when it is in order on the label.
|
||||
let v = text_id(&s, col, "Axes", "4");
|
||||
s.execute("select.set", &json!({"ids": [col.0]})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"wordSpacing": [60, 70, 80]})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [v.0]})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"wordSpacing": [80, 100, 133], "start": 0, "end": 1})).unwrap();
|
||||
assert!(s.execute("text.setFormat", &json!({"wordSpacing": {"min": 90}})).is_err());
|
||||
let kept = spec(&s, col).label_styles.values_para.unwrap();
|
||||
assert_eq!(kept.justification.word_spacing, [60.0, 70.0, 80.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_legend_across_top_spaces_its_labels_by_their_kept_paragraph_settings() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
let id = graph(&mut s, "column");
|
||||
s.execute("graph.setType", &json!({"legendAcrossTop": true})).unwrap();
|
||||
let l = text_id(&s, id, "Legend", "2024");
|
||||
s.execute("select.set", &json!({"ids": [l.0]})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"leftIndent": 40})).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();
|
||||
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
|
||||
let label = group(&n, "Legend").children().unwrap().iter().find(|c| matches!(&c.kind, NodeKind::Text(t) if t.plain_text() == "2024")).unwrap();
|
||||
let label = label.geometric_bounds().unwrap();
|
||||
let next = series_parts(&s, id, 1).last().unwrap().geometric_bounds().unwrap();
|
||||
assert!(next.x0 >= label.x1, "the next swatch starts after the indented label: {label:?} {next:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_centered_label_with_kept_leading_stays_centered_when_the_graph_is_drawn_again() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
let csv = ",2024,2025\nQ|1,3,2\nQ2,5,4\nQ3,-1,6";
|
||||
let id = NodeId(
|
||||
s.execute("graph.create", &json!({"type": "bar", "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap(),
|
||||
);
|
||||
let middle = |s: &Session| {
|
||||
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
|
||||
let label = group(&n, "Axes").children().unwrap().iter().find(|c| matches!(&c.kind, NodeKind::Text(t) if t.plain_text() == "Q\n1")).unwrap();
|
||||
let b = label.geometric_bounds().unwrap();
|
||||
(b.y0 + b.y1) / 2.0
|
||||
};
|
||||
let before = middle(&s);
|
||||
let l = text_id(&s, id, "Axes", "Q\n1");
|
||||
s.execute("select.set", &json!({"ids": [l.0]})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"autoLeading": 400, "spaceBefore": 6})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap();
|
||||
let after = middle(&s);
|
||||
assert!((after - before).abs() < 0.01, "the two-line category label keeps its middle: {before} {after}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_centered_label_with_an_empty_middle_line_and_kept_leading_stays_centered() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
let csv = ",2024,2025\nQ||1,3,2\nQ2,5,4\nQ3,-1,6";
|
||||
let id = NodeId(
|
||||
s.execute("graph.create", &json!({"type": "bar", "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap(),
|
||||
);
|
||||
let middle = |s: &Session| {
|
||||
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
|
||||
let label =
|
||||
group(&n, "Axes").children().unwrap().iter().find(|c| matches!(&c.kind, NodeKind::Text(t) if t.plain_text() == "Q\n\n1")).unwrap();
|
||||
let b = label.geometric_bounds().unwrap();
|
||||
(b.y0 + b.y1) / 2.0
|
||||
};
|
||||
let before = middle(&s);
|
||||
let l = text_id(&s, id, "Axes", "Q\n\n1");
|
||||
s.execute("select.set", &json!({"ids": [l.0]})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"autoLeading": 400})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap();
|
||||
let after = middle(&s);
|
||||
assert!((after - before).abs() < 0.01, "the three-line category label keeps its middle: {before} {after}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_legend_across_top_measures_its_labels_with_system_layout() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
s.execute("type.systemLayout", &json!({"on": true})).unwrap();
|
||||
// Each `i` at 2 pt advances a whole point with System Layout, far more than its own width.
|
||||
let name = "i".repeat(60);
|
||||
let csv = format!(",{name},2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6");
|
||||
let id = NodeId(
|
||||
s.execute("graph.create", &json!({"type": "column", "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap(),
|
||||
);
|
||||
s.execute("graph.setType", &json!({"legendAcrossTop": true})).unwrap();
|
||||
let l = text_id(&s, id, "Legend", &name);
|
||||
s.execute("select.set", &json!({"ids": [l.0]})).unwrap();
|
||||
s.execute("text.setStyle", &json!({"size": 2})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"leftIndent": 1})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap();
|
||||
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
|
||||
let label = group(&n, "Legend").children().unwrap().iter().find(|c| matches!(&c.kind, NodeKind::Text(t) if t.plain_text() == name)).unwrap();
|
||||
let label = label.geometric_bounds().unwrap();
|
||||
let next = series_parts(&s, id, 1).last().unwrap().geometric_bounds().unwrap();
|
||||
assert!(next.x0 >= label.x1, "the next swatch starts after the label set with System Layout: {label:?} {next:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_legend_label_with_a_kept_negative_indent_stays_clear_of_its_swatch() {
|
||||
for across_top in [true, false] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
let id = graph(&mut s, "column");
|
||||
s.execute("graph.setType", &json!({"legendAcrossTop": across_top})).unwrap();
|
||||
let l = text_id(&s, id, "Legend", "2025");
|
||||
s.execute("select.set", &json!({"ids": [l.0]})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"leftIndent": -40})).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();
|
||||
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
|
||||
let bounds = |text: &str| {
|
||||
let label =
|
||||
group(&n, "Legend").children().unwrap().iter().find(|c| matches!(&c.kind, NodeKind::Text(t) if t.plain_text() == text)).unwrap();
|
||||
label.geometric_bounds().unwrap()
|
||||
};
|
||||
let (first, label) = (bounds("2024"), bounds("2025"));
|
||||
let swatch = series_parts(&s, id, 1).last().unwrap().geometric_bounds().unwrap();
|
||||
assert!(label.x0 >= swatch.x1, "the label starts after its swatch (across top: {across_top}): {swatch:?} {label:?}");
|
||||
if across_top {
|
||||
assert!(swatch.x0 >= first.x1, "the swatch starts after the label before it: {first:?} {swatch:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_legend_makes_room_for_the_kept_leading_of_its_labels() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
let csv = ",20|24,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6";
|
||||
let id = NodeId(
|
||||
s.execute("graph.create", &json!({"type": "column", "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap(),
|
||||
);
|
||||
let l = text_id(&s, id, "Legend", "20\n24");
|
||||
s.execute("select.set", &json!({"ids": [l.0]})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"autoLeading": 400})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap();
|
||||
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
|
||||
let bounds = |text: &str| {
|
||||
let label = group(&n, "Legend").children().unwrap().iter().find(|c| matches!(&c.kind, NodeKind::Text(t) if t.plain_text() == text)).unwrap();
|
||||
label.geometric_bounds().unwrap()
|
||||
};
|
||||
let (first, next) = (bounds("20\n24"), bounds("2025"));
|
||||
assert!(next.y0 >= first.y1, "the next legend label starts below the first one: {first:?} {next:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_legend_label_with_kept_negative_space_before_stays_below_the_entry_above_it() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
let csv = ",2024,20|25\nQ1,3,2\nQ2,5,4\nQ3,-1,6";
|
||||
let id = NodeId(
|
||||
s.execute("graph.create", &json!({"type": "column", "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap(),
|
||||
);
|
||||
let l = text_id(&s, id, "Legend", "20\n25");
|
||||
s.execute("select.set", &json!({"ids": [l.0]})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"spaceBefore": -60})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap();
|
||||
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
|
||||
let bounds = |text: &str| {
|
||||
let label = group(&n, "Legend").children().unwrap().iter().find(|c| matches!(&c.kind, NodeKind::Text(t) if t.plain_text() == text)).unwrap();
|
||||
label.geometric_bounds().unwrap()
|
||||
};
|
||||
let (first, next) = (bounds("2024"), bounds("20\n25"));
|
||||
assert!(next.y0 >= first.y1, "the second legend label starts below the first one: {first:?} {next:?}");
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Vector Halftone…",
|
||||
["Object"],
|
||||
None,
|
||||
"{shape?: \"circle\"|\"ellipse\"|\"square\"|\"diamond\"|\"line\" (circle), frequency?: lines per inch 1–300 (20), angle?: deg (45; CMYK: the black screen's, cyan −30°, magenta +30°, yellow −45° from it), mode?: \"mono\"|\"cmyk\" (mono), color?: mono dot colour (\"#000000\"; #rrggbbaa and #rgba are refused), invert?: bool (dots for light tones), clip?: true (clip the dots to the art's outline), keepOriginal?: false} the selection as vector halftone dots sized by its tone, a group of one path per screen (CMYK screens multiply), as one undo step → {id, dots, warning? (an image in the art couldn't be decoded, as object.rasterize says)}",
|
||||
"{shape?: \"circle\"|\"ellipse\"|\"square\"|\"diamond\"|\"line\" (circle), frequency?: lines per inch 1–300 (20), angle?: deg clockwise (45; 0: level rows of dots; CMYK: the black screen's, cyan −30°, magenta +30°, yellow −45° from it), mode?: \"mono\"|\"cmyk\" (mono), color?: mono dot colour (\"#000000\"; #rrggbbaa and #rgba are refused), invert?: bool (dots for light tones), clip?: true (clip the dots to the art's outline), keepOriginal?: false} the selection as vector halftone dots sized by its tone, a group of one path per screen (CMYK screens multiply), as one undo step → {id, dots, warning? (an image in the art couldn't be decoded, as object.rasterize says)}",
|
||||
has_selection,
|
||||
halftone
|
||||
)]
|
||||
|
||||
@@ -128,7 +128,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"New Artboard",
|
||||
["Window", "Artboards"],
|
||||
None,
|
||||
"{x?, y?, width?, height?, name?, color?: color (its background color, as artboard.setProps takes it, #rrggbbaa and #rgba refused; default: none), showCenterMark?: bool, showCrossHairs?: bool, showVideoSafeAreas?: bool (as artboard.setProps takes them; default false), videoRulerPixelAspectRatio?: number 0.1–10 (as artboard.setProps takes it; default 1), side?: \"left\"|\"right\"|\"top\"|\"bottom\" (the size of artboard `beside`, 20 pt from that side of it; refused with x, y, width or height), beside?: n (the artboard `side` places it by; default the last)} (default place: right of the last)",
|
||||
"{x?, y?, width?, height?, name?, color?: color (its background color, as artboard.setProps takes it, #rrggbbaa and #rgba refused; default: none), guideLabel?: name | \"#rrggbb\" | null (its guide label, as artboard.setProps takes it; default: none), showCenterMark?: bool, showCrossHairs?: bool, showVideoSafeAreas?: bool (as artboard.setProps takes them; default false), videoRulerPixelAspectRatio?: number 0.1–10 (as artboard.setProps takes it; default 1), side?: \"left\"|\"right\"|\"top\"|\"bottom\" (the size of artboard `beside`, 20 pt from that side of it; refused with x, y, width or height), beside?: n (the artboard `side` places it by; default the last)} (default place: right of the last)",
|
||||
has_doc,
|
||||
artboard_new
|
||||
),
|
||||
@@ -155,7 +155,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Artboard Options…",
|
||||
["Window", "Artboards"],
|
||||
None,
|
||||
"{index | artboards: [{index, name?, color?, locked?, showCenterMark?, showCrossHairs?, showVideoSafeAreas?, videoRulerPixelAspectRatio?, x?, y?, width?, height?}…] (several artboards in one undo step, each with its own fields; the art inside each before any of them changes goes with it, and an object inside several goes with the first listed that moves or scales its art), name?, color?: color (\"#rrggbb\", {c,m,y,k}, … as the paint commands take, #rrggbbaa and #rgba refused: the background color the canvas draws the artboard's paper in, multiplied with the Simulate Colored Paper color while that is on; exports and print leave it out) | null (no background color), locked?: bool (the Artboards panel's lock column: a locked artboard keeps its rectangle (a new x, y, width or height is an error unless locked is false), can't be moved, deleted or cut, and Rearrange and Fit to Artwork Bounds leave it as it is; the art on it (each object of a layer or sublayer whose bounds with strokes overlap it, a group as one object) acts as locked art until it is unlocked: clicks, marquees, select.set, select.all and the tools pass over it (art drawn or pasted onto it stays selected and editable until a selection leaves it out or it is moved off the locked artboards), it is deselected, and when another artboard moves or scales it stays unless lockedAndHidden), showCenterMark?: bool (Artboard Options' Show Center Mark: the canvas draws a mark at the artboard's center), showCrossHairs?: bool (Show Cross Hairs: the canvas draws a line from the middle of each side through the center), showVideoSafeAreas?: bool (Show Video Safe Areas: the canvas draws the action-safe area 3.5% and the title-safe area 5% in from each edge, as EBU R 95 sets them; the three are screen-only, as the artboard's edges are: exports and print leave them out), videoRulerPixelAspectRatio?: number 0.1–10 (Video Ruler Pixel Aspect Ratio: the width of one video pixel over its height, which View › Rulers › Show Video Rulers counts the artboard in; default 1, square pixels), x?, y?, width?, height?, moveArt?: bool (default false; a position-only change moves the contained art with its artboard), scaleArt?: bool (Scale Artwork with Artboard: resized, the artboard takes the art fully inside it (locked and hidden art only with lockedAndHidden?: bool, default prefs moveLockedWithArtboard) and its guides from its old rectangle onto the new one, each side by its own ratio; strokes/corners/patterns as object.scale), strokes?: bool, corners?: bool, patterns?: bool (the three as object.scale, for art that moves or scales with the artboard), lockedAndHidden?: bool (locked and hidden art moves along; default: the preference; recorded in the journal so a replay matches)} → null, or with scaleArt {scaled: the art scaled}",
|
||||
"{index | artboards: [{index, name?, color?, guideLabel?, locked?, showCenterMark?, showCrossHairs?, showVideoSafeAreas?, videoRulerPixelAspectRatio?, x?, y?, width?, height?}…] (several artboards in one undo step, each with its own fields; the art inside each before any of them changes goes with it, and an object inside several goes with the first listed that moves or scales its art), name?, color?: color (\"#rrggbb\", {c,m,y,k}, … as the paint commands take, #rrggbbaa and #rgba refused: the background color the canvas draws the artboard's paper in, multiplied with the Simulate Colored Paper color while that is on; exports and print leave it out) | null (no background color), guideLabel?: name | \"#rrggbb\" | null (the guide label: the canvas draws the artboard's own guides (guide.add with artboard) in this color instead of the guide color preference, selected ones still in the selection color; a layer color's name spelled exactly as Layer Options lists it (\"Light Blue\", \"Red\", …) or #rrggbb; null for none, which new artboards and older files have; screen-only, so exports and print leave it out; it can change on a locked artboard; an invalid value anywhere in the list changes nothing), locked?: bool (the Artboards panel's lock column: a locked artboard keeps its rectangle (a new x, y, width or height is an error unless locked is false), can't be moved, deleted or cut, and Rearrange and Fit to Artwork Bounds leave it as it is; the art on it (each object of a layer or sublayer whose bounds with strokes overlap it, a group as one object) acts as locked art until it is unlocked: clicks, marquees, select.set, select.all and the tools pass over it (art drawn or pasted onto it stays selected and editable until a selection leaves it out or it is moved off the locked artboards), it is deselected, and when another artboard moves or scales it stays unless lockedAndHidden), showCenterMark?: bool (Artboard Options' Show Center Mark: the canvas draws a mark at the artboard's center), showCrossHairs?: bool (Show Cross Hairs: the canvas draws a line from the middle of each side through the center), showVideoSafeAreas?: bool (Show Video Safe Areas: the canvas draws the action-safe area 3.5% and the title-safe area 5% in from each edge, as EBU R 95 sets them; the three are screen-only, as the artboard's edges are: exports and print leave them out), videoRulerPixelAspectRatio?: number 0.1–10 (Video Ruler Pixel Aspect Ratio: the width of one video pixel over its height, which View › Rulers › Show Video Rulers counts the artboard in; default 1, square pixels), x?, y?, width?, height?, moveArt?: bool (default false; a position-only change moves the contained art with its artboard), scaleArt?: bool (Scale Artwork with Artboard: resized, the artboard takes the art fully inside it (locked and hidden art only with lockedAndHidden?: bool, default prefs moveLockedWithArtboard) and its guides from its old rectangle onto the new one, each side by its own ratio; strokes/corners/patterns as object.scale), strokes?: bool, corners?: bool, patterns?: bool (the three as object.scale, for art that moves or scales with the artboard), lockedAndHidden?: bool (locked and hidden art moves along; default: the preference; recorded in the journal so a replay matches)} → null, or with scaleArt {scaled: the art scaled}; no undo step when nothing changes",
|
||||
has_doc,
|
||||
artboard_set
|
||||
),
|
||||
@@ -243,7 +243,8 @@ pub(crate) fn accepts(d: &Document, parent: Option<NodeId>, n: &Node) -> bool {
|
||||
None => n.is_layer(),
|
||||
Some(p) => match d.node(p).map(|x| &x.kind) {
|
||||
Some(NodeKind::Layer { .. }) => true,
|
||||
Some(NodeKind::Group { .. }) => !n.is_layer(),
|
||||
// A ruler guide stays directly in a layer or sublayer.
|
||||
Some(NodeKind::Group { .. }) => !n.is_layer() && !n.is_ruler_guide(),
|
||||
_ => false,
|
||||
},
|
||||
}
|
||||
@@ -478,8 +479,12 @@ fn clip_toggle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
super::object::release_clip(d, id);
|
||||
return Ok(false);
|
||||
}
|
||||
let top =
|
||||
n.children().and_then(|c| c.last()).map(|c| c.id).ok_or_else(|| EngineError::Other("the layer has no objects to clip by".into()))?;
|
||||
// The topmost object: a layer's ruler guides above its art do not clip.
|
||||
let top = n
|
||||
.children()
|
||||
.and_then(|c| c.iter().rev().find(|c| !c.is_ruler_guide()))
|
||||
.map(|c| c.id)
|
||||
.ok_or_else(|| EngineError::Other("the layer has no objects to clip by".into()))?;
|
||||
super::object::make_clipping_path(d, top)?;
|
||||
d.move_node(top, Some(id), 0)?;
|
||||
if let Some(n) = d.node_mut(id) {
|
||||
@@ -556,9 +561,8 @@ fn delete_rows(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(EngineError::Other("a document needs at least one layer".into()));
|
||||
}
|
||||
s.edit("Delete Selection", |d, sel| {
|
||||
d.remove_many(&ids)?;
|
||||
// The guides on the deleted layers go with them.
|
||||
d.drop_guides_of_deleted_layers(sel);
|
||||
d.remove_many(&ids)?;
|
||||
sel.prune(d);
|
||||
Ok(())
|
||||
})?;
|
||||
@@ -577,10 +581,10 @@ fn duplicate_rows(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad("layer.duplicate", "no such rows"));
|
||||
}
|
||||
let copies = s.edit("Duplicate Selection", |d, _| {
|
||||
super::edit::copy_above(d, &ids, |d, n, c| {
|
||||
// A copied layer's ruler guides are copied with it.
|
||||
super::edit::copy_above(d, &ids, |_, n, c| {
|
||||
if n.is_layer() {
|
||||
c.name = Some(format!("{} copy", n.display_name()));
|
||||
d.copy_layer_guides(n, c);
|
||||
}
|
||||
})
|
||||
})?;
|
||||
@@ -706,6 +710,9 @@ fn select_art(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
vec![]
|
||||
} else if n.is_layer() {
|
||||
n.layer_art(true)
|
||||
} else if n.is_ruler_guide() {
|
||||
// A ruler guide row selects the guide through `guide.select`, not as art.
|
||||
vec![]
|
||||
} else {
|
||||
vec![id]
|
||||
};
|
||||
@@ -784,11 +791,8 @@ fn move_rows(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut nodes = vec![];
|
||||
for id in &ids {
|
||||
let n = d.node(*id).cloned().ok_or(EngineError::NoNode(*id))?;
|
||||
let c = d.reid(&n);
|
||||
if n.is_layer() {
|
||||
d.copy_layer_guides(&n, &c);
|
||||
}
|
||||
nodes.push(Arc::new(c));
|
||||
// A copied layer's ruler guides are copied with it.
|
||||
nodes.push(Arc::new(d.reid(&n)));
|
||||
}
|
||||
nodes
|
||||
} else {
|
||||
@@ -810,6 +814,8 @@ fn move_rows(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ch = d.children_mut(parent)?;
|
||||
let index = index.min(ch.len());
|
||||
ch.splice(index..index, nodes);
|
||||
// A layer's ruler guides stay above its art, each in the order it now has.
|
||||
ch.sort_by_key(|c| c.is_ruler_guide());
|
||||
// Copied type whose layout caches `reid` dropped has them again.
|
||||
if copy {
|
||||
super::typecmd::refresh_bounds_of(d, &out);
|
||||
@@ -859,6 +865,22 @@ fn artboard_color(p: &Value, cmd: &str) -> Result<Option<Option<vectorcraft_colo
|
||||
}
|
||||
}
|
||||
|
||||
/// An artboard command's `guideLabel` param, the color of its own guides: None when absent,
|
||||
/// `Some(None)` for null (no label), else `Some(Some(label))` for a preset's name exactly as
|
||||
/// [`LAYER_COLORS`] spells it or `#rrggbb` ([`LayerColor::from_label_text`]). Any other value,
|
||||
/// such as a preset's index, a name in other letter case or `#rrggbbaa`, is an error.
|
||||
fn artboard_guide_label(p: &Value, cmd: &str) -> Result<Option<Option<LayerColor>>> {
|
||||
match p.get("guideLabel") {
|
||||
None => Ok(None),
|
||||
Some(Value::Null) => Ok(Some(None)),
|
||||
Some(v) => v
|
||||
.as_str()
|
||||
.and_then(LayerColor::from_label_text)
|
||||
.map(|c| Some(Some(c)))
|
||||
.ok_or_else(|| bad(cmd, "`guideLabel` is null, a layer color's name or #rrggbb")),
|
||||
}
|
||||
}
|
||||
|
||||
/// An artboard command's text param `key`: None when absent or null, else the string. Any other
|
||||
/// value, such as 5, is an error: "`{key}` must be a string".
|
||||
fn artboard_str<'a>(p: &'a Value, key: &str, cmd: &str) -> Result<Option<&'a str>> {
|
||||
@@ -898,6 +920,7 @@ fn artboard_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let last = d0.artboards.last().map(|a| a.rect).unwrap_or(Rect::new(0.0, 0.0, 612.0, 792.0));
|
||||
let def = Rect::new(last.x1 + 20.0, last.y0, last.x1 + 20.0 + last.width(), last.y1);
|
||||
let color = artboard_color(p, "artboard.new")?.flatten();
|
||||
let guide_label = artboard_guide_label(p, "artboard.new")?.flatten();
|
||||
check_switches(p, &["showCenterMark", "showCrossHairs", "showVideoSafeAreas"], "artboard.new")?;
|
||||
check_numbers(p, &["x", "y", "width", "height"], "artboard.new")?;
|
||||
let r = match artboard_side(p, "artboard.new")? {
|
||||
@@ -932,6 +955,7 @@ fn artboard_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
show_video_safe_areas,
|
||||
video_ruler_pixel_aspect_ratio,
|
||||
color,
|
||||
guide_label,
|
||||
});
|
||||
Ok(d.artboards.len() - 1)
|
||||
})?;
|
||||
@@ -954,7 +978,7 @@ fn remove_artboards(d: &mut Document, sel: &mut vectorcraft_doc::Selection, indi
|
||||
for &i in indices.iter().rev() {
|
||||
let id = d.artboards.remove(i).id;
|
||||
d.retire_artboard_id(id);
|
||||
d.retain_guides(sel, |_, g| g.artboard != Some(id));
|
||||
d.retain_guides(sel, |g| g.artboard != Some(id));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1001,6 +1025,7 @@ struct ArtboardEdit<'a> {
|
||||
now: Rect,
|
||||
name: Option<&'a str>,
|
||||
color: Option<Option<vectorcraft_color::Color>>,
|
||||
guide_label: Option<Option<LayerColor>>,
|
||||
lock: Option<bool>,
|
||||
center_mark: Option<bool>,
|
||||
cross_hairs: Option<bool>,
|
||||
@@ -1013,6 +1038,23 @@ struct ArtboardEdit<'a> {
|
||||
art: Vec<NodeId>,
|
||||
}
|
||||
|
||||
impl ArtboardEdit<'_> {
|
||||
/// Does the edit change artboard `a` (the one at its index) or the art and guides on it?
|
||||
fn changes(&self, a: &Artboard) -> bool {
|
||||
let name = self.name.map(|n| if n.is_empty() { format!("Artboard {}", self.index + 1) } else { n.to_string() });
|
||||
self.now != a.rect
|
||||
|| self.transform.is_some()
|
||||
|| name.is_some_and(|n| n != a.name)
|
||||
|| self.color.is_some_and(|c| c != a.color)
|
||||
|| self.guide_label.is_some_and(|c| c != a.guide_label)
|
||||
|| self.lock.is_some_and(|l| l != a.locked)
|
||||
|| self.center_mark.is_some_and(|m| m != a.show_center_mark)
|
||||
|| self.cross_hairs.is_some_and(|c| c != a.show_cross_hairs)
|
||||
|| self.safe_areas.is_some_and(|v| v != a.show_video_safe_areas)
|
||||
|| self.pixel_aspect.is_some_and(|r| r != a.video_ruler_pixel_aspect_ratio)
|
||||
}
|
||||
}
|
||||
|
||||
fn artboard_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// One artboard (`index` and its fields), or several (`artboards`, each with its own fields).
|
||||
let entries: Vec<&Value> = match p.get("artboards") {
|
||||
@@ -1044,6 +1086,8 @@ fn artboard_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let was = d.artboards.get(index).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
|
||||
// The background color: a color sets it, null clears it, absent leaves it as it is.
|
||||
let color = artboard_color(q, "artboard.setProps")?;
|
||||
// The guide label likewise; a locked artboard's can change, as its name can.
|
||||
let guide_label = artboard_guide_label(q, "artboard.setProps")?;
|
||||
let now = rect_from(q, was);
|
||||
let lock = q.get("locked").and_then(Value::as_bool);
|
||||
let (center_mark, cross_hairs) = (q.get("showCenterMark").and_then(Value::as_bool), q.get("showCrossHairs").and_then(Value::as_bool));
|
||||
@@ -1070,6 +1114,7 @@ fn artboard_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
now,
|
||||
name: artboard_str(q, "name", "artboard.setProps")?.map(str::trim),
|
||||
color,
|
||||
guide_label,
|
||||
lock,
|
||||
center_mark,
|
||||
cross_hairs,
|
||||
@@ -1082,6 +1127,10 @@ fn artboard_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
art,
|
||||
});
|
||||
}
|
||||
// Nothing to change: no undo step.
|
||||
if !edits.iter().any(|e| d.artboards.get(e.index).is_none_or(|a| e.changes(a))) {
|
||||
return if scale_art { Ok(json!({ "scaled": [] })) } else { ok() };
|
||||
}
|
||||
// Noted in the journal when art goes along, so a replay transforms the same objects whatever
|
||||
// the preference is then.
|
||||
if edits.iter().any(|e| e.transform.is_some()) {
|
||||
@@ -1102,6 +1151,9 @@ fn artboard_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(c) = e.color {
|
||||
a.color = c;
|
||||
}
|
||||
if let Some(c) = e.guide_label {
|
||||
a.guide_label = c;
|
||||
}
|
||||
if let Some(l) = e.lock {
|
||||
a.locked = l;
|
||||
}
|
||||
|
||||
@@ -255,9 +255,9 @@ fn merge_into(d: &mut Document, into: NodeId, others: &[NodeId]) -> Result<()> {
|
||||
let mut all = std::mem::take(ch);
|
||||
all.extend(taken);
|
||||
all.sort_by(|a, b| keys.get(&a.id).cmp(&keys.get(&b.id)));
|
||||
// Ruler guides (the merged layers' among them) stay above the art, each kind in that order.
|
||||
all.sort_by_key(|c| c.is_ruler_guide());
|
||||
*ch = all;
|
||||
// The merged layers' guides go to it too.
|
||||
d.rehome_guides(into);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -318,9 +318,8 @@ fn flatten(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let others: Vec<NodeId> = d.layers.iter().filter(|l| l.id != into && l.visible && !l.is_template()).map(|l| l.id).collect();
|
||||
let hidden: Vec<NodeId> = d.layers.iter().filter(|l| l.id != into && !l.visible && !l.is_template()).map(|l| l.id).collect();
|
||||
s.edit("Flatten Artwork", |d, sel| {
|
||||
d.remove_many(&hidden)?;
|
||||
// Hidden layers are discarded with their guides.
|
||||
d.drop_guides_of_deleted_layers(sel);
|
||||
d.remove_many(&hidden)?;
|
||||
merge_into(d, into, &others)?;
|
||||
sel.prune(d);
|
||||
sel.deselect_uneditable(d);
|
||||
@@ -349,8 +348,8 @@ fn release(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
NodeKind::Group { .. } => true,
|
||||
_ => return Err(bad(C, "release a layer or a group")),
|
||||
};
|
||||
// Its objects, bottom first (sublayers stay as they are).
|
||||
let objects: Vec<NodeId> = n.children().into_iter().flatten().filter(|c| !c.is_layer()).map(|c| c.id).collect();
|
||||
// Its objects, bottom first (sublayers and ruler guides stay as they are).
|
||||
let objects: Vec<NodeId> = n.children().into_iter().flatten().filter(|c| !c.is_layer() && !c.is_ruler_guide()).map(|c| c.id).collect();
|
||||
if objects.is_empty() {
|
||||
return Err(EngineError::Other("there are no objects to release".into()));
|
||||
}
|
||||
@@ -386,7 +385,8 @@ fn release(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let new: Vec<NodeId> = layers.iter().map(|l| l.id).collect();
|
||||
let ch = d.children_mut(Some(parent))?;
|
||||
let at = index.min(ch.len());
|
||||
// Below the parent's ruler guides.
|
||||
let at = index.min(Node::guide_run_start(ch));
|
||||
ch.splice(at..at, layers);
|
||||
// Copied type whose layout caches `reid` dropped has them again.
|
||||
if build {
|
||||
|
||||
@@ -1303,7 +1303,7 @@ fn use_pattern(s: &mut Session, data: &Value, stroke: bool) -> Result<String> {
|
||||
let choices = SwatchChoices::default();
|
||||
// The pattern's name in the document, worked out on a copy: nothing to add is no edit.
|
||||
let mut probe = (*s.doc()?.doc).clone();
|
||||
let imported = clip.import_into(&mut probe, &choices, false);
|
||||
let imported = clip.import_into(&mut probe, &choices, false)?;
|
||||
let renamed = imported.renamed.iter().find(|r| r["kind"] == "pattern" && r["from"] == name.as_str()).and_then(|r| r["to"].as_str());
|
||||
let target = renamed.unwrap_or(&name).to_string();
|
||||
let paint = vectorcraft_doc::pattern::pattern_paint(&target);
|
||||
@@ -1315,7 +1315,7 @@ fn use_pattern(s: &mut Session, data: &Value, stroke: bool) -> Result<String> {
|
||||
in_one_step(s, |s| {
|
||||
if added {
|
||||
s.edit("Add to Swatches", |d, _| {
|
||||
clip.import_into(d, &choices, false);
|
||||
clip.import_into(d, &choices, false)?;
|
||||
if !has_swatch(d) {
|
||||
let swatch = d.free_swatch_name(&target);
|
||||
d.swatches.push(vectorcraft_color::Swatch { name: swatch, paint: paint.clone(), global: false, spot: false });
|
||||
|
||||
@@ -521,10 +521,25 @@ fn linked_image(bytes: &[u8]) -> Result<RasterImage> {
|
||||
/// The file read for a link: the link to it and its image.
|
||||
type FileRead = (LinkInfo, RasterImage);
|
||||
|
||||
/// The file at `f` read for a link like `base` (its image decoded).
|
||||
fn read_image(f: &mut Found, base: &LinkInfo) -> Result<FileRead> {
|
||||
/// The images read in one operation, by the path of the file and its content hash: groups whose
|
||||
/// image keys differ but whose file is the same share one decoded image (and its encoded bytes)
|
||||
/// instead of each keeping a copy.
|
||||
type ImageReads = BTreeMap<String, (String, RasterImage)>;
|
||||
|
||||
/// The file at `f` read for a link like `base` (its image decoded, or the one `reads` holds for
|
||||
/// the same file content).
|
||||
fn read_image(f: &mut Found, base: &LinkInfo, reads: &mut ImageReads) -> Result<FileRead> {
|
||||
let bytes = f.read()?;
|
||||
Ok((f.link(&bytes, base), linked_image(&bytes)?))
|
||||
let hash = hash_bytes(&bytes);
|
||||
let img = match reads.get(&f.path) {
|
||||
Some((h, img)) if *h == hash => img.clone(),
|
||||
_ => {
|
||||
let img = linked_image(&bytes)?;
|
||||
reads.insert(f.path.clone(), (hash.clone(), img.clone()));
|
||||
img
|
||||
}
|
||||
};
|
||||
Ok((f.link_hashed(hash, base), img))
|
||||
}
|
||||
|
||||
/// A linked file read for the objects that show it.
|
||||
@@ -536,8 +551,8 @@ enum Read {
|
||||
}
|
||||
|
||||
/// The file at `f` read for group `g`: its image, or the artboard or page `g` shows.
|
||||
fn read_for(f: &mut Found, g: &Group, cmd: &str) -> Result<Read> {
|
||||
let Some((bounding, page_box)) = g.document else { return read_image(f, &g.link).map(Read::Image) };
|
||||
fn read_for(f: &mut Found, g: &Group, cmd: &str, reads: &mut ImageReads) -> Result<Read> {
|
||||
let Some((bounding, page_box)) = g.document else { return read_image(f, &g.link, reads).map(Read::Image) };
|
||||
let bytes = f.read()?;
|
||||
let page = g.link.page.unwrap_or(1);
|
||||
let src = super::place::document::source(&f.path, &bytes, Some(&f.path), page, bounding, page_box, cmd)?;
|
||||
@@ -922,6 +937,7 @@ pub fn resolve(d: &mut Document, base: LinkBase, update: bool, low_res_proxy_eps
|
||||
// (the path as it was, the content shown, a placed document?, the link now) of each file found
|
||||
// away from its path or read again.
|
||||
let mut moved: Vec<(String, String, bool, LinkInfo)> = vec![];
|
||||
let mut image_reads = ImageReads::new();
|
||||
for g in groups(d, None) {
|
||||
let row = rows(std::iter::once(&g), base.doc);
|
||||
let shows_preview = match g.document {
|
||||
@@ -934,7 +950,7 @@ pub fn resolve(d: &mut Document, base: LinkBase, update: bool, low_res_proxy_eps
|
||||
(Status::Missing, _) => out.missing.extend(row),
|
||||
(Status::Modified, Some(mut f)) if update || unread => {
|
||||
let force = unread.then_some(Preserve::Bounds);
|
||||
match read_for(&mut f, &g, "document.open").and_then(|read| install_read(d, &g.ids, read, force, Some(&g))) {
|
||||
match read_for(&mut f, &g, "document.open", &mut image_reads).and_then(|read| install_read(d, &g.ids, read, force, Some(&g))) {
|
||||
Ok(()) if unread => {}
|
||||
Ok(()) => out.updated.extend(row),
|
||||
Err(_) => out.missing.extend(row),
|
||||
@@ -943,7 +959,7 @@ pub fn resolve(d: &mut Document, base: LinkBase, update: bool, low_res_proxy_eps
|
||||
(Status::Modified, _) => out.modified.extend(row),
|
||||
// A placed document keeps its file's bytes: only lost ones are read again.
|
||||
(Status::Ok, Some(mut f)) if g.document.is_some() => {
|
||||
let read = if stale(d, &g) { Some(read_for(&mut f, &g, "document.open")) } else { None };
|
||||
let read = if stale(d, &g) { Some(read_for(&mut f, &g, "document.open", &mut image_reads)) } else { None };
|
||||
match read {
|
||||
Some(Ok(read)) => {
|
||||
if install_read(d, &g.ids, read, Some(Preserve::Bounds), Some(&g)).is_err() {
|
||||
@@ -1196,6 +1212,7 @@ fn update(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn update_where(s: &mut Session, ids: Option<Vec<NodeId>>, path: Option<&str>) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let (mut reads, mut missing) = (vec![], vec![]);
|
||||
let mut image_reads = ImageReads::new();
|
||||
let asked = ids.is_some() || path.is_some();
|
||||
for g in groups(&st.doc, ids.as_deref()).into_iter().filter(|g| path.is_none_or(|p| g.document.is_some() && g.link.path == p)) {
|
||||
match probe(&g, LinkBase::of(st)) {
|
||||
@@ -1203,7 +1220,7 @@ fn update_where(s: &mut Session, ids: Option<Vec<NodeId>>, path: Option<&str>) -
|
||||
// Unchanged and showing the file: nothing to read unless asked for.
|
||||
(Status::Ok, Some(_)) if !asked && !stale(&st.doc, &g) => {}
|
||||
// Unreadable as an image (or a document) counts as missing.
|
||||
(_, Some(mut f)) => match read_for(&mut f, &g, "links.update") {
|
||||
(_, Some(mut f)) => match read_for(&mut f, &g, "links.update", &mut image_reads) {
|
||||
Ok(read) => reads.push((g, read)),
|
||||
Err(_) => missing.extend(ids_json(&g.ids)),
|
||||
},
|
||||
@@ -1706,13 +1723,14 @@ fn embed(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids: Vec<NodeId> = ids.into_iter().filter(|id| matches!(kind(id), Some(NodeKind::Image(_)))).collect();
|
||||
// Images showing their preview take their file's pixels first.
|
||||
let (mut done, mut reads) = (vec![], vec![]);
|
||||
let mut image_reads = ImageReads::new();
|
||||
for g in groups(&st.doc, Some(&ids)) {
|
||||
if !previewing(&st.doc, &g.key) {
|
||||
done.extend(g.ids);
|
||||
continue;
|
||||
}
|
||||
match probe(&g, LinkBase::of(st)) {
|
||||
(Status::Ok | Status::Modified, Some(mut f)) => match read_image(&mut f, &g.link) {
|
||||
(Status::Ok | Status::Modified, Some(mut f)) => match read_image(&mut f, &g.link, &mut image_reads) {
|
||||
Ok(read) => {
|
||||
done.extend(&g.ids);
|
||||
reads.push((g.ids, read));
|
||||
@@ -1921,3 +1939,26 @@ fn placement_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
set(&mut shown);
|
||||
Ok(json!({ "ids": ids_json(&ids), "placement": placement_json(shown) }))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::*;
|
||||
use crate::tests::links::{Folder, RED, png, write};
|
||||
|
||||
#[test]
|
||||
fn groups_of_one_file_share_one_decoded_image() {
|
||||
let dir = Folder::new("shared-read");
|
||||
let path = dir.file("pic.png");
|
||||
write(&path, &png(40, 20, RED));
|
||||
let (mut reads, base) = (ImageReads::new(), LinkInfo::new(path.clone()));
|
||||
let mut read = || read_image(&mut found_at(path.clone()).unwrap(), &base, &mut reads).unwrap().1;
|
||||
let (a, b) = (read(), read());
|
||||
assert!(Arc::ptr_eq(&a.blob.bytes, &b.blob.bytes), "one retained copy of the bytes");
|
||||
// A changed file is decoded again.
|
||||
write(&path, &png(40, 20, [1, 2, 3]));
|
||||
let c = read();
|
||||
assert!(!Arc::ptr_eq(&a.blob.bytes, &c.blob.bytes));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,10 +49,7 @@ pub(super) fn mesh_create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
match n.appearance.fill_paint() {
|
||||
// The points take the colours the gradient paints there.
|
||||
Paint::Gradient(g) => {
|
||||
path.bounds().and_then(|b| GradientMesh::for_path_with(&path, rows, cols, &*gradient_sampler(&g, b, model))).map(|mut m| {
|
||||
m.highlight(app, highlight);
|
||||
m
|
||||
})
|
||||
path.bounds().and_then(|b| GradientMesh::for_path_shaded(&path, rows, cols, &*gradient_sampler(&g, b, model), app, highlight))
|
||||
}
|
||||
p => GradientMesh::for_path(&path, rows, cols, p.color().unwrap_or(Color::WHITE), app, highlight),
|
||||
}
|
||||
|
||||
@@ -259,10 +259,12 @@ pub(crate) fn follow_history(st: &mut crate::DocState, was: Option<NodeId>) {
|
||||
/// mode when the object or the layer is gone (e.g. deleted).
|
||||
pub(crate) fn sync(d: &mut Document) {
|
||||
let Some(me) = d.mask_edit else { return };
|
||||
let art: Option<Vec<Arc<Node>>> = d.node(me.layer).and_then(|l| l.children().cloned());
|
||||
// The layer's art: ruler guides on it are not part of the mask.
|
||||
let art: Option<Vec<Arc<Node>>> =
|
||||
d.node(me.layer).and_then(|l| l.children()).map(|ch| ch.iter().filter(|c| !c.is_ruler_guide()).cloned().collect());
|
||||
let (Some(art), true) = (art, d.node(me.object).is_some_and(|n| n.extras().mask.is_some())) else {
|
||||
d.mask_edit = None;
|
||||
let _ = d.remove(me.layer);
|
||||
let _ = d.remove_edit_layer(me.layer);
|
||||
return;
|
||||
};
|
||||
let new_art = match art.as_slice() {
|
||||
|
||||
@@ -115,7 +115,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Rasterize…",
|
||||
["Object"],
|
||||
None,
|
||||
"{ppi?, background?: transparent|white|black|\"#rrggbb\" (#rrggbbaa and #rgba are refused), antiAlias?: none|art|type (text snapped to pixels)|bool, padding?|addAround?: pt (0–1000), colorModel?: \"rgb\"|\"cmyk\"|\"grayscale\"|\"bitmap\", clippingMask?: bool (a clip group with the art's outline)} replace the selection with an embedded image (PNG; in a CMYK document with its own colour model, a CMYK image of the art's inks, its transparency as its opacity mask); each defaults to document.rasterEffectsSettings; a frame of threaded text that is rasterized leaves its thread with the text it shows → {id (the image, or its clip group), width, height, warning?: \"an image couldn't be decoded and is missing from the picture\" (as raster exports say)}",
|
||||
"{ppi?, background?: transparent|white|black|\"#rrggbb\" (#rrggbbaa and #rgba are refused), antiAlias?: none|art|type (text snapped to pixels)|bool, padding?|addAround?: pt (0–1000), colorModel?: \"rgb\"|\"cmyk\"|\"grayscale\"|\"bitmap\", clippingMask?: bool (a clip group with the art's outline)} replace the selection with an embedded image (PNG; in a CMYK document with its own colour model, a CMYK image of the art's inks, its transparency as its opacity mask); each defaults to document.rasterEffectsSettings; an image larger than 64 Mpx or 32,768 px a side is refused; a frame of threaded text that is rasterized leaves its thread with the text it shows → {id (the image, or its clip group), width, height, warning?: \"an image couldn't be decoded and is missing from the picture\" (as raster exports say)}",
|
||||
has_selection,
|
||||
rasterize
|
||||
),
|
||||
@@ -670,7 +670,11 @@ fn render_pixels(
|
||||
opts: &vectorcraft_render::RenderOptions,
|
||||
color: RasterColorModel,
|
||||
) -> Result<(vectorcraft_render::Rendered, Option<&'static str>)> {
|
||||
if region.width() * region.height() * scale * scale > MAX_PIXELS || region.width() * scale > 65535.0 || region.height() * scale > 65535.0 {
|
||||
// No side longer than the renderer draws: it would cut the image off there. The sides are
|
||||
// measured in the renderer's whole pixels.
|
||||
let side = vectorcraft_render::MAX_RASTER_SIDE;
|
||||
let (w, h) = vectorcraft_render::region_pixels(region, scale);
|
||||
if region.width() * region.height() * scale * scale > MAX_PIXELS || w > side || h > side {
|
||||
return Err(EngineError::Other("image would be too large; lower the resolution".into()));
|
||||
}
|
||||
let mut r = vectorcraft_render::Renderer::new();
|
||||
@@ -953,6 +957,40 @@ fn polygon_path(pts: &[Point]) -> PathData {
|
||||
PathData::single(vectorcraft_geom::SubPath::new(pts.iter().map(|p| Anchor::corner(*p)).collect(), true))
|
||||
}
|
||||
|
||||
/// The part of an image under `r` (in document space) copied out of its pixels, as PNG bytes, with its size and
|
||||
/// placement. `None` when the image can't be cut that way (turned or skewed, CMYK, pixels that can't be read, or
|
||||
/// `r` not on the pixel grid), so the caller draws it instead.
|
||||
fn crop_pixels(doc: &vectorcraft_doc::Document, im: &ImageObject, r: Rect) -> Option<(Vec<u8>, u32, u32, Affine)> {
|
||||
let blob = doc.images.get(&im.key)?;
|
||||
let [a, b, c, d, _, _] = im.xf.as_coeffs();
|
||||
if blob.is_cmyk() || b.abs() > 1e-9 || c.abs() > 1e-9 || a.abs() < 1e-9 || d.abs() < 1e-9 {
|
||||
return None;
|
||||
}
|
||||
let inv = im.xf.inverse();
|
||||
let (p0, p1) = (inv * Point::new(r.x0, r.y0), inv * Point::new(r.x1, r.y1));
|
||||
// Only a rect on the pixel grid is cut from the pixels; one between pixels keeps its exact bounds by being drawn.
|
||||
// Checked before decoding, so an off-grid crop of a large image doesn't decode it for nothing.
|
||||
let on_grid = |v: f64| (v - v.round()).abs() < 1e-3;
|
||||
if ![p0.x, p0.y, p1.x, p1.y].into_iter().all(on_grid) {
|
||||
return None;
|
||||
}
|
||||
let px = decode_rgba("object.cropImage", blob).ok()?;
|
||||
let (w, h) = px.dimensions();
|
||||
if w == 0 || h == 0 || (w, h) != (im.width, im.height) {
|
||||
return None;
|
||||
}
|
||||
let lo = |v: f64, max: u32| v.round().clamp(0.0, f64::from(max)) as u32;
|
||||
let (x0, x1) = (lo(p0.x.min(p1.x), w), lo(p0.x.max(p1.x), w));
|
||||
let (y0, y1) = (lo(p0.y.min(p1.y), h), lo(p0.y.max(p1.y), h));
|
||||
if x1 <= x0 || y1 <= y0 {
|
||||
return None;
|
||||
}
|
||||
let cut = image::imageops::crop_imm(&px, x0, y0, x1 - x0, y1 - y0).to_image();
|
||||
let mut png = Vec::new();
|
||||
cut.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).ok()?;
|
||||
Some((png, x1 - x0, y1 - y0, im.xf * Affine::translate((f64::from(x0), f64::from(y0)))))
|
||||
}
|
||||
|
||||
fn crop_image(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.cropImage";
|
||||
let st = s.doc()?;
|
||||
@@ -993,6 +1031,11 @@ fn crop_image(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if (r.x0 - ib.x0).abs() < 1e-6 && (r.y0 - ib.y0).abs() < 1e-6 && (r.x1 - ib.x1).abs() < 1e-6 && (r.y1 - ib.y1).abs() < 1e-6 {
|
||||
return Err(bad(C, "nothing to crop"));
|
||||
}
|
||||
// A plain, axis-aligned raster is cut straight from its pixels, so nothing is resampled.
|
||||
if let Some((png, cw, ch, xf)) = crop_pixels(&st.doc, im, r) {
|
||||
put_pixels(s, id, png, cw, ch, xf)?;
|
||||
return Ok(json!({ "id": id.0, "width": cw, "height": ch }));
|
||||
}
|
||||
// Keep the image's pixel density.
|
||||
let scale = (im.width as f64 / ib.width().max(1e-9)).max(im.height as f64 / ib.height().max(1e-9));
|
||||
let mut bare = node.clone();
|
||||
@@ -1027,6 +1070,27 @@ fn crop_image(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The image's pixels as RGBA, held to the allowance opening a raster has.
|
||||
fn decode_rgba(c: &str, blob: &vectorcraft_doc::ImageBlob) -> Result<image::RgbaImage> {
|
||||
// Print rasters run to gigabytes decoded: the same allowance opening them has.
|
||||
let reader = || {
|
||||
image::ImageReader::new(std::io::Cursor::new(blob.bytes.as_slice()))
|
||||
.with_guessed_format()
|
||||
.map_err(|_| bad(c, "the image's pixels can't be read"))
|
||||
};
|
||||
// The image's size as RGBA, read from its header, is held to that allowance before anything
|
||||
// is decoded (#1717).
|
||||
let (w, h) = reader()?.into_dimensions().map_err(|e| bad(c, format!("the image's pixels can't be read: {e}")))?;
|
||||
if u64::from(w) * u64::from(h) > crate::cmd::fileio::MAX_RASTER_ALLOC / 4 {
|
||||
return Err(bad(c, format!("an image of {w} × {h} pixels is too large to draw")));
|
||||
}
|
||||
let mut reader = reader()?;
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_alloc = Some(crate::cmd::fileio::MAX_RASTER_ALLOC);
|
||||
reader.limits(limits);
|
||||
Ok(reader.decode().map_err(|e| bad(c, format!("the image's pixels can't be read: {e}")))?.to_rgba8())
|
||||
}
|
||||
|
||||
/// Crop Image's trim: the image cut to the box of its pixels that aren't fully transparent, the
|
||||
/// pixels copied as they are (no resampling) and placed where they were.
|
||||
fn trim_image(s: &mut Session, id: NodeId, im: ImageObject) -> Result<Value> {
|
||||
@@ -1037,23 +1101,7 @@ fn trim_image(s: &mut Session, id: NodeId, im: ImageObject) -> Result<Value> {
|
||||
if blob.is_cmyk() {
|
||||
return Ok(json!({ "id": id.0, "width": im.width, "height": im.height, "trimmed": false }));
|
||||
}
|
||||
// Print rasters run to gigabytes decoded: the same allowance opening them has.
|
||||
let reader = || {
|
||||
image::ImageReader::new(std::io::Cursor::new(blob.bytes.as_slice()))
|
||||
.with_guessed_format()
|
||||
.map_err(|_| bad(C, "the image's pixels can't be read"))
|
||||
};
|
||||
// The image's size as RGBA, read from its header, is held to that allowance before anything
|
||||
// is decoded (#1717).
|
||||
let (w, h) = reader()?.into_dimensions().map_err(|e| bad(C, format!("the image's pixels can't be read: {e}")))?;
|
||||
if u64::from(w) * u64::from(h) > crate::cmd::fileio::MAX_RASTER_ALLOC / 4 {
|
||||
return Err(bad(C, format!("an image of {w} × {h} pixels is too large to draw")));
|
||||
}
|
||||
let mut reader = reader()?;
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_alloc = Some(crate::cmd::fileio::MAX_RASTER_ALLOC);
|
||||
reader.limits(limits);
|
||||
let px = reader.decode().map_err(|e| bad(C, format!("the image's pixels can't be read: {e}")))?.to_rgba8();
|
||||
let px = decode_rgba(C, blob)?;
|
||||
let (w, h) = px.dimensions();
|
||||
let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0, 0);
|
||||
for (x, y, p) in px.enumerate_pixels() {
|
||||
@@ -1072,23 +1120,22 @@ fn trim_image(s: &mut Session, id: NodeId, im: ImageObject) -> Result<Value> {
|
||||
let mut png = Vec::new();
|
||||
cut.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).map_err(|e| bad(C, e.to_string()))?;
|
||||
let (cw, ch) = (x1 - x0, y1 - y0);
|
||||
put_pixels(s, id, png, cw, ch, im.xf * Affine::translate((f64::from(x0), f64::from(y0))))?;
|
||||
Ok(json!({ "id": id.0, "width": cw, "height": ch, "trimmed": true }))
|
||||
}
|
||||
|
||||
/// Crop Image's edit for a cut made from the image's own pixels: `png` (`w` × `h` px) takes the
|
||||
/// image's place, mapped into the document by `xf`.
|
||||
fn put_pixels(s: &mut Session, id: NodeId, png: Vec<u8>, w: u32, h: u32, xf: Affine) -> Result<()> {
|
||||
s.edit("Crop Image", |d, _| {
|
||||
let key = unique_key(d, "crop");
|
||||
d.images.insert(key.clone(), vectorcraft_doc::ImageBlob::new("image/png", png));
|
||||
if let Some(n) = d.node_mut(id) {
|
||||
n.kind = NodeKind::Image(Box::new(ImageObject {
|
||||
key,
|
||||
width: cw,
|
||||
height: ch,
|
||||
xf: im.xf * Affine::translate((f64::from(x0), f64::from(y0))),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
pixels: None,
|
||||
}));
|
||||
n.kind = NodeKind::Image(Box::new(ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default(), pixels: None }));
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "id": id.0, "width": cw, "height": ch, "trimmed": true }))
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The Control bar's Mask for an image: a clip group of the image and a clipping path on its
|
||||
@@ -1276,6 +1323,7 @@ fn convert_to_artboards(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
show_video_safe_areas: false,
|
||||
video_ruler_pixel_aspect_ratio: 1.0,
|
||||
color: None,
|
||||
guide_label: None,
|
||||
});
|
||||
converted.push(id);
|
||||
}
|
||||
|
||||
@@ -344,6 +344,7 @@ impl DocSettings {
|
||||
show_video_safe_areas: false,
|
||||
video_ruler_pixel_aspect_ratio: 1.0,
|
||||
color: None,
|
||||
guide_label: None,
|
||||
})
|
||||
.collect();
|
||||
d
|
||||
|
||||
@@ -102,15 +102,15 @@ fn items_bounds(d: &Document, ids: &[NodeId], m: Measure) -> Vec<(NodeId, Rect)>
|
||||
ids.iter().filter_map(|id| Some((*id, m.of(d, *id)?))).collect()
|
||||
}
|
||||
|
||||
/// Move each object of `moves` by its offset and each ruler guide of `guides` (index, position)
|
||||
/// Move each object of `moves` by its offset and each ruler guide of `guides` (id, position)
|
||||
/// to its position, as undo step `label`; pattern tiles move too when [`transform_patterns`] says
|
||||
/// so for `ids`. When every move is a rounding error (1e-9 pt or less on both axes), the document
|
||||
/// and its undo history stay as they are.
|
||||
fn apply_moves(s: &mut Session, label: &str, p: &Value, ids: &[NodeId], moves: &[(NodeId, Vec2)], guides: &[(usize, f64)]) -> Result<Value> {
|
||||
fn apply_moves(s: &mut Session, label: &str, p: &Value, ids: &[NodeId], moves: &[(NodeId, Vec2)], guides: &[(NodeId, f64)]) -> Result<Value> {
|
||||
let sc = Scaling { patterns: transform_patterns(s, p, ids)?, ..Scaling::default() };
|
||||
let guides: Vec<(usize, f64)> = {
|
||||
let guides: Vec<(NodeId, f64)> = {
|
||||
let d = &s.doc()?.doc;
|
||||
guides.iter().copied().filter(|&(i, pos)| d.guides.get(i).is_some_and(|g| (g.pos - pos).abs() > 1e-9)).collect()
|
||||
guides.iter().copied().filter(|&(i, pos)| d.ruler_guide(i).is_some_and(|g| (g.pos - pos).abs() > 1e-9)).collect()
|
||||
};
|
||||
if moves.iter().all(|(_, dv)| dv.x.abs() <= 1e-9 && dv.y.abs() <= 1e-9) && guides.is_empty() {
|
||||
return ok();
|
||||
@@ -124,9 +124,7 @@ fn apply_moves(s: &mut Session, label: &str, p: &Value, ids: &[NodeId], moves: &
|
||||
super::pixelgrid::keep_compound_aligned(d, *id, sc);
|
||||
}
|
||||
for &(i, pos) in &guides {
|
||||
if let Some(g) = d.guides.get_mut(i) {
|
||||
g.pos = pos;
|
||||
}
|
||||
d.edit_ruler_guide(i, |g| g.pos = pos);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
@@ -143,7 +141,7 @@ fn align_to<'a>(s: &Session, p: &'a Value) -> Option<&'a str> {
|
||||
/// [`vectorcraft_doc::Selection::key_guide`]): its position, and whether it is vertical.
|
||||
fn key_guide(s: &Session, p: &Value) -> Option<(f64, bool)> {
|
||||
let st = s.doc().ok()?;
|
||||
let g = st.doc.guides.get(st.selection.key_guide?)?;
|
||||
let g = st.doc.ruler_guide(st.selection.key_guide?)?;
|
||||
(align_to(s, p) == Some("key")).then_some((g.pos, g.vertical))
|
||||
}
|
||||
|
||||
@@ -164,7 +162,7 @@ fn align_reference(s: &Session, p: &Value, ids: &[NodeId], m: Measure, own: Opti
|
||||
/// Bounds of the selected ruler guides (each guide is a zero-thickness line), for Align to
|
||||
/// Selection when no objects are selected.
|
||||
fn selected_guides_bounds(st: &crate::DocState) -> Option<Rect> {
|
||||
st.selection.guides.iter().filter_map(|&i| st.doc.guides.get(i)).fold(None, |acc: Option<Rect>, g| {
|
||||
st.selection.guides.iter().filter_map(|&i| st.doc.ruler_guide(i)).fold(None, |acc: Option<Rect>, g| {
|
||||
let r = if g.vertical { Rect::new(g.pos, 0.0, g.pos, 0.0) } else { Rect::new(0.0, g.pos, 0.0, g.pos) };
|
||||
Some(acc.map(|a| a.union(r)).unwrap_or(r))
|
||||
})
|
||||
@@ -264,7 +262,7 @@ pub(super) fn align(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// `v` names. Align to the key object (or key guide) moves them too, and the key guide stays put.
|
||||
/// Align to the artboard moves them (#3463). Align to the selection moves them when only guides are
|
||||
/// selected; with objects in the selection they stay put.
|
||||
fn guide_moves(s: &Session, p: &Value, r: Rect, h: Option<&str>, v: Option<&str>) -> Result<Vec<(usize, f64)>> {
|
||||
fn guide_moves(s: &Session, p: &Value, r: Rect, h: Option<&str>, v: Option<&str>) -> Result<Vec<(NodeId, f64)>> {
|
||||
let st = s.doc()?;
|
||||
let to = align_to(s, p);
|
||||
let move_guides = match to {
|
||||
@@ -290,7 +288,7 @@ fn guide_moves(s: &Session, p: &Value, r: Rect, h: Option<&str>, v: Option<&str>
|
||||
.guides
|
||||
.iter()
|
||||
.filter(|&&i| !skip_key || Some(i) != st.selection.key_guide)
|
||||
.filter_map(|&i| Some((i, at(st.doc.guides.get(i)?.vertical)?)))
|
||||
.filter_map(|&i| Some((i, at(st.doc.ruler_guide(i)?.vertical)?)))
|
||||
.collect())
|
||||
}
|
||||
|
||||
@@ -361,7 +359,7 @@ pub(super) fn can_distribute(s: &Session) -> std::result::Result<(), String> {
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
enum Spaced {
|
||||
Object(NodeId),
|
||||
Guide(usize),
|
||||
Guide(NodeId),
|
||||
}
|
||||
|
||||
/// The selected objects `ids` with their bounds (measured by `m`), and the selected
|
||||
@@ -371,7 +369,7 @@ fn spaced_items(s: &Session, ids: &[NodeId], m: Measure, horiz: bool) -> Result<
|
||||
let st = s.doc()?;
|
||||
let objects = items_bounds(&st.doc, ids, m).into_iter().map(|(id, r)| (Spaced::Object(id), r));
|
||||
let guides = st.selection.guides.iter().filter_map(|&i| {
|
||||
let g = st.doc.guides.get(i).filter(|g| g.vertical == horiz)?;
|
||||
let g = st.doc.ruler_guide(i).filter(|g| g.vertical == horiz)?;
|
||||
Some((Spaced::Guide(i), Rect::new(g.pos, g.pos, g.pos, g.pos)))
|
||||
});
|
||||
Ok(objects.chain(guides).collect())
|
||||
@@ -404,7 +402,7 @@ fn apply_spaced(s: &mut Session, label: &str, p: &Value, ids: &[NodeId], horiz:
|
||||
for &(it, delta) in deltas {
|
||||
match it {
|
||||
Spaced::Object(id) => moves.push((id, along(delta))),
|
||||
Spaced::Guide(i) => guides.extend(d.guides.get(i).map(|g| (i, g.pos + delta))),
|
||||
Spaced::Guide(i) => guides.extend(d.ruler_guide(i).map(|g| (i, g.pos + delta))),
|
||||
}
|
||||
}
|
||||
apply_moves(s, label, p, ids, &moves, &guides)
|
||||
|
||||
@@ -39,7 +39,9 @@ pub(super) fn arrange(s: &mut Session, how: Arrange) -> Result<Value> {
|
||||
group.retain(|id| d.position(*id).is_some_and(|(_, idx, _)| idx >= floor));
|
||||
}
|
||||
for (k, id) in group.iter().enumerate() {
|
||||
let Some((_, idx, len)) = d.position(*id) else { continue };
|
||||
let Some((_, idx, _)) = d.position(*id) else { continue };
|
||||
// The parent's art, without the ruler guides that stay above it.
|
||||
let len = d.children(par).map_or(0, |ch| Node::guide_run_start(ch));
|
||||
if len <= floor {
|
||||
continue;
|
||||
}
|
||||
@@ -73,12 +75,16 @@ pub(super) fn send_to_current_layer(s: &mut Session, _: &Value) -> Result<Value>
|
||||
}
|
||||
// When the selected objects are already the topmost in the current layer, in paint order, the
|
||||
// document and its undo history stay as they are.
|
||||
if st.doc.children(Some(layer)).is_some_and(|c| c.iter().rev().map(|n| n.id).take(ids.len()).eq(ids.iter().rev().copied())) {
|
||||
// (The layer's ruler guides stay above its art.)
|
||||
let art = |c: &&std::sync::Arc<Node>| !c.is_ruler_guide();
|
||||
if st.doc.children(Some(layer)).is_some_and(|c| c.iter().rev().filter(art).map(|n| n.id).take(ids.len()).eq(ids.iter().rev().copied())) {
|
||||
return ok();
|
||||
}
|
||||
s.edit("Send to Current Layer", |d, _| {
|
||||
let moved = d.remove_many(&ids)?;
|
||||
d.children_mut(Some(layer))?.extend(moved);
|
||||
let ch = d.children_mut(Some(layer))?;
|
||||
let at = Node::guide_run_start(ch);
|
||||
ch.splice(at..at, moved);
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
@@ -176,7 +182,7 @@ fn selected_lock_hide_roots(s: &Session) -> Result<Vec<NodeId>> {
|
||||
fn collect_flag(d: &Document, f: impl Fn(&Node) -> bool) -> Vec<NodeId> {
|
||||
let mut v = vec![];
|
||||
d.walk(|n| {
|
||||
if !n.is_layer() && f(n) {
|
||||
if !n.is_layer() && !n.is_ruler_guide() && f(n) {
|
||||
v.push(n.id)
|
||||
}
|
||||
});
|
||||
@@ -198,7 +204,7 @@ fn collect_flag_in_scope(s: &Session, cmd: &str, p: &Value, f: impl Fn(&Node) ->
|
||||
if let Some(node) = d.node(root) {
|
||||
for child in node.children().into_iter().flatten() {
|
||||
child.walk(&mut |n| {
|
||||
if !n.is_layer() && f(n) {
|
||||
if !n.is_layer() && !n.is_ruler_guide() && f(n) {
|
||||
ids.push(n.id);
|
||||
}
|
||||
});
|
||||
@@ -222,7 +228,7 @@ fn collect_flag_in_scope(s: &Session, cmd: &str, p: &Value, f: impl Fn(&Node) ->
|
||||
}
|
||||
for c in d.node(*g).and_then(Node::children).into_iter().flatten() {
|
||||
c.walk(&mut |n| {
|
||||
if !n.is_layer() && f(n) {
|
||||
if !n.is_layer() && !n.is_ruler_guide() && f(n) {
|
||||
v.push(n.id)
|
||||
}
|
||||
});
|
||||
|
||||
@@ -61,7 +61,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Shear…",
|
||||
["Object", "Transform"],
|
||||
None,
|
||||
"{ids?: [id…] (selected first; default: the selection), angle: deg, axis?: \"horizontal\"|\"vertical\", origin?, copy?, objects?: bool (Transform Objects, default true; false: the art stays and, with patterns, only its pattern tiles transform), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size)}. With anchors selected with Direct Selection (and no copy), only those anchors and their handles transform, about the centre of their own box.",
|
||||
"{ids?: [id…] (selected first; default: the selection), angle: deg (positive turns the vertical edges clockwise with the horizontal axis, the top to the right; the horizontal edges clockwise with the vertical axis, the right side down), axis?: \"horizontal\"|\"vertical\", origin?, copy?, objects?: bool (Transform Objects, default true; false: the art stays and, with patterns, only its pattern tiles transform), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size)}. With anchors selected with Direct Selection (and no copy), only those anchors and their handles transform, about the centre of their own box.",
|
||||
has_selection,
|
||||
shear
|
||||
),
|
||||
@@ -297,7 +297,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Live Shape Properties",
|
||||
[],
|
||||
None,
|
||||
"{id?, ids?, radius?: pt (or % of each corner's maximum when `relativeRadii` is true), kind?: \"round\"|\"invertedRound\"|\"chamfer\", relativeRadii?: bool (Relative rounding, as in the reference app: store radii as fractions of each corner's maximum so they scale with the shape), corners?: [i…], items?: [{id, corners?: [i…]}] (several objects, each with its own corners, in place of ids and corners: the canvas's corner widgets across a selection), sides?: n, polygonAngle?: degrees (counterclockwise from its first vertex straight up), polygonRadius?: pt (centre to vertex), sideLength?: pt (sets the radius), makeSidesEqual?: true (a polygon scaled unevenly: drops the uneven scale and shear, keeping its centre, angle and mean radius), pieStart?, pieEnd?: degrees (an ellipse's pie, counterclockwise from 3 o'clock; 0 to 360 is the whole ellipse), invertPie?: true (swaps them: the other part of the ellipse)} (Live Corners on any path: radius and kind set its corners (anchors that aren't smooth, where two sides, straight or curved (#1144), meet at an angle): `corners` (anchor indices of the path with its corners uncut, counting every subpath's anchors in order: a rectangle's 0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left; a polygon's from the first vertex clockwise), else the corners with a Direct-Selected anchor, else every corner; each radius is drawn no larger than half the corner's shorter side allows; a path that isn't a live shape keeps its uncut outline so its corners stay editable, and is plain again once none is cut; sides: a polygon's, which keep the radius they shared; the polygon params apply in that order: sides, makeSidesEqual, polygonAngle, then sideLength or else polygonRadius)",
|
||||
"{id?, ids?, radius?: pt (or % of each corner's maximum when `relativeRadii` is true), kind?: \"round\"|\"invertedRound\"|\"chamfer\", relativeRadii?: bool (Relative rounding, as in the reference app: store radii as fractions of each corner's maximum so they scale with the shape), corners?: [i…], items?: [{id, corners?: [i…]}] (several objects, each with its own corners, in place of ids and corners: the canvas's corner widgets across a selection), sides?: n, polygonAngle?: degrees (counter-clockwise from its first vertex straight up), polygonRadius?: pt (centre to vertex), sideLength?: pt (sets the radius), makeSidesEqual?: true (a polygon scaled unevenly: drops the uneven scale and shear, keeping its centre, angle and mean radius), pieStart?, pieEnd?: degrees (an ellipse's pie, counter-clockwise from 3 o'clock; 0 to 360 is the whole ellipse), invertPie?: true (swaps them: the other part of the ellipse)} (Live Corners on any path: radius and kind set its corners (anchors that aren't smooth, where two sides, straight or curved (#1144), meet at an angle): `corners` (anchor indices of the path with its corners uncut, counting every subpath's anchors in order: a rectangle's 0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left; a polygon's from the first vertex clockwise), else the corners with a Direct-Selected anchor, else every corner; each radius is drawn no larger than half the corner's shorter side allows; a path that isn't a live shape keeps its uncut outline so its corners stay editable, and is plain again once none is cut; sides: a polygon's, which keep the radius they shared; the polygon params apply in that order: sides, makeSidesEqual, polygonAngle, then sideLength or else polygonRadius)",
|
||||
has_selection,
|
||||
set_live_shape
|
||||
),
|
||||
|
||||
@@ -145,16 +145,14 @@ fn apply_transform_moving_guides(
|
||||
}
|
||||
None if copy => {
|
||||
let guide_copies = if with_guides {
|
||||
let copies: Vec<_> = sel.guides.iter().filter_map(|&i| d.guides.get(i).cloned()).collect();
|
||||
let copies: Vec<_> = sel.guides.iter().filter_map(|&i| d.ruler_guide(i)).collect();
|
||||
(!copies.is_empty()).then_some(copies)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let new_ids = duplicate_in(d, sel, &ids, Affine::IDENTITY)?;
|
||||
if let Some(copies) = guide_copies {
|
||||
let first = d.guides.len();
|
||||
d.guides.extend(copies);
|
||||
sel.guides = (first..d.guides.len()).collect();
|
||||
sel.guides = copies.iter().map(|g| d.add_ruler_guide(g)).collect();
|
||||
}
|
||||
new_ids
|
||||
}
|
||||
@@ -177,9 +175,7 @@ fn apply_transform_moving_guides(
|
||||
}
|
||||
if with_guides && (delta.x != 0.0 || delta.y != 0.0) {
|
||||
for i in sel.guides.clone() {
|
||||
if let Some(g) = d.guides.get_mut(i) {
|
||||
*g = g.moved(delta);
|
||||
}
|
||||
d.edit_ruler_guide(i, |g| *g = g.moved(delta));
|
||||
}
|
||||
}
|
||||
Ok(targets)
|
||||
@@ -224,10 +220,8 @@ pub(crate) fn preview_copies(s: &mut Session, roots: &[NodeId], with_guides: boo
|
||||
let ids = duplicate_in(Arc::make_mut(&mut doc), &mut selection, roots, Affine::IDENTITY)?;
|
||||
if has_guide_copies {
|
||||
let d = Arc::make_mut(&mut doc);
|
||||
let first = d.guides.len();
|
||||
let copies: Vec<_> = st.selection.guides.iter().filter_map(|&i| d.guides.get(i).cloned()).collect();
|
||||
d.guides.extend(copies);
|
||||
selection.guides = (first..d.guides.len()).collect();
|
||||
let copies: Vec<_> = st.selection.guides.iter().filter_map(|&i| d.ruler_guide(i)).collect();
|
||||
selection.guides = copies.iter().map(|g| d.add_ruler_guide(g)).collect();
|
||||
}
|
||||
let k = Arc::new(crate::PreviewCopies { from: it.doc.clone(), roots: roots.to_vec(), doc, selection, ids });
|
||||
it.copies = Some(k.clone());
|
||||
|
||||
@@ -14,7 +14,7 @@ use vectorcraft_doc::{Document, Knockout, Node, NodeId, NodeKind, OpacityMask, S
|
||||
|
||||
use super::maskedit::{begin, finish, mask_group, mask_parts, show_editing};
|
||||
use super::pathops::{Beside, separate};
|
||||
use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, has_doc, ids_param, ok, targets};
|
||||
use super::{CommandSpec, Result, Session, always, bad, cmd, has_doc, ids_param, ok, switch_or, targets};
|
||||
use crate::EngineError;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -246,8 +246,8 @@ fn make(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
{
|
||||
return Err(bad(C, why));
|
||||
}
|
||||
let clip = bool_or(p, "clip", !s.menu.new_masks_unclipped);
|
||||
let invert = bool_or(p, "invert", s.menu.new_masks_inverted);
|
||||
let clip = switch_or(p, "clip", !s.menu.new_masks_unclipped, C)?;
|
||||
let invert = switch_or(p, "invert", s.menu.new_masks_inverted, C)?;
|
||||
// Several objects: the top one is the mask. One: an empty mask, drawn in mask editing.
|
||||
let (art, mask_id) = if below.is_empty() { (&ids[..], None) } else { (below, Some(top)) };
|
||||
// Several objects go into a new group, and a layer can't: the group takes a layer's art.
|
||||
@@ -330,8 +330,13 @@ fn release(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
/// Set the mask flags given in `p` on the masked objects among `target`'s targets.
|
||||
fn set_flags(s: &mut Session, target: &Value, label: &str, p: &Value) -> Result<Value> {
|
||||
let get = |k: &str| p.get(k).and_then(Value::as_bool);
|
||||
let (clip, invert, disabled, linked) = (get("clip"), get("invert"), get("disabled"), get("linked"));
|
||||
let get = |k: &str| -> Result<Option<bool>> {
|
||||
match p.get(k) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(_) => switch_or(p, k, false, "transparency.setOpacityMask").map(Some),
|
||||
}
|
||||
};
|
||||
let (clip, invert, disabled, linked) = (get("clip")?, get("invert")?, get("disabled")?, get("linked")?);
|
||||
if clip.is_none() && invert.is_none() && disabled.is_none() && linked.is_none() {
|
||||
return Err(bad("transparency.setOpacityMask", "give at least one of clip, invert, disabled, linked"));
|
||||
}
|
||||
@@ -363,7 +368,7 @@ fn set_flags(s: &mut Session, target: &Value, label: &str, p: &Value) -> Result<
|
||||
}
|
||||
|
||||
fn toggle_new_clip(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let v = bool_or(p, "value", s.menu.new_masks_unclipped);
|
||||
let v = switch_or(p, "value", s.menu.new_masks_unclipped, "transparency.toggleNewMasksClipping")?;
|
||||
s.menu.new_masks_unclipped = !v;
|
||||
Ok(json!({ "value": v }))
|
||||
}
|
||||
@@ -376,7 +381,7 @@ fn toggle_page(s: &mut Session, p: &Value, knockout: bool) -> Result<Value> {
|
||||
}
|
||||
let d = &s.doc()?.doc;
|
||||
let cur = if knockout { d.page_knockout } else { d.page_isolate };
|
||||
let v = bool_or(p, "value", !cur);
|
||||
let v = switch_or(p, "value", !cur, if knockout { "transparency.togglePageKnockoutGroup" } else { "transparency.togglePageIsolatedBlending" })?;
|
||||
if v != cur {
|
||||
s.edit(if knockout { "Page Knockout Group" } else { "Page Isolated Blending" }, |d, _| {
|
||||
*flag(d, knockout) = v;
|
||||
@@ -387,7 +392,7 @@ fn toggle_page(s: &mut Session, p: &Value, knockout: bool) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn toggle_new_invert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let v = bool_or(p, "value", !s.menu.new_masks_inverted);
|
||||
let v = switch_or(p, "value", !s.menu.new_masks_inverted, "transparency.toggleNewMasksInverted")?;
|
||||
s.menu.new_masks_inverted = v;
|
||||
Ok(json!({ "value": v }))
|
||||
}
|
||||
|
||||
@@ -214,7 +214,7 @@ fn attributes(s: &Session, p: &Value) -> Result<AttributesInfo> {
|
||||
/// Overprint Black on `n` and its descendants; returns the objects changed.
|
||||
fn overprint_black_tree(n: &mut Node, op: &OverprintBlack, spots: &[String]) -> usize {
|
||||
let mut changed = usize::from(op.apply(n, spots));
|
||||
for c in n.children_mut().into_iter().flatten() {
|
||||
for c in n.children_mut().into_iter().flatten().filter(|c| !c.is_ruler_guide()) {
|
||||
changed += overprint_black_tree(Arc::make_mut(c), op, spots);
|
||||
}
|
||||
changed
|
||||
|
||||
@@ -21,7 +21,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Fill",
|
||||
[],
|
||||
None,
|
||||
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, method?: classic|perceptual (as paint.editGradient; default classic), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits), dither?: bool (linear and radial fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear or radial `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern) → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}",
|
||||
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, method?: classic|perceptual (as paint.editGradient; default classic), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg counter-clockwise (0: the gradient runs left to right), start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits), dither?: bool (linear and radial fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear or radial `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern) → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}",
|
||||
has_doc,
|
||||
|s, p| set_paint(s, p, true)
|
||||
),
|
||||
@@ -242,6 +242,10 @@ fn same_in_proxy(a: &Paint, b: &Paint) -> bool {
|
||||
/// change ([`leaf_targets`]): groups and layers stand for their contents (and so do blends and
|
||||
/// other containers inside them), a compound path for its parts.
|
||||
pub(crate) fn painted<'a>(n: &'a Node, top: bool, out: &mut Vec<&'a Node>) {
|
||||
// A ruler guide has no paints.
|
||||
if n.is_ruler_guide() {
|
||||
return;
|
||||
}
|
||||
let expand = match n.kind {
|
||||
NodeKind::Group { .. } | NodeKind::Layer { .. } => true,
|
||||
NodeKind::Compound { .. } => false,
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -51,7 +51,7 @@ pub struct ParamKeys<'a> {
|
||||
|
||||
/// The text between the braces of the object `doc` starts with and what follows it, or why it
|
||||
/// has none.
|
||||
fn object_body(doc: &str) -> std::result::Result<(&str, &str), String> {
|
||||
pub(crate) fn object_body(doc: &str) -> std::result::Result<(&str, &str), String> {
|
||||
// (Named, not written in the `Err`: these are for the tests of the descriptions, not messages
|
||||
// the app shows, which the message catalogs would have to translate.)
|
||||
let (no_object, not_closed) = ("the description doesn't start with the params object `{…}`", "the params object `{…}` isn't closed");
|
||||
@@ -78,7 +78,7 @@ fn object_body(doc: &str) -> std::result::Result<(&str, &str), String> {
|
||||
}
|
||||
|
||||
/// `s` split at each `sep` that is outside brackets, quotes and backticks.
|
||||
fn split_top(s: &str, sep: char) -> Vec<&str> {
|
||||
pub(crate) fn split_top(s: &str, sep: char) -> Vec<&str> {
|
||||
let (mut out, mut depth, mut quote, mut start) = (Vec::new(), 0usize, None, 0);
|
||||
for (i, c) in s.char_indices() {
|
||||
match (quote, c) {
|
||||
@@ -98,7 +98,7 @@ fn split_top(s: &str, sep: char) -> Vec<&str> {
|
||||
out
|
||||
}
|
||||
|
||||
fn is_key(s: &str) -> bool {
|
||||
pub(crate) fn is_key(s: &str) -> bool {
|
||||
let mut chars = s.chars();
|
||||
chars.next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_') && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
|
||||
}
|
||||
|
||||
+245
-14
@@ -4,7 +4,7 @@ use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Document, Node, NodeId, NodeKind, Scaling};
|
||||
use vectorcraft_doc::{Document, Node, NodeId, NodeKind, Scaling, Selection};
|
||||
use vectorcraft_geom::{Anchor, AnchorKind, PathData, Point, SubPath, Vec2};
|
||||
|
||||
use super::create::anchor_from_json;
|
||||
@@ -131,11 +131,19 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Reshape",
|
||||
[],
|
||||
None,
|
||||
"{id, x, y, dx, dy, tol=3, radius?} Reshape tool: grab the path at (x, y) (adding an anchor there unless one is within tol) and drag it by (dx, dy); nearby anchors follow with a smooth falloff over radius, measured along the path (default: half the subpath's size)",
|
||||
"{id, x, y, dx, dy, tol=3, focal?: [[subpath, anchor]…], radius?} Reshape tool: grab the path at (x, y) (adding an anchor there unless one is within tol) and drag it by (dx, dy). The grabbed anchor and the anchors in focal are focal points and move by the whole drag. Anchors that are not direct-selected stay in place (a path selected whole, or not selected, has every anchor selected), and so do an open path's two ends unless they are focal points. A selected anchor between two focal points moves whole; one between a focal point and an anchor that stays takes a share of the drag that falls smoothly from 1 at the focal point to 0 at the anchor that stays, by its distance along the path's control polygon. A closed subpath with every anchor selected moves whole; a subpath with no focal point does not move. With radius, only the grabbed point's subpath changes: anchors follow with a smooth falloff over radius, measured along the path (0 or more), whatever the selection; focal and radius cannot be used together. Without focal (or with focal null) and without radius, the earlier rule applies: the selection does not matter, an open path stretches from the grabbed point to its two ends, and on a closed path the anchors follow with the falloff over half the diagonal of the subpath's bounding box; the Reshape tool always sends focal",
|
||||
has_doc,
|
||||
reshape
|
||||
),
|
||||
cmd!("path.insertAnchor", "Add Anchor Point", [], None, "{id, subpath?, segment, t: 0..1}", has_doc, insert_anchor),
|
||||
cmd!(
|
||||
"path.insertAnchor",
|
||||
"Add Anchor Point",
|
||||
[],
|
||||
None,
|
||||
"{id, subpath?, segment, t: 0..1} add an anchor at t on the segment → {anchor}; the direct-selected anchors and selected segments keep the same points (a selected segment it splits stays selected as its two halves), and a path that isn't selected becomes the selection with the new anchor direct-selected",
|
||||
has_doc,
|
||||
insert_anchor
|
||||
),
|
||||
cmd!(
|
||||
"path.cutAtAnchors",
|
||||
"Cut Path at Selected Anchor Points",
|
||||
@@ -281,9 +289,7 @@ pub(crate) fn transform_anchors(s: &mut Session, label: &str, xf: vectorcraft_ge
|
||||
}
|
||||
if is_translation && (delta.x != 0.0 || delta.y != 0.0) {
|
||||
for &i in &sel.guides {
|
||||
if let Some(g) = doc.guides.get_mut(i) {
|
||||
*g = g.moved(delta);
|
||||
}
|
||||
doc.edit_ruler_guide(i, |g| *g = g.moved(delta));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -1224,7 +1230,7 @@ pub(crate) fn delete_anchors(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.edit("Clear", |d, selection| {
|
||||
if with_guides {
|
||||
let gone = selection.guides.clone();
|
||||
d.retain_guides(selection, |i, _| !gone.contains(&i));
|
||||
d.retain_guides(selection, |g| !g.id.is_some_and(|id| gone.contains(&id)));
|
||||
}
|
||||
let targets: BTreeSet<NodeId> = whole.into_iter().filter(|id| d.node(*id).is_some_and(|n| !n.is_layer())).collect();
|
||||
let whole_ids: Vec<NodeId> = targets
|
||||
@@ -1232,6 +1238,12 @@ pub(crate) fn delete_anchors(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.copied()
|
||||
.filter(|id| !d.ancestry(*id).unwrap_or_default().iter().rev().skip(1).any(|ancestor| targets.contains(ancestor)))
|
||||
.collect();
|
||||
// The compound paths that hold a deleted object; one left with no members goes too.
|
||||
let compounds: BTreeSet<NodeId> = whole_ids
|
||||
.iter()
|
||||
.chain(sel.keys())
|
||||
.filter_map(|id| d.parent_of(*id).filter(|p| matches!(d.node(*p).map(|n| &n.kind), Some(NodeKind::Compound { .. }))))
|
||||
.collect();
|
||||
if !whole_ids.is_empty() {
|
||||
d.remove_many(&whole_ids)?;
|
||||
}
|
||||
@@ -1280,6 +1292,11 @@ pub(crate) fn delete_anchors(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
emptied.finish(d)?;
|
||||
for p in compounds {
|
||||
if d.node(p).is_some_and(|n| n.children().is_some_and(|c| c.is_empty())) {
|
||||
d.remove(p)?;
|
||||
}
|
||||
}
|
||||
selection.set(survivors);
|
||||
Ok(())
|
||||
})?;
|
||||
@@ -1399,6 +1416,145 @@ fn open_run(mut anchors: Vec<vectorcraft_geom::Anchor>) -> Option<SubPath> {
|
||||
Some(SubPath::new(anchors, false))
|
||||
}
|
||||
|
||||
/// What an anchor does in a Reshape drag.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Stretch {
|
||||
/// Not selected: it stays in place.
|
||||
Fixed,
|
||||
/// Selected: it takes a share of the drag that depends on where it lies between the anchors
|
||||
/// that stay and the focal points.
|
||||
Selected,
|
||||
/// A focal point (the grabbed anchor, or one the Reshape tool highlighted): it moves by the
|
||||
/// whole drag.
|
||||
Focal,
|
||||
}
|
||||
|
||||
/// The share of a Reshape drag that each anchor of a subpath takes, as `[in handle, anchor, out
|
||||
/// handle]`, from what each anchor does (`roles`, one per anchor). A focal point takes all of the
|
||||
/// drag and an anchor that stays takes none. An open path's end anchors stay unless they are focal
|
||||
/// points. A selected anchor between two focal points takes all of the drag, and one between two
|
||||
/// anchors that stay takes none. For a selected anchor between an anchor that stays and a focal
|
||||
/// point, `u` is its distance from the anchor that stays along the control polygon (anchor, out
|
||||
/// handle, the next in handle, the next anchor) divided by the focal point's distance from it, and
|
||||
/// its share is `u² (3 - 2u)`. A handle takes the share on the tangent line of that curve at its
|
||||
/// anchor, so the two handles of a smooth anchor stay in line with it. That share is below 0 or
|
||||
/// above 1 for a handle long enough beside the span. A closed subpath with no anchor that stays
|
||||
/// moves whole when it has a focal point.
|
||||
fn stretch_weights(anchors: &[Anchor], closed: bool, roles: &[Stretch]) -> Vec<[f64; 3]> {
|
||||
let n = anchors.len();
|
||||
let mut shares = vec![[0.0; 3]; n];
|
||||
let role = |i: usize| match roles.get(i) {
|
||||
Some(Stretch::Selected) if !closed && (i == 0 || i + 1 == n) => Stretch::Fixed,
|
||||
Some(&r) => r,
|
||||
None => Stretch::Fixed,
|
||||
};
|
||||
// The walk along the subpath starts at an anchor that stays, or else at a focal point, and on a
|
||||
// closed subpath it comes back round to the same anchor, so every selected anchor lies between
|
||||
// two anchors that are not selected ones.
|
||||
let start = if closed { (0..n).find(|&i| role(i) == Stretch::Fixed).or_else(|| (0..n).find(|&i| role(i) == Stretch::Focal)) } else { Some(0) };
|
||||
let Some(start) = start.filter(|_| n > 0) else { return shares };
|
||||
let len = if closed { n + 1 } else { n };
|
||||
let order: Vec<usize> = (0..len).map(|k| (start + k) % n).collect();
|
||||
// Where each anchor's in handle, point and out handle lie along the control polygon. The in
|
||||
// handle of the first anchor and the out handle of the last are not part of an open path.
|
||||
let mut pos: Vec<[f64; 3]> = Vec::with_capacity(len);
|
||||
let mut run = 0.0;
|
||||
let mut before: Option<&Anchor> = None;
|
||||
for (k, a) in order.iter().filter_map(|&i| anchors.get(i)).enumerate() {
|
||||
if let Some(b) = before {
|
||||
run += b.h_out.distance(a.h_in);
|
||||
}
|
||||
let c_in = run;
|
||||
if k > 0 {
|
||||
run += a.h_in.distance(a.p);
|
||||
}
|
||||
let c = run;
|
||||
if k + 1 < len {
|
||||
run += a.p.distance(a.h_out);
|
||||
}
|
||||
pos.push([c_in, c, run]);
|
||||
before = Some(a);
|
||||
}
|
||||
// The nearest anchor before and after each place in the walk that is not a selected one.
|
||||
let bound = |k: usize| role(order.get(k).copied().unwrap_or(start)) != Stretch::Selected;
|
||||
let mut previous = vec![0; len];
|
||||
let mut last = 0;
|
||||
for (k, slot) in previous.iter_mut().enumerate() {
|
||||
if bound(k) {
|
||||
last = k;
|
||||
}
|
||||
*slot = last;
|
||||
}
|
||||
let mut next = vec![len - 1; len];
|
||||
let mut first = len - 1;
|
||||
for (k, slot) in next.iter_mut().enumerate().rev() {
|
||||
if bound(k) {
|
||||
first = k;
|
||||
}
|
||||
*slot = first;
|
||||
}
|
||||
let at = |k: usize| pos.get(k).map_or(0.0, |c| c[1]);
|
||||
// The share of the anchor at `k` in the walk, between an anchor that stays at `end` and a
|
||||
// focal point at `focal` along the walk.
|
||||
let smooth = |k: usize, end: f64, focal: f64| {
|
||||
let span = (focal - end).abs();
|
||||
let Some(&[c_in, c, c_out]) = pos.get(k).filter(|_| span > 0.0 && span.is_finite()) else { return [0.0; 3] };
|
||||
let along = |c: f64| (c - end).abs() / span;
|
||||
let u = along(c);
|
||||
let w = u * u * (3.0 - 2.0 * u);
|
||||
let slope = 6.0 * u * (1.0 - u);
|
||||
[w + slope * (along(c_in) - u), w, w + slope * (along(c_out) - u)]
|
||||
};
|
||||
for (k, &i) in order.iter().enumerate().take(n) {
|
||||
let share = match role(i) {
|
||||
Stretch::Fixed => [0.0; 3],
|
||||
Stretch::Focal => [1.0; 3],
|
||||
Stretch::Selected => {
|
||||
let (kp, kn) = (previous.get(k).copied().unwrap_or(k), next.get(k).copied().unwrap_or(k));
|
||||
match (role(order.get(kp).copied().unwrap_or(i)), role(order.get(kn).copied().unwrap_or(i))) {
|
||||
(Stretch::Focal, Stretch::Focal) => [1.0; 3],
|
||||
(Stretch::Fixed, Stretch::Focal) => smooth(k, at(kp), at(kn)),
|
||||
(Stretch::Focal, Stretch::Fixed) => smooth(k, at(kn), at(kp)),
|
||||
_ => [0.0; 3],
|
||||
}
|
||||
}
|
||||
};
|
||||
// A coordinate that isn't finite moves nothing.
|
||||
if let Some(s) = shares.get_mut(i)
|
||||
&& share.iter().all(|s| s.is_finite())
|
||||
{
|
||||
*s = share;
|
||||
}
|
||||
}
|
||||
shares
|
||||
}
|
||||
|
||||
/// After an anchor is added at `(si, added)` of path `id`, number the direct-selected anchors and
|
||||
/// selected segments after it on that subpath one higher, so the selection keeps the same points.
|
||||
/// A selected segment that the new anchor splits stays selected as its two halves, and the new
|
||||
/// anchor joins its ends in the anchors that move with it.
|
||||
fn renumber_after_insert(sel: &mut Selection, id: NodeId, si: usize, added: usize) {
|
||||
if let Some(set) = sel.anchors.get_mut(&id) {
|
||||
// A selected anchor index can name no anchor (`select.anchors` takes any index), so the
|
||||
// renumbering saturates instead of overflowing.
|
||||
*set = set.iter().map(|&(s, a)| if s == si && a >= added { (s, a.saturating_add(1)) } else { (s, a) }).collect();
|
||||
}
|
||||
let split = added.checked_sub(1);
|
||||
if sel.segments.get(&id).is_some_and(|set| split.is_some_and(|g| set.contains(&(si, g)))) {
|
||||
sel.anchors.entry(id).or_default().insert((si, added));
|
||||
}
|
||||
if let Some(set) = sel.segments.get_mut(&id) {
|
||||
*set = set
|
||||
.iter()
|
||||
.flat_map(|&(s, g)| match (s == si, Some(g) == split) {
|
||||
(true, true) => vec![(s, g), (s, g.saturating_add(1))],
|
||||
(true, false) if g >= added => vec![(s, g.saturating_add(1))],
|
||||
_ => vec![(s, g)],
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
|
||||
fn reshape(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id").ok_or_else(|| bad("path.reshape", "missing id"))?;
|
||||
let at = Point::new(f64_req(p, "x", "path.reshape")?, f64_req(p, "y", "path.reshape")?);
|
||||
@@ -1406,19 +1562,94 @@ fn reshape(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let delta = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
|
||||
let tol = f64_or(p, "tol", 3.0).max(0.0);
|
||||
let radius = p.get("radius").and_then(Value::as_f64);
|
||||
s.edit("Reshape", |d, _| {
|
||||
if radius.is_some_and(|r| r < 0.0) {
|
||||
return Err(bad("path.reshape", "`radius` must be 0 or more"));
|
||||
}
|
||||
// The anchor within `tol` of the grab point, if there is one.
|
||||
let near = |sp: &SubPath| match sp.anchors.iter().enumerate().map(|(i, a)| (i, a.p.distance(at))).min_by(|a, b| a.1.total_cmp(&b.1)) {
|
||||
Some((i, dist)) if dist <= tol => Some(i),
|
||||
_ => None,
|
||||
};
|
||||
// The other focal points the Reshape tool highlighted on this path.
|
||||
let mut focal: BTreeSet<(usize, usize)> = match p.get("focal") {
|
||||
None | Some(Value::Null) => BTreeSet::new(),
|
||||
Some(v) if v.is_array() => super::select::checked_refs(Some(v), "path.reshape")?.into_iter().collect(),
|
||||
Some(_) => return Err(bad("path.reshape", "`focal` must be an array of [subpath, anchor] pairs")),
|
||||
};
|
||||
// An empty `focal` chooses the focal-point rule too.
|
||||
if radius.is_some() && !matches!(p.get("focal"), None | Some(Value::Null)) {
|
||||
return Err(bad("path.reshape", "`focal` and `radius` cannot be used together"));
|
||||
}
|
||||
// A step that names neither `focal` nor `radius` (as the Reshape tool recorded one before it
|
||||
// had focal points) keeps that earlier rule: the selection does not matter, an open path
|
||||
// stretches from the grabbed point to its two ends, and a closed one follows with a falloff
|
||||
// over the default radius.
|
||||
let earlier = radius.is_none() && matches!(p.get("focal"), None | Some(Value::Null));
|
||||
{
|
||||
let st = s.doc()?;
|
||||
let node = st.doc.node(id).ok_or(EngineError::NoNode(id))?;
|
||||
let path = node.path_data().ok_or_else(|| EngineError::Other(format!("object {id} is not a path")))?;
|
||||
if let Some((si, ai)) = focal.iter().copied().find(|&(si, ai)| path.subpaths.get(si).is_none_or(|sp| ai >= sp.anchors.len())) {
|
||||
return Err(bad("path.reshape", format!("`focal` names [{si}, {ai}], which is not an anchor of the path")));
|
||||
}
|
||||
// A drag of no length on an anchor moves nothing, so it is not an edit.
|
||||
if delta == Vec2::ZERO {
|
||||
let (si, ..) = path.nearest(at).ok_or_else(|| EngineError::Other("empty path".into()))?;
|
||||
if path.subpaths.get(si).is_some_and(|sp| near(sp).is_some()) {
|
||||
return ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
s.edit("Reshape", |d, sel| {
|
||||
let path = path_mut(d, id)?;
|
||||
let (si, seg, t, _, _) = path.nearest(at).ok_or_else(|| EngineError::Other("empty path".into()))?;
|
||||
let sp = &mut path.subpaths[si];
|
||||
let grabbed = match sp.anchors.iter().enumerate().map(|(i, a)| (i, a.p.distance(at))).min_by(|a, b| a.1.total_cmp(&b.1)) {
|
||||
Some((i, dist)) if dist <= tol => i,
|
||||
_ => sp.insert_anchor(seg, t),
|
||||
let sp = path.subpaths.get_mut(si).ok_or_else(|| EngineError::Other("empty path".into()))?;
|
||||
let grabbed = match near(sp) {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
let added = sp.insert_anchor(seg, t);
|
||||
focal = focal.iter().map(|&(s, a)| if s == si && a >= added { (s, a.saturating_add(1)) } else { (s, a) }).collect();
|
||||
renumber_after_insert(sel, id, si, added);
|
||||
// On a partly selected path the new anchor is selected too.
|
||||
if let Some(set) = sel.anchors.get_mut(&id) {
|
||||
set.insert((si, added));
|
||||
}
|
||||
added
|
||||
}
|
||||
};
|
||||
let earlier_closed = earlier && sp.closed;
|
||||
if radius.is_none() && !earlier_closed {
|
||||
focal.insert((si, grabbed));
|
||||
// Anchors that are not direct-selected stay; a path selected whole, or not selected,
|
||||
// has every anchor selected, and so does every path under the earlier rule.
|
||||
let selected = sel.anchors.get(&id).filter(|_| !earlier);
|
||||
for (k, sp) in path.subpaths.iter_mut().enumerate() {
|
||||
if focal.range((k, 0)..=(k, usize::MAX)).next().is_none() {
|
||||
continue;
|
||||
}
|
||||
let roles: Vec<Stretch> = (0..sp.anchors.len())
|
||||
.map(|i| match (focal.contains(&(k, i)), selected.is_none_or(|set| set.contains(&(k, i)))) {
|
||||
(true, _) => Stretch::Focal,
|
||||
(false, true) => Stretch::Selected,
|
||||
(false, false) => Stretch::Fixed,
|
||||
})
|
||||
.collect();
|
||||
let weights = stretch_weights(&sp.anchors, sp.closed, &roles);
|
||||
for (a, [w_in, w, w_out]) in sp.anchors.iter_mut().zip(weights) {
|
||||
a.p += delta * w;
|
||||
a.h_in += delta * w_in;
|
||||
a.h_out += delta * w_out;
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let sp = path.subpaths.get_mut(si).ok_or_else(|| EngineError::Other("empty path".into()))?;
|
||||
let closed = sp.closed;
|
||||
// Each anchor's distance along the path from the grabbed one, the shorter way round a
|
||||
// closed path: the lengths are measured once, as a path can have many anchors.
|
||||
let pos = sp.anchor_arc_positions();
|
||||
let total = pos.last().copied().unwrap_or(0.0);
|
||||
// The default reach is the subpath's size, as before, now measured along it.
|
||||
// The default reach of the earlier rule: half the diagonal of the subpath's bounding box.
|
||||
let r = radius.unwrap_or_else(|| {
|
||||
let mut bp = vectorcraft_geom::BezPath::new();
|
||||
sp.to_bezpath_into(&mut bp);
|
||||
@@ -1426,7 +1657,6 @@ fn reshape(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(b.width().hypot(b.height()) / 2.0).max(1.0)
|
||||
});
|
||||
let from = pos.get(grabbed).copied().unwrap_or(0.0);
|
||||
let closed = sp.closed;
|
||||
for (i, a) in sp.anchors.iter_mut().enumerate() {
|
||||
let w = if i == grabbed {
|
||||
1.0
|
||||
@@ -1459,6 +1689,7 @@ fn insert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(EngineError::Other("no such segment".into()));
|
||||
}
|
||||
let idx = sp.insert_anchor(seg, t);
|
||||
renumber_after_insert(sel, id, si, idx);
|
||||
// A path that wasn't selected becomes the selection, its new anchor direct-selected, so
|
||||
// the point added shows and can be edited at once (#4237).
|
||||
if !sel.contains(id) {
|
||||
|
||||
@@ -16,8 +16,8 @@ use vectorcraft_pathops::{BoolOp, PathfinderOp};
|
||||
use super::edit::selected_roots;
|
||||
use super::expand::{fill_shape, reshaped, seeded};
|
||||
use super::{
|
||||
CommandSpec, EngineError, Result, Session, always, bad, bool_or, checked_ids_or_selection, cmd, has_doc, has_selected_path, has_selection,
|
||||
number_or, str_param, switch_or,
|
||||
CommandSpec, EngineError, Result, Session, always, bad, check_switches, checked_ids_or_selection, cmd, has_doc, has_selected_path, has_selection,
|
||||
number_or, switch_or, text_param,
|
||||
};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -135,7 +135,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Outline Stroke",
|
||||
["Object", "Path"],
|
||||
None,
|
||||
"{} replace stroked paths with filled outlines; a dimension whose lines are outlined, or selected with the art it measures when that art is outlined, becomes a plain group of its art → {ids}",
|
||||
"{} replace stroked paths with filled outlines; a dimension whose lines are outlined becomes a plain group of its art, except one selected with art it measures, directly or in groups without fills, strokes or effects of their own: its lines stay as they are, and if the outlined art no longer fits its link it stays where it was, no longer linked → {ids}",
|
||||
has_selection,
|
||||
outline_stroke
|
||||
),
|
||||
@@ -221,6 +221,41 @@ pub(crate) fn len_param(p: &Value, key: &str) -> Option<f64> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The number `p` gives `cmd` for `key`, or `None` when `p` leaves it out or gives null. A string
|
||||
/// counts only when all of it is a number (`"20"`). Any other value, such as `true`, `"wide"` or
|
||||
/// `"20abc"`, is an error: "`{key}` must be a number, not {v}".
|
||||
pub(crate) fn number_given(p: &Value, key: &str, cmd: &str) -> Result<Option<f64>> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => match v {
|
||||
Value::String(s) => whole_number(s),
|
||||
_ => num_param(p, key),
|
||||
}
|
||||
.map(Some)
|
||||
.ok_or_else(|| bad(cmd, format!("`{key}` must be a number, not {v}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// The distance `p` gives `cmd` for `key`, or `None` when `p` leaves it out or gives null. A string
|
||||
/// counts only when all of it is a length that [`vectorcraft_doc::Unit::parse`] reads (`"5 mm"`) or a
|
||||
/// number. Any other value, such as `"20abc"`, is an error, as [`number_given`] gives it.
|
||||
pub(crate) fn length_given(p: &Value, key: &str, cmd: &str) -> Result<Option<f64>> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => match v {
|
||||
Value::String(s) => vectorcraft_doc::Unit::Points.parse(s).filter(|v| v.is_finite()).or_else(|| whole_number(s)),
|
||||
_ => num_param(p, key),
|
||||
}
|
||||
.map(Some)
|
||||
.ok_or_else(|| bad(cmd, format!("`{key}` must be a number, not {v}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// `s` as a finite number when all of it, spaces around it aside, is one.
|
||||
fn whole_number(s: &str) -> Option<f64> {
|
||||
s.trim().parse::<f64>().ok().filter(|v| v.is_finite())
|
||||
}
|
||||
|
||||
fn ids_json(ids: &[NodeId]) -> Value {
|
||||
json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() })
|
||||
}
|
||||
@@ -684,21 +719,22 @@ fn run_pf(s: &mut Session, p: &Value, op: PathfinderOp) -> Result<Value> {
|
||||
/// Pathfinder panel › Trap ([`vectorcraft_render::effects::trap`]): the traps between the selected
|
||||
/// objects' leaf shapes, as one group of overprinting paths above the topmost selected object.
|
||||
fn trap(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
use vectorcraft_render::effects::trap::{TRAP_LABEL, TrapOptions, TrapSource, trap_appearance, traps};
|
||||
use vectorcraft_render::effects::trap::{TRAP_LABEL, TrapOptions, TrapSource, trap_appearance, try_traps};
|
||||
const ID: &str = "object.pathfinder.trap";
|
||||
let mut q = p.clone();
|
||||
if let Some(t) = len_param(p, "thickness") {
|
||||
if let Some(t) = length_given(p, "thickness", ID)? {
|
||||
if !(po::TRAP_MIN_WIDTH..=po::TRAP_MAX_WIDTH).contains(&t) {
|
||||
return Err(bad(ID, "thickness must be from 0.01 to 5000 pt"));
|
||||
}
|
||||
q["thickness"] = json!(t);
|
||||
}
|
||||
if num_param(p, "heightWidth").is_some_and(|v| !(1.0..=1000.0).contains(&v)) {
|
||||
if number_given(p, "heightWidth", ID)?.is_some_and(|v| !(1.0..=1000.0).contains(&v)) {
|
||||
return Err(bad(ID, "heightWidth must be from 1 to 1000 %"));
|
||||
}
|
||||
if num_param(p, "tintReduction").is_some_and(|v| !(0.0..=100.0).contains(&v)) {
|
||||
if number_given(p, "tintReduction", ID)?.is_some_and(|v| !(0.0..=100.0).contains(&v)) {
|
||||
return Err(bad(ID, "tintReduction must be from 0 to 100 %"));
|
||||
}
|
||||
check_switches(p, &["trapsWithProcessColor", "reverseTraps"], ID)?;
|
||||
// The effect-only settings stay at their defaults.
|
||||
if let Some(o) = q.as_object_mut() {
|
||||
o.remove("precision");
|
||||
@@ -722,7 +758,12 @@ fn trap(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if sources.iter().filter(|s| s.fill.color().is_some()).count() < 2 {
|
||||
return Err(EngineError::Other("Trap: select two or more objects filled with solid colours".into()));
|
||||
}
|
||||
let made = traps(&sources, &options);
|
||||
let Some(made) = try_traps(&sources, &options) else {
|
||||
return Err(EngineError::Other(format!(
|
||||
"Trap: more than {} pairs of objects lie close enough to trap, or too many objects share a horizontal range; trap fewer objects at a time",
|
||||
vectorcraft_render::effects::trap::MAX_NEAR_PAIRS
|
||||
)));
|
||||
};
|
||||
if made.is_empty() {
|
||||
return Err(EngineError::Other("Trap: no edges of different colours meet among the selected objects".into()));
|
||||
}
|
||||
@@ -743,21 +784,23 @@ fn trap(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
// ---------- Offset Path ----------
|
||||
|
||||
fn join_param(p: &Value, key: &str) -> po::Join {
|
||||
match str_param(p, key).map(str::to_ascii_lowercase).as_deref() {
|
||||
Some("round") => po::Join::Round,
|
||||
Some("bevel") => po::Join::Bevel,
|
||||
_ => po::Join::Miter,
|
||||
fn join_param(p: &Value, key: &str, cmd: &str) -> Result<po::Join> {
|
||||
match text_param(p, key, cmd)?.map(str::to_ascii_lowercase).as_deref() {
|
||||
None | Some("miter") => Ok(po::Join::Miter),
|
||||
Some("round") => Ok(po::Join::Round),
|
||||
Some("bevel") => Ok(po::Join::Bevel),
|
||||
Some(_) => Err(bad(cmd, format!("`{key}` must be one of miter, round, bevel"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn offset_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let delta = len_param(p, "offset").unwrap_or(10.0);
|
||||
const C: &str = "object.path.offsetPath";
|
||||
let delta = length_given(p, "offset", C)?.unwrap_or(10.0);
|
||||
if delta.abs() > 1e5 {
|
||||
return Err(bad("object.path.offsetPath", "offset out of range"));
|
||||
return Err(bad(C, "offset out of range"));
|
||||
}
|
||||
let join = join_param(p, "joins");
|
||||
let miter = num_param(p, "miterLimit").unwrap_or(4.0).clamp(1.0, 500.0);
|
||||
let join = join_param(p, "joins", C)?;
|
||||
let miter = number_given(p, "miterLimit", C)?.unwrap_or(4.0).clamp(1.0, 500.0);
|
||||
let roots = selected_roots(s)?;
|
||||
let ids = s.edit("Offset Path", |d, sel| {
|
||||
let (mut out, mut above) = (vec![], vec![]);
|
||||
@@ -1036,7 +1079,11 @@ 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();
|
||||
let mut ended = super::dimension::end_links(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);
|
||||
let kept = !lifted.is_empty();
|
||||
let ended = super::dimension::end_links(d, &roots);
|
||||
let brushes = vectorcraft_brush::library(d);
|
||||
let mut new_sel = vec![];
|
||||
let mut changed = 0;
|
||||
@@ -1045,10 +1092,10 @@ fn outline_stroke(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
new_sel.push(id);
|
||||
changed += n;
|
||||
}
|
||||
// A dimension whose source was outlined stays plain: its link no longer fits that art.
|
||||
ended.retain(|e| e.source_kept(&before, d));
|
||||
super::dimension::restore_unchanged(d, ended);
|
||||
if changed == 0 {
|
||||
super::dimension::put_back(d, lifted);
|
||||
// A dimension whose source was outlined stays where it was, no longer linked.
|
||||
super::dimension::restore_unchanged(&before, d, ended);
|
||||
if changed == 0 || (kept && d.layers == before.layers) {
|
||||
return Err(EngineError::Other("Outline Stroke: no stroked paths selected".into()));
|
||||
}
|
||||
sel.set(new_sel.iter().copied());
|
||||
@@ -1167,23 +1214,21 @@ pub fn auto_simplify_level(s: &Session) -> Result<f64> {
|
||||
}
|
||||
|
||||
fn simplify(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.path.simplify";
|
||||
let (auto, given_level, given_tolerance) = (switch_or(p, "auto", false, C)?, number_given(p, "level", C)?, length_given(p, "tolerance", C)?);
|
||||
let corner_angle = number_given(p, "cornerAngle", C)?.unwrap_or(30.0).clamp(0.0, 180.0);
|
||||
let (straight_lines, whole) = (switch_or(p, "straightLines", false, C)?, switch_or(p, "wholePaths", false, C)?);
|
||||
// With auto, the slider's level is Auto-Simplify's for the selection's size; otherwise it is the level given.
|
||||
let level = if bool_or(p, "auto", false) {
|
||||
let level = if auto {
|
||||
Some(auto_simplify_level(s)?)
|
||||
} else {
|
||||
num_param(p, "level").map(|l| if l.is_finite() { l.round().clamp(0.0, 100.0) } else { po::SIMPLIFY_LEVEL })
|
||||
given_level.map(|l| if l.is_finite() { l.round().clamp(0.0, 100.0) } else { po::SIMPLIFY_LEVEL })
|
||||
};
|
||||
let tolerance = match level {
|
||||
Some(l) => po::simplify_level_tolerance(l),
|
||||
None => len_param(p, "tolerance").unwrap_or(1.0),
|
||||
None => given_tolerance.unwrap_or(1.0),
|
||||
};
|
||||
let opts = po::SimplifyOptions {
|
||||
tolerance: tolerance.clamp(1e-4, 1e4),
|
||||
corner_angle_deg: num_param(p, "cornerAngle").unwrap_or(30.0).clamp(0.0, 180.0),
|
||||
straight_lines: bool_or(p, "straightLines", false),
|
||||
smooth: 0.0,
|
||||
};
|
||||
let whole = bool_or(p, "wholePaths", false);
|
||||
let opts = po::SimplifyOptions { tolerance: tolerance.clamp(1e-4, 1e4), corner_angle_deg: corner_angle, straight_lines, smooth: 0.0 };
|
||||
// A path with segments direct-selected simplifies only their runs, and keeps those runs (and the
|
||||
// other anchors it had selected) direct-selected.
|
||||
let roots = selected_roots(s)?;
|
||||
@@ -1257,10 +1302,17 @@ fn grid_axis(p: &Value, n: f64, [size, gutter, total]: [&str; 3], extent: f64) -
|
||||
}
|
||||
|
||||
fn split_into_grid(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let rows = num_param(p, "rows").unwrap_or(2.0).round();
|
||||
let cols = num_param(p, "columns").unwrap_or(2.0).round();
|
||||
const C: &str = "object.path.splitIntoGrid";
|
||||
let rows = number_given(p, "rows", C)?.unwrap_or(2.0).round();
|
||||
let cols = number_given(p, "columns", C)?.unwrap_or(2.0).round();
|
||||
if !(1.0..=500.0).contains(&rows) || !(1.0..=500.0).contains(&cols) {
|
||||
return Err(bad("object.path.splitIntoGrid", "rows and columns must be 1..500"));
|
||||
return Err(bad(C, "rows and columns must be 1..500"));
|
||||
}
|
||||
let add_guides = switch_or(p, "addGuides", false, C)?;
|
||||
// Each distance [`grid_axis`] reads is a number, a string with a unit or null, checked before the
|
||||
// selection, so a bad one is an error whatever is selected.
|
||||
for key in ["height", "rowGutter", "totalHeight", "width", "columnGutter", "totalWidth", "gutter"] {
|
||||
length_given(p, key, C)?;
|
||||
}
|
||||
let too_small = || EngineError::Other("Split Into Grid: select paths large enough for the grid".into());
|
||||
// The selected paths, back to front: one grid over them all replaces them, where the topmost
|
||||
@@ -1281,7 +1333,7 @@ fn split_into_grid(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(too_small());
|
||||
}
|
||||
let mut edges: Vec<(bool, f64)> = Vec::new();
|
||||
if bool_or(p, "addGuides", false) {
|
||||
if add_guides {
|
||||
// Row-major cells: the first row has the column edges, each row's first cell the row edges.
|
||||
let bounds = cells.iter().filter_map(PathData::bounds);
|
||||
edges.extend(bounds.clone().take(cols).flat_map(|c| [(true, c.x0), (true, c.x1)]));
|
||||
@@ -1299,7 +1351,9 @@ fn split_into_grid(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let node = shape_node(d, c, Some(&top));
|
||||
out.push(d.insert(par, idx + k, node)?);
|
||||
}
|
||||
d.guides.extend(edges.iter().map(|&(vertical, pos)| vectorcraft_doc::Guide { layer, ..vectorcraft_doc::Guide::new(vertical, pos) }));
|
||||
for &(vertical, pos) in &edges {
|
||||
d.add_ruler_guide(&vectorcraft_doc::Guide { layer, ..vectorcraft_doc::Guide::new(vertical, pos) });
|
||||
}
|
||||
sel.set(out.iter().copied());
|
||||
Ok(out)
|
||||
})?;
|
||||
@@ -1316,9 +1370,10 @@ fn is_painted(a: &Appearance) -> bool {
|
||||
}
|
||||
|
||||
fn clean_up(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let stray = bool_or(p, "strayPoints", true);
|
||||
let unpainted = bool_or(p, "unpaintedObjects", true);
|
||||
let empty_text = bool_or(p, "emptyTextPaths", true);
|
||||
const C: &str = "object.path.cleanUp";
|
||||
let stray = switch_or(p, "strayPoints", true, C)?;
|
||||
let unpainted = switch_or(p, "unpaintedObjects", true, C)?;
|
||||
let empty_text = switch_or(p, "emptyTextPaths", true, C)?;
|
||||
let found = {
|
||||
let d = &s.doc()?.doc;
|
||||
let mut v = vec![];
|
||||
|
||||
@@ -15,13 +15,13 @@ use serde_json::{Value, json};
|
||||
use vectorcraft_color::Paint;
|
||||
use vectorcraft_doc::pattern::{self, Overlap, PATTERN_EDIT_LAYER, PatternDef, PatternEdit, RepeatKind, RepeatSpec, TileType, pattern_paint};
|
||||
use vectorcraft_doc::{Document, Node, NodeId, NodeKind, Selection};
|
||||
use vectorcraft_geom::Rect;
|
||||
use vectorcraft_geom::{Point, Rect};
|
||||
|
||||
use super::edit::{outermost, selected_roots};
|
||||
use super::pathops::Beside;
|
||||
use super::{
|
||||
CommandSpec, EngineError, Result, Session, bad, bool_or, cmd, f64_or, has_doc, has_selection, ids_param, matrix_param, ok, point_param,
|
||||
str_param, targets,
|
||||
CommandSpec, EngineError, Result, Session, bad, check_numbers, check_switches, cmd, f64_or, finite_numbers, has_doc, has_selection, ids_param,
|
||||
matrix_param, ok, switch_or, targets, text_param,
|
||||
};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -116,7 +116,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Mirror",
|
||||
["Object", "Repeat"],
|
||||
None,
|
||||
"{ids?, angle?: deg (90 = vertical axis), offset?: pt (10)} mirror repeat of the selection → {id}",
|
||||
"{ids?, angle?: deg clockwise from level (the mirror axis; 90 = vertical), offset?: pt (10)} mirror repeat of the selection → {id}",
|
||||
has_selection,
|
||||
repeat_mirror
|
||||
),
|
||||
@@ -134,7 +134,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Options…",
|
||||
["Object", "Repeat"],
|
||||
None,
|
||||
"{instances?, radius?, reverseOverlap?, startAngle?, endAngle?, center?: [x,y] | hSpacing?, vSpacing?, rows?, cols?, gridType?, flipRows?, flipCols? | angle?} set the options of the selected repeats",
|
||||
"{instances?, radius?, reverseOverlap?, startAngle?, endAngle?: deg clockwise (0: where the source art sits), center?: [x,y] | hSpacing?, vSpacing?, rows?, cols?, gridType?, brickOffset?, flipRows?, flipCols? | angle?: deg clockwise from level (the mirror axis; 90 = vertical)} set the options of the selected repeats",
|
||||
has_repeat,
|
||||
repeat_options
|
||||
),
|
||||
@@ -271,8 +271,9 @@ fn enter_edit(d: &mut Document, sel: &mut Selection, name: &str, original: Optio
|
||||
/// Leave editing mode: drop the layer, return the edit record.
|
||||
fn leave_edit(d: &mut Document, sel: &mut Selection) -> Result<(PatternEdit, Vec<Arc<Node>>)> {
|
||||
let pe = d.pattern_edit.take().ok_or_else(|| EngineError::Other("not in pattern editing mode".into()))?;
|
||||
let art = match d.remove(pe.layer) {
|
||||
Ok(l) => l.children().cloned().unwrap_or_default(),
|
||||
// The tile art: ruler guides on the edit layer are not part of it and stay in the document.
|
||||
let art = match d.remove_edit_layer(pe.layer) {
|
||||
Ok(l) => l.children().into_iter().flatten().cloned().collect(),
|
||||
Err(_) => vec![],
|
||||
};
|
||||
sel.clear();
|
||||
@@ -287,9 +288,12 @@ fn after_edit_mode(s: &mut Session, layer: Option<NodeId>) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The tile type `p` gives `cmd` (`tileType`, `brickOffset`), else `current` with a given brick offset.
|
||||
/// A `tileType` that is not a string, or a `brickOffset` that is not a number, is an error.
|
||||
fn tile_type_param(p: &Value, cmd: &str, current: TileType) -> Result<TileType> {
|
||||
check_numbers(p, &["brickOffset"], cmd)?;
|
||||
let off = p.get("brickOffset").and_then(Value::as_f64).map(|o| if o > 1.0 { o / 100.0 } else { o });
|
||||
match str_param(p, "tileType") {
|
||||
match text_param(p, "tileType", cmd)? {
|
||||
Some(t) => TileType::parse(t, off).ok_or_else(|| bad(cmd, format!("unknown tileType `{t}` ({})", TileType::IDS.join("|")))),
|
||||
None => Ok(match (current, off) {
|
||||
(TileType::BrickByRow { .. }, Some(o)) => TileType::BrickByRow { offset: o },
|
||||
@@ -311,12 +315,13 @@ fn pattern_make(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(C, "select the art for the pattern tile"));
|
||||
}
|
||||
let tt = tile_type_param(p, C, TileType::Grid)?;
|
||||
check_numbers(p, &["width", "height"], C)?;
|
||||
let (w, h) = (p.get("width").and_then(Value::as_f64), p.get("height").and_then(Value::as_f64));
|
||||
if w.is_some_and(|v| !(v.is_finite() && v > 0.0)) || h.is_some_and(|v| !(v.is_finite() && v > 0.0)) {
|
||||
return Err(bad(C, "width/height must be > 0"));
|
||||
}
|
||||
let edit = bool_or(p, "edit", true);
|
||||
let wanted = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
|
||||
let edit = switch_or(p, "edit", true, C)?;
|
||||
let wanted = text_param(p, "name", C)?.map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
|
||||
let name = s.edit("Make Pattern", |d, sel| {
|
||||
let name = match &wanted {
|
||||
Some(n) => unique_pattern_name(d, n),
|
||||
@@ -343,7 +348,7 @@ fn pattern_make(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn pattern_edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.pattern.edit";
|
||||
let name = match str_param(p, "name") {
|
||||
let name = match text_param(p, "name", C)? {
|
||||
Some(n) => n.to_string(),
|
||||
None => {
|
||||
// The selected object's fill (or stroke) pattern.
|
||||
@@ -419,11 +424,12 @@ fn pattern_cancel(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn pattern_save_copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let wanted = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
|
||||
let wanted = text_param(p, "name", "object.pattern.saveCopy")?.map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
|
||||
let name = s.edit("Save a Copy", |d, _| {
|
||||
let pe = d.pattern_edit.clone().ok_or_else(|| EngineError::Other("not in pattern editing mode".into()))?;
|
||||
let mut def = d.pattern(&pe.pattern).cloned().ok_or_else(|| EngineError::Other("pattern missing".into()))?;
|
||||
let art: Vec<Arc<Node>> = d.node(pe.layer).and_then(|l| l.children().cloned()).unwrap_or_default();
|
||||
let art: Vec<Arc<Node>> =
|
||||
d.node(pe.layer).into_iter().flat_map(|l| l.children().into_iter().flatten()).filter(|c| !c.is_ruler_guide()).cloned().collect();
|
||||
let name = unique_pattern_name(d, wanted.as_deref().unwrap_or(&format!("{} copy", pe.pattern.trim())));
|
||||
def.name = name.clone();
|
||||
def.art = copies(d, &art).into_iter().map(Arc::new).collect();
|
||||
@@ -438,19 +444,21 @@ fn pattern_save_copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn pattern_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "pattern.options";
|
||||
let name = match str_param(p, "name") {
|
||||
let name = match text_param(p, "name", C)? {
|
||||
Some(n) => n.to_string(),
|
||||
None => editing(s).map(|e| e.pattern.clone()).ok_or_else(|| bad(C, "give `name` (or edit a pattern)"))?,
|
||||
};
|
||||
let cur = s.doc()?.doc.pattern(&name).cloned().ok_or_else(|| bad(C, format!("no pattern `{name}`")))?;
|
||||
let tt = tile_type_param(p, C, cur.tile_type)?;
|
||||
check_numbers(p, &["width", "height", "hSpacing", "vSpacing", "copies", "dimCopies"], C)?;
|
||||
check_switches(p, &["sizeTileToArt", "showTileEdge", "showSwatchBounds"], C)?;
|
||||
let num = |k: &str| p.get(k).and_then(Value::as_f64);
|
||||
for k in ["width", "height"] {
|
||||
if num(k).is_some_and(|v| !(v.is_finite() && v > 0.0)) {
|
||||
return Err(bad(C, format!("{k} must be > 0")));
|
||||
}
|
||||
}
|
||||
let new_name = str_param(p, "newName").map(str::trim).filter(|n| !n.is_empty() && *n != name).map(str::to_string);
|
||||
let new_name = text_param(p, "newName", C)?.map(str::trim).filter(|n| !n.is_empty() && *n != name).map(str::to_string);
|
||||
if let Some(n) = &new_name {
|
||||
let d = &s.doc()?.doc;
|
||||
if d.pattern(n).is_some() {
|
||||
@@ -461,17 +469,22 @@ fn pattern_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
let overlap = match p.get("overlap") {
|
||||
Some(o) => Overlap {
|
||||
right_in_front: str_param(o, "h").map_or(cur.overlap.right_in_front, |v| v == "right"),
|
||||
bottom_in_front: str_param(o, "v").map_or(cur.overlap.bottom_in_front, |v| v == "bottom"),
|
||||
None | Some(Value::Null) => cur.overlap,
|
||||
Some(o) if o.is_object() => Overlap {
|
||||
right_in_front: overlap_side(o, "h", ["left", "right"], cur.overlap.right_in_front, C)?,
|
||||
bottom_in_front: overlap_side(o, "v", ["top", "bottom"], cur.overlap.bottom_in_front, C)?,
|
||||
},
|
||||
None => cur.overlap,
|
||||
Some(got) => {
|
||||
let (path, expected) = ("overlap", "an object");
|
||||
return Err(bad(C, format!("`{path}` must be {expected}, not {got}")));
|
||||
}
|
||||
};
|
||||
let edit_layer = editing(s).filter(|e| e.pattern == name).map(|e| e.layer);
|
||||
let out_name = new_name.clone().unwrap_or(name.clone());
|
||||
s.edit("Pattern Options", |d, _| {
|
||||
// While editing, the tile art is the edit layer's.
|
||||
let live_art: Option<Vec<Arc<Node>>> = edit_layer.and_then(|l| d.node(l)).and_then(|l| l.children().cloned());
|
||||
let live_art: Option<Vec<Arc<Node>>> =
|
||||
edit_layer.and_then(|l| d.node(l)).and_then(|l| l.children()).map(|ch| ch.iter().filter(|c| !c.is_ruler_guide()).cloned().collect());
|
||||
let def = d.pattern_mut(&name).ok_or_else(|| EngineError::Other("pattern missing".into()))?;
|
||||
def.tile_type = tt;
|
||||
def.overlap = overlap;
|
||||
@@ -514,6 +527,19 @@ fn pattern_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "name": out_name }))
|
||||
}
|
||||
|
||||
/// Side `side` of the `overlap` object `o` gives `cmd`: true for the second of `names`, false for the
|
||||
/// first, `current` when `o` leaves it out or gives null. Any other value is an error.
|
||||
fn overlap_side(o: &Value, side: &str, names: [&str; 2], current: bool, cmd: &str) -> Result<bool> {
|
||||
match o.get(side) {
|
||||
None | Some(Value::Null) => Ok(current),
|
||||
Some(Value::String(v)) if names.contains(&v.as_str()) => Ok(v == names[1]),
|
||||
Some(_) => {
|
||||
let key = format!("overlap.{side}");
|
||||
Err(bad(cmd, format!("`{key}` must be one of {}", names.join(", "))))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `new` taken for pattern `old` by a swatch or a color group (they share one namespace,
|
||||
/// [`Document::swatch_name_taken`])? It is free when the swatches named `new` are one or more
|
||||
/// swatches of the pattern and none of the pattern's swatches is named `old` (those take the new
|
||||
@@ -571,7 +597,7 @@ fn nodes_using(d: &Document, name: Option<&str>) -> Vec<NodeId> {
|
||||
|
||||
fn pattern_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "pattern.delete";
|
||||
let name = str_param(p, "name").ok_or_else(|| bad(C, "missing name"))?.to_string();
|
||||
let name = text_param(p, "name", C)?.ok_or_else(|| bad(C, "missing name"))?.to_string();
|
||||
if s.doc()?.doc.pattern(&name).is_none() {
|
||||
return Err(bad(C, format!("no pattern `{name}`")));
|
||||
}
|
||||
@@ -694,18 +720,25 @@ fn make_repeat(s: &mut Session, p: &Value, cmd: &str, label: &str, make: impl Fn
|
||||
}
|
||||
|
||||
fn repeat_radial(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.repeat.radial";
|
||||
check_numbers(p, &["instances", "radius"], C)?;
|
||||
let n = f64_or(p, "instances", 8.0);
|
||||
if !(1.0..=1000.0).contains(&n) {
|
||||
return Err(bad("object.repeat.radial", "instances must be 1..1000"));
|
||||
return Err(bad(C, "instances must be 1..1000"));
|
||||
}
|
||||
let radius = p.get("radius").and_then(Value::as_f64).filter(|r| r.is_finite() && *r >= 0.0);
|
||||
make_repeat(s, p, "object.repeat.radial", "Radial Repeat", |src| RepeatSpec::radial(src, n as u32, radius))
|
||||
let radius = p.get("radius").and_then(Value::as_f64);
|
||||
if radius.is_some_and(|r| r < 0.0) {
|
||||
let key = "radius";
|
||||
return Err(bad(C, format!("`{key}` must be a number not below 0")));
|
||||
}
|
||||
make_repeat(s, p, C, "Radial Repeat", |src| RepeatSpec::radial(src, n as u32, radius))
|
||||
}
|
||||
|
||||
fn repeat_grid(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
check_numbers(p, &["hSpacing", "vSpacing", "rows", "cols"], "object.repeat.grid")?;
|
||||
let (hs, vs) = (p.get("hSpacing").and_then(Value::as_f64), p.get("vSpacing").and_then(Value::as_f64));
|
||||
let rows = p.get("rows").and_then(Value::as_u64).map(|v| v.clamp(1, 500) as u32);
|
||||
let cols = p.get("cols").and_then(Value::as_u64).map(|v| v.clamp(1, 500) as u32);
|
||||
let rows = p.get("rows").and_then(Value::as_f64).map(|v| (v as u32).clamp(1, 500));
|
||||
let cols = p.get("cols").and_then(Value::as_f64).map(|v| (v as u32).clamp(1, 500));
|
||||
make_repeat(s, p, "object.repeat.grid", "Grid Repeat", |src| {
|
||||
let b = vectorcraft_doc::live::nodes_bounds(&src).unwrap_or(Rect::new(0.0, 0.0, 10.0, 10.0));
|
||||
let mut r = RepeatSpec::grid(src, hs.unwrap_or(b.width() / 4.0), vs.unwrap_or(b.height() / 4.0));
|
||||
@@ -718,6 +751,7 @@ fn repeat_grid(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn repeat_mirror(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
check_numbers(p, &["angle", "offset"], "object.repeat.mirror")?;
|
||||
let angle = f64_or(p, "angle", 90.0);
|
||||
let offset = f64_or(p, "offset", 10.0);
|
||||
make_repeat(s, p, "object.repeat.mirror", "Mirror Repeat", |src| RepeatSpec::mirror(src, angle, offset))
|
||||
@@ -803,16 +837,26 @@ pub(crate) fn expand_repeats(d: &mut Document, roots: &[NodeId]) -> Result<usize
|
||||
fn repeat_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.repeat.options";
|
||||
let reps = selected_repeats(s);
|
||||
let numbers = ["instances", "radius", "startAngle", "endAngle", "hSpacing", "vSpacing", "rows", "cols", "brickOffset", "angle"];
|
||||
check_numbers(p, &numbers, C)?;
|
||||
check_switches(p, &["reverseOverlap", "flipRows", "flipCols"], C)?;
|
||||
let num = |k: &str| p.get(k).and_then(Value::as_f64).filter(|v| v.is_finite());
|
||||
let flag = |k: &str| p.get(k).and_then(Value::as_bool);
|
||||
if num("instances").is_some_and(|n| !(1.0..=1000.0).contains(&n)) {
|
||||
return Err(bad(C, "instances must be 1..1000"));
|
||||
}
|
||||
let grid_type = match str_param(p, "gridType") {
|
||||
let grid_type = match text_param(p, "gridType", C)? {
|
||||
Some(t) => Some(TileType::parse(t, num("brickOffset")).ok_or_else(|| bad(C, format!("unknown gridType `{t}`")))?),
|
||||
None => None,
|
||||
};
|
||||
let center = point_param(p, "center");
|
||||
let center = match p.get("center") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => {
|
||||
let key = "center";
|
||||
let [x, y] = finite_numbers::<2>(v).ok_or_else(|| bad(C, format!("{key} must be [x, y] with finite coordinates")))?;
|
||||
Some(Point::new(x, y))
|
||||
}
|
||||
};
|
||||
s.edit("Repeat Options", |d, _| {
|
||||
for r in &reps {
|
||||
let Some(NodeKind::Repeat(spec)) = d.node_mut(*r).map(|n| &mut n.kind) else { continue };
|
||||
@@ -852,6 +896,8 @@ fn repeat_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
if let Some(t) = grid_type {
|
||||
*gt = t;
|
||||
} else if let (Some(v), TileType::BrickByRow { offset } | TileType::BrickByColumn { offset }) = (num("brickOffset"), &mut *gt) {
|
||||
*offset = v;
|
||||
}
|
||||
if let Some(v) = flag("flipRows") {
|
||||
*flip_rows = v;
|
||||
|
||||
@@ -10,6 +10,7 @@ use vectorcraft_doc::pattern::pattern_paint;
|
||||
use vectorcraft_doc::{Appearance, Document, Node, NodeId, PatternDef, TileType};
|
||||
use vectorcraft_geom::{PathData, Point, Rect, SubPath, shapes};
|
||||
|
||||
use super::Result;
|
||||
use super::clipboard::Clipboard;
|
||||
use super::clipboard::resources::SwatchChoices;
|
||||
use super::swatchlib::LibraryInfo;
|
||||
@@ -84,11 +85,11 @@ pub fn swatch_source(w: &Swatch) -> Option<Arc<Document>> {
|
||||
/// Add pattern `name` of library `lib` to `d` with the resources its art uses, as pasting art
|
||||
/// painted with it does: the same pattern there already is used, and a name another pattern or
|
||||
/// swatch has gets a number → the pattern's name in `d`.
|
||||
pub(crate) fn import(d: &mut Document, lib: &Document, name: &str) -> String {
|
||||
pub(crate) fn import(d: &mut Document, lib: &Document, name: &str) -> Result<String> {
|
||||
let user = Node::path(NodeId(0), shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::basic(pattern_paint(name), Paint::None, 0.0));
|
||||
let imported = Clipboard::gather(lib, vec![user]).import_into(d, &SwatchChoices::default(), false);
|
||||
let imported = Clipboard::gather(lib, vec![user]).import_into(d, &SwatchChoices::default(), false)?;
|
||||
let renamed = imported.renamed.iter().find(|r| r["kind"] == "pattern" && r["from"] == name).and_then(|r| r["to"].as_str());
|
||||
renamed.unwrap_or(name).to_string()
|
||||
Ok(renamed.unwrap_or(name).to_string())
|
||||
}
|
||||
|
||||
/// The pattern of `d` that is pattern `name` of library `lib`: the same tile options and art under
|
||||
|
||||
@@ -377,7 +377,7 @@ fn placeable(l: &Node) -> bool {
|
||||
fn art_of<'a>(nodes: impl Iterator<Item = &'a Arc<Node>>) -> Vec<Node> {
|
||||
nodes
|
||||
.filter_map(|c| match &c.kind {
|
||||
NodeKind::Path { guide: true, .. } => None,
|
||||
_ if c.is_guide() => None,
|
||||
NodeKind::Layer { children, clip, .. } => placeable(c).then(|| {
|
||||
let mut g = (**c).clone();
|
||||
g.kind = NodeKind::Group { children: art_of(children.iter()).into_iter().map(Arc::new).collect(), clip: *clip };
|
||||
|
||||
@@ -88,6 +88,9 @@ pub fn describe(p: &Plugin) -> Value {
|
||||
if m.paint_output {
|
||||
v["paintOutput"] = json!(true);
|
||||
}
|
||||
if !m.command_allowlist.is_empty() {
|
||||
v["commandAllowlist"] = json!(m.command_allowlist);
|
||||
}
|
||||
if !m.commands.is_empty() {
|
||||
let commands: Vec<Value> = m
|
||||
.commands
|
||||
@@ -258,6 +261,37 @@ impl FilterRun {
|
||||
self.plugin.run(&self.input, &self.params).map_err(err)
|
||||
}
|
||||
|
||||
/// Does the plug-in call commands (its manifest's `commandAllowlist`)? Then it runs only in the
|
||||
/// session ([`FilterRun::run_with_calls`]).
|
||||
pub fn calls(&self) -> bool {
|
||||
!self.plugin.manifest().command_allowlist.is_empty()
|
||||
}
|
||||
|
||||
/// Runs the plug-in in `s`, making the command calls it asks for on the active document
|
||||
/// ([`super::plugin_call::call`]) → its output.
|
||||
fn run_calling(&self, s: &mut Session) -> Result<Vec<u8>> {
|
||||
let may = self.plugin.manifest().permissions();
|
||||
let calls = super::plugin_call::Calls::of(s, may.contains(&Permission::DocRead), may.contains(&Permission::DocWrite));
|
||||
let mut bridge = |id: &str, args: &Value| super::plugin_call::call(s, &calls, id, args);
|
||||
self.plugin.run_calling(&self.input, &self.params, self.plugin.limits(), &mut bridge).0.map_err(err)
|
||||
}
|
||||
|
||||
/// Runs a plug-in that calls commands and applies its output: the calls and the output are one
|
||||
/// undo step, and a run that fails changes nothing ([`super::plugin_call::transaction`]).
|
||||
fn run_with_calls(self, s: &mut Session) -> Result<Value> {
|
||||
let label = self.label();
|
||||
super::plugin_call::transaction(s, &label, |s| {
|
||||
let output = self.run_calling(s)?;
|
||||
self.finish(s, &output)
|
||||
})
|
||||
}
|
||||
|
||||
/// The name of its undo step: the plug-in's, or its command's.
|
||||
fn label(&self) -> String {
|
||||
let m = self.plugin.manifest();
|
||||
self.command.as_ref().map_or_else(|| m.name.trim_end_matches('…').to_string(), Command::shown_label)
|
||||
}
|
||||
|
||||
/// Hands in `output`, which [`FilterRun::run`] returned: the next `plugin.run` that makes this
|
||||
/// same run (same plug-in, `ids` and parameters, the document unchanged) applies it instead of
|
||||
/// running the plug-in. Any other run drops it.
|
||||
@@ -277,9 +311,9 @@ impl FilterRun {
|
||||
return Err(not_granted(&m.name, Permission::State));
|
||||
}
|
||||
let may_write = may.contains(&Permission::DocWrite);
|
||||
let (inputs, parent) = (self.inputs, self.parent);
|
||||
// A command's undo step is named after the command.
|
||||
let label = self.command.as_ref().map_or_else(|| m.name.trim_end_matches('…').to_string(), Command::shown_label);
|
||||
let label = self.label();
|
||||
let (inputs, parent) = (self.inputs, self.parent);
|
||||
let ids = s.edit(&label, |d, sel| {
|
||||
let before = (!may_write).then(|| d.layers.clone());
|
||||
let ids = objects::apply_output(d, &inputs, out.objects, parent, &admit_effects).map_err(err)?;
|
||||
@@ -316,6 +350,12 @@ fn run(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// An output handed in is for this run only (the app hands it in just before), whatever it does.
|
||||
let handed = s.plugin_output.take();
|
||||
let r = FilterRun::prepare(s, p)?;
|
||||
// One that calls commands runs here, where it can make them, on the document with any edit in
|
||||
// progress committed.
|
||||
if r.calls() {
|
||||
let r = if super::plugin_call::settle(s)? { FilterRun::prepare(s, p)? } else { r };
|
||||
return r.run_with_calls(s);
|
||||
}
|
||||
// Handed in for this very run (the app ran it in the background), it is what running the
|
||||
// plug-in again would return.
|
||||
let handed = handed.filter(|h| (h.serial, &h.input, &h.params) == (r.plugin.serial(), &r.input, &r.params));
|
||||
@@ -463,6 +503,16 @@ pub(crate) fn run_command(s: &mut Session, id: &str, p: &Value) -> Result<Value>
|
||||
}
|
||||
};
|
||||
let r = FilterRun::prepare_with(s, plugin, Some(command), id, p)?;
|
||||
if r.calls() {
|
||||
let r = match super::plugin_call::settle(s)? {
|
||||
true => {
|
||||
let (plugin, command) = command_of(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?;
|
||||
FilterRun::prepare_with(s, plugin, Some(command), id, p)?
|
||||
}
|
||||
false => r,
|
||||
};
|
||||
return r.run_with_calls(s).map_err(failed);
|
||||
}
|
||||
let output = r.run().map_err(&failed)?;
|
||||
r.finish(s, &output).map_err(failed)
|
||||
}
|
||||
@@ -654,6 +704,16 @@ fn install(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"`grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app",
|
||||
));
|
||||
}
|
||||
// A plug-in never calls a command no plug-in may call, whatever its manifest lists.
|
||||
let (unknown, refused) = super::plugin_call::check_allowlist(plugin.manifest());
|
||||
if let Some(id) = refused.first() {
|
||||
let (name, id) = (
|
||||
permissions::display_text(&plugin.manifest().name, permissions::MAX_SHOWN_NAME),
|
||||
permissions::display_text(id, permissions::MAX_SHOWN_FIELD),
|
||||
);
|
||||
return Err(bad(C, format!("“{name}” lists `{id}` in `commandAllowlist`, which a plug-in may not call")));
|
||||
}
|
||||
let unknown: Vec<String> = unknown.into_iter().map(str::to_string).collect();
|
||||
// A heavy plug-in is installed once a person agrees.
|
||||
if plugin.manifest().budget == Budget::Heavy && !allow_heavy {
|
||||
let (minutes, times) = vectorcraft_plugins::heavy_terms();
|
||||
@@ -726,7 +786,11 @@ fn install(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if changed {
|
||||
s.store_disabled_plugins();
|
||||
}
|
||||
Ok(describe_installed(®istry::install(plugin)))
|
||||
let mut v = describe_installed(®istry::install(plugin));
|
||||
if !unknown.is_empty() {
|
||||
v["unknownCommands"] = json!(unknown);
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// `plugin.inspect`: what installing the module would install, without installing it, whether that
|
||||
@@ -737,6 +801,13 @@ fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut v = describe(&plugin);
|
||||
v["needsApproval"] = json!(!s.plugin_approved(&plugin));
|
||||
v["needsReplace"] = json!(takes_over(s, &plugin));
|
||||
let (unknown, refused) = super::plugin_call::check_allowlist(plugin.manifest());
|
||||
if !unknown.is_empty() {
|
||||
v["unknownCommands"] = json!(unknown);
|
||||
}
|
||||
if !refused.is_empty() {
|
||||
v["refusedCommands"] = json!(refused);
|
||||
}
|
||||
v
|
||||
})
|
||||
}
|
||||
@@ -775,6 +846,15 @@ pub(crate) fn sync_prefs(s: &Session) {
|
||||
*last = Some((folder.to_string(), s.plugin_dir.clone(), load_folder(s, folder)));
|
||||
}
|
||||
|
||||
/// The Additional Plug-ins Folder preference was cleared from `old`: when `old` is the folder last
|
||||
/// loaded, it is forgotten, so setting the preference to it again loads it again.
|
||||
pub(crate) fn forget_prefs_folder(old: &str) {
|
||||
let mut last = FOLDER.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if last.as_ref().is_some_and(|(f, _, _)| f == old.trim()) {
|
||||
*last = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Turns Safe Mode on for `--safe-mode`, before the plug-in folder is attached: no plug-in loads or
|
||||
/// runs in this process from then on.
|
||||
pub fn start_in_safe_mode() {
|
||||
@@ -971,7 +1051,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"List Plug-ins",
|
||||
[],
|
||||
None,
|
||||
"{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\"|\"importer\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.<id>), format? and extensions? (an exporter's or importer's format id, plugin.<id>, for document.export or document.open's importer, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), inputs?: [\"type\"|\"image\"|\"symbolInstance\"] (the objects besides paths a filter reads, see plugin.run), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies; progress: it may report its progress, see Cancel and write log lines; settings: it may keep settings of its own; cache: it may keep a cache of its own), hash (the module's SHA-256), api (the plug-in API level it was built for, 1 or 2), paintOutput?: true (a live effect whose pieces have paints of their own, see docs/plugins.md), cacheBytes (the bytes its cache holds; see plugin.clearCache), commands?: [{id, command (its command id, plugin.<id>.<command id>, which runs it), label, params (doc), paramsSchema}] (the menu commands a filter or live effect adds)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null, safeMode: bool (no plug-in loads or runs, see plugin.safeMode), disabled: [{id, file, name, label}] (turned off for the next launch, see plugin.setEnabled)}",
|
||||
"{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\"|\"importer\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.<id>), format? and extensions? (an exporter's or importer's format id, plugin.<id>, for document.export or document.open's importer, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), inputs?: [\"type\"|\"image\"|\"symbolInstance\"] (the objects besides paths a filter reads, see plugin.run), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies; progress: it may report its progress, see Cancel and write log lines; settings: it may keep settings of its own; cache: it may keep a cache of its own; commands: a filter may run the commands its commandAllowlist names, see docs/plugins.md), commandAllowlist?: [command id] (the commands a filter with the commands permission may run while it runs, with vc_call), hash (the module's SHA-256), api (the plug-in API level it was built for, 1 or 2), paintOutput?: true (a live effect whose pieces have paints of their own, see docs/plugins.md), cacheBytes (the bytes its cache holds; see plugin.clearCache), commands?: [{id, command (its command id, plugin.<id>.<command id>, which runs it), label, params (doc), paramsSchema}] (the menu commands a filter or live effect adds)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null, safeMode: bool (no plug-in loads or runs, see plugin.safeMode), disabled: [{id, file, name, label}] (turned off for the next launch, see plugin.setEnabled)}",
|
||||
always,
|
||||
list
|
||||
),
|
||||
@@ -989,7 +1069,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Run Plug-in",
|
||||
[],
|
||||
None,
|
||||
"{id, params?: {…} (the plug-in's parameters; or give them as top-level keys; missing ones take their defaults, see plugin.info), ids?: [object id] (left out or null: the selection), …the plug-in's parameters as top-level keys} run an object filter on the selected paths and compound paths (inside selected groups and layers too; hidden, locked, guide and clipping paths, compound paths that clip a clip group and paths inside a group that clips one are left out): it may change, delete or add objects, as one undo step; the result is selected, except the objects it locked or made guides. A filter whose manifest lists inputs (type, image, symbolInstance; it lists doc.read in permissions, so plugin.install needs grant) is also given the selected objects of those kinds to read, in paint order from the bottom, with at most 16 MiB of pixels and 16 MiB of type runs per run (an object that doesn't fit what the objects before it left comes with pixelsOmitted or truncated; an image that reaches the decoder counts toward the pixels even when decoding fails); every object counts toward the input's half of the plug-in's memory cap, ids, bounds, transforms and names included, and an object whose fields besides its pixels and runs don't fit fails the run with a memory-budget error naming it and its size (a path or compound path fails it with the memory-budget error for a selection too large to hand over), and more than 100,000 of these objects fail it with an object-budget error: its output returns each as {id} or exactly as given, or the run is refused and nothing changes. With nothing selected it gets no objects (generators add theirs to the current layer) → {plugin, ids}",
|
||||
"{id, params?: {…} (the plug-in's parameters; or give them as top-level keys; missing ones take their defaults, see plugin.info), ids?: [object id] (left out or null: the selection), …the plug-in's parameters as top-level keys} run an object filter on the selected paths and compound paths (inside selected groups and layers too; hidden, locked, guide and clipping paths, compound paths that clip a clip group and paths inside a group that clips one are left out): it may change, delete or add objects, as one undo step; the result is selected, except the objects it locked or made guides. A filter whose manifest lists inputs (type, image, symbolInstance; it lists doc.read in permissions, so plugin.install needs grant) is also given the selected objects of those kinds to read, in paint order from the bottom, with at most 16 MiB of pixels and 16 MiB of type runs per run (an object that doesn't fit what the objects before it left comes with pixelsOmitted or truncated; an image that reaches the decoder counts toward the pixels even when decoding fails); every object counts toward the input's half of the plug-in's memory cap, ids, bounds, transforms and names included, and an object whose fields besides its pixels and runs don't fit fails the run with a memory-budget error naming it and its size (a path or compound path fails it with the memory-budget error for a selection too large to hand over), and more than 100,000 of these objects fail it with an object-budget error: its output returns each as {id} or exactly as given, or the run is refused and nothing changes. With nothing selected it gets no objects (generators add theirs to the current layer). A filter with a commandAllowlist runs the commands it names while it runs (vc_call, see docs/plugins.md): its calls and its output are one undo step, and a run that fails or makes a call that fails changes nothing → {plugin, ids}",
|
||||
has_doc,
|
||||
run
|
||||
),
|
||||
@@ -998,7 +1078,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Install Plug-in",
|
||||
[],
|
||||
None,
|
||||
"{path: a .wasm file (desktop) | dataBase64: the module (+ name?: shown as its source), replace?: bool (true once the user agreed: a module with other bytes than the plug-in installed with its id, or with none installed, one whose id has settings or a cache in the plug-in store, installs only with it, takes that plug-in's place and gets its settings and cache; false: one whose id is installed is refused; left out, the same module installs again), name?: file name (with dataBase64), allowHeavy?: bool (a filter whose manifest asks for a heavy budget, budget \"heavy\" in plugin.inspect, installs only with it), grant?: [permission] (a plug-in whose manifest lists permissions, needsApproval in plugin.inspect, installs only when grant holds every one of them; the grant is recorded with the module's hash and covers only that module, so a module with other bytes needs a new grant even when it lists the same permissions; installing a module that lists no permissions forgets the grant held for its id; a refused install keeps the grant, a failed write of the module to the plug-in store leaves the grant in the store, and a stored module that is disabled or not read in Safe Mode keeps the grant for its id; with a plug-in store attached, only the grants kept there count; a request's allowHeavy and grant are refused unless the session was started with vectorcraft-cli --allow-plugin-grants: in the desktop app a person approves the install, see ui.installPlugin, and a module whose hash is already recorded, by a person's approval or by a grant accepted under that flag, needs no grant), hash?: string (the module's SHA-256 as plugin.inspect gave it: a module with other bytes is refused)} validate and install a WebAssembly plug-in for this session → its plugin.list entry",
|
||||
"{path: a .wasm file (desktop) | dataBase64: the module (+ name?: shown as its source), replace?: bool (true once the user agreed: a module with other bytes than the plug-in installed with its id, or with none installed, one whose id has settings or a cache in the plug-in store, installs only with it, takes that plug-in's place and gets its settings and cache; false: one whose id is installed is refused; left out, the same module installs again), name?: file name (with dataBase64), allowHeavy?: bool (a filter whose manifest asks for a heavy budget, budget \"heavy\" in plugin.inspect, installs only with it), grant?: [permission] (a plug-in whose manifest lists permissions, needsApproval in plugin.inspect, installs only when grant holds every one of them; the grant is recorded with the module's hash and covers only that module, so a module with other bytes needs a new grant even when it lists the same permissions; installing a module that lists no permissions forgets the grant held for its id; a refused install keeps the grant, a failed write of the module to the plug-in store leaves the grant in the store, and a stored module that is disabled or not read in Safe Mode keeps the grant for its id; with a plug-in store attached, only the grants kept there count; a request's allowHeavy and grant are refused unless the session was started with vectorcraft-cli --allow-plugin-grants: in the desktop app a person approves the install, see ui.installPlugin, and a module whose hash is already recorded, by a person's approval or by a grant accepted under that flag, needs no grant), hash?: string (the module's SHA-256 as plugin.inspect gave it: a module with other bytes is refused)} validate and install a WebAssembly plug-in for this session (a module whose commandAllowlist names a command no plug-in may call, such as one that reads or writes files, is refused) → its plugin.list entry, plus unknownCommands?: [id] (commandAllowlist ids no command has; a call of one fails)",
|
||||
always,
|
||||
install
|
||||
),
|
||||
@@ -1007,7 +1087,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Inspect Plug-in",
|
||||
[],
|
||||
None,
|
||||
"{path: a .wasm file (desktop) | dataBase64: the module, name?: the file name shown as its source (with dataBase64)} validate a WebAssembly plug-in without installing it → the plugin.list entry it would have, plus needsApproval: bool (true when it lists permissions no person has granted this very module, its bytes included: it then needs a person's approval, see plugin.install) and needsReplace: bool (true when it would take the place of another module with its id: plugin.install then needs replace: true)",
|
||||
"{path: a .wasm file (desktop) | dataBase64: the module, name?: the file name shown as its source (with dataBase64)} validate a WebAssembly plug-in without installing it → the plugin.list entry it would have, plus needsApproval: bool (true when it lists permissions no person has granted this very module, its bytes included: it then needs a person's approval, see plugin.install) and needsReplace: bool (true when it would take the place of another module with its id: plugin.install then needs replace: true), unknownCommands?: [id] (commandAllowlist ids no command has) and refusedCommands?: [id] (commandAllowlist ids no plug-in may call: plugin.install refuses the module)",
|
||||
always,
|
||||
inspect
|
||||
),
|
||||
|
||||
@@ -0,0 +1,577 @@
|
||||
//! Commands a filter plug-in calls with `vc_call` ([`vectorcraft_plugins::host`]): which commands a
|
||||
//! plug-in may never call ([`denied`]), the call itself ([`call`]), and the run around the calls
|
||||
//! ([`transaction`]).
|
||||
//!
|
||||
//! A filter whose manifest's `commandAllowlist` names commands runs on the session's thread. Each
|
||||
//! call it makes runs that command on the document the run is for, through [`Session::execute`] with
|
||||
//! its params checked as any request's are, and sees the selection the calls before it left. The
|
||||
//! calls and the filter's output are one undo step, named after the plug-in or its command; a run
|
||||
//! that fails, traps, runs out of budget or is canceled, or makes a call that fails, leaves every
|
||||
//! document, its selection and its history as they were.
|
||||
//!
|
||||
//! A plug-in never calls a command that reads or writes a file or folder, uses the clipboard,
|
||||
//! changes preferences, runs a script, an action or a plug-in (a plug-in command, or an effect a
|
||||
//! plug-in adds), opens, closes or switches documents, or undoes or redoes, whatever its manifest
|
||||
//! lists. A command whose params name a file or folder is refused unless [`NOT_FILES`] says that
|
||||
//! key names part of the document in it, and so is a call whose arguments carry such a key at any
|
||||
//! depth ([`names_file`]); a call runs with no file or folder readable or writable
|
||||
//! ([`execute_confined`]). Every other command is refused too unless
|
||||
//! [`reviewed::ALLOWED`] lists it, so a command added to the registry later is refused until
|
||||
//! someone decides.
|
||||
|
||||
mod reviewed;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::Value;
|
||||
use vectorcraft_plugins::host::{CALL_COMMAND_FAILED, CALL_DENIED_COMMAND, CALL_DISABLED, CALL_UNKNOWN_COMMAND, CallFailure};
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
/// Command ids a plug-in may never call, by their start: plug-ins, scripts and actions, files,
|
||||
/// documents and links, the clipboard, preferences, the view and the app, and the undo history.
|
||||
const DENIED_PREFIXES: &[&str] = &[
|
||||
"plugin.",
|
||||
"script.",
|
||||
"action.",
|
||||
"actionSet.",
|
||||
"command.",
|
||||
"prefs.",
|
||||
"help.",
|
||||
"engine.",
|
||||
"file.",
|
||||
"document.",
|
||||
"addon.document.",
|
||||
"library.",
|
||||
"links.",
|
||||
"recovery.",
|
||||
"shortcutSet.",
|
||||
"clipboard.",
|
||||
"view.",
|
||||
"print.",
|
||||
"pdf.",
|
||||
"webExport.",
|
||||
"css.",
|
||||
"assets.",
|
||||
"importMatches.",
|
||||
"edit.undo",
|
||||
"edit.redo",
|
||||
"edit.clearHistory",
|
||||
"edit.copy",
|
||||
"edit.cut",
|
||||
"edit.paste",
|
||||
"edit.newDocument",
|
||||
"edit.colorSettings",
|
||||
"edit.dropSelection",
|
||||
"artboard.copy",
|
||||
"artboard.cut",
|
||||
"text.rescanFonts",
|
||||
"text.fontList",
|
||||
"text.spelling.",
|
||||
"colorTheme.",
|
||||
"magicWand.",
|
||||
"eyedropper.",
|
||||
"layer.pasteRemembersLayers",
|
||||
"type.systemLayout",
|
||||
"type.fractionalWidths",
|
||||
"appearance.setNewArtBasic",
|
||||
"appearance.newArt",
|
||||
"perspective.widget",
|
||||
"transparency.toggleNewMasks",
|
||||
];
|
||||
|
||||
/// Words in a command id that name a file or the clipboard (any case): a command with one of them
|
||||
/// in its id may read or write a file a plug-in can't see.
|
||||
const FILE_WORDS: &[&str] = &[
|
||||
"export",
|
||||
"import",
|
||||
"save",
|
||||
"load",
|
||||
"open",
|
||||
"file",
|
||||
"folder",
|
||||
"link",
|
||||
"librar",
|
||||
"preset",
|
||||
"recover",
|
||||
"package",
|
||||
"print",
|
||||
"profile",
|
||||
"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"];
|
||||
|
||||
/// Commands and the param key in [`FILE_KEYS`] that, in them, names part of the document (a path of
|
||||
/// its art, a setting, an attribute it stores or a result field), not a file.
|
||||
pub(crate) const NOT_FILES: &[(&str, &str)] = &[
|
||||
("object.setLiveShape", "path"),
|
||||
("path.reverse", "paths"),
|
||||
("object.pathfinder.options", "paths"),
|
||||
("object.maskImage", "path"),
|
||||
("text.createInPath", "path"),
|
||||
("imageTrace.make", "paths"),
|
||||
("symbol.instance.setAppearance", "path"),
|
||||
("symbol.instance.selectPart", "path"),
|
||||
("object.objectsOnPath.attach", "path"),
|
||||
("attributes.set", "url"),
|
||||
("svgInteractivity.info", "files"),
|
||||
("addon.cleanup.rejoin", "paths"),
|
||||
];
|
||||
|
||||
/// Is command id `id` one a plug-in may never call, from its id alone ([`DENIED_PREFIXES`],
|
||||
/// [`FILE_WORDS`])?
|
||||
fn denied_id(id: &str) -> bool {
|
||||
let lower = id.to_ascii_lowercase();
|
||||
DENIED_PREFIXES.iter().any(|p| id.starts_with(p)) || FILE_WORDS.iter().any(|w| lower.contains(w))
|
||||
}
|
||||
|
||||
/// Why a plug-in may never call `spec`, or `None` when it may (when its manifest lists it): its id
|
||||
/// or a file key ([`denied_by_rule`]), [`reviewed::DENIED`], or no place in [`reviewed::ALLOWED`].
|
||||
pub(crate) fn denied(spec: &CommandSpec) -> Option<&'static str> {
|
||||
if let Some(why) = denied_by_rule(spec) {
|
||||
return Some(why);
|
||||
}
|
||||
if reviewed::DENIED.binary_search(&spec.id).is_ok() {
|
||||
return Some("reviewed");
|
||||
}
|
||||
if reviewed::ALLOWED.binary_search(&spec.id).is_err() {
|
||||
return Some("not reviewed");
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Why a plug-in may never call `spec` from its id ([`denied_id`]) or its params (a key in
|
||||
/// [`FILE_KEYS`] that [`NOT_FILES`] doesn't name for it), or `None`.
|
||||
fn denied_by_rule(spec: &CommandSpec) -> Option<&'static str> {
|
||||
let id = spec.id;
|
||||
if denied_id(id) {
|
||||
return Some("id");
|
||||
}
|
||||
let mut keys = FILE_KEYS.iter().filter(|k| !NOT_FILES.contains(&(id, **k)));
|
||||
let in_schema = |k: &str| spec.schema.is_some_and(|s| s.params.iter().any(|p| p.name == k));
|
||||
if keys.any(|k| names_key(spec.params, k) || in_schema(k)) {
|
||||
return Some("file key");
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The ids `m`'s `commandAllowlist` names that no command has (a later version may add them; a
|
||||
/// call of one fails), and those a plug-in may never call ([`denied`]).
|
||||
pub(crate) fn check_allowlist(m: &vectorcraft_plugins::Manifest) -> (Vec<&str>, Vec<&str>) {
|
||||
let (mut unknown, mut refused) = (Vec::new(), Vec::new());
|
||||
for id in &m.command_allowlist {
|
||||
match super::find_command(id) {
|
||||
Some(spec) if denied(spec).is_some() => refused.push(id.as_str()),
|
||||
Some(_) => {}
|
||||
None if denied_id(id) => refused.push(id.as_str()),
|
||||
None => unknown.push(id.as_str()),
|
||||
}
|
||||
}
|
||||
(unknown, refused)
|
||||
}
|
||||
|
||||
/// Does params text `text` name the key `key`: `key:` or `key?:`, not as the end of a longer word?
|
||||
pub(crate) fn names_key(text: &str, key: &str) -> bool {
|
||||
text.match_indices(key).any(|(at, _)| {
|
||||
let before = text[..at].chars().next_back();
|
||||
let after = &text[at + key.len()..];
|
||||
before.is_none_or(|c| !(c.is_alphanumeric() || c == '_' || c == '.')) && (after.starts_with(':') || after.starts_with("?:"))
|
||||
})
|
||||
}
|
||||
|
||||
/// 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, isn't counted. The walk keeps its own stack, so deep
|
||||
/// arguments can't overflow the thread's.
|
||||
fn names_file(id: &str, args: &Value) -> bool {
|
||||
let mut stack = vec![(args, true)];
|
||||
while let Some((v, top)) = stack.pop() {
|
||||
match v {
|
||||
Value::Array(a) => stack.extend(a.iter().map(|x| (x, false))),
|
||||
Value::Object(o) => {
|
||||
for (k, x) in o {
|
||||
let k = k.as_str();
|
||||
if FILE_KEYS.contains(&k) && !(top && NOT_FILES.contains(&(id, k))) {
|
||||
return true;
|
||||
}
|
||||
stack.push((x, false));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Param keys whose value names an effect or a command (`id` is `effect.apply`'s other name for
|
||||
/// `effect`).
|
||||
const PLUGIN_KEYS: &[&str] = &["effect", "effects", "id", "command", "commands"];
|
||||
|
||||
/// Does `v` name a plug-in anywhere: an effect or a command ([`PLUGIN_KEYS`]) whose id starts with
|
||||
/// `plugin.`, at any depth? A call that does is refused, so a plug-in never runs another, or
|
||||
/// itself. Other strings, such as text, may start with `plugin.`.
|
||||
fn names_plugin(v: &Value) -> bool {
|
||||
names_plugin_in(v, false)
|
||||
}
|
||||
|
||||
/// [`names_plugin`] for `v`, the value of a key in [`PLUGIN_KEYS`] (or an item of one) when
|
||||
/// `named`.
|
||||
fn names_plugin_in(v: &Value, named: bool) -> bool {
|
||||
match v {
|
||||
Value::String(s) => named && s.starts_with(vectorcraft_plugins::PREFIX),
|
||||
Value::Array(a) => a.iter().any(|x| names_plugin_in(x, named)),
|
||||
Value::Object(o) => o.iter().any(|(k, x)| names_plugin_in(x, PLUGIN_KEYS.contains(&k.as_str()))),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Commits an edit in progress in the active document (not a preview's), as [`transaction`] does →
|
||||
/// whether that changed the document or its selection. A run that calls commands is prepared again
|
||||
/// then, so its input is the document its calls change.
|
||||
pub(crate) fn settle(s: &mut Session) -> Result<bool> {
|
||||
if s.previewing {
|
||||
return Ok(false);
|
||||
}
|
||||
let Some(before) = s.active().map(|d| (d.doc.clone(), d.selection.clone())) else { return Ok(false) };
|
||||
super::edit::settle_interaction(s)?;
|
||||
Ok(s.active().is_none_or(|d| !Arc::ptr_eq(&before.0, &d.doc) || before.1 != d.selection))
|
||||
}
|
||||
|
||||
/// What a run that makes command calls keeps fixed: the document it runs in, the documents open,
|
||||
/// whether an interaction holds its changes (its own, or a preview's), and whether the plug-in may
|
||||
/// read and change the document.
|
||||
pub(crate) struct Calls {
|
||||
document: Option<u64>,
|
||||
open: Vec<u64>,
|
||||
interaction: bool,
|
||||
may_read: bool,
|
||||
may_write: bool,
|
||||
}
|
||||
|
||||
impl Calls {
|
||||
/// The run's fixed state in `s` as it is now; `may_read` and `may_write` are whether it has
|
||||
/// `doc.read` and `doc.write`.
|
||||
pub(crate) fn of(s: &Session, may_read: bool, may_write: bool) -> Calls {
|
||||
let (document, open) = (s.active().map(|d| d.uid), s.docs.iter().map(|d| d.uid).collect());
|
||||
Calls { document, open, interaction: s.in_interaction(), may_read, may_write }
|
||||
}
|
||||
|
||||
/// Is `s` still as the run needs it: the same documents open, the same one active and the
|
||||
/// interaction holding the run's changes still open?
|
||||
fn kept(&self, s: &Session) -> bool {
|
||||
s.active().map(|d| d.uid) == self.document
|
||||
&& s.docs.iter().map(|d| d.uid).eq(self.open.iter().copied())
|
||||
&& (!self.interaction || s.in_interaction())
|
||||
}
|
||||
}
|
||||
|
||||
/// Makes plug-in call `id` with `args` in `s` → its result, or why it didn't run or failed. A
|
||||
/// command a plug-in may never call ([`denied`]), one whose args name a plug-in, one that changes
|
||||
/// which documents are open or active or ends the run's edit, and one that changes the document for
|
||||
/// a plug-in without `doc.write` are refused, and so is every call of a plug-in without `doc.read`:
|
||||
/// a command's result describes the document.
|
||||
pub(crate) fn call(s: &mut Session, run: &Calls, id: &str, args: &Value) -> std::result::Result<Value, CallFailure> {
|
||||
let refused = || CallFailure { code: CALL_DENIED_COMMAND, why: EngineError::Other("a plug-in may not call this command".into()).to_string() };
|
||||
if !run.may_read {
|
||||
return Err(refused());
|
||||
}
|
||||
let Some(spec) = super::find_command(id) else {
|
||||
return Err(CallFailure { code: CALL_UNKNOWN_COMMAND, why: super::params::unknown_command(id).to_string() });
|
||||
};
|
||||
if denied(spec).is_some() || names_plugin(args) || names_file(id, args) {
|
||||
return Err(refused());
|
||||
}
|
||||
let before = s.active().map(|d| d.doc.clone());
|
||||
let r = execute_confined(s, id, args);
|
||||
let value = match r {
|
||||
Ok(v) => v,
|
||||
Err(e @ EngineError::Disabled(..)) => return Err(CallFailure { code: CALL_DISABLED, why: e.to_string() }),
|
||||
Err(e) => return Err(CallFailure { code: CALL_COMMAND_FAILED, why: e.to_string() }),
|
||||
};
|
||||
let changed = before.is_some_and(|b| s.active().is_some_and(|d| !Arc::ptr_eq(&b, &d.doc)));
|
||||
if !run.kept(s) || (changed && !run.may_write) {
|
||||
return Err(refused());
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
/// Runs command `id` with `args` in `s` with no file or folder readable or writable: every path the
|
||||
/// command reaches through the checked file access ([`crate::file_access`]) is refused, whatever
|
||||
/// param carries it.
|
||||
fn execute_confined(s: &mut Session, id: &str, args: &Value) -> Result<Value> {
|
||||
crate::file_access::confine(Some(&crate::file_access::AutomationRoots::none()), || s.execute(id, args))
|
||||
}
|
||||
|
||||
/// Runs `f`, a plug-in run that makes command calls, as one undo step named `label` in the active
|
||||
/// document: the commands it calls and the output it applies change the document inside one
|
||||
/// interaction, which is kept when `f` succeeds. When `f` fails, every document goes back to how it
|
||||
/// was (its art, selection, interaction and history), and so does the session state the commands a
|
||||
/// plug-in may call change ([`Saved`]). During a preview the preview's own interaction holds the
|
||||
/// changes. A trace handed in for the next Image Trace is set aside while `f` runs, so a call
|
||||
/// traces anew, and handed back after; the next Image Trace still checks it was made of its inputs.
|
||||
pub(crate) fn transaction(s: &mut Session, label: &str, f: impl FnOnce(&mut Session) -> Result<Value>) -> Result<Value> {
|
||||
if s.active().is_some() && !s.previewing {
|
||||
super::edit::settle_interaction(s)?;
|
||||
}
|
||||
let own = s.active().is_some() && !s.previewing && !s.in_interaction();
|
||||
if own {
|
||||
s.begin_interaction(label)?;
|
||||
}
|
||||
let saved = Saved::of(s);
|
||||
let start = s.docs.clone();
|
||||
let active = s.active;
|
||||
let trace = s.trace_hand_off.take();
|
||||
let r = f(s);
|
||||
s.trace_hand_off = trace;
|
||||
match r {
|
||||
Ok(v) => {
|
||||
if own
|
||||
&& let Some(st) = s.active_mut()
|
||||
&& let Some(it) = st.interaction.take()
|
||||
{
|
||||
st.keep_interaction(it);
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
Err(e) => {
|
||||
let open = std::mem::replace(&mut s.docs, start);
|
||||
for st in &mut s.docs {
|
||||
// Past the newest revision the document reached, so the restored one redraws.
|
||||
let newest = open.iter().filter(|d| d.uid == st.uid).map(|d| d.revision).max().unwrap_or(st.revision);
|
||||
st.revision = newest.max(st.revision).saturating_add(1);
|
||||
crate::deselect_hidden(&s.menu, &st.doc, &mut st.selection);
|
||||
}
|
||||
s.active = active;
|
||||
if own && let Some(st) = s.active_mut() {
|
||||
st.interaction = None;
|
||||
}
|
||||
saved.restore(s);
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The session state outside the documents that a command a plug-in may call changes: the paint
|
||||
/// defaults and the colors it remembers, the drawing mode, the clipboard, the undo group, the
|
||||
/// preferences and menu checks, the selected gradient stop, mesh point and Appearance item, and the
|
||||
/// options a command keeps for its next run. [`transaction`] puts it back when a run fails.
|
||||
struct Saved {
|
||||
paint: crate::PaintDefaults,
|
||||
fill_active: bool,
|
||||
draw_mode: crate::DrawMode,
|
||||
draw_inside: Option<vectorcraft_doc::NodeId>,
|
||||
clipboard: crate::Clipboard,
|
||||
untitled_counter: u32,
|
||||
explicit_undo_group: Option<Option<String>>,
|
||||
prefs: crate::Prefs,
|
||||
menu: super::menucmds::MenuState,
|
||||
gradient_stop: Option<(usize, super::gradient::StopOwner)>,
|
||||
freeform_point: Option<(usize, super::gradient::StopOwner)>,
|
||||
active_appearance_item: Option<super::appearance::ActiveItem>,
|
||||
eyedropper_sample: Option<Arc<vectorcraft_doc::Node>>,
|
||||
last_solid: vectorcraft_color::Color,
|
||||
last_gradient: vectorcraft_color::GradientPaint,
|
||||
recent_colors: Vec<vectorcraft_color::Color>,
|
||||
recent_urls: Vec<String>,
|
||||
global_edit_options: super::globaledit::Options,
|
||||
envelope_defaults: Option<(vectorcraft_doc::live::EnvelopeOptions, f64)>,
|
||||
}
|
||||
|
||||
impl Saved {
|
||||
fn of(s: &Session) -> Saved {
|
||||
Saved {
|
||||
paint: s.paint.clone(),
|
||||
fill_active: s.fill_active,
|
||||
draw_mode: s.draw_mode,
|
||||
draw_inside: s.draw_inside,
|
||||
clipboard: s.clipboard.clone(),
|
||||
untitled_counter: s.untitled_counter,
|
||||
explicit_undo_group: s.explicit_undo_group.clone(),
|
||||
prefs: s.prefs.clone(),
|
||||
menu: s.menu.clone(),
|
||||
gradient_stop: s.gradient_stop,
|
||||
freeform_point: s.freeform_point,
|
||||
active_appearance_item: s.active_appearance_item.clone(),
|
||||
eyedropper_sample: s.eyedropper_sample.clone(),
|
||||
last_solid: s.last_solid,
|
||||
last_gradient: s.last_gradient.clone(),
|
||||
recent_colors: s.recent_colors.clone(),
|
||||
recent_urls: s.recent_urls.clone(),
|
||||
global_edit_options: s.global_edit_options.clone(),
|
||||
envelope_defaults: s.envelope_defaults,
|
||||
}
|
||||
}
|
||||
|
||||
fn restore(self, s: &mut Session) {
|
||||
s.paint = self.paint;
|
||||
s.fill_active = self.fill_active;
|
||||
s.draw_mode = self.draw_mode;
|
||||
s.draw_inside = self.draw_inside;
|
||||
s.clipboard = self.clipboard;
|
||||
s.untitled_counter = self.untitled_counter;
|
||||
s.explicit_undo_group = self.explicit_undo_group;
|
||||
s.prefs = self.prefs;
|
||||
s.menu = self.menu;
|
||||
s.gradient_stop = self.gradient_stop;
|
||||
s.freeform_point = self.freeform_point;
|
||||
s.active_appearance_item = self.active_appearance_item;
|
||||
s.eyedropper_sample = self.eyedropper_sample;
|
||||
s.last_solid = self.last_solid;
|
||||
s.last_gradient = self.last_gradient;
|
||||
s.recent_colors = self.recent_colors;
|
||||
s.recent_urls = self.recent_urls;
|
||||
s.global_edit_options = self.global_edit_options;
|
||||
s.envelope_defaults = self.envelope_defaults;
|
||||
s.pending_paint = None;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Every command in the registry is sorted: the ones a plug-in may call name no file, folder,
|
||||
/// clipboard, preference or script in their params text, and the known file, document, script
|
||||
/// and history commands are refused.
|
||||
#[test]
|
||||
fn every_command_is_sorted() {
|
||||
let specs = crate::cmd::command_specs();
|
||||
for id in [
|
||||
"document.save",
|
||||
"file.revert",
|
||||
"document.serialize",
|
||||
"links.update",
|
||||
"graphicStyle.libraries",
|
||||
"symbol.libraries",
|
||||
"brush.libraries",
|
||||
"text.findFontFiles",
|
||||
"plugin.run",
|
||||
"plugin.install",
|
||||
"command.batch",
|
||||
"edit.undo",
|
||||
"edit.redo",
|
||||
"edit.copy",
|
||||
"edit.paste",
|
||||
"prefs.set",
|
||||
] {
|
||||
let Some(spec) = crate::cmd::find_command(id) else { panic!("no command {id}") };
|
||||
assert!(denied(spec).is_some(), "{id} is allowed");
|
||||
}
|
||||
for id in ["select.all", "object.transform", "path.reverse", "symbol.instance.selectPart"] {
|
||||
let Some(spec) = crate::cmd::find_command(id) else { panic!("no command {id}") };
|
||||
assert_eq!(denied(spec), None, "{id} is refused");
|
||||
}
|
||||
for (id, key) in NOT_FILES {
|
||||
let Some(spec) = crate::cmd::find_command(id) else { panic!("no command {id}") };
|
||||
assert!(names_key(spec.params, key), "{id} has no `{key}` param");
|
||||
}
|
||||
let leaks: Vec<&str> = specs
|
||||
.iter()
|
||||
.filter(|c| denied(c).is_none())
|
||||
.filter(|c| {
|
||||
let lower = c.id.to_ascii_lowercase();
|
||||
FILE_WORDS.iter().any(|w| lower.contains(w))
|
||||
|| FILE_KEYS.iter().any(|k| names_key(c.params, k) && !NOT_FILES.contains(&(c.id, *k)))
|
||||
|| names_key(c.params, "sourceDocument")
|
||||
})
|
||||
.map(|c| c.id)
|
||||
.collect();
|
||||
assert!(leaks.is_empty(), "allowed commands that name a file or another document: {leaks:?}");
|
||||
let allowed = specs.iter().filter(|c| denied(c).is_none()).count();
|
||||
assert!(allowed > 0 && allowed < specs.len());
|
||||
}
|
||||
|
||||
/// Every command no rule refuses is in exactly one of [`reviewed::ALLOWED`] and
|
||||
/// [`reviewed::DENIED`], and they name only such commands, in order: a command added to the
|
||||
/// registry fails here until someone puts it in one.
|
||||
#[test]
|
||||
fn every_command_is_reviewed() {
|
||||
for list in [reviewed::ALLOWED, reviewed::DENIED] {
|
||||
assert!(list.windows(2).all(|w| w[0] < w[1]), "a reviewed list is out of order or names a command twice");
|
||||
}
|
||||
let (allowed, refused) = (|id: &str| reviewed::ALLOWED.binary_search(&id).is_ok(), |id: &str| reviewed::DENIED.binary_search(&id).is_ok());
|
||||
let unreviewed: Vec<&str> =
|
||||
crate::cmd::command_specs().iter().filter(|c| denied_by_rule(c).is_none() && allowed(c.id) == refused(c.id)).map(|c| c.id).collect();
|
||||
assert!(unreviewed.is_empty(), "commands in neither or both of reviewed::ALLOWED and reviewed::DENIED: {unreviewed:?}");
|
||||
let stale: Vec<&str> = reviewed::ALLOWED
|
||||
.iter()
|
||||
.chain(reviewed::DENIED)
|
||||
.copied()
|
||||
.filter(|id| crate::cmd::find_command(id).is_none_or(|c| denied_by_rule(c).is_some()))
|
||||
.collect();
|
||||
assert!(stale.is_empty(), "reviewed commands that no command has or a rule refuses: {stale:?}");
|
||||
}
|
||||
|
||||
/// Only an effect or a command naming a plug-in counts, at any depth; text that happens to
|
||||
/// start with `plugin.` doesn't.
|
||||
#[test]
|
||||
fn a_plugin_is_named_only_as_an_effect_or_a_command() {
|
||||
use serde_json::json;
|
||||
assert!(names_plugin(&json!({"effect": "plugin.test.fx"})));
|
||||
assert!(names_plugin(&json!({"id": "plugin.test.fx"})));
|
||||
assert!(names_plugin(&json!({"effects": ["builtin.blur", "plugin.test.fx"]})));
|
||||
assert!(names_plugin(&json!({"items": [{"effect": "plugin.test.fx", "params": {}}]})));
|
||||
assert!(names_plugin(&json!({"command": "plugin.test.run"})));
|
||||
assert!(!names_plugin(&json!({"text": "plugin.example"})));
|
||||
assert!(!names_plugin(&json!({"name": "plugin.style", "effect": "drop-shadow"})));
|
||||
assert!(!names_plugin(&json!({"effect": {"name": "plugin.style"}})));
|
||||
}
|
||||
|
||||
/// A file or folder key counts at any depth, in objects and in arrays; a key [`NOT_FILES`] names
|
||||
/// for the command only at the top level; ordinary nested params don't count.
|
||||
#[test]
|
||||
fn a_file_key_counts_at_any_depth() {
|
||||
use serde_json::json;
|
||||
let texture = json!({"effect": "distort.glass", "params": {"textureImage": {"path": "/tmp/t.png"}}});
|
||||
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("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 a shape's path in object.setLiveShape, but only as its own param.
|
||||
assert!(!names_file("object.setLiveShape", &json!({"path": [[0, 0], [1, 1]]})));
|
||||
assert!(names_file("object.setLiveShape", &json!({"options": {"path": "/tmp/x"}})));
|
||||
assert!(names_file("object.move", &json!({"path": [[0, 0]]})));
|
||||
// Deep nesting is walked without recursion.
|
||||
let mut deep = json!({"file": "/tmp/x"});
|
||||
for _ in 0..10_000 {
|
||||
deep = Value::Array(vec![deep]);
|
||||
}
|
||||
assert!(names_file("select.all", &deep));
|
||||
std::mem::forget(deep);
|
||||
}
|
||||
|
||||
/// A call reads no file even when a param the checks above don't know carries its path: the
|
||||
/// command runs with no file readable. Run as an ordinary request, the same texture loads.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn a_confined_call_reads_no_file() {
|
||||
use serde_json::json;
|
||||
let file = std::env::temp_dir().join(format!("vc-plugin-call-texture-{}.png", std::process::id()));
|
||||
let png = vectorcraft_trace::Raster::from_fn(8, 8, |x, y| if (x + y) % 2 == 0 { [0, 0, 0, 255] } else { [255, 255, 255, 255] }).encode_png();
|
||||
std::fs::write(&file, png).unwrap();
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap();
|
||||
let p = json!({"effect": "distort.glass", "params": {"textureImage": {"path": file.to_string_lossy()}}});
|
||||
let e = execute_confined(&mut s, "effect.apply", &p).map(|_| ()).unwrap_err().to_string();
|
||||
let read = s.execute("effect.apply", &p).map(|_| ());
|
||||
let _ = std::fs::remove_file(&file);
|
||||
assert!(e.contains("read authority is absent"), "{e}");
|
||||
read.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_are_found_as_whole_keys() {
|
||||
assert!(names_key("{path: a .wasm file}", "path"));
|
||||
assert!(names_key("{ids?: [id], path?: string}", "path"));
|
||||
assert!(!names_key("{subpath: 1}", "path"));
|
||||
assert!(!names_key("{x} on the path: the art", "the"));
|
||||
assert!(!names_key("{pathData: 1}", "path"));
|
||||
assert!(names_key("path: x", "path"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,679 @@
|
||||
//! The commands no rule in [`super::denied`] refuses, each placed in one list: those a plug-in may
|
||||
//! call ([`ALLOWED`]) and those it may not ([`DENIED`]). A command in neither is refused, and the
|
||||
//! test `every_command_is_reviewed` fails until it is placed.
|
||||
|
||||
/// Commands a plug-in may call when its manifest lists them, in order.
|
||||
pub(super) const ALLOWED: &[&str] = &[
|
||||
"addon.arrange.grid",
|
||||
"addon.arrange.groupOverlapping",
|
||||
"addon.arrange.randomizeOpacity",
|
||||
"addon.arrange.replace",
|
||||
"addon.arrange.size",
|
||||
"addon.arrange.sort",
|
||||
"addon.arrange.swap",
|
||||
"addon.cleanup.alignInPlace",
|
||||
"addon.cleanup.breakText",
|
||||
"addon.cleanup.check",
|
||||
"addon.cleanup.combineText",
|
||||
"addon.cleanup.deepUngroup",
|
||||
"addon.cleanup.fix",
|
||||
"addon.cleanup.joinText",
|
||||
"addon.cleanup.rejoin",
|
||||
"addon.cleanup.removeBreaks",
|
||||
"addon.cleanup.replaceFonts",
|
||||
"addon.cleanup.resetTextTransform",
|
||||
"addon.cleanup.show",
|
||||
"addon.cleanup.simplify",
|
||||
"addon.color.applyNearestSwatch",
|
||||
"addon.color.fillToStroke",
|
||||
"addon.color.filter",
|
||||
"addon.color.nearestSwatch",
|
||||
"addon.color.select",
|
||||
"addon.color.strokeToFill",
|
||||
"addon.color.swatchLegend",
|
||||
"addon.contour.set",
|
||||
"addon.pathtools.addPoints",
|
||||
"addon.pathtools.close",
|
||||
"addon.pathtools.corner",
|
||||
"addon.pathtools.cornerRemove",
|
||||
"addon.pathtools.cornerSet",
|
||||
"addon.pathtools.crossings",
|
||||
"addon.pathtools.divideDashes",
|
||||
"addon.pathtools.equalize",
|
||||
"addon.pathtools.erase",
|
||||
"addon.pathtools.expandDashes",
|
||||
"addon.pathtools.extend",
|
||||
"addon.pathtools.inspect",
|
||||
"addon.pathtools.mergeEdge",
|
||||
"addon.pathtools.removeKeepShape",
|
||||
"addon.pathtools.setAnchor",
|
||||
"addon.pathtools.shape.convert",
|
||||
"addon.pathtools.shape.create",
|
||||
"addon.pathtools.shape.set",
|
||||
"addon.pathtools.slideAnchor",
|
||||
"addon.pathtools.smooth",
|
||||
"addon.pen.append",
|
||||
"addon.pen.close",
|
||||
"addon.pen.closeJoin",
|
||||
"addon.pen.removePoint",
|
||||
"addon.pen.reshape",
|
||||
"addon.pen.segment",
|
||||
"addon.pen.togglePoint",
|
||||
"addon.select.byArtboards",
|
||||
"addon.shapes.box3d",
|
||||
"addon.shapes.configure",
|
||||
"addon.shapes.ellipse",
|
||||
"addon.shapes.polygon",
|
||||
"addon.shapes.rectangle",
|
||||
"addon.shapes.spiral",
|
||||
"addon.shapes.star",
|
||||
"addon.type.releasePath",
|
||||
"addon.type.textTweaker",
|
||||
"appearance.addEffect",
|
||||
"appearance.addFill",
|
||||
"appearance.addStroke",
|
||||
"appearance.clear",
|
||||
"appearance.copyFrom",
|
||||
"appearance.duplicateItem",
|
||||
"appearance.moveItem",
|
||||
"appearance.reduceToBasic",
|
||||
"appearance.removeItem",
|
||||
"appearance.setActiveItem",
|
||||
"appearance.setItem",
|
||||
"appearance.showAllHidden",
|
||||
"appearance.targetContents",
|
||||
"appearance.transfer",
|
||||
"artboard.convertToArtboards",
|
||||
"artboard.delete",
|
||||
"artboard.deleteEmpty",
|
||||
"artboard.duplicate",
|
||||
"artboard.fitToArt",
|
||||
"artboard.fitToSelection",
|
||||
"artboard.move",
|
||||
"artboard.new",
|
||||
"artboard.rearrange",
|
||||
"artboard.rename",
|
||||
"artboard.reorder",
|
||||
"artboard.setProps",
|
||||
"artboard.switchOrientation",
|
||||
"attributes.info",
|
||||
"attributes.set",
|
||||
"brush.apply",
|
||||
"brush.delete",
|
||||
"brush.duplicate",
|
||||
"brush.freehand",
|
||||
"brush.get",
|
||||
"brush.list",
|
||||
"brush.move",
|
||||
"brush.new",
|
||||
"brush.options",
|
||||
"brush.remove",
|
||||
"brush.selectionArt",
|
||||
"brush.setCurrent",
|
||||
"brush.unused",
|
||||
"charStyle.apply",
|
||||
"charStyle.delete",
|
||||
"charStyle.duplicate",
|
||||
"charStyle.edit",
|
||||
"charStyle.list",
|
||||
"charStyle.new",
|
||||
"charStyle.redefine",
|
||||
"charStyle.rename",
|
||||
"charStyle.setAttrs",
|
||||
"charStyle.unused",
|
||||
"color.convert",
|
||||
"color.gamutCheck",
|
||||
"color.harmony",
|
||||
"color.plates",
|
||||
"dataset.capture",
|
||||
"dataset.delete",
|
||||
"dataset.list",
|
||||
"dataset.new",
|
||||
"dataset.next",
|
||||
"dataset.prev",
|
||||
"dataset.rename",
|
||||
"dataset.select",
|
||||
"dataset.set",
|
||||
"dataset.update",
|
||||
"dimension.applyToAll",
|
||||
"dimension.create",
|
||||
"dimension.update",
|
||||
"edit.clear",
|
||||
"edit.colors.adjustBalance",
|
||||
"edit.colors.blendFrontToBack",
|
||||
"edit.colors.blendHorizontally",
|
||||
"edit.colors.blendVertically",
|
||||
"edit.colors.invert",
|
||||
"edit.colors.saturate",
|
||||
"edit.colors.toCMYK",
|
||||
"edit.colors.toGrayscale",
|
||||
"edit.colors.toRGB",
|
||||
"edit.duplicate",
|
||||
"edit.findNext",
|
||||
"edit.findReplace",
|
||||
"effect.apply",
|
||||
"effect.deferredStatus",
|
||||
"effect.duplicate",
|
||||
"effect.expandAppearance",
|
||||
"effect.list",
|
||||
"effect.move",
|
||||
"effect.remove",
|
||||
"effect.setParams",
|
||||
"flattener.preview",
|
||||
"gradient.selectStop",
|
||||
"graph.column",
|
||||
"graph.create",
|
||||
"graph.design",
|
||||
"graph.marker",
|
||||
"graph.setData",
|
||||
"graph.setType",
|
||||
"graphicStyle.apply",
|
||||
"graphicStyle.delete",
|
||||
"graphicStyle.duplicate",
|
||||
"graphicStyle.list",
|
||||
"graphicStyle.merge",
|
||||
"graphicStyle.move",
|
||||
"graphicStyle.new",
|
||||
"graphicStyle.redefine",
|
||||
"graphicStyle.rename",
|
||||
"graphicStyle.sortByName",
|
||||
"graphicStyle.unused",
|
||||
"guide.add",
|
||||
"guide.list",
|
||||
"guide.move",
|
||||
"guide.remove",
|
||||
"guide.select",
|
||||
"guide.setLayer",
|
||||
"image.info",
|
||||
"imageTrace.expand",
|
||||
"imageTrace.make",
|
||||
"imageTrace.makeAndExpand",
|
||||
"imageTrace.release",
|
||||
"imageTrace.setView",
|
||||
"layer.clippingMask.toggle",
|
||||
"layer.collectInNew",
|
||||
"layer.delete",
|
||||
"layer.duplicate",
|
||||
"layer.flatten",
|
||||
"layer.hideOthers",
|
||||
"layer.highlight",
|
||||
"layer.locate",
|
||||
"layer.lockOthers",
|
||||
"layer.merge",
|
||||
"layer.move",
|
||||
"layer.new",
|
||||
"layer.newSublayer",
|
||||
"layer.outlineOthers",
|
||||
"layer.previewAll",
|
||||
"layer.releaseToLayers",
|
||||
"layer.releaseToLayersBuild",
|
||||
"layer.reverse",
|
||||
"layer.selectAll",
|
||||
"layer.setCurrent",
|
||||
"layer.setProps",
|
||||
"layer.showAll",
|
||||
"layer.target",
|
||||
"layer.template",
|
||||
"layer.unlockAll",
|
||||
"livePaint.expand",
|
||||
"livePaint.fill",
|
||||
"livePaint.gapOptions",
|
||||
"livePaint.info",
|
||||
"livePaint.make",
|
||||
"livePaint.merge",
|
||||
"livePaint.release",
|
||||
"livePaint.strokeEdge",
|
||||
"node.move",
|
||||
"object.align",
|
||||
"object.arrange.bringForward",
|
||||
"object.arrange.bringToFront",
|
||||
"object.arrange.sendBackward",
|
||||
"object.arrange.sendToBack",
|
||||
"object.arrange.sendToCurrentLayer",
|
||||
"object.blend.expand",
|
||||
"object.blend.info",
|
||||
"object.blend.make",
|
||||
"object.blend.release",
|
||||
"object.blend.replaceSpine",
|
||||
"object.blend.reverseFrontToBack",
|
||||
"object.blend.reverseSpine",
|
||||
"object.blend.spine.addAnchor",
|
||||
"object.blend.spine.convertAnchor",
|
||||
"object.blend.spine.moveAnchor",
|
||||
"object.blend.spine.removeAnchor",
|
||||
"object.blend.spine.reshapeSegment",
|
||||
"object.clippingMask.editContents",
|
||||
"object.clippingMask.editMask",
|
||||
"object.clippingMask.make",
|
||||
"object.clippingMask.release",
|
||||
"object.compoundPath.make",
|
||||
"object.compoundPath.release",
|
||||
"object.compoundShape.expand",
|
||||
"object.compoundShape.make",
|
||||
"object.compoundShape.release",
|
||||
"object.compoundShape.setMode",
|
||||
"object.convertDocumentColorMode",
|
||||
"object.createObjectMosaic",
|
||||
"object.createTrimMarks",
|
||||
"object.cropImage",
|
||||
"object.distort",
|
||||
"object.distribute",
|
||||
"object.distributeSpacing",
|
||||
"object.envelope.editContents",
|
||||
"object.envelope.expand",
|
||||
"object.envelope.info",
|
||||
"object.envelope.makeWithMesh",
|
||||
"object.envelope.makeWithTopObject",
|
||||
"object.envelope.makeWithWarp",
|
||||
"object.envelope.options",
|
||||
"object.envelope.release",
|
||||
"object.envelope.resetWithMesh",
|
||||
"object.envelope.resetWithWarp",
|
||||
"object.envelope.setMeshPoint",
|
||||
"object.exitIsolation",
|
||||
"object.expand",
|
||||
"object.expand.info",
|
||||
"object.expandBrush",
|
||||
"object.expandShape",
|
||||
"object.flattenTransparency",
|
||||
"object.group",
|
||||
"object.hide",
|
||||
"object.hide.above",
|
||||
"object.hide.otherLayers",
|
||||
"object.hide.unselected",
|
||||
"object.intertwine.arrange",
|
||||
"object.intertwine.edit",
|
||||
"object.intertwine.make",
|
||||
"object.intertwine.release",
|
||||
"object.isolate",
|
||||
"object.lock",
|
||||
"object.lock.above",
|
||||
"object.lock.otherLayers",
|
||||
"object.lock.unselected",
|
||||
"object.makePixelPerfect",
|
||||
"object.maskImage",
|
||||
"object.mesh.addLine",
|
||||
"object.mesh.create",
|
||||
"object.mesh.deletePoint",
|
||||
"object.mesh.expand",
|
||||
"object.mesh.movePoint",
|
||||
"object.mesh.movePoints",
|
||||
"object.mesh.setPointColor",
|
||||
"object.move",
|
||||
"object.nudge",
|
||||
"object.objectsOnPath.attach",
|
||||
"object.objectsOnPath.expand",
|
||||
"object.objectsOnPath.options",
|
||||
"object.path.addAnchorPoints",
|
||||
"object.path.cleanUp",
|
||||
"object.path.divideObjectsBelow",
|
||||
"object.path.offsetPath",
|
||||
"object.path.outlineStroke",
|
||||
"object.path.simplify",
|
||||
"object.path.smooth",
|
||||
"object.path.splitIntoGrid",
|
||||
"object.pathfinder.crop",
|
||||
"object.pathfinder.divide",
|
||||
"object.pathfinder.exclude",
|
||||
"object.pathfinder.intersect",
|
||||
"object.pathfinder.merge",
|
||||
"object.pathfinder.minusBack",
|
||||
"object.pathfinder.minusFront",
|
||||
"object.pathfinder.outline",
|
||||
"object.pathfinder.trap",
|
||||
"object.pathfinder.trim",
|
||||
"object.pathfinder.unite",
|
||||
"object.pattern.cancel",
|
||||
"object.pattern.done",
|
||||
"object.pattern.edit",
|
||||
"object.pattern.make",
|
||||
"object.puppetWarp",
|
||||
"object.puppetWarp.pins",
|
||||
"object.rasterize",
|
||||
"object.reflect",
|
||||
"object.repeat.expand",
|
||||
"object.repeat.grid",
|
||||
"object.repeat.mirror",
|
||||
"object.repeat.options",
|
||||
"object.repeat.radial",
|
||||
"object.repeat.release",
|
||||
"object.resetBoundingBox",
|
||||
"object.rotate",
|
||||
"object.scale",
|
||||
"object.setBounds",
|
||||
"object.setLiveShape",
|
||||
"object.setPixelAligned",
|
||||
"object.setProps",
|
||||
"object.shape.convertToShape",
|
||||
"object.shear",
|
||||
"object.showAll",
|
||||
"object.slice.clipToArtboard",
|
||||
"object.slice.combine",
|
||||
"object.slice.create",
|
||||
"object.slice.delete",
|
||||
"object.slice.deleteAll",
|
||||
"object.slice.divide",
|
||||
"object.slice.duplicate",
|
||||
"object.slice.fromGuides",
|
||||
"object.slice.fromSelection",
|
||||
"object.slice.make",
|
||||
"object.slice.move",
|
||||
"object.slice.options",
|
||||
"object.slice.release",
|
||||
"object.slice.select",
|
||||
"object.slice.setRect",
|
||||
"object.textWrap.make",
|
||||
"object.textWrap.options",
|
||||
"object.textWrap.release",
|
||||
"object.transform",
|
||||
"object.transformAgain",
|
||||
"object.transformEach",
|
||||
"object.ungroup",
|
||||
"object.unlockAll",
|
||||
"object.vectorHalftone",
|
||||
"paint.complement",
|
||||
"paint.default",
|
||||
"paint.editGradient",
|
||||
"paint.freeform.addLine",
|
||||
"paint.freeform.addPoint",
|
||||
"paint.freeform.deletePoint",
|
||||
"paint.freeform.get",
|
||||
"paint.freeform.selectPoint",
|
||||
"paint.freeform.setPoint",
|
||||
"paint.freeform.splitLine",
|
||||
"paint.invert",
|
||||
"paint.lastColor",
|
||||
"paint.lastGradient",
|
||||
"paint.none",
|
||||
"paint.proxies",
|
||||
"paint.recent",
|
||||
"paint.sampleColor",
|
||||
"paint.setFill",
|
||||
"paint.setGradientGeom",
|
||||
"paint.setStroke",
|
||||
"paint.swap",
|
||||
"paint.toggleActive",
|
||||
"paraStyle.apply",
|
||||
"paraStyle.delete",
|
||||
"paraStyle.duplicate",
|
||||
"paraStyle.list",
|
||||
"paraStyle.new",
|
||||
"paraStyle.redefine",
|
||||
"paraStyle.rename",
|
||||
"paraStyle.setAttrs",
|
||||
"paraStyle.unused",
|
||||
"path.appendAnchor",
|
||||
"path.average",
|
||||
"path.averageJoin",
|
||||
"path.blob",
|
||||
"path.close",
|
||||
"path.convertAnchor",
|
||||
"path.convertAnchors",
|
||||
"path.create",
|
||||
"path.curvature",
|
||||
"path.curvatureEdit",
|
||||
"path.cutAtAnchors",
|
||||
"path.deleteAnchors",
|
||||
"path.eraseRegion",
|
||||
"path.eraseSegments",
|
||||
"path.freehand",
|
||||
"path.insertAnchor",
|
||||
"path.join",
|
||||
"path.joinScrub",
|
||||
"path.knife",
|
||||
"path.lineCut",
|
||||
"path.mirrorCut",
|
||||
"path.moveAnchor",
|
||||
"path.moveAnchors",
|
||||
"path.rectCut",
|
||||
"path.removeAnchor",
|
||||
"path.removeAnchors",
|
||||
"path.reshape",
|
||||
"path.reshapeSegment",
|
||||
"path.reverse",
|
||||
"path.setAnchors",
|
||||
"path.setFillRule",
|
||||
"path.setHandle",
|
||||
"path.smoothRegion",
|
||||
"path.split",
|
||||
"pattern.delete",
|
||||
"pattern.list",
|
||||
"pattern.options",
|
||||
"pattern.transform",
|
||||
"perspective.attach",
|
||||
"perspective.draw",
|
||||
"perspective.editText",
|
||||
"perspective.grid.define",
|
||||
"perspective.grid.get",
|
||||
"perspective.grid.lock",
|
||||
"perspective.grid.lockStation",
|
||||
"perspective.grid.rulers",
|
||||
"perspective.grid.set",
|
||||
"perspective.grid.show",
|
||||
"perspective.grid.snap",
|
||||
"perspective.move",
|
||||
"perspective.nudge",
|
||||
"perspective.plane.matchObject",
|
||||
"perspective.plane.move",
|
||||
"perspective.plane.set",
|
||||
"perspective.release",
|
||||
"perspective.transform",
|
||||
"recolor.apply",
|
||||
"recolor.colors",
|
||||
"recolor.randomize",
|
||||
"recolor.reduce",
|
||||
"select.add",
|
||||
"select.all",
|
||||
"select.allOnArtboard",
|
||||
"select.anchors",
|
||||
"select.anchorsMany",
|
||||
"select.color",
|
||||
"select.font",
|
||||
"select.globalEdit",
|
||||
"select.globalEditExclude",
|
||||
"select.globalEditOptions",
|
||||
"select.inverse",
|
||||
"select.key",
|
||||
"select.magicWand",
|
||||
"select.nextAbove",
|
||||
"select.nextBelow",
|
||||
"select.none",
|
||||
"select.object.allOnSameLayers",
|
||||
"select.object.areaText",
|
||||
"select.object.bristleBrushStrokes",
|
||||
"select.object.brushStrokes",
|
||||
"select.object.clippingMasks",
|
||||
"select.object.directionHandles",
|
||||
"select.object.pointText",
|
||||
"select.object.slices",
|
||||
"select.object.strayPoints",
|
||||
"select.object.textObjects",
|
||||
"select.recall",
|
||||
"select.reselect",
|
||||
"select.same.appearance",
|
||||
"select.same.appearanceAttribute",
|
||||
"select.same.blendingMode",
|
||||
"select.same.fillAndStroke",
|
||||
"select.same.fillColor",
|
||||
"select.same.fontFamily",
|
||||
"select.same.fontFamilyStyle",
|
||||
"select.same.fontFamilyStyleSize",
|
||||
"select.same.fontSize",
|
||||
"select.same.graphicStyle",
|
||||
"select.same.opacity",
|
||||
"select.same.shapeType",
|
||||
"select.same.strokeColor",
|
||||
"select.same.strokeWeight",
|
||||
"select.same.symbolInstance",
|
||||
"select.same.textFillAndStrokeColor",
|
||||
"select.same.textFillColor",
|
||||
"select.same.textStrokeColor",
|
||||
"select.segments",
|
||||
"select.set",
|
||||
"select.toggle",
|
||||
"select.update",
|
||||
"shape.arc",
|
||||
"shape.ellipse",
|
||||
"shape.flare",
|
||||
"shape.line",
|
||||
"shape.polarGrid",
|
||||
"shape.polygon",
|
||||
"shape.rectangle",
|
||||
"shape.rectangularGrid",
|
||||
"shape.spiral",
|
||||
"shape.star",
|
||||
"shapeBuilder.merge",
|
||||
"shapeBuilder.regions",
|
||||
"shaper.expand",
|
||||
"shaper.release",
|
||||
"shaper.releaseSource",
|
||||
"shaper.scribble",
|
||||
"shaper.select",
|
||||
"slice.list",
|
||||
"stroke.set",
|
||||
"stroke.setAdvanced",
|
||||
"stroke.weightDown",
|
||||
"stroke.weightUp",
|
||||
"stroke.widthPoint.copy",
|
||||
"stroke.widthPoint.remove",
|
||||
"stroke.widthPoint.set",
|
||||
"svgFilter.apply",
|
||||
"svgFilter.define",
|
||||
"svgFilter.delete",
|
||||
"svgFilter.list",
|
||||
"svgInteractivity.clearEvents",
|
||||
"svgInteractivity.info",
|
||||
"svgInteractivity.removeEvent",
|
||||
"svgInteractivity.setEvent",
|
||||
"swatch.addUsedColors",
|
||||
"swatch.delete",
|
||||
"swatch.duplicate",
|
||||
"swatch.edit",
|
||||
"swatch.editGroup",
|
||||
"swatch.list",
|
||||
"swatch.merge",
|
||||
"swatch.move",
|
||||
"swatch.new",
|
||||
"swatch.newGroup",
|
||||
"swatch.resetDefaults",
|
||||
"swatch.setSpot",
|
||||
"swatch.sortByKind",
|
||||
"swatch.sortByName",
|
||||
"swatch.spotOptions",
|
||||
"swatch.ungroup",
|
||||
"swatch.unused",
|
||||
"symbol.adjust",
|
||||
"symbol.delete",
|
||||
"symbol.duplicate",
|
||||
"symbol.edit",
|
||||
"symbol.exitEditing",
|
||||
"symbol.instance.reset",
|
||||
"symbol.instance.resetTransform",
|
||||
"symbol.instance.selectPart",
|
||||
"symbol.instance.setAppearance",
|
||||
"symbol.list",
|
||||
"symbol.new",
|
||||
"symbol.options",
|
||||
"symbol.place",
|
||||
"symbol.replace",
|
||||
"symbol.selectInstances",
|
||||
"symbol.setCurrent",
|
||||
"symbol.spray",
|
||||
"symbol.unused",
|
||||
"symbol.update",
|
||||
"text.areaOptions",
|
||||
"text.convertListToText",
|
||||
"text.create",
|
||||
"text.createInPath",
|
||||
"text.discardEmpty",
|
||||
"text.editRange",
|
||||
"text.fitHeadline",
|
||||
"text.fonts",
|
||||
"text.getRange",
|
||||
"text.glyphAlternates",
|
||||
"text.insertInline",
|
||||
"text.missingFonts",
|
||||
"text.replaceFont",
|
||||
"text.reshapeArea",
|
||||
"text.reshapePath",
|
||||
"text.setFormat",
|
||||
"text.setGlyphAlternate",
|
||||
"text.setPathHandle",
|
||||
"text.setRangeStyle",
|
||||
"text.setStyle",
|
||||
"text.setText",
|
||||
"text.startList",
|
||||
"text.tabs.clear",
|
||||
"text.tabs.get",
|
||||
"text.tabs.set",
|
||||
"text.thread.add",
|
||||
"text.thread.break",
|
||||
"text.thread.create",
|
||||
"text.thread.info",
|
||||
"text.thread.releaseSelection",
|
||||
"text.thread.remove",
|
||||
"text.touchType",
|
||||
"transparency.disableOpacityMask",
|
||||
"transparency.editOpacityMask",
|
||||
"transparency.enableOpacityMask",
|
||||
"transparency.info",
|
||||
"transparency.makeOpacityMask",
|
||||
"transparency.opacityMaskInfo",
|
||||
"transparency.releaseOpacityMask",
|
||||
"transparency.set",
|
||||
"transparency.setOpacityMask",
|
||||
"transparency.stopEditingOpacityMask",
|
||||
"transparency.togglePageIsolatedBlending",
|
||||
"transparency.togglePageKnockoutGroup",
|
||||
"transparency.viewOpacityMask",
|
||||
"type.changeCase",
|
||||
"type.compositeFonts.list",
|
||||
"type.convertToAreaType",
|
||||
"type.convertToPointType",
|
||||
"type.createOutlines",
|
||||
"type.dynamicText",
|
||||
"type.fillPlaceholder",
|
||||
"type.fitHeadline",
|
||||
"type.insert",
|
||||
"type.opticalMarginAlignment",
|
||||
"type.orientation.horizontal",
|
||||
"type.orientation.vertical",
|
||||
"type.pathOptions",
|
||||
"type.resetHorizontalScale",
|
||||
"type.resetTracking",
|
||||
"type.ruby",
|
||||
"type.scaleTextAttributes",
|
||||
"type.size.decrease",
|
||||
"type.size.decreaseByFive",
|
||||
"type.size.increase",
|
||||
"type.size.increaseByFive",
|
||||
"type.smartPunctuation",
|
||||
"type.step",
|
||||
"variable.bind",
|
||||
"variable.define",
|
||||
"variable.delete",
|
||||
"variable.highlight",
|
||||
"variable.list",
|
||||
"variable.lock",
|
||||
"variable.makeGraphDynamic",
|
||||
"variable.makeImageDynamic",
|
||||
"variable.makeTextDynamic",
|
||||
"variable.makeVisibilityDynamic",
|
||||
"variable.rename",
|
||||
"variable.unbind",
|
||||
];
|
||||
|
||||
/// Commands a plug-in may not call although no rule refuses them, in order: they change the
|
||||
/// preferences, a tool's options, a drag's state or the font names the whole app knows, or run
|
||||
/// plug-ins again.
|
||||
pub(super) const DENIED: &[&str] = &[
|
||||
"addon.cleanup.splitText",
|
||||
"addon.pathtools.movePointsTogether",
|
||||
"addon.pen.nextSegment",
|
||||
"effect.refreshDeferred",
|
||||
"graphicStyle.setOptions",
|
||||
"object.blend.options",
|
||||
"object.liquify",
|
||||
"object.pathfinder.options",
|
||||
"type.compositeFonts.set",
|
||||
];
|
||||
@@ -1,11 +1,12 @@
|
||||
//! Preferences (Edit → Preferences): `prefs.get`, `prefs.set`, `prefs.reset`, `prefs.list`.
|
||||
//! Preferences (Edit → Preferences): `prefs.get`, `prefs.set`, `prefs.reset`, `prefs.list`,
|
||||
//! `prefs.export`, `prefs.import`.
|
||||
//!
|
||||
//! [`PREF_SPECS`] is the single source of truth for every preference: its category, section,
|
||||
//! label and value kind. The Preferences dialog renders from it and `prefs.set` validates with it.
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use super::{CommandSpec, EngineError, Result, Session, always, bad, cmd, str_param};
|
||||
use super::{CommandSpec, EngineError, Result, Session, always, bad, cmd, str_param, switch_or, text_param};
|
||||
use crate::Prefs;
|
||||
use crate::units::Measure;
|
||||
use vectorcraft_doc::{GridPrefs, Unit};
|
||||
@@ -229,6 +230,7 @@ pub const PREF_SPECS: &[PrefSpec] = &[
|
||||
p!("guideColor", "Guides & Grid", "Guides", "Color", color),
|
||||
p!("guideStyle", "Guides & Grid", "Guides", "Style", choice(LINE_STYLE)),
|
||||
p!("lockGuidesByDefault", "Guides & Grid", "Guides", "Lock Guides by Default", bool),
|
||||
p!("cmdRulerDragArtboardGuides", "Guides & Grid", "Guides", "Artboard Guides with Cmd/Ctrl + Ruler Drag", bool),
|
||||
p!("gridColor", "Guides & Grid", "Grid", "Color", color),
|
||||
p!("gridStyle", "Guides & Grid", "Grid", "Style", choice(LINE_STYLE)),
|
||||
p!("gridlineEvery", "Guides & Grid", "Grid", "Gridline every", len(1.0, 16383.0, General)),
|
||||
@@ -457,6 +459,9 @@ pub const PREF_GROUPS: &[&str] = &["eyedropper", "perspectiveWidget", "pathfinde
|
||||
|
||||
/// Validate a value for preference group `key` against `current`.
|
||||
fn validate_group(key: &str, current: &Value, v: &Value) -> std::result::Result<Value, String> {
|
||||
if !v.is_object() {
|
||||
return Err(format!("`{key}` must be an object"));
|
||||
}
|
||||
match key {
|
||||
"eyedropper" => {
|
||||
let cur: super::EyedropperOptions = serde_json::from_value(current.clone()).unwrap_or_default();
|
||||
@@ -692,6 +697,9 @@ impl Session {
|
||||
let history_changed = p.history_states != self.prefs.history_states;
|
||||
let tile_edge_changed = p.pattern_tile_edge_color != self.prefs.pattern_tile_edge_color;
|
||||
let fonts_folder_changed = p.fonts_folder != self.prefs.fonts_folder;
|
||||
if p.plugins_folder.trim().is_empty() && !self.prefs.plugins_folder.trim().is_empty() {
|
||||
super::plugin::forget_prefs_folder(&self.prefs.plugins_folder);
|
||||
}
|
||||
let hyphen_exceptions_changed = p.hyphenation_exceptions != self.prefs.hyphenation_exceptions;
|
||||
let corner_radius_changed = p.corner_radius != self.prefs.corner_radius;
|
||||
let lock_guides_changed = p.lock_guides_by_default != self.prefs.lock_guides_by_default;
|
||||
@@ -763,15 +771,117 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Set Preference",
|
||||
[],
|
||||
None,
|
||||
"{key, value} or {values: {key: value, …}} set preferences (validated; see prefs.list; lengths in pt or strings with a unit; unitsGeneral also sets the active document's units, one undo step; the eyedropper group takes a partial object, as eyedropper.setOptions; a new recoveryFolder must be a folder that can be listed and written in, and a new pluginsFolder a folder that can be listed: otherwise an error, and nothing changes; an empty value is not checked)",
|
||||
"{key, value} or {values: {key: value, …}, documentUnits?: bool=true} set preferences (validated; see prefs.list; lengths in pt or strings with a unit; unitsGeneral also sets the active document's units, one undo step, unless documentUnits is false; the eyedropper group takes a partial object, as eyedropper.setOptions, and a group given anything but an object is an error; a new recoveryFolder must be a folder that can be listed and written in, and a new pluginsFolder a folder that can be listed: otherwise an error, and nothing changes; an empty value is not checked)",
|
||||
always,
|
||||
set
|
||||
),
|
||||
cmd!(query "prefs.reset", "Reset Preferences", [], None, "{category?} reset one category (or everything) to defaults", always, reset),
|
||||
cmd!(query "prefs.list", "List Preferences", [], None, "{} → [{key, category, section, label, kind, value, …}] every preference", always, list),
|
||||
cmd!(
|
||||
query "prefs.export",
|
||||
"Export Preferences",
|
||||
[],
|
||||
None,
|
||||
"{path?} write the preferences as a JSON file {format: \"vectorcraft-preferences\", version: 1, prefs: {key: value, …}}: every preference prefs.list lists and the eyedropper, perspectiveWidget, pathfinder and glyphSnap groups, without the folder preferences fontsFolder, pluginsFolder, recoveryFolder and templatesFolder and without saved presets; a file over 1 MB, which prefs.import would refuse, is an error and is not written → {path, count}; without path → {data: the file's text, count}",
|
||||
always,
|
||||
export
|
||||
),
|
||||
cmd!(
|
||||
query "prefs.import",
|
||||
"Import Preferences",
|
||||
[],
|
||||
None,
|
||||
"{path? | data?: the file's text} apply the preferences of a file prefs.export wrote (at most 1 MB). Every value is checked first, as prefs.set checks it: another format, a newer version, an unknown preference, a value of the wrong type or out of range, or a folder preference is an error, and nothing changes. Preferences the file leaves out keep their values; the open document's units stay as they are; no undo step; not a command.batch step → {imported: count}",
|
||||
always,
|
||||
import
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// The `format` of a preferences file (`prefs.export`, `prefs.import`).
|
||||
pub const PREFS_FORMAT: &str = "vectorcraft-preferences";
|
||||
|
||||
/// The `version` `prefs.export` writes; `prefs.import` refuses a newer one.
|
||||
pub const PREFS_VERSION: u64 = 1;
|
||||
|
||||
/// The largest preferences file `prefs.import` reads.
|
||||
const MAX_PREFS_FILE_BYTES: usize = 1 << 20;
|
||||
|
||||
/// Folder preferences, which name folders on one computer: a preferences file leaves them out.
|
||||
const FOLDER_PREFS: &[&str] = &["fontsFolder", "pluginsFolder", "recoveryFolder", "templatesFolder"];
|
||||
|
||||
/// The preferences `prefs.export` writes: every [`PREF_SPECS`] key but the [`FOLDER_PREFS`], and
|
||||
/// the [`PREF_GROUPS`].
|
||||
fn exported(prefs: &crate::Prefs) -> Map<String, Value> {
|
||||
let all = prefs.to_json();
|
||||
let keys = PREF_SPECS.iter().map(|s| s.key).filter(|k| !FOLDER_PREFS.contains(k)).chain(PREF_GROUPS.iter().copied());
|
||||
keys.map(|k| (k.to_string(), all[k].clone())).collect()
|
||||
}
|
||||
|
||||
fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "prefs.export";
|
||||
let path = text_param(p, "path", C)?;
|
||||
let prefs = exported(&s.prefs);
|
||||
let count = prefs.len();
|
||||
let file = json!({ "format": PREFS_FORMAT, "version": PREFS_VERSION, "prefs": prefs });
|
||||
let text = serde_json::to_string_pretty(&file).map_err(|e| bad(C, e.to_string()))?;
|
||||
// A file `prefs.import` would refuse is not written.
|
||||
if text.len() > MAX_PREFS_FILE_BYTES {
|
||||
return Err(bad(C, "the file is larger than 1 MB"));
|
||||
}
|
||||
match path {
|
||||
Some(path) => {
|
||||
super::fileio::write_file(path, text.as_bytes())?;
|
||||
Ok(json!({"path": path, "count": count}))
|
||||
}
|
||||
None => Ok(json!({"data": text, "count": count})),
|
||||
}
|
||||
}
|
||||
|
||||
/// The preferences in the bytes of a preferences file, unchecked: `Err` says why the file is not
|
||||
/// one this version reads.
|
||||
fn read_prefs_file(bytes: &[u8]) -> std::result::Result<Map<String, Value>, String> {
|
||||
let v: Value = serde_json::from_slice(bytes).map_err(|e| format!("not a preferences file: {e}"))?;
|
||||
let Some(file) = v.as_object() else { return Err("not a preferences file: no JSON object".into()) };
|
||||
match file.get("format").and_then(Value::as_str) {
|
||||
Some(PREFS_FORMAT) => {}
|
||||
Some(format) => return Err(format!("not a preferences file (format `{format}`)")),
|
||||
None => return Err("not a preferences file: no `format`".into()),
|
||||
}
|
||||
match file.get("version").and_then(Value::as_u64) {
|
||||
Some(v) if v > PREFS_VERSION => return Err(format!("the file was saved by a newer version of VectorCraft (version {v})")),
|
||||
Some(1..) => {}
|
||||
_ => return Err("not a preferences file: no `version`".into()),
|
||||
}
|
||||
let Some(prefs) = file.get("prefs").and_then(Value::as_object) else { return Err("not a preferences file: no `prefs`".into()) };
|
||||
if prefs.is_empty() {
|
||||
return Err("the file holds no preferences".into());
|
||||
}
|
||||
if let Some(key) = prefs.keys().find(|k| FOLDER_PREFS.contains(&k.as_str())) {
|
||||
return Err(format!("`{key}` names a folder, which preferences files leave out"));
|
||||
}
|
||||
Ok(prefs.clone())
|
||||
}
|
||||
|
||||
fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "prefs.import";
|
||||
let too_large = || bad(C, "the file is larger than 1 MB");
|
||||
let bytes = match (text_param(p, "path", C)?, text_param(p, "data", C)?) {
|
||||
(Some(path), None) => super::fileio::read_file_head(path, MAX_PREFS_FILE_BYTES + 1)?,
|
||||
(None, Some(text)) if text.len() > MAX_PREFS_FILE_BYTES => return Err(too_large()),
|
||||
(None, Some(text)) => text.as_bytes().to_vec(),
|
||||
_ => return Err(bad(C, "give `path` or `data`")),
|
||||
};
|
||||
if bytes.len() > MAX_PREFS_FILE_BYTES {
|
||||
return Err(too_large());
|
||||
}
|
||||
let values = read_prefs_file(&bytes).map_err(|e| bad(C, e))?;
|
||||
let mut next = s.prefs.clone();
|
||||
next.set_values(&values).map_err(|e| bad(C, e))?;
|
||||
s.apply_prefs(next);
|
||||
Ok(json!({ "imported": values.len() }))
|
||||
}
|
||||
|
||||
fn get(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let all = s.prefs.to_json();
|
||||
match str_param(p, "key") {
|
||||
@@ -789,6 +899,7 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if values.is_empty() {
|
||||
return Err(bad("prefs.set", "give {key, value} or {values}"));
|
||||
}
|
||||
let document_units = switch_or(p, "documentUnits", true, "prefs.set")?;
|
||||
let mut next = s.prefs.clone();
|
||||
next.set_values(&values).map_err(|e| bad("prefs.set", e))?;
|
||||
if let Some(e) = s.prefs.keep_folders(&mut next).into_iter().next() {
|
||||
@@ -797,7 +908,7 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.prefs.check_folders(&values).map_err(EngineError::Other)?;
|
||||
s.apply_prefs(next);
|
||||
// Units ▸ General is also the open document's units (as Document Setup sets them).
|
||||
if values.contains_key("unitsGeneral") && s.active().is_some() {
|
||||
if document_units && values.contains_key("unitsGeneral") && s.active().is_some() {
|
||||
s.set_document_units(s.default_units())?;
|
||||
}
|
||||
let all = s.prefs.to_json();
|
||||
|
||||
@@ -23,7 +23,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Print Setup",
|
||||
[],
|
||||
None,
|
||||
"{preset?: name ([Default] or a saved print preset, any case: print.presets.list; its settings replace the document's, as choosing it in the Print dialog does), settings?: {copies: 1 (1–999), collate: true, reverse, artboards: all|range|ignore (all the art as one page), range: \"1-3, 5\" (1-based, with artboards: range), skipBlank (leave out artboards with no art that prints), media: letter|legal|tabloid|a3|a4|a5|a6|b4|b5|b4 (jis)|b5 (jis)|b6 (jis)|japanese postcard|japanese double postcard|japanese envelope #3|japanese envelope #4|kaku #2|kaku #3|yonroku-ban|custom (a paper is named by its id or its dialog label, any case and spacing, so `B5 (JIS)` works; b4 and b5 stay the ISO 216 sizes, 250 × 353 and 176 × 250 mm, and the JIS sizes are b4 (jis) 257 × 364 mm, b5 (jis) 182 × 257 mm and b6 (jis) 128 × 182 mm), width, height (custom paper, 72–14400 pt), orientation: portrait|landscape|portraitFlipped|landscapeFlipped, autoRotate: true (turn the paper to each artboard; orientation ignored), transverse (the page a quarter turn on the paper), printLayers: visiblePrintable|visible|all (template layers never print), placement: {origin: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight (the point of the printed area, the artboard with its bleed and marks, on the same point of the imageable area), x, y (pt, right and down)}, scaling: none|fit|custom|tileFull|tileImageable, scale: {width: 100, height: 100} (% with custom and tiling), overlap: 0 (pt between tiles), tileRange: \"\" (1-based tiles across then down; empty: all), copiesPerSheet: false (not with tiling: as many copies of each artboard, with its bleed and marks, as fit the imageable area, across then down, not turned; the placement places the grid; an artboard that doesn't fit prints one copy per sheet, with a warning), gutter: 0 (pt between copies, 0–720), margin: 0 (pt the device can't print around the paper: the imageable area is inside it), marks: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25, offset: 6} (as document.exportPdf, at the paper's scale; page information adds the tile and the ink), bleed: {useDocument: true (the document's bleed, document.setup), top, bottom, left, right} (pt), output: {mode: composite|separations (one grey page per ink that prints), emulsion: up|down (down mirrors), image: positive|negative, spotsToProcess, inks: [{name: \"Cyan\"|…|a spot swatch, print: true, frequency (lpi, default 60), angle (default C 15, M 75, Y 0, K 45, spots 45)}]}, graphics: {autoFlatness: true, flatness: 1 (0.2–100), fonts: none|subset|complete}, color: {intent: perceptual|relativeColorimetric|saturation|absoluteColorimetric, preserveNumbers: true (CMYK colours keep their values), profile: \"\" (the printer profile, an RGB or CMYK one: composite PDF colours are converted to it with the intent, separations separate with a CMYK one; empty: as they are)}, advanced: {printAsBitmap (composite pages print as one image each, at the document's raster effects resolution), overprints: preserve|discard|simulate (composite output; preserve writes them to overprint (/OP), simulate prints them as Overprint Preview shows them; separations always keep them), flattenerPreset: \"\" (a flattener preset, built-in or saved: transparency is flattened before printing; empty: it prints live; PostScript defaults to medium)}, tileOrigin: {placed: false, x, y} (where the Print Tiling tool put the pages: print.tiling.set)}} store the print settings with the document, over the ones it has or the preset's (null keeps a value), as one undo step; no settings or preset → {settings} (the current ones, defaults if never set up)",
|
||||
"{preset?: name ([Default] or a saved print preset, any case: print.presets.list; its settings replace the document's, as choosing it in the Print dialog does), settings?: {copies: 1 (1–999), collate: true, reverse, artboards: all|range|ignore (all the art as one page), range: \"1-3, 5\" (1-based, with artboards: range), skipBlank (leave out artboards with no art that prints), media: letter|legal|tabloid|a3|a4|a5|a6|b4|b5|b4 (jis)|b5 (jis)|b6 (jis)|japanese postcard|japanese double postcard|japanese envelope #3|japanese envelope #4|kaku #2|kaku #3|yonroku-ban|custom (a paper is named by its id or its dialog label, any case and spacing, so `B5 (JIS)` works; b4 and b5 stay the ISO 216 sizes, 250 × 353 and 176 × 250 mm, and the JIS sizes are b4 (jis) 257 × 364 mm, b5 (jis) 182 × 257 mm and b6 (jis) 128 × 182 mm), width, height (custom paper, 72–14400 pt), orientation: portrait|landscape|portraitFlipped|landscapeFlipped, autoRotate: true (turn the paper to each artboard; orientation ignored), transverse (the page a quarter turn on the paper), printLayers: visiblePrintable|visible|all (template layers never print), placement: {origin: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight (the point of the printed area, the artboard with its bleed and marks, on the same point of the imageable area), x, y (pt, right and down)}, scaling: none|fit|custom|tileFull|tileImageable, scale: {width: 100, height: 100} (% with custom and tiling), overlap: 0 (pt between tiles), tileRange: \"\" (1-based tiles across then down; empty: all), copiesPerSheet: false (not with tiling: as many copies of each artboard, with its bleed and marks, as fit the imageable area, across then down, not turned; the placement places the grid; an artboard that doesn't fit prints one copy per sheet, with a warning), gutter: 0 (pt between copies, 0–720), margin: 0 (pt the device can't print around the paper: the imageable area is inside it), marks: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25, offset: 6} (as document.exportPdf, at the paper's scale; page information adds the tile and the ink), bleed: {useDocument: true (the document's bleed, document.setup), top, bottom, left, right} (pt), output: {mode: composite|separations (one grey page per ink that prints), emulsion: up|down (down mirrors), image: positive|negative, spotsToProcess, inks: [{name: \"Cyan\"|…|a spot swatch, print: true, frequency (lpi, default 60), angle (screen angle, degrees counter-clockwise as PostScript setscreen takes it; default C 15, M 75, Y 0, K 45, spots 45)}]}, graphics: {autoFlatness: true, flatness: 1 (0.2–100), fonts: none|subset|complete}, color: {intent: perceptual|relativeColorimetric|saturation|absoluteColorimetric, preserveNumbers: true (CMYK colours keep their values), profile: \"\" (the printer profile, an RGB or CMYK one: composite PDF colours are converted to it with the intent, separations separate with a CMYK one; empty: as they are)}, advanced: {printAsBitmap (composite pages print as one image each, at the document's raster effects resolution, or lower for a page larger than 64 Mpx or 32,768 px a side), overprints: preserve|discard|simulate (composite output; preserve writes them to overprint (/OP), simulate prints them as Overprint Preview shows them; separations always keep them), flattenerPreset: \"\" (a flattener preset, built-in or saved: transparency is flattened before printing; empty: it prints live; PostScript defaults to medium)}, tileOrigin: {placed: false, x, y} (where the Print Tiling tool put the pages: print.tiling.set)}} store the print settings with the document, over the ones it has or the preset's (null keeps a value), as one undo step; no settings or preset → {settings} (the current ones, defaults if never set up)",
|
||||
has_doc,
|
||||
setup
|
||||
),
|
||||
|
||||
@@ -48,12 +48,13 @@ pub(crate) fn prepare(cmd: &str, doc: &Document, set: &PrintSettings, saved: &[F
|
||||
Ok(Some(flatten_document(&printed, &o)?.unwrap_or_else(|| printed.into_owned())))
|
||||
}
|
||||
|
||||
/// Leave guide paths out of `nodes` (the renderer draws them; they never print).
|
||||
/// Leave guides out of `nodes`: guide paths (the renderer draws them; they never print) and ruler
|
||||
/// guides.
|
||||
fn drop_guides(nodes: &mut Vec<Arc<Node>>) {
|
||||
fn has_guides(n: &Node) -> bool {
|
||||
matches!(n.kind, NodeKind::Path { guide: true, .. }) || n.children().is_some_and(|c| c.iter().any(|c| has_guides(c)))
|
||||
n.is_guide() || n.children().is_some_and(|c| c.iter().any(|c| has_guides(c)))
|
||||
}
|
||||
nodes.retain(|n| !matches!(n.kind, NodeKind::Path { guide: true, .. }));
|
||||
nodes.retain(|n| !n.is_guide());
|
||||
for n in nodes.iter_mut().filter(|n| has_guides(n)) {
|
||||
if let Some(children) = Arc::make_mut(n).children_mut() {
|
||||
drop_guides(children);
|
||||
@@ -76,9 +77,11 @@ fn bitmap_document(cmd: &str, doc: &Document, set: &PrintSettings, rich_black: b
|
||||
let mut images = vec![];
|
||||
for (_, r) in regions {
|
||||
let area = outset(r, bleed);
|
||||
// At most MAX_PIXELS, and 65535 pixels a side.
|
||||
// At most MAX_PIXELS, and no side longer than the renderer draws (rounded up to whole
|
||||
// pixels below).
|
||||
let side = area.width().max(area.height()).max(1.0);
|
||||
let scale = (doc.raster_effects_ppi / 72.0).min((MAX_PIXELS / (area.width() * area.height()).max(1.0)).sqrt()).min(65_535.0 / side);
|
||||
let longest = f64::from(vectorcraft_render::MAX_RASTER_SIDE) - 2.0;
|
||||
let scale = (doc.raster_effects_ppi / 72.0).min((MAX_PIXELS / (area.width() * area.height()).max(1.0)).sqrt()).min(longest / side);
|
||||
if !(scale.is_finite() && scale > 0.0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -263,7 +263,7 @@ fn retain_effects(n: &mut Node, members: bool, keep: &dyn Fn(&Effect) -> bool) {
|
||||
it.effects_mut().retain(keep);
|
||||
}
|
||||
if members {
|
||||
for c in n.children_mut().into_iter().flatten() {
|
||||
for c in n.children_mut().into_iter().flatten().filter(|c| !c.is_ruler_guide()) {
|
||||
retain_effects(Arc::make_mut(c), true, keep);
|
||||
}
|
||||
}
|
||||
@@ -367,7 +367,9 @@ pub(crate) fn replace_with_image(mut v: Node, image: Node) -> Node {
|
||||
v.appearance = Default::default();
|
||||
v.set_clips(false);
|
||||
if let Some(ch) = v.children_mut() {
|
||||
*ch = vec![Arc::new(image)];
|
||||
// Its ruler guides stay above the image.
|
||||
let guides = ch.split_off(Node::guide_run_start(ch));
|
||||
*ch = std::iter::once(Arc::new(image)).chain(guides).collect();
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -252,6 +252,18 @@ pub(crate) fn selectable_ids(d: &Document, iso: Option<NodeId>) -> BTreeSet<Node
|
||||
/// Whether [`walk_selectable`] visits `id`, read from the objects that hold it instead of a walk of
|
||||
/// the document. `locks` is the document's [`Document::locked_artboards`].
|
||||
pub(crate) fn is_selectable(d: &Document, iso: Option<NodeId>, locks: &LockedArtboards, id: NodeId) -> bool {
|
||||
is_selectable_within(d, iso, Some(locks), id)
|
||||
}
|
||||
|
||||
/// [`is_selectable`] without the locked artboards, for an edit's new selection: after the edit,
|
||||
/// the session takes the art on a locked artboard out of the selection, except the new art the
|
||||
/// edit made.
|
||||
pub(crate) fn is_selectable_on_any_artboard(d: &Document, iso: Option<NodeId>, id: NodeId) -> bool {
|
||||
is_selectable_within(d, iso, None, id)
|
||||
}
|
||||
|
||||
/// [`is_selectable`], leaving out the locked artboards when `locks` is `None`.
|
||||
fn is_selectable_within(d: &Document, iso: Option<NodeId>, locks: Option<&LockedArtboards>, id: NodeId) -> bool {
|
||||
let Some(a) = d.ancestry(id) else { return false };
|
||||
if !a.iter().all(|i| d.node(*i).is_some_and(|n| n.visible && !n.locked && !n.is_template())) {
|
||||
return false;
|
||||
@@ -262,7 +274,7 @@ pub(crate) fn is_selectable(d: &Document, iso: Option<NodeId>, locks: &LockedArt
|
||||
None => {
|
||||
// The object Select All takes: the outermost one that is not a layer.
|
||||
let top = a.iter().filter_map(|i| d.node(*i)).find(|n| !n.is_layer());
|
||||
top.is_some_and(|t| locks.is_empty() || !locks.holds(t))
|
||||
top.is_some_and(|t| locks.is_none_or(|l| l.is_empty() || !l.holds(t)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -430,15 +442,16 @@ fn key(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if id.is_some() {
|
||||
return Err(bad("select.key", "give `id` or `guide`, not both"));
|
||||
}
|
||||
if !sel.guides.contains(&g) {
|
||||
let gid = super::docmenu::guide_ids(&s.doc()?.doc).get(g).copied();
|
||||
let Some(gid) = gid.filter(|id| sel.guides.contains(id)) else {
|
||||
return Err(bad("select.key", format!("guide {g} is not selected")));
|
||||
}
|
||||
};
|
||||
if sel.is_empty() {
|
||||
return Err(bad("select.key", "select objects to align to the guide"));
|
||||
}
|
||||
s.select(|_, sel| {
|
||||
sel.key = None;
|
||||
sel.key_guide = Some(g);
|
||||
sel.key_guide = Some(gid);
|
||||
})?;
|
||||
return key_result(s);
|
||||
}
|
||||
@@ -460,9 +473,9 @@ fn key(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
/// `{key}`, with `guide` when a guide is the key.
|
||||
fn key_result(s: &Session) -> Result<Value> {
|
||||
let sel = &s.doc()?.selection;
|
||||
let mut out = json!({ "key": sel.key });
|
||||
if let Some(g) = sel.key_guide {
|
||||
let st = s.doc()?;
|
||||
let mut out = json!({ "key": st.selection.key });
|
||||
if let Some(g) = st.selection.key_guide.and_then(|id| super::docmenu::guide_indexes(&st.doc, &[id]).first().copied()) {
|
||||
out["guide"] = json!(g);
|
||||
}
|
||||
Ok(out)
|
||||
|
||||
@@ -340,8 +340,9 @@ fn release(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
fn from_guides(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let d = &s.doc()?.doc;
|
||||
let region = d.slice_clip().ok_or_else(|| EngineError::Other("Create from Guides: the document has no artboard".into()))?;
|
||||
let guides = d.ruler_guides();
|
||||
let cuts = |vertical: bool, lo: f64, hi: f64| -> Vec<f64> {
|
||||
let mut v: Vec<f64> = d.guides.iter().filter(|g| g.vertical == vertical && g.pos > lo && g.pos < hi).map(|g| g.pos).collect();
|
||||
let mut v: Vec<f64> = guides.iter().filter(|g| g.vertical == vertical && g.pos > lo && g.pos < hi).map(|g| g.pos).collect();
|
||||
v.extend([lo, hi]);
|
||||
v.sort_by(f64::total_cmp);
|
||||
v.dedup();
|
||||
|
||||
@@ -85,6 +85,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
always,
|
||||
change_word
|
||||
),
|
||||
cmd!(
|
||||
query "text.spelling.editDictionary",
|
||||
"Edit Custom Dictionary",
|
||||
[],
|
||||
None,
|
||||
"{remove?: [word…], add?: [word…] (each one word, at most 64 characters; at most 10000 of each)} deletes the words of remove that are there and adds those of add, then saves once: all of it, or none of it when the save fails (Edit Custom Dictionary's Done) → {removed: [word…], added: [word…]}",
|
||||
always,
|
||||
edit_dictionary
|
||||
),
|
||||
cmd!(
|
||||
query "text.spelling.ignoreAll",
|
||||
"Ignore All",
|
||||
@@ -223,6 +232,12 @@ fn suggest(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "suggestions": suggestions }))
|
||||
}
|
||||
|
||||
/// `w` as the custom dictionary keeps it (trimmed, lower case), or None when it isn't one word of at
|
||||
/// most `craft_proof::MAX_CUSTOM_WORD_CHARS` characters: what `text.spelling.addWord` takes.
|
||||
pub fn custom_word(w: &str) -> Option<String> {
|
||||
craft_proof::custom_word(w)
|
||||
}
|
||||
|
||||
/// `p`'s `key` as a custom-dictionary word (trimmed, lower case), or an error naming what it must be.
|
||||
fn word_param(p: &Value, key: &str, command: &str) -> Result<String> {
|
||||
let w = str_param(p, key).ok_or_else(|| bad(command, format!("missing `{key}`")))?;
|
||||
@@ -342,6 +357,62 @@ fn change_word(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "word": to }))
|
||||
}
|
||||
|
||||
/// Most words `text.spelling.editDictionary` takes in each of its lists.
|
||||
const MAX_EDIT_WORDS: usize = 10_000;
|
||||
|
||||
/// `p`'s `key`, a list of custom-dictionary words (none when it is missing), deduplicated.
|
||||
fn word_list(p: &Value, key: &str, command: &str) -> Result<BTreeSet<String>> {
|
||||
let Some(v) = p.get(key) else { return Ok(BTreeSet::new()) };
|
||||
let words = v.as_array().ok_or_else(|| bad(command, format!("`{key}` must be a list of words")))?;
|
||||
if words.len() > MAX_EDIT_WORDS {
|
||||
return Err(bad(command, format!("`{key}` holds more than {MAX_EDIT_WORDS} words")));
|
||||
}
|
||||
words
|
||||
.iter()
|
||||
.map(|w| {
|
||||
w.as_str()
|
||||
.and_then(craft_proof::custom_word)
|
||||
.ok_or_else(|| bad(command, format!("`{key}` must hold single words of at most {} characters", craft_proof::MAX_CUSTOM_WORD_CHARS)))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `text.spelling.editDictionary`: Edit Custom Dictionary's Done. Every delete and add is made,
|
||||
/// then saved once; when the save fails, all of them are taken back, so a failed Done leaves the
|
||||
/// dictionary as it was rather than half changed.
|
||||
fn edit_dictionary(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "text.spelling.editDictionary";
|
||||
let (remove, add) = (word_list(p, "remove", C)?, word_list(p, "add", C)?);
|
||||
let before: BTreeSet<String> = craft_proof::user_dictionary().into_iter().collect();
|
||||
// A word in both lists stays; a word to delete that another window deleted already is skipped.
|
||||
let removed: Vec<String> = remove.iter().filter(|w| !add.contains(*w) && before.contains(*w)).cloned().collect();
|
||||
let added: Vec<String> = add.iter().filter(|w| !before.contains(*w)).cloned().collect();
|
||||
if removed.is_empty() && added.is_empty() {
|
||||
return Ok(json!({ "removed": removed, "added": added }));
|
||||
}
|
||||
for w in &removed {
|
||||
craft_proof::remove_word(w);
|
||||
}
|
||||
for w in &added {
|
||||
craft_proof::add_word(w);
|
||||
}
|
||||
let saved = save_dictionary(s, &removed, C);
|
||||
dictionary_changed();
|
||||
saved.inspect_err(|_| {
|
||||
for w in &added {
|
||||
craft_proof::remove_word(w);
|
||||
}
|
||||
for w in &removed {
|
||||
craft_proof::add_word(w);
|
||||
}
|
||||
// Words the save took in from the file stay, as for one word.
|
||||
if !added.is_empty() {
|
||||
retake_file_words(s);
|
||||
}
|
||||
})?;
|
||||
Ok(json!({ "removed": removed, "added": added }))
|
||||
}
|
||||
|
||||
/// `text.spelling.ignoreAll`: Ignore All, for the active document.
|
||||
fn ignore_all(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "text.spelling.ignoreAll";
|
||||
|
||||
@@ -20,7 +20,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Stroke Options",
|
||||
["Window", "Stroke"],
|
||||
None,
|
||||
"{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), widthPoints?, gradientMode? (as document.inspect reports them in strokeOptions, so those read back unchanged; ignored here: a custom profile is set with the Width tool, gradientMode with paint.editGradient), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does) → {objects: how many objects took the stroke options}",
|
||||
"{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|LineArrow|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), widthPoints?, gradientMode? (as document.inspect reports them in strokeOptions, so those read back unchanged; ignored here: a custom profile is set with the Width tool, gradientMode with paint.editGradient), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does) → {objects: how many objects took the stroke options}",
|
||||
has_doc,
|
||||
stroke_set
|
||||
),
|
||||
|
||||
@@ -153,7 +153,8 @@ fn descends(n: &Node, top: bool) -> bool {
|
||||
/// The objects whose appearance a style applied to `n` sets: `n`, or its painted contents.
|
||||
fn painted<'a>(n: &'a Node, top: bool, out: &mut Vec<&'a Node>) {
|
||||
match n.children() {
|
||||
Some(ch) if descends(n, top) => ch.iter().for_each(|c| painted(c, false, out)),
|
||||
// A layer's ruler guides have no appearance to set.
|
||||
Some(ch) if descends(n, top) => ch.iter().filter(|c| !c.is_ruler_guide()).for_each(|c| painted(c, false, out)),
|
||||
_ => out.push(n),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -753,7 +753,7 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let (Paint::Pattern { pattern, .. }, Some(src)) = (&w.paint, &patterns) {
|
||||
// The pattern comes with its swatch; a pattern the document has without one
|
||||
// gets one.
|
||||
let target = patternlib::import(d, src, pattern);
|
||||
let target = patternlib::import(d, src, pattern)?;
|
||||
let name = match patternlib::pattern_swatch(d, &target) {
|
||||
Some(name) => name,
|
||||
None => {
|
||||
|
||||
@@ -248,7 +248,7 @@ pub(crate) fn leave(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
/// the instance editing was entered from. The symbol already holds the layer's art ([`sync`]).
|
||||
pub(crate) fn finish(d: &mut Document, sel: &mut Selection) {
|
||||
let Some(se) = d.symbol_edit.take() else { return };
|
||||
let _ = d.remove(se.layer);
|
||||
let _ = d.remove_edit_layer(se.layer);
|
||||
match se.instance.filter(|i| d.node(*i).is_some()) {
|
||||
Some(i) => sel.set([i]),
|
||||
None => sel.clear(),
|
||||
@@ -480,18 +480,24 @@ fn follow_objects(d: &mut Document, name: &str, old: (&[Arc<Node>], bool), new:
|
||||
/// an instance of the symbol itself.
|
||||
pub(crate) fn sync(before: &Document, d: &mut Document) -> Result<()> {
|
||||
let Some(se) = d.symbol_edit.clone() else { return Ok(()) };
|
||||
let art: Option<Vec<Arc<Node>>> = d.node(se.layer).and_then(|l| l.children().cloned());
|
||||
// The layer's art: ruler guides on it are not part of the symbol.
|
||||
let art_of = |d: &Document| -> Option<Vec<Arc<Node>>> {
|
||||
d.node(se.layer).and_then(|l| l.children()).map(|ch| ch.iter().filter(|c| !c.is_ruler_guide()).cloned().collect())
|
||||
};
|
||||
let art = art_of(d);
|
||||
let (Some(art), Some(index)) = (art, d.symbols.iter().position(|x| x.name == se.symbol)) else {
|
||||
d.symbol_edit = None;
|
||||
let _ = d.remove(se.layer);
|
||||
let _ = d.remove_edit_layer(se.layer);
|
||||
return Ok(());
|
||||
};
|
||||
// Entering, and edits that left the layer's art alone, write nothing.
|
||||
let same = |b: &[Arc<Node>]| b.len() == art.len() && b.iter().zip(&art).all(|(a, b)| Arc::ptr_eq(a, b));
|
||||
let (old, old_group) = match before.symbol_edit.as_ref().filter(|b| b.layer == se.layer) {
|
||||
None => return Ok(()),
|
||||
Some(_) if before.node(se.layer).and_then(|l| l.children()).is_some_and(|b| same(b)) => return Ok(()),
|
||||
Some(b) => (before.node(se.layer).and_then(|l| l.children().cloned()).unwrap_or_default(), b.group),
|
||||
Some(b) => match art_of(before) {
|
||||
Some(old) if same(&old) => return Ok(()),
|
||||
old => (old.unwrap_or_default(), b.group),
|
||||
},
|
||||
};
|
||||
if art.iter().any(|n| holds_symbol(d, n, &se.symbol, &mut HashSet::new())) {
|
||||
return Err(EngineError::Other("a symbol can't hold an instance of itself".into()));
|
||||
|
||||
@@ -157,6 +157,9 @@ pub struct BuiltinSymbolLibrary {
|
||||
pub const BUILTIN: &[BuiltinSymbolLibrary] = &[
|
||||
BuiltinSymbolLibrary { id: "basic-shapes", name: "Basic Shapes", make: basic_shapes },
|
||||
BuiltinSymbolLibrary { id: "ui-frames", name: "UI Frames", make: ui_frames },
|
||||
BuiltinSymbolLibrary { id: "map-markers", name: "Map Markers", make: map_markers },
|
||||
BuiltinSymbolLibrary { id: "inventory-icons", name: "Inventory Icons", make: inventory_icons },
|
||||
BuiltinSymbolLibrary { id: "skill-icons", name: "Skill Icons", make: skill_icons },
|
||||
];
|
||||
|
||||
/// Built-in library `id`, made once.
|
||||
@@ -221,6 +224,140 @@ fn ui_frames() -> SymbolLibrary {
|
||||
SymbolLibrary { name: "UI Frames".into(), doc }
|
||||
}
|
||||
|
||||
/// Map Markers: pin, flag, crosshair and checkpoint for map/HUD art (original, no 9-slice).
|
||||
fn map_markers() -> SymbolLibrary {
|
||||
let c = Point::ZERO;
|
||||
let pin = [(0.0, 22.0), (-9.0, 4.0), (-12.0, -6.0), (-8.0, -16.0), (0.0, -20.0), (8.0, -16.0), (12.0, -6.0), (9.0, 4.0)];
|
||||
let flag = [(-16.0, 22.0), (-12.0, 22.0), (-12.0, -8.0), (18.0, -14.0), (-12.0, -20.0), (-12.0, -22.0), (-16.0, -22.0)];
|
||||
let pin: Vec<Point> = pin.iter().map(|&(x, y)| Point::new(x, y)).collect();
|
||||
let flag: Vec<Point> = flag.iter().map(|&(x, y)| Point::new(x, y)).collect();
|
||||
let markers = [
|
||||
("Pin", vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&pin, true)), "#e74c3c"),
|
||||
("Flag", vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&flag, true)), "#3498db"),
|
||||
("Crosshair", shapes::star(c, 20.0, 5.0, 4, 0.0), "#2c3e50"),
|
||||
("Checkpoint", shapes::polygon(c, 18.0, 4, 0.0), "#27ae60"),
|
||||
];
|
||||
let mut doc = Document::new(100.0, 100.0);
|
||||
for (name, path, hex) in markers {
|
||||
let fill = Color::from_hex(hex).unwrap_or(Color::BLACK);
|
||||
let edge = fill.lerp(&Color::BLACK, 0.35);
|
||||
let id = doc.alloc_id();
|
||||
let art = Node::path(id, path, Appearance::basic(Paint::solid(fill), Paint::solid(edge), 1.25));
|
||||
let Ok((art, size, _)) = normalize(art) else { continue };
|
||||
doc.symbols.push(Symbol { dynamic: false, name: name.into(), art: Arc::new(art) });
|
||||
set_natural_size(&mut doc, name, Some(size));
|
||||
}
|
||||
doc.title = "Map Markers".into();
|
||||
SymbolLibrary { name: "Map Markers".into(), doc }
|
||||
}
|
||||
|
||||
/// Inventory Icons: sword, shield, potion, key and gem for loot/RPG art (original, no 9-slice).
|
||||
fn inventory_icons() -> SymbolLibrary {
|
||||
let c = Point::ZERO;
|
||||
let sword = [
|
||||
(0.0, -22.0),
|
||||
(3.0, -10.0),
|
||||
(3.0, 6.0),
|
||||
(8.0, 8.0),
|
||||
(8.0, 12.0),
|
||||
(3.0, 12.0),
|
||||
(3.0, 20.0),
|
||||
(-3.0, 20.0),
|
||||
(-3.0, 12.0),
|
||||
(-8.0, 12.0),
|
||||
(-8.0, 8.0),
|
||||
(-3.0, 6.0),
|
||||
(-3.0, -10.0),
|
||||
];
|
||||
let shield = [(0.0, -20.0), (16.0, -12.0), (14.0, 8.0), (0.0, 20.0), (-14.0, 8.0), (-16.0, -12.0)];
|
||||
let potion =
|
||||
[(-4.0, -20.0), (4.0, -20.0), (4.0, -12.0), (10.0, -6.0), (12.0, 6.0), (8.0, 18.0), (-8.0, 18.0), (-12.0, 6.0), (-10.0, -6.0), (-4.0, -12.0)];
|
||||
let key = [
|
||||
(-4.0, -18.0),
|
||||
(4.0, -18.0),
|
||||
(6.0, -14.0),
|
||||
(6.0, -8.0),
|
||||
(4.0, -6.0),
|
||||
(4.0, 16.0),
|
||||
(8.0, 16.0),
|
||||
(8.0, 20.0),
|
||||
(-2.0, 20.0),
|
||||
(-2.0, 12.0),
|
||||
(0.0, 12.0),
|
||||
(0.0, -6.0),
|
||||
(-4.0, -8.0),
|
||||
(-6.0, -14.0),
|
||||
];
|
||||
let sword: Vec<Point> = sword.iter().map(|&(x, y)| Point::new(x, y)).collect();
|
||||
let shield: Vec<Point> = shield.iter().map(|&(x, y)| Point::new(x, y)).collect();
|
||||
let potion: Vec<Point> = potion.iter().map(|&(x, y)| Point::new(x, y)).collect();
|
||||
let key: Vec<Point> = key.iter().map(|&(x, y)| Point::new(x, y)).collect();
|
||||
let icons = [
|
||||
("Sword", vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&sword, true)), "#95a5a6"),
|
||||
("Shield", vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&shield, true)), "#3498db"),
|
||||
("Potion", vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&potion, true)), "#9b59b6"),
|
||||
("Key", vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&key, true)), "#f1c40f"),
|
||||
("Gem", shapes::polygon(c, 16.0, 6, 0.0), "#1abc9c"),
|
||||
];
|
||||
let mut doc = Document::new(100.0, 100.0);
|
||||
for (name, path, hex) in icons {
|
||||
let fill = Color::from_hex(hex).unwrap_or(Color::BLACK);
|
||||
let edge = fill.lerp(&Color::BLACK, 0.35);
|
||||
let id = doc.alloc_id();
|
||||
let art = Node::path(id, path, Appearance::basic(Paint::solid(fill), Paint::solid(edge), 1.25));
|
||||
let Ok((art, size, _)) = normalize(art) else { continue };
|
||||
doc.symbols.push(Symbol { dynamic: false, name: name.into(), art: Arc::new(art) });
|
||||
set_natural_size(&mut doc, name, Some(size));
|
||||
}
|
||||
doc.title = "Inventory Icons".into();
|
||||
SymbolLibrary { name: "Inventory Icons".into(), doc }
|
||||
}
|
||||
|
||||
/// Skill Icons: attack, defend, heal, buff and ult marks for RPG/UI art (original, no 9-slice).
|
||||
fn skill_icons() -> SymbolLibrary {
|
||||
let c = Point::ZERO;
|
||||
let attack = [(-18.0, 14.0), (-6.0, 2.0), (-2.0, 6.0), (18.0, -14.0), (14.0, -18.0), (-6.0, -2.0), (-10.0, 2.0), (-18.0, 6.0)];
|
||||
let defend = [(0.0, -18.0), (14.0, -10.0), (12.0, 6.0), (0.0, 18.0), (-12.0, 6.0), (-14.0, -10.0)];
|
||||
let heal = [
|
||||
(-4.0, -18.0),
|
||||
(4.0, -18.0),
|
||||
(4.0, -4.0),
|
||||
(18.0, -4.0),
|
||||
(18.0, 4.0),
|
||||
(4.0, 4.0),
|
||||
(4.0, 18.0),
|
||||
(-4.0, 18.0),
|
||||
(-4.0, 4.0),
|
||||
(-18.0, 4.0),
|
||||
(-18.0, -4.0),
|
||||
(-4.0, -4.0),
|
||||
];
|
||||
let buff = [(0.0, -18.0), (10.0, -2.0), (4.0, -2.0), (4.0, 18.0), (-4.0, 18.0), (-4.0, -2.0), (-10.0, -2.0)];
|
||||
let attack: Vec<Point> = attack.iter().map(|&(x, y)| Point::new(x, y)).collect();
|
||||
let defend: Vec<Point> = defend.iter().map(|&(x, y)| Point::new(x, y)).collect();
|
||||
let heal: Vec<Point> = heal.iter().map(|&(x, y)| Point::new(x, y)).collect();
|
||||
let buff: Vec<Point> = buff.iter().map(|&(x, y)| Point::new(x, y)).collect();
|
||||
let icons = [
|
||||
("Attack", vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&attack, true)), "#e74c3c"),
|
||||
("Defend", vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&defend, true)), "#3498db"),
|
||||
("Heal", vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&heal, true)), "#2ecc71"),
|
||||
("Buff", vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&buff, true)), "#f1c40f"),
|
||||
("Ult", shapes::star(c, 18.0, 7.0, 8, 0.0), "#9b59b6"),
|
||||
];
|
||||
let mut doc = Document::new(100.0, 100.0);
|
||||
for (name, path, hex) in icons {
|
||||
let fill = Color::from_hex(hex).unwrap_or(Color::BLACK);
|
||||
let edge = fill.lerp(&Color::BLACK, 0.35);
|
||||
let id = doc.alloc_id();
|
||||
let art = Node::path(id, path, Appearance::basic(Paint::solid(fill), Paint::solid(edge), 1.25));
|
||||
let Ok((art, size, _)) = normalize(art) else { continue };
|
||||
doc.symbols.push(Symbol { dynamic: false, name: name.into(), art: Arc::new(art) });
|
||||
set_natural_size(&mut doc, name, Some(size));
|
||||
}
|
||||
doc.title = "Skill Icons".into();
|
||||
SymbolLibrary { name: "Skill Icons".into(), doc }
|
||||
}
|
||||
|
||||
/// An instance of symbol `name` (what the resource gathering follows).
|
||||
fn instance(name: &str) -> Node {
|
||||
Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: name.into(), xf: Affine::IDENTITY })
|
||||
@@ -314,7 +451,7 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
// Where the symbols land, worked out on a copy: nothing to add and nothing to place is no edit.
|
||||
let mut probe = (*st.doc).clone();
|
||||
let imported = clip.import_into(&mut probe, &choices, false);
|
||||
let imported = clip.import_into(&mut probe, &choices, false)?;
|
||||
let doc_names: Vec<String> = picked.iter().map(|n| target(&imported.renamed, n)).collect();
|
||||
let centre = st.doc.artboards.first().map(|a| a.rect.center()).unwrap_or(Point::ZERO);
|
||||
let at = if place { Some(Point::new(number_or(p, "x", centre.x, C)?, number_or(p, "y", centre.y, C)?)) } else { None };
|
||||
@@ -322,7 +459,7 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut out = json!({ "library": info.id });
|
||||
if imported.added > 0 || place {
|
||||
let placed = s.edit("Add to Symbols", |d, sel| {
|
||||
let imported = clip.import_into(d, &choices, false);
|
||||
let imported = clip.import_into(d, &choices, false)?;
|
||||
// Every symbol brought in (nested ones too) takes its Symbol Options along.
|
||||
for sym in &clip.symbols {
|
||||
let to = target(&imported.renamed, &sym.name);
|
||||
|
||||
@@ -12,7 +12,7 @@ use super::stroke::StrokeChange;
|
||||
use super::typecmd::refresh_bounds;
|
||||
use super::{
|
||||
CommandSpec, EngineError, Result, Session, bad, bool_or, check_numbers, check_switches, cmd, color_value, has_doc, has_selection, id_param,
|
||||
index, number_or, point_param, str_param, switch_or, targets,
|
||||
index, number_or, point_param, str_param, switch_or, targets, text_param,
|
||||
};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -40,7 +40,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Character",
|
||||
[],
|
||||
None,
|
||||
"{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), story?: bool = false (all the text of every frame of the threaded story `id` is a frame of, in one undo step; no `start` or `end`), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\" (#rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], featureChanges?: [\"tnum\", \"-onum\", …] (OpenType features turned on, or with - off, on top of each character's own; its other features are kept), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings: in vertical type a block of tate-chu-yoko characters moves up by a positive Up/Down and down by a negative one, right by a positive Left/Right and left by a negative one, as one piece with the values of its first character; 0 by default; automatic digit blocks and horizontal type do not move; neither turns tateChuYoko on), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, with Show East Asian Options: in vertical type those half-width characters, such as Latin letters and digits, stand upright one below the other instead of being rotated; spaces and tate-chu-yoko blocks are not changed; kept on the characters and ignored in horizontal type; off by default), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference: the height of the font that size measures, the em box by default; with a font that has no such height, or one outside 0.1 to 2 em, the em box), language?: name|tag (Character panel › Language: one of Document Setup's languages by name or BCP 47 tag, case-insensitive, such as \"Turkish\" or \"tr\"; the characters are shaped in it, so the font uses that language's forms (OpenType `locl`); saved as the tag, English: USA, the default, as none; new type takes the document's language), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs, warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}",
|
||||
"{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), story?: bool = false (all the text of every frame of the threaded story `id` is a frame of, in one undo step; no `start` or `end`), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), fill?: colour|\"none\", stroke?: colour|\"none\" (#rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], featureChanges?: [\"tnum\", \"-onum\", …] (OpenType features turned on, or with - off, on top of each character's own; its other features are kept), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings: in vertical type a block of tate-chu-yoko characters moves up by a positive Up/Down and down by a negative one, right by a positive Left/Right and left by a negative one, as one piece with the values of its first character; on a vertical path a positive Up/Down moves it toward the path's start and a positive Left/Right to the path's left; 0 by default; automatic digit blocks and horizontal type do not move; neither turns tateChuYoko on), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, with Show East Asian Options: in vertical type those half-width characters, such as Latin letters and digits, stand upright one below the other instead of being rotated; spaces and tate-chu-yoko blocks are not changed; kept on the characters and ignored in horizontal type; off by default), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference: the height of the font that size measures, the em box by default; with a font that has no such height, or one outside 0.1 to 2 em, the em box), language?: name|tag (Character panel › Language: one of Document Setup's languages by name or BCP 47 tag, case-insensitive, such as \"Turkish\" or \"tr\"; the characters are shaped in it, so the font uses that language's forms (OpenType `locl`); saved as the tag, English: USA, the default, as none; new type takes the document's language), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs, warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}",
|
||||
has_doc,
|
||||
set_range_style
|
||||
),
|
||||
@@ -49,7 +49,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Touch Type",
|
||||
[],
|
||||
None,
|
||||
"{id, start: byte (a character of it), dx?: pt, dy?: pt, hScale?: %, vScale?: %, rotation?: deg} change one character (a ligature, one glyph for several characters, as a whole) as a Touch Type tool drag does, as one undo step: dx moves it along its line in reading order by changing the kerning of the character before it on the line and its own kerning, and the characters after it stay where they are (an error for the first logical character of a line or at a reading-direction boundary; supported in either reading direction; limited to what kerning from -1000 to 10000 can give); dy raises it (baseline shift, up from its line whatever its rotation, also for the first character of a line and a right-to-left character); dx and dy are document points; hScale (including the object transform), vScale and rotation set its scales and rotation as text.setRangeStyle does. The kerning of the two characters also keeps the character and the ones after it where they were along the line when the font kerned them with their neighbors; a wider or narrower character moves the ones after it by the change in its width → {id, start, end} (the character's bytes)",
|
||||
"{id, start: byte (a character of it), dx?: pt, dy?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright)} change one character (a ligature, one glyph for several characters, as a whole) as a Touch Type tool drag does, as one undo step: dx moves it along its line in reading order by changing the kerning of the character before it on the line and its own kerning, and the characters after it stay where they are (an error for the first logical character of a line or at a reading-direction boundary; supported in either reading direction; limited to what kerning from -1000 to 10000 can give); dy raises it (baseline shift, up from its line whatever its rotation, also for the first character of a line and a right-to-left character); dx and dy are document points; hScale (including the object transform), vScale and rotation set its scales and rotation as text.setRangeStyle does. The kerning of the two characters also keeps the character and the ones after it where they were along the line when the font kerned them with their neighbors; a wider or narrower character moves the ones after it by the change in its width → {id, start, end} (the character's bytes)",
|
||||
has_doc,
|
||||
touch_type
|
||||
),
|
||||
@@ -568,7 +568,8 @@ pub(crate) fn change_font_axes(st: &mut CharStyle, change: &AxisChange) {
|
||||
/// `features: ["dlig", "-liga", …]` → the canonical tag list (differences from the defaults).
|
||||
pub(crate) fn features_param(p: &Value, cmd: &str) -> Result<Option<Vec<String>>> {
|
||||
let Some(v) = p.get("features").filter(|v| !v.is_null()) else { return Ok(None) };
|
||||
let tags: Vec<&str> = v.as_array().ok_or_else(|| bad(cmd, "features must be a list of tags"))?.iter().filter_map(Value::as_str).collect();
|
||||
let not_tags = || bad(cmd, "features must be a list of tags");
|
||||
let tags: Vec<&str> = v.as_array().ok_or_else(not_tags)?.iter().map(|t| t.as_str().ok_or_else(not_tags)).collect::<Result<_>>()?;
|
||||
if let Some(t) = tags.iter().find(|t| !vectorcraft_text::OtFeatures::known_tag(t)) {
|
||||
return Err(bad(
|
||||
cmd,
|
||||
@@ -708,8 +709,8 @@ impl CharChange {
|
||||
stroke_opts.weight = Some(w.clamp(0.0, 1000.0));
|
||||
}
|
||||
let c = Self {
|
||||
font: str_param(p, "font").map(str::to_string),
|
||||
style: str_param(p, "style").map(str::to_string),
|
||||
font: text_param(p, "font", cmd)?.map(str::to_string),
|
||||
style: text_param(p, "style", cmd)?.map(str::to_string),
|
||||
size: num("size"),
|
||||
leading: auto_or_num(p, "leading", cmd)?,
|
||||
tracking: num("tracking"),
|
||||
@@ -1086,12 +1087,16 @@ fn set_range_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
/// The em a laid-out glyph's kerning and tracking are measured in, in text space: the size it
|
||||
/// was shaped at (superscript, small caps and Shrink Text to Fit included).
|
||||
/// was shaped at (superscript, small caps and Shrink Text to Fit included), in horizontal type
|
||||
/// as its Horizontal Scale draws it ([`vectorcraft_text::spacing_scale`]).
|
||||
fn glyph_em(db: &vectorcraft_text::FontDb, t: &TextObject, g: &vectorcraft_text::PositionedGlyph) -> Option<f64> {
|
||||
let face = db.face_by_id(g.font_id)?;
|
||||
let h_scale = edit::slice_runs(&t.runs, g.byte, g.byte + g.len).first()?.style.h_scale / 100.0;
|
||||
let runs = edit::slice_runs(&t.runs, g.byte, g.byte + g.len);
|
||||
let style = &runs.first()?.style;
|
||||
// The glyph's own scale takes in the Horizontal Scale.
|
||||
let h_scale = style.h_scale / 100.0;
|
||||
let [a, b, ..] = g.xf.as_coeffs();
|
||||
Some(a.hypot(b) * face.units_per_em() / h_scale).filter(|em| em.is_finite() && *em > 0.0)
|
||||
Some(a.hypot(b) * face.units_per_em() / h_scale * vectorcraft_text::spacing_scale(style, t.vertical)).filter(|em| em.is_finite() && *em > 0.0)
|
||||
}
|
||||
|
||||
/// The current pair correction in thousandths of an em, including an automatic correction.
|
||||
@@ -1275,19 +1280,25 @@ fn set_glyph_alternate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// out (overflowing text) is shaped alone in its writing direction, with the rest of its grapheme
|
||||
// in the grapheme's own styles.
|
||||
let plain = t.plain_text();
|
||||
let unchosen = holder.as_ref().filter(|_| !styled).and_then(|_| {
|
||||
let mut probe = t.clone();
|
||||
edit::style_range(&mut probe.runs, bytes.start, bytes.end, |st| st.glyph_alternate = None);
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &probe);
|
||||
own_glyph(&plain, &lay, ch, &bytes, &face).unwrap_or_else(|| shaped_alone(&probe, ch, &bytes, &face).0)
|
||||
});
|
||||
let mut probe = t.clone();
|
||||
edit::style_range(&mut probe.runs, bytes.start, bytes.end, |st| st.glyph_alternate = None);
|
||||
let probe_lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &probe);
|
||||
let unchosen = holder
|
||||
.as_ref()
|
||||
.filter(|_| !styled)
|
||||
.and_then(|_| own_glyph(&plain, &probe_lay, ch, &bytes, &face).unwrap_or_else(|| shaped_alone(&probe, ch, &bytes, &face).0));
|
||||
let substituted = unchosen.is_some_and(|g| g != default);
|
||||
let choice = match gid.filter(|g| *g != default || substituted) {
|
||||
None => None,
|
||||
Some(g) if g == default || face.alternates(ch).iter().any(|a| a.gid == g) => Some(face.choice(ch, g)),
|
||||
Some(g) => return Err(bad(C, format!("glyph {g} is not an alternate of {ch} in {} {}", face.family, face.style))),
|
||||
};
|
||||
let drawn = choice.as_ref().map_or(unchosen.unwrap_or(default), |c| c.gid);
|
||||
// Without a choice, the glyph drawn in whatever face draws the character, as
|
||||
// `text.glyphAlternates` reports it (a fallback face's glyph, the capital of All Caps).
|
||||
let drawn = match &choice {
|
||||
Some(c) => c.gid,
|
||||
None => drawn_glyph(&plain, &probe_lay, ch, &bytes, &face).or_else(|| shaped_alone(&probe, ch, &bytes, &face).1).unwrap_or(default),
|
||||
};
|
||||
// The run holding the character. The request changes nothing when the choice is the one the
|
||||
// character's run holds and that run holds the character alone; otherwise the edit splits the
|
||||
// run, so that the following text does not take the character into a ligature.
|
||||
@@ -1310,6 +1321,16 @@ fn set_glyph_alternate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
if held == choice && (choice.is_none() || alone) {
|
||||
// The text is left as it is, so the glyph is the one the unchanged text draws. The probe
|
||||
// splits a run that holds an earlier character's choice, and the split can break a ligature
|
||||
// that takes in this character.
|
||||
let drawn = match &choice {
|
||||
None if probe.runs != t.runs => {
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
|
||||
drawn_glyph(&plain, &lay, ch, &bytes, &face).or_else(|| shaped_alone(&t, ch, &bytes, &face).1).unwrap_or(default)
|
||||
}
|
||||
_ => drawn,
|
||||
};
|
||||
return Ok(json!({"id": id.0, "start": bytes.start, "end": bytes.end, "gid": drawn}));
|
||||
}
|
||||
s.edit("Glyphs", |d, _| {
|
||||
@@ -1602,6 +1623,7 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
path_effect: Default::default(),
|
||||
path_align: Default::default(),
|
||||
path_spacing: 0.0,
|
||||
dynamic_text: false,
|
||||
wrap: Vec::new(),
|
||||
optical_margin: false,
|
||||
anti_alias: Default::default(),
|
||||
@@ -1893,6 +1915,7 @@ fn headline_tracking(t: &TextObject, target: f64) -> Option<f64> {
|
||||
path_effect: Default::default(),
|
||||
path_align: Default::default(),
|
||||
path_spacing: 0.0,
|
||||
dynamic_text: false,
|
||||
wrap: Vec::new(),
|
||||
optical_margin: false,
|
||||
anti_alias: Default::default(),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user