Files
vectorcraft/crates/engine/src/cmd/rasterfx.rs
T
dotCooCoo d9bd9b134d Symbols: instances of imported symbols are measured by their art
Every symbol instance was measured as its transform applied to the
20 × 20 square around the origin, which symbols made in the app keep
their art in. The SVG and DXF importers keep symbol art as drawn, so
the box of an instance of an imported symbol did not match its art.
The SVG Star instance at x 20, y 30 draws from (20, 30) to (40, 50)
and was measured from (10, 20) to (30, 40). Align moved such an
instance by the wrong amount, clicks and marquees beside its art
selected it, and the canvas, exports and prints left it out when its
art reached the view or the page and the square did not.

An instance is now measured by its symbol's art, which
Document::symbol_boxes looks up in the document each time
(Node::geometric_bounds_with, visual_bounds_with and
reach_bounds_with). With Use Preview Bounds on, the strokes in the art
count at their own weight, as the canvas draws them, and
object.setBounds counts them at that weight when it sizes an instance
or an object that holds one. When strokes do not scale and a width or
height of 0 or more is smaller than what a path's stroke adds,
object.setBounds now gives the path no width or height, as it does
for an instance whose art has strokes; before, it mirrored the path.
With Use Preview Bounds on, object.setBounds now measures the margins
that strokes add object by object, the members of groups and the
objects in symbol instances' art included, and solves for the width,
height and reference point asked for when the width and height are 0
or more. Before, it measured the margins of the whole selection and
could miss the size or the reference point asked for when strokes did
not scale and the objects' strokes added different margins, and when
strokes scaled and the width and height were out of proportion, even
for one path. A clip group, a Shaper Group, a compound path, type, and
a group or instance with effects or an opacity mask of its own are each
measured as one object, and so is an instance in perspective or one
turned or slanted relative to the bounding box, other than by quarter
turns. When the selection holds more than 4,096 objects to measure this
way, each selected object is measured as one. Instances inside symbol
art are measured by their own symbol's art. An instance is measured as
the square when its symbol is missing, when its symbol or a symbol
nested in its art holds an instance of itself, and when symbols nest 32
deep in its art.

Align and Distribute, the Transform panel, the bounding box and the
selection outline, clicks, the marquee, document.inspect and
document.node, Fit to Artwork Bounds and Fit to Selected Art, canvas
culling, thumbnails, the SVG filter region of an instance with a
raster effect, raster effect images, Export Selection, the SVG of an
artboard, printing, and the PDF, EPS, EMF/WMF and DXF writers use the
new box. The renderer also culls an instance by the effects and
brushed strokes in its art, and it culls a blend, envelope or repeat
by the brush art of what it holds and the effects in the art of the
instances in it. It culls an instance with geometry effects of its own
by the effects in its art after those geometry effects reshape the art.
It measures the effects in a symbol's art once for each symbol and
linear part of the transform that places it. For symbols whose art
holds instances, it measures at most 4,096 placements a frame, and at
most 4,096 more for each pattern tile it rasterizes and each placed
document it draws. Past that, it estimates the reach from the symbol's
reach with no transform, which it measures once. When the drawn
document's symbols change, the renderer drops the cached bounds of the
instances and of the objects that hold them.

The PDF and EPS writers, the SVG of an artboard and Skip Blank
Artboards grow the box of an instance whose art holds brushed strokes
by 16 times the widest stroke (at least 1 pt) of the objects in that
art that have a brushed stroke, plus 8 pt, as the renderer grows the
cull bounds of a brushed path. They keep an instance whose brush art
reaches the page. The SVG of an artboard also keeps an object that
holds an instance when what the canvas paints for it, the effects in
the symbol's art and the instance's own included, reaches the artboard.

Not yet: other commands that measure objects, such as Select › All on
Active Artboard, Transform Each and Convert to Artboards, still measure
an instance by the square.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-14 04:56:30 +07:00

