mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 10:47:11 -06:00
document.rasterEffectsSettings accepts a resolution from 1 to 2,400 ppi, but a file's raster_effects_ppi opened as it was saved. With 0, -5 or 5000 in the file, object.rasterize without a ppi param failed with "ppi must be between 1 and 2400", and document.rasterEffectsSettings and document.info reported the stored value. With 0 or -5, EPS and PDF export wrote a freeform gradient as a 1 x 1 px image, and EMF export wrote every gradient as one. A saved New Document preset in the preferences kept such a value too. A file's or a saved preset's resolution outside 1 to 2,400 ppi is now read as the nearer end of that range. Documents with a resolution in range open as before. Docs: docs/mcp.md (resolution, rasterEffectsPpi).
261 lines
13 KiB
Rust
261 lines
13 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::rastersettings::MAX_ADD_AROUND;
|
|
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));
|
|
}
|
|
|
|
/// The transforms of the images in `doc` flattened for output.
|
|
fn image_transforms(doc: &Document) -> Vec<[f64; 6]> {
|
|
let mut xfs = vec![];
|
|
crate::flatten_raster_effects(doc).unwrap().walk(|n| {
|
|
if let NodeKind::Image(im) = &n.kind {
|
|
xfs.push(im.xf.as_coeffs());
|
|
}
|
|
});
|
|
xfs
|
|
}
|
|
|
|
#[test]
|
|
fn a_file_with_add_around_out_of_range_opens_with_it_in_range() {
|
|
let mut s = session();
|
|
shadowed(&mut s);
|
|
let mut d = (*s.doc().unwrap().doc).clone();
|
|
d.raster_effects.add_around = 1e300;
|
|
let back = vectorcraft_format::load(&vectorcraft_format::save_file(&d)).unwrap();
|
|
assert_eq!(back.raster_effects.add_around, MAX_ADD_AROUND);
|
|
let xfs = image_transforms(&back);
|
|
assert!(!xfs.is_empty() && xfs.iter().flatten().all(|c| c.is_finite()), "{xfs:?}");
|
|
// 1,000 pt on every side at 72 ppi.
|
|
assert_eq!(effect_image(&back).width() - effect_image(&s.doc().unwrap().doc).width(), 2000);
|
|
d.raster_effects.add_around = -5.0;
|
|
assert_eq!(vectorcraft_format::load(&vectorcraft_format::save_file(&d)).unwrap().raster_effects.add_around, 0.0);
|
|
}
|
|
|
|
#[test]
|
|
fn art_too_large_for_f64_gets_no_effect_image() {
|
|
let mut s = session();
|
|
let r = shadowed(&mut s);
|
|
// Settings out of range in a document made without opening a file.
|
|
let mut d = (*s.doc().unwrap().doc).clone();
|
|
d.raster_effects.add_around = 1e300;
|
|
assert_eq!(image_transforms(&d), Vec::<[f64; 6]>::new());
|
|
let mut d = (*s.doc().unwrap().doc).clone();
|
|
let NodeKind::Path { path, live, .. } = &mut d.node_mut(r).unwrap().kind else { panic!("a path") };
|
|
(*path, *live) = (vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(-1e300, -1e300, 1e300, 1e300)), None);
|
|
assert_eq!(image_transforms(&d), Vec::<[f64; 6]>::new());
|
|
// Expand Appearance of it by id, with nothing selected, puts no image into the document (the
|
|
// rectangle at its own size gets one).
|
|
let normal = (*s.doc().unwrap().doc).clone();
|
|
let mut expanded_images = |d: Document| {
|
|
let st = s.doc_mut().unwrap();
|
|
(st.doc, st.selection) = (std::sync::Arc::new(d), Default::default());
|
|
s.execute("effect.expandAppearance", &json!({"ids": [r.0]})).unwrap();
|
|
let mut images = 0;
|
|
s.doc().unwrap().doc.walk(|n| images += usize::from(matches!(n.kind, NodeKind::Image(_))));
|
|
images
|
|
};
|
|
assert_eq!(expanded_images(normal), 1);
|
|
assert_eq!(expanded_images(d), 0);
|
|
}
|
|
|
|
#[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()));
|
|
}
|
|
|
|
/// Save the active document with the raster effects resolution `ppi` and open the file.
|
|
fn reopen_with_ppi(s: &mut Session, ppi: f64) {
|
|
let mut d = (*s.doc().unwrap().doc).clone();
|
|
d.raster_effects_ppi = ppi;
|
|
let data = vectorcraft_format::base64_encode(&vectorcraft_format::save_file(&d));
|
|
s.execute("document.open", &json!({"name": "ppi.vectorcraft", "dataBase64": data})).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn a_file_with_the_resolution_out_of_range_opens_with_it_in_range() {
|
|
for (saved, opened, pixels) in [(0.0, 1.0, 1), (-5.0, 1.0, 1), (5000.0, 2400.0, 334), (f64::MAX, 2400.0, 334)] {
|
|
let mut s = session();
|
|
let r = id(&s.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 10, "height": 10})).unwrap());
|
|
s.execute("paint.setStroke", &json!({"none": true, "ids": [r.0]})).unwrap();
|
|
reopen_with_ppi(&mut s, saved);
|
|
assert_eq!(s.doc().unwrap().doc.raster_effects_ppi, opened, "saved as {saved}");
|
|
assert_eq!(s.execute(C, &json!({})).unwrap()["resolution"], opened);
|
|
assert_eq!(s.execute("document.info", &json!({})).unwrap()["document"]["rasterEffectsPpi"], opened);
|
|
// Rasterize without a ppi param renders the 10 pt square at it.
|
|
s.execute("select.set", &json!({"ids": [r.0]})).unwrap();
|
|
let out = s.execute("object.rasterize", &json!({})).unwrap();
|
|
assert_eq!((out["width"].as_u64(), out["height"].as_u64()), (Some(pixels), Some(pixels)), "saved as {saved}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_gradient_exports_at_the_resolution_the_file_opens_with() {
|
|
for saved in [0.0, -5.0] {
|
|
let mut s = session();
|
|
let r = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 720, "height": 720})).unwrap());
|
|
s.execute("paint.setFill", &json!({"gradient": {"kind": "linear"}, "ids": [r.0]})).unwrap();
|
|
reopen_with_ppi(&mut s, saved);
|
|
// EMF writes the gradient as an image at the raster effects resolution: 720 pt at 1 ppi.
|
|
let region = vectorcraft_geom::Rect::new(0.0, 0.0, 720.0, 720.0);
|
|
let out = vectorcraft_metafile::export(&s.doc().unwrap().doc, region, vectorcraft_metafile::Kind::Emf).unwrap();
|
|
let mut sizes = vec![];
|
|
vectorcraft_metafile::import(&out.bytes).unwrap().document.walk(|n| {
|
|
if let NodeKind::Image(im) = &n.kind {
|
|
sizes.push((im.width, im.height));
|
|
}
|
|
});
|
|
assert_eq!(sizes, [(10, 10)], "saved as {saved}");
|
|
}
|
|
}
|