diff --git a/crates/engine/src/cmd/fileio/legacy_ai.rs b/crates/engine/src/cmd/fileio/legacy_ai.rs index d4820242d..691c0e836 100644 --- a/crates/engine/src/cmd/fileio/legacy_ai.rs +++ b/crates/engine/src/cmd/fileio/legacy_ai.rs @@ -20,7 +20,9 @@ //! pixels, or opacity or a blending mode on the image, the fill, the shape or a group it is in) is //! expanded into its tiles first so its images flatten too. With `omit` the images with //! see-through pixels, opacity or a blending mode of their own are left out, and pattern fills stay -//! as the writer draws them (their tiles opaque over white). An image that can't be decoded is +//! as the writer draws them (their tiles opaque over white). A colorized image is flattened or +//! left out the same way, unless its pixels are all black or white: the writer writes that one as +//! a 1-bit image mask in its color, which keeps its white pixels clear. An image that can't be decoded is //! missing from the images that flattening and expanding raster effects draw, and the warnings //! then carry the raster exports' note once. //! @@ -179,8 +181,10 @@ fn expand_image_patterns(d: &mut Document) -> Result<()> { } /// The images of `d` a legacy `.ai` can't hold, in paint order with their places in it (as -/// [`Document::walk`] counts). These have see-through pixels or a colorize paint, or have opacity -/// or a blending mode of their own or, with `flatten`, of a group they are in. +/// [`Document::walk`] counts). These have see-through pixels or a colorize paint (except the +/// colorized images the writer writes as image masks, clear pixels included, +/// [`vectorcraft_eps::writes_image_mask`]), or +/// have opacity or a blending mode of their own or, with `flatten`, of a group they are in. /// With `flatten` only visible images are returned, because a hidden one (which the hidden objects /// option may write) is written over white. With `omit` all of them are returned. fn see_through_images(d: &Document, mode: SeeThrough) -> Vec<(usize, NodeId)> { @@ -189,8 +193,7 @@ fn see_through_images(d: &Document, mode: SeeThrough) -> Vec<(usize, NodeId)> { let mut at = 0; d.walk(|n| { if let NodeKind::Image(im) = &n.kind - && (keys.contains(&im.key) - || n.extras().colorize.is_some() + && (((keys.contains(&im.key) || n.extras().colorize.is_some()) && !vectorcraft_eps::writes_image_mask(d, n)) || n.opacity < 1.0 || n.blend != BlendMode::Normal || (mode == SeeThrough::Flatten && transparent(d, n.id))) diff --git a/crates/engine/src/cmd/fileio/native.rs b/crates/engine/src/cmd/fileio/native.rs index 6cb513db6..4d293fe69 100644 --- a/crates/engine/src/cmd/fileio/native.rs +++ b/crates/engine/src/cmd/fileio/native.rs @@ -121,7 +121,7 @@ pub const AI_OPTIONS: &[FormatOption] = &[ name: "transparentImages", ty: "string", default: "\"flatten\"", - description: "with aiVersion 7, the images version 7 can't hold (see-through pixels, opacity or a blending mode): flatten (the default: each becomes an opaque image of itself and the art under it, clipped to where it changes what shows; that art stays as it is) or omit (left out)", + description: "with aiVersion 7, the images version 7 can't hold (see-through pixels, opacity, a blending mode, or a colorize color unless every pixel is black or white): flatten (the default: each becomes an opaque image of itself and the art under it, clipped to where it changes what shows; that art stays as it is) or omit (left out)", }, FormatOption { name: "hiddenObjects", diff --git a/crates/engine/src/tests/colorize.rs b/crates/engine/src/tests/colorize.rs index 2dab48c9f..40152af74 100644 --- a/crates/engine/src/tests/colorize.rs +++ b/crates/engine/src/tests/colorize.rs @@ -370,13 +370,21 @@ fn colorized_gray_image_is_flattened_for_pdf_x_1a() { assert!(!String::from_utf8_lossy(&bytes).contains("/SMask"), "PDF/X-1a has no soft mask"); } +/// A colorized image with a gray pixel is left out; one of black and white pixels is written as an +/// image mask and kept. #[test] fn colorized_gray_image_is_omitted_from_legacy_ai_when_requested() { + let mut s = session(); + let mid = png(image::DynamicImage::ImageLuma8(image::GrayImage::from_raw(2, 1, vec![0, 128]).unwrap())); + let id = image(&mut s, "mid", mid); + s.execute("paint.setFill", &json!({"color": "#ff0000", "ids": [id.0]})).unwrap(); + let out = s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7", "transparentImages": "omit"})).unwrap(); + assert!(out["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("are left out")), "{}", out["warnings"]); let mut s = session(); let id = image(&mut s, "gray", gray(false)); s.execute("paint.setFill", &json!({"color": "#ff0000", "ids": [id.0]})).unwrap(); let out = s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7", "transparentImages": "omit"})).unwrap(); - assert!(out["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("are left out")), "{}", out["warnings"]); + assert!(!out["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("are left out")), "{}", out["warnings"]); } #[test] diff --git a/crates/engine/src/tests/legacy_ai.rs b/crates/engine/src/tests/legacy_ai.rs index b9f67f51a..c4722c9c1 100644 --- a/crates/engine/src/tests/legacy_ai.rs +++ b/crates/engine/src/tests/legacy_ai.rs @@ -447,6 +447,100 @@ fn transparent_images_can_be_left_out_and_opacity_makes_one_transparent() { assert_eq!(v["options"]["transparentImages"]["value"], "flatten", "{v}"); } +/// A red square (10–50 × 10–50) half under a 4 × 2 black-and-white image (30–70 × 10–30) that the +/// spot color `Ink` colorizes. Returns the session and the image's pixels. +fn colorized_bitmap_over_square() -> (Session, Vec) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap(); + s.execute("swatch.new", &json!({"name": "Ink", "color": {"c": 100, "m": 0, "y": 0, "k": 0}, "spot": true})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 40})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + let gray = vec![0, 255, 0, 255, 255, 0, 255, 0]; + let mut bytes = vec![]; + image::GrayImage::from_raw(4, 2, gray.clone()).unwrap().write_to(&mut std::io::Cursor::new(&mut bytes), image::ImageFormat::Png).unwrap(); + let p = json!({"name": "b.png", "dataBase64": vectorcraft_format::base64_encode(&bytes), "rect": [30, 10, 40, 20]}); + s.execute("file.place", &p).unwrap(); + s.execute("paint.setFill", &json!({"swatch": "Ink"})).unwrap(); + (s, gray) +} + +/// An image's pixels' coverage and its colorize paint. +type Colorized = (Option<(u32, u32, Vec)>, Option); + +/// The colorized image of each image node of `d`, with its pixels' coverage. +fn colorized(d: &vectorcraft_doc::Document) -> Vec { + let mut out = vec![]; + d.walk(|n| { + if let vectorcraft_doc::NodeKind::Image(im) = &n.kind { + out.push((d.image_blob(im).and_then(vectorcraft_doc::ImageBlob::coverage), n.extras().colorize.as_deref().cloned())); + } + }); + out +} + +/// A colorized image of black and white pixels is neither flattened nor left out: it is written as +/// a 1-bit image mask in its color, with either setting of `transparentImages`, and opens again +/// colorized with that color, its white pixels clear over the square, and the square as it was. +#[test] +fn a_colorized_black_and_white_image_is_written_as_an_image_mask() { + let (mut s, gray) = colorized_bitmap_over_square(); + let before = colorized(&s.doc().unwrap().doc); + let want: Vec = gray.iter().map(|v| 255 - v).collect(); + assert!(matches!(before.as_slice(), [(Some((4, 2, c)), Some(_))] if *c == want), "{before:?}"); + for mode in ["flatten", "omit"] { + let r = s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7", "transparentImages": mode})).unwrap(); + assert!(!warnings(&r).iter().any(|w| w.contains("transparent images") || w.contains("flattened")), "{mode}: {r}"); + let mut t = Session::new(); + t.execute("document.open", &json!({"name": "t.ai", "dataBase64": r["dataBase64"]})).unwrap(); + let d = &t.doc().unwrap().doc; + let after = colorized(d); + assert_eq!(after.len(), 1, "{mode}: {after:?}"); + assert_eq!(after[0].0, before[0].0, "{mode}"); + let (Some(a), Some(b)) = (&after[0].1, &before[0].1) else { panic!("{mode}: {after:?}") }; + let rgb = |p: &vectorcraft_color::Paint| p.color().map(|c| c.to_rgb()).unwrap(); + assert!(rgb(a).iter().zip(rgb(b)).all(|(x, y)| (x - y).abs() < 0.01), "{mode}: {a:?} {b:?}"); + assert_eq!(images(d), (1, 0), "{mode}"); + // The first row draws as before: black (the color) and white (clear) over the square, then + // black and white (clear) over the page. + let src = &s.doc().unwrap().doc; + for x in [35.0, 45.0, 55.0, 65.0] { + let (now, was) = (colour_at(d, x, 15.0), colour_at(src, x, 15.0)); + assert!(now.iter().zip(was).all(|(a, b)| a.abs_diff(b) <= 3), "{mode} at {x}: {now:?} {was:?}"); + } + assert_ne!(colour_at(src, 35.0, 15.0), colour_at(src, 45.0, 15.0), "{mode}"); + assert_ne!(colour_at(src, 55.0, 15.0), colour_at(src, 65.0, 15.0), "{mode}"); + } +} + +/// A colorized image of black and clear pixels is written as an image mask too, its clear pixels +/// clear, instead of being flattened or left out for its see-through pixels. +#[test] +fn a_colorized_image_of_black_and_clear_pixels_is_written_as_an_image_mask() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap(); + s.execute("swatch.new", &json!({"name": "Ink", "color": {"c": 100, "m": 0, "y": 0, "k": 0}, "spot": true})).unwrap(); + let mut bytes = vec![]; + image::GrayAlphaImage::from_raw(2, 1, vec![0, 255, 0, 0]) + .unwrap() + .write_to(&mut std::io::Cursor::new(&mut bytes), image::ImageFormat::Png) + .unwrap(); + let p = json!({"name": "c.png", "dataBase64": vectorcraft_format::base64_encode(&bytes), "rect": [30, 10, 20, 10]}); + s.execute("file.place", &p).unwrap(); + s.execute("paint.setFill", &json!({"swatch": "Ink"})).unwrap(); + let before = colorized(&s.doc().unwrap().doc); + assert!(matches!(before.as_slice(), [(Some((2, 1, c)), Some(_))] if *c == [255, 0]), "{before:?}"); + for mode in ["flatten", "omit"] { + let r = s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7", "transparentImages": mode})).unwrap(); + assert!(!warnings(&r).iter().any(|w| w.contains("transparent images") || w.contains("flattened")), "{mode}: {r}"); + let mut t = Session::new(); + t.execute("document.open", &json!({"name": "t.ai", "dataBase64": r["dataBase64"]})).unwrap(); + let after = colorized(&t.doc().unwrap().doc); + assert_eq!(after.len(), 1, "{mode}: {after:?}"); + assert_eq!(after[0].0, before[0].0, "{mode}"); + assert!(after[0].1.is_some(), "{mode}: {after:?}"); + } +} + /// #2692's file, an image clipped to a circle inside a circle's stroke, and #2830's, an image with /// see-through pixels (which the save flattens into an image inside a clipping group), each with /// an orange square on the next layer. @@ -639,7 +733,8 @@ fn colorized_grayscale_image_pattern_keeps_its_colors_in_legacy_ai() { for (a, b) in before.iter().zip(&after) { assert!(a.iter().zip(b).all(|(a, b)| a.abs_diff(*b) <= 3), "before {before:?} after {after:?}"); } - assert!(saved_warnings.iter().any(|w| w.contains("flattened with the art under them")), "{saved_warnings:?}"); + // The tile's pixels are black or white: its images are written as image masks, not flattened. + assert!(!saved_warnings.iter().any(|w| w.contains("flattened with the art under them")), "{saved_warnings:?}"); } /// #3666: a pattern fill whose tile is an image, under opacity, is flattened with the art under it diff --git a/crates/eps/src/legacy_ai.rs b/crates/eps/src/legacy_ai.rs index 290fe3c41..412961802 100644 --- a/crates/eps/src/legacy_ai.rs +++ b/crates/eps/src/legacy_ai.rs @@ -150,6 +150,27 @@ pub struct LegacyAiOutput { pub warnings: Vec, } +/// Whether image node `n` of `doc` is written as a 1-bit image mask in its colorize color +/// (ImageMask 1, §8.1) that draws as the document does: it is colorized with a solid color, its +/// pixels take the color and are all black or white (or clear), it has no opacity mask turned on, +/// and it has no more pixels than the writer writes. Such an image keeps its clear pixels in the file. +pub fn writes_image_mask(doc: &Document, n: &Node) -> bool { + let NodeKind::Image(im) = &n.kind else { return false }; + // The size from the file's header, before any pixels are decoded. + let fits = |bytes: &[u8]| { + image::ImageReader::new(std::io::Cursor::new(bytes)) + .with_guessed_format() + .ok() + .and_then(|r| r.into_dimensions().ok()) + .is_some_and(|(w, h)| w > 0 && h > 0 && u64::from(w) * u64::from(h) <= MAX_IMAGE_PIXELS) + }; + matches!(n.extras().colorize.as_deref(), Some(Paint::Solid { .. })) + && !n.extras().mask.as_ref().is_some_and(|m| !m.disabled) + && doc + .image_blob(im) + .is_some_and(|b| b.takes_colorize() && fits(&b.bytes) && b.coverage().is_some_and(|(_, _, c)| c.iter().all(|&v| v == 0 || v == 255))) +} + /// Write `doc`'s first artboard and every layer (all its art, on the artboard or not) as a /// legacy `.ai` file of version 7. Art space is y up, its origin at the artboard's left edge and /// just under its bottom edge: the artboard's top is on a whole point, where readers that place diff --git a/crates/eps/src/legacy_ai/prolog.rs b/crates/eps/src/legacy_ai/prolog.rs index 554447cec..b0d606bba 100644 --- a/crates/eps/src/legacy_ai/prolog.rs +++ b/crates/eps/src/legacy_ai/prolog.rs @@ -36,7 +36,7 @@ pub(crate) const PROCSET: &str = "VectorCraft_Legacy 1 0"; /// application's procedure set): paths and their painting with the fill and stroke colours, /// compound paths painted as one path, clipping groups, guides that don't print, layers that are /// hidden or don't print marking nothing, and images drawn from the samples on the comment lines -/// that follow `XI`. Gradients fill with the colour set before them (the writer sets the first +/// that follow `XI` (an image mask's 0 bits in the fill color). Gradients fill with the colour set before them (the writer sets the first /// stop's), and type marks nothing: apps that read the format draw both. The page trailer's /// `annotatepage` (§3, Script Setup: `gsave annotatepage grestore showpage`) marks nothing. pub(super) const PROLOG: &str = "%%BeginResource: procset VectorCraft_Legacy 1 0 @@ -96,9 +96,11 @@ VCLegacy begin /_l 256 string def /_s 128 string def /_row {{currentfile _l readline not {exit} if dup length 1 gt {exit} if pop} loop dup length 1 gt {dup length 1 sub 1 exch getinterval /ASCIIHexDecode filter _s readstring pop} {pop ()} ifelse} bd -/XI {pop pop pop pop /_ic exch def /_ib exch def /_ih exch def /_iw exch def pop pop pop pop +% An image mask (ImageMask 1) paints its 0 bits in the fill color. +/XI {1 eq /_im exch def pop pop pop /_ic exch def /_ib exch def /_ih exch def /_iw exch def pop pop pop pop [1 0 0 -1 0 0] exch matrix concatmatrix - _on {gsave concat _iw _ih _ib [1 0 0 1 0 0] /_row load false _ic 1 eq {1} {_ic} ifelse colorimage grestore} {pop} ifelse} bd + _on {gsave concat _im {_fc _iw _ih false [1 0 0 1 0 0] /_row load imagemask} + {_iw _ih _ib [1 0 0 1 0 0] /_row load false _ic 1 eq {1} {_ic} ifelse colorimage} ifelse grestore} {pop} ifelse} bd % Type (15) is set by the app that reads the file: its operators take their operands and mark % nothing; encodings (3.3) are skipped. /TE {cleartomark} bd /TZ {cleartomark} bd diff --git a/crates/eps/src/legacy_ai/tests.rs b/crates/eps/src/legacy_ai/tests.rs index 617aa5455..851228ea6 100644 --- a/crates/eps/src/legacy_ai/tests.rs +++ b/crates/eps/src/legacy_ai/tests.rs @@ -442,6 +442,94 @@ fn empty_images_get_the_size_note_and_undecodable_ones_the_decode_note() { assert_eq!(notes(b"not an image".to_vec()), [UNDECODED]); } +/// A gray image of `doc`, `w` × `h` pixels of `gray`, colorized `paint`, placed 10 pt a pixel +/// at (`x`, `y`). +fn colorized_image(doc: &mut Document, (w, h): (u32, u32), gray: Vec, paint: &Paint, (x, y): (f64, f64)) -> Node { + let mut png = vec![]; + image::GrayImage::from_raw(w, h, gray).unwrap().write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let blob = ImageBlob::new("image/png", png); + let key = blob.content_key(); + doc.images.insert(key.clone(), blob); + let xf = Affine::new([10.0, 0.0, 0.0, 10.0, x, y]); + let im = ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default(), pixels: None }; + let mut n = Node::new(doc.alloc_id(), NodeKind::Image(Box::new(im))); + n.extras_mut().colorize = Some(Box::new(paint.clone())); + n +} + +/// A colorized image whose pixels are black or white is written as a 1-bit image mask +/// (ImageMask 1) after its color as the fill: its black pixels are 0 bits, which take the color, and its +/// white pixels 1 bits, which are clear. It prints in its color over the square behind it and +/// reads back colorized with that color and with the same clear pixels. A colorized image with +/// gray pixels is written with its color in its pixels, over white, with the note for +/// transparent pixels. +#[test] +fn colorized_images_keep_their_color() { + let mut doc = Document::new(200.0, 100.0); + doc.layers.clear(); + let layer = doc.add_layer(Some("Art")); + let square = shape(&mut doc, shapes::rectangle(Rect::new(5.0, 5.0, 45.0, 30.0)), red(), Paint::None); + doc.insert(Some(layer), usize::MAX, square).unwrap(); + let paint = Paint::solid(Color::cmyk(0.0, 0.5, 1.0, 0.0)); + // 1 0 0 1 0 1 1 0 / 0 1 1 0 1 0 0 1 in black (1) and white (0). + let mask: Vec = "1001011001101001".bytes().map(|b| if b == b'1' { 0 } else { 255 }).collect(); + let bitmap = colorized_image(&mut doc, (8, 2), mask.clone(), &paint, (10.0, 10.0)); + doc.insert(Some(layer), usize::MAX, bitmap).unwrap(); + let out = write(&doc); + let text = String::from_utf8(out.bytes.clone()).unwrap(); + let images = rasters(&text); + assert_eq!(images.len(), 1, "{text}"); + let [_, _, xi, data @ .., _, _] = images[0].as_slice() else { panic!("{:?}", images[0]) }; + assert!(xi.ends_with(" ] 0 0 8 2 8 2 1 1 0 0 0 1 XI"), "{xi}"); + // Each row is a byte: the black pixels are 0 bits. + assert_eq!(data, &["%6996"], "{text}"); + let at = text.find("%AI5_File:\n").unwrap(); + assert!(text[..at].ends_with("0 0.5 1 0 k\n"), "{text}"); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + let read = crate::import(&out.bytes).unwrap().document; + let mut found = vec![]; + read.walk(|n| { + if let NodeKind::Image(im) = &n.kind { + found.push((read.image_blob(im).and_then(ImageBlob::coverage), n.extras().colorize.as_deref().cloned())); + } + }); + let want: Vec = mask.iter().map(|&v| 255 - v).collect(); + assert_eq!(found, [(Some((8, 2, want)), Some(paint.clone()))]); + + // Gray pixels: the color at each pixel's strength, over white. + let mut doc = Document::new(200.0, 100.0); + doc.layers.clear(); + let layer = doc.add_layer(Some("Art")); + let tint = colorized_image(&mut doc, (2, 1), vec![0, 128], &Paint::solid(Color::rgb(1.0, 0.0, 0.0)), (10.0, 10.0)); + doc.insert(Some(layer), usize::MAX, tint).unwrap(); + let out = write(&doc); + let text = String::from_utf8(out.bytes.clone()).unwrap(); + let images = rasters(&text); + let [_, _, xi, data @ .., _, _] = images[0].as_slice() else { panic!("{:?}", images[0]) }; + assert!(xi.ends_with(" ] 0 0 2 1 2 1 8 3 0 0 0 0 XI"), "{xi}"); + assert_eq!(data, &["%FF0000FF8080"], "{text}"); + assert_eq!(out.warnings, [IMAGE_ALPHA]); +} + +/// An image mask is only for a colorized image the writer draws as the document does: not one +/// with an opacity mask turned on, and not one with more pixels than the writer writes. +#[test] +fn image_masks_are_for_images_the_writer_draws_as_they_are() { + let mut doc = Document::new(200.0, 100.0); + let paint = Paint::solid(Color::cmyk(0.0, 0.5, 1.0, 0.0)); + let mut n = colorized_image(&mut doc, (2, 1), vec![0, 255], &paint, (10.0, 10.0)); + assert!(writes_image_mask(&doc, &n)); + let art = shape(&mut doc, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Paint::solid(Color::gray(1.0)), Paint::None); + n.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(art, false))); + assert!(!writes_image_mask(&doc, &n)); + if let Some(m) = n.extras_mut().mask.as_mut() { + m.disabled = true; + } + assert!(writes_image_mask(&doc, &n)); + let big = colorized_image(&mut doc, (8193, 8193), vec![255; 8193 * 8193], &paint, (10.0, 10.0)); + assert!(!writes_image_mask(&doc, &big)); +} + #[test] fn a_gradient_fill_and_its_stroke_are_one_object() { let mut doc = Document::new(100.0, 100.0); diff --git a/crates/eps/src/legacy_ai/writer.rs b/crates/eps/src/legacy_ai/writer.rs index 1fe58d659..ae8fddc70 100644 --- a/crates/eps/src/legacy_ai/writer.rs +++ b/crates/eps/src/legacy_ai/writer.rs @@ -272,7 +272,11 @@ impl<'a> Writer<'a> { } NodeKind::Image(im) => { self.lock(locked); - self.image(self.doc.image_blob(im), im.width, im.height, im.xf); + let blob = self.doc.image_blob(im); + // A colorize color the renderer draws the image in. + let colorize = + n.extras().colorize.as_deref().filter(|p| matches!(p, Paint::Solid { .. }) && blob.is_some_and(ImageBlob::takes_colorize)); + self.image(blob, im.width, im.height, im.xf, colorize); } NodeKind::SymbolInstance { symbol, xf } => { self.warn(LIVE); @@ -835,7 +839,7 @@ impl<'a> Writer<'a> { // Colour bitmap glyphs (emoji): their images, which the runs' paint doesn't paint. for g in &layout.glyphs { if let Some((image, to_text)) = g.placed_image() { - self.image(Some(&image.blob), image.width, image.height, t.xf * to_text); + self.image(Some(&image.blob), image.width, image.height, t.xf * to_text, None); } // COLR glyphs: their layers, as art. if let Some(art) = g.placed_colour(t.xf) { @@ -853,8 +857,12 @@ impl<'a> Writer<'a> { /// `%AI5_EndRaster`, then `F`. §8.4's examples have no `F`; without it, an app reading the file /// can leave out an image in a clipping group and the art after it. No path is open there, so /// the `F` paints nothing. `blob` holds the pixels, placed as a grid of `width` × `height` - /// by `xf`. - fn image(&mut self, blob: Option<&ImageBlob>, width: u32, height: u32, xf: Affine) { + /// by `xf`, and `colorize` is the solid color the image is colorized with. A colorized image + /// whose pixels are all black or white is written as an image mask (ImageMask 1, §8.1): its + /// color as the fill, then 1-bit samples, a 0 bit for each black pixel, which takes the color, + /// and a 1 bit for each white pixel, which is clear. Another colorized image is written with + /// its color in its pixels. + fn image(&mut self, blob: Option<&ImageBlob>, width: u32, height: u32, xf: Affine, colorize: Option<&Paint>) { let flip = Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 0.0]); let m = self.to_art * xf * flip; if !invertible(m) { @@ -876,25 +884,61 @@ impl<'a> Writer<'a> { // The object places the image's own pixel grid (its width × height) wherever the file's is. let fit = Affine::scale_non_uniform(f64::from(width.max(1)) / f64::from(w), f64::from(height.max(1)) / f64::from(h)); let m = self.to_art * xf * fit * flip; + // Over white. + let over = |c: u8, a: u8| ((u32::from(c) * u32::from(a) + 255 * (255 - u32::from(a)) + 127) / 255) as u8; + if let Some(paint) = colorize { + let Some((_, _, coverage)) = blob.and_then(ImageBlob::coverage).filter(|(cw, ch, _)| (*cw, *ch) == (w, h)) else { + self.warn(UNDECODED); + return; + }; + if coverage.iter().all(|&v| v == 0 || v == 255) { + let stride = (w as usize).div_ceil(8); + let mut bits = vec![0xFF; stride.saturating_mul(h as usize)]; + for (i, _) in coverage.iter().enumerate().filter(|(_, v)| **v == 255) { + let (x, y) = (i % w as usize, i / w as usize); + if let Some(byte) = bits.get_mut(y * stride + x / 8) { + *byte &= !(0x80 >> (x % 8)); + } + } + self.color(paint, false); + self.raster(m, &format!("0 0 {w} {h} {w} {h} 1 1 0 0 0 1"), &bits); + return; + } + let rgb = paint.color().map_or([0; 3], |c| c.to_rgb().map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)); + let samples: Vec = coverage.iter().flat_map(|&a| rgb.map(|c| over(c, a))).collect(); + self.warn(IMAGE_ALPHA); + self.raster(m, &format!("0 0 {w} {h} {w} {h} 8 3 0 0 0 0"), &samples); + return; + } let rgba = img.to_rgba8(); - let mut hex = String::with_capacity(rgba.len() / 4 * 7); - hex.push('%'); - let mut on_line = 0; + let mut samples = Vec::with_capacity(rgba.len() / 4 * 3); let mut see_through = false; for px in rgba.pixels() { let [r, g, b, a] = px.0; if a < 255 { see_through = true; } - // Over white. - let over = |c: u8| ((u32::from(c) * u32::from(a) + 255 * (255 - u32::from(a)) + 127) / 255) as u8; - for c in [over(r), over(g), over(b)] { - let _ = write!(hex, "{c:02X}"); - on_line += 2; - if on_line >= HEX_LINE { - hex.push_str("\n%"); - on_line = 0; - } + samples.extend([over(r, a), over(g, a), over(b, a)]); + } + if see_through { + self.warn(IMAGE_ALPHA); + } + self.raster(m, &format!("0 0 {w} {h} {w} {h} 8 3 0 0 0 0"), &samples); + } + + /// An image's lines (§8.4): `%AI5_File:`, `%AI5_BeginRaster`, matrix `m` as `[ a b c d tx ty ]` + /// and the numbers `operands` with `XI`, `samples` in hex on comment lines of [`HEX_LINE`] + /// digits, `%AI5_EndRaster`, then `F`. + fn raster(&mut self, m: Affine, operands: &str, samples: &[u8]) { + let mut hex = String::with_capacity(samples.len() * 2 + samples.len() / HEX_LINE * 2 + 2); + hex.push('%'); + let mut on_line = 0; + for c in samples { + let _ = write!(hex, "{c:02X}"); + on_line += 2; + if on_line >= HEX_LINE { + hex.push_str("\n%"); + on_line = 0; } } if hex.ends_with('%') { @@ -902,14 +946,11 @@ impl<'a> Writer<'a> { } else { hex.push('\n'); } - if see_through { - self.warn(IMAGE_ALPHA); - } let [a, b, c, d, e, f] = m.as_coeffs(); let s = &mut self.body; s.push_str("%AI5_File:\n%AI5_BeginRaster\n[ "); ps::push_nums(s, &[a, b, c, d, e, f]); - let _ = writeln!(s, "] 0 0 {w} {h} {w} {h} 8 3 0 0 0 0 XI"); + let _ = writeln!(s, "] {operands} XI"); s.push_str(&hex); s.push_str("%AI5_EndRaster\nF\n"); } diff --git a/crates/eps/src/lib.rs b/crates/eps/src/lib.rs index 51d244752..c5ee02e72 100644 --- a/crates/eps/src/lib.rs +++ b/crates/eps/src/lib.rs @@ -47,7 +47,7 @@ pub use import::{ AiLayers, Imported, POSTSCRIPT_ERRORS, ai_alone, ai_alone_on_page, ai_layers_alone, family_style, import, import_trusting, import_with, is_loss, layered_ai, layered_ai_from, page, }; -pub use legacy_ai::{HiddenObjects, LegacyAiOptions, LegacyAiOutput, export_legacy_ai}; +pub use legacy_ai::{HiddenObjects, LegacyAiOptions, LegacyAiOutput, export_legacy_ai, writes_image_mask}; pub use print::{PrintJob, PrintPage, print}; use vectorcraft_doc::Document; diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 4186db3eb..4791a586e 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (SVG import: `color-interpolation="linearRGB"` gradients, #5878) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`, `OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public @@ -82,7 +82,9 @@ private editing data. VectorCraft: PostScript form, from the published *Adobe Illustrator File Format Specification* version 7.0 ([PRONOM fmt/423](https://www.nationalarchives.gov.uk/PRONOM/fmt/423)), which apps reading `.ai` open with its structure (#868). It keeps layers (names, order, visibility, lock), groups, compound paths, clipping groups, - solid colours, linear and radial gradients and RGB images. The format has one artboard: each artboard chosen in + solid colours, linear and radial gradients and RGB images. A colorized image whose pixels are all black or white + is written as a 1-bit image mask (ImageMask 1) in its color, with its white pixels clear, and opens colorized + again; other colorized images are flattened or omitted as the Transparent Images setting below says. The format has one artboard: each artboard chosen in the options dialog's artboard picker (thumbnails to check, default all) is a file of its own, `{name}-{artboard}.ai` when there are several. It has no transparency: objects are written opaque, and images with see-through pixels, opacity or a blending mode are flattened (an opaque image of themselves and the art diff --git a/docs/mcp.md b/docs/mcp.md index c70283b70..de4a76d6f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -879,7 +879,9 @@ document, takes the PDF options and keeps its path when reopened, so Save writes without giving the document a path. `options: {aiVersion: "7"}` writes legacy `.ai` files of version 7 instead (the PostScript form, which keeps layers, groups, compound paths and clipping groups in apps that read it), one per artboard of `range` (default all; `{name}-{artboard}.ai` when several, listed in the result's `files`), with -`transparentImages: "flatten"` (default) or `"omit"` for images with see-through pixels, opacity or a blending mode, +`transparentImages: "flatten"` (default) or `"omit"` for images with see-through pixels, opacity, a blending mode +or a colorize color (except a colorized image of black and white pixels, which is written as a 1-bit image mask in +its color with its white pixels clear), `hiddenObjects: "layer"` (default: a hidden layer of each layer's hidden objects), `"omit"` or `"visible"`, and `outlineText: true` to write type as outlines instead of text objects. Type with rotated characters (Character Rotation) is always written as outlines, which keep the rotation; without `outlineText: true` this gives the warning "type with rotated characters is