591 lines
27 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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 (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.
//!
//! SVG has filters for shadows, glows, blurs and feathers but none for the Photoshop-style
//! effects (Radial Blur, Unsharp Mask…): SVG export turns only the objects carrying one of those
//! into images the same way ([`flatten_pixel_effects`]).
use std::sync::Arc;
use serde_json::{Map, Value, json};
use vectorcraft_color::{BlendMode, Paint};
use vectorcraft_doc::rastersettings::MAX_ADD_AROUND;
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};
use super::*;
mod report;
use report::FlattenReport;
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
"document.rasterEffectsSettings",
"Document Raster Effects Settings…",
["Effect"],
None,
"{resolution?: ppi (1–2400) | \"screen\" (72) | \"medium\" (150) | \"high\" (300), colorModel?: \"rgb\"|\"cmyk\" (the document's mode)|\"grayscale\"|\"bitmap\", background?: \"transparent\"|\"white\", antiAlias?: bool (off: hard edges), clippingMask?: bool (white background only under the art; Rasterize clips to the art), addAround?: pt (0–1000, room around the art), preserveSpotColors?: bool (stored)} how raster effects (shadows, glows, blurs, feathers) become images in PDF export and Expand Appearance, and the defaults of object.rasterize; one undo step; always → the settings",
has_doc,
settings
)]
}
const C: &str = "document.rasterEffectsSettings";
/// What `document.rasterEffectsSettings` reports (and takes back).
fn settings_json(d: &Document) -> Value {
let r = &d.raster_effects;
let model = match (r.color_model, d.color_mode) {
(RasterColorModel::Document, ColorMode::Rgb) => "rgb",
(RasterColorModel::Document, ColorMode::Cmyk) => "cmyk",
(m, _) => m.id(),
};
json!({
"resolution": d.raster_effects_ppi,
"colorModel": model,
"background": r.background.id(),
"antiAlias": r.anti_alias,
"clippingMask": r.clipping_mask,
"addAround": r.add_around,
"preserveSpotColors": r.preserve_spot_colors,
})
}
fn resolution(v: &Value) -> Result<f64> {
let ppi = match v {
Value::String(v) => match v.to_ascii_lowercase().as_str() {
"screen" => 72.0,
"medium" => 150.0,
"high" => 300.0,
other => other.trim_end_matches("ppi").trim().parse::<f64>().map_err(|_| bad(C, format!("unknown resolution `{v}`")))?,
},
v => v.as_f64().ok_or_else(|| bad(C, "resolution must be a number or screen|medium|high"))?,
};
if !(1.0..=2400.0).contains(&ppi) {
return Err(bad(C, "resolution must be between 1 and 2400 ppi"));
}
Ok(ppi)
}
/// `colorModel` for a document in `mode`: its own mode's name means the document's model.
pub(crate) fn color_model(v: &Value, mode: ColorMode, cmd: &str) -> Result<RasterColorModel> {
let s = v.as_str().unwrap_or_default().to_ascii_lowercase();
match (s.as_str(), mode) {
("document", _) | ("rgb", ColorMode::Rgb) | ("cmyk", ColorMode::Cmyk) => Ok(RasterColorModel::Document),
("grayscale" | "gray", _) => Ok(RasterColorModel::Grayscale),
("bitmap", _) => Ok(RasterColorModel::Bitmap),
_ => {
let own = if mode == ColorMode::Rgb { "rgb" } else { "cmyk" };
Err(bad(cmd, format!("colorModel must be {own} (the document's mode), grayscale or bitmap")))
}
}
}
/// `addAround`/`padding` in points.
pub(crate) fn add_around(v: &Value, cmd: &str, key: &str) -> Result<f64> {
v.as_f64().filter(|x| (0.0..=MAX_ADD_AROUND).contains(x)).ok_or_else(|| bad(cmd, format!("{key} must be 0–{MAX_ADD_AROUND} pt")))
}
fn flag(k: &str, v: &Value) -> Result<bool> {
v.as_bool().ok_or_else(|| bad(C, format!("{k} must be true or false")))
}
fn settings(s: &mut Session, p: &Value) -> Result<Value> {
let empty = Map::new();
let o = match p {
Value::Null => &empty,
Value::Object(o) => o,
_ => return Err(bad(C, "params must be an object")),
};
let d = &s.doc()?.doc;
let (mut ppi, mut r) = (d.raster_effects_ppi, d.raster_effects.clone());
// A null value leaves its setting as it is.
for (k, v) in o.iter().filter(|(_, v)| !v.is_null()) {
match k.as_str() {
"resolution" => ppi = resolution(v)?,
"colorModel" => r.color_model = color_model(v, d.color_mode, C)?,
"background" => r.background = super::docsetup::background_param(v, C)?,
"antiAlias" => r.anti_alias = flag(k, v)?,
"clippingMask" => r.clipping_mask = flag(k, v)?,
"addAround" => r.add_around = add_around(v, C, k)?,
"preserveSpotColors" => r.preserve_spot_colors = flag(k, v)?,
_ => return Err(bad(C, format!("unknown setting `{k}`"))),
}
}
if ppi != d.raster_effects_ppi || r != d.raster_effects {
s.edit("Document Raster Effects Settings", |d, _| {
d.raster_effects_ppi = ppi;
d.raster_effects = r;
Ok(())
})?;
}
Ok(settings_json(&s.doc()?.doc))
}
/// `art` in a clip group clipped by `path` filled by `rule` (a clipping path that paints nothing).
pub(crate) fn clip_group(d: &mut Document, path: PathData, rule: FillRule, art: Node) -> Node {
let mut clip = super::pathops::shape_node(d, path, None);
clip.appearance = Appearance::basic(Paint::None, Paint::None, 0.0).into();
match &mut clip.kind {
NodeKind::Path { clipping, rule: r, .. } => (*clipping, *r) = (true, rule),
NodeKind::Compound { rule: r, .. } => *r = rule,
_ => {}
}
Node::new(d.alloc_id(), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(art)], clip: true })
}
/// 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();
let mut layer = Node::layer(NodeId(u64::MAX), "raster", vectorcraft_doc::LayerColor::Preset(0));
if let Some(ch) = layer.children_mut() {
*ch = nodes.into_iter().map(Arc::new).collect();
}
tmp.layers = vec![Arc::new(layer)];
let mut r = vectorcraft_render::Renderer::new();
r.threads = 0;
// Art Optimized is supersampled, as in exports: shapes meeting edge to edge leave no seam.
r.render_region_with(&tmp, region, scale, &RenderOptions { skip_templates: true, anti_alias, supersample: true, ..Default::default() })
}
/// Pixels per point of raster effects rendered as images (the document's raster effects
/// resolution).
pub(crate) fn effects_scale(doc: &Document) -> f64 {
(doc.raster_effects_ppi / 72.0).clamp(1.0 / 72.0, 2400.0 / 72.0)
}
/// An embedded image (a new node of `out`, its pixels in `out.images`) of `whole` rendered alone
/// 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> {
// 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(out, whole)?;
let look = out.raster_effects.clone();
let reach = whole.appearance.items.iter().map(|i| effects::outset(i.effects(), b)).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());
let region = Rect::new(b.x0, b.y0, b.x0 + (b.width() * scale).ceil().max(1.0) / scale, b.y0 + (b.height() * scale).ceil().max(1.0) / scale);
// Anti-alias off: hard edges (the effects themselves stay smooth).
let anti_alias = if look.anti_alias { AntiAlias::Art } else { AntiAlias::None };
let mut img = render(out, vec![whole.clone()], region, scale, anti_alias);
if let Some(bare) = knockout {
let obj = render(out, vec![bare.clone()], region, scale, anti_alias);
for (px, o) in img.pixels.as_chunks_mut::<4>().0.iter_mut().zip(obj.pixels.as_chunks::<4>().0) {
let keep = 1.0 - o[3] as f32 / 255.0;
for c in px.iter_mut() {
*c = (*c as f32 * keep).round() as u8;
}
}
}
// Document Raster Effects Settings: colour model, background.
look.finish_pixels(&mut img.pixels);
// Can't encode: keep the object as it is (vector, effects ignored) rather than fail the export.
let png = img.to_png().ok()?;
let id = out.alloc_id();
let mut key = format!("raster-effect-{}", id.0);
while out.images.contains_key(&key) {
key.push('+');
}
out.images.insert(key.clone(), ImageBlob::new("image/png", png));
let xf = Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale);
let mut image = Node::new(
id,
NodeKind::Image(Box::new(ImageObject { key, width: img.width, height: img.height, xf, link: None, placement: Default::default() })),
);
image.name = Some("Raster effect".into());
Some(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()
}
/// 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) {
retain_effects(n, members, &|e| !effects::is_raster(&e.id));
}
/// Keep the effects of `n` (its own and its fills' and strokes') that `keep` accepts and, with
/// `members`, of everything inside it.
fn retain_effects(n: &mut Node, members: bool, keep: &dyn Fn(&Effect) -> bool) {
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() {
retain_effects(Arc::make_mut(c), true, keep);
}
}
}
/// The blend mode raster effect `e` paints with (`None`: not a raster effect, or one without).
fn effect_mode(e: &Effect) -> Option<BlendMode> {
effects::raster_effects(std::slice::from_ref(e)).first().and_then(RasterFx::mode)
}
/// The images of the shadows and outer glows of `n`, one per blend mode they use (in the order
/// the modes first come), each composited with its mode: rendered alone, over nothing, they can't
/// blend with the art below the object, so the image does.
fn below_images(d: &mut Document, n: &Node) -> Option<Vec<Node>> {
let mut modes: Vec<BlendMode> = vec![];
for mode in raster_fx(n).iter().filter_map(RasterFx::mode) {
if !modes.contains(&mode) {
modes.push(mode);
}
}
if modes.len() < 2 {
let mut image = raster_image(d, n, true, false)?;
image.blend = modes.first().copied().unwrap_or_default();
return Some(vec![image]);
}
modes
.into_iter()
.map(|mode| {
let mut only = n.clone();
retain_effects(&mut only, false, &|e| effect_mode(e).is_none_or(|m| m == mode));
let mut image = raster_image(d, &only, true, false)?;
image.blend = mode;
Some(image)
})
.collect()
}
/// 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) = (1.0, Default::default());
whole.extras_mut().mask = 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) = (None, 1.0, Default::default(), false);
inner.extras_mut().mask = None;
// Knockout is between the object's own parts (a blend's steps): it stays with them,
// so the image doesn't knock out the art over it.
m.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;
}
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() }.into();
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);
let mut g = n.clone();
g.kind = NodeKind::Group { children: vec![Arc::new(art)], clip: false };
Some(g)
}
/// Which objects' raster effects become images.
#[derive(Clone, Copy, PartialEq)]
enum Which {
/// Every raster effect (PDF).
All,
/// Objects with a Photoshop-style effect, which SVG can't draw (with all their raster effects).
Pixel,
}
/// Has `n` (its own, its fills' or strokes') raster effects `which` turns into an image?
fn imaged(n: &Node, which: Which) -> bool {
let fx = raster_fx(n);
match which {
Which::All => !fx.is_empty(),
Which::Pixel => fx.iter().any(|f| matches!(f, RasterFx::Pixel(_))),
}
}
/// Does `n` hold raster effects that `which` turns into images: 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, which: Which) -> bool {
n.visible
&& !matches!(n.kind, NodeKind::Layer { template: true, .. })
&& (imaged(n, which)
|| n.extras().mask.as_ref().is_some_and(|m| needs(&m.art, which))
|| (is_container(n) && n.children().is_some_and(|ch| ch.iter().any(|c| needs(c, which)))))
}
/// The art of `n`'s opacity mask with its raster effects as images.
fn walk_mask(out: &mut Document, n: &mut Node, report: &mut FlattenReport) {
if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut())
&& let Some(art) = walk(out, &m.art, report)
{
m.art = Arc::new(art);
}
}
/// `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, report: &mut FlattenReport) -> Option<Node> {
let checkpoint = report.checkpoint();
let result = walk_inner(out, n, report);
report.attribute_since(checkpoint, n);
result
}
fn walk_inner(out: &mut Document, n: &Node, report: &mut FlattenReport) -> Option<Node> {
let which = report.which;
if !needs(n, which) {
return None;
}
if !imaged(n, which) {
let mut m = n.clone();
walk_mask(out, &mut m, report);
if is_container(&m)
&& let Some(ch) = walk_all(out, n.children()?, report)
&& let Some(slot) = m.children_mut()
{
*slot = ch;
}
return Some(m);
}
if let Some(g) = placed_symbol(out, n) {
return Some(walk(out, &g, report).unwrap_or(g));
}
let below = below_only(n);
if !below && let Some(g) = split_items(out, n) {
return Some(walk(out, &g, report).unwrap_or(g));
}
// An image that can't be made leaves the object as it is (the writer reports its effects).
let mut v = n.clone();
strip_raster(&mut v, false);
let mut m = if below {
let images = below_images(out, n)?;
for image in &images {
report.record(n, image);
}
let mut m = walk(out, &v, report).unwrap_or(v);
// Each goes in first: the last one put is the lowest.
for image in images.into_iter().rev() {
put_below(out, &mut m, image);
}
m
} else {
let image = raster_image(out, n, false, false)?;
report.record(n, &image);
replace_with_image(v, image)
};
walk_mask(out, &mut m, report);
Some(m)
}
/// `nodes` with their raster effects as images, `None` when none has any.
fn walk_all(out: &mut Document, nodes: &[Arc<Node>], report: &mut FlattenReport) -> Option<Vec<Arc<Node>>> {
nodes
.iter()
.any(|n| needs(n, report.which))
.then(|| nodes.iter().map(|n| walk(out, n, report).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: 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> {
flatten(doc, &mut FlattenReport::new(Which::All, doc))
}
/// A copy of `doc` in which the objects with Photoshop-style raster effects (Radial Blur, Unsharp
/// Mask…) are images as [`flatten_raster_effects`] makes them (SVG export: its filters draw the
/// other raster effects live), or `None` when there are none.
pub fn flatten_pixel_effects(doc: &Document) -> Option<Document> {
flatten(doc, &mut FlattenReport::new(Which::Pixel, doc))
}
fn flatten(doc: &Document, report: &mut FlattenReport) -> Option<Document> {
let which = report.which;
let any = |nodes: &[Arc<Node>]| nodes.iter().any(|n| needs(n, which));
if !any(&doc.layers) && !doc.symbols.iter().any(|s| needs(&s.art, which)) && !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 mut out = src.clone();
if let Some(layers) = walk_all(&mut out, &src.layers, report) {
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, report))
&& 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, report))
&& let Some(p) = out.patterns.get_mut(i)
{
p.art = art;
}
}
Some(out)
}
/// PDF bytes for `doc`, with raster effects rendered as images.
pub fn export_pdf(doc: &Document, opts: &vectorcraft_pdf::PdfOptions) -> std::result::Result<Vec<u8>, vectorcraft_pdf::PdfError> {
export_pdf_with_report(doc, opts).map(|r| r.bytes)
}
/// Like [`export_pdf`], also returning the writer's warnings (options not applied yet, features
/// approximated or left out).
pub fn export_pdf_with_report(
doc: &Document,
opts: &vectorcraft_pdf::PdfOptions,
) -> std::result::Result<vectorcraft_pdf::ExportReport, vectorcraft_pdf::PdfError> {
let mut report = FlattenReport::new(Which::All, doc);
let flat = flatten(doc, &mut report);
let mut exported = vectorcraft_pdf::export_with_report(flat.as_ref().unwrap_or(doc), opts)?;
exported.warnings.extend(report.warnings());
Ok(exported)
}
#[cfg(test)]
mod tests {
use serde_json::json;
use vectorcraft_doc::NodeKind;
use crate::Session;
#[test]
fn settings_and_pdf_flattening() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap();
assert_eq!(s.execute("document.rasterEffectsSettings", &json!({})).unwrap()["resolution"], json!(72.0));
s.execute("document.rasterEffectsSettings", &json!({"resolution": "high"})).unwrap();
assert!(s.execute("document.rasterEffectsSettings", &json!({"resolution": 0})).is_err());
let r = s.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap();
let b = s.execute("shape.ellipse", &json!({"x": 180, "y": 50, "width": 80, "height": 80})).unwrap()["id"].as_u64().unwrap();
s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [r]})).unwrap();
s.execute("effect.apply", &json!({"effect": "blur.gaussian", "params": {"radius": 4}, "ids": [b]})).unwrap();
let doc = s.doc().unwrap().doc.clone();
let flat = super::flatten_raster_effects(&doc).unwrap();
let kids = flat.layers[0].children().unwrap();
// Shadow: an image (300 ppi) under the vector rectangle, which keeps no raster effect.
let NodeKind::Group { children, .. } = &kids[0].kind else { panic!("shadow becomes a group: {:?}", kids[0].kind_label()) };
let NodeKind::Image(im) = &children[0].kind else { panic!() };
assert!(im.width as f64 > 100.0 * 300.0 / 72.0);
assert!(children[1].path_data().is_some() && children[1].appearance.effects.is_empty());
// Blur changes the object itself: an image alone.
assert!(matches!(kids[1].kind, NodeKind::Image(_)));
let pdf = super::export_pdf(&doc, &Default::default()).unwrap();
let text = String::from_utf8_lossy(&pdf);
assert!(text.contains("/Image"), "the PDF embeds the effect images");
// The source document is untouched.
assert!(s.doc().unwrap().doc.node(vectorcraft_doc::NodeId(r)).unwrap().appearance.effects.len() == 1);
}
}