mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
Merge pull request #3770 from Silvertree2010/fix/3666-legacy-ai-v7-round-trip-changes
Flatten transparent image pattern tiles in legacy .ai v7 export
This commit is contained in:
@@ -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,45 @@ 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<String>) -> Result<usize> {
|
||||
let (_, n) = replace_leaves(d, root, |d, l| {
|
||||
// Most leaves have no pattern fill: skip the ancestry walk for them.
|
||||
if !l.appearance.items.iter().any(|i| pattern_fill(d, i).is_some()) {
|
||||
return None;
|
||||
}
|
||||
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<String>, 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 +460,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<Node> {
|
||||
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<Node> {
|
||||
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 +479,7 @@ fn expand_patterns(d: &mut Document, l: &Node) -> Option<Node> {
|
||||
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);
|
||||
|
||||
@@ -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<NodeId> = 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.
|
||||
|
||||
@@ -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<u8> {
|
||||
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##"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200" viewBox="0 0 200 200"><defs><pattern id="p" patternUnits="userSpaceOnUse" width="32" height="32"><image width="32" height="32" xlink:href="{href}"/></pattern></defs><g><rect x="40" y="40" width="128" height="128" fill="#0000ff"/><rect x="40" y="40" width="128" height="128" fill="url(#p)" opacity="{opacity}"/></g></svg>"##
|
||||
);
|
||||
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##"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200" viewBox="0 0 200 200"><defs><pattern id="p" patternUnits="userSpaceOnUse" width="32" height="32"><image width="32" height="32" xlink:href="data:image/png;base64,{see_through}"/></pattern><pattern id="q" patternUnits="userSpaceOnUse" width="32" height="32"><image width="32" height="32" xlink:href="data:image/png;base64,{opaque}"/></pattern></defs><rect x="40" y="40" width="128" height="128" fill="url(#q)"/><rect x="40" y="40" width="128" height="128" fill="url(#p)"/></svg>"##
|
||||
);
|
||||
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<NodeId> = 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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user