Files
vectorcraft/crates/render/src/encode/tests.rs
T
bflatasticandClaude Opus 5.5 950a941003 M4.33: Export As with Use Artboards (All or a range) and art bounds
File > Export > Export As... (file.exportAs) picks any writable format and Use Artboards: All
or a range writes one file per artboard, <file>-<artboard>.<ext> (a PDF keeps them as pages);
off, the export covers the bounds of the visible art (an artless document is refused before the
save dialog). The format's options dialog follows: PNG/JPEG/WebP Options, SVG Options, or Save
PDF for artboards as pages. document.export takes useArtboards for raster formats (true: one
file per chosen artboard, default all) and PDF (false: the art's bounds), and the app's exports
return every file written and the encoder's warnings.

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

192 lines
8.6 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.
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().chunks_exact(4).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);
}