Images: SVG and EPS export write colorized grayscale and 1-bit images in their fill color

SVG export ignored an image's colorize paint and wrote the image's own
black, white and gray pixels. EPS export flattened every colorized image
into a process-color raster, so a spot color lost its plate. Both now
write the image in the fill color, with the coverage the canvas and PDF
export use (darkness times alpha), so white and transparent pixels take
no color.

- SVG writes a coverage PNG as a mask over a rectangle in the color,
  shared between repeated images while each keeps its own color. Linked
  mode writes the coverage PNG as a file, and SVG Tiny 1.2 writes a PNG
  in the color. A colorized image whose pixels are missing, or whose
  grayscale pixels can't be decoded, is left out with a warning.
- EPS writes fully covered or uncovered pixels as an imagemask in the
  color's process or Separation ink (Registration on every plate), and a
  grayscale image as ink samples scaled by coverage. Level 3 masks its
  uncovered pixels out and Level 2 writes them white; gray midtones are
  written over white, with the existing warnings.
- EPS flattening no longer counts a solid colorize paint on a grayscale
  or 1-bit image as transparency by itself. Opacity, blending modes,
  opacity masks, effects and overlapping transparent art still flatten
  it, and so does a colorize paint the writer can't draw.

The svg crate's tests turn on resvg's raster-images feature to render
the embedded PNGs.

