#[path = "../src/test_utils.rs"]
mod test_utils;
use test_utils::{distance_rms, generate_gradient_d, get_test_data_path, read_test_data};
use jpegli::{
decoder::Decoder,
encoder::{ChromaSubsampling, EncoderConfig, PixelLayout},
};
fn encode_rgb(
width: u32,
height: u32,
data: &[u8],
config: &EncoderConfig,
) -> jpegli::encoder::Result<Vec<u8>> {
let mut enc = config.encode_from_bytes(width, height, PixelLayout::Rgb8Srgb)?;
enc.push_packed(data, enough::Unstoppable)?;
enc.finish()
}
fn load_png(filename: &str) -> Option<(u32, u32, Vec<u8>)> {
let path = get_test_data_path(filename);
if !path.exists() {
return None;
}
let decoder = png::Decoder::new(std::fs::File::open(&path).ok()?);
let mut reader = decoder.read_info().ok()?;
let mut buf = vec![0; reader.output_buffer_size()];
let info = reader.next_frame(&mut buf).ok()?;
let pixels = match info.color_type {
png::ColorType::Rgb => buf[..info.buffer_size()].to_vec(),
png::ColorType::Rgba => {
buf[..info.buffer_size()]
.chunks(4)
.flat_map(|c| [c[0], c[1], c[2]])
.collect()
}
png::ColorType::Grayscale => {
buf[..info.buffer_size()]
.iter()
.flat_map(|&g| [g, g, g])
.collect()
}
_ => return None,
};
Some((info.width, info.height, pixels))
}
fn decode_test_jpeg(filename: &str) -> Option<(u32, u32, Vec<u8>)> {
let data = read_test_data(filename)?;
let decoder = Decoder::new();
let decoded = decoder.decode(&data).ok()?;
Some((decoded.width, decoded.height, decoded.data))
}
#[test]
fn test_file_size_parity_synthetic() {
let img = generate_gradient_d(256, 256, 3);
let config = EncoderConfig::ycbcr(85.0, ChromaSubsampling::Quarter);
let jpeg = encode_rgb(256, 256, &img.pixels, &config).expect("encode failed");
let min_expected = 500; let max_expected = 50000;
println!("Rust Q85 256x256 gradient: {} bytes", jpeg.len());
assert!(
jpeg.len() >= min_expected && jpeg.len() <= max_expected,
"File size {} outside expected range {}-{}",
jpeg.len(),
min_expected,
max_expected
);
}
#[test]
fn test_file_size_scaling() {
let img = generate_gradient_d(256, 256, 3);
let sizes: Vec<(f32, usize)> = [50.0, 70.0, 85.0, 95.0]
.iter()
.map(|&q| {
let config = EncoderConfig::ycbcr(q, ChromaSubsampling::Quarter);
(q, encode_rgb(256, 256, &img.pixels, &config).unwrap().len())
})
.collect();
println!("File sizes by quality:");
for (q, size) in &sizes {
println!(" Q{}: {} bytes", q, size);
}
for i in 1..sizes.len() {
assert!(
sizes[i].1 >= sizes[i - 1].1 * 8 / 10,
"Q{} should be >= Q{} size",
sizes[i].0,
sizes[i - 1].0
);
}
}
#[test]
#[ignore = "requires testdata"]
fn test_decode_cpp_flower_420() {
if let Some((width, height, pixels)) = decode_test_jpeg("jxl/flower/flower.png.im_q85_420.jpg")
{
println!("Decoded flower 420: {}x{}", width, height);
assert_eq!(width, 2268);
assert_eq!(height, 1512);
assert_eq!(pixels.len(), 2268 * 1512 * 3);
} else {
eprintln!("Skipping: testdata not available");
}
}
#[test]
#[ignore = "requires testdata"]
fn test_decode_cpp_flower_444() {
if let Some((width, height, pixels)) = decode_test_jpeg("jxl/flower/flower.png.im_q85_444.jpg")
{
println!("Decoded flower 444: {}x{}", width, height);
assert_eq!(width, 2268);
assert_eq!(height, 1512);
assert_eq!(pixels.len(), 2268 * 1512 * 3);
} else {
eprintln!("Skipping: testdata not available");
}
}
#[test]
#[ignore = "requires testdata"]
fn test_decode_cpp_flower_progressive() {
if let Some((width, height, _)) = decode_test_jpeg("jxl/flower/flower.png.im_q85_420_progr.jpg")
{
println!("Decoded progressive flower: {}x{}", width, height);
assert_eq!(width, 2268);
assert_eq!(height, 1512);
} else {
eprintln!("Skipping: testdata not available");
}
}
#[test]
#[ignore = "requires testdata"]
fn test_quality_vs_cpp_decoded() {
let png_result = load_png("jxl/flower/flower.png");
if png_result.is_none() {
eprintln!("Skipping: PNG testdata not available");
return;
}
let (width, height, original) = png_result.unwrap();
let cpp_decoded = decode_test_jpeg("jxl/flower/flower.png.im_q85_444.jpg");
if cpp_decoded.is_none() {
eprintln!("Skipping: JPEG testdata not available");
return;
}
let (_, _, cpp_pixels) = cpp_decoded.unwrap();
let config = EncoderConfig::ycbcr(85.0, ChromaSubsampling::Quarter);
let rust_jpeg = encode_rgb(width, height, &original, &config).expect("Rust encode failed");
let decoder = Decoder::new();
let rust_decoded = decoder.decode(&rust_jpeg).expect("Rust decode failed");
let cpp_rms = distance_rms(&original, &cpp_pixels);
let rust_rms = distance_rms(&original, &rust_decoded.data);
println!("Quality comparison vs original:");
println!(" C++ Q85: RMS = {:.4}", cpp_rms);
println!(" Rust Q85: RMS = {:.4}", rust_rms);
assert!(
rust_rms < cpp_rms * 2.0,
"Rust quality significantly worse than C++"
);
}
fn count_markers(jpeg: &[u8], marker: u8) -> usize {
jpeg.windows(2)
.filter(|w| w[0] == 0xFF && w[1] == marker)
.count()
}
#[test]
fn test_marker_structure() {
let img = generate_gradient_d(128, 128, 3);
let config = EncoderConfig::ycbcr(85.0, ChromaSubsampling::Quarter);
let jpeg = encode_rgb(128, 128, &img.pixels, &config).expect("encode failed");
assert!(jpeg.starts_with(&[0xFF, 0xD8]), "Missing SOI");
assert!(jpeg.ends_with(&[0xFF, 0xD9]), "Missing EOI");
let app0_count = count_markers(&jpeg, 0xE0);
let dqt_count = count_markers(&jpeg, 0xDB);
let sof0_count = count_markers(&jpeg, 0xC0);
let sof1_count = count_markers(&jpeg, 0xC1);
let sof2_count = count_markers(&jpeg, 0xC2);
let dht_count = count_markers(&jpeg, 0xC4);
let sos_count = count_markers(&jpeg, 0xDA);
println!("Marker counts:");
println!(" APP0 (JFIF): {}", app0_count);
println!(" DQT: {}", dqt_count);
println!(" SOF0 (baseline): {}", sof0_count);
println!(" SOF1 (extended): {}", sof1_count);
println!(" SOF2 (progressive): {}", sof2_count);
println!(" DHT: {}", dht_count);
println!(" SOS: {}", sos_count);
assert_eq!(
app0_count, 0,
"Should NOT have JFIF marker (matches C++ jpegli)"
);
assert!(dqt_count >= 1, "Should have DQT marker");
assert!(
sof0_count >= 1 || sof1_count >= 1,
"Should have SOF0 or SOF1 marker (sequential encoding)"
);
assert!(dht_count >= 1, "Should have DHT marker");
assert_eq!(sos_count, 1, "Baseline should have exactly 1 SOS");
}
#[test]
fn test_progressive_marker_structure() {
let img = generate_gradient_d(128, 128, 3);
let config = EncoderConfig::ycbcr(85.0, ChromaSubsampling::Quarter).progressive(true);
let jpeg = encode_rgb(128, 128, &img.pixels, &config).expect("encode failed");
let sof2_count = count_markers(&jpeg, 0xC2);
let sos_count = count_markers(&jpeg, 0xDA);
println!("Progressive marker counts:");
println!(" SOF2: {}", sof2_count);
println!(" SOS: {}", sos_count);
assert_eq!(sof2_count, 1, "Progressive should have SOF2");
assert!(sos_count > 1, "Progressive should have multiple SOS");
}
fn extract_dqt_table(jpeg: &[u8], table_id: u8) -> Option<Vec<u8>> {
let mut pos = 0;
while pos + 4 < jpeg.len() {
if jpeg[pos] == 0xFF && jpeg[pos + 1] == 0xDB {
let length = ((jpeg[pos + 2] as usize) << 8) | (jpeg[pos + 3] as usize);
let table_start = pos + 4;
let mut offset = 0;
while offset < length - 2 {
let pq_tq = jpeg[table_start + offset];
let precision = (pq_tq >> 4) & 0x0F;
let id = pq_tq & 0x0F;
let table_size = if precision == 0 { 64 } else { 128 };
if id == table_id {
let start = table_start + offset + 1;
let end = start + table_size.min(jpeg.len() - start);
return Some(jpeg[start..end].to_vec());
}
offset += 1 + table_size;
}
pos += 2 + length;
} else {
pos += 1;
}
}
None
}
#[test]
fn test_quant_tables_present() {
let img = generate_gradient_d(64, 64, 3);
let config = EncoderConfig::ycbcr(85.0, ChromaSubsampling::Quarter);
let jpeg = encode_rgb(64, 64, &img.pixels, &config).expect("encode failed");
let table0 = extract_dqt_table(&jpeg, 0);
let table1 = extract_dqt_table(&jpeg, 1);
assert!(table0.is_some(), "Should have quant table 0 (luma)");
assert!(table1.is_some(), "Should have quant table 1 (chroma)");
let len0 = table0.unwrap().len();
let len1 = table1.unwrap().len();
assert!(
len0 == 64 || len0 == 128,
"Luma table should be 64 or 128 bytes, got {}",
len0
);
assert!(
len1 == 64 || len1 == 128,
"Chroma table should be 64 or 128 bytes, got {}",
len1
);
}
#[test]
fn test_quant_tables_vary_with_quality() {
let img = generate_gradient_d(64, 64, 3);
let config50 = EncoderConfig::ycbcr(50.0, ChromaSubsampling::Quarter);
let q50_jpeg = encode_rgb(64, 64, &img.pixels, &config50).expect("encode Q50 failed");
let config95 = EncoderConfig::ycbcr(95.0, ChromaSubsampling::Quarter);
let q95_jpeg = encode_rgb(64, 64, &img.pixels, &config95).expect("encode Q95 failed");
let q50_table = extract_dqt_table(&q50_jpeg, 0).unwrap();
let q95_table = extract_dqt_table(&q95_jpeg, 0).unwrap();
let q50_sum: u32 = q50_table.iter().map(|&x| x as u32).sum();
let q95_sum: u32 = q95_table.iter().map(|&x| x as u32).sum();
println!("Q50 table sum: {}", q50_sum);
println!("Q95 table sum: {}", q95_sum);
assert!(
q95_sum < q50_sum,
"Q95 should have smaller quant values than Q50"
);
}
#[test]
fn test_jpeg_decoder_compatibility() {
let img = generate_gradient_d(128, 128, 3);
let config = EncoderConfig::ycbcr(90.0, ChromaSubsampling::Quarter);
let jpeg = encode_rgb(128, 128, &img.pixels, &config).expect("encode failed");
let mut decoder =
zune_jpeg::JpegDecoder::new(zune_jpeg::zune_core::bytestream::ZCursor::new(&jpeg[..]));
let decoded = decoder.decode().expect("jpeg-decoder failed");
let (dec_width, dec_height) = decoder.dimensions().unwrap();
assert_eq!(dec_width, 128);
assert_eq!(dec_height, 128);
assert_eq!(decoded.len(), 128 * 128 * 3);
}
#[test]
fn test_zune_jpeg_compatibility() {
let img = generate_gradient_d(128, 128, 3);
let config = EncoderConfig::ycbcr(90.0, ChromaSubsampling::Quarter);
let jpeg = encode_rgb(128, 128, &img.pixels, &config).expect("encode failed");
use zune_jpeg::zune_core::bytestream::ZCursor;
let cursor = ZCursor::new(&jpeg);
let mut decoder = zune_jpeg::JpegDecoder::new(cursor);
let decoded = decoder.decode().expect("zune-jpeg failed");
let (dec_width, dec_height) = decoder.dimensions().unwrap();
assert_eq!(dec_width as u32, 128);
assert_eq!(dec_height as u32, 128);
assert!(!decoded.is_empty());
}
fn count_dht_tables(jpeg: &[u8]) -> (usize, usize) {
let mut dc_count = 0;
let mut ac_count = 0;
let mut pos = 0;
while pos + 4 < jpeg.len() {
if jpeg[pos] == 0xFF && jpeg[pos + 1] == 0xC4 {
let length = ((jpeg[pos + 2] as usize) << 8) | (jpeg[pos + 3] as usize);
let mut offset = 0;
while offset < length - 2 {
let tc_th = jpeg[pos + 4 + offset];
let tc = (tc_th >> 4) & 0x0F;
if tc == 0 {
dc_count += 1;
} else {
ac_count += 1;
}
let mut table_size = 0;
for i in 0..16 {
if pos + 5 + offset + i < jpeg.len() {
table_size += jpeg[pos + 5 + offset + i] as usize;
}
}
offset += 1 + 16 + table_size;
}
pos += 2 + length;
} else {
pos += 1;
}
}
(dc_count, ac_count)
}
#[test]
fn test_huffman_tables_present() {
let img = generate_gradient_d(64, 64, 3);
let config = EncoderConfig::ycbcr(85.0, ChromaSubsampling::Quarter);
let jpeg = encode_rgb(64, 64, &img.pixels, &config).expect("encode failed");
let (dc_count, ac_count) = count_dht_tables(&jpeg);
println!("Huffman tables: {} DC, {} AC", dc_count, ac_count);
assert!(dc_count >= 2, "Should have at least 2 DC tables");
assert!(ac_count >= 2, "Should have at least 2 AC tables");
}
fn extract_sof_params(jpeg: &[u8]) -> Option<(u8, u16, u16, u8)> {
for pos in 0..jpeg.len() - 10 {
if jpeg[pos] == 0xFF
&& (jpeg[pos + 1] == 0xC0 || jpeg[pos + 1] == 0xC1 || jpeg[pos + 1] == 0xC2)
{
let precision = jpeg[pos + 4];
let height = ((jpeg[pos + 5] as u16) << 8) | (jpeg[pos + 6] as u16);
let width = ((jpeg[pos + 7] as u16) << 8) | (jpeg[pos + 8] as u16);
let components = jpeg[pos + 9];
return Some((precision, height, width, components));
}
}
None
}
#[test]
fn test_sof_parameters() {
let img = generate_gradient_d(320, 240, 3);
let config = EncoderConfig::ycbcr(85.0, ChromaSubsampling::Quarter);
let jpeg = encode_rgb(320, 240, &img.pixels, &config).expect("encode failed");
let (precision, height, width, components) = extract_sof_params(&jpeg).expect("SOF not found");
assert_eq!(precision, 8, "Should be 8-bit precision");
assert_eq!(width, 320, "Width mismatch in SOF");
assert_eq!(height, 240, "Height mismatch in SOF");
assert_eq!(components, 3, "Should have 3 components for RGB");
}