Merge main into fix/plugin-grant-module-hash

This commit is contained in:
dotCooCoo
2026-11-08 13:46:32 -08:00
43 changed files with 938 additions and 35 deletions
+1 -1
View File
@@ -263,7 +263,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Symbol Instance Appearance",
[],
None,
"{id?|ids?: [one id] (default: the one selected object), path: [i…] (the object in the symbol's art: child indices in paint order from the art's root down through its groups; [] the root), fill?: \"#rrggbb\"|{…paint params of paint.setFill: color, none, swatch, tint, gradient, keepModel}|null (null: the symbol's own again), stroke?: as fill, opacity?: 0..100|null, blend?: name|null} change the appearance of one object of a dynamic symbol instance's art, for that instance only: it stays linked, so editing the symbol reshapes it and keeps the change; refused for a static symbol or a path naming no object → {id, overrides: how many objects the instance changes}",
"{id?|ids?: [one id] (default: the one selected object), path: [i…] (the object in the symbol's art: child indices in paint order from the art's root down through its groups; [] the root), fill?: \"#rrggbb\"|{…paint params of paint.setFill: color, none, swatch, tint, gradient, keepModel}|null (null: the symbol's own again), stroke?: as fill, opacity?: 0..100|null, blend?: name|null} change the appearance of one object of a dynamic symbol instance's art, for that instance only: it stays linked, so editing the symbol reshapes it and keeps the change; refused for a static symbol, a path naming no object, or a fill or stroke other than null for an object that does not draw it (a guide, blend, envelope, Repeat, Intertwine group, gradient mesh or placed document; a symbol instance's stroke, and its fill unless a solid color or none, which stains its art; an image's fill or stroke without a Convert to Shape or Outline Object effect), with nothing of the call applied; opacity, blend and null are taken for every object → {id, overrides: how many objects the instance changes}",
has_selection,
symbol_instance_set_appearance
),
+60
View File
@@ -129,6 +129,64 @@ fn path_param(p: &Value, art: &Node, cmd: &str) -> Result<Vec<u32>> {
Ok(path)
}
/// The refusal of `key` (`fill` or `stroke`) for `n`, an object of a dynamic symbol's art, when `n`
/// does not draw that paint of its own; `None` when it does. Paths (not guides),
/// compound paths, type, groups and layers draw them; a symbol instance draws its fill as a stain
/// on its art ([`solid_fill_check`]) and no stroke; an image draws one only when that fill or
/// 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 { .. } | 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) };
if effects.is_some_and(|e| vectorcraft_render::effects::gives_image_shape(e)) {
return None;
}
bad(cmd, format!("{key} cannot be set on an image without a Convert to Shape or Outline Object effect on it"))
}
NodeKind::SymbolInstance { .. } if key == "fill" => return None,
NodeKind::SymbolInstance { .. } => bad(cmd, "stroke cannot be set on a symbol instance"),
NodeKind::Blend { .. } => bad(cmd, format!("{key} cannot be set on a blend")),
NodeKind::Envelope { .. } => bad(cmd, format!("{key} cannot be set on an envelope")),
NodeKind::Mesh(_) => bad(cmd, format!("{key} cannot be set on a gradient mesh")),
NodeKind::Repeat(_) => bad(cmd, format!("{key} cannot be set on a Repeat")),
NodeKind::PlacedDocument(_) => bad(cmd, format!("{key} cannot be set on a placed document")),
NodeKind::Intertwine { .. } => bad(cmd, format!("{key} cannot be set on an Intertwine group")),
})
}
/// Refuse a `fill` or `stroke` other than null that the object at `path` of `art` does not draw
/// ([`paint_refusal`]); null, which takes a change back, is taken for every object.
fn check_paint_kinds(p: &Value, art: &Node, path: &[u32], cmd: &str) -> Result<()> {
let Some(n) = symbol_overrides::part(art, path) else { return Ok(()) };
for key in ["fill", "stroke"] {
if p.get(key).is_some_and(|v| !v.is_null())
&& let Some(e) = paint_refusal(n, key, cmd)
{
return Err(e);
}
}
Ok(())
}
/// Refuse `fill`, the paint `p` asks for the object at `path` of `art`, when that object is a
/// symbol instance and `fill` is a gradient or a pattern: an instance stains its art with a solid
/// fill only, so such a fill draws nothing.
fn solid_fill_check(p: &Value, art: &Node, path: &[u32], fill: Option<&vectorcraft_color::Paint>, cmd: &str) -> Result<()> {
let asked = p.get("fill").is_some_and(|v| !v.is_null());
let is_instance = symbol_overrides::part(art, path).is_some_and(|n| matches!(n.kind, NodeKind::SymbolInstance { .. }));
let draws_nothing = |f: &vectorcraft_color::Paint| match f {
vectorcraft_color::Paint::Gradient(_) => true,
vectorcraft_color::Paint::Pattern { .. } => true,
vectorcraft_color::Paint::None | vectorcraft_color::Paint::Solid { .. } => false,
};
if asked && is_instance && fill.is_some_and(draws_nothing) {
return Err(bad(cmd, "fill on a symbol instance must be a solid color or none"));
}
Ok(())
}
/// `fill` or `stroke` of `p` for override `o`: absent leaves it, null clears it, a colour string or
/// an object of `paint.setFill`'s paint params sets it.
fn paint_change(s: &Session, p: &Value, key: &str, cmd: &str) -> Result<Option<Option<vectorcraft_color::Paint>>> {
@@ -170,9 +228,11 @@ pub(super) fn symbol_instance_set_appearance(s: &mut Session, p: &Value) -> Resu
}
let (id, art) = dynamic_instance(s, p, C)?;
let path = path_param(p, &art, C)?;
check_paint_kinds(p, &art, &path, C)?;
let d = &s.doc()?.doc;
let old = d.node(id).and_then(|n| n.extras().symbol_overrides.iter().find(|o| o.path == path).cloned());
let new = changed(s, p, old.unwrap_or_else(|| SymbolOverride { path: path.clone(), ..Default::default() }), C)?;
solid_fill_check(p, &art, &path, new.fill.as_ref(), C)?;
let count = edit_part(s, id, &path, "Symbol Instance Appearance", |o| *o = new)?;
Ok(json!({ "id": id.0, "overrides": count }))
}
+309
View File
@@ -4,6 +4,7 @@
//! Fill or Stroke changes it.
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Node, NodeKind};
use vectorcraft_geom::Rect;
use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
@@ -107,6 +108,314 @@ fn static_symbols_and_bad_targets_are_refused() {
assert_eq!(overrides(&s, inst), 0);
}
fn undo_len(s: &Session) -> usize {
s.doc().unwrap().history.undo.len()
}
/// The frontmost object of the active layer.
fn top(s: &Session) -> NodeId {
let d = &s.doc().unwrap().doc;
d.node(d.default_layer().unwrap()).unwrap().children().unwrap().last().unwrap().id
}
/// Every pixel of the page.
fn page(s: &Session) -> Vec<[u8; 4]> {
let r = vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, Rect::new(0.0, 0.0, 200.0, 100.0), 1.0, true);
(0..r.height).flat_map(|y| (0..r.width).map(move |x| (x, y))).map(|(x, y)| r.pixel(x, y)).collect()
}
/// A dynamic symbol "Art" of the frontmost object, made in place: its instance.
fn art_of_top(s: &mut Session) -> NodeId {
let t = top(s);
id(&s.execute("symbol.new", &json!({"name": "Art", "ids": [t.0], "type": "dynamic"})).unwrap())
}
/// Put a red fill and stroke on the root of instance `inst`'s art directly, past the command.
fn paint_root_red(s: &mut Session, inst: NodeId) {
let red = Paint::solid(Color::rgb(1.0, 0.0, 0.0));
s.edit("paint", |d, _| {
let o = vectorcraft_doc::symbol_overrides::SymbolOverride { path: vec![], fill: Some(red.clone()), stroke: Some(red), ..Default::default() };
d.node_mut(inst).ok_or(EngineError::NoNode(inst))?.extras_mut().symbol_overrides.push(o);
Ok(())
})
.unwrap();
}
/// A 30×30 red image placed with its frame from (50, 20) to (80, 50).
fn place_image(s: &mut Session) {
let png = crate::tests_links::png(30, 30, [255, 0, 0]);
let data = vectorcraft_format::base64_encode(&png);
s.execute("file.place", &json!({"name": "red.png", "dataBase64": data, "at": [65, 35], "link": false})).unwrap();
}
fn select_top(s: &mut Session, n: usize) {
let d = &s.doc().unwrap().doc;
let kids = d.node(d.default_layer().unwrap()).unwrap().children().unwrap();
let ids: Vec<u64> = kids[kids.len() - n..].iter().map(|c| c.id.0).collect();
s.execute("select.set", &json!({ "ids": ids })).unwrap();
}
#[test]
fn objects_that_draw_no_fill_or_stroke_of_their_own_refuse_one() {
let folder = crate::tests_links::Folder::new("dynamic-symbol-kinds");
let source = folder.file("placed.vectorcraft");
let mut doc = vectorcraft_doc::Document::new(40.0, 40.0);
let layer = doc.layers[0].id;
let look = vectorcraft_doc::Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), Paint::None, 0.0);
let rect = vectorcraft_doc::Node::path(doc.alloc_id(), vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 40.0)), look);
doc.insert(Some(layer), 0, rect).unwrap();
crate::tests_links::write(&source, &vectorcraft_format::save_file(&doc));
type Make = Box<dyn Fn(&mut Session)>;
let cases: Vec<(&str, &str, Make)> = vec![
(
"guide",
"a guide",
Box::new(|s| {
square(s, 10.0);
select_top(s, 1);
s.execute("view.guides.make", &json!({})).unwrap();
}),
),
(
"blend",
"a blend",
Box::new(|s| {
let (a, b) = (square(s, 10.0), square(s, 60.0));
s.execute("object.blend.make", &json!({"ids": [a.0, b.0], "steps": 1})).unwrap();
}),
),
(
"envelope",
"an envelope",
Box::new(|s| {
square(s, 10.0);
select_top(s, 1);
s.execute("object.envelope.makeWithWarp", &json!({"style": "arch", "bend": 50})).unwrap();
}),
),
("radial Repeat", "a Repeat", Box::new(|s| repeat(s, "object.repeat.radial"))),
("grid Repeat", "a Repeat", Box::new(|s| repeat(s, "object.repeat.grid"))),
("mirror Repeat", "a Repeat", Box::new(|s| repeat(s, "object.repeat.mirror"))),
(
"Intertwine",
"an Intertwine group",
Box::new(|s| {
square(s, 10.0);
square(s, 30.0);
select_top(s, 2);
s.execute("object.intertwine.make", &json!({})).unwrap();
}),
),
(
"gradient mesh",
"a gradient mesh",
Box::new(|s| {
let r = square(s, 10.0);
s.execute("object.mesh.create", &json!({"ids": [r.0], "rows": 1, "cols": 1})).unwrap();
}),
),
("image", "an image", Box::new(place_image)),
(
"placed document",
"a placed document",
Box::new(move |s| {
let r = s.execute("file.place", &json!({"path": source, "at": [40, 40]})).unwrap();
assert_eq!(r["linked"], true, "{r}");
}),
),
];
// Every case runs, and the failure lists each object that fails.
let mut failures = Vec::new();
for (label, kind, make) in cases {
let mut s = session();
make(&mut s);
let inst = art_of_top(&mut s);
if let Err(why) = refuses_own_paint(&mut s, inst, kind) {
failures.push(format!("{label}: {why}"));
}
}
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
/// Check that the root of instance `inst`'s art, an object of `kind` that draws no fill or stroke
/// of its own, refuses both, takes null, opacity and blend, and draws no stored fill or stroke.
fn refuses_own_paint(s: &mut Session, inst: NodeId, kind: &str) -> std::result::Result<(), String> {
let set = |s: &mut Session, p: &Value| s.execute("symbol.instance.setAppearance", p);
let undo = undo_len(s);
for key in ["fill", "stroke"] {
for paint in [json!("#ff0000"), json!({"none": true})] {
// With a valid opacity too: nothing of the call is applied.
let p = json!({"id": inst.0, "path": [], key: paint, "opacity": 50});
match set(s, &p) {
Ok(_) => return Err(format!("{p} was taken")),
Err(e) if !e.to_string().contains(&format!("{key} cannot be set on {kind}")) => return Err(format!("{p}: {e}")),
Err(_) => {}
}
}
}
if (overrides(s, inst), undo_len(s)) != (0, undo) {
return Err("a refused call changed the instance or added an undo step".into());
}
// Null (taking a change back), opacity and blend are taken.
let clear = json!({"id": inst.0, "path": [], "fill": null, "stroke": null});
for p in [clear, json!({"id": inst.0, "path": [], "opacity": 50, "blend": "multiply"})] {
set(s, &p).map_err(|e| format!("{p}: {e}"))?;
}
if overrides(s, inst) != 1 {
return Err("opacity and blend were not stored".into());
}
// The renderer draws no fill or stroke of the object's own: a stored one changes nothing.
let before = page(s);
paint_root_red(s, inst);
if page(s) != before {
return Err("a stored fill and stroke changed the canvas".into());
}
Ok(())
}
fn repeat(s: &mut Session, cmd: &str) {
square(s, 10.0);
select_top(s, 1);
s.execute(cmd, &json!({})).unwrap();
}
#[test]
fn a_symbol_instance_in_the_art_takes_a_fill_that_stains_it_and_no_stroke() {
let mut s = session();
pair(&mut s, "static");
let inst = art_of_top(&mut s);
let undo = undo_len(&s);
let e = s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [], "stroke": "#ff0000"})).unwrap_err().to_string();
assert!(e.contains("stroke cannot be set on a symbol instance"), "{e}");
assert_eq!((overrides(&s, inst), undo_len(&s)), (0, undo));
// A stroke stored past the command draws nothing.
let before = page(&s);
let red = Paint::solid(Color::rgb(1.0, 0.0, 0.0));
s.edit("paint", |d, _| {
let o = vectorcraft_doc::symbol_overrides::SymbolOverride { path: vec![], stroke: Some(red), ..Default::default() };
d.node_mut(inst).ok_or(EngineError::NoNode(inst))?.extras_mut().symbol_overrides.push(o);
Ok(())
})
.unwrap();
assert!(page(&s) == before, "a stroke on a symbol instance draws nothing");
// The fill stains the nested instance's art.
s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [], "fill": "#ff0000"})).unwrap();
assert_eq!(pixel(&s, 30, 30), RED, "the fill stains the art");
}
#[test]
fn a_symbol_instance_in_the_art_refuses_a_gradient_fill() {
let mut s = session();
pair(&mut s, "static");
let inst = art_of_top(&mut s);
let undo = undo_len(&s);
let gradient = json!({"stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#00ff00"}]});
let p = json!({"id": inst.0, "path": [], "fill": {"gradient": gradient}, "opacity": 50});
let e = s.execute("symbol.instance.setAppearance", &p).unwrap_err().to_string();
assert!(e.contains("fill on a symbol instance must be a solid color or none"), "{e}");
assert_eq!((overrides(&s, inst), undo_len(&s)), (0, undo), "nothing changes and no undo step");
// A gradient fill stored past the command draws nothing.
let before = page(&s);
let paint = Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient::default())));
s.edit("paint", |d, _| {
let o = vectorcraft_doc::symbol_overrides::SymbolOverride { path: vec![], fill: Some(paint), ..Default::default() };
d.node_mut(inst).ok_or(EngineError::NoNode(inst))?.extras_mut().symbol_overrides.push(o);
Ok(())
})
.unwrap();
assert!(page(&s) == before, "a gradient fill on a symbol instance draws nothing");
// A solid fill and none are taken.
s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [], "fill": {"none": true}})).unwrap();
s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [], "fill": "#ff0000"})).unwrap();
assert_eq!(pixel(&s, 30, 30), RED);
}
#[test]
fn a_symbol_instance_in_the_art_refuses_a_pattern_fill() {
let mut s = session();
let tile = id(&s.execute("shape.rectangle", &json!({"x": 150, "y": 60, "width": 10, "height": 10})).unwrap());
s.execute("object.pattern.make", &json!({"ids": [tile.0], "name": "Dots", "edit": false})).unwrap();
pair(&mut s, "static");
let inst = art_of_top(&mut s);
let undo = undo_len(&s);
let p = json!({"id": inst.0, "path": [], "fill": {"swatch": "Dots"}, "opacity": 50});
let e = s.execute("symbol.instance.setAppearance", &p).unwrap_err().to_string();
assert!(e.contains("fill on a symbol instance must be a solid color or none"), "{e}");
assert_eq!((overrides(&s, inst), undo_len(&s)), (0, undo), "nothing changes and no undo step");
// The pattern draws on a path.
let probe = square(&mut s, 110.0);
let before = page(&s);
s.execute("paint.setFill", &json!({"ids": [probe.0], "swatch": "Dots"})).unwrap();
assert!(page(&s) != before, "the pattern draws on a path");
// A pattern fill stored on the nested instance past the command draws nothing.
let before = page(&s);
let paint = s.doc().unwrap().doc.swatch("Dots").unwrap().paint.clone();
assert!(matches!(paint, Paint::Pattern { .. }), "{paint:?}");
s.edit("paint", |d, _| {
let o = vectorcraft_doc::symbol_overrides::SymbolOverride { path: vec![], fill: Some(paint), ..Default::default() };
d.node_mut(inst).ok_or(EngineError::NoNode(inst))?.extras_mut().symbol_overrides.push(o);
Ok(())
})
.unwrap();
assert!(page(&s) == before, "a pattern fill on a symbol instance draws nothing");
}
#[test]
fn null_takes_back_an_old_change_an_object_does_not_draw() {
let mut s = session();
let (a, b) = (square(&mut s, 10.0), square(&mut s, 60.0));
s.execute("object.blend.make", &json!({"ids": [a.0, b.0], "steps": 1})).unwrap();
let inst = art_of_top(&mut s);
// A change stored before the command refused one, as an older document may hold.
paint_root_red(&mut s, inst);
s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [], "fill": null})).unwrap();
assert!(node(&s, inst).extras().symbol_overrides[0].fill.is_none());
s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [], "stroke": null})).unwrap();
assert_eq!(overrides(&s, inst), 0);
}
#[test]
fn paths_type_groups_and_shaped_image_paint_take_a_fill_and_stroke() {
let set = |s: &mut Session, inst: NodeId, path: Value| {
s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": path, "fill": "#ff0000", "stroke": "#ff0000"})).unwrap()
};
// A group's own fill paints over its members.
let mut s = session();
let inst = pair(&mut s, "dynamic");
assert_eq!(set(&mut s, inst, json!([]))["overrides"], 1);
assert_eq!(pixel(&s, 30, 30), RED, "the group's own fill");
// A compound path, type and a clipping path.
let mut s = session();
let (a, b) = (square(&mut s, 10.0), square(&mut s, 60.0));
s.execute("object.compoundPath.make", &json!({"ids": [a.0, b.0]})).unwrap();
let inst = art_of_top(&mut s);
set(&mut s, inst, json!([]));
assert_eq!(pixel(&s, 30, 30), RED, "the compound path");
let mut s = session();
s.execute("text.create", &json!({"x": 10, "y": 40, "text": "Hi"})).unwrap();
let inst = art_of_top(&mut s);
set(&mut s, inst, json!([]));
let mut s = session();
let (a, b) = (square(&mut s, 10.0), square(&mut s, 20.0));
s.execute("object.clippingMask.make", &json!({"ids": [a.0, b.0]})).unwrap();
let inst = art_of_top(&mut s);
assert!(matches!(&node(&s, inst).kind, NodeKind::SymbolInstance { .. }));
set(&mut s, inst, json!([0]));
set(&mut s, inst, json!([]));
// An image whose fill has Convert to Shape: the fill paints 9 pt past its frame.
let mut s = session();
place_image(&mut s);
s.execute("appearance.addFill", &json!({})).unwrap();
s.execute("paint.setFill", &json!({"color": "#00ff00", "item": 0})).unwrap();
s.execute("effect.apply", &json!({"effect": "convertToShape.rectangle", "item": 0})).unwrap();
assert_eq!(pixel(&s, 44, 35), [0, 255, 0, 255]);
let inst = art_of_top(&mut s);
let e = s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [], "stroke": "#ff0000"})).unwrap_err().to_string();
assert!(e.contains("stroke cannot be set on an image without a Convert to Shape or Outline Object effect on it"), "{e}");
s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [], "fill": "#ff0000"})).unwrap();
assert_eq!(pixel(&s, 44, 35), RED, "the shaped fill takes the instance's color");
}
#[test]
fn redefining_the_symbol_keeps_the_changes_whose_objects_remain() {
let mut s = session();
+19
View File
@@ -461,6 +461,25 @@ fn svg_export_writes_the_filter_when_it_is_the_last_effect() {
assert!(!out.contains("<feOffset dx=\"3\""), "{out}");
}
/// A filter at the size limit whose written form, with the id SVG export gives it, would be longer
/// than SVG import reads is exported as an image, which opens again as the canvas draws it.
#[test]
fn svg_export_draws_a_filter_too_long_to_read_back_as_an_image() {
let (mut s, id) = blue_square();
let (open, close) =
(r##"<filter id="r"><feFlood flood-color="#ff0000" result=""##, r#""/><feComposite in2="SourceGraphic" operator="in"/></filter>"#);
let xml = format!("{open}{}{close}", "a".repeat(vectorcraft_doc::SvgFilter::MAX_XML - open.len() - close.len()));
run(&mut s, "effect.apply", json!({"effect": "svg.filter", "ids": [id.0], "params": {"xml": xml}}));
assert_eq!(&render(&s).pixel(100, 100)[..3], &[255, 0, 0]);
let out = svg(&mut s);
assert!(out.contains("<image") && !out.contains("<filter"), "{} bytes", out.len());
let (back, warnings) = vectorcraft_svg::import_with_report(&out).unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
let again = vectorcraft_render::Renderer::new().render_region(&back, vectorcraft_geom::Rect::new(0.0, 0.0, 200.0, 200.0), 1.0, true);
let px = again.pixel(100, 100);
assert!(px[0] > 240 && px[1] < 15 && px[2] < 15, "{px:?}");
}
/// A filter SVG 1.1 can't hold (a blend mode SVG 1.1 lacks) is exported as an image, as the canvas
/// draws it; an SVG 1.1 one stays a filter.
#[test]