Files
vectorcraft/crates/engine/src/cmd/fileio/tests_raster.rs
T

336 lines
18 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.
//! Raster export options: resolution (stored in the file), background, anti-aliasing and
//! interlacing, also on Export for Screens and Rasterize.
use serde_json::{Value, json};
use super::*;
fn session(width: f64, height: f64, artboards: usize) -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": width, "height": height, "artboards": artboards})).unwrap();
s
}
fn b64(v: &Value) -> Vec<u8> {
vectorcraft_format::base64_decode(v["dataBase64"].as_str().expect("dataBase64")).unwrap()
}
fn rgba(bytes: &[u8]) -> image::RgbaImage {
image::load_from_memory(bytes).unwrap().to_rgba8()
}
/// A chunk's data by type (first match) in a PNG file.
fn png_chunk<'a>(file: &'a [u8], ty: &[u8; 4]) -> Option<&'a [u8]> {
let mut i = 8;
while i + 8 <= file.len() {
let len = u32::from_be_bytes(file[i..i + 4].try_into().unwrap()) as usize;
if &file[i + 4..i + 8] == ty {
return Some(&file[i + 8..i + 8 + len]);
}
i += 12 + len;
}
None
}
#[test]
fn png_options_resolution_background_interlace_and_anti_aliasing() {
let mut s = session(40.0, 30.0, 1);
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
s.execute("shape.ellipse", &json!({"x": 5.3, "y": 4.7, "width": 25.8, "height": 21.5})).unwrap();
let png = |s: &mut Session, p: Value| b64(&s.execute("document.export", &p).unwrap());
let hi = png(&mut s, json!({"format": "png", "ppi": 300}));
let ppm = (300.0f64 / 0.0254).round() as u32;
assert_eq!(&png_chunk(&hi, b"pHYs").unwrap()[..4], ppm.to_be_bytes(), "pHYs = ppi / 0.0254");
assert_eq!(rgba(&png(&mut s, json!({"format": "png", "ppi": 144}))).dimensions(), (80, 60), "144 ppi doubles the size");
// scale alone stores 72 × scale.
let scaled = png(&mut s, json!({"format": "png", "scale": 2}));
assert_eq!(&png_chunk(&scaled, b"pHYs").unwrap()[..4], ((144.0f64 / 0.0254).round() as u32).to_be_bytes());
let white = rgba(&png(&mut s, json!({"format": "png", "background": "white"})));
assert!(white.pixels().all(|p| p[3] == 255), "a white background has no alpha below 255");
assert_eq!(white.get_pixel(0, 0).0, [255, 255, 255, 255]);
assert_eq!(rgba(&png(&mut s, json!({"format": "png", "background": "black"}))).get_pixel(0, 0).0, [0, 0, 0, 255]);
assert_eq!(rgba(&png(&mut s, json!({"format": "png", "background": "#3366cc"}))).get_pixel(0, 0).0, [0x33, 0x66, 0xcc, 255]);
assert_eq!(rgba(&png(&mut s, json!({"format": "png"}))).get_pixel(0, 0)[3], 0, "transparent by default");
assert_eq!(rgba(&png(&mut s, json!({"format": "jpg", "background": "black"}))).get_pixel(0, 0)[0], 0);
let laced = png(&mut s, json!({"format": "png", "interlaced": true}));
assert_eq!(laced[28], 1, "IHDR interlace method: Adam7");
assert_eq!(rgba(&laced), rgba(&png(&mut s, json!({"format": "png"}))), "the interlaced file decodes to the same image");
let hard = rgba(&png(&mut s, json!({"format": "png", "antiAlias": "none"})));
assert!(hard.pixels().all(|p| p[3] == 0 || p[3] == 255), "no anti-aliasing: alpha is 0 or 255");
let soft = rgba(&png(&mut s, json!({"format": "png", "antiAlias": "art"})));
assert!(soft.pixels().any(|p| p[3] != 0 && p[3] != 255));
for bad in [
json!({"antiAlias": "smooth"}),
json!({"background": "plaid"}),
json!({"interlaced": "yes"}),
json!({"ppi": "high"}),
json!({"ppi": 0}),
json!({"ppi": -72}),
] {
let mut p = bad.clone();
p["format"] = json!("png");
assert!(s.execute("document.export", &p).is_err(), "{bad}");
}
}
#[test]
fn screens_and_rasterize_take_anti_aliasing() {
let mut s = session(40.0, 30.0, 1);
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
let id = s.execute("shape.ellipse", &json!({"x": 5.3, "y": 4.7, "width": 25.8, "height": 21.5})).unwrap()["id"].as_u64().unwrap();
let hard = |img: &image::RgbaImage| img.pixels().all(|p| p[3] == 0 || p[3] == 255);
let r = s
.execute(
"document.exportForScreens",
&json!({"antiAlias": "none", "formats": [{"format": "png"}, {"format": "png", "scale": 2, "antiAlias": "art"}]}),
)
.unwrap();
assert!(hard(&rgba(&b64(&r["files"][0]))), "the top-level mode applies to rows without their own");
assert!(!hard(&rgba(&b64(&r["files"][1]))), "a row's own mode wins");
s.execute("select.set", &json!({"ids": [id]})).unwrap();
s.execute("object.rasterize", &json!({"ppi": 72, "antiAlias": "none", "background": "transparent"})).unwrap();
let doc = &s.doc().unwrap().doc;
let vectorcraft_doc::NodeKind::Image(im) = &doc.layers[0].children().unwrap()[0].kind else { panic!("rasterized to an image") };
assert!(hard(&rgba(&doc.images[&im.key].bytes)));
s.execute("edit.undo", &json!({})).unwrap();
s.execute("select.set", &json!({"ids": [id]})).unwrap();
s.execute("object.rasterize", &json!({"ppi": 72, "background": "black"})).unwrap();
let doc = &s.doc().unwrap().doc;
let vectorcraft_doc::NodeKind::Image(im) = &doc.layers[0].children().unwrap()[0].kind else { panic!("rasterized to an image") };
assert_eq!(rgba(&doc.images[&im.key].bytes).get_pixel(0, 0).0, [0, 0, 0, 255]);
assert!(s.execute("object.rasterize", &json!({"antiAlias": "fuzzy"})).is_err());
}
/// Appearance of Black › Printing / Exporting (#394): Output All Blacks as Rich Black makes an
/// RGB PNG of a CMYK document draw pure black ink as RGB black; Accurately (the default) shows it
/// through the CMYK profile. `blackOnScreen` does not change the export; a rich black is the same
/// either way.
#[test]
fn black_output_rich_draws_pure_cmyk_black_as_rgb_black_in_png() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 20, "height": 20, "colorMode": "cmyk"})).unwrap();
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
s.execute("paint.setFill", &json!({"color": {"c": 0, "m": 0, "y": 0, "k": 1}})).unwrap();
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap();
let centre = |s: &mut Session| {
let png = rgba(&b64(&s.execute("document.export", &json!({"format": "png", "background": "white"})).unwrap()));
png.get_pixel(10, 10).0
};
let accurate = centre(&mut s);
let shown = vectorcraft_color::Color::cmyk(0.0, 0.0, 0.0, 1.0).to_rgba8(1.0);
assert!(accurate.iter().zip(shown).all(|(a, b)| a.abs_diff(b) <= 1), "accurate: {accurate:?}");
assert!(accurate[0] > 10, "accurate: dark grey, not RGB black");
s.execute("prefs.set", &json!({"key": "blackOnScreen", "value": "rich"})).unwrap();
assert_eq!(centre(&mut s), accurate, "blackOnScreen does not change exports");
s.execute("prefs.set", &json!({"key": "blackOutput", "value": "rich"})).unwrap();
assert_eq!(centre(&mut s), [0, 0, 0, 255], "rich: RGB black");
s.execute("select.all", &json!({})).unwrap();
s.execute("paint.setFill", &json!({"color": {"c": 0.6, "m": 0.4, "y": 0.4, "k": 1}})).unwrap();
let rich = centre(&mut s);
s.execute("prefs.set", &json!({"key": "blackOutput", "value": "accurate"})).unwrap();
assert_eq!(centre(&mut s), rich, "a rich black shows the same either way");
}
/// Output All Blacks as Rich Black reaches a CMYK document composed from ink planes (one with a
/// blending mode) as it does one drawn directly, and leaves CMYK-model exports alone: only RGB
/// output changes.
#[test]
fn black_output_rich_covers_ink_plane_documents_and_leaves_cmyk_exports_alone() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 40, "height": 20, "colorMode": "cmyk"})).unwrap();
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
s.execute("paint.setFill", &json!({"color": {"c": 0, "m": 0, "y": 0, "k": 1}})).unwrap();
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap();
s.execute("shape.rectangle", &json!({"x": 30, "y": 0, "width": 10, "height": 20})).unwrap();
s.execute("transparency.set", &json!({"blendMode": "multiply"})).unwrap();
let png =
|s: &mut Session| rgba(&b64(&s.execute("document.export", &json!({"format": "png", "background": "white"})).unwrap())).get_pixel(10, 10).0;
let cmyk = |s: &mut Session, format: &str| b64(&s.execute("document.export", &json!({"format": format, "colorModel": "cmyk"})).unwrap());
let accurate = png(&mut s);
assert!(accurate[0] > 10, "accurate: dark grey, {accurate:?}");
let inks = [cmyk(&mut s, "tiff"), cmyk(&mut s, "jpg")];
s.execute("prefs.set", &json!({"key": "blackOutput", "value": "rich"})).unwrap();
assert_eq!(png(&mut s), [0, 0, 0, 255], "rich: RGB black through the ink planes too");
assert!(inks == [cmyk(&mut s, "tiff"), cmyk(&mut s, "jpg")], "CMYK exports keep the document's inks");
}
#[test]
fn png_says_it_is_srgb_unless_embed_icc_is_false() {
let mut s = session(40.0, 30.0, 1);
s.execute("shape.rectangle", &json!({"x": 5, "y": 5, "width": 20, "height": 10})).unwrap();
let png = |s: &mut Session, p: Value| b64(&s.execute("document.export", &p).unwrap());
let on = png(&mut s, json!({"format": "png"}));
assert_eq!(png_chunk(&on, b"sRGB"), Some(&[0u8][..]), "an sRGB chunk, perceptual intent");
let off = png(&mut s, json!({"format": "png", "embedIcc": false}));
assert!(png_chunk(&off, b"sRGB").is_none());
assert_eq!(rgba(&on), rgba(&off), "same pixels either way");
let r = s.execute("document.formats", &json!({})).unwrap();
let f = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "png").unwrap();
assert!(f["options"].get("embedIcc").is_some(), "png lists embedIcc");
}
#[test]
fn formats_list_the_raster_options() {
let mut s = Session::new();
let r = s.execute("document.formats", &json!({})).unwrap();
let opts = |id: &str| r["formats"].as_array().unwrap().iter().find(|f| f["id"] == id).unwrap()["options"].clone();
for k in ["ppi", "scale", "background", "antiAlias", "interlaced"] {
assert!(opts("png").get(k).is_some(), "png lists {k}");
}
assert_eq!(opts("png")["antiAlias"]["default"], "art");
assert_eq!(opts("png")["ppi"]["default"], 72);
assert!(opts("jpg").get("quality").is_some() && opts("jpg").get("interlaced").is_none());
assert!(opts("webp").get("antiAlias").is_some() && opts("svg").get("antiAlias").is_none());
}
#[test]
fn screens_rows_with_a_resolution_name_their_scale() {
let mut s = session(40.0, 30.0, 1);
let r = s.execute("document.exportForScreens", &json!({"formats": [{"format": "png", "ppi": 144}, {"format": "svg", "ppi": 144}]})).unwrap();
assert_eq!((r["files"][0]["name"].as_str(), r["files"][1]["name"].as_str()), (Some("Artboard-1@2x.png"), Some("Artboard-1.svg")), "{r}");
assert_eq!(rgba(&b64(&r["files"][0])).dimensions(), (80, 60));
}
#[test]
fn a_300_ppi_export_places_back_at_its_physical_size() {
let mut s = session(72.0, 36.0, 1);
let png = b64(&s.execute("document.export", &json!({"format": "png", "ppi": 300, "background": "white"})).unwrap());
assert_eq!(rgba(&png).dimensions(), (300, 150));
assert_eq!(super::ppi::resolution(&png), Some((300.0, 300.0)), "the place reader reads the writer's pHYs");
let r = s.execute("file.place", &json!({"name": "art.png", "dataBase64": vectorcraft_format::base64_encode(&png)})).unwrap();
assert!((r["width"].as_f64().unwrap() - 72.0).abs() < 1e-6 && (r["height"].as_f64().unwrap() - 36.0).abs() < 1e-6, "100%: {r}");
}
#[test]
fn the_document_background_is_the_default_export_background() {
let mut s = session(40.0, 30.0, 1);
s.execute("document.setup", &json!({"backgroundContents": "white"})).unwrap();
let png = |s: &mut Session, p: Value| rgba(&b64(&s.execute("document.export", &p).unwrap()));
assert_eq!(png(&mut s, json!({"format": "png"})).get_pixel(0, 0).0, [255, 255, 255, 255], "a white document exports opaque");
assert_eq!(png(&mut s, json!({"format": "png", "background": "transparent"})).get_pixel(0, 0)[3], 0, "the option wins");
assert_eq!(png(&mut s, json!({"format": "webp", "background": "black"})).get_pixel(0, 0).0, [0, 0, 0, 255]);
}
#[test]
fn an_image_opens_at_the_size_its_resolution_declares() {
let mut s = session(72.0, 36.0, 1);
let hi = b64(&s.execute("document.export", &json!({"format": "png", "ppi": 300, "background": "white"})).unwrap());
let lo = b64(&s.execute("document.export", &json!({"format": "png", "background": "white"})).unwrap());
let lo = super::ppi::with_png_resolution(&lo, (72.0, 72.0));
for (png, w, h, what) in [(hi, 72.0, 36.0, "300 ppi: 300 x 150 px is 1 x 0.5 in"), (lo, 72.0, 36.0, "72 ppi: 1 px = 1 pt")] {
s.execute("document.open", &json!({"name": "art.png", "dataBase64": vectorcraft_format::base64_encode(&png)})).unwrap();
let doc = &s.doc().unwrap().doc;
let ab = doc.artboards[0].rect;
assert!((ab.width() - w).abs() < 1e-6 && (ab.height() - h).abs() < 1e-6, "{what}: artboard {ab:?}");
let art = doc.art_bounds().expect("the image");
assert!((art.width() - w).abs() < 1e-6 && (art.height() - h).abs() < 1e-6, "{what}: the image fills it, {art:?}");
}
}
#[test]
fn a_raster_export_says_nothing_of_images_another_thread_could_not_decode() {
// While another thread keeps exporting a document whose image can't be decoded (two exports at
// once, or the canvas drawing such a document), PNG exports of art with no image report
// nothing, and every export on the other thread still reports its own image.
use std::sync::atomic::{AtomicBool, Ordering};
use vectorcraft_doc::{ImageBlob, ImageObject, Node, NodeKind};
let mut broken = vectorcraft_doc::Document::new(40.0, 20.0);
broken.images.insert("broken".into(), ImageBlob::new("image/png", b"\x89PNG\r\n\x1a\n".to_vec()));
let im = ImageObject {
key: "broken".into(),
width: 2,
height: 1,
xf: vectorcraft_geom::Affine::scale(20.0),
link: None,
placement: Default::default(),
};
let (id, layer) = (broken.alloc_id(), broken.layers[0].id);
broken.insert(Some(layer), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap();
let mut s = session(400.0, 400.0, 1);
s.execute("shape.ellipse", &json!({"x": 20, "y": 20, "width": 360, "height": 360})).unwrap();
let says_so = |w: &[Value]| w.iter().any(|w| w.as_str().is_some_and(|w| w.contains("couldn't be decoded")));
let stop = AtomicBool::new(false);
let (tx, rx) = std::sync::mpsc::channel();
// Nothing in the scope panics, so the other thread is always told to stop.
let (theirs, ours) = std::thread::scope(|scope| {
let other = scope.spawn(|| {
let mut warned = vec![];
while !stop.load(Ordering::Relaxed) {
let w = encode_with_warnings(&broken, "png", &json!({})).map(|(_, w)| w.into_iter().map(Value::from).collect::<Vec<_>>());
warned.push(w.is_ok_and(|w| says_so(&w)));
let _ = tx.send(());
}
warned
});
let started = rx.recv_timeout(std::time::Duration::from_secs(60)).is_ok();
let ours: Vec<Option<bool>> = (0..5)
.map(|_| s.execute("document.export", &json!({"format": "png"})).ok().map(|r| says_so(r["warnings"].as_array().map_or(&[], |w| w))))
.collect();
stop.store(true, Ordering::Relaxed);
(other.join().ok(), started.then_some(ours))
});
let theirs = theirs.expect("the other thread's exports");
assert!(!theirs.is_empty() && theirs.iter().all(|w| *w), "the other thread's exports: {theirs:?}");
assert_eq!(ours.expect("the other thread started"), vec![Some(false); 5], "exports with no image");
}
#[test]
fn a_raster_export_says_when_an_image_could_not_be_decoded() {
// A PNG whose header reads (so it places at its size) but whose pixel data is cut short:
// the renderer can't decode it and draws nothing, which the export must say.
let mut s = session(72.0, 36.0, 1);
let mut png = Vec::new();
image::RgbaImage::from_pixel(64, 32, image::Rgba([200, 30, 30, 255]))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
let cut = png_chunk(&png, b"IDAT").map(|d| d.as_ptr() as usize - png.as_ptr() as usize).unwrap() + 4;
png.truncate(cut);
s.execute("file.place", &json!({"name": "cut.png", "dataBase64": vectorcraft_format::base64_encode(&png)})).unwrap();
let r = s.execute("document.export", &json!({"format": "png", "background": "transparent"})).unwrap();
let warnings = r["warnings"].as_array().cloned().unwrap_or_default();
assert!(warnings.iter().any(|w| w.as_str().is_some_and(|w| w.contains("couldn't be decoded"))), "{r}");
}
#[test]
fn export_rect_writes_that_page_area_and_leaves_the_document_alone() {
let mut s = session(200.0, 400.0, 1);
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
s.execute("shape.rectangle", &json!({"x": 20, "y": 40, "width": 40, "height": 300})).unwrap();
let boards = s.doc().unwrap().doc.artboards.len();
let r = s.execute("document.export", &json!({"format": "png", "rect": [20, 40, 40, 300], "ppi": 72})).unwrap();
let img = rgba(&b64(&r));
assert_eq!((img.width(), img.height()), (40, 300));
assert_eq!(img.get_pixel(20, 150).0[3], 255, "the strip is all art");
assert_eq!(s.doc().unwrap().doc.artboards.len(), boards, "no helper artboard is left");
// Twice the resolution, and a window that holds both art and empty page.
let img = rgba(&b64(&s.execute("document.export", &json!({"format": "png", "rect": [0.0, 40.0, 40.0, 10.0], "scale": 2})).unwrap()));
assert_eq!((img.width(), img.height()), (80, 20));
assert_eq!((img.get_pixel(10, 10).0[3], img.get_pixel(60, 10).0[3]), (0, 255));
}
#[test]
fn export_rect_refuses_what_cannot_be_exported() {
let mut s = session(200.0, 400.0, 1);
s.execute("shape.rectangle", &json!({"x": 20, "y": 40, "width": 40, "height": 300})).unwrap();
let bad = [
json!({"format": "png", "rect": [0, 0, 0, 10]}),
json!({"format": "png", "rect": [0, 0, -5, 10]}),
json!({"format": "png", "rect": [0, 0, 10]}),
json!({"format": "png", "rect": [0, 0, "a", 10]}),
json!({"format": "png", "rect": "20,40,40,300"}),
json!({"format": "png", "rect": [1e308, 0, 1e308, 10]}),
json!({"format": "png", "rect": [0, 0, 100000, 100000]}),
json!({"format": "png", "rect": [0, 0, 10, 10], "artboard": 0}),
json!({"format": "png", "rect": [0, 0, 10, 10], "useArtboards": true}),
json!({"format": "svg", "rect": [0, 0, 10, 10]}),
];
for p in bad {
assert!(s.execute("document.export", &p).is_err(), "{p}");
}
}