Merge pull request #4783 from dotCooCoo/fix/pdf-eps-pattern-image-flatten

PDF/EPS export: see-through and colorized pattern tile images are flattened
This commit is contained in:
BFlat
2026-11-01 01:27:48 +07:00
committed by GitHub
10 changed files with 648 additions and 54 deletions
+8 -2
View File
@@ -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))))
}
}
+259 -34
View File
@@ -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<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;
/// 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<String>,
colorize: &dyn Fn(&Document, &Node) -> bool,
) -> Result<usize> {
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<String>,
colorize: &dyn Fn(&Document, &Node) -> bool,
budget: usize,
) -> Result<usize> {
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<String>, 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<String>,
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<String>,
colorize: &dyn Fn(&Document, &Node) -> bool,
levels: u32,
made: &std::cell::Cell<usize>,
budget: usize,
) -> Result<usize> {
fn outermost(
d: &Document,
n: &Node,
see_through: &BTreeSet<String>,
colorize: &dyn Fn(&Document, &Node) -> bool,
levels: u32,
out: &mut Vec<NodeId>,
) {
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<String>,
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<String>,
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<String>, 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<String>, 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<String>, 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<Node> {
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<Node> {
/// 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<Node> {
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<Node> {
/// `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<Vec<Node>> {
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::<usize>();
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
+7 -5
View File
@@ -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<Encoded> {
return Err(bad(C, "the document has no artboard"));
}
let regions: Vec<Rect> = 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 };
+1 -1
View File
@@ -167,7 +167,7 @@ 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)?;
super::super::expand::expand_transparent_image_patterns(d, id, &keys, &|_, _| true)?;
}
Ok(())
}
+3 -3
View File
@@ -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<Option<Document>> {
if !set.pdf13() {
return Ok(None);
+1 -1
View File
@@ -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};
+37 -3
View File
@@ -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<Node> = art.iter().map(|n| sc.plain(n)).collect();
let reaches: Vec<Option<Rect>> = plain.iter().map(reach).collect();
let see_through: Vec<bool> = plain.iter().map(|n| n.shows_transparency() || o.draws_see_through_image(n)).collect();
let see_through: Vec<bool> = 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<Option<Document>> {
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<NodeId> =
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<Option<Document>> {
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<Option<Document>> {
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![];
+21 -1
View File
@@ -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<BTreeSet<String>>,
/// 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))
+8 -2
View File
@@ -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<Value> {
// 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<Value> {
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 {
+303 -2
View File
@@ -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::<usize>(), 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}");
}
}