192 lines
8.7 KiB
Rust
192 lines
8.7 KiB
Rust
use vectorcraft_color::{Color, Paint};
|
||
use vectorcraft_doc::{Appearance, CharStyle, Node, NodeKind, TextObject};
|
||
use vectorcraft_geom::{Point, shapes};
|
||
|
||
use super::png::{PngOptions, encode, pixels_per_metre};
|
||
use super::*;
|
||
|
||
/// A chunk's data by type (first match).
|
||
fn chunk_data<'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
|
||
}
|
||
|
||
/// Deterministic straight-alpha noise with some fully transparent and some opaque pixels.
|
||
fn noise(w: u32, h: u32, opaque: bool) -> Vec<u8> {
|
||
let mut x: u32 = 0x1234_5678;
|
||
(0..w * h * 4)
|
||
.map(|i| {
|
||
x ^= x << 13;
|
||
x ^= x >> 17;
|
||
x ^= x << 5;
|
||
if i % 4 == 3 && opaque { 255 } else { (x >> 24) as u8 }
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
fn decode(png: &[u8]) -> Vec<u8> {
|
||
image::load_from_memory_with_format(png, image::ImageFormat::Png).expect("decodes").to_rgba8().into_raw()
|
||
}
|
||
|
||
#[test]
|
||
fn png_round_trips_plain_and_interlaced_at_odd_sizes() {
|
||
for (w, h) in [(1, 1), (3, 5), (9, 9), (17, 2), (8, 8), (33, 31)] {
|
||
for opaque in [false, true] {
|
||
let px = noise(w, h, opaque);
|
||
for interlaced in [false, true] {
|
||
let file = encode(&px, w, h, &PngOptions { ppi: None, interlaced }).unwrap();
|
||
assert_eq!(&file[..8], b"\x89PNG\r\n\x1a\n");
|
||
assert_eq!(file[28], u8::from(interlaced), "IHDR interlace method");
|
||
assert_eq!(file[25], if opaque { 2 } else { 6 }, "opaque images are written as RGB");
|
||
assert_eq!(decode(&file), px, "{w}×{h} opaque={opaque} interlaced={interlaced}");
|
||
assert!(chunk_data(&file, b"pHYs").is_none());
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn phys_holds_pixels_per_metre() {
|
||
let file = encode(&noise(2, 2, true), 2, 2, &PngOptions { ppi: Some(300.0), interlaced: false }).unwrap();
|
||
let phys = chunk_data(&file, b"pHYs").expect("pHYs");
|
||
let ppm = (300.0f64 / 0.0254).round() as u32;
|
||
assert_eq!(ppm, pixels_per_metre(300.0));
|
||
assert_eq!(phys, [ppm.to_be_bytes().as_slice(), ppm.to_be_bytes().as_slice(), &[1]].concat());
|
||
}
|
||
|
||
#[test]
|
||
fn png_refuses_a_buffer_of_the_wrong_size() {
|
||
assert!(encode(&[0; 12], 2, 2, &PngOptions::default()).is_err());
|
||
assert!(encode(&[], 0, 0, &PngOptions::default()).is_err());
|
||
}
|
||
|
||
/// A 40×30 pt document with a black circle off the pixel grid.
|
||
fn circle_doc() -> Document {
|
||
let mut d = Document::new(40.0, 30.0);
|
||
let id = d.alloc_id();
|
||
let n = Node::path(id, shapes::ellipse(Rect::new(5.3, 4.7, 31.1, 26.2)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0));
|
||
let l = d.layers[0].id;
|
||
d.insert(Some(l), 0, n).unwrap();
|
||
d
|
||
}
|
||
|
||
fn export(d: &Document, format: RasterFormat, o: &RasterExportOptions) -> image::RgbaImage {
|
||
let bytes = Renderer::new().export_region(d, d.artboards[0].rect, format, o).unwrap();
|
||
image::load_from_memory(&bytes).unwrap().to_rgba8()
|
||
}
|
||
|
||
#[test]
|
||
fn resolution_sets_the_pixel_size_and_is_stored() {
|
||
let d = circle_doc();
|
||
assert_eq!(export(&d, RasterFormat::Png, &RasterExportOptions::default()).dimensions(), (40, 30));
|
||
let o = RasterExportOptions { ppi: 144.0, ..Default::default() };
|
||
assert_eq!(export(&d, RasterFormat::Png, &o).dimensions(), (80, 60), "144 ppi doubles the size");
|
||
let png = Renderer::new().export_region(&d, d.artboards[0].rect, RasterFormat::Png, &o).unwrap();
|
||
assert_eq!(&chunk_data(&png, b"pHYs").unwrap()[..4], pixels_per_metre(144.0).to_be_bytes());
|
||
let jpg = Renderer::new().export_region(&d, d.artboards[0].rect, RasterFormat::Jpeg, &o).unwrap();
|
||
// JFIF APP0: units 1 (dots per inch), then the x and y density.
|
||
let app0 = jpg.windows(5).position(|w| w == b"JFIF\0").unwrap();
|
||
assert_eq!(&jpg[app0 + 7..app0 + 12], [1, 0, 144, 0, 144]);
|
||
}
|
||
|
||
#[test]
|
||
fn backgrounds_are_opaque_or_transparent() {
|
||
let d = circle_doc();
|
||
let transparent = export(&d, RasterFormat::Png, &RasterExportOptions::default());
|
||
assert_eq!(transparent.get_pixel(0, 0)[3], 0);
|
||
for (bg, want) in [([255, 255, 255], [255, 255, 255, 255]), ([0, 0, 0], [0, 0, 0, 255]), ([30, 120, 200], [30, 120, 200, 255])] {
|
||
let img = export(&d, RasterFormat::Png, &RasterExportOptions { background: Some(bg), ..Default::default() });
|
||
assert!(img.pixels().all(|p| p[3] == 255), "no alpha below 255 on {bg:?}");
|
||
assert_eq!(img.get_pixel(0, 0).0, want);
|
||
}
|
||
let webp = export(&d, RasterFormat::WebP, &RasterExportOptions { background: Some([0, 0, 0]), ..Default::default() });
|
||
assert_eq!(webp.get_pixel(0, 0).0, [0, 0, 0, 255]);
|
||
}
|
||
|
||
#[test]
|
||
fn interlaced_export_decodes_to_the_same_pixels() {
|
||
let d = circle_doc();
|
||
let plain = Renderer::new().export_region(&d, d.artboards[0].rect, RasterFormat::Png, &RasterExportOptions::default()).unwrap();
|
||
let o = RasterExportOptions { interlaced: true, ..Default::default() };
|
||
let laced = Renderer::new().export_region(&d, d.artboards[0].rect, RasterFormat::Png, &o).unwrap();
|
||
assert_eq!((plain[28], laced[28]), (0, 1));
|
||
assert_eq!(decode(&plain), decode(&laced));
|
||
}
|
||
|
||
#[test]
|
||
fn anti_alias_none_paints_whole_pixels() {
|
||
let d = circle_doc();
|
||
let soft = export(&d, RasterFormat::Png, &RasterExportOptions::default());
|
||
assert!(soft.pixels().any(|p| p[3] != 0 && p[3] != 255), "art anti-aliasing has partial edge pixels");
|
||
let hard = export(&d, RasterFormat::Png, &RasterExportOptions { anti_alias: AntiAlias::None, ..Default::default() });
|
||
assert!(hard.pixels().all(|p| p[3] == 0 || p[3] == 255), "no anti-aliasing: every alpha is 0 or 255");
|
||
let covered = |img: &image::RgbaImage| img.pixels().filter(|p| p[3] >= 128).count() as i64;
|
||
assert!((covered(&hard) - covered(&soft)).abs() <= 8, "the same shape, only harder edges");
|
||
}
|
||
|
||
#[test]
|
||
fn type_anti_aliasing_snaps_text_to_pixels() {
|
||
let mut d = Document::new(80.0, 30.0);
|
||
let id = d.alloc_id();
|
||
let t = TextObject::point(Point::new(4.37, 18.61), "Hill", CharStyle { size: 12.0, ..CharStyle::default() });
|
||
let n = Node::new(id, NodeKind::Text(Box::new(t)));
|
||
let l = d.layers[0].id;
|
||
d.insert(Some(l), 0, n).unwrap();
|
||
let art = export(&d, RasterFormat::Png, &RasterExportOptions::default());
|
||
let typ = export(&d, RasterFormat::Png, &RasterExportOptions { anti_alias: AntiAlias::Type, ..Default::default() });
|
||
assert!(art.pixels().any(|p| p[3] > 0), "the text is drawn");
|
||
assert_ne!(art, typ, "type moves to whole pixels");
|
||
// Snapping moves glyphs by under a pixel: the ink stays where it was.
|
||
let ink = |img: &image::RgbaImage| img.pixels().map(|p| p[3] as u64).sum::<u64>() as f64;
|
||
assert!((ink(&typ) / ink(&art) - 1.0).abs() < 0.1);
|
||
}
|
||
|
||
#[test]
|
||
fn anti_alias_none_is_the_same_on_every_thread_count() {
|
||
// Glows are filtered offscreen when the renderer is multithreaded: their clip must be hard too.
|
||
let mut d = Document::new(80.0, 80.0);
|
||
let id = d.alloc_id();
|
||
let mut n = Node::path(id, shapes::rectangle(Rect::new(20.5, 20.3, 60.4, 59.6)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0));
|
||
let glow = serde_json::json!({"color": "#ffffff", "mode": "normal", "opacity": 100, "blur": 4});
|
||
n.appearance.effects = vec![vectorcraft_doc::Effect { id: "stylize.innerGlow".into(), params: glow, visible: true }];
|
||
let l = d.layers[0].id;
|
||
d.insert(Some(l), 0, n).unwrap();
|
||
let opts = RenderOptions { anti_alias: AntiAlias::None, ..Default::default() };
|
||
let render = |threads: u16| {
|
||
let mut r = Renderer::new();
|
||
r.threads = threads;
|
||
r.render_region_with(&d, d.artboards[0].rect, 1.0, &opts)
|
||
};
|
||
let (st, mt) = (render(0), render(3));
|
||
for img in [&st, &mt] {
|
||
assert!(img.to_straight().as_chunks::<4>().0.iter().all(|p| p[3] == 0 || p[3] == 255), "hard edges");
|
||
}
|
||
let worst = st.pixels.iter().zip(&mt.pixels).map(|(a, b)| a.abs_diff(*b)).max().unwrap();
|
||
assert!(worst <= 4, "max channel difference {worst}");
|
||
}
|
||
|
||
#[test]
|
||
fn art_bounds_cover_visible_art_only() {
|
||
let mut d = circle_doc();
|
||
let l = d.layers[0].id;
|
||
let hidden = {
|
||
let id = d.alloc_id();
|
||
let mut n =
|
||
Node::path(id, shapes::rectangle(Rect::new(100.0, 100.0, 120.0, 120.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0));
|
||
n.visible = false;
|
||
n
|
||
};
|
||
d.insert(Some(l), 1, hidden).unwrap();
|
||
let b = art_bounds(&d).unwrap();
|
||
assert!((b.x0 - 5.3).abs() < 1e-6 && (b.x1 - 31.1).abs() < 1e-6 && (b.y1 - 26.2).abs() < 1e-6, "{b:?}");
|
||
let empty = Document::new(10.0, 10.0);
|
||
assert_eq!(art_bounds(&empty), None);
|
||
}
|