#[doc(hidden)]
pub mod content;
mod fingerprint;
mod quality;
mod reencode;
mod scanner;
pub use fingerprint::EncoderFamily;
#[cfg(test)]
pub(crate) use fingerprint::generate_ijg_table;
pub use quality::{Confidence, QualityEstimate, QualityScale};
pub use reencode::{ReencodeError, ReencodeSettings};
use crate::foundation::consts::{MARKER_SOF0, MARKER_SOF2};
use crate::types::{Dimensions, JpegMode, Subsampling};
use alloc::vec::Vec;
#[derive(Debug, Clone)]
pub struct JpegProbe {
pub encoder: EncoderFamily,
pub quality: QualityEstimate,
pub dimensions: Dimensions,
pub subsampling: Subsampling,
pub mode: JpegMode,
pub num_components: u8,
pub scan_count: u16,
pub dqt_tables: Vec<DqtTable>,
}
#[derive(Debug, Clone)]
pub struct DqtTable {
pub index: u8,
pub values: [u16; 64],
pub precision: u8,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum ProbeError {
TooShort,
NotJpeg,
NoQuantTables,
Truncated,
}
impl core::fmt::Display for ProbeError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::TooShort => write!(f, "data too short to be a JPEG"),
Self::NotJpeg => write!(f, "missing SOI marker, not a JPEG file"),
Self::NoQuantTables => write!(f, "no quantization tables found"),
Self::Truncated => write!(f, "truncated JPEG data"),
}
}
}
impl std::error::Error for ProbeError {}
pub fn probe(data: &[u8]) -> Result<JpegProbe, ProbeError> {
let scan = scanner::scan_headers(data).map_err(|e| match e {
scanner::ScanError::TooShort => ProbeError::TooShort,
scanner::ScanError::NotJpeg => ProbeError::NotJpeg,
scanner::ScanError::Truncated => ProbeError::Truncated,
})?;
if scan.dqt_tables.iter().all(|t| t.is_none()) {
return Err(ProbeError::NoQuantTables);
}
let sof = scan.sof.as_ref();
let dimensions = sof.map_or(Dimensions::new(0, 0), |s| {
Dimensions::new(s.width as u32, s.height as u32)
});
let num_components = sof.map_or(0, |s| s.num_components);
let mode = match sof.map(|s| s.marker) {
Some(MARKER_SOF0) => JpegMode::Baseline,
Some(MARKER_SOF2) => JpegMode::Progressive,
Some(0xC9) => JpegMode::ArithmeticSequential,
Some(0xCA) => JpegMode::ArithmeticProgressive,
_ => JpegMode::Baseline,
};
let subsampling = detect_subsampling(sof);
let encoder = fingerprint::identify_encoder(&scan);
let quality = quality::estimate_quality(&scan, &encoder);
let mut dqt_tables = Vec::new();
for (idx, table) in scan.dqt_tables.iter().enumerate() {
if let Some(t) = table {
dqt_tables.push(DqtTable {
index: idx as u8,
values: t.values,
precision: t.precision,
});
}
}
Ok(JpegProbe {
encoder,
quality,
dimensions,
subsampling,
mode,
num_components,
scan_count: scan.sos_count,
dqt_tables,
})
}
impl zencodec::SourceEncodingDetails for JpegProbe {
fn source_generic_quality(&self) -> Option<f32> {
match self.quality.scale {
QualityScale::IjgQuality | QualityScale::MozjpegQuality => Some(self.quality.value),
QualityScale::ButteraugliDistance => {
Some((100.0 - self.quality.value * 5.0).clamp(0.0, 100.0))
}
}
}
}
fn detect_subsampling(sof: Option<&scanner::SofInfo>) -> Subsampling {
let sof = match sof {
Some(s) => s,
None => return Subsampling::S420,
};
if sof.num_components < 3 {
return Subsampling::S444; }
let (_, luma_h, luma_v, _) = sof.components[0];
let (_, cb_h, cb_v, _) = sof.components[1];
if luma_h == cb_h && luma_v == cb_v {
Subsampling::S444
} else if luma_h == 2 * cb_h && luma_v == 2 * cb_v {
Subsampling::S420
} else if luma_h == 2 * cb_h && luma_v == cb_v {
Subsampling::S422
} else if luma_h == cb_h && luma_v == 2 * cb_v {
Subsampling::S440
} else {
Subsampling::S420 }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::foundation::consts::{
MARKER_APP0, MARKER_DQT, MARKER_EOI, MARKER_SOF0, MARKER_SOF2, MARKER_SOI, MARKER_SOS,
};
#[test]
fn test_probe_error_display() {
assert_eq!(
ProbeError::TooShort.to_string(),
"data too short to be a JPEG"
);
assert_eq!(
ProbeError::NotJpeg.to_string(),
"missing SOI marker, not a JPEG file"
);
}
#[test]
fn test_probe_too_short() {
assert_eq!(probe(&[]).unwrap_err(), ProbeError::TooShort);
assert_eq!(probe(&[0xFF]).unwrap_err(), ProbeError::TooShort);
assert_eq!(probe(&[0xFF, 0xD8]).unwrap_err(), ProbeError::NoQuantTables);
}
#[test]
fn test_probe_not_jpeg() {
assert_eq!(
probe(&[0x00, 0x00, 0x00, 0x00]).unwrap_err(),
ProbeError::NotJpeg
);
assert_eq!(probe(b"PNG\r\n").unwrap_err(), ProbeError::NotJpeg);
}
#[test]
fn test_probe_minimal_jpeg() {
let jpeg = build_minimal_jpeg(75, false);
let result = probe(&jpeg).unwrap();
assert_eq!(result.encoder, EncoderFamily::LibjpegTurbo);
assert_eq!(result.quality.value, 75.0);
assert_eq!(result.quality.confidence, Confidence::Exact);
assert_eq!(result.quality.scale, QualityScale::IjgQuality);
assert_eq!(result.dimensions.width, 8);
assert_eq!(result.dimensions.height, 8);
assert_eq!(result.mode, JpegMode::Baseline);
assert_eq!(result.num_components, 3);
assert_eq!(result.scan_count, 1);
assert_eq!(result.dqt_tables.len(), 2);
}
#[test]
fn test_probe_quality_sweep_ijg() {
for q in [10, 25, 50, 75, 85, 90, 95, 100] {
let jpeg = build_minimal_jpeg(q, false);
let result = probe(&jpeg).unwrap();
assert_eq!(
result.quality.value, q as f32,
"IJG Q{q} probe failed: got {:.0}",
result.quality.value
);
assert_eq!(result.quality.confidence, Confidence::Exact);
}
}
#[test]
fn test_probe_optimized_huffman_detected_as_imagemagick() {
let jpeg = build_minimal_jpeg_with_custom_huffman(75);
let result = probe(&jpeg).unwrap();
assert_eq!(result.encoder, EncoderFamily::ImageMagick);
}
fn build_minimal_jpeg(quality: u8, progressive: bool) -> Vec<u8> {
use super::fingerprint::generate_ijg_table;
use crate::foundation::consts::MARKER_DHT;
let mut data = Vec::new();
data.extend_from_slice(&[0xFF, MARKER_SOI]);
let jfif = [
0xFF,
MARKER_APP0,
0x00,
0x10, b'J',
b'F',
b'I',
b'F',
0x00, 0x01,
0x01, 0x00, 0x00,
0x01, 0x00,
0x01, 0x00,
0x00, ];
data.extend_from_slice(&jfif);
let luma_natural = generate_ijg_table(quality, false);
data.extend_from_slice(&[0xFF, MARKER_DQT]);
data.extend_from_slice(&[0x00, 0x43]); data.push(0x00); for z in 0..64 {
let natural_idx = crate::foundation::consts::JPEG_NATURAL_ORDER[z] as usize;
data.push(luma_natural[natural_idx] as u8);
}
let chroma_natural = generate_ijg_table(quality, true);
data.extend_from_slice(&[0xFF, MARKER_DQT]);
data.extend_from_slice(&[0x00, 0x43]); data.push(0x01); for z in 0..64 {
let natural_idx = crate::foundation::consts::JPEG_NATURAL_ORDER[z] as usize;
data.push(chroma_natural[natural_idx] as u8);
}
let sof_marker = if progressive {
MARKER_SOF2
} else {
MARKER_SOF0
};
data.extend_from_slice(&[0xFF, sof_marker]);
data.extend_from_slice(&[0x00, 0x11]); data.push(0x08); data.extend_from_slice(&[0x00, 0x08]); data.extend_from_slice(&[0x00, 0x08]); data.push(0x03); data.extend_from_slice(&[0x01, 0x11, 0x00]);
data.extend_from_slice(&[0x02, 0x11, 0x01]);
data.extend_from_slice(&[0x03, 0x11, 0x01]);
data.extend_from_slice(&[0xFF, MARKER_DHT]);
data.extend_from_slice(&[0x00, 0x1F]); data.push(0x00); data.extend_from_slice(&[
0x00, 0x01, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00,
]);
data.extend_from_slice(&[
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B,
]);
data.extend_from_slice(&[0xFF, MARKER_DHT]);
data.extend_from_slice(&[0x00, 0xB5]);
data.push(0x10); data.extend_from_slice(&[
0x00, 0x02, 0x01, 0x03, 0x03, 0x02, 0x04, 0x03, 0x05, 0x05, 0x04, 0x04, 0x00, 0x00,
0x01, 0x7D,
]);
for i in 0..162u8 {
data.push(i);
}
data.extend_from_slice(&[0xFF, MARKER_DHT]);
data.extend_from_slice(&[0x00, 0x1F]); data.push(0x01); data.extend_from_slice(&[
0x00, 0x03, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00,
0x00, 0x00,
]);
data.extend_from_slice(&[
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B,
]);
data.extend_from_slice(&[0xFF, MARKER_DHT]);
data.extend_from_slice(&[0x00, 0xB5]); data.push(0x11); data.extend_from_slice(&[
0x00, 0x02, 0x01, 0x02, 0x04, 0x04, 0x03, 0x04, 0x07, 0x05, 0x04, 0x04, 0x00, 0x01,
0x02, 0x77,
]);
for i in 0..162u8 {
data.push(i);
}
data.extend_from_slice(&[0xFF, MARKER_SOS]);
data.extend_from_slice(&[0x00, 0x0C]); data.push(0x03); data.extend_from_slice(&[0x01, 0x00]); data.extend_from_slice(&[0x02, 0x11]); data.extend_from_slice(&[0x03, 0x11]); data.extend_from_slice(&[0x00, 0x3F, 0x00]);
data.push(0x00);
data.extend_from_slice(&[0xFF, MARKER_EOI]);
data
}
fn build_minimal_jpeg_with_custom_huffman(quality: u8) -> Vec<u8> {
use super::fingerprint::generate_ijg_table;
use crate::foundation::consts::MARKER_DHT;
let mut data = Vec::new();
data.extend_from_slice(&[0xFF, MARKER_SOI]);
let jfif = [
0xFF,
MARKER_APP0,
0x00,
0x10,
b'J',
b'F',
b'I',
b'F',
0x00,
0x01,
0x01,
0x00,
0x00,
0x01,
0x00,
0x01,
0x00,
0x00,
];
data.extend_from_slice(&jfif);
let luma_natural = generate_ijg_table(quality, false);
data.extend_from_slice(&[0xFF, MARKER_DQT, 0x00, 0x43, 0x00]);
for z in 0..64 {
let natural_idx = crate::foundation::consts::JPEG_NATURAL_ORDER[z] as usize;
data.push(luma_natural[natural_idx] as u8);
}
let chroma_natural = generate_ijg_table(quality, true);
data.extend_from_slice(&[0xFF, MARKER_DQT, 0x00, 0x43, 0x01]);
for z in 0..64 {
let natural_idx = crate::foundation::consts::JPEG_NATURAL_ORDER[z] as usize;
data.push(chroma_natural[natural_idx] as u8);
}
data.extend_from_slice(&[0xFF, MARKER_SOF0]);
data.extend_from_slice(&[0x00, 0x11, 0x08]);
data.extend_from_slice(&[0x00, 0x08, 0x00, 0x08]);
data.push(0x03);
data.extend_from_slice(&[0x01, 0x11, 0x00]);
data.extend_from_slice(&[0x02, 0x11, 0x01]);
data.extend_from_slice(&[0x03, 0x11, 0x01]);
data.extend_from_slice(&[0xFF, MARKER_DHT, 0x00, 0x1F, 0x00]);
data.extend_from_slice(&[
0x00, 0x01, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00,
]);
data.extend_from_slice(&[
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B,
]);
let ac_sym_count: u16 = 100;
let ac_len = 2 + 1 + 16 + ac_sym_count;
data.extend_from_slice(&[0xFF, MARKER_DHT]);
data.push((ac_len >> 8) as u8);
data.push((ac_len & 0xFF) as u8);
data.push(0x10); data.extend_from_slice(&[
0x00, 0x02, 0x01, 0x03, 0x03, 0x02, 0x04, 0x03, 0x05, 0x05, 0x04, 0x04, 0x00, 0x00,
0x01, 0x3F,
]);
for i in 0..100u8 {
data.push(i);
}
data.extend_from_slice(&[0xFF, MARKER_DHT, 0x00, 0x1F, 0x01]);
data.extend_from_slice(&[
0x00, 0x03, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00,
0x00, 0x00,
]);
data.extend_from_slice(&[
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B,
]);
data.extend_from_slice(&[0xFF, MARKER_DHT]);
data.push((ac_len >> 8) as u8);
data.push((ac_len & 0xFF) as u8);
data.push(0x11); data.extend_from_slice(&[
0x00, 0x02, 0x01, 0x03, 0x03, 0x02, 0x04, 0x03, 0x05, 0x05, 0x04, 0x04, 0x00, 0x00,
0x01, 0x3F,
]);
for i in 0..100u8 {
data.push(i);
}
data.extend_from_slice(&[0xFF, MARKER_SOS]);
data.extend_from_slice(&[0x00, 0x0C, 0x03]);
data.extend_from_slice(&[0x01, 0x00, 0x02, 0x11, 0x03, 0x11]);
data.extend_from_slice(&[0x00, 0x3F, 0x00]);
data.push(0x00);
data.extend_from_slice(&[0xFF, MARKER_EOI]);
data
}
}