From 33ab5adbc2102ed46063fa6a23013bf27bcd195e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 30 Oct 2026 18:09:45 -0700 Subject: [PATCH 1/7] PDF/EPS export: flatten see-through and colorized pattern tile images PDF 1.3, PDF/X-1a, PDF/X-3, EPS and PostScript printing flatten transparency with `flatten_document`, which only looked at art in the layers. An image with see-through pixels, a colorize paint, opacity or a blending mode drawn only through a pattern fill stayed in the pattern definition, so the flattener returned early and the writer drew the tile opaque and uncolorized (PDF 1.3 export failed on the transparency left). `flatten_document` now first expands pattern fills whose tile image needs flattening into their tiles, as legacy .ai save does. See-through tile pixels count only where the caller counts see-through images (PDF 1.3); EPS level 3 already masks them out. The EPS and PostScript print transparency warnings now see those tile images too. --- crates/engine/src/cmd/expand.rs | 30 +++++--- crates/engine/src/cmd/fileio/eps.rs | 12 ++-- crates/engine/src/cmd/fileio/pdf.rs | 6 +- crates/engine/src/cmd/flatten.rs | 2 +- crates/engine/src/cmd/flatten/apply.rs | 28 +++++++- crates/engine/src/cmd/print.rs | 10 ++- crates/engine/src/tests/legacy_ai.rs | 97 ++++++++++++++++++++++++++ 7 files changed, 164 insertions(+), 21 deletions(-) diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index 5ad8054fa..e536d0374 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -456,18 +456,32 @@ pub(crate) fn expand_transparent_image_patterns(d: &mut Document, root: NodeId, 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) + expand_patterns_where(d, l, |d, i| transparent_pattern_fill(d, l, i, see_through)) })?; Ok(n) } +/// Whether a path or compound path of `d` has a pattern fill that +/// [`expand_transparent_image_patterns`] would expand with `see_through`. +pub(crate) fn has_transparent_image_patterns(d: &Document, see_through: &BTreeSet) -> bool { + let mut found = false; + d.walk(|n| { + if !found && matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) { + found = n.appearance.items.iter().any(|i| transparent_pattern_fill(d, n, i, see_through)); + } + }); + found +} + +/// Whether `i`, an item of leaf `l`, is a pattern fill whose tile art holds a transparent image +/// ([`transparent_image`]), 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) -> bool { + let Some((f, def, _)) = pattern_fill(d, i) else { return false }; + let shape = d.ancestry(l.id).unwrap_or_default().iter().filter_map(|a| d.node(*a)).any(blended); + let on = shape || f.opacity < 1.0 || f.blend != BlendMode::Normal; + def.art.iter().any(|a| transparent_image(a, see_through, on)) +} + /// Whether `n` has opacity or a blending mode of its own. fn blended(n: &Node) -> bool { n.opacity < 1.0 || n.blend != BlendMode::Normal diff --git a/crates/engine/src/cmd/fileio/eps.rs b/crates/engine/src/cmd/fileio/eps.rs index a05cc0824..a999b31ce 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_document}; 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| l.shows_transparency()); + let flat = flatten_document(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/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..94e05a357 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}; 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..463323029 100644 --- a/crates/engine/src/cmd/flatten/apply.rs +++ b/crates/engine/src/cmd/flatten/apply.rs @@ -292,13 +292,35 @@ pub(super) fn opaque(n: &mut Node) { } } +/// Expand pattern fills whose image tiles need transparency flattening. +pub(crate) fn expand_transparent_tile_images(doc: &Document, o: &FlattenOptions) -> Result> { + if doc.patterns.is_empty() { + return Ok(None); + } + let keys = o.no_transparency.clone().unwrap_or_default(); + if !crate::cmd::expand::has_transparent_image_patterns(doc, &keys) { + return Ok(None); + } + let mut d = doc.clone(); + let layers: Vec = d.layers.iter().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)?; + } + 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 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)?; + let doc = expanded.as_ref().unwrap_or(doc); 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)) { - return Ok(None); + return Ok(expanded); } let mut roots = vec![]; for l in doc.layers.iter().filter(|l| l.visible && !matches!(l.kind, NodeKind::Layer { template: true, .. })) { @@ -307,7 +329,7 @@ pub(crate) fn flatten_document(doc: &Document, o: &FlattenOptions) -> Result 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; + let expanded = if postscript && !bitmap { + let options = super::printadvanced::flattener(PRINT, &set, &s.prefs.flattener_presets)?; + options.as_ref().map(|o| expand_transparent_tile_images(doc, o)).transpose()?.flatten() + } else { + None + }; + let doc = expanded.as_ref().unwrap_or(doc); // 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,7 +210,6 @@ 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()) { warnings.push("transparency is flattened into opaque art and images (PostScript has none): see flattenerPreset".to_string()); } diff --git a/crates/engine/src/tests/legacy_ai.rs b/crates/engine/src/tests/legacy_ai.rs index 7ac5c3c5c..793fe19d2 100644 --- a/crates/engine/src/tests/legacy_ai.rs +++ b/crates/engine/src/tests/legacy_ai.rs @@ -704,3 +704,100 @@ fn only_the_transparent_pattern_fill_of_a_leaf_is_expanded() { assert_eq!(patterns.len(), 1, "{patterns:?}"); assert!(images > 0); } + +#[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_flattens_colorized_pattern_tile_images_and_warns() { + 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(); + 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 eps_warns_for_colorized_image_nested_in_pattern_tile_group() { + 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); + d.patterns[0].art.push(std::sync::Arc::new(vectorcraft_doc::Node::group(id, art))); + 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)); +} + +#[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()); +} From 7b4d6282f12f1b95c653d058a92bed1dbf699447 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 30 Oct 2026 21:26:34 -0700 Subject: [PATCH 2/7] Flattening: an option leaves colorized images to writers that draw them Flattening counted every colorized image as transparency, in the art and in pattern tiles. A new flattener option, keeps_colorize, stops counting an image colorized with a solid paint whose pixels are grayscale or 1-bit, for a format whose writer draws such images in their ink. Opacity, blending modes, opacity masks and effects still flatten them, and pattern tiles are still expanded for those. The option is off, so exports flatten as before; the EPS writer that draws colorized images turns it on. The EPS pattern tile tests now check the exported colors, and a tile group with opacity for the warning. --- crates/doc/src/node/ops.rs | 10 ++++-- crates/engine/src/cmd/expand.rs | 38 +++++++++++++++-------- crates/engine/src/cmd/fileio/eps.rs | 2 +- crates/engine/src/cmd/fileio/legacy_ai.rs | 2 +- crates/engine/src/cmd/flatten/apply.rs | 15 ++++----- crates/engine/src/cmd/flatten/options.rs | 22 ++++++++++++- crates/engine/src/tests/legacy_ai.rs | 28 +++++++++++++---- 7 files changed, 86 insertions(+), 31 deletions(-) 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 e536d0374..cf61a305b 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -447,27 +447,32 @@ 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 { +pub(crate) fn expand_transparent_image_patterns( + d: &mut Document, + root: NodeId, + see_through: &BTreeSet, + colorize: &dyn Fn(&Document, &Node) -> bool, +) -> 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; } - expand_patterns_where(d, l, |d, i| transparent_pattern_fill(d, l, i, see_through)) + expand_patterns_where(d, l, |d, i| transparent_pattern_fill(d, l, i, see_through, colorize)) })?; Ok(n) } /// Whether a path or compound path of `d` has a pattern fill that -/// [`expand_transparent_image_patterns`] would expand with `see_through`. -pub(crate) fn has_transparent_image_patterns(d: &Document, see_through: &BTreeSet) -> bool { +/// [`expand_transparent_image_patterns`] would expand with `see_through` and `colorize`. +pub(crate) fn has_transparent_image_patterns(d: &Document, see_through: &BTreeSet, colorize: &dyn Fn(&Document, &Node) -> bool) -> bool { let mut found = false; d.walk(|n| { if !found && matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) { - found = n.appearance.items.iter().any(|i| transparent_pattern_fill(d, n, i, see_through)); + found = n.appearance.items.iter().any(|i| transparent_pattern_fill(d, n, i, see_through, colorize)); } }); found @@ -475,11 +480,17 @@ pub(crate) fn has_transparent_image_patterns(d: &Document, see_through: &BTreeSe /// Whether `i`, an item of leaf `l`, is a pattern fill whose tile art holds a transparent image /// ([`transparent_image`]), 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) -> bool { +fn transparent_pattern_fill( + d: &Document, + l: &Node, + i: &AppearanceItem, + see_through: &BTreeSet, + colorize: &dyn Fn(&Document, &Node) -> bool, +) -> bool { let Some((f, def, _)) = pattern_fill(d, i) else { return false }; let shape = d.ancestry(l.id).unwrap_or_default().iter().filter_map(|a| d.node(*a)).any(blended); let on = shape || f.opacity < 1.0 || f.blend != BlendMode::Normal; - def.art.iter().any(|a| transparent_image(a, see_through, on)) + def.art.iter().any(|a| transparent_image(d, a, see_through, colorize, on)) } /// Whether `n` has opacity or a blending mode of its own. @@ -487,13 +498,14 @@ 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`. +fn transparent_image(d: &Document, n: &Node, keys: &BTreeSet, colorize: &dyn Fn(&Document, &Node) -> bool, on: bool) -> bool { 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)), + _ => n.children().is_some_and(|ch| ch.iter().any(|c| transparent_image(d, c, keys, colorize, on))), } } diff --git a/crates/engine/src/cmd/fileio/eps.rs b/crates/engine/src/cmd/fileio/eps.rs index a999b31ce..2c8ae4a84 100644 --- a/crates/engine/src/cmd/fileio/eps.rs +++ b/crates/engine/src/cmd/fileio/eps.rs @@ -301,7 +301,7 @@ fn encode_document(doc: &Document, p: &Value, saving: bool) -> Result { let regions: Vec = pages.iter().map(|(_, region, _)| *region).collect(); let expanded = expand_transparent_tile_images(doc, &set.flatten)?; let source = expanded.as_ref().unwrap_or(doc); - let transparent = source.layers.iter().any(|l| l.shows_transparency()); + let transparent = source.layers.iter().any(|l| set.flatten.shows_transparency(source, l)); let flat = flatten_document(source, &set.flatten)?; let drawn = flat.as_ref().unwrap_or(source); let mut enc = Encoded { warnings, joiner: Some("_"), ..Encoded::default() }; 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/flatten/apply.rs b/crates/engine/src/cmd/flatten/apply.rs index 463323029..7fbe44a0d 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). @@ -298,28 +298,29 @@ pub(crate) fn expand_transparent_tile_images(doc: &Document, o: &FlattenOptions) return Ok(None); } let keys = o.no_transparency.clone().unwrap_or_default(); - if !crate::cmd::expand::has_transparent_image_patterns(doc, &keys) { + let colorize = |d: &Document, n: &Node| o.counts_colorize(d, n); + if !crate::cmd::expand::has_transparent_image_patterns(doc, &keys, &colorize) { return Ok(None); } let mut d = doc.clone(); let layers: Vec = d.layers.iter().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)?; + 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). Pattern tile images with opacity, blending or colorize are expanded; -/// see-through tile pixels are expanded when `o.no_transparency` counts them. `None` when nothing -/// changes. +/// 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)?; let doc = expanded.as_ref().unwrap_or(doc); 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(expanded); } 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/tests/legacy_ai.rs b/crates/engine/src/tests/legacy_ai.rs index 793fe19d2..0a3f9af6f 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| { @@ -745,11 +745,10 @@ fn eps_level3_writes_see_through_pattern_tile_images() { } #[test] -fn eps_flattens_colorized_pattern_tile_images_and_warns() { +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(); - 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) { @@ -758,14 +757,31 @@ fn eps_flattens_colorized_pattern_tile_images_and_warns() { } #[test] -fn eps_warns_for_colorized_image_nested_in_pattern_tile_group() { +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"); +} + +#[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); - d.patterns[0].art.push(std::sync::Arc::new(vectorcraft_doc::Node::group(id, 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] From 6dfda4db7677b91cb7818a78cae5011795a748b8 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 30 Oct 2026 22:44:47 -0700 Subject: [PATCH 3/7] PDF/EPS export: pattern tile images in live art and hidden shapes flatten as drawn Blends, envelopes, repeats and Intertwine groups that hold a see-through or colorized pattern tile image are expanded into the art the writers draw for them before the tiles are expanded, so those tiles are flattened too. Random Transform effects in that art are baked first. Hidden shapes, shapes in hidden groups or layers and template layers are skipped: expanding a hidden shape made its tiles show in the file, and art that is not written no longer makes a flattened copy of the document. --- crates/engine/src/cmd/expand.rs | 94 +++++++++++++++++++++++--- crates/engine/src/cmd/flatten/apply.rs | 3 +- crates/engine/src/tests/legacy_ai.rs | 60 ++++++++++++++++ 3 files changed, 146 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index cf61a305b..b3bfab9c6 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -449,33 +449,107 @@ fn expand_pattern_fills(s: &mut Session) -> Result<()> { /// 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 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. +/// 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. 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 { + let live = expand_live_with_transparent_image_patterns(d, root, see_through, colorize)?; 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; } + // 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; + } expand_patterns_where(d, l, |d, i| transparent_pattern_fill(d, l, i, see_through, colorize)) })?; - Ok(n) + Ok(live + n) } -/// Whether a path or compound path of `d` has a pattern fill that -/// [`expand_transparent_image_patterns`] would expand with `see_through` and `colorize`. +/// 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 { - let mut found = false; - d.walk(|n| { - if !found && matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) { - found = n.appearance.items.iter().any(|i| transparent_pattern_fill(d, n, i, see_through, colorize)); + d.layers.iter().any(|l| holds_transparent_image_patterns(d, l, see_through, colorize)) +} + +/// 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. 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) -> 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)) } - }); - found + _ => n.children().is_some_and(|ch| ch.iter().any(|c| holds_transparent_image_patterns(d, c, see_through, colorize))), + } +} + +/// 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 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. 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, +) -> Result { + fn outermost(d: &Document, n: &Node, see_through: &BTreeSet, colorize: &dyn Fn(&Document, &Node) -> bool, 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) { + out.push(n.id); + } + } + NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => { + children.iter().for_each(|c| outermost(d, c, see_through, colorize, 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, &mut live); + } + let mut count = 0; + for id in &live { + 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 }; + 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 diff --git a/crates/engine/src/cmd/flatten/apply.rs b/crates/engine/src/cmd/flatten/apply.rs index 7fbe44a0d..0cd1ab159 100644 --- a/crates/engine/src/cmd/flatten/apply.rs +++ b/crates/engine/src/cmd/flatten/apply.rs @@ -303,7 +303,8 @@ pub(crate) fn expand_transparent_tile_images(doc: &Document, o: &FlattenOptions) return Ok(None); } let mut d = doc.clone(); - let layers: Vec = d.layers.iter().map(|l| l.id).collect(); + 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)?; diff --git a/crates/engine/src/tests/legacy_ai.rs b/crates/engine/src/tests/legacy_ai.rs index 0a3f9af6f..96878b043 100644 --- a/crates/engine/src/tests/legacy_ai.rs +++ b/crates/engine/src/tests/legacy_ai.rs @@ -817,3 +817,63 @@ fn opaque_pattern_tile_images_do_not_change_during_export_flattening() { 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); +} + +#[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}"); + } +} From c09ea597319ff9b22fff48375cff4099c950b8f0 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 31 Oct 2026 00:06:51 -0700 Subject: [PATCH 4/7] PDF/EPS export: transparent images in patterns nested in tile art flatten A pattern fill in a pattern's tile art that paints a see-through or colorized image was not counted, so PDF 1.3 export kept it as a pattern and failed on its transparency. Detection now follows pattern fills in tile art down to the three levels the canvas draws, skipping hidden tile art, and the tiles the expansion makes are expanded again for them. Expansion stops once the expanded art holds MAX_GENERATED objects. --- crates/engine/src/cmd/expand.rs | 119 ++++++++++++++++++++------- crates/engine/src/tests/legacy_ai.rs | 48 +++++++++++ 2 files changed, 137 insertions(+), 30 deletions(-) diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index b3bfab9c6..eec854a74 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -451,47 +451,73 @@ fn expand_pattern_fills(s: &mut Session) -> Result<()> { /// in the tile art, the fill, the shape or a group the shape is in has opacity or a blending /// 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. Returns how many objects changed. +/// 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. Pattern fills stay once the expanded art holds +/// [`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 { - let live = expand_live_with_transparent_image_patterns(d, root, see_through, colorize)?; - 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 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)?; + 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() >= vectorcraft_doc::live::MAX_GENERATED || !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 art = expand_patterns_where(d, l, |d, i| transparent_pattern_fill(d, l, i, see_through, colorize, levels))?; + made.set(made.get().saturating_add(art.count())); + Some(art) + })?; + if live + n == 0 { + break; } - // 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; - } - expand_patterns_where(d, l, |d, i| transparent_pattern_fill(d, l, i, see_through, colorize)) - })?; - Ok(live + 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)) + 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. 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) -> bool { +/// [`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)) + 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))), + _ => n.children().is_some_and(|ch| ch.iter().any(|c| holds_transparent_image_patterns(d, c, see_through, colorize, levels))), } } @@ -501,7 +527,7 @@ fn drawn(n: &Node) -> bool { } /// Expand the shown blends, envelopes, repeats and Intertwine groups under `root` that hold a -/// pattern fill [`holds_transparent_image_patterns`] counts into the art the exporters write for +/// 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. Returns how many were expanded. fn expand_live_with_transparent_image_patterns( @@ -509,19 +535,27 @@ fn expand_live_with_transparent_image_patterns( root: NodeId, see_through: &BTreeSet, colorize: &dyn Fn(&Document, &Node) -> bool, + levels: u32, ) -> Result { - fn outermost(d: &Document, n: &Node, see_through: &BTreeSet, colorize: &dyn Fn(&Document, &Node) -> bool, out: &mut Vec) { + 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) { + 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, out)); + children.iter().for_each(|c| outermost(d, c, see_through, colorize, levels, out)); } _ => {} } @@ -536,7 +570,7 @@ fn expand_live_with_transparent_image_patterns( } let mut live = vec![]; if let Some(n) = d.node(root) { - outermost(d, n, see_through, colorize, &mut live); + outermost(d, n, see_through, colorize, levels, &mut live); } let mut count = 0; for id in &live { @@ -553,18 +587,36 @@ fn expand_live_with_transparent_image_patterns( } /// Whether `i`, an item of leaf `l`, is a pattern fill whose tile art holds a transparent image -/// ([`transparent_image`]), counting the fill's and the shape's opacity and blending mode. +/// ([`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 Some((f, def, _)) = pattern_fill(d, i) else { return false }; let shape = d.ancestry(l.id).unwrap_or_default().iter().filter_map(|a| d.node(*a)).any(blended); - let on = shape || f.opacity < 1.0 || f.blend != BlendMode::Normal; - def.art.iter().any(|a| transparent_image(d, a, see_through, colorize, on)) + 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. @@ -574,12 +626,19 @@ fn blended(n: &Node) -> 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`. -fn transparent_image(d: &Document, n: &Node, keys: &BTreeSet, colorize: &dyn Fn(&Document, &Node) -> bool, on: bool) -> bool { +/// `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() && colorize(d, n)), - _ => n.children().is_some_and(|ch| ch.iter().any(|c| transparent_image(d, c, keys, colorize, on))), + 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))), } } diff --git a/crates/engine/src/tests/legacy_ai.rs b/crates/engine/src/tests/legacy_ai.rs index 96878b043..ecac48bb7 100644 --- a/crates/engine/src/tests/legacy_ai.rs +++ b/crates/engine/src/tests/legacy_ai.rs @@ -855,6 +855,54 @@ fn pdf13_flattens_colorized_pattern_tile_images_in_a_repeat() { assert_pdf13_draws_pattern_square_as_canvas(&mut s); } +/// [`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(); From e2fddc6b127fc4728cecffb13fc9fc9693c1d52a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 31 Oct 2026 04:11:26 -0700 Subject: [PATCH 5/7] PDF/EPS export: pattern fills whose tiles pass the object limit stay pattern fills The expansion counted the objects a pattern fill made only after making them, so one shape could clone up to 250,000 tile positions times the tile art's objects past the limit. Each fill now counts its tiles before they are made, and after outlining strokes and effects for turned or sheared tiles, and keeps room for the copy and group of the fills after it. A fill that does not fit stays a pattern fill. --- crates/engine/src/cmd/expand.rs | 86 ++++++++++++++++++++++------ crates/engine/src/tests/legacy_ai.rs | 55 ++++++++++++++++++ 2 files changed, 125 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index eec854a74..1b09fb631 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -453,7 +453,7 @@ fn expand_pattern_fills(s: &mut Session) -> Result<()> { /// 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. Pattern fills stay once the expanded art holds +/// expanded again for it. A pattern fill stays when its tiles 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( @@ -461,6 +461,18 @@ pub(crate) fn expand_transparent_image_patterns( 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 art the pattern fills expand to held to `budget` +/// objects. Live art expands under its own budget ([`expand_live_with_transparent_image_patterns`]). +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); @@ -469,15 +481,21 @@ pub(crate) fn expand_transparent_image_patterns( let live = expand_live_with_transparent_image_patterns(d, root, see_through, colorize, levels)?; 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() >= vectorcraft_doc::live::MAX_GENERATED || !l.appearance.items.iter().any(|i| pattern_fill(d, i).is_some()) { + 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 art = expand_patterns_where(d, l, |d, i| transparent_pattern_fill(d, l, i, see_through, colorize, levels))?; - made.set(made.get().saturating_add(art.count())); + 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 { @@ -650,12 +668,12 @@ fn transparent_image(d: &Document, n: &Node, keys: &BTreeSet, colorize: /// ([`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)) { @@ -668,19 +686,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))); } @@ -707,30 +753,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/tests/legacy_ai.rs b/crates/engine/src/tests/legacy_ai.rs index ecac48bb7..f038ab251 100644 --- a/crates/engine/src/tests/legacy_ai.rs +++ b/crates/engine/src/tests/legacy_ai.rs @@ -705,6 +705,61 @@ fn only_the_transparent_pattern_fill_of_a_leaf_is_expanded() { 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); From a32a7cbb0d685471403764fc97e1e6cd8846fd7c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 31 Oct 2026 04:27:09 -0700 Subject: [PATCH 6/7] PDF/EPS export: EPS export and PostScript printing expand pattern tile images once EPS export expanded the pattern fills whose tile images need flattening and then flattened the result with flatten_document, which expanded them again with a new object limit. PostScript printing did the same, on the layers shown rather than the layers that print. EPS export now flattens the expanded document without expanding it again (flatten_expanded), and PostScript printing leaves the expansion to the flattener, which runs on the layers that print, and only checks for such pattern fills to warn. --- crates/engine/src/cmd/fileio/eps.rs | 4 ++-- crates/engine/src/cmd/flatten.rs | 2 +- crates/engine/src/cmd/flatten/apply.rs | 24 +++++++++++++++++------- crates/engine/src/cmd/print.rs | 16 +++++++--------- crates/engine/src/tests/legacy_ai.rs | 9 +++++++++ 5 files changed, 36 insertions(+), 19 deletions(-) diff --git a/crates/engine/src/cmd/fileio/eps.rs b/crates/engine/src/cmd/fileio/eps.rs index 2c8ae4a84..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, expand_transparent_tile_images, 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}; @@ -302,7 +302,7 @@ fn encode_document(doc: &Document, p: &Value, saving: bool) -> Result { 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_document(source, &set.flatten)?; + 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() || expanded.is_some()) { diff --git a/crates/engine/src/cmd/flatten.rs b/crates/engine/src/cmd/flatten.rs index 94e05a357..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::{expand_transparent_tile_images, 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 0cd1ab159..d229f1bbb 100644 --- a/crates/engine/src/cmd/flatten/apply.rs +++ b/crates/engine/src/cmd/flatten/apply.rs @@ -292,16 +292,21 @@ 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 doc.patterns.is_empty() { + 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); - if !crate::cmd::expand::has_transparent_image_patterns(doc, &keys, &colorize) { - return Ok(None); - } 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(); @@ -319,10 +324,15 @@ pub(crate) fn expand_transparent_tile_images(doc: &Document, o: &FlattenOptions) /// 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)?; - let doc = expanded.as_ref().unwrap_or(doc); + 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| o.shows_transparency(doc, l) || o.draws_see_through_image(l)) { - return Ok(expanded); + return Ok(None); } let mut roots = vec![]; for l in doc.layers.iter().filter(|l| l.visible && !matches!(l.kind, NodeKind::Layer { template: true, .. })) { @@ -331,7 +341,7 @@ pub(crate) fn flatten_document(doc: &Document, o: &FlattenOptions) -> Result Result { 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; - let expanded = if postscript && !bitmap { - let options = super::printadvanced::flattener(PRINT, &set, &s.prefs.flattener_presets)?; - options.as_ref().map(|o| expand_transparent_tile_images(doc, o)).transpose()?.flatten() - } else { - None - }; - let doc = expanded.as_ref().unwrap_or(doc); + // 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"); @@ -210,7 +208,7 @@ fn print(s: &mut Session, p: &Value) -> Result { None => Level::default(), }; let mut warnings = vec![]; - 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 f038ab251..1b0f7ad3f 100644 --- a/crates/engine/src/tests/legacy_ai.rs +++ b/crates/engine/src/tests/legacy_ai.rs @@ -820,6 +820,15 @@ fn flattening_that_keeps_colorize_leaves_colorized_pattern_tile_images_alone() { 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(); From 93e27cafd341459b3861feba87470b00ce8fefa1 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 31 Oct 2026 04:33:50 -0700 Subject: [PATCH 7/7] PDF/EPS export: live art expanded for pattern tile images counts against the object limit Blends, envelopes, repeats and Intertwine groups that hold a pattern fill with a transparent tile image were each expanded under a limit of their own, so many of them together had no limit. Their art now counts toward the same limit as the pattern tiles, and an object whose art would pass it stays live. --- crates/engine/src/cmd/expand.rs | 26 +++++++++++++++++++------- crates/engine/src/tests/legacy_ai.rs | 16 ++++++++++++++++ 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index 1b09fb631..4745437d4 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -453,9 +453,9 @@ fn expand_pattern_fills(s: &mut Session) -> Result<()> { /// 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 when its tiles would take the expanded art past -/// [`MAX_GENERATED`](vectorcraft_doc::live::MAX_GENERATED) objects. Returns how many objects -/// changed. +/// 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, @@ -465,8 +465,7 @@ pub(crate) fn expand_transparent_image_patterns( expand_transparent_image_patterns_within(d, root, see_through, colorize, vectorcraft_doc::live::MAX_GENERATED) } -/// [`expand_transparent_image_patterns`] with the art the pattern fills expand to held to `budget` -/// objects. Live art expands under its own budget ([`expand_live_with_transparent_image_patterns`]). +/// [`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, @@ -478,7 +477,7 @@ pub(crate) fn expand_transparent_image_patterns_within( 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)?; + 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()) { @@ -547,13 +546,17 @@ fn drawn(n: &Node) -> bool { /// 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. Returns how many were expanded. +/// 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, @@ -592,10 +595,19 @@ fn expand_live_with_transparent_image_patterns( } 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; diff --git a/crates/engine/src/tests/legacy_ai.rs b/crates/engine/src/tests/legacy_ai.rs index 1b0f7ad3f..90c728a2f 100644 --- a/crates/engine/src/tests/legacy_ai.rs +++ b/crates/engine/src/tests/legacy_ai.rs @@ -919,6 +919,22 @@ fn pdf13_flattens_colorized_pattern_tile_images_in_a_repeat() { 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 {