diff --git a/crates/plugins/src/objects.rs b/crates/plugins/src/objects.rs index 5c915b5a2..17d7c1e19 100644 --- a/crates/plugins/src/objects.rs +++ b/crates/plugins/src/objects.rs @@ -6,9 +6,10 @@ //! in-range coordinates, capped counts, clamped colours) before it reaches the document. use std::collections::{HashMap, HashSet}; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use serde_json::{Value, json}; +use vectorcraft_color::cms::{self, Cms, CmykLut, lab}; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{ Appearance, AppearanceItem, Document, Effect, FillLayer, ImageBlob, LineCap, LineJoin, Node, NodeId, NodeKind, StrokeLayer, SymbolBoxes, @@ -288,8 +289,8 @@ fn affine_json(xf: Affine) -> Value { /// or [`MAX_INPUT_RUNS`]; /// - an image (`"image"`): `pixelSize: [w, h]`, `colorMode: "rgb"|"cmyk"|"gray"|"unknown"`, /// `linked`, `pixelsOmitted`, and for an embedded image of at most [`MAX_INPUT_PIXELS`] -/// `pixelsBase64`: straight RGBA, 8 bits a channel, row by row from the top. A linked image's file -/// and path are never read or given; +/// `pixelsBase64`: straight RGBA, 8 bits a channel, row by row from the top (a CMYK image's inks +/// through the working CMYK profile). A linked image's file and path are never read or given; /// - a symbol instance (`"symbolInstance"`): `symbolId` (the symbol's name), `missing: true` when /// the document has no such symbol, and `overrides: {}`. pub fn encode_read_only(doc: &Document, n: &Node) -> Option { @@ -424,6 +425,9 @@ fn rgba_pixels(b: &ImageBlob, w: u32, h: u32, max: usize) -> Option> { if bytes == 0 || bytes.div_ceil(3) * 4 > max as u64 { return None; } + if b.is_cmyk() { + return cmyk_rgba(b, w, h); + } let reader = || image::ImageReader::new(std::io::Cursor::new(b.bytes.as_slice())).with_guessed_format().ok(); if reader()?.into_dimensions().ok()? != (w, h) { return None; @@ -437,6 +441,51 @@ fn rgba_pixels(b: &ImageBlob, w: u32, h: u32, max: usize) -> Option> { (img.dimensions() == (w, h)).then(|| img.into_raw()) } +/// The inks → XYZ table of [`cmyk_rgba`] and the color settings it was built with. +static CMYK_DISPLAY: Mutex, Arc)>> = Mutex::new(None); + +/// The pixels of CMYK image `b` as straight RGBA, when it is `w` × `h` pixels (checked from its +/// header first): its inks shown through the working CMYK profile, by a table built as +/// `vectorcraft-render`'s `InkLut` builds its own and kept for the active color settings. +fn cmyk_rgba(b: &ImageBlob, w: u32, h: u32) -> Option> { + if b.header_size()? != (w, h) { + return None; + } + let inks = b.cmyk().filter(|i| (i.width, i.height) == (w, h))?; + let lut = { + let cms = cms::active(); + let mut slot = CMYK_DISPLAY.lock().unwrap_or_else(|e| e.into_inner()); + match &*slot { + Some((owner, lut)) if Arc::ptr_eq(owner, &cms) => lut.clone(), + _ => { + let lut = Arc::new(CmykLut::build(|c| { + let [x, y, z] = lab::lab_to_xyz(cms.cmyk_display_lab(c)); + [x, y, z, 0.0] + })); + *slot = Some((cms, lut.clone())); + lut + } + } + }; + let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; + let mut out = Vec::with_capacity(inks.data.len()); + // Runs of one color convert once. + let mut last: Option<([u8; 4], [u8; 4])> = None; + for p in inks.data.as_chunks::<4>().0 { + let rgba = match last { + Some((k, v)) if k == *p => v, + _ => { + let [x, y, z, _] = lut.apply(p.map(|v| f32::from(v) / 255.0)); + let [r, g, b] = lab::xyz_to_srgb([x, y, z]).map(q); + last = Some((*p, [r, g, b, 255])); + [r, g, b, 255] + } + }; + out.extend_from_slice(&rgba); + } + Some(out) +} + fn out_err(at: &str, msg: impl std::fmt::Display) -> Error { Error::Output(format!("{at}: {msg}")) } diff --git a/crates/plugins/src/objects/tests.rs b/crates/plugins/src/objects/tests.rs index 308bcd2a4..d421f98dc 100644 --- a/crates/plugins/src/objects/tests.rs +++ b/crates/plugins/src/objects/tests.rs @@ -458,6 +458,25 @@ fn declared_embedded_and_linked_images_have_distinct_metadata_and_pixel_omission } } +/// A CMYK image's pixels are its inks as the working CMYK profile shows them on the canvas: black +/// ink alone is the profile's black, not RGB black. +#[test] +fn cmyk_image_pixels_go_through_the_working_cmyk_profile() { + use vectorcraft_color::cms::{self, lab}; + let (mut d, _, layer, _) = mixed(); + let inks = vectorcraft_doc::cmyk::Inks::new(2, 1, vec![0, 0, 0, 255, 0, 0, 0, 0]).unwrap(); + d.images.insert("k".into(), ImageBlob::cmyk_tiff(&inks).unwrap()); + let n = image_node(&mut d, "k", (2, 1), None); + let id = d.insert(Some(layer), usize::MAX, n).unwrap(); + let o = encode_read_only(&d, d.node(id).unwrap()).unwrap(); + assert_eq!((&o["colorMode"], &o["pixelsOmitted"]), (&json!("cmyk"), &json!(false))); + let pixels = craft_vectorcraft_format::base64::decode(o["pixelsBase64"].as_str().unwrap()).unwrap(); + let shown = |c: [f32; 4]| lab::xyz_to_srgb(lab::lab_to_xyz(cms::active().cmyk_display_lab(c))).map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8); + let ([r, g, b], [r2, g2, b2]) = (shown([0.0, 0.0, 0.0, 1.0]), shown([0.0; 4])); + assert_eq!(pixels, [r, g, b, 255, r2, g2, b2, 255]); + assert_ne!(&pixels[..3], &[0, 0, 0], "not converted without the profile"); +} + /// A symbol instance comes with the symbol it shows and its transform, never its art; one whose /// symbol the document lacks says so. #[test] diff --git a/docs/plugins.md b/docs/plugins.md index f0f205a89..1c4495b7c 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -429,8 +429,9 @@ In this example the character and paragraph styles are cut short; a filter gets - An image (`"image"`): `pixelSize` is its width and height in pixels; `colorMode` is `"rgb"`, `"cmyk"`, `"gray"` or `"unknown"`, read from the image itself. An embedded image whose pixels take at most 1 MiB in base64 (443 × 443 pixels fit) has them in `pixelsBase64`: straight RGBA, 8 bits a channel, row by row from the top, and - `pixelsOmitted: false`. The pixels of all the images in one input take at most 16 MiB in base64. A larger image, one - whose pixels don't fit what the images before it in paint order left, and a linked image (`linked: true`) have + `pixelsOmitted: false`. A CMYK image's pixels are its inks through the working CMYK profile. The pixels of all the + images in one input take at most 16 MiB in base64. A larger image, one whose pixels don't fit what the images before + it in paint order left, and a linked image (`linked: true`) have `pixelsOmitted: true` and no pixels; the host checks an image's size before it decodes the image. The host never reads a linked image's file for a filter and never gives its path. - A symbol instance (`"symbolInstance"`): `symbolId` is the name of the symbol it shows, with `missing: true` when the diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index bf3f20da8..0848098d8 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1542,7 +1542,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **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. - **Plug-in instruction budget at a bulk memory instruction:** a run that pauses where one slice of its instruction budget ends keeps the instructions the slice still held. A `memory.fill` or `memory.copy` that the slice can't pay for in full runs on the next slice, so a run whose budget covers its work finishes; a run ends with `Limit("instruction budget")` only when the instructions it has left can't pay for the next instruction. Regressions: `crates/plugins/tests/abi.rs`. Scores and hours unchanged. - **Plug-in permissions (G17):** a manifest may list `permissions` (`doc.read`, `doc.write`, `state`, `effect.render`, `export.write`; the first three follow storytold/photocraft#662) and an `api` level; without the list a plug-in gets what its kind implies and installs as before. Install Plug-in… and File › Open show the name, id, version, file SHA-256 and each permission in plain words, with control and bidirectional characters escaped, and install nothing on Cancel; `plugin.install` needs `grant`, and folders install only granted plug-ins. The grant is kept with the module's hash, a module asking for more asks again, and a filter run refuses output its permissions don't allow. Regressions: `crates/plugins/src/permissions.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. -- **Filter plug-ins read type, images and symbol instances (G17, #4333):** a filter at plug-in API level 2 lists `inputs` (`type`, `image`, `symbolInstance`) with `doc.read` in its `permissions`, so installing it asks for approval, and is also given the selected objects of those kinds, read only: type with its runs, character and paragraph attributes (no text past 256 KiB or 4,096 runs or paragraphs, or 16 MiB of runs over the input), images with their size, color mode and, when embedded and at most 1 MiB in base64 (16 MiB over the input), their RGBA pixels (never a linked file's), and symbol instances with their symbol and transform. Every object counts toward the input's half of the memory cap; one whose fields besides its pixels and runs don't fit fails the run naming it and its size, a path or compound path that doesn't fit fails it too, and so do more than 100,000 of these objects. Its output returns each as `{id}` or exactly as given; any change or omission is refused before the document changes. Filters without `inputs` get the same input as before, except that compound paths that clip a clip group and paths inside a group that clips one are left out for every filter. Regressions: `crates/plugins/src/objects/tests.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. +- **Filter plug-ins read type, images and symbol instances (G17, #4333):** a filter at plug-in API level 2 lists `inputs` (`type`, `image`, `symbolInstance`) with `doc.read` in its `permissions`, so installing it asks for approval, and is also given the selected objects of those kinds, read only: type with its runs, character and paragraph attributes (no text past 256 KiB or 4,096 runs or paragraphs, or 16 MiB of runs over the input), images with their size, color mode and, when embedded and at most 1 MiB in base64 (16 MiB over the input), their RGBA pixels (a CMYK image's through the working CMYK profile; never a linked file's), and symbol instances with their symbol and transform. Every object counts toward the input's half of the memory cap; one whose fields besides its pixels and runs don't fit fails the run naming it and its size, a path or compound path that doesn't fit fails it too, and so do more than 100,000 of these objects. Its output returns each as `{id}` or exactly as given; any change or omission is refused before the document changes. Filters without `inputs` get the same input as before, except that compound paths that clip a clip group and paths inside a group that clips one are left out for every filter. Regressions: `crates/plugins/src/objects/tests.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. - **Plug-in progress, Cancel and log lines (G17):** at plug-in API level 2 a plug-in with the `progress` permission imports `env.vc_progress`, `env.vc_cancelled` and `env.vc_log`. A heavy filter's and an export's progress windows show its progress as a percentage bar, their Cancel reaches it through `vc_cancelled`, and its log lines go to the app's log with its id, capped per line and per run. Installing checks every import and names the one it refuses. Regressions: `crates/plugins/src/host.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged. - **Plug-in settings and cache (G17, #2191):** at plug-in API level 2 a plug-in with the `settings` permission keeps settings of its own (`env.vc_settings_get`, `_set`, `_delete`) and one with `cache` a cache the user can clear (`env.vc_cache_read`, `_write`, `_clear`), under its id in the `Plug-ins` folder. Keys are plain names, never paths; VectorCraft's own caps refuse a write past 256 settings, 1 MiB of settings or 512 MiB of cache; a damaged settings file is moved aside and the settings start empty. `plugin.clearCache` empties a cache, `plugin.list` and `plugin.info` give `cacheBytes`, replacing a plug-in keeps its storage and `plugin.remove` deletes it. Another module under an id that is installed or has storage installs only with `replace: true`, which the app asks the user for; each storage call counts the whole settings file or cache entry it reads and writes toward the run's 1 GiB. Regressions: `crates/plugins/src/storage.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged.