vectorcraft/crates/render/src/encode/tests_jpeg.rs

157 lines
7.0 KiB
Rust
Raw Permalink 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::cms::{self, IccProfile, ProfileKind};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, Node};
use vectorcraft_geom::shapes;
use super::jpeg::{self, ColorModel, JpegOptions, Method};
use super::*;
/// The segments before the image data: (marker, payload).
fn segments(file: &[u8]) -> Vec<(u8, &[u8])> {
let mut out = vec![];
let mut i = 2;
while i + 4 <= file.len() && file[i] == 0xFF {
let marker = file[i + 1];
let len = u16::from_be_bytes([file[i + 2], file[i + 3]]) as usize;
out.push((marker, &file[i + 4..i + 2 + len]));
if marker == 0xDA {
break;
}
i += 2 + len;
}
out
}
/// The frame header (SOF0/SOF2…): (marker, component count).
fn frame(file: &[u8]) -> (u8, u8) {
let (m, p) = segments(file).into_iter().find(|(m, _)| (0xC0..=0xC2).contains(m)).expect("SOF");
(m, p[5])
}
/// Every APP2 ICC chunk joined in order.
fn icc(file: &[u8]) -> Option<Vec<u8>> {
let chunks: Vec<&[u8]> =
segments(file).into_iter().filter(|(m, p)| *m == 0xE2 && p.starts_with(b"ICC_PROFILE\0")).map(|(_, p)| &p[14..]).collect();
(!chunks.is_empty()).then(|| chunks.concat())
}
fn noise(w: u32, h: u32, channels: usize) -> Vec<u8> {
let mut x: u32 = 0x9e37_79b9;
(0..w as usize * h as usize * channels)
.map(|i| {
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
// Smooth ramps plus a little noise, as photos and gradients are.
((i / channels) as u32 % w * 255 / w) as u8 ^ (x >> 29) as u8
})
.collect()
}
#[test]
fn size_falls_with_quality() {
let px = noise(64, 48, 3);
let size = |q| jpeg::encode(&px, 64, 48, q, None, &JpegOptions::default()).unwrap().len();
assert!(size(10) < size(50) && size(50) < size(95), "{} {} {}", size(10), size(50), size(95));
assert_eq!(size(0), size(1), "quality 0 is the lowest the coder has");
}
#[test]
fn methods_write_baseline_or_progressive_frames() {
let px = noise(40, 30, 3);
let enc = |method, scans| jpeg::encode(&px, 40, 30, 80, None, &JpegOptions { method, scans, ..Default::default() }).unwrap();
let sos = |f: &[u8]| f.windows(2).filter(|w| w == &[0xFF, 0xDA]).count();
let base = enc(Method::Baseline, 3);
assert_eq!((frame(&base).0, sos(&base)), (0xC0, 1));
let opt = enc(Method::Optimized, 3);
assert_eq!(frame(&opt).0, 0xC0, "optimized tables are still baseline");
assert!(opt.len() < base.len(), "optimized tables are smaller");
let (p3, p5) = (enc(Method::Progressive, 3), enc(Method::Progressive, 5));
assert_eq!(frame(&p3).0, 0xC2, "SOF2");
assert!(sos(&p3) > 1 && sos(&p5) > sos(&p3), "more scans: {} {}", sos(&p3), sos(&p5));
for f in [&base, &p3, &p5] {
let back = image::load_from_memory(f).unwrap();
assert_eq!((back.width(), back.height()), (40, 30));
}
}
#[test]
fn colour_models_write_their_components_and_profiles() {
for (model, comps, kind) in
[(ColorModel::Rgb, 3, ProfileKind::Rgb), (ColorModel::Cmyk, 4, ProfileKind::Cmyk), (ColorModel::Gray, 1, ProfileKind::Gray)]
{
let o = JpegOptions { color_model: model, ..Default::default() };
let file = jpeg::encode(&noise(16, 8, model.channels()), 16, 8, 90, Some(150.0), &o).unwrap();
assert_eq!(frame(&file).1, comps, "{model:?}");
let p = IccProfile::from_bytes(None, &icc(&file).expect("APP2 ICC")).unwrap();
assert_eq!(p.kind, kind, "{model:?}");
let app0 = segments(&file).into_iter().find(|(m, _)| *m == 0xE0).unwrap().1;
assert_eq!(&app0[..5], b"JFIF\0");
assert_eq!(app0[7..12], [1, 0, 150, 0, 150], "dots per inch");
let none = jpeg::encode(&noise(16, 8, model.channels()), 16, 8, 90, None, &JpegOptions { embed_icc: false, ..o }).unwrap();
assert!(icc(&none).is_none());
}
// The CMYK file carries the working CMYK space's profile.
let o = JpegOptions { color_model: ColorModel::Cmyk, ..Default::default() };
let file = jpeg::encode(&[0; 4], 1, 1, 90, None, &o).unwrap();
assert_eq!(&icc(&file).unwrap()[..], &cms::icc_bytes(&cms::active_settings().cmyk).unwrap()[..]);
assert!(jpeg::encode(&[0; 3], 70_000, 1, 90, None, &JpegOptions::default()).is_err(), "too wide");
assert!(jpeg::encode(&[0; 2], 1, 1, 90, None, &JpegOptions::default()).is_err(), "short buffer");
}
#[test]
fn model_and_method_ids_round_trip() {
for m in ColorModel::ALL {
assert_eq!(ColorModel::from_id(m.id()), Some(m));
assert_eq!(ColorModel::from_id(m.label()), Some(m));
}
assert_eq!(ColorModel::from_id("grey"), Some(ColorModel::Gray));
for m in Method::ALL {
assert_eq!(Method::from_id(m.id()), Some(m));
}
assert_eq!(Method::from_id("bogus"), None);
}
/// A 20×10 pt document: a black-ink rectangle on the left half, an RGB red one on the right.
fn ink_doc() -> Document {
let mut d = Document::new(20.0, 10.0);
let l = d.layers[0].id;
let black = Paint::solid(Color::Cmyk { c: 0.0, m: 0.0, y: 0.0, k: 1.0 });
let red = Paint::solid(Color::Rgb { r: 1.0, g: 0.0, b: 0.0 });
for (rect, paint) in [(Rect::new(0.0, 0.0, 10.0, 10.0), black), (Rect::new(10.0, 0.0, 20.0, 10.0), red)] {
let id = d.alloc_id();
d.insert(Some(l), 0, Node::path(id, shapes::rectangle(rect), Appearance::basic(paint, Paint::None, 0.0))).unwrap();
}
d
}
#[test]
fn cmyk_exports_keep_the_inks_of_cmyk_colours() {
let d = ink_doc();
let o = RasterExportOptions { jpeg: JpegOptions { color_model: ColorModel::Cmyk, ..Default::default() }, ..Default::default() };
let inks = Renderer::new().render_region_inks(&d, d.artboards[0].rect, 1.0, &o.render_options(RasterFormat::Jpeg));
assert_eq!(inks.len(), 20 * 10 * 4);
let at = |x: usize, y: usize| &inks[(y * 20 + x) * 4..][..4];
assert_eq!(at(3, 5), [0, 0, 0, 255], "black ink alone, not a rich black");
let red = at(15, 5);
assert!(red[1] > 200 && red[2] > 200 && red[0] < 40, "RGB red separates to magenta and yellow: {red:?}");
let file = Renderer::new().export_region(&d, d.artboards[0].rect, RasterFormat::Jpeg, &o).unwrap();
assert_eq!(frame(&file).1, 4, "4 components");
let back = image::load_from_memory(&file).unwrap().to_rgb8();
assert!(back.get_pixel(3, 5).0.iter().all(|v| *v < 60), "black reads back dark: {:?}", back.get_pixel(3, 5));
let r = back.get_pixel(15, 5).0;
assert!(r[0] > 180 && r[1] < 90, "red reads back red: {r:?}");
}
#[test]
fn gray_exports_keep_the_lightness() {
let d = ink_doc();
let o = RasterExportOptions { jpeg: JpegOptions { color_model: ColorModel::Gray, ..Default::default() }, ..Default::default() };
let file = Renderer::new().export_region(&d, d.artboards[0].rect, RasterFormat::Jpeg, &o).unwrap();
assert_eq!(frame(&file).1, 1);
let back = image::load_from_memory(&file).unwrap().to_luma8();
let red = back.get_pixel(15, 5)[0];
// sRGB red's relative luminance 0.2126, encoded: about 127.
assert!((115..=140).contains(&red), "{red}");
}