diff --git a/crates/doc/src/node/ops.rs b/crates/doc/src/node/ops.rs index fcf2c9429..edab5f73c 100644 --- a/crates/doc/src/node/ops.rs +++ b/crates/doc/src/node/ops.rs @@ -686,6 +686,12 @@ impl Node { /// stroke opacity below 100%, a blend mode, an opacity mask, effects, transparent gradient /// stops or a colorized image. Hidden objects show none. pub fn shows_transparency(&self) -> bool { + self.shows_transparency_with(&|_| true) + } + + /// [`Self::shows_transparency`], counting a colorized image by itself only when `colorize` + /// accepts it, for files that write some colorized images in their colorize paint (EPS). + pub fn shows_transparency_with(&self, colorize: &dyn Fn(&Node) -> bool) -> bool { use vectorcraft_color::Paint; let see_through = |p: &Paint| matches!(p, Paint::Gradient(g) if g.gradient.stops.iter().any(|s| s.opacity < 1.0)); let effects = |fx: &[crate::Effect]| fx.iter().any(|e| e.visible); @@ -693,14 +699,14 @@ impl Node { && (self.opacity < 1.0 || self.blend != BlendMode::Normal || self.extras().mask.as_ref().is_some_and(|m| !m.disabled) - || (matches!(self.kind, NodeKind::Image(_)) && self.extras().colorize.is_some()) + || (matches!(self.kind, NodeKind::Image(_)) && self.extras().colorize.is_some() && colorize(self)) || effects(&self.appearance.effects) || self .appearance .items .iter() .any(|i| i.visible() && (i.opacity() < 1.0 || i.blend() != BlendMode::Normal || see_through(i.paint()) || effects(i.effects()))) - || self.children().is_some_and(|ch| ch.iter().any(|c| c.shows_transparency()))) + || self.children().is_some_and(|ch| ch.iter().any(|c| c.shows_transparency_with(colorize)))) } } diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index 5ad8054fa..4745437d4 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -447,25 +447,206 @@ fn expand_pattern_fills(s: &mut Session) -> Result<()> { /// 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 when its key is in `see_through` (it has see-through -/// pixels), when it has a colorize paint, or when it, a group +/// pixels), when it has a colorize paint that `colorize` counts, 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| { - // 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; +/// mode. Blends, envelopes, repeats and Intertwine groups that hold such a fill are expanded +/// first ([`expand_live_with_transparent_image_patterns`]). Hidden shapes and shapes in hidden +/// groups or layers and in template layers stay as they are. A pattern fill in tile art counts +/// too, down to the [`PATTERN_LEVELS`] the canvas draws, and the tile art the expansion makes is +/// expanded again for it. A pattern fill stays, and a live object stays live, when its art would +/// take the expanded art past [`MAX_GENERATED`](vectorcraft_doc::live::MAX_GENERATED) objects. +/// Returns how many objects changed. +pub(crate) fn expand_transparent_image_patterns( + d: &mut Document, + root: NodeId, + see_through: &BTreeSet, + colorize: &dyn Fn(&Document, &Node) -> bool, +) -> Result { + expand_transparent_image_patterns_within(d, root, see_through, colorize, vectorcraft_doc::live::MAX_GENERATED) +} + +/// [`expand_transparent_image_patterns`] with the expanded art held to `budget` objects. +pub(crate) fn expand_transparent_image_patterns_within( + d: &mut Document, + root: NodeId, + see_through: &BTreeSet, + colorize: &dyn Fn(&Document, &Node) -> bool, + budget: usize, +) -> Result { + let mut changed = 0; + let made = std::cell::Cell::new(0usize); + // Each pass brings the patterns in the expanded tiles' art up one level. + for levels in (1..=PATTERN_LEVELS).rev() { + let live = expand_live_with_transparent_image_patterns(d, root, see_through, colorize, levels, &made, budget)?; + let (_, n) = replace_leaves(d, root, |d, l| { + // Past the budget, and for most leaves, which have no pattern fill, skip the ancestry walk. + if made.get() >= budget || !l.appearance.items.iter().any(|i| pattern_fill(d, i).is_some()) { + return None; + } + // The expanded art would be shown. + if !d.ancestry(l.id).unwrap_or_default().iter().filter_map(|a| d.node(*a)).all(drawn) { + return None; + } + let which = |d: &Document, i: &AppearanceItem| transparent_pattern_fill(d, l, i, see_through, colorize, levels); + let art = expand_patterns_where(d, l, which, budget - made.get())?; + // The copies of the shape's other items count too. + let count = art.count_with_masks(); + if count > budget - made.get() { + return None; + } + made.set(made.get() + count); + Some(art) + })?; + if live + n == 0 { + break; } - 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) + changed += live + n; + } + Ok(changed) +} + +/// How many levels of patterns the canvas draws: a pattern fill in the art of a pattern, and so +/// on, as [`vectorcraft_render`] draws them. +const PATTERN_LEVELS: u32 = 3; + +/// Whether a layer of `d` holds a pattern fill that [`expand_transparent_image_patterns`] would +/// expand with `see_through` and `colorize` ([`holds_transparent_image_patterns`]). +pub(crate) fn has_transparent_image_patterns(d: &Document, see_through: &BTreeSet, colorize: &dyn Fn(&Document, &Node) -> bool) -> bool { + d.layers.iter().any(|l| holds_transparent_image_patterns(d, l, see_through, colorize, PATTERN_LEVELS)) +} + +/// Whether `n`, art of `d`, is or holds a shown path or compound path (in groups, blends, +/// envelopes, repeats and Intertwine groups) with a pattern fill that +/// [`transparent_pattern_fill`] counts with `levels`. Guides, clipping paths and template layers +/// don't count. +fn holds_transparent_image_patterns( + d: &Document, + n: &Node, + see_through: &BTreeSet, + colorize: &dyn Fn(&Document, &Node) -> bool, + levels: u32, +) -> bool { + if !drawn(n) { + return false; + } + match &n.kind { + NodeKind::Path { guide: true, .. } | NodeKind::Path { clipping: true, .. } => false, + NodeKind::Path { .. } | NodeKind::Compound { .. } => { + n.appearance.items.iter().any(|i| transparent_pattern_fill(d, n, i, see_through, colorize, levels)) + } + _ => n.children().is_some_and(|ch| ch.iter().any(|c| holds_transparent_image_patterns(d, c, see_through, colorize, levels))), + } +} + +/// Whether `n` is shown and isn't a template layer. +fn drawn(n: &Node) -> bool { + n.visible && !matches!(n.kind, NodeKind::Layer { template: true, .. }) +} + +/// Expand the shown blends, envelopes, repeats and Intertwine groups under `root` that hold a +/// pattern fill [`holds_transparent_image_patterns`] counts with `levels` into the art the exporters write for +/// them ([`expand_live_deep`](vectorcraft_render::effects::expand_live_deep), under one budget for +/// everything nested in each), so the shapes they draw are leaves. The objects that art holds count +/// in `made`, and an object whose art would take `made` past `budget` stays live. Returns how many +/// were expanded. +fn expand_live_with_transparent_image_patterns( + d: &mut Document, + root: NodeId, + see_through: &BTreeSet, + colorize: &dyn Fn(&Document, &Node) -> bool, + levels: u32, + made: &std::cell::Cell, + budget: usize, +) -> Result { + fn outermost( + d: &Document, + n: &Node, + see_through: &BTreeSet, + colorize: &dyn Fn(&Document, &Node) -> bool, + levels: u32, + out: &mut Vec, + ) { + if !drawn(n) { + return; + } + match &n.kind { + NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Repeat(_) | NodeKind::Intertwine { .. } => { + if holds_transparent_image_patterns(d, n, see_through, colorize, levels) { + out.push(n.id); + } + } + NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => { + children.iter().for_each(|c| outermost(d, c, see_through, colorize, levels, out)); + } + _ => {} + } + } + /// Fresh ids for everything under `n`: copies of the same art share theirs. + fn fresh_ids(d: &mut Document, n: &mut Node) { + for c in n.children_mut().into_iter().flatten() { + let c = Arc::make_mut(c); + c.id = d.alloc_id(); + fresh_ids(d, c); + } + } + let mut live = vec![]; + if let Some(n) = d.node(root) { + outermost(d, n, see_through, colorize, levels, &mut live); + } + let mut count = 0; + for id in &live { + if made.get() >= budget { + break; + } + let Some(n) = d.node(*id) else { continue }; + // Random Transform effects take their seed from the ids: bake them first ([`bake_seeded`]), + // and leave the object live when that can't be done. + let Some(mut g) = bake_seeded(&vectorcraft_render::effects::expand_live_deep(Some(d), n)) else { continue }; + // An object whose art would take the expanded art past the budget stays live. + let size = g.count_with_masks(); + if size > budget - made.get() { + continue; + } + made.set(made.get() + size); + g.id = *id; + fresh_ids(d, &mut g); + *d.node_mut(*id).ok_or(EngineError::NoNode(*id))? = g; + count += 1; + } + Ok(count) +} + +/// Whether `i`, an item of leaf `l`, is a pattern fill whose tile art holds a transparent image +/// ([`transparent_image`], through pattern fills in that art down to `levels` patterns), +/// counting the fill's and the shape's opacity and blending mode. +fn transparent_pattern_fill( + d: &Document, + l: &Node, + i: &AppearanceItem, + see_through: &BTreeSet, + colorize: &dyn Fn(&Document, &Node) -> bool, + levels: u32, +) -> bool { + let shape = d.ancestry(l.id).unwrap_or_default().iter().filter_map(|a| d.node(*a)).any(blended); + pattern_fill_holds_transparent_image(d, i, see_through, colorize, shape, levels) +} + +/// Whether `i` is a pattern fill whose tile art holds a transparent image ([`transparent_image`]), +/// down to `levels` patterns, counting the fill's opacity and blending mode, or anything when `on`. +fn pattern_fill_holds_transparent_image( + d: &Document, + i: &AppearanceItem, + keys: &BTreeSet, + colorize: &dyn Fn(&Document, &Node) -> bool, + on: bool, + levels: u32, +) -> bool { + if levels == 0 { + return false; + } + let Some((f, def, _)) = pattern_fill(d, i) else { return false }; + let on = on || f.opacity < 1.0 || f.blend != BlendMode::Normal; + def.art.iter().any(|a| transparent_image(d, a, keys, colorize, on, levels - 1)) } /// Whether `n` has opacity or a blending mode of its own. @@ -473,13 +654,21 @@ fn blended(n: &Node) -> bool { n.opacity < 1.0 || n.blend != BlendMode::Normal } -/// 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 { +/// Whether `n` (art of `d`) is or holds an image with see-through pixels (key in `keys`), a +/// colorize paint that `colorize` counts, opacity or a blending mode of its own or of a group in +/// `n`, or anywhere when `on`, also in the tile art of the pattern fills of the paths in `n`, down +/// to `levels` patterns. Hidden art doesn't count. +fn transparent_image(d: &Document, n: &Node, keys: &BTreeSet, colorize: &dyn Fn(&Document, &Node) -> bool, on: bool, levels: u32) -> bool { + if !n.visible { + return false; + } let on = on || blended(n); match &n.kind { - 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))), + NodeKind::Image(im) => on || keys.contains(&im.key) || (n.extras().colorize.is_some() && colorize(d, n)), + NodeKind::Path { .. } | NodeKind::Compound { .. } => { + n.appearance.items.iter().any(|i| pattern_fill_holds_transparent_image(d, i, keys, colorize, on, levels)) + } + _ => n.children().is_some_and(|ch| ch.iter().any(|c| transparent_image(d, c, keys, colorize, on, levels))), } } @@ -491,12 +680,12 @@ fn transparent_image(n: &Node, keys: &BTreeSet, on: bool) -> bool { /// ([`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 { - expand_patterns_where(d, l, |_, _| true) + expand_patterns_where(d, l, |_, _| true, usize::MAX) } /// [`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 { +/// items, as does a pattern fill whose tiles would take the tiles of `l` past `budget` objects. +fn expand_patterns_where(d: &mut Document, l: &Node, which: impl Fn(&Document, &AppearanceItem) -> bool, budget: usize) -> Option { if !matches!(l.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) || !l.appearance.items.iter().any(|i| pattern_fill(d, i).is_some() && which(d, i)) { @@ -509,19 +698,47 @@ fn expand_patterns_where(d: &mut Document, l: &Node, which: impl Fn(&Document, & let mut parts = vec![]; let mut rest = vec![]; let mut expanded = false; + // What `assemble` adds around the tiles when every picked fill expands: a copy of the path for + // each run of other items, a group around the parts and one for the path's transparency, and + // the path's opacity mask. + let (mut copies, mut run, mut picked) = (0usize, false, vec![]); + for i in &l.appearance.items { + if let Some((f, _, _)) = pattern_fill(d, i).filter(|_| which(d, i)) { + run = false; + picked.push(f.opacity < 1.0 || f.blend != BlendMode::Normal); + } else if i.visible() && !i.paint().is_none() && !run { + (copies, run) = (copies + 1, true); + } + } + let group = copies + picked.len() > 1 || !l.appearance.effects.is_empty(); + // A lone tile group with transparency of its own goes in a group for the path's. + let transparency = blended(l) && !group && picked.first().is_some_and(|b| *b); + let around = copies.saturating_mul(l.count()) + usize::from(group) + usize::from(transparency) + l.count_with_masks().saturating_sub(l.count()); + let mut left = budget.saturating_sub(around); + // A fill that expands while others follow leaves room for the copy and group that those take + // if they don't fit, and once one doesn't fit, the ones after it stay too. + let (mut later, mut full) = (picked.len(), false); for item in &l.appearance.items { 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)| { + later -= usize::from(fill.is_some()); + let room = if later > 0 { left.saturating_sub(l.count() + 1) } else { left }; + let tiles = fill.filter(|_| !full).and_then(|(f, def, xf)| { let (fl, _, bounds) = fill_shape(l, item, &path, bounds)?; - let t = pattern_tiles(d, &def, xf, bounds); + let Some(t) = pattern_tiles(d, &def, xf, bounds, room) else { + full = true; + return None; + }; (!t.is_empty()).then_some((f, fl, t)) }); - let Some(art) = tiles.and_then(|(f, fl, t)| tile_art(d, &fl, f, &t)) else { + let art = tiles.and_then(|(f, fl, t)| tile_art(d, &fl, f, &t)); + full |= art.as_ref().is_some_and(|a| a.count_with_masks() > room); + let Some(art) = art.filter(|a| a.count_with_masks() <= room) else { if item.visible() && !item.paint().is_none() { rest.push(item.clone()); } continue; }; + left -= art.count_with_masks(); if !rest.is_empty() { parts.push(styled_copy(d, l, std::mem::take(&mut rest))); } @@ -548,30 +765,38 @@ fn expand_patterns_where(d: &mut Document, l: &Node, which: impl Fn(&Document, & /// not, and when it does more than move art that holds a symbol instance, whose strokes the canvas /// draws scaled with the tile and an instance does not scale. Random Transform effects and brushes /// that vary at random in the art are baked first ([`seeds_baked`], [`random_brushes_baked`]). -fn pattern_tiles(d: &mut Document, def: &PatternDef, xf: Affine, bounds: Rect) -> Vec { +/// `None` when the tiles would hold more than `budget` objects. +fn pattern_tiles(d: &mut Document, def: &PatternDef, xf: Affine, bounds: Rect, budget: usize) -> Option> { if !moves_only(xf) && def.art.iter().any(|a| has_instances(a)) { - return vec![]; + return Some(vec![]); } let Some(def) = seeds_baked(def) else { - return vec![]; + return Some(vec![]); }; let brushes = vectorcraft_brush::library(d); let def = random_brushes_baked(d, &brushes, def); let def = &*def; + let per_tile = def.art.iter().map(|a| a.count_with_masks()).sum::(); + if def.offsets_covering(xf.inverse().transform_rect_bbox(bounds)).len().saturating_mul(per_tile) > budget { + return None; + } let scaling = Scaling { strokes: true, effects: Some(vectorcraft_render::effects::scale_effect), patterns: true, ..Scaling::default() }; if scales_evenly(xf) && (moves_only(xf) || !def.art.iter().any(|a| has_geometry_effects(a))) { - return def.instances_in_with(xf, bounds, scaling); + return Some(def.instances_in_with(xf, bounds, scaling)); } if def.art.iter().any(|a| has_unbakeable_effects(a)) { - return vec![]; + return Some(vec![]); } let placed = Scaling { patterns: true, ..Scaling::default() }; let mut tiles = def.instances_in_with(Affine::IDENTITY, xf.inverse().transform_rect_bbox(bounds), placed); + // Outlined strokes and effects can make more objects than the art had. + let mut left = budget; for t in &mut tiles { *t = prepared(d, &brushes, t); + left = left.checked_sub(t.count_with_masks())?; place_art(t, xf, scaling); } - tiles + Some(tiles) } /// Pattern `def` with each path in its art (opacity masks included) that has a random Transform diff --git a/crates/engine/src/cmd/fileio/eps.rs b/crates/engine/src/cmd/fileio/eps.rs index a05cc0824..d2424bf0a 100644 --- a/crates/engine/src/cmd/fileio/eps.rs +++ b/crates/engine/src/cmd/fileio/eps.rs @@ -12,7 +12,7 @@ use vectorcraft_doc::Document; use vectorcraft_eps::{EpsOptions, Level, Overprint, Preview, Raster}; use vectorcraft_geom::{Point, Rect}; -use super::super::flatten::{FlattenOptions, flatten_document}; +use super::super::flatten::{FlattenOptions, expand_transparent_tile_images, flatten_expanded}; use super::super::{CommandSpec, EngineError, Result, Session, bad, bool_or, cmd, has_doc, str_param}; use super::{ArtboardPick, Encoded, FormatOption}; @@ -299,11 +299,13 @@ fn encode_document(doc: &Document, p: &Value, saving: bool) -> Result { return Err(bad(C, "the document has no artboard")); } let regions: Vec = pages.iter().map(|(_, region, _)| *region).collect(); - let transparent = doc.layers.iter().any(|l| l.shows_transparency()); - let flat = flatten_document(doc, &set.flatten)?; - let drawn = flat.as_ref().unwrap_or(doc); + let expanded = expand_transparent_tile_images(doc, &set.flatten)?; + let source = expanded.as_ref().unwrap_or(doc); + let transparent = source.layers.iter().any(|l| set.flatten.shows_transparency(source, l)); + let flat = flatten_expanded(source, &set.flatten)?; + let drawn = flat.as_ref().unwrap_or(source); let mut enc = Encoded { warnings, joiner: Some("_"), ..Encoded::default() }; - if transparent && flat.is_some() { + if transparent && (flat.is_some() || expanded.is_some()) { enc.warnings.push("transparency is flattened into opaque art and images (EPS has none): see the flattener preset".into()); } let preview_limit = if saving { SAVE_PREVIEW } else { MAX_PREVIEW }; diff --git a/crates/engine/src/cmd/fileio/legacy_ai.rs b/crates/engine/src/cmd/fileio/legacy_ai.rs index 0e53a5a18..7cbbda2fe 100644 --- a/crates/engine/src/cmd/fileio/legacy_ai.rs +++ b/crates/engine/src/cmd/fileio/legacy_ai.rs @@ -167,7 +167,7 @@ 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)?; + super::super::expand::expand_transparent_image_patterns(d, id, &keys, &|_, _| true)?; } Ok(()) } diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index dfa7d1cfa..213a442cf 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -317,9 +317,9 @@ pub const DEFAULT_FLATTENER: &str = "High Resolution"; /// `doc` with its transparency flattened for a PDF 1.3 file (asked for, or PDF/X-1a and PDF/X-3, /// whose files have none), with `flattenerPreset` (default High Resolution) and `p`'s `flattener` -/// options over it; images with see-through pixels and colorized images count as transparency, -/// rasterized areas are clipped to their regions, and discarded overprints are dropped. `None` -/// when nothing changes (or the file keeps transparency). +/// options over it; images with see-through pixels and colorized images, including those in +/// pattern tiles, count as transparency. Rasterized areas are clipped to their regions, and +/// discarded overprints are dropped. `None` when nothing changes (or the file keeps transparency). fn flatten_for(cmd: &str, doc: &Document, set: &PdfSettings, p: &Value) -> Result> { if !set.pdf13() { return Ok(None); diff --git a/crates/engine/src/cmd/flatten.rs b/crates/engine/src/cmd/flatten.rs index 47a190a43..ccbe9c049 100644 --- a/crates/engine/src/cmd/flatten.rs +++ b/crates/engine/src/cmd/flatten.rs @@ -49,7 +49,7 @@ mod presets; mod report; pub(crate) use analysis::{outlined_text, see_through_images}; -pub(crate) use apply::flatten_document; +pub(crate) use apply::{expand_transparent_tile_images, flatten_document, flatten_expanded, has_transparent_tile_images}; pub use commands::specs; pub use options::FlattenOptions; pub use presets::{FlattenerPreset, PRESET_EXTS, PRESET_FORMAT}; diff --git a/crates/engine/src/cmd/flatten/apply.rs b/crates/engine/src/cmd/flatten/apply.rs index 884351dea..d229f1bbb 100644 --- a/crates/engine/src/cmd/flatten/apply.rs +++ b/crates/engine/src/cmd/flatten/apply.rs @@ -74,7 +74,7 @@ pub(super) fn plan(src: &Document, roots: &[NodeId], o: &FlattenOptions, render: let mut sc = Scratch { brushes: vectorcraft_brush::library(&baked), doc: baked.clone() }; let plain: Vec = art.iter().map(|n| sc.plain(n)).collect(); let reaches: Vec> = plain.iter().map(reach).collect(); - let see_through: Vec = plain.iter().map(|n| n.shows_transparency() || o.draws_see_through_image(n)).collect(); + let see_through: Vec = plain.iter().map(|n| o.shows_transparency(&baked, n) || o.draws_see_through_image(n)).collect(); let cmyk = src.color_mode == ColorMode::Cmyk; let mut out = Plan { flat: vec![], kept: vec![], transparent: roots.iter().zip(&see_through).filter(|(_, t)| **t).map(|(id, _)| *id).collect() }; // Baking above stopped early when cancelled; each group is a place to stop (a plan changes nothing). @@ -292,12 +292,46 @@ pub(super) fn opaque(n: &mut Node) { } } +/// Whether `doc` has pattern fills whose image tiles need transparency flattening with `o` +/// ([`expand_transparent_tile_images`]). +pub(crate) fn has_transparent_tile_images(doc: &Document, o: &FlattenOptions) -> bool { + let keys = o.no_transparency.clone().unwrap_or_default(); + let colorize = |d: &Document, n: &Node| o.counts_colorize(d, n); + !doc.patterns.is_empty() && crate::cmd::expand::has_transparent_image_patterns(doc, &keys, &colorize) +} + +/// Expand pattern fills whose image tiles need transparency flattening. +pub(crate) fn expand_transparent_tile_images(doc: &Document, o: &FlattenOptions) -> Result> { + if !has_transparent_tile_images(doc, o) { + return Ok(None); + } + let keys = o.no_transparency.clone().unwrap_or_default(); + let colorize = |d: &Document, n: &Node| o.counts_colorize(d, n); + let mut d = doc.clone(); + let layers: Vec = + d.layers.iter().filter(|l| l.visible && !matches!(l.kind, NodeKind::Layer { template: true, .. })).map(|l| l.id).collect(); + let mut changed = 0; + for id in layers { + changed += crate::cmd::expand::expand_transparent_image_patterns(&mut d, id, &keys, &colorize)?; + } + Ok((changed > 0).then_some(d)) +} + /// `doc` with everything it draws flattened with `o`, as formats without transparency (EPS) /// write it: every visible object of the visible layers, locked ones too (a clipping layer's -/// clipping path stays). `None` when that changes nothing. +/// clipping path stays). Pattern tile images with opacity, blending or a colorize paint that +/// `o` counts ([`FlattenOptions::counts_colorize`]) are expanded; see-through tile pixels are +/// expanded when `o.no_transparency` counts them. `None` when nothing changes. pub(crate) fn flatten_document(doc: &Document, o: &FlattenOptions) -> Result> { + let expanded = expand_transparent_tile_images(doc, o)?; + Ok(flatten_expanded(expanded.as_ref().unwrap_or(doc), o)?.or(expanded)) +} + +/// [`flatten_document`] for `doc` whose pattern tile images were expanded already +/// ([`expand_transparent_tile_images`]). +pub(crate) fn flatten_expanded(doc: &Document, o: &FlattenOptions) -> Result> { let outlining = o.text_to_outlines || o.strokes_to_outlines || !o.preserve_overprints; - if !outlining && !doc.layers.iter().any(|l| l.shows_transparency() || o.draws_see_through_image(l)) { + if !outlining && !doc.layers.iter().any(|l| o.shows_transparency(doc, l) || o.draws_see_through_image(l)) { return Ok(None); } let mut roots = vec![]; diff --git a/crates/engine/src/cmd/flatten/options.rs b/crates/engine/src/cmd/flatten/options.rs index 57331660a..d4f18f385 100644 --- a/crates/engine/src/cmd/flatten/options.rs +++ b/crates/engine/src/cmd/flatten/options.rs @@ -2,7 +2,7 @@ use super::analysis::draws_image; use super::apply::{apply, kept_work, plan}; use super::presets::{FlattenerPreset, saved_preset}; use super::report::target_roots; -use super::{BTreeSet, Deserialize, EngineError, Node, Result, Serialize, Session, Value, bad, json, str_param}; +use super::{BTreeSet, Deserialize, Document, EngineError, Node, NodeKind, Paint, Result, Serialize, Session, Value, bad, json, str_param}; /// Object → Flatten Transparency settings: a preset's, adjusted by the command's params. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -34,6 +34,10 @@ pub struct FlattenOptions { /// amounts). `None` for ordinary flattening. Not a preset option. #[serde(skip)] pub no_transparency: Option>, + /// The file writes a colorized image in its colorize paint (EPS), so a colorize paint by + /// itself doesn't count as transparency. Not a preset option. + #[serde(skip)] + pub keeps_colorize: bool, } impl Default for FlattenOptions { @@ -81,6 +85,7 @@ impl FlattenOptions { preserve_alpha: false, preserve_overprints: true, no_transparency: None, + keeps_colorize: false, } } @@ -138,6 +143,21 @@ impl FlattenOptions { Ok(o) } + /// Whether `n`, art of `d`, shows transparency these options flatten + /// ([`Node::shows_transparency`], colorized images as [`Self::counts_colorize`] says). + pub(crate) fn shows_transparency(&self, d: &Document, n: &Node) -> bool { + n.shows_transparency_with(&|c| self.counts_colorize(d, c)) + } + + /// Whether colorized image `n`, art of `d`, counts as transparency by itself. With + /// [`Self::keeps_colorize`], an image colorized with a solid paint whose pixels are grayscale + /// or 1-bit doesn't: the EPS writer draws it in that paint. + pub(crate) fn counts_colorize(&self, d: &Document, n: &Node) -> bool { + let NodeKind::Image(im) = &n.kind else { return true }; + let solid = matches!(n.extras().colorize.as_deref(), Some(Paint::Solid { .. })); + !self.keeps_colorize || !solid || d.image_blob(im).is_some_and(|b| b.gray_source().is_none()) + } + /// `n` shows an image whose see-through pixels count as transparency. pub(super) fn draws_see_through_image(&self, n: &Node) -> bool { self.no_transparency.as_ref().is_some_and(|keys| draws_image(n, keys)) diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index 0face3aa8..075bac2f5 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -11,6 +11,7 @@ use vectorcraft_pdf::{PrintLayers, PrintOptions, PrintPreview, PrintSettings}; use super::fileio::pdf::{merge, pdf_error}; use super::fileio::{extension, write_or_return}; +use super::flatten::has_transparent_tile_images; use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, cmd, has_doc, str_param}; const SETUP: &str = "print.setup"; @@ -179,6 +180,12 @@ fn print(s: &mut Session, p: &Value) -> Result { // Raster effects print as images at the document's raster effects resolution. let flat = super::rasterfx::flatten_raster_effects(doc); let doc = flat.as_ref().unwrap_or(doc); + let bitmap = set.advanced.print_as_bitmap && set.output.mode == vectorcraft_pdf::OutputMode::Composite; + // The flattener expands pattern fills whose tile images need flattening, in the layers that print. + let tile_images = postscript + && !bitmap + && super::printadvanced::flattener(PRINT, &set, &s.prefs.flattener_presets)? + .is_some_and(|o| has_transparent_tile_images(&vectorcraft_pdf::printed_document(doc, &set), &o)); // Print as Bitmap and the flattener preset (Advanced) render and flatten it first. let prepared = super::printadvanced::prepare(PRINT, doc, &set, &s.prefs.flattener_presets, s.prefs.black_output == "rich")?; let path = str_param(p, "path"); @@ -201,8 +208,7 @@ fn print(s: &mut Session, p: &Value) -> Result { None => Level::default(), }; let mut warnings = vec![]; - let bitmap = set.advanced.print_as_bitmap && set.output.mode == vectorcraft_pdf::OutputMode::Composite; - if !bitmap && doc.layers.iter().any(|l| l.shows_transparency()) { + if !bitmap && (tile_images || doc.layers.iter().any(|l| l.shows_transparency())) { warnings.push("transparency is flattened into opaque art and images (PostScript has none): see flattenerPreset".to_string()); } if !set.color.profile.trim().is_empty() && set.output.mode == vectorcraft_pdf::OutputMode::Composite { diff --git a/crates/engine/src/tests/legacy_ai.rs b/crates/engine/src/tests/legacy_ai.rs index 7ac5c3c5c..90c728a2f 100644 --- a/crates/engine/src/tests/legacy_ai.rs +++ b/crates/engine/src/tests/legacy_ai.rs @@ -546,7 +546,7 @@ fn a_colorized_grayscale_image_pattern_fill_is_expanded() { 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); + assert_eq!(crate::cmd::expand::expand_transparent_image_patterns(d, layer, &keys, &|_, _| true).unwrap(), 1); let mut patterns = 0; let mut tile_images = 0; d.walk(|n| { @@ -688,7 +688,7 @@ fn only_the_transparent_pattern_fill_of_a_leaf_is_expanded() { 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); + assert_eq!(crate::cmd::expand::expand_transparent_image_patterns(d, layer, &keys, &|_, _| true).unwrap(), 1); let mut patterns = vec![]; let mut images = 0; d.walk(|n| { @@ -704,3 +704,304 @@ fn only_the_transparent_pattern_fill_of_a_leaf_is_expanded() { assert_eq!(patterns.len(), 1, "{patterns:?}"); assert!(images > 0); } + +/// A pattern fill whose expanded art would hold more objects than the budget stays a pattern fill: +/// the 32 pt tiles over the 128 pt square are 25 copies of the tile art's 3 objects, which are not +/// made with a smaller budget, and the clip group around them counts too. +#[test] +fn a_transparent_image_pattern_past_the_budget_stays() { + let expand = |budget: usize| { + let mut s = pattern_over_blue(&checker([0, 200, 0, 255], [255, 255, 255, 0]), 1.0); + assert_eq!(s.doc().unwrap().doc.patterns[0].art.iter().map(|a| a.count_with_masks()).sum::(), 3); + 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; + let before = d.layers[0].count_with_masks(); + let n = crate::cmd::expand::expand_transparent_image_patterns_within(d, layer, &keys, &|_, _| true, budget).unwrap(); + // The objects the expansion made, with the square it replaced. + (n, images(d).0, d.layers[0].count_with_masks() + 1 - before) + }; + let (n, tiles, made) = expand(usize::MAX); + assert_eq!((n, tiles), (1, 25)); + assert!(made > 75, "{made}"); + assert_eq!(expand(made), (1, 25, made)); + assert_eq!(expand(made - 1).0, 0); + assert_eq!(expand(74).0, 0); +} + +/// On a shape with two transparent image pattern fills, both expand with a budget their tiles and +/// the group around them fit, and one expands with one object less. +#[test] +fn two_transparent_image_pattern_fills_expand_within_the_budget() { + let expand = |budget: usize| { + let mut s = pattern_over_blue(&checker([0, 200, 0, 255], [255, 255, 255, 0]), 1.0); + let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let mut square = None; + d.walk(|n| { + if n.appearance.items.iter().any( + |i| matches!(i, vectorcraft_doc::appearance::AppearanceItem::Fill(f) if matches!(f.paint, vectorcraft_color::Paint::Pattern { .. })), + ) { + square = Some(n.id); + } + }); + let n = d.node_mut(square.unwrap()).unwrap(); + assert_eq!(n.appearance.items.len(), 1); + let fill = n.appearance.items[0].clone(); + n.appearance.items.push(fill); + let keys = crate::cmd::flatten::see_through_images(d); + let layer = d.layers[0].id; + let before = d.layers[0].count_with_masks(); + let n = crate::cmd::expand::expand_transparent_image_patterns_within(d, layer, &keys, &|_, _| true, budget).unwrap(); + (n, images(d).0, d.layers[0].count_with_masks() + 1 - before) + }; + let (n, tiles, made) = expand(usize::MAX); + assert_eq!((n, tiles), (1, 50)); + assert_eq!(expand(made), (1, 50, made)); + assert_eq!(expand(made - 1).1, 25); +} + +#[test] +fn pdf13_flattens_see_through_pattern_tile_images() { + let mut s = pattern_over_blue(&checker([0, 200, 0, 255], [255, 255, 255, 0]), 1.0); + let before = [colour_at(&s.doc().unwrap().doc, 72.0, 72.0), colour_at(&s.doc().unwrap().doc, 88.0, 72.0)]; + let r = s.execute("document.exportPdf", &json!({"compatibility": "1.3"})).unwrap(); + let bytes = vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap(); + let page = vectorcraft_pdf::import(&bytes).unwrap(); + let after = [colour_at(&page, 72.0, 72.0), colour_at(&page, 88.0, 72.0)]; + for (a, b) in before.iter().zip(after) { + assert!(a.iter().zip(b).all(|(a, b)| a.abs_diff(b) <= 4), "before {before:?} after {after:?}"); + } +} + +#[test] +fn pdf13_flattens_colorized_pattern_tile_images() { + let mut s = colorized_pattern_over_blue(); + let before = [colour_at(&s.doc().unwrap().doc, 72.0, 72.0), colour_at(&s.doc().unwrap().doc, 88.0, 72.0)]; + let r = s.execute("document.exportPdf", &json!({"compatibility": "1.3"})).unwrap(); + let bytes = vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap(); + let page = vectorcraft_pdf::import(&bytes).unwrap(); + let after = [colour_at(&page, 72.0, 72.0), colour_at(&page, 88.0, 72.0)]; + for (a, b) in before.iter().zip(after) { + assert!(a.iter().zip(b).all(|(a, b)| a.abs_diff(b) <= 4), "before {before:?} after {after:?}"); + } +} + +#[test] +fn eps_level3_writes_see_through_pattern_tile_images() { + let mut s = pattern_over_blue(&checker([0, 200, 0, 255], [255, 255, 255, 0]), 1.0); + let before = [colour_at(&s.doc().unwrap().doc, 72.0, 72.0), colour_at(&s.doc().unwrap().doc, 88.0, 72.0)]; + let r = s.execute("document.exportEps", &json!({"previewFormat": "none", "useArtboards": true, "level": 3})).unwrap(); + s.execute("document.open", &json!({"name": "p.eps", "dataBase64": r["dataBase64"], "editingData": false})).unwrap(); + let page = &s.doc().unwrap().doc; + let after = [colour_at(page, 72.0, 72.0), colour_at(page, 88.0, 72.0)]; + for (a, b) in before.iter().zip(after) { + assert!(a.iter().zip(b).all(|(a, b)| a.abs_diff(b) <= 4), "before {before:?} after {after:?}"); + } +} + +#[test] +fn eps_writes_colorized_pattern_tile_images_in_their_colors() { + let mut s = colorized_pattern_over_blue(); + let before = [colour_at(&s.doc().unwrap().doc, 72.0, 72.0), colour_at(&s.doc().unwrap().doc, 88.0, 72.0)]; + let r = s.execute("document.exportEps", &json!({"previewFormat": "none", "useArtboards": true, "level": 3})).unwrap(); + s.execute("document.open", &json!({"name": "p.eps", "dataBase64": r["dataBase64"], "editingData": false})).unwrap(); + let after = [colour_at(&s.doc().unwrap().doc, 72.0, 72.0), colour_at(&s.doc().unwrap().doc, 88.0, 72.0)]; + for (a, b) in before.iter().zip(after) { + assert!(a.iter().zip(b).all(|(a, b)| a.abs_diff(b) <= 4), "before {before:?} after {after:?}"); + } +} + +#[test] +fn flattening_that_keeps_colorize_leaves_colorized_pattern_tile_images_alone() { + let s = colorized_pattern_over_blue(); + let d = &s.doc().unwrap().doc; + assert!(crate::cmd::flatten::flatten_document(d, &crate::cmd::FlattenOptions::default()).unwrap().is_some()); + let o = crate::cmd::FlattenOptions { keeps_colorize: true, ..Default::default() }; + assert!(crate::cmd::flatten::flatten_document(d, &o).unwrap().is_none(), "the pattern isn't expanded for a colorize paint alone"); +} + +/// EPS export expands pattern tile images once: flattening the expanded +/// document leaves the patterns the expansion kept, past its object limit, as they are. +#[test] +fn flattening_an_expanded_document_does_not_expand_patterns_again() { + let s = colorized_pattern_over_blue(); + let d = &s.doc().unwrap().doc; + assert!(crate::cmd::flatten::flatten_expanded(d, &crate::cmd::FlattenOptions::default()).unwrap().is_none()); +} + +#[test] +fn eps_flattens_colorized_image_in_a_see_through_pattern_tile_group_and_warns() { + let mut s = colorized_pattern_over_blue(); + let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let id = d.alloc_id(); + let art = std::mem::take(&mut d.patterns[0].art); + let mut group = vectorcraft_doc::Node::group(id, art); + group.opacity = 0.5; + d.patterns[0].art.push(std::sync::Arc::new(group)); + let before = [colour_at(&s.doc().unwrap().doc, 72.0, 72.0), colour_at(&s.doc().unwrap().doc, 88.0, 72.0)]; + let r = s.execute("document.exportEps", &json!({"previewFormat": "none", "useArtboards": true, "level": 3})).unwrap(); + assert!(warnings(&r).iter().any(|w| w.contains("transparency is flattened")), "{:?}", warnings(&r)); + s.execute("document.open", &json!({"name": "p.eps", "dataBase64": r["dataBase64"], "editingData": false})).unwrap(); + let after = [colour_at(&s.doc().unwrap().doc, 72.0, 72.0), colour_at(&s.doc().unwrap().doc, 88.0, 72.0)]; + for (a, b) in before.iter().zip(after) { + assert!(a.iter().zip(b).all(|(a, b)| a.abs_diff(b) <= 4), "before {before:?} after {after:?}"); + } +} + +#[test] +fn postscript_print_warns_for_colorized_pattern_tile_image() { + let mut s = colorized_pattern_over_blue(); + let r = s.execute("file.print", &json!({"format": "postscript"})).unwrap(); + assert!(warnings(&r).iter().any(|w| w.contains("transparency is flattened")), "{:?}", warnings(&r)); +} + +#[test] +fn eps_pattern_does_not_flatten_a_separate_see_through_image() { + let mut output = vec![]; + for tile in [checker([0, 200, 0, 255], [255, 230, 0, 255]), checker([0, 200, 0, 255], [255, 255, 255, 0])] { + let mut s = pattern_over_blue(&tile, 1.0); + s.execute("shape.rectangle", &json!({"x": 8, "y": 8, "width": 24, "height": 24})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + let p = json!({"name": "i.png", "dataBase64": vectorcraft_format::base64_encode(&png(20, 20, [0, 0, 0, 128])), "at": [20, 20]}); + s.execute("file.place", &p).unwrap(); + let r = + s.execute("document.exportEps", &json!({"previewFormat": "none", "useArtboards": true, "level": 3, "flattenerPreset": "high"})).unwrap(); + let image_warning = warnings(&r).iter().any(|w| w.contains("partly transparent")); + s.execute("document.open", &json!({"name": "p.eps", "dataBase64": r["dataBase64"], "editingData": false})).unwrap(); + output.push((colour_at(&s.doc().unwrap().doc, 20.0, 20.0), image_warning)); + } + assert_eq!(output[0], output[1], "EPS layer image changed when a see-through pattern tile was present: {output:?}"); +} + +#[test] +fn opaque_pattern_tile_images_do_not_change_during_export_flattening() { + let s = pattern_over_blue(&checker([0, 200, 0, 255], [255, 230, 0, 255]), 1.0); + let d = &s.doc().unwrap().doc; + assert!(crate::cmd::flatten::flatten_document(d, &crate::cmd::FlattenOptions::default()).unwrap().is_none()); + let o = crate::cmd::FlattenOptions { no_transparency: Some(crate::cmd::flatten::see_through_images(d)), ..Default::default() }; + assert!(crate::cmd::flatten::flatten_document(d, &o).unwrap().is_none()); +} + +/// The id of the square [`pattern_over_blue`] fills with its pattern. +fn pattern_square(s: &Session) -> vectorcraft_doc::NodeId { + let mut id = None; + s.doc().unwrap().doc.walk(|n| { + if n.appearance.items.iter().any(|i| matches!(i.paint(), vectorcraft_color::Paint::Pattern { .. })) { + id = Some(n.id); + } + }); + id.unwrap() +} + +/// PDF 1.3 draws `s` as the canvas draws it at two points of the pattern square. +fn assert_pdf13_draws_pattern_square_as_canvas(s: &mut Session) { + let before = [colour_at(&s.doc().unwrap().doc, 72.0, 72.0), colour_at(&s.doc().unwrap().doc, 88.0, 72.0)]; + let r = s.execute("document.exportPdf", &json!({"compatibility": "1.3"})).unwrap(); + let bytes = vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap(); + let page = vectorcraft_pdf::import(&bytes).unwrap(); + let after = [colour_at(&page, 72.0, 72.0), colour_at(&page, 88.0, 72.0)]; + for (a, b) in before.iter().zip(after) { + assert!(a.iter().zip(b).all(|(a, b)| a.abs_diff(b) <= 4), "before {before:?} after {after:?}"); + } +} + +#[test] +fn pdf13_flattens_colorized_pattern_tile_images_in_a_repeat() { + let mut s = colorized_pattern_over_blue(); + let square = pattern_square(&s); + s.execute("object.repeat.grid", &json!({"ids": [square.0]})).unwrap(); + assert!(s.doc().unwrap().doc.layers[0].count() > 3); + let d = &s.doc().unwrap().doc; + let mut repeats = 0; + d.walk(|n| repeats += usize::from(matches!(n.kind, vectorcraft_doc::NodeKind::Repeat(_)))); + assert_eq!(repeats, 1); + assert!(crate::cmd::flatten::flatten_document(d, &crate::cmd::FlattenOptions::default()).unwrap().is_some()); + assert_pdf13_draws_pattern_square_as_canvas(&mut s); +} + +/// A repeat whose art would take the expanded art past the budget stays live. +#[test] +fn a_repeat_past_the_budget_stays_live() { + for (budget, live) in [(1, 1), (usize::MAX, 0)] { + let mut s = colorized_pattern_over_blue(); + let square = pattern_square(&s); + s.execute("object.repeat.grid", &json!({"ids": [square.0]})).unwrap(); + let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let layer = d.layers[0].id; + crate::cmd::expand::expand_transparent_image_patterns_within(d, layer, &Default::default(), &|_, _| true, budget).unwrap(); + let mut repeats = 0; + d.walk(|n| repeats += usize::from(matches!(n.kind, vectorcraft_doc::NodeKind::Repeat(_)))); + assert_eq!(repeats, live, "budget {budget}"); + } +} + +/// [`colorized_pattern_over_blue`] with the square filled with a pattern named `outer` whose tile +/// art is a 32 pt square filled with the colorized image pattern. +fn nested_colorized_pattern_over_blue() -> Session { + let mut s = colorized_pattern_over_blue(); + let square = pattern_square(&s); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 32, "height": 32})).unwrap(); + let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let inner = d.patterns[0].name.clone(); + let rect = d.layers[0].children().unwrap().last().unwrap().id; + let mut tile = d.remove(rect).unwrap(); + let paint = vectorcraft_color::Paint::Pattern { pattern: inner, xf: Default::default() }; + let tile_node = std::sync::Arc::make_mut(&mut tile); + tile_node.appearance.items.retain(|i| matches!(i, vectorcraft_doc::appearance::AppearanceItem::Fill(_))); + for i in &mut tile_node.appearance.items { + if let vectorcraft_doc::appearance::AppearanceItem::Fill(f) = i { + f.paint = paint.clone(); + } + } + let mut outer = d.patterns[0].clone(); + outer.name = "outer".into(); + outer.art = vec![tile]; + d.patterns.push(outer); + for i in &mut d.node_mut(square).unwrap().appearance.items { + if let vectorcraft_doc::appearance::AppearanceItem::Fill(f) = i { + f.paint = vectorcraft_color::Paint::Pattern { pattern: "outer".into(), xf: Default::default() }; + } + } + s +} + +#[test] +fn pdf13_flattens_colorized_image_in_a_pattern_nested_in_tile_art() { + let mut s = nested_colorized_pattern_over_blue(); + assert_eq!(colour_at(&s.doc().unwrap().doc, 72.0, 72.0), [255, 0, 0]); + assert!(crate::cmd::flatten::flatten_document(&s.doc().unwrap().doc, &crate::cmd::FlattenOptions::default()).unwrap().is_some()); + assert_pdf13_draws_pattern_square_as_canvas(&mut s); +} + +#[test] +fn flattening_skips_a_pattern_nested_in_hidden_tile_art() { + let mut s = nested_colorized_pattern_over_blue(); + let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let outer = d.pattern_mut("outer").unwrap(); + std::sync::Arc::make_mut(&mut outer.art[0]).visible = false; + let d = &s.doc().unwrap().doc; + assert!(crate::cmd::flatten::flatten_document(d, &crate::cmd::FlattenOptions::default()).unwrap().is_none()); +} + +#[test] +fn pdf13_leaves_a_hidden_shape_with_a_colorized_pattern_tile_image_hidden() { + let mut s = colorized_pattern_over_blue(); + let square = pattern_square(&s); + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(square).unwrap().visible = false; + assert_pdf13_draws_pattern_square_as_canvas(&mut s); +} + +#[test] +fn flattening_skips_pattern_tile_images_on_hidden_and_template_layers() { + for template in [false, true] { + let mut s = colorized_pattern_over_blue(); + let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let layer = std::sync::Arc::make_mut(&mut d.layers[0]); + match &mut layer.kind { + vectorcraft_doc::NodeKind::Layer { template: t, .. } if template => *t = true, + _ => layer.visible = false, + } + let d = &s.doc().unwrap().doc; + assert!(crate::cmd::flatten::flatten_document(d, &crate::cmd::FlattenOptions::default()).unwrap().is_none(), "template {template}"); + } +}