Files
vectorcraft/crates/render/src/encode/tests_jpeg.rs
T
bflatasticandClaude Opus 5.5 ba4ed2ff93 M4.48: JPEG Options — RGB/CMYK/gray, baseline/optimized/progressive, embedded ICC profiles and image maps
document.export {format: "jpg"} takes colorModel (rgb|cmyk|gray), quality 0–100, method
(baseline|optimized|progressive), scans (3–5), embedIcc and imageMap (none|client|server).
JPEGs are written with jpeg-encoder. CMYK files hold ink amounts drawn as the renderer's ink
planes, so CMYK colours keep their inks (black type stays black ink alone). Profiles are made
in code from the CMS (cms::icc_bytes): the RGB spaces from their primaries, the CMYK spaces as
Lab lookup tables sampled from their model, a grey with sRGB's tone curve, and loaded
profiles as loaded. Image maps write <stem>.html or <stem>.map beside the image for objects
with a URL and an Image Map shape. The JPEG Options dialog gains Color Model, Quality 0–10,
Method and Scans, Image Map and Embed ICC Profile; CMYK documents default to CMYK.

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

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