M4.58: raster effects in PDF on every kind of object

PDF export now renders the shadows, glows, blurs and feathers of groups,
layers, type, images, symbol instances, live objects and single fills and
strokes (and those inside opacity masks, symbol definitions and pattern
tiles) as images at the document raster effects resolution, instead of
leaving them out with a warning. Shadows and outer glows go under the
untouched vector art; a path whose fills or strokes carry effects is
written as one piece per fill and stroke. Expand Appearance shares the
image helpers (moved to rasterfx.rs); effect images now cover what the
renderer paints (members' shadows included).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-05 11:20:35 +07:00
parent 7051a65c65
commit e967c31be6
10 changed files with 405 additions and 143 deletions

View File

@ -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 {

View File

@ -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};

View File

@ -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<CommandSpec> {
@ -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<effects::RasterFx> = 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,

View File

@ -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]

View File

@ -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<RasterFx> {
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<Node>, 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<Node> {
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<Node> {
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<RasterFx> {
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<Node> {
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<Node> {
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<Node> {
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<Node> {
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<Arc<Node>> = 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<Node> {
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<Node>]) -> Option<Vec<Arc<Node>>> {
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<Document> {
if !doc.layers.iter().any(|l| needs(l)) {
let any = |nodes: &[Arc<Node>]| 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)
}

View File

@ -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;

View File

@ -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<u8> {
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");
}
}

View File

@ -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()));

View File

@ -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<Rect> {
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;

View File

@ -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}}}