Files
vectorcraft/crates/engine/src/tests_rastersettings.rs
T
bflatasticandClaude Opus 5.5 94f0f1b0d2 M4.56: Document Raster Effects Settings with colour model, background, anti-alias, clipping mask and room around
document.rasterEffectsSettings now also takes and reports colorModel,
background, antiAlias, clippingMask, addAround and preserveSpotColors in one
undo step (Document.raster_effects, RasterEffectsSettings, next to the
existing raster_effects_ppi so old files load unchanged). The raster effect
images of PDF export and Expand Appearance get the extra room and are
finished by the settings (hard edges, grayscale or bitmap, opaque on
white, white only under the art with a clipping mask). Rasterize takes
them as defaults (params override), and its clipping mask puts the image
in a clip group with the art's outline. The settings have their own dialog
(rasterEffectsSettings, ui.rasterEffectsSettingsDialog) and Rasterize…
opens its options prefilled from them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:55:48 +07:00

161 lines
7.9 KiB
Rust

//! Effect → Document Raster Effects Settings (`document.rasterEffectsSettings`): the settings, the
//! raster effect images they make (PDF export, Expand Appearance) and the Rasterize defaults.
use serde_json::{Value, json};
use vectorcraft_doc::{Background, Document, NodeId, NodeKind, RasterColorModel, RasterEffectsSettings};
use super::*;
const C: &str = "document.rasterEffectsSettings";
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap();
s
}
fn id(v: &Value) -> NodeId {
NodeId(v["id"].as_u64().unwrap())
}
/// A red rectangle with a drop shadow.
fn shadowed(s: &mut Session) -> NodeId {
let r = id(&s.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap());
s.execute("paint.setFill", &json!({"color": "#ff0000", "ids": [r.0]})).unwrap();
s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [r.0]})).unwrap();
r
}
/// The decoded pixels of the first raster effect image of `doc` flattened for output.
fn effect_image(doc: &Document) -> image::RgbaImage {
let flat = crate::flatten_raster_effects(doc).unwrap();
let kids = flat.layers[0].children().unwrap();
let NodeKind::Group { children, .. } = &kids[0].kind else { panic!("a shadow becomes a group") };
let NodeKind::Image(im) = &children[0].kind else { panic!("the image goes under the object") };
image::load_from_memory(&flat.images[&im.key].bytes).unwrap().to_rgba8()
}
#[test]
fn settings_report_change_in_one_step_and_reject_junk() {
let mut s = session();
let r = s.execute(C, &json!({})).unwrap();
assert_eq!(
r,
json!({"resolution": 72.0, "colorModel": "rgb", "background": "transparent", "antiAlias": true, "clippingMask": false, "addAround": 0.0, "preserveSpotColors": true})
);
let r = s
.execute(C, &json!({"resolution": "high", "colorModel": "grayscale", "background": "white", "antiAlias": false, "clippingMask": true, "addAround": 36}))
.unwrap();
assert_eq!((r["resolution"].as_f64(), r["colorModel"].as_str(), r["addAround"].as_f64()), (Some(300.0), Some("grayscale"), Some(36.0)));
assert_eq!(s.doc().unwrap().history.undo.len(), 1);
// The report fed back is no change.
s.execute(C, &r).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), 1);
for bad in [
json!({"colorModel": "cmyk"}),
json!({"colorModel": "lab"}),
json!({"background": "pink"}),
json!({"antiAlias": "yes"}),
json!({"addAround": -1}),
json!({"addAround": 5000}),
json!({"resolution": 0}),
json!({"dpi": 300}),
] {
assert!(s.execute(C, &bad).is_err(), "{bad}");
}
s.execute("edit.undo", &json!({})).unwrap();
let d = &s.doc().unwrap().doc;
assert_eq!((d.raster_effects_ppi, &d.raster_effects), (72.0, &RasterEffectsSettings::default()));
// A CMYK document names its own mode.
let mut s = Session::new();
s.execute("file.new", &json!({"colorMode": "cmyk"})).unwrap();
assert_eq!(s.execute(C, &json!({"colorModel": "cmyk"})).unwrap()["colorModel"], "cmyk");
}
#[test]
fn a_white_background_makes_effect_images_opaque() {
let mut s = session();
shadowed(&mut s);
let clear = effect_image(&s.doc().unwrap().doc);
assert_eq!(clear.get_pixel(0, 0)[3], 0, "transparent by default");
s.execute(C, &json!({"background": "white"})).unwrap();
let white = effect_image(&s.doc().unwrap().doc);
assert!(white.pixels().all(|p| p[3] == 255), "every pixel is opaque");
assert_eq!(white.get_pixel(0, 0).0, [255, 255, 255, 255]);
// With a clipping mask, the white stays under the art only.
s.execute(C, &json!({"clippingMask": true})).unwrap();
assert_eq!(effect_image(&s.doc().unwrap().doc).get_pixel(0, 0)[3], 0);
}
#[test]
fn add_around_grows_the_effect_image() {
let mut s = session();
shadowed(&mut s);
let before = effect_image(&s.doc().unwrap().doc);
s.execute(C, &json!({"addAround": 36})).unwrap();
let after = effect_image(&s.doc().unwrap().doc);
// 36 pt on every side at 72 ppi.
assert_eq!((after.width() - before.width(), after.height() - before.height()), (72, 72));
}
#[test]
fn the_color_model_and_anti_alias_reach_the_pixels() {
let mut s = session();
shadowed(&mut s);
s.execute(C, &json!({"colorModel": "bitmap", "antiAlias": false})).unwrap();
let img = effect_image(&s.doc().unwrap().doc);
assert!(img.pixels().filter(|p| p[3] > 0).all(|p| p[0] == p[1] && p[1] == p[2] && (p[0] == 0 || p[0] == 255)), "black and white");
// Anti-alias off: Rasterize leaves no partly covered pixel on a circle's edge.
let e = id(&s.execute("shape.ellipse", &json!({"x": 180, "y": 180, "width": 50, "height": 50})).unwrap());
s.execute("select.set", &json!({"ids": [e.0]})).unwrap();
let out = s.execute("object.rasterize", &json!({"colorModel": "rgb"})).unwrap();
let d = &s.doc().unwrap().doc;
let NodeKind::Image(im) = &d.node(id(&out)).unwrap().kind else { panic!("an image") };
let img = image::load_from_memory(&d.images[&im.key].bytes).unwrap().to_rgba8();
assert!(img.pixels().all(|p| p[3] == 0 || p[3] == 255), "hard edges");
assert!(img.pixels().any(|p| p[3] == 0) && img.pixels().any(|p| p[3] == 255));
}
#[test]
fn rasterize_takes_the_document_settings_as_defaults() {
let mut s = session();
s.execute(C, &json!({"background": "white", "colorModel": "grayscale", "addAround": 10})).unwrap();
let r = id(&s.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 40, "height": 40})).unwrap());
s.execute("paint.setFill", &json!({"color": "#ff0000", "ids": [r.0]})).unwrap();
s.execute("paint.setStroke", &json!({"none": true, "ids": [r.0]})).unwrap();
s.execute("select.set", &json!({"ids": [r.0]})).unwrap();
let out = s.execute("object.rasterize", &json!({"ppi": 72})).unwrap();
assert_eq!((out["width"].as_u64(), out["height"].as_u64()), (Some(60), Some(60)), "10 pt around");
let d = &s.doc().unwrap().doc;
let NodeKind::Image(im) = &d.node(id(&out)).unwrap().kind else { panic!("an image") };
let img = image::load_from_memory(&d.images[&im.key].bytes).unwrap().to_rgba8();
assert_eq!(img.get_pixel(0, 0).0, [255, 255, 255, 255], "white around the art");
let p = img.get_pixel(30, 30).0;
assert!(p[0] == p[1] && p[1] == p[2] && p[0] < 128, "red in grayscale: {p:?}");
// Params win; a clipping mask makes a clip group with the art's outline.
s.execute("edit.undo", &json!({})).unwrap();
s.execute("select.set", &json!({"ids": [r.0]})).unwrap();
let out = s.execute("object.rasterize", &json!({"ppi": 72, "padding": 0, "clippingMask": true, "background": "transparent"})).unwrap();
let d = &s.doc().unwrap().doc;
let NodeKind::Group { children, clip: true } = &d.node(id(&out)).unwrap().kind else { panic!("a clip group") };
let clip = children[0].geometric_bounds().unwrap();
assert!((clip.width() - 40.0).abs() < 1e-6 && matches!(children[1].kind, NodeKind::Image(_)));
assert_eq!(s.doc().unwrap().selection.objects.to_vec(), [id(&out)]);
assert!(s.execute("object.rasterize", &json!({"colorModel": "lab"})).is_err());
}
#[test]
fn raster_settings_round_trip_and_old_files_load() {
let mut d = Document::new(10.0, 10.0);
d.raster_effects =
RasterEffectsSettings { color_model: RasterColorModel::Bitmap, background: Background::White, add_around: 12.0, ..Default::default() };
let back = vectorcraft_format::load(&vectorcraft_format::save_file(&d)).unwrap();
assert_eq!(back.raster_effects, d.raster_effects);
// Saved before the settings existed: only the resolution.
let old = Document::new(10.0, 10.0);
let bytes = vectorcraft_format::save_file(&old);
assert!(!String::from_utf8_lossy(&bytes).contains("raster_effects\""), "default settings aren't written");
let back = vectorcraft_format::load(&bytes).unwrap();
assert_eq!((back.raster_effects_ppi, back.raster_effects), (72.0, RasterEffectsSettings::default()));
}