From 4bb1df492964a505f1ca54a6329ec6972acf4adc Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 16:04:43 -0800 Subject: [PATCH] Plug-ins: a filter's gradient stop plates are clamped into range like its other colors A filter's or importer's output clamps each gradient stop's offset, opacity, midpoint, tint and color, but kept its four process plates as given, so a plate below 0, above 1 or infinite was stored in the document. Each plate is now clamped to 0-1: 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. Docs: docs/plugins.md, docs/target-app-parity.md. --- crates/plugins/src/objects.rs | 12 ++++++++++-- crates/plugins/src/objects/tests.rs | 27 +++++++++++++++++++++++++++ docs/plugins.md | 5 +++-- docs/target-app-parity.md | 1 + 4 files changed, 41 insertions(+), 4 deletions(-) diff --git a/crates/plugins/src/objects.rs b/crates/plugins/src/objects.rs index 26ba5b668b..3e844415a7 100644 --- a/crates/plugins/src/objects.rs +++ b/crates/plugins/src/objects.rs @@ -579,6 +579,11 @@ fn unit(v: f32) -> f32 { v.clamp(0.0, 1.0) } +/// Process plate `v` in 0..1: NaN is 0, infinities go to the nearest end. +fn plate(v: f32) -> f32 { + if v.is_nan() { 0.0 } else { unit(v) } +} + /// `c` with its components in range. pub(crate) fn clamp_color(c: Color) -> Color { match c { @@ -589,8 +594,8 @@ pub(crate) fn clamp_color(c: Color) -> Color { } } -/// A paint from plug-in output (`null`: none), with colours, opacities and offsets clamped and -/// gradient and pattern placements checked. +/// A paint from plug-in output (`null`: none), with colors, process plates, opacities and +/// offsets clamped and gradient and pattern placements checked. fn decode_paint(v: &Value, at: &str) -> Result { if v.is_null() { return Ok(Paint::None); @@ -613,6 +618,9 @@ fn decode_paint(v: &Value, at: &str) -> Result { s.midpoint = s.midpoint.clamp(0.13, 0.87); s.tint = unit(s.tint); s.color = clamp_color(s.color); + if let Some(plates) = &mut s.plates { + *plates = plates.map(plate); + } } stops.sort_by(|a, b| a.offset.total_cmp(&b.offset)); g.angle = g.angle.rem_euclid(360.0); diff --git a/crates/plugins/src/objects/tests.rs b/crates/plugins/src/objects/tests.rs index a6f3c63f30..3483971355 100644 --- a/crates/plugins/src/objects/tests.rs +++ b/crates/plugins/src/objects/tests.rs @@ -109,6 +109,33 @@ 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 +/// 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 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]); + 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 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 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() { + assert_eq!([f32::NAN, f32::INFINITY, f32::NEG_INFINITY, 0.5].map(plate), [0.0, 1.0, 0.0, 0.5]); +} + /// A path handed back as it was stays the live shape it was (a rectangle keeps its corner /// widgets); a path that changed is a plain path. #[test] diff --git a/docs/plugins.md b/docs/plugins.md index f616479167..124cc76174 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -734,8 +734,9 @@ For an **object filter**, the output is the new state of the input objects: The host validates everything before the document changes: coordinates must be finite and within ±4,000,000 pt, at most 100,000 objects and 4,000,000 anchors, 256 fills, strokes or gradient stops; colours, opacities and offsets are clamped into range, stroke weights to 0–1,000 pt and miter limits to 1–500; a pattern must exist in the document. A -paint returned unchanged keeps its swatch link; any other paint is unlinked. One refused effect record refuses the -whole output. +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. 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 diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 68b93ac4d8..2273b1e16f 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1753,6 +1753,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Plug-in parameters as their manifest presents them (G17):** each parameter may have a `label`, a `unit` shown after its value, `help` on its label and a `helpUrl` page opened from a button, a `group` (sections that fold, after the parameters outside any group) and a `visibleWhen` that shows it only while the checkboxes and choices it names have one of the listed values (a hidden parameter still goes to the plug-in). `string` and `numberList` parameters join number, int, bool and choice, and a plug-in declares up to 256 parameters, at most 64 outside groups. The filter, live-effect and exporter dialogs use these keys, and the Appearance panel's effect editor the labels, units, help and conditions; hosts from before them ignore them. Regressions: `crates/plugins/src/dialog.rs`, `crates/plugins/src/manifest.rs`, `crates/ui-egui/src/dialogs/tests_plugin_fields.rs`, `crates/ui-egui/src/panels/appearance.rs`. Scores and hours unchanged. - **Live-effect plug-ins read their object's paints (G17):** a live effect is given the object's `fills` and `strokes` as a filter gets them, read only, and `_context.item` (`"object"`, or the fill or stroke whose own effects hold it); the result is cached by paint too, so a change of paint runs it again. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/plugins/src/effect.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. - **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 ` 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.