Docs: docs/mcp.md, docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-11-01 00:35:27 +07:00
committed by bflatastic
parent 93e27cafd3
commit bb6cbfb2b4
13 changed files with 795 additions and 15 deletions
+155 -1
View File
@@ -119,6 +119,8 @@ pub(crate) struct Scene<'a> {
brushes: Option<Vec<vectorcraft_brush::Brush>>,
/// Image keys → what draws them (`None`: left out).
images: HashMap<String, Option<Arc<String>>>,
/// The source key and solid paint together identify a colorized encoding.
colorized_images: HashMap<(String, String), Option<Arc<String>>>,
/// Symbols and brush art being written inside one another.
nest: u32,
}
@@ -137,6 +139,7 @@ impl<'a> Scene<'a> {
warnings: vec![],
brushes: None,
images: HashMap::new(),
colorized_images: HashMap::new(),
nest: 0,
}
}
@@ -297,7 +300,15 @@ impl<'a> Scene<'a> {
self.shape(n, &bp, *rule);
}
NodeKind::Text(t) => self.text(n, t),
NodeKind::Image(im) => self.image(&im.key, self.doc.image_blob(im), im.width, im.height, im.xf),
NodeKind::Image(im) => {
let blob = self.doc.image_blob(im);
match n.extras().colorize.as_deref() {
Some(p @ Paint::Solid { .. }) if blob.is_none_or(|b| b.gray_source().is_some()) => {
self.colorized_image(&im.key, blob, im.width, im.height, im.xf, p);
}
_ => self.image(&im.key, blob, im.width, im.height, im.xf),
}
}
NodeKind::SymbolInstance { symbol, xf } => {
if let Some(sym) = self.doc.symbols.iter().find(|s| &s.name == symbol)
&& self.nest < MAX_NEST
@@ -886,6 +897,149 @@ impl<'a> Scene<'a> {
}
}
/// Gray coverage in the selected process or Separation ink. Binary coverage is a stencil;
/// continuous coverage leaves zero pixels untouched at Level 3 and writes them as paper at Level 2.
fn colorized_image(&mut self, key: &str, blob: Option<&ImageBlob>, width: u32, height: u32, xf: Affine, paint: &Paint) {
let place = xf * Affine::scale_non_uniform(f64::from(width.max(1)), f64::from(height.max(1)));
if !invertible(place) {
return;
}
let cache = (key.to_string(), format!("{paint:?}"));
let encoded = match self.colorized_images.get(&cache) {
Some(e) => e.clone(),
None => {
let e = self.encode_colorized(blob, paint).map(Arc::new);
self.colorized_images.insert(cache, e.clone());
e
}
};
if let Some(e) = encoded {
self.place_image(&e, place);
}
}
fn encode_colorized(&mut self, blob: Option<&ImageBlob>, paint: &Paint) -> Option<String> {
let Some((w, h, coverage)) = blob.and_then(ImageBlob::coverage) else {
self.warn(UNDECODED);
return None;
};
if w == 0 || h == 0 || u64::from(w) * u64::from(h) > MAX_IMAGE_PIXELS {
self.warn(LARGE_IMAGE);
return None;
}
let Paint::Solid { color, swatch, tint } = paint else { return None };
let ink = self.solid(color, swatch.as_deref(), *tint);
let binary = coverage.iter().all(|&v| v == 0 || v == 255);
let clear = coverage.contains(&0);
if coverage.iter().any(|&v| v > 0 && v < 255) {
self.warn(IMAGE_PARTLY);
}
let keyed = !binary && clear && self.o.level == Level::Three;
if !binary && clear && !keyed {
self.warn(IMAGE_ALPHA_L2);
}
// Zero tint is uncommon for covered spot pixels; process uses the same rare key as
// ordinary images. Nudge a covered collision so only clear pixels match the mask.
let key = match ink {
Ink::Spot { .. } => [0, 0, 0, 0],
Ink::Process(Space::Gray, ..) => [0, 0, 0, 0],
Ink::Process(..) => [255, 0, 255, 0],
};
let mut data = Vec::new();
if binary {
let stride = (w as usize).div_ceil(8);
data.resize(stride.saturating_mul(h as usize), 0);
for (i, &v) in coverage.iter().enumerate() {
if v == 255
&& let Some(byte) = data.get_mut((i / w as usize) * stride + (i % w as usize) / 8)
{
*byte |= 0x80 >> (i % w as usize % 8);
}
}
} else {
for &v in &coverage {
if v == 0 && keyed {
let n = match ink {
Ink::Spot { .. } => 1,
Ink::Process(space, ..) => space.n(),
};
data.extend(key.iter().take(n));
continue;
}
let a = f32::from(v) / 255.0;
match ink {
Ink::Spot { tint, .. } => {
let mut sample = (255.0 * tint * a).round() as u8;
if keyed && sample == key[0] {
sample = 1;
}
data.push(sample);
}
Ink::Process(space, components) => {
let paper = if space == Space::Cmyk { 0.0 } else { 1.0 };
let n = space.n();
let mut samples = [0; 4];
for (sample, &component) in samples.iter_mut().zip(components.iter()).take(n) {
*sample = (255.0 * (paper + (component - paper) * a)).round() as u8;
}
if keyed && samples.iter().take(n).eq(key.iter().take(n)) {
samples[if n > 1 { 1 } else { 0 }] = 1;
}
data.extend(samples.iter().take(n));
}
}
}
}
let (filter, encoded) = match self.o.level {
Level::Three => ("/FlateDecode", ps::deflate(&data)),
Level::Two => {
let mut rl = Vec::new();
ps::packbits(&data, &mut rl);
rl.push(128);
("/RunLengthDecode", rl)
}
};
let mut out = String::new();
if binary {
// imagemask takes its current ink from the graphics state.
out.push_str(&self.ink_code(ink));
} else {
match ink {
Ink::Spot { space, .. } => {
let _ = writeln!(out, "VCsp{space} setcolorspace");
}
Ink::Process(space, ..) => {
let _ = writeln!(out, "{} setcolorspace", space.name());
}
}
}
let n = match ink {
Ink::Spot { .. } => 1,
Ink::Process(space, ..) => space.n(),
};
let decode = if binary { "1 0".to_string() } else { "0 1 ".repeat(n).trim_end().to_string() };
let _ = write!(
out,
"{{ /VCsrc currentfile /ASCII85Decode filter def\n<< /ImageType {} /Width {w} /Height {h} /BitsPerComponent {} /Decode [{decode}] /ImageMatrix [{w} 0 0 {h} 0 0]",
if keyed { 4 } else { 1 },
if binary { 1 } else { 8 },
);
if keyed {
let mask: Vec<String> = key.iter().take(n).map(|v| format!("{v} {v}")).collect();
let _ = write!(out, " /MaskColor [{}]", mask.join(" "));
}
let _ = writeln!(out, "\n/DataSource VCsrc {filter} filter >> {}\nVCsrc flushfile }} exec", if binary { "imagemask" } else { "image" });
out.push_str(&ps::ascii85(&encoded));
Some(out)
}
/// Ink-setting operators in the cached stencil body, built with the same path as solid fills.
fn ink_code(&mut self, ink: Ink) -> String {
let body = std::mem::take(&mut self.body);
self.set_ink(ink);
std::mem::replace(&mut self.body, body)
}
/// Image `blob` as RGBA pixels; `None` (with a warning) when it can't be written.
fn decode_image(&mut self, blob: Option<&ImageBlob>) -> Option<image::RgbaImage> {
let Some(img) = blob.and_then(|blob| image::load_from_memory(&blob.bytes).ok()) else {
+181
View File
@@ -331,6 +331,187 @@ fn unpack(data: &[u8]) -> Vec<u8> {
out
}
fn colorized_doc(gray: Vec<u8>, paint: Option<Paint>) -> Document {
let mut d = Document::new(200.0, 200.0);
d.swatches.push(Swatch { name: "Ink".into(), paint: Paint::solid(Color::cmyk(0.0, 1.0, 1.0, 0.0)), global: true, spot: true });
let mut png = Vec::new();
image::DynamicImage::ImageLuma8(image::GrayImage::from_raw(gray.len() as u32, 1, gray).unwrap())
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
d.images.insert("gray".into(), ImageBlob::png(png));
let im = ImageObject {
key: "gray".into(),
width: 3,
height: 1,
xf: Affine::translate((10.0, 10.0)) * Affine::scale(10.0),
link: None,
placement: Default::default(),
pixels: None,
};
let mut n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)));
n.extras_mut().colorize = paint.map(Box::new);
d.insert(d.default_layer(), 0, n).unwrap();
d
}
fn one_bit_blob() -> ImageBlob {
// Uncompressed two-pixel TIFF, BlackIsZero; black then white.
let entries: [(u16, u16, u32); 9] =
[(256, 3, 2), (257, 3, 1), (258, 3, 1), (259, 3, 1), (262, 3, 1), (273, 4, 122), (277, 3, 1), (278, 3, 1), (279, 4, 1)];
let mut bytes = b"II*\0".to_vec();
bytes.extend_from_slice(&8u32.to_le_bytes());
bytes.extend_from_slice(&9u16.to_le_bytes());
for (tag, kind, value) in entries {
bytes.extend_from_slice(&tag.to_le_bytes());
bytes.extend_from_slice(&kind.to_le_bytes());
bytes.extend_from_slice(&1u32.to_le_bytes());
bytes.extend_from_slice(&value.to_le_bytes());
}
bytes.extend_from_slice(&0u32.to_le_bytes());
bytes.push(0b0100_0000);
ImageBlob::new("image/tiff", bytes)
}
fn colorized_samples(d: &Document, level: Level) -> (String, Vec<u8>, Vec<String>) {
let out = export(d, &EpsOptions { level, ..opts(d) }, None, None).unwrap();
let ps = text(&out);
checked_page(&ps);
let encoded = image_data(&ps);
let samples = if level == Level::Three { ps::inflate(&encoded, 1 << 20).unwrap() } else { unpack(&encoded) };
(ps, samples, out.warnings)
}
#[test]
fn binary_colorized_images_are_ink_stencils_at_both_levels() {
for (paint, setup) in [
(Paint::solid(Color::rgb8(255, 0, 0)), "1 0 0 rg"),
(Paint::Solid { color: Color::rgb8(255, 0, 0), swatch: Some("Ink".into()), tint: 0.5 }, "VCsp0 setcolorspace 0.5 setcolor"),
(Paint::registration(), "VCsp0 setcolorspace 1 setcolor"),
] {
let d = colorized_doc(vec![0, 255, 0], Some(paint.clone()));
for level in [Level::Two, Level::Three] {
let (ps, samples, warnings) = colorized_samples(&d, level);
assert!(ps.contains("imagemask") && ps.contains("/BitsPerComponent 1 /Decode [1 0]"), "{ps}");
assert!(ps.contains(setup), "{ps}");
assert_eq!(samples, vec![0b1010_0000]);
assert!(warnings.is_empty(), "{warnings:?}");
assert!(ps.contains(if level == Level::Two { "/RunLengthDecode" } else { "/FlateDecode" }));
if matches!(paint, Paint::Solid { swatch: Some(_), .. }) {
assert!(ps.contains("/Separation"));
}
if paint.is_registration() {
assert!(ps.contains("/All /DeviceCMYK"));
assert!(dsc(&ps, "DocumentCustomColors").is_none());
} else if matches!(paint, Paint::Solid { swatch: Some(_), .. }) {
assert_eq!(dsc(&ps, "DocumentCustomColors").as_deref(), Some("(Ink)"));
}
}
}
}
#[test]
fn one_bit_source_uses_the_same_stencil_coverage() {
let mut d = colorized_doc(vec![0, 255, 255], Some(Paint::solid(Color::rgb8(0, 0, 255))));
d.images.insert("gray".into(), one_bit_blob());
let id = d.layers[0].children().unwrap()[0].id;
if let NodeKind::Image(im) = &mut d.node_mut(id).unwrap().kind {
im.width = 2;
}
assert!(d.images["gray"].gray_source().unwrap().one_bit);
let (_, samples, warnings) = colorized_samples(&d, Level::Three);
assert_eq!(samples, vec![0b1000_0000]);
assert!(warnings.is_empty());
}
#[test]
fn one_source_with_two_spot_tints_writes_two_inks() {
let first = Paint::Solid { color: Color::rgb8(255, 0, 0), swatch: Some("Ink".into()), tint: 1.0 };
let mut d = colorized_doc(vec![0, 255, 0], Some(first));
let mut n = (*d.layers[0].children().unwrap()[0]).clone();
n.id = d.alloc_id();
n.extras_mut().colorize = Some(Box::new(Paint::Solid { color: Color::rgb8(255, 128, 128), swatch: Some("Ink".into()), tint: 0.5 }));
d.insert(d.default_layer(), 1, n).unwrap();
let ps = page_of(&d, &opts(&d));
assert!(ps.contains("VCsp0 setcolorspace 1 setcolor") && ps.contains("VCsp0 setcolorspace 0.5 setcolor"), "{ps}");
assert_eq!(ps.matches("imagemask").count(), 2);
}
#[test]
fn gray_colorized_ramp_scales_process_and_spot_inks_over_paper() {
for level in [Level::Two, Level::Three] {
let process = colorized_doc(vec![0, 128, 255], Some(Paint::solid(Color::rgb8(255, 0, 0))));
let (ps, pixels, warnings) = colorized_samples(&process, level);
assert!(ps.contains("/DeviceRGB setcolorspace") && !ps.contains("imagemask"));
if level == Level::Three {
assert!(ps.contains("/ImageType 4") && ps.contains("/MaskColor [255 255 0 0 255 255]"));
assert_eq!(pixels, vec![255, 0, 0, 255, 128, 128, 255, 0, 255]);
assert_eq!(warnings, vec!["partly transparent image pixels are written over white"]);
} else {
assert!(ps.contains("/ImageType 1") && !ps.contains("/MaskColor"));
assert_eq!(pixels, vec![255, 0, 0, 255, 128, 128, 255, 255, 255]);
assert_eq!(
warnings,
vec![
"partly transparent image pixels are written over white",
"transparent image pixels are written white at PostScript Level 2 (Level 3 masks them out)"
]
);
}
let gray = colorized_doc(vec![0, 128, 255], Some(Paint::solid(Color::gray(0.5))));
let (ps, pixels, warnings) = colorized_samples(&gray, level);
assert!(ps.contains("/DeviceGray setcolorspace"));
assert_eq!(pixels, if level == Level::Three { vec![128, 192, 0] } else { vec![128, 192, 255] });
assert_eq!(ps.contains("/MaskColor [0 0]"), level == Level::Three);
assert_eq!(warnings.len(), if level == Level::Three { 1 } else { 2 });
let spot = colorized_doc(vec![0, 128, 255], Some(Paint::Solid { color: Color::rgb8(255, 128, 128), swatch: Some("Ink".into()), tint: 0.5 }));
let (ps, pixels, warnings) = colorized_samples(&spot, level);
assert!(ps.contains("/Separation (Ink) cvn") && ps.contains("VCsp0 setcolorspace"));
assert_eq!(dsc(&ps, "DocumentCustomColors").as_deref(), Some("(Ink)"));
assert_eq!(pixels, vec![128, 64, 0]);
assert_eq!(ps.contains("/MaskColor [0 0]"), level == Level::Three);
assert_eq!(warnings.len(), if level == Level::Three { 1 } else { 2 });
let mut cmyk = process.clone();
cmyk.color_mode = vectorcraft_doc::ColorMode::Cmyk;
let (ps, pixels, _) = colorized_samples(&cmyk, level);
assert!(ps.contains("/DeviceCMYK setcolorspace"));
assert_eq!(pixels.len(), 12);
assert_eq!(&pixels[8..], if level == Level::Three { &[255, 0, 255, 0][..] } else { &[0, 0, 0, 0][..] });
}
}
#[test]
fn continuous_colorized_coverage_without_clear_pixels_only_warns_for_midtones() {
let d = colorized_doc(vec![0, 128, 254], Some(Paint::solid(Color::rgb8(255, 0, 255))));
for level in [Level::Two, Level::Three] {
let (ps, pixels, warnings) = colorized_samples(&d, level);
assert!(ps.contains("/ImageType 1") && !ps.contains("/MaskColor"));
assert_eq!(&pixels[..3], &[255, 0, 255]);
assert_eq!(warnings, vec!["partly transparent image pixels are written over white"]);
}
let d = colorized_doc(vec![0, 128, 255], Some(Paint::solid(Color::rgb8(255, 0, 255))));
let (ps, pixels, _) = colorized_samples(&d, Level::Three);
assert!(ps.contains("/MaskColor [255 255 0 0 255 255]"));
assert_eq!(&pixels[..3], &[255, 1, 255]);
assert_eq!(&pixels[6..], &[255, 0, 255]);
}
#[test]
fn uncolorized_image_stays_on_original_eps_path() {
let d = colorized_doc(vec![0, 128, 255], None);
let ps = page_of(&d, &opts(&d));
assert!(!ps.contains("imagemask") && ps.contains("/ImageType 1"));
let patterned = colorized_doc(vec![0, 128, 255], Some(Paint::Pattern { pattern: "missing".into(), xf: Affine::IDENTITY }));
assert_eq!(page_of(&patterned, &opts(&patterned)), ps);
let mut color = png_doc();
let original = page_of(&color, &opts(&color));
let id = color.layers[0].children().unwrap()[0].id;
color.node_mut(id).unwrap().extras_mut().colorize = Some(Box::new(Paint::solid(Color::rgb8(255, 0, 0))));
assert_eq!(page_of(&color, &opts(&color)), original);
}
#[test]
fn run_length_and_ascii85_round_trip() {
let cycle: Vec<u8> = (0..=255u8).cycle().take(1000).collect();