From 94759cbe71dfec3870d973c272c5b4ec62c19236 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 23 Oct 2026 05:39:58 +0000 Subject: [PATCH] Flatten transparent image pattern tiles in legacy .ai v7 export Fixes #3666 Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/expand.rs | 50 +++++++- crates/engine/src/cmd/fileio/legacy_ai.rs | 39 +++++- crates/engine/src/tests_legacy_ai.rs | 144 ++++++++++++++++++++++ 3 files changed, 224 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index 512501d8d..81ea48866 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -5,12 +5,13 @@ //! gradient becomes a mesh shaped like the object. A pattern fill becomes the tiles it paints inside a clip //! group shaped like the object. Create Gradient Mesh shares the sampling. +use std::collections::BTreeSet; use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_color::cms::Model; use vectorcraft_color::freeform::spread_scale; -use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, Paint}; +use vectorcraft_color::{BlendMode, Color, Gradient, GradientKind, GradientPaint, Paint}; use vectorcraft_doc::appearance::{AppearanceItem, FillLayer}; use vectorcraft_doc::live::GradientMesh; use vectorcraft_doc::pattern::{PatternDef, place_art}; @@ -412,6 +413,41 @@ 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_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 +/// 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 { + let (_, n) = replace_leaves(d, root, |d, l| { + let shape = d.ancestry(l.id).unwrap_or_default().iter().filter_map(|a| d.node(*a)).any(blended); + let transparent = |d: &Document, i: &AppearanceItem| { + pattern_fill(d, i).is_some_and(|(f, def, _)| { + let on = shape || f.opacity < 1.0 || f.blend != BlendMode::Normal; + def.art.iter().any(|a| transparent_image(a, see_through, on)) + }) + }; + expand_patterns_where(d, l, transparent) + })?; + Ok(n) +} + +/// Whether `n` has opacity or a blending mode of its own. +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`. +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), + _ => n.children().is_some_and(|ch| ch.iter().any(|c| transparent_image(c, keys, on))), + } +} + /// Path `l` with each pattern fill turned into the tiles it paints over the box of the outline it /// fills, in a clip group shaped like that outline with the fill's opacity, blend mode and effects /// other than geometry effects, in appearance order: the other items stay on plain copies of the @@ -420,7 +456,15 @@ fn expand_pattern_fills(s: &mut Session) -> Result<()> { /// ([`pattern_tiles`]). A pattern fill that lays no tile there stays. `None` when no pattern fill /// lays a tile. fn expand_patterns(d: &mut Document, l: &Node) -> Option { - if !matches!(l.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) || !l.appearance.items.iter().any(|i| pattern_fill(d, i).is_some()) { + expand_patterns_where(d, l, |_, _| true) +} + +/// [`expand_patterns`] for only the pattern fills `which` picks: the others stay like the other +/// items. +fn expand_patterns_where(d: &mut Document, l: &Node, which: impl Fn(&Document, &AppearanceItem) -> bool) -> Option { + if !matches!(l.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) + || !l.appearance.items.iter().any(|i| pattern_fill(d, i).is_some() && which(d, i)) + { return None; } let reshaped = reshaped(l); @@ -431,7 +475,7 @@ fn expand_patterns(d: &mut Document, l: &Node) -> Option { let mut rest = vec![]; let mut expanded = false; for item in &l.appearance.items { - let fill = pattern_fill(d, item).map(|(f, def, xf)| (f, def.clone(), xf)); + let fill = pattern_fill(d, item).filter(|_| which(d, item)).map(|(f, def, xf)| (f, def.clone(), xf)); let tiles = fill.and_then(|(f, def, xf)| { let (fl, _, bounds) = fill_shape(l, item, &path, bounds)?; let t = pattern_tiles(d, &def, xf, bounds); diff --git a/crates/engine/src/cmd/fileio/legacy_ai.rs b/crates/engine/src/cmd/fileio/legacy_ai.rs index 9fb7e06db..22971934f 100644 --- a/crates/engine/src/cmd/fileio/legacy_ai.rs +++ b/crates/engine/src/cmd/fileio/legacy_ai.rs @@ -15,8 +15,13 @@ //! blending mode written opaque is a box hiding the art under it (version 7 images have no alpha //! channel, §8.1). With `flatten` (the default) each becomes an opaque image of itself and the art //! under it, rendered from the document as it is, clipped to the area where it changes what shows; -//! the art under it stays as it was. With `omit` they are left out. An image that can't be decoded -//! is missing from the images that flattening and expanding raster effects draw, and the warnings +//! the art under it stays as it was. An image in a group with opacity or a blending mode is +//! flattened the same way, and a pattern fill that paints a transparent image (see-through +//! 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 +//! missing from the images that flattening and expanding raster effects draw, and the warnings //! then carry the raster exports' note once. //! //! The format can't hide objects, only layers (`hiddenObjects`): with `layer` (the default) each @@ -117,6 +122,9 @@ pub(super) fn encode(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result let (full, placed) = crate::cmd::place::document::full_documents(&one); let mut d = full.into_owned(); expand_appearances(&mut d); + if mode == SeeThrough::Flatten { + expand_image_patterns(&mut d)?; + } let images = see_through_images(&d, mode); if !images.is_empty() { note(&mut enc.warnings, if mode == SeeThrough::Flatten { FLATTENED } else { OMITTED }); @@ -153,17 +161,30 @@ 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. +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(); + for id in layers { + super::super::expand::expand_transparent_image_patterns(d, id, &keys)?; + } + Ok(()) +} + /// The images of `d` a legacy `.ai` can't hold, in paint order with their places in it (as -/// [`Document::walk`] counts): those with see-through pixels, opacity or a blending mode. Only -/// visible ones flatten (a hidden one, which the hidden objects option may write, is written over -/// white); all are left out with `omit`. +/// [`Document::walk`] counts). These are the images with see-through pixels, and the images +/// that 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)> { let keys = super::super::flatten::see_through_images(d); let mut out = vec![]; let mut at = 0; d.walk(|n| { if let NodeKind::Image(im) = &n.kind - && (keys.contains(&im.key) || n.opacity < 1.0 || n.blend != BlendMode::Normal) + && (keys.contains(&im.key) || n.opacity < 1.0 || n.blend != BlendMode::Normal || (mode == SeeThrough::Flatten && transparent(d, n.id))) { out.push((at, n.id)); } @@ -175,6 +196,12 @@ fn see_through_images(d: &Document, mode: SeeThrough) -> Vec<(usize, NodeId)> { out } +/// Whether node `id` or a group it is in has opacity or a blending mode. [`Document::ancestry`] +/// lists `id` itself last, so its own opacity and blending mode count too. +fn transparent(d: &Document, id: NodeId) -> bool { + d.ancestry(id).unwrap_or_default().iter().filter_map(|a| d.node(*a)).any(|n| n.opacity < 1.0 || n.blend != BlendMode::Normal) +} + /// Replace each of `images` (from [`see_through_images`]) with an opaque image of what shows /// where it is, clipped to where it changes that; one that changes nothing is left out. Each is /// rendered from `d` as it was, so images over one another flatten as they look. diff --git a/crates/engine/src/tests_legacy_ai.rs b/crates/engine/src/tests_legacy_ai.rs index fb7a23019..381ea16ae 100644 --- a/crates/engine/src/tests_legacy_ai.rs +++ b/crates/engine/src/tests_legacy_ai.rs @@ -499,3 +499,147 @@ fn a_range_saves_those_artboards_and_save_writes_them_again() { let r = s.execute("document.save", &json!({})).unwrap(); assert_eq!(r["files"].as_array().map(Vec::len), Some(2), "{r}"); } + +/// A 32×32 PNG checkerboard of 16-pixel squares: `a` top left and bottom right, `b` elsewhere. +fn checker(a: [u8; 4], b: [u8; 4]) -> Vec { + let img = image::RgbaImage::from_fn(32, 32, |x, y| image::Rgba(if (x < 16) == (y < 16) { a } else { b })); + let mut out = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); + out +} + +/// A blue square (40–168) under a square at `opacity` filled with a pattern whose 32 pt tile is the +/// image `tile`, grouped. +fn pattern_over_blue(tile: &[u8], opacity: f32) -> Session { + let href = format!("data:image/png;base64,{}", vectorcraft_format::base64_encode(tile)); + let svg = format!( + r##""## + ); + let mut s = Session::new(); + s.execute("document.open", &json!({"name": "p.svg", "dataBase64": vectorcraft_format::base64_encode(svg.as_bytes())})).unwrap(); + s +} + +/// #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] +fn a_transparent_image_pattern_is_flattened_with_the_art_under_it() { + for (tile, what) in + [(checker([0, 200, 0, 255], [255, 230, 0, 255]), "opaque tile"), (checker([0, 200, 0, 255], [255, 255, 255, 0]), "see-through tile")] + { + let mut s = pattern_over_blue(&tile, 0.6); + 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(); + let p = json!({"format": "ai", "aiVersion": "7", "transparentImages": "flatten", "hiddenObjects": "layer"}); + let r = s.execute("file.saveCopy", &p).unwrap(); + assert!(warnings(&r).iter().any(|w| w.contains("flattened with the art under them")), "{what}: {:?}", warnings(&r)); + let r = s.execute("document.open", &json!({"name": "t.ai", "dataBase64": r["dataBase64"]})).unwrap(); + assert!(!r.to_string().contains("left out"), "{what}: {:?}", warnings(&r)); + let d = &s.doc().unwrap().doc; + let after: Vec<[u8; 3]> = points.iter().map(|&(x, y)| colour_at(d, x, y)).collect(); + for (a, b) in before.iter().zip(&after) { + assert!(a.iter().zip(b).all(|(a, b)| a.abs_diff(*b) <= 3), "{what}: before {before:?} after {after:?}"); + } + } +} + +/// #3666: an opaque pattern fill whose tile is an opaque image is still written as its tiles by the +/// writer, not flattened. +#[test] +fn an_opaque_image_pattern_is_written_as_its_tiles() { + let mut s = pattern_over_blue(&checker([0, 200, 0, 255], [255, 230, 0, 255]), 1.0); + 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(); + let p = json!({"format": "ai", "aiVersion": "7", "transparentImages": "flatten", "hiddenObjects": "layer"}); + let r = s.execute("file.saveCopy", &p).unwrap(); + let w = warnings(&r); + assert!(w.iter().any(|w| w.contains("pattern fills are written as their tiles")), "{w:?}"); + assert!(!w.iter().any(|w| w.contains("flattened with the art under them")), "{w:?}"); + 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:?}"); + } +} + +/// A square (40–168) with two pattern fills of 32 pt image tiles: a red and blue checkerboard, +/// with a green checkerboard with see-through squares over it. +fn two_pattern_fills() -> Session { + let see_through = vectorcraft_format::base64_encode(&checker([0, 200, 0, 255], [255, 255, 255, 0])); + let opaque = vectorcraft_format::base64_encode(&checker([200, 0, 0, 255], [0, 0, 200, 255])); + let svg = format!( + r##""## + ); + let mut s = Session::new(); + s.execute("document.open", &json!({"name": "p.svg", "dataBase64": vectorcraft_format::base64_encode(svg.as_bytes())})).unwrap(); + // Move the opaque pattern fill under the see-through one, on the second rectangle. + let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let ids: Vec = d.layers[0].children().unwrap().iter().map(|n| n.id).collect(); + let under = d.node(ids[0]).unwrap().appearance.items.clone(); + assert!(!under.is_empty()); + let n = d.node_mut(ids[1]).unwrap(); + let mut items = under; + items.append(&mut n.appearance.items); + n.appearance.items = items; + let _ = d.remove(ids[0]); + s +} + +/// #3666: on a shape with two pattern fills, only the one that paints a transparent image is +/// flattened; the opaque image pattern under it is still written as its tiles. +#[test] +fn only_the_transparent_image_pattern_fill_of_a_shape_is_flattened() { + let mut s = two_pattern_fills(); + 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[1], [0, 0, 200], "{before:?}"); + let p = json!({"format": "ai", "aiVersion": "7", "transparentImages": "flatten", "hiddenObjects": "layer"}); + let r = s.execute("file.saveCopy", &p).unwrap(); + let w = warnings(&r); + assert!(w.iter().any(|w| w.contains("flattened with the art under them")), "{w:?}"); + assert!(w.iter().any(|w| w.contains("pattern fills are written as their tiles")), "{w:?}"); + 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:?}"); + } +} + +/// #3666: with `omit`, a pattern fill whose tile image is see-through is not expanded, so its tiles +/// aren't left out: the writer still draws them. +#[test] +fn omit_keeps_a_transparent_image_pattern() { + let mut s = pattern_over_blue(&checker([0, 200, 0, 255], [255, 255, 255, 0]), 1.0); + let p = json!({"format": "ai", "aiVersion": "7", "transparentImages": "omit", "hiddenObjects": "layer"}); + let r = s.execute("file.saveCopy", &p).unwrap(); + let w = warnings(&r); + assert!(w.iter().any(|w| w.contains("pattern fills are written as their tiles")), "{w:?}"); + assert!(!w.iter().any(|w| w.contains("are left out")), "{w:?}"); + s.execute("document.open", &json!({"name": "t.ai", "dataBase64": r["dataBase64"]})).unwrap(); + assert!(images(&s.doc().unwrap().doc).0 > 0); +} + +/// #3666: expanding the transparent image patterns of a shape with two pattern fills expands only +/// the fill that paints a transparent image; the opaque image pattern stays a pattern fill. +#[test] +fn only_the_transparent_pattern_fill_of_a_leaf_is_expanded() { + let mut s = two_pattern_fills(); + let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let keys = crate::cmd::flatten::see_through_images(d); + let layer = d.layers[0].id; + assert_eq!(crate::cmd::expand::expand_transparent_image_patterns(d, layer, &keys).unwrap(), 1); + let mut patterns = vec![]; + let mut images = 0; + d.walk(|n| { + for i in &n.appearance.items { + if let vectorcraft_doc::appearance::AppearanceItem::Fill(f) = i + && let vectorcraft_color::Paint::Pattern { pattern, .. } = &f.paint + { + patterns.push(pattern.clone()); + } + } + images += usize::from(matches!(n.kind, vectorcraft_doc::NodeKind::Image(_))); + }); + assert_eq!(patterns.len(), 1, "{patterns:?}"); + assert!(images > 0); +}