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.
This commit is contained in:
dotCooCoo
2026-11-05 23:25:05 +07:00
committed by bflatastic
parent a3ec061995
commit 84904020e8
9 changed files with 112 additions and 17 deletions
+7
View File
@@ -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.
+7 -2
View File
@@ -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.
+21
View File
@@ -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();
+1 -1
View File
@@ -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);
+12 -5
View File
@@ -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<Node>, keys: &mut BTreeSet<String>) {
/// `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<Node>, images: &BTreeMap<String, ImageBlob>, keys: &mut BTreeSet<String>) {
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<Node>, keys: &mut BTreeSet<String>) {
}
if let Some(ch) = node.children_mut() {
for c in ch.iter_mut() {
keep_colorized_pixels(c, keys);
keep_colorized_pixels(c, images, keys);
}
}
}
+28
View File
@@ -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]
+12 -6
View File
@@ -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))
}
+3 -3
View File
@@ -218,7 +218,7 @@ impl Renderer {
&& let Some(region) = self.clip_of(&m.art)
&& shape_masked_image(a, im, &region.0)
{
self.draw_image_in(ctx, f, im, Some(&region), images::image_tint(a, f.ink), a.extras().pixelated);
self.draw_image_in(ctx, f, im, Some(&region), 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, &region.0)
{
self.draw_image_in(ctx, f, im, Some(&region), images::image_tint(image, f.ink), image.extras().pixelated);
self.draw_image_in(ctx, f, im, Some(&region), 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
+21
View File
@@ -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<u8>| {
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() {