diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 44bc4eff0..220bb8667 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -2763,6 +2763,10 @@ fn editing_text() -> String { "%AI5_File:\n%AI5_BeginRaster\n() 1 XG\n/DeviceCMYK XN\n[ 4 0 0 4 10 30 ] 2 1 0 Xh\n[ 4 0 0 4 10 30 ] 0 0 2 1 2 1 8 4 1 0 0 0 4 4 0 0\n\ %%BeginData: 18\rXI\nff000000000000ff80ff\n%%EndData\nXH\n%AI5_EndRaster\nN" .into(), + // A 9 × 2 1-bit image mask (ImageMask 1) colorized by the global fill before it, in hex. + "0 0 0 1 (Black global) 0.4 0 Xk\n%AI5_File:\n%AI5_BeginRaster\n() 1 XG\n/DeviceGray XN\n[ 4 0 0 4 10 30 ] 9 2 0 Xh\n\ + [ 4 0 0 4 10 30 ] 0 0 9 2 9 2 1 1 0 0 0 1 4 4 0 0\n%%BeginData: 8\rXI\n6080ff00\n%%EndData\nXH\n%AI5_EndRaster\nN" + .into(), ] .join("\n"); let layer = |name: &str, visible: u8, body: &str| { @@ -2849,6 +2853,23 @@ fn the_editing_fixture_reads_its_cmyk_image_with_its_mask() { assert!(layers && found.contains(&(Some(vec![255, 0, 0, 0, 0, 0, 0, 255]), true)), "{found:?} {warnings:?}"); } +/// The fuzzed editing data, as it is, reads its 1-bit image mask as an image colorized with the +/// global color before it. +#[test] +fn the_editing_fixture_reads_its_image_mask_colorized() { + let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&editing_text())].concat(); + let (doc, warnings, layers) = vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false, true, 1.0); + let mut found = vec![]; + doc.visit_images(|n, im| { + if let Some(vectorcraft_color::Paint::Solid { swatch, .. }) = n.extras().colorize.as_deref() { + found.push((swatch.clone(), im.width, im.height, doc.images[&im.key].coverage().map(|c| c.2))); + } + }); + // Clear bits (black) show the color; set bits (white) are transparent. + let cover = vec![255, 0, 0, 255, 255, 255, 255, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255]; + assert!(layers && found == [(Some("Black global".to_string()), 9, 2, Some(cover))], "{found:?} {warnings:?}"); +} + /// The text document of a story of centred type with two styles, as an editing copy keeps it. fn text_document_text() -> String { "/0 << /1 << /0 [ << /0 << /0 << /0 (Helvetica) >> >> >> ] >> /8 << /0 [ << /0 << /0 [ 0 0 ] /2 << /2 [ 1 0 0 1 3 4 ] >> >> >> ] >> >>\n\ diff --git a/crates/eps/src/import/ai/read/art.rs b/crates/eps/src/import/ai/read/art.rs index 521052b88..7db7fe85e 100644 --- a/crates/eps/src/import/ai/read/art.rs +++ b/crates/eps/src/import/ai/read/art.rs @@ -424,10 +424,12 @@ impl<'a> Reader<'a> { self.warn("an image VectorCraft can't read was left out"); return Ok(()); }; - let [_, _, _, _, w, h, bits, kind, alpha, _, binary, ..] = n.as_slice() else { + let [_, _, _, _, w, h, bits, kind, alpha, _, binary, rest @ ..] = n.as_slice() else { self.warn("an image VectorCraft can't read was left out"); return Ok(()); }; + // ImageMask 1: the image is transparent and takes a color (version 7 specification §8.1). + let image_mask = rest.first() == Some(&1.0); let (w, h) = (*w as u64, *h as u64); if w.saturating_mul(h) > MAX_PIXELS { self.warn(IMAGE_TOO_LARGE); @@ -470,7 +472,10 @@ impl<'a> Reader<'a> { && *bits == 8.0 && let Some(blob) = Inks::new(w as u32, h as u32, samples.to_vec()).and_then(|i| ImageBlob::cmyk_tiff(&i)) { - return self.add_raster(blob, opacity, &m, (w as u32, h as u32)); + return self.add_raster(blob, opacity, &m, (w as u32, h as u32), None); + } + if image_mask && channels == 1 && *bits == 1.0 { + return self.colorized_raster(samples, row, opacity, &m, (w as u32, h as u32)); } let mut rgba = Vec::with_capacity((w * h * 4) as usize); for y in 0..h as usize { @@ -506,13 +511,45 @@ impl<'a> Reader<'a> { self.warn("an image VectorCraft can't read was left out"); return Ok(()); } - self.add_raster(ImageBlob::new("image/png", png), None, &m, (w as u32, h as u32)) + self.add_raster(ImageBlob::new("image/png", png), None, &m, (w as u32, h as u32), None) + } + + /// A 1-bit gray image whose ImageMask is 1 (`samples`, `row` bytes a row): a grayscale image + /// colorized with the fill color. Its 0 bits, which open black with ImageMask 0, take that color, + /// and its 1 bits are transparent. A fill that isn't a solid color colorizes it black. + fn colorized_raster(&mut self, samples: &[u8], row: usize, alpha: Option<&[u8]>, m: &[f64], (w, h): (u32, u32)) -> Result<(), String> { + let mut gray = Vec::with_capacity(w as usize * h as usize); + for y in 0..h as usize { + let line = samples.get(y * row..(y + 1) * row).unwrap_or_default(); + for x in 0..w as usize { + let v = line.get(x / 8).map_or(0, |b| ((b >> (7 - x % 8)) & 1) * 255); + gray.push(v); + } + } + let mut png = vec![]; + let written = image::GrayImage::from_raw(w, h, gray).map(|img| img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)); + if !matches!(written, Some(Ok(()))) { + self.warn("an image VectorCraft can't read was left out"); + return Ok(()); + } + let paint = self.mask_paint(); + self.add_raster(ImageBlob::new("image/png", png), alpha, m, (w, h), Some(paint)) + } + + /// The color that colorizes an image mask: the fill color, or black when the fill isn't a + /// solid color. + fn mask_paint(&self) -> Paint { + match &self.gs.fill { + p @ Paint::Solid { .. } => p.clone(), + _ => Paint::solid(vectorcraft_color::Color::BLACK), + } } /// `XF` (§8.2): an image whose pixels are in the file the `XG` before it names becomes a linked /// image of that file, of its `w` × `h` pixels placed as the matrix maps them, as `XI` places an /// image. The data keeps no pixels or preview of it, so a placeholder is spread over those pixels - /// until its file is found. One without that file name, or whose operands `XI` couldn't read (no + /// until its file is found. A 1-bit image mask is colorized as `XI` colorizes one. One without + /// that file name, or whose operands `XI` couldn't read (no /// matrix, too few numbers, no pixels, or samples of other than 1 or 8 bits), is left out with a /// warning. No pixels are read, so its size isn't held to the limit on `XI`'s samples. fn linked_image(&mut self, vals: Vec, hidden: bool) -> Result<(), String> { @@ -552,13 +589,16 @@ impl<'a> Reader<'a> { self.placeholders.push(xf.transform_rect_bbox(Rect::new(0.0, 0.0, f64::from(w), f64::from(h)))); } let im = ImageObject { key, width: w, height: h, xf, link: Some(LinkInfo::new(path)), placement: Default::default(), pixels: None }; - self.add_image(im, hidden) + // A 1-bit image mask (ImageMask 1) keeps the color that colorizes it once its file is relinked. + let image_mask = bits == 1.0 && n.get(7) == Some(&1.0) && n.get(11) == Some(&1.0); + let colorize = image_mask.then(|| self.mask_paint()); + self.add_image_colorized(im, None, colorize, hidden) } /// Add the image `blob` (`w` × `h` pixels), which the matrix `m` maps into art space, with /// `alpha` (its alpha channel, one byte a pixel) as its opacity mask when it hides part of the - /// image. - fn add_raster(&mut self, blob: ImageBlob, alpha: Option<&[u8]>, m: &[f64], (w, h): (u32, u32)) -> Result<(), String> { + /// image, and `colorize` as the color that colorizes it. + fn add_raster(&mut self, blob: ImageBlob, alpha: Option<&[u8]>, m: &[f64], (w, h): (u32, u32), colorize: Option) -> Result<(), String> { // The matrix is an art-space matrix (y up) applied to the pixels with their rows running // downwards, (u, -v): pixels → (u, -v) → art space. Flipping the matrix's result instead // gives the same place only when the image isn't rotated (b = c = 0). @@ -575,7 +615,8 @@ impl<'a> Reader<'a> { let mask = alpha.and_then(|a| self.alpha_mask(a, w, h, xf)); let key = blob.content_key(); self.doc.images.entry(key.clone()).or_insert(blob); - self.add_image_masked(ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default(), pixels: None }, mask, false) + let im = ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default(), pixels: None }; + self.add_image_colorized(im, mask, colorize, false) } /// An image's alpha channel (`w` × `h` bytes) as a grayscale image over the image (`xf`): its @@ -603,11 +644,18 @@ impl<'a> Reader<'a> { /// Add an image object with the state's transparency and `mask` as its opacity mask. fn add_image_masked(&mut self, im: ImageObject, mask: Option, hidden: bool) -> Result<(), String> { + self.add_image_colorized(im, mask, None, hidden) + } + + /// Add an image object with the state's transparency, `mask` as its opacity mask and + /// `colorize` as the color that colorizes it. + fn add_image_colorized(&mut self, im: ImageObject, mask: Option, colorize: Option, hidden: bool) -> Result<(), String> { let gs = self.gs.clone(); let mut node = self.new_node(NodeKind::Image(Box::new(im)), &gs); node.opacity = gs.opacity; node.blend = gs.blend; node.extras_mut().mask = mask.map(Box::new); + node.extras_mut().colorize = colorize.map(Box::new); self.add(node, hidden)?; self.image_tail = self.after_object; Ok(()) diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 5203b1695..20c2b6e1c 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -2173,6 +2173,61 @@ fn one_bit_images_read_each_channel() { assert_eq!((0..3).map(|x| img.get_pixel(x, 0).0[0]).collect::>(), [255, 0, 255]); } +/// A gray image `w` × 1 in hex with ImageMask `mask` and `bits` bits a pixel, after the fill `fill`. +fn masked_gray(fill: &str, bits: u32, mask: u32, w: u32, hex: &[u8]) -> Vec { + let text = String::from_utf8(image("[ 1 0 0 1 0 100 ]", (w, 1), ("DeviceGray", 1, 0), hex, hex.len())).unwrap(); + text.replacen(" 8 1 0 0 1 0 ", &format!(" {bits} 1 0 0 0 {mask} "), 1).replacen("(L) Ln\n", &format!("(L) Ln\n{fill}"), 1).into_bytes() +} + +/// ImageMask 1 on a 1-bit gray image: the image is colorized with the fill color, so its black +/// pixels take the color and its white ones are transparent. ImageMask 0 keeps the opaque image. +#[test] +fn image_masks_are_colorized_with_the_fill() { + // 4 × 1 at 1 bit: black, white, white, black (0110). + let r = read(&masked_gray("0 0 1 0 k\n", 1, 1, 4, b"60")).unwrap(); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + let a = art(&r.doc); + let Some(Paint::Solid { color, .. }) = a[0].extras().colorize.as_deref() else { panic!("{:?}", a[0].extras().colorize) }; + assert_eq!(color.to_rgba8(1.0), Color::cmyk(0.0, 0.0, 1.0, 0.0).to_rgba8(1.0)); + let NodeKind::Image(im) = &a[0].kind else { panic!("{:?}", a[0].kind) }; + let blob = &r.doc.images[&im.key]; + assert!(blob.gray_source().is_some_and(|g| !g.alpha), "an image a fill color can colorize"); + let img = pixels(&r.doc).0; + assert_eq!( + (0..4).map(|x| img.get_pixel(x, 0).0).collect::>(), + [[0, 0, 0, 255], [255, 255, 255, 255], [255, 255, 255, 255], [0, 0, 0, 255]] + ); + // How much of the color each pixel shows: the black ones only. + assert_eq!(blob.coverage().map(|c| c.2), Some(vec![255, 0, 0, 255])); + // A spot color keeps its swatch and tint. + let spot = "0 0 1 0 (Ink) 0.5 0 Xx\n"; + let a = art(&read(&masked_gray(spot, 1, 1, 4, b"60")).unwrap().doc); + assert!( + matches!(a[0].extras().colorize.as_deref(), Some(Paint::Solid { swatch: Some(s), tint, .. }) if s == "Ink" && (*tint - 0.5).abs() < 1e-6), + "{:?}", + a[0].extras().colorize + ); + // A fill that isn't a solid color (here a pattern) colorizes it black. + let patterned = String::from_utf8(masked_gray("(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n", 1, 1, 4, b"60")).unwrap(); + let doc = read(patterned.replacen("%AI5_BeginLayer", &format!("{DOTS}%AI5_BeginLayer"), 1).as_bytes()).unwrap().doc; + let a = art(&doc); + let Some(Paint::Solid { color, swatch: None, .. }) = a[0].extras().colorize.as_deref() else { panic!("{:?}", a[0].extras().colorize) }; + assert_eq!(color.to_rgba8(1.0), Color::BLACK.to_rgba8(1.0)); + // An 8-bit gray image with ImageMask 1 opens as before, with no color. + let doc = read(&masked_gray("0 1 0 0 k\n", 8, 1, 2, b"00ff")).unwrap().doc; + assert!(art(&doc)[0].extras().colorize.is_none()); + assert_eq!(pixels(&doc).0.get_pixel(1, 0).0, [255, 255, 255, 255]); + // ImageMask 0: the opaque image, with no color. + let doc = read(&masked_gray("0 0 1 0 k\n", 1, 0, 4, b"60")).unwrap().doc; + assert!(art(&doc)[0].extras().colorize.is_none()); + assert_eq!(pixels(&doc).0.get_pixel(0, 0).0, [0, 0, 0, 255]); + // An RGB image can't take a color: ImageMask 1 leaves it as it is. + let text = String::from_utf8(image("[ 1 0 0 1 0 100 ]", (1, 1), ("DeviceRGB", 3, 0), b"ff0000", 6)).unwrap(); + let doc = read(text.replacen(" 8 3 0 0 1 0 ", " 8 3 0 0 0 1 ", 1).as_bytes()).unwrap().doc; + assert!(art(&doc)[0].extras().colorize.is_none()); + assert_eq!(pixels(&doc).0.get_pixel(0, 0).0, [255, 0, 0, 255]); +} + #[test] fn images_with_alpha_channels() { // RGB samples, then the alpha channel (not counted in the data's size). @@ -2262,6 +2317,27 @@ fn raster7(head: &str, matrix: &str, (w, h): (u32, u32), op: &str, tail: &str) - format!("%AI5_File:\n%AI5_BeginRaster\n{head}{matrix} 0 0 {w} {h} {w} {h} 8 3 0 0 0 0 {op}\n{tail}%AI5_EndRaster\n") } +/// A linked 1-bit gray image with ImageMask 1 keeps the fill color as the color that colorizes it, +/// as an embedded one does. Other linked images have no color. +#[test] +fn linked_image_masks_are_colorized_with_the_fill() { + let m = "[ 1 0 0 1 0 0 ]"; + let linked = |bits_kind_mask: &str| { + let xf = raster7("(a.tif) 0 XG\n", m, (2, 2), "XF", "").replace(" 8 3 0 0 0 0 XF", &format!(" {bits_kind_mask} XF")); + let r = read(stream7(&layer7("L", &format!("0 0 1 0 (Ink) 0.5 0 Xx\n{xf}"))).as_bytes()).unwrap(); + let a = art(&r.doc); + assert!(matches!(&a[0].kind, NodeKind::Image(im) if im.link.is_some()), "{:?} {:?}", a, r.warnings); + a[0].extras().colorize.as_deref().cloned() + }; + assert!( + matches!(linked("1 1 0 0 0 1"), Some(Paint::Solid { swatch: Some(s), tint, .. }) if s == "Ink" && (tint - 0.5).abs() < 1e-6), + "a 1-bit image mask" + ); + assert_eq!(linked("1 1 0 0 0 0"), None, "ImageMask 0"); + assert_eq!(linked("8 1 0 0 0 1"), None, "8-bit gray"); + assert_eq!(linked("8 3 0 0 0 1"), None, "RGB"); +} + /// A linked image in the version 7 form (§8.2, §8.3), `(path) modified XG` and then `XF` with no /// samples, is a linked image of that file. The data keeps no pixels of it, so it shows a /// placeholder over the place the matrix maps the image's pixels to, as an embedded image (`XI`) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index cea00e1b3..b2ee69583 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -130,6 +130,9 @@ private editing data. VectorCraft: with a fill's offset, scale, rotation, reflection and shear applied before its matrix. Its linked images in the version 7 form (`(path) modified XG`, then `XF` with no samples) come in as linked images of their files, showing a placeholder where the image goes until the file is relinked in the Links panel. + A 1-bit gray image whose `XI` or `XF` has ImageMask 1 comes in colorized by the fill color set before it, + or by black when that fill is not a solid color. VectorCraft's own rule is that the pixels that open black + with ImageMask 0 take the color and the others are transparent. - Estimate: **~65%** for real exchange with Illustrator users (open: ~75%; save: PDF-compatible, or legacy version 7 with layers and groups; not yet tested by users who hand files back). Closing the open-side issues: 10–20 h with users' files.