From 7584b6335ee1e0125394951d5cdbed4cf5a38b39 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 30 Oct 2026 15:52:54 -0700 Subject: [PATCH] Legacy .ai: colorized image pattern tiles keep their color Saving a legacy .ai with transparent images flattened expands pattern fills whose tile image is see-through or has opacity or a blending mode, so the tile images flatten with the art under them. A pattern whose tile is an opaque grayscale image with a colorize paint was not expanded, so the writer drew the source gray pixels over white. A colorize paint now counts too. --- crates/engine/src/cmd/expand.rs | 12 ++--- crates/engine/src/cmd/fileio/legacy_ai.rs | 5 +- crates/engine/src/tests/legacy_ai.rs | 61 +++++++++++++++++++++++ 3 files changed, 69 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index b117b77a9..5ad8054fa 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -444,10 +444,10 @@ fn expand_pattern_fills(s: &mut Session) -> Result<()> { }) } -/// Expand the pattern fills under `root` that paint a transparent image into the tiles they paint +/// Expand the pattern fills under `root` that paint a transparent or colorized image into the tiles they paint /// ([`expand_patterns_where`]), so each tile image becomes an object of its own; the other -/// pattern fills stay. A tile image counts as -/// transparent when its key is in `see_through` (it has see-through pixels), or when it, a group +/// pattern fills stay. A tile image counts when its key is in `see_through` (it has see-through +/// pixels), when it has a colorize paint, or when it, a group /// in the tile art, the fill, the shape or a group the shape is in has opacity or a blending /// mode. Returns how many shapes changed. pub(crate) fn expand_transparent_image_patterns(d: &mut Document, root: NodeId, see_through: &BTreeSet) -> Result { @@ -473,12 +473,12 @@ fn blended(n: &Node) -> bool { n.opacity < 1.0 || n.blend != BlendMode::Normal } -/// Whether `n` is or holds a transparent image: one with see-through pixels (key in `keys`), or -/// with opacity or a blending mode of its own or of a group in `n`, or anywhere when `on`. +/// Whether `n` is or holds an image with see-through pixels (key in `keys`), a colorize paint, +/// opacity or a blending mode of its own or of a group in `n`, or anywhere when `on`. fn transparent_image(n: &Node, keys: &BTreeSet, on: bool) -> bool { let on = on || blended(n); match &n.kind { - NodeKind::Image(im) => on || keys.contains(&im.key), + NodeKind::Image(im) => on || keys.contains(&im.key) || n.extras().colorize.is_some(), _ => n.children().is_some_and(|ch| ch.iter().any(|c| transparent_image(c, keys, on))), } } diff --git a/crates/engine/src/cmd/fileio/legacy_ai.rs b/crates/engine/src/cmd/fileio/legacy_ai.rs index 6a87b1e9e..0e53a5a18 100644 --- a/crates/engine/src/cmd/fileio/legacy_ai.rs +++ b/crates/engine/src/cmd/fileio/legacy_ai.rs @@ -161,9 +161,8 @@ fn expand_appearances(d: &mut Document) { } } -/// Expand every layer's pattern fills that paint a transparent image into their tiles, so those -/// images flatten like any other transparent image. The writer would otherwise draw them opaque -/// over white. +/// Expand every layer's pattern fills that paint an image with see-through pixels, a colorize paint, +/// opacity or blending into their tiles, so those images can be flattened. fn expand_image_patterns(d: &mut Document) -> Result<()> { let keys = super::super::flatten::see_through_images(d); let layers: Vec = d.layers.iter().map(|l| l.id).collect(); diff --git a/crates/engine/src/tests/legacy_ai.rs b/crates/engine/src/tests/legacy_ai.rs index 52706abbd..7ac5c3c5c 100644 --- a/crates/engine/src/tests/legacy_ai.rs +++ b/crates/engine/src/tests/legacy_ai.rs @@ -520,6 +520,67 @@ fn pattern_over_blue(tile: &[u8], opacity: f32) -> Session { s } +/// A pattern of opaque grayscale pixels whose black squares draw red and white squares leave the blue background visible. +fn colorized_pattern_over_blue() -> Session { + fn colorize_image(n: &mut vectorcraft_doc::Node) -> bool { + if matches!(n.kind, vectorcraft_doc::NodeKind::Image(_)) { + n.extras_mut().colorize = Some(Box::new(vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb8(255, 0, 0)))); + return true; + } + n.children_mut().is_some_and(|children| children.iter_mut().any(|child| colorize_image(std::sync::Arc::make_mut(child)))) + } + + let tile = image::GrayImage::from_fn(32, 32, |x, y| image::Luma([if (x < 16) == (y < 16) { 0 } else { 255 }])); + let mut bytes = vec![]; + tile.write_to(&mut std::io::Cursor::new(&mut bytes), image::ImageFormat::Png).unwrap(); + let mut s = pattern_over_blue(&bytes, 1.0); + let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + assert!(d.patterns[0].art.iter_mut().any(|art| colorize_image(std::sync::Arc::make_mut(art)))); + s +} + +#[test] +fn a_colorized_grayscale_image_pattern_fill_is_expanded() { + let mut s = colorized_pattern_over_blue(); + let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let keys = crate::cmd::flatten::see_through_images(d); + assert!(keys.is_empty()); + let layer = d.layers[0].id; + assert_eq!(crate::cmd::expand::expand_transparent_image_patterns(d, layer, &keys).unwrap(), 1); + let mut patterns = 0; + let mut tile_images = 0; + d.walk(|n| { + patterns += n + .appearance + .items + .iter() + .filter( + |i| matches!(i, vectorcraft_doc::appearance::AppearanceItem::Fill(f) if matches!(f.paint, vectorcraft_color::Paint::Pattern { .. })), + ) + .count(); + tile_images += usize::from(matches!(n.kind, vectorcraft_doc::NodeKind::Image(_))); + }); + assert_eq!(patterns, 0); + assert!(tile_images > 0); +} + +#[test] +fn colorized_grayscale_image_pattern_keeps_its_colors_in_legacy_ai() { + let mut s = colorized_pattern_over_blue(); + let points = [(72.0, 72.0), (88.0, 72.0), (136.0, 152.0), (152.0, 152.0)]; + let before: Vec<[u8; 3]> = points.iter().map(|&(x, y)| colour_at(&s.doc().unwrap().doc, x, y)).collect(); + assert_eq!(before[0], [255, 0, 0], "{before:?}"); + assert_eq!(before[1], [0, 0, 255], "{before:?}"); + let r = s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7", "transparentImages": "flatten"})).unwrap(); + let saved_warnings = warnings(&r); + s.execute("document.open", &json!({"name": "t.ai", "dataBase64": r["dataBase64"]})).unwrap(); + let after: Vec<[u8; 3]> = points.iter().map(|&(x, y)| colour_at(&s.doc().unwrap().doc, x, y)).collect(); + 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:?}"); +} + /// #3666: a pattern fill whose tile is an image, under opacity, is flattened with the art under it /// like a transparent image, instead of being written opaque over white. #[test]