vectorcraft/crates/engine/src/cmd/rasterfx.rs

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