Files
vectorcraft/crates/engine/src/tests_pdfraster.rs
T
arhammusheer 5beac4f122 #1244: smaller objects: Node 352 -> 152 bytes, NodeKind 200 -> 80, and a cheaper undo estimate
Rarely set fields move into one optional boxed NodeExtras, and large NodeKind payloads (images,
live shapes, blends, envelopes) are boxed. Every object, and every undo step that copies it,
shrinks: a 50k-path move keeps ~24.5 MB per step instead of ~40 MB. Files stay byte-identical
(Node keeps its flat JSON through a borrowed Serialize and a from-wire Deserialize, no flatten),
and loading is slightly faster. Undo and redo reuse their step's size estimate instead of
walking the document again (8.3 -> 5.6 ms for a 50k-path move).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmMmc1J8XeNLxhRihWv9cc

# Conflicts:
#	crates/engine/src/cmd/graph.rs
#	crates/svg/src/export.rs
2026-10-12 11:02:32 +07:00

224 lines
10 KiB
Rust

//! 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(Box::new(ImageObject { key: "pic".into(), width: 40, height: 30, xf, link: None, placement: Default::default() })),
);
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");
}
}
#[test]
fn shadows_and_glows_keep_their_blend_modes() {
// #827: a red Screen glow over grey can't darken it, as Normal would.
let glow = Effect {
id: "stylize.outerGlow".into(),
params: json!({"mode": "screen", "color": "#ff0000", "opacity": 100.0, "blur": 20.0}),
visible: true,
};
let mut d = Document::new(200.0, 200.0);
let back = rect(&mut d, Rect::new(0.0, 0.0, 200.0, 200.0), Color::rgb(0.5, 0.5, 0.5));
add(&mut d, back);
let dot = rect(&mut d, Rect::new(70.0, 70.0, 130.0, 130.0), Color::rgb(0.12, 0.12, 0.12));
add(&mut d, with(dot, glow.clone()));
assert_written("screen glow", &d);
let r = crate::cmd::rasterfx::export_pdf_with_report(&d, &Default::default()).unwrap();
let back = render(&vectorcraft_pdf::import(&r.bytes).unwrap());
for x in [133, 138, 145] {
let i = (100 * back.width as usize + x) * 4;
let (red, green, blue) = (back.pixels[i], back.pixels[i + 1], back.pixels[i + 2]);
assert!(red > green && green >= 124 && blue >= 124, "x {x}: {:?}", (red, green, blue));
}
// A Multiply shadow and the Screen glow: an image each, composited with its mode.
let mut d = Document::new(200.0, 200.0);
let dot = rect(&mut d, Rect::new(70.0, 70.0, 130.0, 130.0), Color::rgb(0.12, 0.12, 0.12));
add(&mut d, with(with(dot, shadow()), glow));
let flat = crate::flatten_raster_effects(&d).unwrap();
let modes: Vec<_> =
flat.layers[0].children().unwrap()[0].children().unwrap().iter().filter(|c| matches!(c.kind, NodeKind::Image(_))).map(|c| c.blend).collect();
assert_eq!(modes, [vectorcraft_color::BlendMode::Multiply, vectorcraft_color::BlendMode::Screen]);
}
#[test]
fn pdf_resolution_cap_warns_once_for_original_object_when_appearance_items_split() {
let mut d = Document::new(300.0, 300.0);
d.raster_effects_ppi = 2400.0;
d.raster_effects.anti_alias = false;
let mut n = Node::path(
d.alloc_id(),
shapes::rectangle(Rect::new(20.0, 20.0, 270.0, 270.0)),
Appearance::basic(Paint::solid(Color::BLACK), Paint::solid(Color::BLACK), 20.0),
);
let id = n.id;
n.name = Some("Large original".into());
for item in &mut n.appearance.items {
item.effects_mut().push(Effect { params: json!({"radius": 0.0}), ..blur() });
}
add(&mut d, n);
let r = crate::cmd::rasterfx::export_pdf_with_report(&d, &Default::default()).unwrap();
assert!(String::from_utf8_lossy(&r.bytes).contains("/Subtype/Image"));
assert_eq!(r.warnings.len(), 1, "{:?}", r.warnings);
let warning = &r.warnings[0];
assert!(warning.contains(&format!("object {}", id.0)) && warning.contains("Large original"), "{warning}");
assert!(warning.contains("requested 2400.0 ppi") && warning.contains("achieved 1268.7 ppi"), "{warning}");
}
#[test]
fn pdf_resolution_cap_stays_quiet_when_small_effect_meets_requested_ppi() {
let mut d = Document::new(50.0, 50.0);
d.raster_effects_ppi = 300.0;
let n = with(rect(&mut d, Rect::new(10.0, 10.0, 30.0, 30.0), Color::BLACK), blur());
add(&mut d, n);
let r = crate::cmd::rasterfx::export_pdf_with_report(&d, &Default::default()).unwrap();
assert!(String::from_utf8_lossy(&r.bytes).contains("/Subtype/Image"));
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
}