Browse / github.com/storytold/vectorcraft / patch
Show placed raster images by plate in Separations Preview
Separation Preview (CMYK channel visibility toggles) does not affect raster images inside VectorCraft Describe the bug: The Separation Preview panel in VectorCraft fails to update the viewport correctly when toggling individual CMYK channel visibility (eye icons for Cyan, Magenta, Yellow, and Black) on raster images placed within the vector document. While this feature behaves correctly in PhotoCraft for bitmap layers (where disabling channels progressively isolates the proper ink plates, e.g., hiding C, then M, then Y, as shown in the attached screenshots), VectorCraft's rendering engine does not reflect these changes on raster objects, leaving the artwork unaffected when toggling channels o
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- Silvertree2010 (imported from pull request #4199, open)
- Base
443b31b13df3a7ab611c2fe1b95e73898ed74fdd- Size
- +266 −18 in 7 files
- Review
- pending
- Shared
- 2026-10-28
- Id
2634027997b4d06db5d2c9ad
How it went
No other operator has reported using it yet.
Diff
raw · crates/render/src/blobs.rs crates/render/src/images.rs crates/render/src/proof.rs crates/render/src/renderer/images.rs crates/render/src/renderer/text.rs crates/render/src/tests.rs crates/render/src/tests/separations.rs
@@ -11,8 +11,15 @@ pub(crate) fn lookup(key: &images::ImageKey, touch: bool) -> Option<Arc<Pixmap>> pub(crate) fn blob_key(blob: &vectorcraft_doc::ImageBlob) -> (images::ImageKey, Option<Arc<ink::InkLut>>, Option<(u16, u16)>) { let id = images::blob_id(blob); let lut = id.cmyk.then(ink::display_lut);- let key =- images::ImageKey { content: id.content, adjust: None, grey: false, ink: Ink::Display, luts: (lut.as_ref().map_or(0, |l| l.1), 0), mip: 0 };+ let key = images::ImageKey {+ content: id.content,+ adjust: None,+ grey: false,+ ink: Ink::Display,+ luts: (lut.as_ref().map_or(0, |l| l.1), 0),+ plates: 0,+ mip: 0,+ }; (key, lut.map(|l| l.0), id.size) } @@ -90,6 +90,9 @@ pub(crate) struct ImageKey { /// Generations of the colour tables the pixels were made with (0 = none): a CMYK image's /// display table, and the separation table of ink planes (see `ink`). pub luts: (u64, u64),+ /// Generation of the Separations Preview tables the pixels show the visible plates with+ /// (0 = none, see `proof::plate_image`).+ pub plates: u64, /// Halved this many times ([`half`]). pub mip: u8, }@@ -337,7 +340,7 @@ mod tests { use super::*; fn key(content: &str) -> ImageKey {- ImageKey { content: content.into(), adjust: None, grey: false, ink: Ink::Display, luts: (0, 0), mip: 0 }+ ImageKey { content: content.into(), adjust: None, grey: false, ink: Ink::Display, luts: (0, 0), plates: 0, mip: 0 } } #[test]@@ -8,7 +8,8 @@ //! renders as greyscale ink coverage (black = 100%); with several, the visible inks are //! composited through the proof profile, spots multiplied on top. Colours linked to the //! Registration swatch print their tint on every plate, spot plates included.-//! Placed raster images are not separated (limitation).+//! Placed raster images show their pixels the same way ([`plate_image`]): RGB pixels are+//! separated into process inks, CMYK images show their own inks; they print on no spot plate. //! * **Overprint Preview** (and Separations Preview, which implies it): fills and strokes that //! overprint ([`vectorcraft_doc::FillLayer::overprint`], characters' too) are drawn with //! Darken, which approximates their inks printing over the inks below: a zero ink lets the@@ -19,16 +20,20 @@ use std::borrow::Cow; use std::collections::HashMap;-use std::sync::{Arc, RwLock, Weak};+use std::sync::atomic::{AtomicU64, Ordering};+use std::sync::{Arc, Mutex, OnceLock, RwLock, Weak}; -use vectorcraft_color::cms::{self, Cms, PROCESS_PLATES};+use vectorcraft_color::cms::{self, Cms, CmykLut, PROCESS_PLATES, ProofLut, lab}; pub use vectorcraft_color::cms::{Intent, ProofSetup, ProofTarget}; use vectorcraft_color::{Color, Swatch, SwatchGroup}; pub use vectorcraft_doc::inks::{Inks, Link, Plate, inks, map_document_colors, map_node_colors, plates, spot_color}; use vectorcraft_doc::overprint::darken_overprints;-use vectorcraft_doc::{Document, Node, NodeId, Shared};+use vectorcraft_doc::{Document, ImageBlob, Node, NodeId, Shared};+use vello_cpu::Pixmap;+use vello_cpu::color::PremulRgba8; use crate::RenderOptions;+use crate::images::{self, ImageKey}; /// Whether overprinting shows: Overprint Preview, or Separations Preview (which implies it). pub(crate) fn overprints(opts: &RenderOptions) -> bool {@@ -40,13 +45,7 @@ fn plate_color(doc: &Document, c: &Cms, proof: &ProofSetup, visible: &[String], let rgb = if visible.len() == 1 { [1.0 - ink.on_plate(&visible[0]); 3] } else {- let mut cmyk = ink.cmyk;- for (i, p) in PROCESS_PLATES.iter().enumerate() {- if !visible.iter().any(|v| v == p) {- cmyk[i] = 0.0;- }- }- let mut rgb = c.proof_cmyk_to_srgb(cmyk, proof);+ let mut rgb = visible_process_srgb(c, proof, visible, ink.cmyk); for name in visible.iter().filter(|v| !PROCESS_PLATES.contains(&v.as_str())) { let t = ink.spot_tint(name); if t > 0.0@@ -64,6 +63,153 @@ fn plate_color(doc: &Document, c: &Cms, proof: &ProofSetup, visible: &[String], Color::Rgb { r, g, b } } +/// The display sRGB of process inks `cmyk` with only the `visible` plates printing, through the+/// proof's CMYK space.+fn visible_process_srgb(c: &Cms, proof: &ProofSetup, visible: &[String], mut cmyk: [f32; 4]) -> [f32; 3] {+ for (i, p) in PROCESS_PLATES.iter().enumerate() {+ if !visible.iter().any(|v| v == p) {+ cmyk[i] = 0.0;+ }+ }+ c.proof_cmyk_to_srgb(cmyk, proof)+}++/// The tables Separations Preview shows placed images' pixels with ([`plate_image`]), for one+/// proof setup and its visible plates.+pub(crate) struct ImagePlates {+ cms: Arc<Cms>,+ /// [`cms::profiles_generation`].+ profiles: u64,+ proof: ProofSetup,+ /// Images made with these tables are kept under it ([`ImageKey::plates`]); never reused.+ generation: u64,+ /// One plate visible: the process plate's index, `None` for a spot plate (no image prints on+ /// it).+ single: Option<Option<usize>>,+ /// Display sRGB → the visible plates as they show (display sRGB).+ rgb: ProofLut,+ /// CMYK inks → the visible plates as they show (XYZ), made when a CMYK image is first shown+ /// with several plates.+ cmyk: OnceLock<CmykLut>,+}++impl ImagePlates {+ fn build(cms: Arc<Cms>, profiles: u64, proof: ProofSetup, generation: u64) -> Self {+ let visible = proof.separations.as_deref().unwrap_or_default();+ let single = match visible {+ [one] => Some(PROCESS_PLATES.iter().position(|p| p == one)),+ _ => None,+ };+ let rgb = ProofLut::build(|rgb| {+ let inks = cms.srgb_to_cmyk(rgb, proof.intent);+ match single {+ Some(i) => [1.0 - i.map_or(0.0, |i| inks[i]); 3],+ None => visible_process_srgb(&cms, &proof, visible, inks),+ }+ });+ Self { cms, profiles, proof, generation, single, rgb, cmyk: OnceLock::new() }+ }++ /// CMYK ink amounts `inks` (0 = no ink) as the visible plates show them.+ fn show_inks(&self, inks: [u8; 4]) -> [u8; 3] {+ match self.single {+ Some(Some(i)) => [255 - inks.get(i).copied().unwrap_or(0); 3],+ Some(None) => [255; 3],+ None => {+ let lut = self.cmyk.get_or_init(|| {+ let visible = self.proof.separations.as_deref().unwrap_or_default();+ CmykLut::build(|inks| {+ let [x, y, z] = lab::srgb_to_xyz(visible_process_srgb(&self.cms, &self.proof, visible, inks));+ [x, y, z, 0.0]+ })+ });+ let [x, y, z, _] = lut.apply(inks.map(|v| v as f32 / 255.0));+ lab::xyz_to_srgb([x, y, z]).map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)+ }+ }+ }+}++static IMAGE_PLATES: Mutex<Option<Arc<ImagePlates>>> = Mutex::new(None);+static IMAGE_PLATES_GENERATION: AtomicU64 = AtomicU64::new(0);++/// The tables placed images show with in Separations Preview, `None` when it's off.+pub(crate) fn image_plates(opts: &RenderOptions) -> Option<Arc<ImagePlates>> {+ let proof = opts.proof.as_ref().filter(|p| p.separations.is_some())?;+ let c = cms::active();+ let profiles = cms::profiles_generation();+ let mut slot = IMAGE_PLATES.lock().unwrap_or_else(|e| e.into_inner());+ if let Some(p) = &*slot+ && Arc::ptr_eq(&p.cms, &c)+ && p.profiles == profiles+ && p.proof == *proof+ {+ return Some(p.clone());+ }+ let generation = IMAGE_PLATES_GENERATION.fetch_add(1, Ordering::Relaxed) + 1;+ let p = Arc::new(ImagePlates::build(c, profiles, proof.clone(), generation));+ *slot = Some(p.clone());+ Some(p)+}++/// The key [`plate_image`] gives image pixels `key` shown with `plates`.+pub(crate) fn plate_key(key: &ImageKey, plates: &ImagePlates) -> ImageKey {+ ImageKey { plates: plates.generation, ..key.clone() }+}++/// Image pixels `pm` (cached as `key`, from `blob` when it is a document's image as it is) as the+/// visible plates of `plates` print them, cached with the images, and their key. A CMYK image+/// shows its own inks; other pixels are separated.+pub(crate) fn plate_image(key: &ImageKey, pm: &Arc<Pixmap>, blob: Option<&ImageBlob>, plates: &ImagePlates, touch: bool) -> (ImageKey, Arc<Pixmap>) {+ let want = plate_key(key, plates);+ let hit = {+ let mut s = images::store();+ if touch { s.get(&want) } else { s.peek(&want) }+ };+ if let Some(p) = hit {+ return (want, p);+ }+ let size = (pm.width() as u32, pm.height() as u32);+ let cmyk = key.luts.0 != 0 && key.adjust.is_none() && !key.grey;+ let inks = blob.filter(|_| cmyk).and_then(ImageBlob::cmyk).filter(|i| (i.width, i.height) == size);+ let mut out = Pixmap::new(pm.width(), pm.height());+ // Runs of one colour convert once.+ if let Some(inks) = &inks {+ let mut last: Option<([u8; 4], [u8; 3])> = None;+ for (o, p) in out.data_mut().iter_mut().zip(inks.data.as_chunks::<4>().0) {+ let [r, g, b] = match last {+ Some((k, v)) if k == *p => v,+ _ => {+ let v = plates.show_inks(*p);+ last = Some((*p, v));+ v+ }+ };+ *o = PremulRgba8 { r, g, b, a: 255 };+ }+ } else {+ let mut last: Option<(PremulRgba8, PremulRgba8)> = None;+ for (o, s) in out.data_mut().iter_mut().zip(pm.data()) {+ let a = s.a;+ if a == 0 {+ continue;+ }+ *o = match last {+ Some((k, v)) if k == *s => v,+ _ => {+ let un = |v: u8| (v as f32 / a as f32).min(1.0);+ let [r, g, b] = plates.rgb.apply([un(s.r), un(s.g), un(s.b)]).map(|v| (v.clamp(0.0, 1.0) * a as f32).round() as u8);+ let v = PremulRgba8 { r, g, b, a };+ last = Some((*s, v));+ v+ }+ };+ }+ }+ let pm = images::store().insert(want.clone(), Arc::new(out));+ (want, pm)+}+ /// The art a renderer drew with Overprint Preview or Separations Preview, kept for its next frame /// ([`prepare`]): the document's layers and symbol art as they were, each beside the art drawn for /// it, and what that art was made with. The kept nodes keep their addresses: no other node gets@@ -22,14 +22,20 @@ impl Renderer { // With mips the full pixels are only looked at (not marked used) until they're drawn, and // a mip the store holds is drawn without them: they may have been dropped (#2820). let mips = f.opts.image_mips;+ // Separations Preview: the pixels show the visible plates.+ let plates = if outline { None } else { proof::image_plates(f.opts) }; let unit = f.view * im.xf * Affine::scale_non_uniform(im.width as f64, im.height as f64); let shown = if outline && !f.doc.setup.outline_images { None- } else if let Some(pm) = mips.then(|| self.kept_mip(f, &im.key, im.pixels.as_deref(), outline, unit)).flatten() {+ } else if let Some(pm) = mips.then(|| self.kept_mip(f, &im.key, im.pixels.as_deref(), outline, plates.as_deref(), unit)).flatten() { Some(pm) } else { self.image_pixmap(f.doc, &im.key, im.pixels.as_deref(), outline, !mips).map(|(key, pm)| { let (key, pm) = if outline { (key, pm) } else { ink::ink_image(&key, &pm, f.ink, f.doc.image_blob(im), !mips) };+ let (key, pm) = match &plates {+ Some(p) => proof::plate_image(&key, &pm, f.doc.image_blob(im), p, !mips),+ None => (key, pm),+ }; if mips { image_mip(&key, pm, unit) } else { pm } }) };@@ -191,11 +197,24 @@ impl Renderer { } /// The mip [`image_mip`] draws image `key` (or `own`) with through `m`, in greyscale with- /// `grey` (the outline view's), when the store holds it, marked used there; `None` when it- /// draws the full pixels or the mip is yet to be made.- fn kept_mip(&self, f: &Frame, key: &str, own: Option<&vectorcraft_doc::ImageBlob>, grey: bool, m: Affine) -> Option<Arc<Pixmap>> {+ /// `grey` (the outline view's) or showing the visible `plates` of Separations Preview, when the+ /// store holds it, marked used there; `None` when it draws the full pixels or the mip is yet+ /// to be made.+ fn kept_mip(+ &self,+ f: &Frame,+ key: &str,+ own: Option<&vectorcraft_doc::ImageBlob>,+ grey: bool,+ plates: Option<&proof::ImagePlates>,+ m: Affine,+ ) -> Option<Arc<Pixmap>> { let (key, (w, h)) = self.image_key(f.doc, key, own, grey)?; let key = if grey { key } else { ink::ink_key(&key, f.ink) };+ let key = match plates {+ Some(p) => proof::plate_key(&key, p),+ None => key,+ }; let level = mip_level(m, w, h); if level == 0 { return None;@@ -242,6 +242,10 @@ impl Renderer { fn draw_glyph_image(&mut self, ctx: &mut RenderContext, f: &Frame, image: &vectorcraft_text::GlyphImage, xf: Affine) { let Some((key, pm)) = blob_pixmap(&image.blob, true) else { return }; let (key, pm) = ink::ink_image(&key, &pm, f.ink, Some(&image.blob), true);+ let (key, pm) = match proof::image_plates(f.opts) {+ Some(p) => proof::plate_image(&key, &pm, Some(&image.blob), &p, true),+ None => (key, pm),+ }; let unit = f.view * xf * Affine::scale_non_uniform(image.width as f64, image.height as f64); let pm = image_mip(&key, pm, unit); let sx = image.width as f64 / pm.width().max(1) as f64;@@ -24,6 +24,7 @@ mod memory; mod objectfx; mod placed_document; mod rasterize;+mod separations; mod setup; mod strokegradient; mod tileedge;new file mode 100644@@ -0,0 +1,68 @@+//! Separations Preview of placed raster images (#4153): an image shows the inks of the visible+//! plates, as vector art does, not its composite colours.++use vectorcraft_doc::cmyk::Inks;+use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind};++use crate::*;++/// A 20×20 document filled by image `blob`.+fn doc_with(blob: ImageBlob) -> Document {+ let mut d = Document::new(20.0, 20.0);+ d.images.insert("img".into(), blob);+ let (id, layer) = (d.alloc_id(), d.layers[0].id);+ let im = ImageObject { key: "img".into(), width: 20, height: 20, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None };+ d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap();+ d+}++/// The centre pixel of `d` with the plates `visible` shown (`None`: Separations Preview off).+fn centre(d: &Document, visible: Option<&[&str]>) -> [u8; 4] {+ let proof = visible.map(|v| proof::ProofSetup { separations: Some(v.iter().map(|p| p.to_string()).collect()), ..Default::default() });+ let opts = RenderOptions { background: Some([255; 4]), overprint_preview: proof.is_some(), proof, ..Default::default() };+ Renderer::new().render_region_with(d, d.artboards[0].rect, 1.0, &opts).pixel(10, 10)+}++fn near(a: [u8; 4], b: [u8; 4], tol: i32) -> bool {+ a.iter().zip(b).all(|(x, y)| (*x as i32 - y as i32).abs() <= tol)+}++const ALL: &[&str] = &["Cyan", "Magenta", "Yellow", "Black"];++#[test]+fn rgb_images_show_the_visible_plates() {+ let img = image::RgbaImage::from_pixel(20, 20, image::Rgba([0, 255, 255, 255]));+ let mut png = vec![];+ img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();+ let d = doc_with(ImageBlob::new("image/png", png));+ let cms = vectorcraft_color::cms::active();+ let inks = cms.srgb_to_cmyk([0.0, 1.0, 1.0], proof::ProofSetup::default().intent);+ // One plate: its ink as grey coverage (black = 100%).+ for (i, plate) in ["Cyan", "Magenta", "Yellow", "Black"].into_iter().enumerate() {+ let v = ((1.0 - inks[i]) * 255.0).round() as u8;+ let got = centre(&d, Some(&[plate]));+ assert!(near(got, [v, v, v, 255], 4), "{plate}: {got:?} ≠ {v}");+ }+ let cyan = centre(&d, Some(&["Cyan"]));+ assert!(cyan[0] < 110, "a cyan image prints mostly cyan: {cyan:?}");+ // Hiding the cyan plate takes the cyan ink away.+ let all = centre(&d, Some(ALL));+ let no_cyan = centre(&d, Some(&["Magenta", "Yellow", "Black"]));+ assert!(no_cyan[0] > all[0] + 60, "hiding Cyan: {no_cyan:?} vs {all:?}");+ // A spot plate the image doesn't print on shows paper.+ assert_eq!(centre(&d, Some(&["Spot"])), [255; 4]);+}++#[test]+fn cmyk_images_show_their_own_inks_on_the_plates() {+ let ink = [255u8, 0, 0, 0];+ let blob = ImageBlob::cmyk_tiff(&Inks::new(20, 20, ink.repeat(400)).unwrap()).unwrap();+ let d = doc_with(blob);+ assert_eq!(centre(&d, Some(&["Cyan"])), [0, 0, 0, 255], "100% cyan is solid on the cyan plate");+ for plate in ["Magenta", "Yellow", "Black"] {+ assert_eq!(centre(&d, Some(&[plate])), [255; 4], "no {plate} ink");+ }+ assert!(near(centre(&d, Some(&["Magenta", "Yellow", "Black"])), [255; 4], 2), "paper where only cyan prints");+ let all = centre(&d, Some(ALL));+ assert!(near(all, centre(&d, None), 4), "every plate shows the image: {all:?}");+}