From 84904020e88aeef5f1f8e359be15dc8957b7fd67 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 5 Nov 2026 07:06:53 -0800 Subject: [PATCH] Images: a colorized image's missing-link placeholder shows in its own colors A linked 1-bit image mask from a .ai file keeps its fill color as a colorize paint, so the image takes the color once its file is relinked. Until then the image shows the missing-link placeholder, and the canvas and PDF export colorized that placeholder too: its light tile turned nearly transparent and its red cross took the fill color. ImageBlob::takes_colorize is false for a preview whose pixels are not grayscale. While it is false, the canvas draws the image and PDF export writes it as it is, printed separations separate it as an uncolorized image, and transparency flattening (EPS, PDF 1.3 and PDF/X-1a export, Flatten Transparency) does not count its paint as transparency. The paint stays on the image, and a grayscale preview is still colorized. SVG and EPS export already write only grayscale images colorized. --- crates/doc/src/links.rs | 7 ++++++ crates/engine/src/cmd/flatten/options.rs | 9 ++++++-- crates/engine/src/tests/colorize.rs | 21 ++++++++++++++++++ crates/pdf/src/export/nodes.rs | 2 +- crates/pdf/src/print/plates.rs | 17 +++++++++----- crates/pdf/src/tests/colorize.rs | 28 ++++++++++++++++++++++++ crates/render/src/renderer/images.rs | 18 ++++++++++----- crates/render/src/renderer/nodes.rs | 6 ++--- crates/render/src/tests/colorize.rs | 21 ++++++++++++++++++ 9 files changed, 112 insertions(+), 17 deletions(-) diff --git a/crates/doc/src/links.rs b/crates/doc/src/links.rs index 08121b566..781e61e68 100644 --- a/crates/doc/src/links.rs +++ b/crates/doc/src/links.rs @@ -180,6 +180,13 @@ impl ImageBlob { pub fn is_proxy(&self) -> bool { self.proxy.as_ref().is_some_and(|p| Arc::ptr_eq(p, &self.bytes)) } + + /// Does a colorize paint ([`crate::NodeExtras::colorize`]) show on these bytes? Not while + /// they are a preview whose pixels aren't grayscale, such as [`missing_placeholder`]: until + /// the file is relinked the image shows the preview as it is, and keeps the paint. + pub fn takes_colorize(&self) -> bool { + !self.is_proxy() || self.gray_source().is_some() + } } /// The side of [`missing_placeholder`]'s image, in pixels. diff --git a/crates/engine/src/cmd/flatten/options.rs b/crates/engine/src/cmd/flatten/options.rs index d4f18f385..52a900289 100644 --- a/crates/engine/src/cmd/flatten/options.rs +++ b/crates/engine/src/cmd/flatten/options.rs @@ -151,11 +151,16 @@ impl FlattenOptions { /// Whether colorized image `n`, art of `d`, counts as transparency by itself. With /// [`Self::keeps_colorize`], an image colorized with a solid paint whose pixels are grayscale - /// or 1-bit doesn't: the EPS writer draws it in that paint. + /// or 1-bit doesn't: the EPS writer draws it in that paint. An image whose pixels don't take + /// the paint ([`vectorcraft_doc::ImageBlob::takes_colorize`]) never counts: it draws uncolorized. pub(crate) fn counts_colorize(&self, d: &Document, n: &Node) -> bool { let NodeKind::Image(im) = &n.kind else { return true }; + let blob = d.image_blob(im); + if blob.is_some_and(|b| !b.takes_colorize()) { + return false; + } let solid = matches!(n.extras().colorize.as_deref(), Some(Paint::Solid { .. })); - !self.keeps_colorize || !solid || d.image_blob(im).is_some_and(|b| b.gray_source().is_none()) + !self.keeps_colorize || !solid || blob.is_some_and(|b| b.gray_source().is_none()) } /// `n` shows an image whose see-through pixels count as transparency. diff --git a/crates/engine/src/tests/colorize.rs b/crates/engine/src/tests/colorize.rs index bcd590c2d..2dab48c9f 100644 --- a/crates/engine/src/tests/colorize.rs +++ b/crates/engine/src/tests/colorize.rs @@ -411,6 +411,27 @@ fn eps_export_still_flattens_a_colorize_paint_on_a_color_image() { assert!(out["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("transparency is flattened")), "{}", out["warnings"]); } +/// A linked image showing the missing-link placeholder draws uncolorized, so its colorize paint +/// doesn't flatten it in EPS or PDF 1.3 export. +#[test] +fn a_colorized_missing_link_placeholder_is_not_flattened() { + let mut s = session(); + let bytes = std::sync::Arc::new(vectorcraft_doc::links::missing_placeholder().unwrap()); + let id = image(&mut s, "pic", ImageBlob { mime: "image/png".into(), bytes: bytes.clone(), proxy: Some(bytes) }); + s.edit("Colorize", |d, _| { + d.node_mut(id).unwrap().extras_mut().colorize = Some(Box::new(Paint::solid(Color::rgb8(255, 0, 0)))); + Ok(()) + }) + .unwrap(); + let flattened = + |out: &serde_json::Value| out["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("transparency is flattened")); + let eps = s.execute("document.exportEps", &json!({"previewFormat": "none", "useArtboards": false})).unwrap(); + assert!(!flattened(&eps), "{}", eps["warnings"]); + let pdf = s.execute("document.exportPdf", &json!({"compatibility": "1.3"})).unwrap(); + assert!(!flattened(&pdf), "{}", pdf["warnings"]); + assert_eq!(colorize(&s, id), Some(Paint::solid(Color::rgb8(255, 0, 0))), "the image keeps its paint"); +} + #[test] fn flattened_colorized_image_shows_the_art_under_white_pixels() { let mut s = session(); diff --git a/crates/pdf/src/export/nodes.rs b/crates/pdf/src/export/nodes.rs index 80a95ad16..389552948 100644 --- a/crates/pdf/src/export/nodes.rs +++ b/crates/pdf/src/export/nodes.rs @@ -196,7 +196,7 @@ impl Exporter<'_> { } NodeKind::Text(t) => self.text(s, n, t, page), NodeKind::Image(im) => match n.extras().colorize.as_deref() { - Some(p @ Paint::Solid { .. }) => { + Some(p @ Paint::Solid { .. }) if self.doc.image_blob(im).is_none_or(vectorcraft_doc::ImageBlob::takes_colorize) => { if writable(s, im.xf, Rect::new(0.0, 0.0, im.width.max(1).into(), im.height.max(1).into())) { if let Some(colorized) = self.colorized_image(im, p, !n.extras().pixelated) { self.image(s, im, Some(colorized), n.extras().pixelated); diff --git a/crates/pdf/src/print/plates.rs b/crates/pdf/src/print/plates.rs index 7c8499642..a46d1137a 100644 --- a/crates/pdf/src/print/plates.rs +++ b/crates/pdf/src/print/plates.rs @@ -9,7 +9,7 @@ use vectorcraft_color::cms::{self, Cms, Model, ProfileKind}; use vectorcraft_color::swatch::REGISTRATION; use vectorcraft_doc::inks::{Link, inks, plates, visit_node_colors}; use vectorcraft_doc::overprint::darken_overprints; -use vectorcraft_doc::{ColorMode, Document, Node, NodeKind}; +use vectorcraft_doc::{ColorMode, Document, ImageBlob, Node, NodeKind}; use super::{DEFAULT_FREQUENCY, PrintInk, PrintSettings}; @@ -120,7 +120,7 @@ impl<'a> Separator<'a> { for n in nodes { darken_overprints(n, discard_white); self.node(plate, Arc::make_mut(n)); - keep_colorized_pixels(n, &mut colorized); + keep_colorized_pixels(n, &d.images, &mut colorized); } // A colorized image's pixels say where its color prints, on every plate: they are kept // under their own key, and only the color is separated. @@ -153,13 +153,20 @@ fn colorized_key(key: &str) -> String { } /// Point the colorized images in `n`'s subtree at [`colorized_key`], collecting their keys in -/// `keys`. -fn keep_colorized_pixels(n: &mut Arc, keys: &mut BTreeSet) { +/// `keys`. An image whose pixels in `images` don't take its colorize paint +/// ([`ImageBlob::takes_colorize`]) loses the paint instead, and is separated as the image it shows. +fn keep_colorized_pixels(n: &mut Arc, images: &BTreeMap, keys: &mut BTreeSet) { let colorized = |n: &Node| matches!(n.kind, NodeKind::Image(ref im) if im.pixels.is_none()) && n.extras().colorize.is_some(); if !colorized(n) && n.children().is_none_or(|ch| ch.is_empty()) { return; } let node = Arc::make_mut(n); + if colorized(node) + && let NodeKind::Image(im) = &node.kind + && images.get(&im.key).is_some_and(|b| !b.takes_colorize()) + { + node.extras_mut().colorize = None; + } if colorized(node) && let NodeKind::Image(im) = &mut node.kind { @@ -168,7 +175,7 @@ fn keep_colorized_pixels(n: &mut Arc, keys: &mut BTreeSet) { } if let Some(ch) = node.children_mut() { for c in ch.iter_mut() { - keep_colorized_pixels(c, keys); + keep_colorized_pixels(c, images, keys); } } } diff --git a/crates/pdf/src/tests/colorize.rs b/crates/pdf/src/tests/colorize.rs index 406ae08b8..5ec2e813a 100644 --- a/crates/pdf/src/tests/colorize.rs +++ b/crates/pdf/src/tests/colorize.rs @@ -166,6 +166,34 @@ fn separations_print_the_image_on_its_ink_plate() { } } +/// A linked image showing the missing-link placeholder is written in the placeholder's own colors +/// though it keeps a colorize paint for its file, and printed separations separate it as an +/// uncolorized image. +#[test] +fn a_colorized_missing_link_placeholder_is_written_in_its_own_colors() { + let placeholder = || { + let mut d = doc(ColorMode::Cmyk, None); + let bytes = Arc::new(vectorcraft_doc::links::missing_placeholder().unwrap()); + d.images.insert("pic".into(), ImageBlob { mime: "image/png".into(), bytes: bytes.clone(), proxy: Some(bytes) }); + d + }; + let plain = placeholder(); + let mut d = placeholder(); + let id = d.layers[0].children().unwrap()[0].id; + d.node_mut(id).unwrap().extras_mut().colorize = Some(Box::new(Paint::solid(Color::cmyk(1.0, 0.0, 0.0, 0.0)))); + let (colorized, uncolorized) = (images(&pdf(&d)), images(&pdf(&plain))); + assert!(!colorized.is_empty()); + assert_eq!(colorized, uncolorized, "the placeholder as it is, without a soft mask from its coverage"); + let settings: PrintSettings = serde_json::from_value(serde_json::json!({"output": {"mode": "separations"}})).unwrap(); + let job = plan(&d, &PrintOptions { settings, ..Default::default() }).unwrap(); + assert!(!job.plates.is_empty()); + for p in &job.plates { + let n = &p.layers[0].children().unwrap()[0]; + let NodeKind::Image(im) = &n.kind else { panic!("the image") }; + assert_eq!((im.key.as_str(), n.extras().colorize.is_none()), ("pic", true), "separated as the image it shows"); + } +} + /// A pixelated colorized image (an envelope's with Anti-Alias off) is written without /// /Interpolate, as an uncolorized one is. #[test] diff --git a/crates/render/src/renderer/images.rs b/crates/render/src/renderer/images.rs index 6cabfc5bf..15997e797 100644 --- a/crates/render/src/renderer/images.rs +++ b/crates/render/src/renderer/images.rs @@ -1,14 +1,20 @@ use super::{ - Affine, Arc, BezPath, Document, FillRule, Frame, HashMap, Ink, Node, Pixmap, Rect, RenderContext, Renderer, Shape, blob_key, blob_pixmap, - fill_rule, image_mip, ink, lookup, mip_level, peniko, proof, vello_cpu, + Affine, Arc, BezPath, Document, FillRule, Frame, HashMap, Ink, Node, NodeKind, Pixmap, Rect, RenderContext, Renderer, Shape, blob_key, + blob_pixmap, fill_rule, image_mip, ink, lookup, mip_level, peniko, proof, vello_cpu, }; use crate::images; -/// The color image node `n` is colorized with ([`vectorcraft_doc::NodeExtras::colorize`]), if it -/// is, as `ink` paints it: the display color, or on an ink plane the color's own inks, as a fill -/// of that color paints them. -pub(super) fn image_tint(n: &Node, ink: Ink) -> Option<[u8; 3]> { +/// The color image node `n` of `doc` is colorized with ([`vectorcraft_doc::NodeExtras::colorize`]), +/// if it is and its pixels take it ([`vectorcraft_doc::ImageBlob::takes_colorize`]), as `ink` +/// paints it: the display color, or on an ink plane the color's own inks, as a fill of that color +/// paints them. +pub(super) fn image_tint(doc: &Document, n: &Node, ink: Ink) -> Option<[u8; 3]> { let c = n.extras().colorize.as_deref()?.color()?; + if let NodeKind::Image(im) = &n.kind + && doc.image_blob(im).is_some_and(|b| !b.takes_colorize()) + { + return None; + } Some(ink.rgb(&c).map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)) } diff --git a/crates/render/src/renderer/nodes.rs b/crates/render/src/renderer/nodes.rs index b69ecb10f..176739c31 100644 --- a/crates/render/src/renderer/nodes.rs +++ b/crates/render/src/renderer/nodes.rs @@ -218,7 +218,7 @@ impl Renderer { && let Some(region) = self.clip_of(&m.art) && shape_masked_image(a, im, ®ion.0) { - self.draw_image_in(ctx, f, im, Some(®ion), images::image_tint(a, f.ink), a.extras().pixelated); + self.draw_image_in(ctx, f, im, Some(®ion), images::image_tint(f.doc, a, f.ink), a.extras().pixelated); self.stats.drawn += 1; return; } @@ -486,7 +486,7 @@ impl Renderer { && let NodeKind::Image(im) = &image.kind && plain_image(image, im, ®ion.0) { - self.draw_image_in(ctx, f, im, Some(®ion), images::image_tint(image, f.ink), image.extras().pixelated); + self.draw_image_in(ctx, f, im, Some(®ion), images::image_tint(f.doc, image, f.ink), image.extras().pixelated); self.stats.drawn += 1; return; } @@ -536,7 +536,7 @@ impl Renderer { } } NodeKind::Text(t) => self.draw_text(ctx, f, n, t), - NodeKind::Image(im) => self.draw_image_in(ctx, f, im, None, images::image_tint(n, f.ink), n.extras().pixelated), + NodeKind::Image(im) => self.draw_image_in(ctx, f, im, None, images::image_tint(f.doc, n, f.ink), n.extras().pixelated), NodeKind::SymbolInstance { symbol, xf } => { if let Some(sym) = f.doc.symbols.iter().find(|s| &s.name == symbol) { // The instance draws when the frame has room for its art's objects; a symbol diff --git a/crates/render/src/tests/colorize.rs b/crates/render/src/tests/colorize.rs index 15eb3c5a0..49e14e5e6 100644 --- a/crates/render/src/tests/colorize.rs +++ b/crates/render/src/tests/colorize.rs @@ -142,6 +142,27 @@ fn colorize_dims_with_the_grays() { assert!(px[1].abs_diff(128) <= 3 && px[2].abs_diff(127) <= 3 && px[0] <= 2, "got {px:?}"); } +/// A linked image showing the missing-link placeholder draws it in its own colors though it keeps +/// a colorize paint for its file; a grayscale preview still draws colorized. +#[test] +fn a_colorized_missing_link_placeholder_draws_in_its_own_colors() { + let proxy = |bytes: Vec| { + let bytes = std::sync::Arc::new(bytes); + ImageBlob { mime: "image/png".into(), bytes: bytes.clone(), proxy: Some(bytes) } + }; + let blue = Paint::solid(Color::rgb(0.0, 0.0, 1.0)); + let sharp = RenderOptions { smooth_images: false, ..Default::default() }; + let placeholder = proxy(vectorcraft_doc::links::missing_placeholder().unwrap()); + // 64 pixels at 0.5 pt: (16, 5) is the light tile, (5, 5) the cross. + let d = doc_of(placeholder, Some(blue.clone()), (64, 64), 0.5); + let out = Renderer::new().render(&d, 40, 20, Affine::IDENTITY, &sharp); + assert!(near(out.pixel(16, 5), [236, 236, 236, 255], 2), "the light tile, got {:?}", out.pixel(16, 5)); + assert!(near(out.pixel(5, 5), [200, 60, 60, 255], 2), "the red cross, got {:?}", out.pixel(5, 5)); + let d = doc(proxy(gray_png()), Some(blue)); + let px = Renderer::new().render(&d, 40, 20, Affine::IDENTITY, &sharp).pixel(10, 10); + assert!(px[2] >= 250 && px[0] <= 5 && px[1] <= 5, "a grayscale preview takes the color, got {px:?}"); +} + /// Separations Preview shows a spot-colorized image on its ink's plate only. #[test] fn separations_preview_shows_the_image_on_its_spot_plate() {