157 lines
7.0 KiB
Rust
157 lines
7.0 KiB
Rust
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}");
|
||
}
|