diff --git a/crates/effects/src/group.rs b/crates/effects/src/group.rs index 1a45d4a8..19926b01 100644 --- a/crates/effects/src/group.rs +++ b/crates/effects/src/group.rs @@ -144,7 +144,7 @@ fn is_container(n: &Node) -> bool { } /// Does `item` paint anything? -pub(crate) fn paints(item: &AppearanceItem) -> bool { +pub fn paints(item: &AppearanceItem) -> bool { item.visible() && !item.paint().is_none() && match item { diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 8cba3550..bcd0645f 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -47,7 +47,8 @@ use vectorcraft_geom::{BezPath, FillRule, PathData, Rect}; pub use bake::{StrokeArt, bake_document, expand_art, expand_leaf, fresh_ids, needs_bake}; pub use clip::clip_outline; pub use group::{ - OutlineHook, PATHFINDER_EFFECTS, evaluate_container, has_container_appearance, has_pathfinder, is_pathfinder, member_shapes, pathfinder_children, + OutlineHook, PATHFINDER_EFFECTS, evaluate_container, has_container_appearance, has_pathfinder, is_pathfinder, member_shapes, paints, + pathfinder_children, }; pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks}; pub use raster::{RasterFx, outset, raster_effects}; diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 3b59c9d4..8c15b915 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -13,6 +13,7 @@ use vectorcraft_geom::{FillRule, PathData}; use vectorcraft_render::effects; use super::appearance::{ItemTarget, appearance_targets, index_param, item_target, item_target_at}; +use super::rasterfx; use super::*; pub fn specs() -> Vec { @@ -287,69 +288,17 @@ fn is_container(n: &Node) -> bool { matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) } -/// Remove the raster effects of `n` (its own and its fills' and strokes') and, with `members`, of -/// everything inside it. -fn strip_raster(n: &mut Node, members: bool) { - let keep = |e: &Effect| !effects::is_raster(&e.id); - n.appearance.effects.retain(keep); - for it in &mut n.appearance.items { - it.effects_mut().retain(keep); - } - if members { - for c in n.children_mut().into_iter().flatten() { - strip_raster(Arc::make_mut(c), true); - } - } -} - /// The raster effects of `n` (its own, its fills' and strokes') as an embedded image rendered at /// the document's raster effects resolution, with whether they all paint below it (shadows and /// outer glows: the image then holds just them). A group's or layer's image leaves out its -/// members' raster effects when those are expanded with them. `None` when it has none. +/// members' raster effects, which are expanded with them. `None` when it has none. fn raster_image(d: &mut Document, n: &Node) -> Option<(Node, bool)> { - let fx: Vec = std::iter::once(&n.appearance.effects) - .chain(n.appearance.items.iter().map(|i| i.effects())) - .flat_map(|e| effects::raster_effects(e)) - .collect(); + let fx = rasterfx::raster_fx(n); if fx.is_empty() { return None; } let below = fx.iter().all(effects::RasterFx::is_below); - // Its transparency stays on the expanded object. - let mut whole = n.clone(); - (whole.opacity, whole.blend, whole.mask) = (1.0, Default::default(), None); - if below && is_container(&whole) { - for c in whole.children_mut().into_iter().flatten() { - strip_raster(Arc::make_mut(c), true); - } - } - let bare = below.then(|| { - let mut b = whole.clone(); - strip_raster(&mut b, false); - b - }); - let scale = super::rasterfx::effects_scale(d); - let image = super::rasterfx::effect_image(d, &whole, bare.as_ref(), scale)?; - Some((image, below)) -} - -/// `m` (an expanded object) with `image` of its shadows and outer glows painted below its art: the -/// image goes first in a group or layer (after a layer's clipping path), anything else is grouped -/// with it under its id, name and transparency. -fn put_below(d: &mut Document, m: &mut Node, image: Node) { - match &mut m.kind { - NodeKind::Group { children, clip: false } => children.insert(0, Arc::new(image)), - NodeKind::Layer { children, clip, .. } => children.insert(usize::from(*clip).min(children.len()), Arc::new(image)), - _ => { - let mut inner = m.clone(); - inner.id = d.alloc_id(); - (inner.name, inner.opacity, inner.blend, inner.isolate, inner.mask) = (None, 1.0, Default::default(), false, None); - inner.knockout = Default::default(); - inner.knockout_shape = false; - m.appearance = Default::default(); - m.kind = NodeKind::Group { children: vec![Arc::new(image), Arc::new(inner)], clip: false }; - } - } + Some((rasterfx::raster_image(d, n, below, true)?, below)) } /// Give the members of `m`, art evaluated from the object of the same id (its pieces share that @@ -395,23 +344,13 @@ fn expand_node(d: &mut Document, id: NodeId, brushes: &[vectorcraft_brush::Brush } let image = raster_image(d, &n); let mut v = n.clone(); - strip_raster(&mut v, false); + rasterfx::strip_raster(&mut v, false); let mut stroke_art = |d: &mut Document, path: &PathData, rule: FillRule, st: &StrokeLayer| super::pathops::outlined_stroke(d, brushes, path, rule, st); let expanded = match image { // Blur, feather and inner glow change the object itself: it becomes the image (a layer // keeps it as its only member). - Some((image, false)) => Some(if n.is_layer() { - let mut m = v; - m.appearance = Default::default(); - m.set_clips(false); - if let Some(ch) = m.children_mut() { - *ch = vec![Arc::new(image)]; - } - m - } else { - Node { kind: image.kind, appearance: Default::default(), ..v } - }), + Some((image, false)) => Some(rasterfx::replace_with_image(v, image)), image => { let m = if container { // A group's or layer's own fills and strokes become art among its members. @@ -435,7 +374,7 @@ fn expand_node(d: &mut Document, id: NodeId, brushes: &[vectorcraft_brush::Brush match image { Some((image, _)) => { let mut m = m.unwrap_or(v); - put_below(d, &mut m, image); + rasterfx::put_below(d, &mut m, image); Some(m) } None => m, diff --git a/crates/engine/src/cmd/fileio/tests_pdf.rs b/crates/engine/src/cmd/fileio/tests_pdf.rs index 2156a294..18fe6f13 100644 --- a/crates/engine/src/cmd/fileio/tests_pdf.rs +++ b/crates/engine/src/cmd/fileio/tests_pdf.rs @@ -158,10 +158,13 @@ fn pdf_settings_summarise_changes_and_warnings() { s.execute("select.all", &json!({})).unwrap(); s.execute("object.group", &json!({})).unwrap(); let g = s.doc().unwrap().selection.objects[0].0; + // Raster effects are written as images: nothing to report. s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [g]})).unwrap(); + assert_eq!(s.execute("document.pdfSettings", &json!({"includeDocument": true})).unwrap()["warnings"], json!([])); + s.execute("transparency.set", &json!({"knockout": "on"})).unwrap(); assert_eq!(s.execute("document.pdfSettings", &json!({})).unwrap()["warnings"], json!([])); let full = s.execute("document.pdfSettings", &json!({"includeDocument": true})).unwrap(); - assert!(warnings(&full).iter().any(|w| w.contains("group objects")), "{}", full["warnings"]); + assert!(warnings(&full).iter().any(|w| w.contains("knockout groups")), "{}", full["warnings"]); } #[test] diff --git a/crates/engine/src/cmd/rasterfx.rs b/crates/engine/src/cmd/rasterfx.rs index e7ed3754..e8c7896b 100644 --- a/crates/engine/src/cmd/rasterfx.rs +++ b/crates/engine/src/cmd/rasterfx.rs @@ -1,18 +1,21 @@ //! Effect → Document Raster Effects Settings, and raster effects for PDF export. //! //! PDF has no live shadows, glows or blurs, so PDF export receives a copy of the document in which -//! every object with a raster effect is accompanied by an image rendered with the document's raster -//! effects settings (resolution, colour model, background, anti-aliasing, room around the art). -//! Shadows and outer glows only add the effect: the image (with the object's own area knocked out) -//! goes under the untouched vector object. Effects that change the object itself (inner glow, -//! feather, Gaussian blur) replace it with the image. +//! every object with a raster effect (any kind of object, or one of its fills or strokes) is +//! accompanied by an image rendered with the document's raster effects settings (resolution, colour +//! model, background, anti-aliasing, room around the art). Shadows and outer glows only add the +//! effect: the image (with the object's own area knocked out) goes under the untouched vector +//! object. Effects that change the object itself (inner glow, feather, Gaussian blur) replace it +//! with the image. Expand Appearance makes its images with the same helpers. use std::sync::Arc; use serde_json::{Map, Value, json}; use vectorcraft_color::Paint; use vectorcraft_doc::rastersettings::MAX_ADD_AROUND; -use vectorcraft_doc::{Appearance, ColorMode, Document, ImageBlob, ImageObject, Node, NodeId, NodeKind, RasterColorModel}; +use vectorcraft_doc::{ + Appearance, AppearanceItem, ColorMode, Document, Effect, ImageBlob, ImageObject, Node, NodeId, NodeKind, RasterColorModel, StrokeLayer, +}; use vectorcraft_geom::{Affine, FillRule, PathData, Rect}; use vectorcraft_render::effects::{self, RasterFx}; use vectorcraft_render::{AntiAlias, RenderOptions}; @@ -135,17 +138,6 @@ pub(crate) fn clip_group(d: &mut Document, path: PathData, rule: FillRule, art: Node::new(d.alloc_id(), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(art)], clip: true }) } -fn raster_fx(n: &Node) -> Vec { - if !matches!(n.kind, NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. }) { - return vec![]; - } - effects::raster_effects(&n.appearance.effects) -} - -fn needs(n: &Node) -> bool { - !raster_fx(n).is_empty() || n.children().is_some_and(|ch| ch.iter().any(|c| needs(c))) -} - /// Render `nodes` alone over transparency: (premultiplied pixels, width, height). fn render(doc: &Document, nodes: Vec, region: Rect, scale: f64, anti_alias: AntiAlias) -> vectorcraft_render::Rendered { let mut tmp = doc.clone(); @@ -166,16 +158,16 @@ pub(crate) fn effects_scale(doc: &Document) -> f64 { } /// An embedded image (a new node of `out`, its pixels in `out.images`) of `whole` rendered alone -/// with `out`'s resources at `scale` over its visual bounds and the reach of its raster effects -/// (its own and its fills' and strokes'). With `knockout`, the coverage of that art is knocked out +/// with `out`'s resources at `scale` over all it paints (the reach of its own, its members' and its +/// fills' and strokes' raster effects included). With `knockout`, the coverage of that art is knocked out /// of the image, which then only holds what the effects add around it (a shadow to go under the /// vector object). The document's raster effects settings add room around it and finish its pixels. pub(crate) fn effect_image(out: &mut Document, whole: &Node, knockout: Option<&Node>, scale: f64) -> Option { - let b = whole.visual_bounds()?; + // What it paints (its members' effects included), and how far its fills' and strokes' raster + // effects reach beyond that. + let b = vectorcraft_render::painted_bounds(whole)?; let look = out.raster_effects.clone(); - let reach = whole.appearance.items.iter().map(|i| effects::outset(i.effects())).fold(effects::outset(&whole.appearance.effects), f64::max) - + 2.0 - + look.add_around; + let reach = whole.appearance.items.iter().map(|i| effects::outset(i.effects())).fold(0.0, f64::max) + 2.0 + look.add_around; let b = b.inflate(reach, reach); // Whole pixels, and no larger than 64 Mpx. let scale = scale.min((64.0e6 / (b.width() * b.height()).max(1.0)).sqrt()); @@ -208,67 +200,234 @@ pub(crate) fn effect_image(out: &mut Document, whole: &Node, knockout: Option<&N Some(image) } -/// The image (and, for shadows and outer glows, the vector object above it) standing in for `n`. -fn flatten_node(out: &mut Document, n: &Node, scale: f64) -> Option { - let below_only = raster_fx(n).iter().all(RasterFx::is_below); - let mut whole = n.clone(); - whole.blend = vectorcraft_doc::color::BlendMode::Normal; - // Shadows only: knock the object's own coverage out of the image; the vector object goes on top. - let bare = below_only.then(|| { - let mut bare = whole.clone(); - bare.appearance.effects.retain(|e| !effects::is_raster(&e.id)); - bare.opacity = 1.0; - bare - }); - let mut image = effect_image(out, &whole, bare.as_ref(), scale)?; - let vector = bare.map(|_| { - let mut v = n.clone(); - v.appearance.effects.retain(|e| !effects::is_raster(&e.id)); - v.blend = vectorcraft_doc::color::BlendMode::Normal; - v - }); - Some(match vector { - Some(v) => { - let mut g = Node::group(out.alloc_id(), vec![Arc::new(image), Arc::new(v)]); - g.blend = n.blend; - g.name = n.name.clone(); - g - } - None => { - image.blend = n.blend; - image - } - }) +/// The visible raster effects of `n`: its own and its fills' and strokes' (none on guides and +/// clipping paths, which paint nothing). +pub(crate) fn raster_fx(n: &Node) -> Vec { + if matches!(n.kind, NodeKind::Path { guide: true, .. } | NodeKind::Path { clipping: true, .. }) { + return vec![]; + } + std::iter::once(&n.appearance.effects).chain(n.appearance.items.iter().map(|i| i.effects())).flat_map(|e| effects::raster_effects(e)).collect() } -fn walk(out: &mut Document, n: &Node, scale: f64) -> Option { - if !needs(n) || !n.visible { +/// Remove the raster effects of `n` (its own and its fills' and strokes') and, with `members`, of +/// everything inside it. +pub(crate) fn strip_raster(n: &mut Node, members: bool) { + let keep = |e: &Effect| !effects::is_raster(&e.id); + n.appearance.effects.retain(keep); + for it in &mut n.appearance.items { + it.effects_mut().retain(keep); + } + if members { + for c in n.children_mut().into_iter().flatten() { + strip_raster(Arc::make_mut(c), true); + } + } +} + +/// The raster effects of `n` (its own, its fills' and strokes') as an embedded image of `d` +/// rendered at the document's raster effects resolution, without `n`'s transparency (that stays +/// on the object). With `below` (they all paint below it: shadows and outer glows) the image holds +/// just them; with `members` too, a group's or layer's image leaves out its members' raster +/// effects (they get images of their own). +pub(crate) fn raster_image(d: &mut Document, n: &Node, below: bool, members: bool) -> Option { + let mut whole = n.clone(); + (whole.opacity, whole.blend, whole.mask) = (1.0, Default::default(), None); + if below && members && is_container(&whole) { + for c in whole.children_mut().into_iter().flatten() { + strip_raster(Arc::make_mut(c), true); + } + } + let bare = below.then(|| { + let mut b = whole.clone(); + strip_raster(&mut b, false); + b + }); + let scale = effects_scale(d); + effect_image(d, &whole, bare.as_ref(), scale) +} + +/// `v` (an object without its raster effects) replaced by `image` of the object with them: the +/// image takes its place with its id, name and transparency (a layer keeps the image as its only +/// member). +pub(crate) fn replace_with_image(mut v: Node, image: Node) -> Node { + if !v.is_layer() { + return Node { kind: image.kind, appearance: Default::default(), ..v }; + } + v.appearance = Default::default(); + v.set_clips(false); + if let Some(ch) = v.children_mut() { + *ch = vec![Arc::new(image)]; + } + v +} + +/// `m` with `image` of its shadows and outer glows painted below its art: the image goes first in +/// a group or layer (after a layer's clipping path), anything else is grouped with it under its +/// id, name and transparency. +pub(crate) fn put_below(d: &mut Document, m: &mut Node, image: Node) { + match &mut m.kind { + NodeKind::Group { children, clip: false } => children.insert(0, Arc::new(image)), + NodeKind::Layer { children, clip, .. } => children.insert(usize::from(*clip).min(children.len()), Arc::new(image)), + _ => { + let mut inner = m.clone(); + inner.id = d.alloc_id(); + (inner.name, inner.opacity, inner.blend, inner.isolate, inner.mask) = (None, 1.0, Default::default(), false, None); + inner.knockout = Default::default(); + inner.knockout_shape = false; + m.appearance = Default::default(); + m.kind = NodeKind::Group { children: vec![Arc::new(image), Arc::new(inner)], clip: false }; + } + } +} + +fn is_container(n: &Node) -> bool { + matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) +} + +/// Can the raster effects of `n` go in an image under its untouched vector art? They must all +/// paint below it (shadows, outer glows) and belong to the object or to fills and strokes with +/// nothing painted under them (a stroke's shadow falls on the fills below it, and type's +/// characters or a group's members paint below its upper fills and strokes). +fn below_only(n: &Node) -> bool { + let contents = (is_container(n) || matches!(n.kind, NodeKind::Text(_))).then(|| n.appearance.contents_at()); + let mut covered = false; + for (i, item) in n.appearance.items.iter().enumerate() { + covered |= contents == Some(i); + let fx = effects::raster_effects(item.effects()); + if !fx.iter().all(RasterFx::is_below) || (covered && !fx.is_empty()) { + return false; + } + covered |= effects::paints(item); + } + effects::raster_effects(&n.appearance.effects).iter().all(RasterFx::is_below) +} + +/// A path or compound path with several fills and strokes, some of them with raster effects, as a +/// group of one path per fill and stroke (each keeping its own effects, so each gets an image of +/// its own) under the object's transparency and effects. `None` for other objects. +fn split_items(out: &mut Document, n: &Node) -> Option { + let items = &n.appearance.items; + if !matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) + || items.iter().filter(|i| effects::paints(i)).count() < 2 + || items.iter().all(|i| effects::raster_effects(i.effects()).is_empty()) + { return None; } - if !raster_fx(n).is_empty() { - return flatten_node(out, n, scale); + let mut stroke_piece = |d: &mut Document, path: &PathData, rule: FillRule, st: &StrokeLayer| { + let mut p = super::pathops::shape_node(d, path.clone(), None); + p.appearance = Appearance { items: vec![AppearanceItem::Stroke(st.clone())], ..Default::default() }; + if let NodeKind::Path { rule: r, .. } | NodeKind::Compound { rule: r, .. } = &mut p.kind { + *r = rule; + } + Some(p) + }; + effects::expand_leaf(out, n, &mut stroke_piece) +} + +/// Symbol instance `n` as a group of its symbol's art (placed, and tinted as the instance tints +/// it) with the instance's appearance and transparency, which PDF export draws the same: the +/// instance alone has no size to render its effects' image over. `None` for other objects. +fn placed_symbol(d: &Document, n: &Node) -> Option { + let NodeKind::SymbolInstance { symbol, xf } = &n.kind else { return None }; + let mut art = vectorcraft_render::instance_art(&d.symbols.iter().find(|s| s.name == *symbol)?.art, n); + art.transform(*xf, false); + Some(Node { kind: NodeKind::Group { children: vec![Arc::new(art)], clip: false }, ..n.clone() }) +} + +/// Does `n` hold raster effects PDF export draws: its own, its fills' and strokes', its opacity +/// mask's or (groups and layers) its members'? Hidden objects and template layers aren't drawn. +fn needs(n: &Node) -> bool { + n.visible + && !matches!(n.kind, NodeKind::Layer { template: true, .. }) + && (!raster_fx(n).is_empty() + || n.mask.as_ref().is_some_and(|m| needs(&m.art)) + || (is_container(n) && n.children().is_some_and(|ch| ch.iter().any(|c| needs(c))))) +} + +/// The art of `n`'s opacity mask with its raster effects as images. +fn walk_mask(out: &mut Document, n: &mut Node) { + if let Some(m) = n.mask.as_mut() + && let Some(art) = walk(out, &m.art) + { + m.art = Arc::new(art); } - let mut m = n.clone(); - let ch: Vec> = n.children()?.iter().map(|c| walk(out, c, scale).map(Arc::new).unwrap_or_else(|| c.clone())).collect(); - if let Some(slot) = m.children_mut() { - *slot = ch; +} + +/// `n` with its raster effects (and its members' and its opacity mask's) as images, `None` when it +/// has none. An object whose effects all paint below it keeps its vector art over an image of +/// them; one whose effects change it (blur, feather, inner glow) becomes the image. +fn walk(out: &mut Document, n: &Node) -> Option { + if !needs(n) { + return None; } + if raster_fx(n).is_empty() { + let mut m = n.clone(); + walk_mask(out, &mut m); + if is_container(&m) + && let Some(ch) = walk_all(out, n.children()?) + && let Some(slot) = m.children_mut() + { + *slot = ch; + } + return Some(m); + } + if let Some(g) = placed_symbol(out, n) { + return Some(walk(out, &g).unwrap_or(g)); + } + let below = below_only(n); + if !below && let Some(g) = split_items(out, n) { + return Some(walk(out, &g).unwrap_or(g)); + } + // An image that can't be made leaves the object as it is (the writer reports its effects). + let image = raster_image(out, n, below, false)?; + let mut v = n.clone(); + strip_raster(&mut v, false); + let mut m = if below { + let mut m = walk(out, &v).unwrap_or(v); + put_below(out, &mut m, image); + m + } else { + replace_with_image(v, image) + }; + walk_mask(out, &mut m); Some(m) } +/// `nodes` with their raster effects as images, `None` when none has any. +fn walk_all(out: &mut Document, nodes: &[Arc]) -> Option>> { + nodes.iter().any(|n| needs(n)).then(|| nodes.iter().map(|n| walk(out, n).map(Arc::new).unwrap_or_else(|| n.clone())).collect()) +} + /// A copy of `doc` with raster effects turned into images at the document's raster effects -/// resolution, or `None` when there are none. +/// resolution, or `None` when there are none: on every kind of object (paths, groups and layers, +/// type, images, symbol instances, live objects) and on single fills and strokes, in the layers, +/// opacity masks, symbol definitions and pattern tiles. pub fn flatten_raster_effects(doc: &Document) -> Option { - if !doc.layers.iter().any(|l| needs(l)) { + let any = |nodes: &[Arc]| nodes.iter().any(|n| needs(n)); + if !any(&doc.layers) && !doc.symbols.iter().any(|s| needs(&s.art)) && !doc.patterns.iter().any(|p| any(&p.art)) { return None; } // Geometry effects first, so the vector objects kept above shadows are final. let baked = effects::bake_document(doc); let src = baked.as_ref().unwrap_or(doc); - let scale = effects_scale(src); let mut out = src.clone(); - let layers = src.layers.clone(); - out.layers = layers.iter().map(|l| walk(&mut out, l, scale).map(Arc::new).unwrap_or_else(|| l.clone())).collect(); + if let Some(layers) = walk_all(&mut out, &src.layers) { + out.layers = layers; + } + for i in 0..out.symbols.len() { + if let Some(art) = out.symbols.get(i).map(|s| s.art.clone()).and_then(|a| walk(&mut out, &a)) + && let Some(s) = out.symbols.get_mut(i) + { + s.art = Arc::new(art); + } + } + for i in 0..out.patterns.len() { + if let Some(art) = out.patterns.get(i).map(|p| p.art.clone()).and_then(|a| walk_all(&mut out, &a)) + && let Some(p) = out.patterns.get_mut(i) + { + p.art = art; + } + } Some(out) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 39914f03..038e8631 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1075,6 +1075,8 @@ mod tests_pattern; #[cfg(test)] mod tests_pdfpresets; #[cfg(test)] +mod tests_pdfraster; +#[cfg(test)] mod tests_place; #[cfg(test)] mod tests_prefs; diff --git a/crates/engine/src/tests_pdfraster.rs b/crates/engine/src/tests_pdfraster.rs new file mode 100644 index 00000000..bf10064f --- /dev/null +++ b/crates/engine/src/tests_pdfraster.rs @@ -0,0 +1,149 @@ +//! Raster effects in PDF on every kind of object: groups, layers, type, images, symbol instances, +//! live objects and single fills and strokes are written with images of their shadows, glows and +//! blurs (no "left out" warning), and the PDF reads back looking like the canvas. + +use std::io::Cursor; +use std::sync::Arc; + +use serde_json::json; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, CharStyle, Document, Effect, ImageBlob, ImageObject, Node, NodeKind, Symbol, TextObject}; +use vectorcraft_geom::{Affine, Point, Rect, shapes}; +use vectorcraft_render::{Rendered, Renderer}; + +use super::*; + +fn shadow() -> Effect { + Effect { id: "stylize.dropShadow".into(), params: json!({"x": 6.0, "y": 6.0, "blur": 3.0, "opacity": 75.0}), visible: true } +} + +fn blur() -> Effect { + Effect { id: "blur.gaussian".into(), params: json!({"radius": 3.0}), visible: true } +} + +fn rect(d: &mut Document, r: Rect, fill: Color) -> Node { + Node::path(d.alloc_id(), shapes::rectangle(r), Appearance::basic(Paint::solid(fill), Paint::None, 0.0)) +} + +fn add(d: &mut Document, n: Node) { + let l = d.layers[0].id; + d.insert(Some(l), usize::MAX, n).unwrap(); +} + +fn with(mut n: Node, fx: Effect) -> Node { + n.appearance.effects.push(fx); + n +} + +fn png(w: u32, h: u32) -> Vec { + let img = image::RgbaImage::from_fn(w, h, |x, _| image::Rgba([(x * 255 / w) as u8, 60, 160, 255])); + let mut out = Vec::new(); + image::DynamicImage::from(img).write_to(&mut Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); + out +} + +fn render(d: &Document) -> Rendered { + Renderer::new().render_region(d, Rect::new(0.0, 0.0, 200.0, 200.0), 1.0, true) +} + +/// Mean difference of the colour channels of two renders, as a share of full scale. +fn mean_diff(a: &Rendered, b: &Rendered) -> f64 { + let sum: u64 = a.pixels.iter().zip(&b.pixels).map(|(x, y)| x.abs_diff(*y) as u64).sum(); + sum as f64 / a.pixels.len() as f64 / 255.0 +} + +/// The PDF of `d` embeds images, warns about nothing and reads back within 2% of the canvas, much +/// closer than the art without its raster effects. +fn assert_written(what: &str, d: &Document) { + let r = crate::cmd::rasterfx::export_pdf_with_report(d, &Default::default()).unwrap(); + assert!(r.warnings.is_empty(), "{what}: {:?}", r.warnings); + assert!(String::from_utf8_lossy(&r.bytes).contains("/Subtype/Image"), "{what}: an image XObject"); + let canvas = render(d); + let diff = mean_diff(&canvas, &render(&vectorcraft_pdf::import(&r.bytes).unwrap())); + let mut plain = d.clone(); + for l in &mut plain.layers { + crate::cmd::rasterfx::strip_raster(Arc::make_mut(l), true); + } + for s in &mut plain.symbols { + crate::cmd::rasterfx::strip_raster(Arc::make_mut(&mut s.art), true); + } + let without = mean_diff(&canvas, &render(&plain)); + assert!(diff < 0.02 && diff < without / 4.0, "{what}: read back {:.2}% off ({:.2}% without the effects)", diff * 100.0, without * 100.0); +} + +#[test] +fn groups_layers_type_images_and_symbols_keep_their_raster_effects() { + // A group's shadow (one shadow of both members). + let mut d = Document::new(200.0, 200.0); + let (a, b) = ( + rect(&mut d, Rect::new(30.0, 30.0, 90.0, 90.0), Color::rgb(1.0, 0.0, 0.0)), + rect(&mut d, Rect::new(70.0, 70.0, 150.0, 150.0), Color::rgb(0.0, 0.0, 1.0)), + ); + let g = Node::group(d.alloc_id(), vec![Arc::new(a), Arc::new(b)]); + add(&mut d, with(g, shadow())); + assert_written("group", &d); + + // A layer's blur. + let mut d = Document::new(200.0, 200.0); + let r = rect(&mut d, Rect::new(40.0, 40.0, 160.0, 120.0), Color::rgb(0.1, 0.6, 0.2)); + add(&mut d, r); + Arc::make_mut(&mut d.layers[0]).appearance.effects.push(blur()); + assert_written("layer", &d); + + // Type with a shadow. + let mut d = Document::new(200.0, 200.0); + let style = CharStyle { size: 60.0, fill: Paint::solid(Color::rgb(0.8, 0.1, 0.1)), ..Default::default() }; + let t = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(20.0, 120.0), "Ab", style)))); + add(&mut d, with(t, shadow())); + assert_written("type", &d); + + // An image with a shadow. + let mut d = Document::new(200.0, 200.0); + d.images.insert("pic".into(), ImageBlob::new("image/png", png(40, 30))); + let xf = Affine::translate((40.0, 50.0)) * Affine::scale(2.5); + let im = Node::new(d.alloc_id(), NodeKind::Image(ImageObject { key: "pic".into(), width: 40, height: 30, xf, link: None })); + add(&mut d, with(im, shadow())); + assert_written("image", &d); + + // A symbol instance with a shadow, and a symbol whose art blurs. + let mut d = Document::new(200.0, 200.0); + let art = rect(&mut d, Rect::new(0.0, 0.0, 50.0, 50.0), Color::rgb(0.9, 0.6, 0.0)); + d.symbols.push(Symbol { name: "Tile".into(), art: Arc::new(art) }); + let blurred = with(rect(&mut d, Rect::new(0.0, 0.0, 40.0, 40.0), Color::rgb(0.2, 0.2, 0.8)), blur()); + d.symbols.push(Symbol { name: "Soft".into(), art: Arc::new(blurred) }); + let inst = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Tile".into(), xf: Affine::translate((30.0, 30.0)) }); + add(&mut d, with(inst, shadow())); + let soft = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Soft".into(), xf: Affine::translate((120.0, 120.0)) }); + add(&mut d, soft); + assert_written("symbols", &d); +} + +#[test] +fn live_objects_and_single_fills_and_strokes_keep_their_raster_effects() { + // A blend with a shadow. + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let a = s.execute("shape.rectangle", &json!({"x": 20, "y": 40, "width": 30, "height": 30})).unwrap()["id"].clone(); + let b = s.execute("shape.rectangle", &json!({"x": 140, "y": 120, "width": 30, "height": 30})).unwrap()["id"].clone(); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + let g = s.execute("object.blend.make", &json!({"steps": 3})).unwrap()["id"].clone(); + s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [g]})).unwrap(); + assert_written("blend", &s.doc().unwrap().doc); + + // A stroke's shadow falls on the fill below it; a fill's blur stays under a sharp stroke. + let wide_blur = Effect { params: json!({"radius": 12.0}), ..blur() }; + for (item, fx) in [(1, shadow()), (0, wide_blur)] { + let mut d = Document::new(200.0, 200.0); + let mut n = Node::path( + d.alloc_id(), + shapes::rectangle(Rect::new(40.0, 40.0, 160.0, 160.0)), + Appearance::basic(Paint::solid(Color::rgb(1.0, 0.9, 0.2)), Paint::solid(Color::rgb(0.1, 0.1, 0.6)), 6.0), + ); + n.appearance.effects_mut(Some(item)).unwrap().push(fx); + add(&mut d, n); + assert_written(&format!("item {item}"), &d); + let flat = crate::flatten_raster_effects(&d).unwrap(); + let pieces = flat.layers[0].children().unwrap()[0].children().map_or(0, |c| c.len()); + assert_eq!(pieces, 2, "item {item}: one piece per fill and stroke"); + } +} diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index a784ed70..247d8d65 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -391,9 +391,9 @@ impl Exporter<'_> { Some(out) } - /// Warn when `effects` (an object's, a fill's or a stroke's) has a visible raster effect. - /// Warn when `effects` has a raster effect: the app renders them to images before export - /// (`vectorcraft_engine::export_pdf`), so the ones still here (on `what`) can't be written. + /// Warn when `effects` (an object's, a fill's or a stroke's) has a visible raster effect: the + /// app renders them to images before export (`vectorcraft_engine::export_pdf`), so the ones + /// still here (on `what`) can't be written. fn warn_raster(&mut self, effects: &[vectorcraft_doc::Effect], what: impl FnOnce() -> String) { if effects.iter().any(|e| e.visible && vectorcraft_effects::is_raster(&e.id)) { self.warn(format!("raster effects (shadows, glows, blur, feather) on {} are left out of the PDF", what())); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 7d281d9f..3b4007a9 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -36,6 +36,12 @@ pub use pattern::render_pattern_swatch; pub use vectorcraft_effects as effects; pub use vello_cpu; +/// Bounds of everything `n` paints, as the renderer culls it: its members, strokes, brush art and +/// geometry effects, and the shadows and glows of it and its members. +pub fn painted_bounds(n: &Node) -> Option { + brush_fx::cull_bounds(n) +} + /// Largest raster an export may ask [`Renderer::render_region`] for, per side. The CPU rasteriser /// addresses at most `u16::MAX` pixels per side (and panics at that edge). pub const MAX_RASTER_SIDE: u32 = 32_768; diff --git a/docs/mcp.md b/docs/mcp.md index 5fc724b6..07faf7e3 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -456,7 +456,7 @@ fills and strokes above it paint over the members (characters), those below unde objects themselves (a group's own stack; layers through `ids`) or, with `target: "contents"`, the objects inside the groups and layers. `appearance.targetContents` selects a group's members (double-clicking the Contents row). SVG and PDF export bake a group's own fills, strokes and geometry effects into paths; its raster effects stay a -filter on the whole group. +filter on the whole group in SVG and become an image of the whole group in PDF. ```json {"name":"run_command","arguments":{"command":"appearance.addFill","params":{"ids":[7]}}} @@ -937,7 +937,10 @@ export or Expand Appearance renders them: `resolution` (ppi, also New Document's (off: hard edges), `clippingMask` (the white stays under the art only), `addAround` (points of room around the art) and `preserveSpotColors` (stored). They are also the defaults of `object.rasterize`, whose params override them (its `clippingMask` puts the image in a clip group with the art's outline). No params reports them; any of them changes them -in one undo step. +in one undo step. PDF export renders the raster effects of every kind of object this way (paths, groups, layers, type, +images, symbol instances, live objects, and those in opacity masks, symbols and pattern tiles): shadows and outer glows +go in an image under the untouched vector art, other effects replace the object with its image, and a path whose +fills or strokes carry effects is written as one piece per fill and stroke, so only the affected ones become images. ```json {"name":"run_command","arguments":{"command":"document.rasterEffectsSettings","params":{"resolution":"high","background":"white","addAround":36}}}