Plug-ins: unlinking a gradient stop's spot swatch also clears its plates

A filter or importer that changes a gradient paint unlinks each stop from
its swatch and resets its tint, but left the stop's process plates. The
PDF and EPS writers read plates only with a spot swatch, so the stop kept
a plates array with no ink, saved with the document and compared in later
edits. Unlinking now clears the plates too, in fills and strokes; a paint
returned unchanged keeps its link, tint and plates.

The plate clamping test now checks the decoded output, before admission.
This commit is contained in:
dotCooCoo
2026-11-13 19:56:38 -08:00
parent 1e4e3534f5
commit 7c40cd2df2
4 changed files with 67 additions and 12 deletions
+3 -3
View File
@@ -951,7 +951,8 @@ fn paints(ap: &Appearance, fill: bool) -> Vec<&Paint> {
}
/// `p` without swatch links: a plug-in's colours are its own, so only paints it returned
/// unchanged keep theirs.
/// unchanged keep theirs. A gradient stop also loses its process plates, which print only with
/// a spot swatch.
fn unlinked(mut p: Paint) -> Paint {
match &mut p {
Paint::Solid { swatch, tint, .. } => {
@@ -961,8 +962,7 @@ fn unlinked(mut p: Paint) -> Paint {
Paint::Gradient(g) => {
g.swatch = None;
for s in &mut g.gradient.stops {
s.swatch = None;
s.tint = 1.0;
s.set_color(s.color, None);
}
}
Paint::None | Paint::Pattern { .. } => {}
+61 -7
View File
@@ -109,27 +109,81 @@ fn only_a_radial_keeps_a_focal_point_through_a_plug_in() {
}
}
/// A gradient stop's process plates handed back out of range are clamped to 0..1, each in its
/// A gradient stop's process plates handed back out of range are read clamped to 0..1, each in its
/// place, in a fill and in a stroke (a JSON number too large for `f32` is infinite); plates in range
/// and a stop without plates are kept.
#[test]
fn gradient_stop_plates_are_clamped_through_a_plug_in() {
use vectorcraft_color::{Gradient, GradientKind, GradientPaint, GradientStop};
let (mut d, ids, _) = doc();
let (_, ids, _) = doc();
let stops = vec![GradientStop::new(0.0, Color::WHITE), GradientStop::new(0.5, Color::gray(0.5)), GradientStop::new(1.0, Color::BLACK)];
let mut p = serde_json::to_value(Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops))))).unwrap();
p["gradient"]["stops"][0]["plates"] = json!([-0.5, 1.5, -1e300, 1e300]);
p["gradient"]["stops"][1]["plates"] = json!([0.25, 2.0, 0.75, -3.0]);
for (i, plates) in [(0, json!([-0.5, 1.5, -1e300, 1e300])), (1, json!([0.25, 2.0, 0.75, -3.0]))] {
p["gradient"]["stops"][i]["plates"] = plates;
p["gradient"]["stops"][i]["swatch"] = json!("Spot");
}
let out = json!({"objects": [{"id": ids[0].0, "fills": [p.clone()], "strokes": [{"paint": p}]}]});
apply_output(&mut d, &ids, decode_json(out).unwrap(), None, &keep).unwrap();
let ap = &d.node(ids[0]).unwrap().appearance;
for (what, paint) in [("fill", ap.fill_paint()), ("stroke", ap.stroke_paint())] {
let out = decode_json(out).unwrap();
let fill = &out[0].fills.as_ref().unwrap()[0];
let stroke = &out[0].strokes.as_ref().unwrap()[0].paint;
for (what, paint) in [("fill", fill), ("stroke", stroke)] {
let Paint::Gradient(g) = paint else { panic!("a gradient {what}") };
let plates: Vec<_> = g.gradient.stops.iter().map(|s| s.plates).collect();
assert_eq!(plates, [Some([0.0, 1.0, 0.0, 1.0]), Some([0.25, 1.0, 0.75, 0.0]), None], "{what}");
}
}
/// A gradient linked to a gradient swatch, whose first stop is linked to a spot swatch at a tint
/// with process plates.
fn spot_gradient(color: Color) -> Paint {
use vectorcraft_color::{Gradient, GradientKind, GradientPaint, GradientStop};
let mut spot = GradientStop::new(0.0, color);
spot.midpoint = 0.3;
spot.opacity = 0.75;
spot.set_color(color, Some(("Spot".into(), 0.4)));
spot.plates = Some([0.1, 0.2, 0.3, 0.4]);
let mut g = GradientPaint::new(Gradient::new(GradientKind::Linear, vec![spot, GradientStop::new(1.0, Color::BLACK)]));
g.swatch = Some("Blend".into());
Paint::Gradient(Box::new(g))
}
/// A changed gradient handed back loses each stop's spot link, tint and process plates, in a fill
/// and in a stroke, and keeps the stop's color, offset, opacity and midpoint; a new stop handed back
/// with plates and no link has none.
#[test]
fn changed_gradient_fill_and_stroke_drop_spot_plates() {
let (mut d, ids, _) = doc();
let a = ids[0];
let was = Color::cmyk(0.0, 0.5, 1.0, 0.0);
d.node_mut(a).unwrap().appearance.set_fill(spot_gradient(was));
d.node_mut(a).unwrap().appearance.set_stroke(spot_gradient(was));
let now = Color::cmyk(0.0, 0.25, 0.5, 0.0);
let mut p = serde_json::to_value(spot_gradient(now)).unwrap();
p["gradient"]["stops"][1]["plates"] = json!([0.5, 0.5, 0.5, 0.5]);
let out = json!({"objects": [{"id": a.0, "fills": [p.clone()], "strokes": [{"paint": p}]}]});
apply_output(&mut d, &[a], decode_json(out).unwrap(), None, &keep).unwrap();
let ap = &d.node(a).unwrap().appearance;
for (what, paint) in [("fill", ap.fill_paint()), ("stroke", ap.stroke_paint())] {
let Paint::Gradient(g) = paint else { panic!("a gradient {what}") };
assert_eq!(g.swatch, None, "{what}");
let stops: Vec<_> = g.gradient.stops.iter().map(|s| (s.offset, s.color, s.opacity, s.midpoint, s.swatch.clone(), s.tint, s.plates)).collect();
assert_eq!(stops, [(0.0, now, 0.75, 0.3, None, 1.0, None), (1.0, Color::BLACK, 1.0, 0.5, None, 1.0, None)], "{what}");
}
}
/// A gradient handed back as it was keeps its stops' spot links, tints and process plates.
#[test]
fn unchanged_gradient_paint_keeps_spot_plates() {
let (mut d, ids, _) = doc();
let a = ids[0];
d.node_mut(a).unwrap().appearance.set_fill(spot_gradient(Color::cmyk(0.0, 0.5, 1.0, 0.0)));
d.node_mut(a).unwrap().appearance.set_stroke(spot_gradient(Color::cmyk(0.0, 0.5, 1.0, 0.0)));
let input: Value = serde_json::from_slice(&encode_input(&d, &[a])).unwrap();
let before = d.clone();
apply_output(&mut d, &[a], decode(&serde_json::to_vec(&input).unwrap()).unwrap(), None, &keep).unwrap();
assert_eq!(d, before);
}
/// A plate that is not a number is 0; infinities go to the nearest end of 0..1.
#[test]
fn plates_that_are_not_finite_are_in_range() {
+2 -2
View File
@@ -617,7 +617,7 @@ An **importer** gets the bytes of the file being opened, as they are (not JSON),
`paint`, `width` in points, `join` (`"miter"`, `"round"` or `"bevel"`), `cap` (`"butt"`, `"round"` or `"square"`)
and `miterLimit`. Paints use the native file format's form: `{"type": "solid", "color", "swatch"?, "tint"?}`,
`{"type": "gradient", "gradient": {"kind": "Linear"|"Radial"|"Freeform", "stops": [{offset, color, opacity,
midpoint}], "dither"?, "method"?: "Perceptual"|"HueClockwise"|"HueCounterclockwise"}, "geom"?, "angle", "freeform"?}`, `{"type": "pattern", "pattern",
midpoint, swatch?, tint?, plates?}], "dither"?, "method"?: "Perceptual"|"HueClockwise"|"HueCounterclockwise"}, "geom"?, "angle", "freeform"?}`, `{"type": "pattern", "pattern",
"xf"?}`. Colours are `{"model": "rgb", r, g, b}`, `{"model": "cmyk", c, m, y, k}`, `{"model": "gray", k}` (ink: 0
white, 1 black) or `{"model": "lab", l, a, b}`; components 0–1 except Lab's.
- `effects` (filters): the object's own effects in order, as its effect records: `id` (as `effect.list` lists it, or
@@ -742,7 +742,7 @@ most 100,000 objects and 4,000,000 anchors, 256 fills, strokes or gradient stops
clamped into range, stroke weights to 0–1,000 pt and miter limits to 1–500; a pattern must exist in the document. A
gradient stop's four process `plates` are clamped to 0–1 as well: a value that is not a number becomes 0, and an
infinite one becomes 0 or 1 by its sign. A paint returned unchanged keeps its swatch link; any other paint is
unlinked. One refused effect record refuses the whole output.
unlinked, and each of its gradient stops then has no swatch, a tint of 1 and no `plates`. One refused effect record refuses the whole output.
A filter's output may also hold `documentData`: any JSON of at most 1 MiB, which replaces what the plug-in keeps with
the document (`null` removes it), in the same undo step as its objects. Settings that belong to the whole document
+1
View File
@@ -1929,6 +1929,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Live-effect plug-ins before a fill's or stroke's own 3D effect:** a live effect listed before a fill's or stroke's own Extrude & Bevel, Inflate or Rotate effect is given `_context.item` for that fill or stroke (`{"fill": i}` or `{"stroke": i}`), not `"object"`, in the drawn art, in the 3D meshes and in Expand as Wireframes; a deferred stage there is listed at that fill's or stroke's place. Regressions: `crates/effects/src/tests/solidcontext.rs`. Scores and hours unchanged.
- **Filter plug-ins set effects and stroke styles (G17):** a filter is given each object's effect records and its strokes' `join`, `cap` and `miterLimit`, and may return them; the records are admitted as `effect.apply` admits them (a known effect or installed live-effect plug-in, parameters it takes, one 3D effect that fits, no texture read from a file), and one refused refuses the output. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/plugins/src/objects/tests.rs`. Scores and hours unchanged.
- **Gradient stop plates from filter plug-ins are clamped (G17):** the four process `plates` of a gradient stop in a filter's or importer's output are clamped to 0–1, as its other colors are; a value that is not a number becomes 0, and an infinite one becomes 0 or 1 by its sign. Painted live effects still refuse such a stop. Regressions: `crates/plugins/src/objects/tests.rs`. Scores and hours unchanged.
- **Changed plug-in gradients lose spot plates (G17):** a gradient that a filter or importer changes loses each stop's swatch link, tint and process `plates` together, in fills and strokes, and keeps the stop's color, offset, opacity and midpoint; a gradient returned unchanged keeps all three. Regressions: `crates/plugins/src/objects/tests.rs` (`changed_gradient_fill_and_stroke_drop_spot_plates`, `unchanged_gradient_paint_keeps_spot_plates`). Scores and hours unchanged.
- **Plug-in data kept with the document (G17):** a filter writes JSON of at most 1 MiB with `documentData` (one undo step with its objects; `null` removes it) and reads it back in `_context.documentData`, which no other filter is given. It is the document's foreign data (`unknown.pluginData`, by plug-in id), as the perspective grid is, so the native file keeps it with no section of its own; an exporter is given the whole document, so it can read every plug-in's. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_export.rs` (the exporter), `crates/doc/src/plugin_data.rs`, `crates/plugins/src/objects/tests.rs`. Scores and hours unchanged.
- **Heavy filter plug-ins (G17):** a filter's manifest may ask for `"budget": "heavy"`: 1 G instructions (20 times a light run's) and 300 s a run, for routing, packing or simulation. Install Plug-in… and File › Open ask the user first, `plugin.install` needs `allowHeavy: true` (`plugin.inspect` tells beforehand), the Additional Plug-ins Folder skips such filters, and the plug-in store keeps the answer. Its dialog has no Preview; the app runs it on a thread of its own with a progress window (Cancel, Continue in Background) and applies the result as `plugin.run` with the same parameters, one undo step, unless the document or the plug-in changed meanwhile. Regressions: `crates/plugins/tests/abi.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged.
- **Safe Mode for plug-ins (G17):** `vectorcraft --safe-mode` and `vectorcraft-cli --safe-mode <subcommand>` start without plug-ins. When a launch stopped while a plug-in was loading or drawing its first live effect, the next launch names that plug-in, without saying it caused the stop, and offers Disable and Continue, Start Normally or Start in Safe Mode. Help › Safe Mode lists the disabled plug-ins with Enable on Re-launch, which takes effect at the next launch, and the app bar and the window title show Safe Mode. The help page (https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf, pages 65 to 69) describes the same choice between diagnosing and starting normally, a Safe Mode with the problem item off and the rest of the app working, and turning the item back on at the next launch. In Safe Mode no plug-in loads or runs, and a live-effect plug-in draws its object's geometry unchanged while the object keeps the effect, which the Appearance panel marks "(Safe Mode)" with the plug-in's id in its tooltip and `effect.list` marks `skipped`; Expand Appearance refuses such an object. The web build keeps no plug-ins between page loads and has no marker. Each desktop launch with preferences keeps a marker in the `Launch` folder next to them; `vectorcraft-cli` and a desktop launch without preferences keep none, so a stop there leads to no choice at the next launch. Disabled plug-ins are listed in `Plug-ins/disabled.json`. `plugin.safeMode` reports Safe Mode and the disabled plug-ins, and `plugin.setEnabled` changes the list. Regressions: `crates/plugins/src/safe_mode.rs`, `crates/plugins/tests/safe_mode.rs`, `crates/engine/tests/plugin_safe_mode.rs`, `crates/ui-egui/tests/safe_mode.rs`. Scores and hours unchanged.