use crate::IoError;
use fovea::image::{Image, ImageView};
use fovea::pixel::{Srgb8, SrgbMono8, SrgbMono16};
pub enum JpegImage {
SrgbMono8(Image<SrgbMono8>),
SrgbMono16(Image<SrgbMono16>),
Srgb8(Image<Srgb8>),
}
impl JpegImage {
#[must_use]
pub fn width(&self) -> usize {
use fovea::image::ImageView;
match self {
JpegImage::SrgbMono8(img) => img.width(),
JpegImage::SrgbMono16(img) => img.width(),
JpegImage::Srgb8(img) => img.width(),
}
}
#[must_use]
pub fn height(&self) -> usize {
use fovea::image::ImageView;
match self {
JpegImage::SrgbMono8(img) => img.height(),
JpegImage::SrgbMono16(img) => img.height(),
JpegImage::Srgb8(img) => img.height(),
}
}
#[must_use]
pub fn size(&self) -> fovea::Size {
use fovea::image::ImageView;
match self {
JpegImage::SrgbMono8(img) => img.size(),
JpegImage::SrgbMono16(img) => img.size(),
JpegImage::Srgb8(img) => img.size(),
}
}
}
impl std::fmt::Debug for JpegImage {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
JpegImage::SrgbMono8(img) => {
write!(f, "SrgbMono8({}x{})", img.width(), img.height())
}
JpegImage::SrgbMono16(img) => {
write!(f, "SrgbMono16({}x{})", img.width(), img.height())
}
JpegImage::Srgb8(img) => write!(f, "Srgb8({}x{})", img.width(), img.height()),
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Default, PartialEq)]
pub struct JpegExifInfo {
pub orientation: Option<u8>,
pub datetime: Option<String>,
pub datetime_original: Option<String>,
pub camera_make: Option<String>,
pub camera_model: Option<String>,
pub software: Option<String>,
pub exposure_time: Option<(u32, u32)>,
pub f_number: Option<(u32, u32)>,
pub iso_speed: Option<u16>,
pub focal_length: Option<(u32, u32)>,
pub gps_latitude: Option<f64>,
pub gps_longitude: Option<f64>,
pub gps_altitude: Option<f64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum JpegColorSpace {
Srgb,
IccTagged,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum JpegPixelDensity {
Dpi {
x: u16,
y: u16,
},
Dpcm {
x: u16,
y: u16,
},
AspectRatio {
x: u16,
y: u16,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum JpegBitDepth {
Eight,
Twelve,
}
#[non_exhaustive]
#[derive(Debug, Clone)]
pub struct JpegMetadata {
pub exif: Option<JpegExifInfo>,
pub raw_exif: Option<Box<[u8]>>,
pub icc_profile: Option<Box<[u8]>>,
pub pixel_density: Option<JpegPixelDensity>,
pub comments: Vec<String>,
pub source_bit_depth: JpegBitDepth,
pub color_space: JpegColorSpace,
}
#[derive(Debug)]
#[non_exhaustive]
pub struct JpegDecoded {
pub image: JpegImage,
pub metadata: JpegMetadata,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ByteOrder {
Little,
Big,
}
struct TiffReader<'a> {
data: &'a [u8],
order: ByteOrder,
}
impl<'a> TiffReader<'a> {
fn new(data: &'a [u8], order: ByteOrder) -> Self {
Self { data, order }
}
#[allow(dead_code)]
fn len(&self) -> usize {
self.data.len()
}
fn u8_at(&self, offset: usize) -> Option<u8> {
self.data.get(offset).copied()
}
fn u16_at(&self, offset: usize) -> Option<u16> {
let bytes: &[u8] = self.data.get(offset..offset.checked_add(2)?)?;
let arr: [u8; 2] = [bytes[0], bytes[1]];
Some(match self.order {
ByteOrder::Little => u16::from_le_bytes(arr),
ByteOrder::Big => u16::from_be_bytes(arr),
})
}
fn u32_at(&self, offset: usize) -> Option<u32> {
let bytes: &[u8] = self.data.get(offset..offset.checked_add(4)?)?;
let arr: [u8; 4] = [bytes[0], bytes[1], bytes[2], bytes[3]];
Some(match self.order {
ByteOrder::Little => u32::from_le_bytes(arr),
ByteOrder::Big => u32::from_be_bytes(arr),
})
}
fn rational_at(&self, offset: usize) -> Option<(u32, u32)> {
let num = self.u32_at(offset)?;
let den = self.u32_at(offset.checked_add(4)?)?;
Some((num, den))
}
}
const MAX_IFD_ENTRIES: u16 = 1000;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct IfdEntry {
tag: u16,
tiff_type: u16,
count: u32,
value_or_offset: u32,
}
fn read_ifd_entries(reader: &TiffReader<'_>, ifd_offset: usize) -> Vec<IfdEntry> {
let count = match reader.u16_at(ifd_offset) {
Some(c) => c.min(MAX_IFD_ENTRIES),
None => return Vec::new(),
};
let mut entries = Vec::with_capacity(count as usize);
for i in 0..count as usize {
let base = match ifd_offset
.checked_add(2)
.and_then(|b| b.checked_add(i.checked_mul(12)?))
{
Some(b) => b,
None => break,
};
let tag = match reader.u16_at(base) {
Some(v) => v,
None => break,
};
let tiff_type = match reader.u16_at(base + 2) {
Some(v) => v,
None => break,
};
let count = match reader.u32_at(base + 4) {
Some(v) => v,
None => break,
};
let value_or_offset = match reader.u32_at(base + 8) {
Some(v) => v,
None => break,
};
entries.push(IfdEntry {
tag,
tiff_type,
count,
value_or_offset,
});
}
entries
}
fn read_ascii(reader: &TiffReader<'_>, offset: usize, count: u32) -> Option<String> {
let count = count as usize;
if count == 0 {
return None;
}
let end = offset.checked_add(count)?;
let bytes = reader.data.get(offset..end)?;
let trimmed = match bytes.iter().rposition(|&b| b != 0) {
Some(last) => &bytes[..=last],
None => return None, };
if trimmed.is_empty() {
return None;
}
if let Ok(s) = std::str::from_utf8(trimmed) {
return Some(s.to_string());
}
let s: String = trimmed.iter().map(|&b| b as char).collect();
Some(s)
}
fn read_ifd_ascii(
reader: &TiffReader<'_>,
count: u32,
value_or_offset: u32,
entry_value_offset: usize,
) -> Option<String> {
if count <= 4 {
read_ascii(reader, entry_value_offset, count)
} else {
read_ascii(reader, value_or_offset as usize, count)
}
}
const TIFF_TYPE_BYTE: u16 = 1;
const TIFF_TYPE_ASCII: u16 = 2;
const TIFF_TYPE_SHORT: u16 = 3;
#[allow(dead_code)]
const TIFF_TYPE_LONG: u16 = 4;
const TIFF_TYPE_RATIONAL: u16 = 5;
fn dms_to_decimal(
degrees: (u32, u32),
minutes: (u32, u32),
seconds: (u32, u32),
ref_char: u8,
) -> Option<f64> {
if degrees.1 == 0 || minutes.1 == 0 || seconds.1 == 0 {
return None;
}
let deg = degrees.0 as f64 / degrees.1 as f64;
let min = minutes.0 as f64 / minutes.1 as f64;
let sec = seconds.0 as f64 / seconds.1 as f64;
if deg >= 360.0 || min >= 60.0 || sec >= 60.0 {
return None;
}
let sign = match ref_char {
b'N' | b'E' => 1.0,
b'S' | b'W' => -1.0,
_ => return None,
};
Some(sign * (deg + min / 60.0 + sec / 3600.0))
}
#[allow(dead_code)]
fn parse_exif(raw: &[u8]) -> Option<JpegExifInfo> {
if raw.len() < 14 {
return None;
}
if &raw[0..6] != b"Exif\0\0" {
return None;
}
parse_tiff_exif(&raw[6..])
}
fn parse_tiff_exif(tiff: &[u8]) -> Option<JpegExifInfo> {
if tiff.len() < 8 {
return None;
}
let order = match &tiff[0..2] {
b"II" => ByteOrder::Little,
b"MM" => ByteOrder::Big,
_ => return None,
};
let reader = TiffReader::new(tiff, order);
let magic = reader.u16_at(2)?;
if magic != 42 {
return None;
}
let ifd0_offset = reader.u32_at(4)? as usize;
let mut info = JpegExifInfo::default();
let mut exif_ifd_offset: Option<usize> = None;
let mut gps_ifd_offset: Option<usize> = None;
let ifd0_entries = read_ifd_entries(&reader, ifd0_offset);
for (i, entry) in ifd0_entries.iter().enumerate() {
let IfdEntry {
tag,
tiff_type,
count,
value_or_offset,
} = *entry;
let entry_val_off = ifd0_offset + 2 + i * 12 + 8;
match tag {
0x0112 if tiff_type == TIFF_TYPE_SHORT && count == 1 => {
if let Some(v) = reader.u16_at(entry_val_off) {
if (1..=8).contains(&v) {
info.orientation = Some(v as u8);
}
}
}
0x010F if tiff_type == TIFF_TYPE_ASCII => {
info.camera_make = read_ifd_ascii(&reader, count, value_or_offset, entry_val_off);
}
0x0110 if tiff_type == TIFF_TYPE_ASCII => {
info.camera_model = read_ifd_ascii(&reader, count, value_or_offset, entry_val_off);
}
0x0131 if tiff_type == TIFF_TYPE_ASCII => {
info.software = read_ifd_ascii(&reader, count, value_or_offset, entry_val_off);
}
0x0132 if tiff_type == TIFF_TYPE_ASCII => {
info.datetime = read_ifd_ascii(&reader, count, value_or_offset, entry_val_off);
}
0x8769 if count == 1 => {
exif_ifd_offset = Some(value_or_offset as usize);
}
0x8825 if count == 1 => {
gps_ifd_offset = Some(value_or_offset as usize);
}
_ => {}
}
}
if let Some(exif_off) = exif_ifd_offset {
let exif_entries = read_ifd_entries(&reader, exif_off);
for (i, entry) in exif_entries.iter().enumerate() {
let IfdEntry {
tag,
tiff_type,
count,
value_or_offset,
} = *entry;
let entry_val_off = exif_off + 2 + i * 12 + 8;
match tag {
0x829A if tiff_type == TIFF_TYPE_RATIONAL && count == 1 => {
info.exposure_time = reader.rational_at(value_or_offset as usize);
}
0x829D if tiff_type == TIFF_TYPE_RATIONAL && count == 1 => {
info.f_number = reader.rational_at(value_or_offset as usize);
}
0x8827 if tiff_type == TIFF_TYPE_SHORT && count == 1 => {
info.iso_speed = reader.u16_at(entry_val_off);
}
0x9003 if tiff_type == TIFF_TYPE_ASCII => {
info.datetime_original =
read_ifd_ascii(&reader, count, value_or_offset, entry_val_off);
}
0x920A if tiff_type == TIFF_TYPE_RATIONAL && count == 1 => {
info.focal_length = reader.rational_at(value_or_offset as usize);
}
_ => {}
}
}
}
if let Some(gps_off) = gps_ifd_offset {
let gps_entries = read_ifd_entries(&reader, gps_off);
let mut lat_ref: Option<u8> = None;
let mut lat_dms: Option<[(u32, u32); 3]> = None;
let mut lon_ref: Option<u8> = None;
let mut lon_dms: Option<[(u32, u32); 3]> = None;
let mut alt_ref: Option<u8> = None;
let mut alt_rational: Option<(u32, u32)> = None;
for (i, entry) in gps_entries.iter().enumerate() {
let IfdEntry {
tag,
tiff_type,
count,
value_or_offset,
} = *entry;
let entry_val_off = gps_off + 2 + i * 12 + 8;
match tag {
0x0001 if tiff_type == TIFF_TYPE_ASCII && count == 2 => {
lat_ref = reader.u8_at(entry_val_off);
}
0x0002 if tiff_type == TIFF_TYPE_RATIONAL && count == 3 => {
let off = value_or_offset as usize;
if let (Some(d), Some(m), Some(s)) = (
reader.rational_at(off),
reader.rational_at(off + 8),
reader.rational_at(off + 16),
) {
lat_dms = Some([d, m, s]);
}
}
0x0003 if tiff_type == TIFF_TYPE_ASCII && count == 2 => {
lon_ref = reader.u8_at(entry_val_off);
}
0x0004 if tiff_type == TIFF_TYPE_RATIONAL && count == 3 => {
let off = value_or_offset as usize;
if let (Some(d), Some(m), Some(s)) = (
reader.rational_at(off),
reader.rational_at(off + 8),
reader.rational_at(off + 16),
) {
lon_dms = Some([d, m, s]);
}
}
0x0005 if tiff_type == TIFF_TYPE_BYTE && count == 1 => {
alt_ref = reader.u8_at(entry_val_off);
}
0x0006 if tiff_type == TIFF_TYPE_RATIONAL && count == 1 => {
alt_rational = reader.rational_at(value_or_offset as usize);
}
_ => {}
}
}
if let (Some(ref_ch), Some(dms)) = (lat_ref, lat_dms) {
info.gps_latitude = dms_to_decimal(dms[0], dms[1], dms[2], ref_ch);
}
if let (Some(ref_ch), Some(dms)) = (lon_ref, lon_dms) {
info.gps_longitude = dms_to_decimal(dms[0], dms[1], dms[2], ref_ch);
}
if let Some((num, den)) = alt_rational {
if den != 0 {
let alt = num as f64 / den as f64;
let sign = match alt_ref {
Some(1) => -1.0,
_ => 1.0, };
info.gps_altitude = Some(sign * alt);
}
}
}
Some(info)
}
fn scan_com_markers(data: &[u8]) -> Vec<String> {
let mut comments = Vec::new();
if data.len() < 2 || data[0] != 0xFF || data[1] != 0xD8 {
return comments;
}
let mut pos = 2;
while pos + 1 < data.len() {
if data[pos] != 0xFF {
pos += 1;
continue;
}
while pos + 1 < data.len() && data[pos + 1] == 0xFF {
pos += 1;
}
if pos + 1 >= data.len() {
break;
}
let marker = data[pos + 1];
pos += 2;
match marker {
0xDA => break,
0x00 | 0x01 | 0xD0..=0xD7 => continue,
0xFE => {
if pos + 2 > data.len() {
break;
}
let len = u16::from_be_bytes([data[pos], data[pos + 1]]) as usize;
if len < 2 {
break; }
let payload_len = len - 2;
let payload_start = pos + 2;
if payload_start + payload_len > data.len() {
break; }
let payload = &data[payload_start..payload_start + payload_len];
let text = if let Ok(s) = std::str::from_utf8(payload) {
s.to_string()
} else {
payload.iter().map(|&b| b as char).collect()
};
comments.push(text);
pos = payload_start + payload_len;
}
_ => {
if pos + 2 > data.len() {
break;
}
let len = u16::from_be_bytes([data[pos], data[pos + 1]]) as usize;
if len < 2 || pos + len > data.len() {
break;
}
pos += len;
}
}
}
comments
}
fn scan_jfif_density(data: &[u8]) -> Option<JpegPixelDensity> {
if data.len() < 2 || data[0] != 0xFF || data[1] != 0xD8 {
return None;
}
let mut pos = 2;
while pos + 1 < data.len() {
if data[pos] != 0xFF {
pos += 1;
continue;
}
while pos + 1 < data.len() && data[pos + 1] == 0xFF {
pos += 1;
}
if pos + 1 >= data.len() {
break;
}
let marker = data[pos + 1];
pos += 2;
match marker {
0xDA => break, 0x00 | 0x01 | 0xD0..=0xD7 => continue,
0xE0 => {
if pos + 2 > data.len() {
return None;
}
let len = u16::from_be_bytes([data[pos], data[pos + 1]]) as usize;
if len < 2 || pos + len > data.len() {
return None;
}
let segment = &data[pos + 2..pos + len];
if segment.len() >= 12 && &segment[0..5] == b"JFIF\0" {
let units = segment[7];
let x_density = u16::from_be_bytes([segment[8], segment[9]]);
let y_density = u16::from_be_bytes([segment[10], segment[11]]);
return match units {
1 => Some(JpegPixelDensity::Dpi {
x: x_density,
y: y_density,
}),
2 => Some(JpegPixelDensity::Dpcm {
x: x_density,
y: y_density,
}),
_ => Some(JpegPixelDensity::AspectRatio {
x: x_density,
y: y_density,
}),
};
}
pos += len;
}
_ => {
if pos + 2 > data.len() {
break;
}
let len = u16::from_be_bytes([data[pos], data[pos + 1]]) as usize;
if len < 2 || pos + len > data.len() {
break;
}
pos += len;
}
}
}
None
}
fn decode_error(e: jpeg_decoder::Error) -> IoError {
match e {
jpeg_decoder::Error::Io(io) => IoError::Io(io),
other => IoError::DecodeFailed {
source: Box::new(other),
},
}
}
pub fn decode(data: &[u8]) -> Result<JpegDecoded, IoError> {
let comments = scan_com_markers(data);
let pixel_density = scan_jfif_density(data);
let mut decoder = jpeg_decoder::Decoder::new(std::io::Cursor::new(data));
let pixels = decoder.decode().map_err(decode_error)?;
let info = decoder.info().ok_or_else(|| IoError::DecodeFailed {
source: "jpeg decoder produced no image info after successful decode".into(),
})?;
let width = info.width as usize;
let height = info.height as usize;
let (image, source_bit_depth) = pixels_to_image(pixels, width, height, info.pixel_format)?;
let metadata = build_metadata(&decoder, source_bit_depth, comments, pixel_density);
Ok(JpegDecoded { image, metadata })
}
pub fn decode_reader(mut reader: impl std::io::Read) -> Result<JpegDecoded, IoError> {
let mut buf = Vec::new();
reader.read_to_end(&mut buf)?;
decode(&buf)
}
fn pixels_to_image(
pixels: Vec<u8>,
width: usize,
height: usize,
pixel_format: jpeg_decoder::PixelFormat,
) -> Result<(JpegImage, JpegBitDepth), IoError> {
match pixel_format {
jpeg_decoder::PixelFormat::L8 => {
let img = Image::from_raw_bytes(width, height, pixels).map_err(|_| {
IoError::DecodeFailed {
source: "pixel count does not match image dimensions (L8)".into(),
}
})?;
Ok((JpegImage::SrgbMono8(img), JpegBitDepth::Eight))
}
jpeg_decoder::PixelFormat::L16 => {
let pixel_vec: Vec<SrgbMono16> = pixels
.chunks_exact(2)
.map(|b| SrgbMono16::new(u16::from_ne_bytes([b[0], b[1]])))
.collect();
let img =
Image::from_vec(width, height, pixel_vec).map_err(|_| IoError::DecodeFailed {
source: "pixel count does not match image dimensions (L16)".into(),
})?;
Ok((JpegImage::SrgbMono16(img), JpegBitDepth::Twelve))
}
jpeg_decoder::PixelFormat::RGB24 => {
let img = Image::from_raw_bytes(width, height, pixels).map_err(|_| {
IoError::DecodeFailed {
source: "pixel count does not match image dimensions (RGB24)".into(),
}
})?;
Ok((JpegImage::Srgb8(img), JpegBitDepth::Eight))
}
jpeg_decoder::PixelFormat::CMYK32 => Err(IoError::UnsupportedFeature {
reason: "CMYK JPEG is not supported — convert to RGB before loading",
}),
}
}
fn build_metadata<R: std::io::Read>(
decoder: &jpeg_decoder::Decoder<R>,
source_bit_depth: JpegBitDepth,
comments: Vec<String>,
pixel_density: Option<JpegPixelDensity>,
) -> JpegMetadata {
let raw_exif_data = decoder.exif_data();
let exif = raw_exif_data.and_then(parse_tiff_exif);
let raw_exif = raw_exif_data.map(|d| d.to_vec().into_boxed_slice());
let icc_profile = decoder.icc_profile().map(|v| v.into_boxed_slice());
let color_space = if icc_profile.is_some() {
JpegColorSpace::IccTagged
} else {
JpegColorSpace::Srgb
};
JpegMetadata {
exif,
raw_exif,
icc_profile,
pixel_density,
comments,
source_bit_depth,
color_space,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum JpegSamplingFactor {
F1x1,
F2x1,
F2x2,
}
#[non_exhaustive]
#[derive(Debug, Clone)]
pub struct JpegEncodeOptions {
pub quality: u8,
pub sampling_factor: Option<JpegSamplingFactor>,
pub progressive: bool,
}
impl Default for JpegEncodeOptions {
fn default() -> Self {
Self {
quality: 85,
sampling_factor: None,
progressive: false,
}
}
}
mod jpeg_pixel_sealed {
pub trait Sealed {}
}
pub trait JpegPixel: jpeg_pixel_sealed::Sealed + fovea::pixel::PlainPixel {
const JPEG_COLOR_TYPE: jpeg_encoder::ColorType;
}
macro_rules! impl_jpeg_pixel {
($ty:ty, $color:expr) => {
impl jpeg_pixel_sealed::Sealed for $ty {}
impl JpegPixel for $ty {
const JPEG_COLOR_TYPE: jpeg_encoder::ColorType = $color;
}
};
}
impl_jpeg_pixel!(SrgbMono8, jpeg_encoder::ColorType::Luma);
impl_jpeg_pixel!(Srgb8, jpeg_encoder::ColorType::Rgb);
fn encode_error(e: jpeg_encoder::EncodingError) -> IoError {
match e {
jpeg_encoder::EncodingError::IoError(io) => IoError::Io(io),
other => IoError::EncodeFailed {
source: Box::new(other),
},
}
}
pub fn encode<P: JpegPixel>(
image: &(impl fovea::image::ImageView<Pixel = P> + fovea::image::PlainImage),
options: &JpegEncodeOptions,
) -> Result<Vec<u8>, IoError> {
let mut buf = Vec::new();
encode_writer(image, &mut buf, options)?;
Ok(buf)
}
pub fn encode_writer<P: JpegPixel>(
image: &(impl fovea::image::ImageView<Pixel = P> + fovea::image::PlainImage),
writer: impl std::io::Write,
options: &JpegEncodeOptions,
) -> Result<(), IoError> {
if image.width() > u16::MAX as usize || image.height() > u16::MAX as usize {
return Err(IoError::UnsupportedFeature {
reason: "JPEG dimensions exceed 65535 (u16::MAX) per ITU-T T.81 §B.2.2",
});
}
let width = image.width() as u16;
let height = image.height() as u16;
let quality = options.quality.clamp(1, 100);
let mut encoder = jpeg_encoder::Encoder::new(writer, quality);
if let Some(sf) = options.sampling_factor {
let sampling = match sf {
JpegSamplingFactor::F1x1 => jpeg_encoder::SamplingFactor::F_1_1,
JpegSamplingFactor::F2x1 => jpeg_encoder::SamplingFactor::F_2_1,
JpegSamplingFactor::F2x2 => jpeg_encoder::SamplingFactor::F_2_2,
};
encoder.set_sampling_factor(sampling);
}
if options.progressive {
encoder.set_progressive(true);
}
let bytes: &[u8] = image.as_bytes();
encoder
.encode(bytes, width, height, P::JPEG_COLOR_TYPE)
.map_err(encode_error)?;
Ok(())
}
pub fn encode_jpeg_image(
image: &JpegImage,
options: &JpegEncodeOptions,
) -> Result<Vec<u8>, IoError> {
match image {
JpegImage::SrgbMono8(img) => encode(img, options),
JpegImage::SrgbMono16(_) => Err(IoError::UnsupportedFeature {
reason: "16-bit grayscale (12-bit JPEG) cannot be re-encoded to baseline JPEG",
}),
JpegImage::Srgb8(img) => encode(img, options),
}
}
#[cfg(test)]
#[allow(clippy::erasing_op, clippy::identity_op)]
mod tests {
use super::*;
use fovea::image::ImageView;
use std::mem;
fn build_jpeg_rgb(width: u16, height: u16, r: u8, g: u8, b: u8) -> Vec<u8> {
let w = width as usize;
let h = height as usize;
let mut pixels = Vec::with_capacity(w * h * 3);
for _ in 0..w * h {
pixels.push(r);
pixels.push(g);
pixels.push(b);
}
let mut buf = Vec::new();
let encoder = jpeg_encoder::Encoder::new(&mut buf, 90);
encoder
.encode(&pixels, width, height, jpeg_encoder::ColorType::Rgb)
.unwrap();
buf
}
fn build_jpeg_gray(width: u16, height: u16, value: u8) -> Vec<u8> {
let w = width as usize;
let h = height as usize;
let pixels = vec![value; w * h];
let mut buf = Vec::new();
let encoder = jpeg_encoder::Encoder::new(&mut buf, 90);
encoder
.encode(&pixels, width, height, jpeg_encoder::ColorType::Luma)
.unwrap();
buf
}
fn inject_com_marker(jpeg: &[u8], text: &str) -> Vec<u8> {
assert!(jpeg.len() >= 2 && jpeg[0] == 0xFF && jpeg[1] == 0xD8);
let text_bytes = text.as_bytes();
let seg_len = (text_bytes.len() + 2) as u16; let mut out = Vec::with_capacity(jpeg.len() + 4 + text_bytes.len());
out.extend_from_slice(&jpeg[..2]); out.push(0xFF);
out.push(0xFE); out.extend_from_slice(&seg_len.to_be_bytes());
out.extend_from_slice(text_bytes);
out.extend_from_slice(&jpeg[2..]); out
}
fn inject_jfif_app0(jpeg: &[u8], units: u8, x: u16, y: u16) -> Vec<u8> {
assert!(jpeg.len() >= 2 && jpeg[0] == 0xFF && jpeg[1] == 0xD8);
let mut segment = Vec::new();
segment.extend_from_slice(b"JFIF\0"); segment.push(1); segment.push(2); segment.push(units);
segment.extend_from_slice(&x.to_be_bytes());
segment.extend_from_slice(&y.to_be_bytes());
segment.push(0); segment.push(0); let seg_len = (segment.len() + 2) as u16;
let mut out = Vec::with_capacity(jpeg.len() + 4 + segment.len());
out.extend_from_slice(&jpeg[..2]); out.push(0xFF);
out.push(0xE0); out.extend_from_slice(&seg_len.to_be_bytes());
out.extend_from_slice(&segment);
out.extend_from_slice(&jpeg[2..]); out
}
#[test]
fn jpeg_image_enum_is_compact() {
let srgb8_size = mem::size_of::<Image<Srgb8>>();
let mono8_size = mem::size_of::<Image<SrgbMono8>>();
let mono16_size = mem::size_of::<Image<SrgbMono16>>();
let enum_size = mem::size_of::<JpegImage>();
let max_variant = srgb8_size.max(mono8_size).max(mono16_size);
assert!(
enum_size <= max_variant + 16,
"JpegImage enum is unexpectedly large: {enum_size} bytes \
(max variant is {max_variant} bytes)"
);
}
#[test]
fn jpeg_image_debug_srgb_mono8() {
let img = JpegImage::SrgbMono8(Image::fill(10, 20, SrgbMono8::new(0)));
assert_eq!(format!("{:?}", img), "SrgbMono8(10x20)");
}
#[test]
fn jpeg_image_debug_srgb_mono16() {
let img = JpegImage::SrgbMono16(Image::fill(5, 15, SrgbMono16::new(0)));
assert_eq!(format!("{:?}", img), "SrgbMono16(5x15)");
}
#[test]
fn jpeg_image_debug_srgb8() {
let img = JpegImage::Srgb8(Image::fill(320, 240, Srgb8::new(0, 0, 0)));
assert_eq!(format!("{:?}", img), "Srgb8(320x240)");
}
#[test]
fn jpeg_image_debug_1x1() {
let img = JpegImage::Srgb8(Image::fill(1, 1, Srgb8::new(0, 0, 0)));
assert_eq!(format!("{:?}", img), "Srgb8(1x1)");
}
#[test]
fn jpeg_image_debug_all_variants() {
let variants: Vec<JpegImage> = vec![
JpegImage::SrgbMono8(Image::fill(1, 1, SrgbMono8::new(0))),
JpegImage::SrgbMono16(Image::fill(2, 3, SrgbMono16::new(0))),
JpegImage::Srgb8(Image::fill(4, 5, Srgb8::new(0, 0, 0))),
];
let expected = ["SrgbMono8(1x1)", "SrgbMono16(2x3)", "Srgb8(4x5)"];
for (v, e) in variants.iter().zip(expected.iter()) {
assert_eq!(format!("{:?}", v), *e);
}
}
#[test]
fn jpeg_exif_info_default_all_none() {
let info = JpegExifInfo::default();
assert_eq!(info.orientation, None);
assert_eq!(info.datetime, None);
assert_eq!(info.datetime_original, None);
assert_eq!(info.camera_make, None);
assert_eq!(info.camera_model, None);
assert_eq!(info.software, None);
assert_eq!(info.exposure_time, None);
assert_eq!(info.f_number, None);
assert_eq!(info.iso_speed, None);
assert_eq!(info.focal_length, None);
assert_eq!(info.gps_latitude, None);
assert_eq!(info.gps_longitude, None);
assert_eq!(info.gps_altitude, None);
}
#[test]
fn jpeg_exif_info_fully_populated() {
let info = JpegExifInfo {
orientation: Some(6),
datetime: Some("2025:01:15 12:30:00".to_string()),
datetime_original: Some("2025:01:15 12:29:59".to_string()),
camera_make: Some("Canon".to_string()),
camera_model: Some("EOS R5".to_string()),
software: Some("Lightroom 13.0".to_string()),
exposure_time: Some((1, 250)),
f_number: Some((28, 10)),
iso_speed: Some(400),
focal_length: Some((50, 1)),
gps_latitude: Some(48.8566),
gps_longitude: Some(2.3522),
gps_altitude: Some(35.0),
};
assert_eq!(info.orientation, Some(6));
assert_eq!(info.datetime.as_deref(), Some("2025:01:15 12:30:00"));
assert_eq!(
info.datetime_original.as_deref(),
Some("2025:01:15 12:29:59")
);
assert_eq!(info.camera_make.as_deref(), Some("Canon"));
assert_eq!(info.camera_model.as_deref(), Some("EOS R5"));
assert_eq!(info.software.as_deref(), Some("Lightroom 13.0"));
assert_eq!(info.exposure_time, Some((1, 250)));
assert_eq!(info.f_number, Some((28, 10)));
assert_eq!(info.iso_speed, Some(400));
assert_eq!(info.focal_length, Some((50, 1)));
assert!((info.gps_latitude.unwrap() - 48.8566).abs() < 1e-10);
assert!((info.gps_longitude.unwrap() - 2.3522).abs() < 1e-10);
assert!((info.gps_altitude.unwrap() - 35.0).abs() < 1e-10);
}
#[test]
fn jpeg_exif_info_partial_fields() {
let info = JpegExifInfo {
orientation: Some(1),
camera_make: Some("Nikon".to_string()),
..Default::default()
};
assert_eq!(info.orientation, Some(1));
assert_eq!(info.camera_make.as_deref(), Some("Nikon"));
assert_eq!(info.camera_model, None);
assert_eq!(info.exposure_time, None);
assert_eq!(info.gps_latitude, None);
}
#[test]
fn jpeg_exif_info_clone() {
let info = JpegExifInfo {
orientation: Some(3),
datetime: Some("2025:06:01 08:00:00".to_string()),
..Default::default()
};
let cloned = info.clone();
assert_eq!(info, cloned);
}
#[test]
fn jpeg_exif_info_debug() {
let info = JpegExifInfo::default();
let dbg = format!("{:?}", info);
assert!(dbg.contains("JpegExifInfo"));
assert!(dbg.contains("orientation: None"));
}
#[test]
fn jpeg_exif_info_partial_eq() {
let a = JpegExifInfo {
orientation: Some(1),
..Default::default()
};
let b = JpegExifInfo {
orientation: Some(1),
..Default::default()
};
let c = JpegExifInfo {
orientation: Some(2),
..Default::default()
};
assert_eq!(a, b);
assert_ne!(a, c);
}
#[test]
fn jpeg_exif_info_gps_negative_values() {
let info = JpegExifInfo {
gps_latitude: Some(-33.8688),
gps_longitude: Some(-151.2093),
gps_altitude: Some(-10.5),
..Default::default()
};
assert!(info.gps_latitude.unwrap() < 0.0);
assert!(info.gps_longitude.unwrap() < 0.0);
assert!(info.gps_altitude.unwrap() < 0.0);
}
#[test]
fn jpeg_exif_info_exposure_rational_precision() {
let info = JpegExifInfo {
exposure_time: Some((1, 8000)),
f_number: Some((14, 10)),
focal_length: Some((200, 1)),
..Default::default()
};
let (num, den) = info.exposure_time.unwrap();
assert_eq!(num, 1);
assert_eq!(den, 8000);
let exposure_secs = num as f64 / den as f64;
assert!((exposure_secs - 0.000125).abs() < 1e-10);
}
#[test]
fn jpeg_color_space_variants_constructible() {
let srgb = JpegColorSpace::Srgb;
let icc = JpegColorSpace::IccTagged;
assert_eq!(srgb, JpegColorSpace::Srgb);
assert_eq!(icc, JpegColorSpace::IccTagged);
assert_ne!(srgb, icc);
}
#[test]
fn jpeg_color_space_is_copy() {
let cs = JpegColorSpace::Srgb;
let cs2 = cs;
assert_eq!(cs, cs2);
}
#[test]
fn jpeg_color_space_debug() {
assert_eq!(format!("{:?}", JpegColorSpace::Srgb), "Srgb");
assert_eq!(format!("{:?}", JpegColorSpace::IccTagged), "IccTagged");
}
#[test]
fn jpeg_color_space_clone() {
let cs = JpegColorSpace::IccTagged;
let cloned = cs;
assert_eq!(cs, cloned);
}
#[test]
fn jpeg_pixel_density_dpi() {
let d = JpegPixelDensity::Dpi { x: 300, y: 300 };
match d {
JpegPixelDensity::Dpi { x, y } => {
assert_eq!(x, 300);
assert_eq!(y, 300);
}
_ => panic!("expected Dpi"),
}
}
#[test]
fn jpeg_pixel_density_dpcm() {
let d = JpegPixelDensity::Dpcm { x: 118, y: 118 };
match d {
JpegPixelDensity::Dpcm { x, y } => {
assert_eq!(x, 118);
assert_eq!(y, 118);
}
_ => panic!("expected Dpcm"),
}
}
#[test]
fn jpeg_pixel_density_aspect_ratio() {
let d = JpegPixelDensity::AspectRatio { x: 1, y: 2 };
match d {
JpegPixelDensity::AspectRatio { x, y } => {
assert_eq!(x, 1);
assert_eq!(y, 2);
}
_ => panic!("expected AspectRatio"),
}
}
#[test]
fn jpeg_pixel_density_is_copy() {
let d = JpegPixelDensity::Dpi { x: 72, y: 72 };
let d2 = d;
assert_eq!(d, d2);
}
#[test]
fn jpeg_pixel_density_debug() {
let d = JpegPixelDensity::Dpi { x: 300, y: 300 };
let dbg = format!("{:?}", d);
assert!(dbg.contains("Dpi"));
assert!(dbg.contains("300"));
}
#[test]
fn jpeg_pixel_density_eq() {
let a = JpegPixelDensity::Dpi { x: 300, y: 300 };
let b = JpegPixelDensity::Dpi { x: 300, y: 300 };
let c = JpegPixelDensity::Dpi { x: 72, y: 72 };
let d = JpegPixelDensity::Dpcm { x: 300, y: 300 };
assert_eq!(a, b);
assert_ne!(a, c);
assert_ne!(a, d);
}
#[test]
fn jpeg_pixel_density_non_square() {
let d = JpegPixelDensity::Dpi { x: 300, y: 600 };
match d {
JpegPixelDensity::Dpi { x, y } => {
assert_eq!(x, 300);
assert_eq!(y, 600);
}
_ => panic!("expected Dpi"),
}
}
#[test]
fn jpeg_pixel_density_all_variants_debug() {
let _ = format!("{:?}", JpegPixelDensity::Dpi { x: 1, y: 1 });
let _ = format!("{:?}", JpegPixelDensity::Dpcm { x: 1, y: 1 });
let _ = format!("{:?}", JpegPixelDensity::AspectRatio { x: 1, y: 1 });
}
fn make_minimal_metadata() -> JpegMetadata {
JpegMetadata {
exif: None,
raw_exif: None,
icc_profile: None,
pixel_density: None,
comments: vec![],
source_bit_depth: JpegBitDepth::Eight,
color_space: JpegColorSpace::Srgb,
}
}
#[test]
fn jpeg_metadata_minimal() {
let meta = make_minimal_metadata();
assert!(meta.exif.is_none());
assert!(meta.raw_exif.is_none());
assert!(meta.icc_profile.is_none());
assert!(meta.pixel_density.is_none());
assert!(meta.comments.is_empty());
assert_eq!(meta.source_bit_depth, JpegBitDepth::Eight);
assert_eq!(meta.color_space, JpegColorSpace::Srgb);
}
#[test]
fn jpeg_metadata_fully_populated() {
let exif = JpegExifInfo {
orientation: Some(1),
camera_make: Some("Sony".to_string()),
..Default::default()
};
let meta = JpegMetadata {
exif: Some(exif),
raw_exif: Some(vec![0x45, 0x78, 0x69, 0x66].into_boxed_slice()),
icc_profile: Some(vec![0u8; 128].into_boxed_slice()),
pixel_density: Some(JpegPixelDensity::Dpi { x: 300, y: 300 }),
comments: vec!["test comment".to_string(), "another".to_string()],
source_bit_depth: JpegBitDepth::Twelve,
color_space: JpegColorSpace::IccTagged,
};
assert!(meta.exif.is_some());
assert_eq!(meta.exif.as_ref().unwrap().orientation, Some(1));
assert!(meta.raw_exif.is_some());
assert!(meta.icc_profile.is_some());
assert_eq!(meta.icc_profile.as_ref().unwrap().len(), 128);
assert_eq!(
meta.pixel_density,
Some(JpegPixelDensity::Dpi { x: 300, y: 300 })
);
assert_eq!(meta.comments.len(), 2);
assert_eq!(meta.comments[0], "test comment");
assert_eq!(meta.source_bit_depth, JpegBitDepth::Twelve);
assert_eq!(meta.color_space, JpegColorSpace::IccTagged);
}
#[test]
fn jpeg_metadata_with_12bit_depth() {
let meta = JpegMetadata {
source_bit_depth: JpegBitDepth::Twelve,
..make_minimal_metadata()
};
assert_eq!(meta.source_bit_depth, JpegBitDepth::Twelve);
}
#[test]
fn jpeg_metadata_clone() {
let meta = JpegMetadata {
exif: Some(JpegExifInfo {
orientation: Some(3),
..Default::default()
}),
comments: vec!["hello".to_string()],
..make_minimal_metadata()
};
let cloned = meta.clone();
assert_eq!(
cloned.exif.as_ref().unwrap().orientation,
meta.exif.as_ref().unwrap().orientation
);
assert_eq!(cloned.comments, meta.comments);
assert_eq!(cloned.source_bit_depth, meta.source_bit_depth);
assert_eq!(cloned.color_space, meta.color_space);
}
#[test]
fn jpeg_metadata_debug() {
let meta = make_minimal_metadata();
let dbg = format!("{:?}", meta);
assert!(dbg.contains("JpegMetadata"));
assert!(dbg.contains("source_bit_depth"));
}
#[test]
fn jpeg_metadata_icc_profile_boxed_slice() {
let profile_data: Box<[u8]> = vec![1, 2, 3, 4].into_boxed_slice();
let meta = JpegMetadata {
icc_profile: Some(profile_data),
..make_minimal_metadata()
};
assert_eq!(meta.icc_profile.as_ref().unwrap().len(), 4);
assert_eq!(meta.icc_profile.as_ref().unwrap()[0], 1);
}
#[test]
fn jpeg_metadata_raw_exif_boxed_slice() {
let raw: Box<[u8]> = vec![0x45, 0x78, 0x69, 0x66, 0x00, 0x00].into_boxed_slice();
let meta = JpegMetadata {
raw_exif: Some(raw),
..make_minimal_metadata()
};
assert_eq!(meta.raw_exif.as_ref().unwrap().len(), 6);
}
#[test]
fn jpeg_metadata_multiple_comments() {
let meta = JpegMetadata {
comments: vec![
"comment 1".to_string(),
"comment 2".to_string(),
"comment 3".to_string(),
],
..make_minimal_metadata()
};
assert_eq!(meta.comments.len(), 3);
assert_eq!(meta.comments[2], "comment 3");
}
#[test]
fn jpeg_decoded_field_access() {
let decoded = JpegDecoded {
image: JpegImage::Srgb8(Image::fill(1, 1, Srgb8::new(0, 0, 0))),
metadata: make_minimal_metadata(),
};
let _img = decoded.image;
let _meta = decoded.metadata;
}
#[test]
fn jpeg_decoded_with_srgb_mono8() {
let decoded = JpegDecoded {
image: JpegImage::SrgbMono8(Image::fill(10, 10, SrgbMono8::new(128))),
metadata: make_minimal_metadata(),
};
match &decoded.image {
JpegImage::SrgbMono8(img) => {
assert_eq!(img.width(), 10);
assert_eq!(img.height(), 10);
}
_ => panic!("expected SrgbMono8"),
}
}
#[test]
fn jpeg_decoded_with_srgb_mono16() {
let decoded = JpegDecoded {
image: JpegImage::SrgbMono16(Image::fill(5, 5, SrgbMono16::new(1024))),
metadata: JpegMetadata {
source_bit_depth: JpegBitDepth::Twelve,
..make_minimal_metadata()
},
};
match &decoded.image {
JpegImage::SrgbMono16(img) => {
assert_eq!(img.width(), 5);
assert_eq!(img.height(), 5);
}
_ => panic!("expected SrgbMono16"),
}
assert_eq!(decoded.metadata.source_bit_depth, JpegBitDepth::Twelve);
}
#[test]
fn jpeg_decoded_with_full_metadata() {
let img = Image::fill(1, 1, Srgb8::new(0, 0, 0));
let decoded = JpegDecoded {
image: JpegImage::Srgb8(img),
metadata: JpegMetadata {
exif: Some(JpegExifInfo {
orientation: Some(6),
camera_make: Some("TestCam".to_string()),
..Default::default()
}),
raw_exif: Some(vec![0xAA, 0xBB].into_boxed_slice()),
icc_profile: Some(vec![0xCC, 0xDD].into_boxed_slice()),
pixel_density: Some(JpegPixelDensity::Dpi { x: 300, y: 300 }),
comments: vec!["photo comment".to_string()],
source_bit_depth: JpegBitDepth::Eight,
color_space: JpegColorSpace::IccTagged,
},
};
match &decoded.image {
JpegImage::Srgb8(img) => {
assert_eq!(img.width(), 1);
assert_eq!(img.height(), 1);
}
_ => panic!("expected Srgb8"),
}
let exif = decoded.metadata.exif.as_ref().unwrap();
assert_eq!(exif.orientation, Some(6));
assert_eq!(exif.camera_make.as_deref(), Some("TestCam"));
assert_eq!(decoded.metadata.color_space, JpegColorSpace::IccTagged);
}
#[test]
fn jpeg_decoded_debug() {
let decoded = JpegDecoded {
image: JpegImage::Srgb8(Image::fill(2, 2, Srgb8::new(0, 0, 0))),
metadata: make_minimal_metadata(),
};
let dbg = format!("{:?}", decoded);
assert!(dbg.contains("JpegDecoded"));
assert!(dbg.contains("Srgb8(2x2)"));
assert!(dbg.contains("JpegMetadata"));
}
#[test]
fn jpeg_image_exhaustive_match() {
let img = JpegImage::Srgb8(Image::fill(1, 1, Srgb8::new(0, 0, 0)));
let name = match &img {
JpegImage::SrgbMono8(_) => "SrgbMono8",
JpegImage::SrgbMono16(_) => "SrgbMono16",
JpegImage::Srgb8(_) => "Srgb8",
};
assert_eq!(name, "Srgb8");
}
#[test]
fn jpeg_color_space_exhaustive_match() {
let cs = JpegColorSpace::Srgb;
let name = match cs {
JpegColorSpace::Srgb => "Srgb",
JpegColorSpace::IccTagged => "IccTagged",
};
assert_eq!(name, "Srgb");
}
#[test]
fn jpeg_pixel_density_exhaustive_match() {
let d = JpegPixelDensity::Dpi { x: 72, y: 72 };
let kind = match d {
JpegPixelDensity::Dpi { .. } => "dpi",
JpegPixelDensity::Dpcm { .. } => "dpcm",
JpegPixelDensity::AspectRatio { .. } => "aspect",
};
assert_eq!(kind, "dpi");
}
#[test]
fn jpeg_color_space_is_small() {
assert!(mem::size_of::<JpegColorSpace>() <= 2);
}
#[test]
fn jpeg_pixel_density_is_small() {
assert!(mem::size_of::<JpegPixelDensity>() <= 8);
}
#[test]
fn jpeg_metadata_is_reasonable_size() {
let size = mem::size_of::<JpegMetadata>();
assert!(
size < 512,
"JpegMetadata is {size} bytes — expected under 512"
);
}
fn variant_name(img: &JpegImage) -> &'static str {
match img {
JpegImage::SrgbMono8(_) => "SrgbMono8",
JpegImage::SrgbMono16(_) => "SrgbMono16",
JpegImage::Srgb8(_) => "Srgb8",
}
}
#[test]
fn variant_name_covers_all() {
assert_eq!(
variant_name(&JpegImage::SrgbMono8(Image::fill(1, 1, SrgbMono8::new(0)))),
"SrgbMono8"
);
assert_eq!(
variant_name(&JpegImage::SrgbMono16(Image::fill(
1,
1,
SrgbMono16::new(0)
))),
"SrgbMono16"
);
assert_eq!(
variant_name(&JpegImage::Srgb8(Image::fill(1, 1, Srgb8::new(0, 0, 0)))),
"Srgb8"
);
}
#[test]
fn jpeg_image_preserves_dimensions_mono8() {
let img = JpegImage::SrgbMono8(Image::fill(100, 200, SrgbMono8::new(42)));
match &img {
JpegImage::SrgbMono8(i) => {
assert_eq!(i.width(), 100);
assert_eq!(i.height(), 200);
}
_ => panic!("wrong variant"),
}
}
#[test]
fn jpeg_image_preserves_dimensions_mono16() {
let img = JpegImage::SrgbMono16(Image::fill(50, 75, SrgbMono16::new(1000)));
match &img {
JpegImage::SrgbMono16(i) => {
assert_eq!(i.width(), 50);
assert_eq!(i.height(), 75);
}
_ => panic!("wrong variant"),
}
}
#[test]
fn jpeg_image_preserves_dimensions_srgb8() {
let img = JpegImage::Srgb8(Image::fill(1920, 1080, Srgb8::new(128, 64, 32)));
match &img {
JpegImage::Srgb8(i) => {
assert_eq!(i.width(), 1920);
assert_eq!(i.height(), 1080);
}
_ => panic!("wrong variant"),
}
}
#[test]
fn jpeg_image_pixel_data_accessible() {
let img = JpegImage::Srgb8(Image::generate(3, 2, |x, y| {
Srgb8::new(x as u8, y as u8, 0)
}));
match &img {
JpegImage::Srgb8(i) => {
assert_eq!(i.get(0, 0).unwrap().r.0, 0);
assert_eq!(i.get(0, 0).unwrap().g.0, 0);
assert_eq!(i.get(2, 1).unwrap().r.0, 2);
assert_eq!(i.get(2, 1).unwrap().g.0, 1);
}
_ => panic!("wrong variant"),
}
}
#[test]
fn tiff_reader_u8_at() {
let data = [0xAA, 0xBB, 0xCC];
let r = TiffReader::new(&data, ByteOrder::Little);
assert_eq!(r.u8_at(0), Some(0xAA));
assert_eq!(r.u8_at(1), Some(0xBB));
assert_eq!(r.u8_at(2), Some(0xCC));
assert_eq!(r.u8_at(3), None);
}
#[test]
fn tiff_reader_u16_little_endian() {
let data = [0x34, 0x12]; let r = TiffReader::new(&data, ByteOrder::Little);
assert_eq!(r.u16_at(0), Some(0x1234));
}
#[test]
fn tiff_reader_u16_big_endian() {
let data = [0x12, 0x34]; let r = TiffReader::new(&data, ByteOrder::Big);
assert_eq!(r.u16_at(0), Some(0x1234));
}
#[test]
fn tiff_reader_u16_out_of_bounds() {
let data = [0x12];
let r = TiffReader::new(&data, ByteOrder::Little);
assert_eq!(r.u16_at(0), None);
}
#[test]
fn tiff_reader_u16_at_offset() {
let data = [0x00, 0x00, 0x78, 0x56]; let r = TiffReader::new(&data, ByteOrder::Little);
assert_eq!(r.u16_at(2), Some(0x5678));
}
#[test]
fn tiff_reader_u32_little_endian() {
let data = [0x78, 0x56, 0x34, 0x12]; let r = TiffReader::new(&data, ByteOrder::Little);
assert_eq!(r.u32_at(0), Some(0x12345678));
}
#[test]
fn tiff_reader_u32_big_endian() {
let data = [0x12, 0x34, 0x56, 0x78]; let r = TiffReader::new(&data, ByteOrder::Big);
assert_eq!(r.u32_at(0), Some(0x12345678));
}
#[test]
fn tiff_reader_u32_out_of_bounds() {
let data = [0x12, 0x34, 0x56];
let r = TiffReader::new(&data, ByteOrder::Little);
assert_eq!(r.u32_at(0), None);
}
#[test]
fn tiff_reader_u32_partial_overlap() {
let data = [0x01, 0x02, 0x03, 0x04, 0x05];
let r = TiffReader::new(&data, ByteOrder::Little);
assert!(r.u32_at(0).is_some());
assert_eq!(r.u32_at(2), None);
}
#[test]
fn tiff_reader_rational_little_endian() {
let mut data = vec![];
data.extend_from_slice(&1u32.to_le_bytes());
data.extend_from_slice(&250u32.to_le_bytes());
let r = TiffReader::new(&data, ByteOrder::Little);
assert_eq!(r.rational_at(0), Some((1, 250)));
}
#[test]
fn tiff_reader_rational_big_endian() {
let mut data = vec![];
data.extend_from_slice(&1u32.to_be_bytes());
data.extend_from_slice(&250u32.to_be_bytes());
let r = TiffReader::new(&data, ByteOrder::Big);
assert_eq!(r.rational_at(0), Some((1, 250)));
}
#[test]
fn tiff_reader_rational_out_of_bounds() {
let data = [0u8; 7];
let r = TiffReader::new(&data, ByteOrder::Little);
assert_eq!(r.rational_at(0), None);
}
#[test]
fn tiff_reader_rational_at_offset() {
let mut data = vec![0u8; 4];
data.extend_from_slice(&50u32.to_le_bytes());
data.extend_from_slice(&1u32.to_le_bytes());
let r = TiffReader::new(&data, ByteOrder::Little);
assert_eq!(r.rational_at(4), Some((50, 1)));
}
#[test]
fn tiff_reader_empty_data() {
let data: [u8; 0] = [];
let r = TiffReader::new(&data, ByteOrder::Little);
assert_eq!(r.u8_at(0), None);
assert_eq!(r.u16_at(0), None);
assert_eq!(r.u32_at(0), None);
assert_eq!(r.rational_at(0), None);
assert_eq!(r.len(), 0);
}
#[test]
fn tiff_reader_len() {
let data = [1, 2, 3, 4, 5];
let r = TiffReader::new(&data, ByteOrder::Big);
assert_eq!(r.len(), 5);
}
#[test]
fn tiff_reader_u16_max_offset_no_overflow() {
let data = [0xFFu8; 4];
let r = TiffReader::new(&data, ByteOrder::Little);
assert_eq!(r.u16_at(usize::MAX), None);
assert_eq!(r.u32_at(usize::MAX), None);
assert_eq!(r.rational_at(usize::MAX), None);
}
#[test]
fn byte_order_debug_and_eq() {
let le = ByteOrder::Little;
let be = ByteOrder::Big;
assert_eq!(le, ByteOrder::Little);
assert_ne!(le, be);
assert_eq!(format!("{:?}", le), "Little");
assert_eq!(format!("{:?}", be), "Big");
let le2 = le;
assert_eq!(le, le2);
}
fn build_ifd_le(entries: &[(u16, u16, u32, u32)]) -> Vec<u8> {
let mut buf = Vec::new();
buf.extend_from_slice(&(entries.len() as u16).to_le_bytes());
for &(tag, typ, count, val) in entries {
buf.extend_from_slice(&tag.to_le_bytes());
buf.extend_from_slice(&typ.to_le_bytes());
buf.extend_from_slice(&count.to_le_bytes());
buf.extend_from_slice(&val.to_le_bytes());
}
buf
}
fn build_ifd_be(entries: &[(u16, u16, u32, u32)]) -> Vec<u8> {
let mut buf = Vec::new();
buf.extend_from_slice(&(entries.len() as u16).to_be_bytes());
for &(tag, typ, count, val) in entries {
buf.extend_from_slice(&tag.to_be_bytes());
buf.extend_from_slice(&typ.to_be_bytes());
buf.extend_from_slice(&count.to_be_bytes());
buf.extend_from_slice(&val.to_be_bytes());
}
buf
}
#[test]
fn read_ifd_single_entry_le() {
let data = build_ifd_le(&[(0x010F, 2, 6, 100)]);
let r = TiffReader::new(&data, ByteOrder::Little);
let entries = read_ifd_entries(&r, 0);
assert_eq!(entries.len(), 1);
assert_eq!(
entries[0],
IfdEntry {
tag: 0x010F,
tiff_type: 2,
count: 6,
value_or_offset: 100
}
);
}
#[test]
fn read_ifd_single_entry_be() {
let data = build_ifd_be(&[(0x010F, 2, 6, 100)]);
let r = TiffReader::new(&data, ByteOrder::Big);
let entries = read_ifd_entries(&r, 0);
assert_eq!(entries.len(), 1);
assert_eq!(
entries[0],
IfdEntry {
tag: 0x010F,
tiff_type: 2,
count: 6,
value_or_offset: 100
}
);
}
#[test]
fn read_ifd_multiple_entries() {
let data = build_ifd_le(&[(0x010F, 2, 6, 100), (0x0110, 2, 10, 200), (0x0112, 3, 1, 6)]);
let r = TiffReader::new(&data, ByteOrder::Little);
let entries = read_ifd_entries(&r, 0);
assert_eq!(entries.len(), 3);
assert_eq!(entries[0].tag, 0x010F);
assert_eq!(entries[1].tag, 0x0110);
assert_eq!(entries[2].tag, 0x0112);
assert_eq!(entries[2].value_or_offset, 6); }
#[test]
fn read_ifd_zero_entries() {
let data = [0u8, 0]; let r = TiffReader::new(&data, ByteOrder::Little);
let entries = read_ifd_entries(&r, 0);
assert!(entries.is_empty());
}
#[test]
fn read_ifd_empty_data() {
let data: [u8; 0] = [];
let r = TiffReader::new(&data, ByteOrder::Little);
let entries = read_ifd_entries(&r, 0);
assert!(entries.is_empty());
}
#[test]
fn read_ifd_out_of_bounds_offset() {
let data = build_ifd_le(&[(0x010F, 2, 6, 100)]);
let r = TiffReader::new(&data, ByteOrder::Little);
let entries = read_ifd_entries(&r, 9999);
assert!(entries.is_empty());
}
#[test]
fn read_ifd_truncated_entry() {
let mut data = build_ifd_le(&[(0x010F, 2, 6, 100), (0x0110, 2, 10, 200)]);
data.truncate(2 + 12 + 6); let r = TiffReader::new(&data, ByteOrder::Little);
let entries = read_ifd_entries(&r, 0);
assert_eq!(entries.len(), 1);
}
#[test]
fn read_ifd_capped_at_max() {
let mut data = Vec::new();
data.extend_from_slice(&2000u16.to_le_bytes()); for _ in 0..2 {
data.extend_from_slice(&0x0100u16.to_le_bytes());
data.extend_from_slice(&3u16.to_le_bytes());
data.extend_from_slice(&1u32.to_le_bytes());
data.extend_from_slice(&42u32.to_le_bytes());
}
let r = TiffReader::new(&data, ByteOrder::Little);
let entries = read_ifd_entries(&r, 0);
assert_eq!(entries.len(), 2);
}
#[test]
fn read_ifd_at_nonzero_offset() {
let mut data = vec![0u8; 8];
data.extend_from_slice(&build_ifd_le(&[(0x0112, 3, 1, 1)]));
let r = TiffReader::new(&data, ByteOrder::Little);
let entries = read_ifd_entries(&r, 8);
assert_eq!(entries.len(), 1);
assert_eq!(
entries[0],
IfdEntry {
tag: 0x0112,
tiff_type: 3,
count: 1,
value_or_offset: 1
}
);
}
#[test]
fn read_ifd_preserves_value_or_offset() {
let data = build_ifd_le(&[
(0x0112, 3, 1, 6),
(0x8769, 4, 1, 99999),
(0x010F, 2, 20, 500),
]);
let r = TiffReader::new(&data, ByteOrder::Little);
let entries = read_ifd_entries(&r, 0);
assert_eq!(
entries[0],
IfdEntry {
tag: 0x0112,
tiff_type: 3,
count: 1,
value_or_offset: 6
}
);
assert_eq!(
entries[1],
IfdEntry {
tag: 0x8769,
tiff_type: 4,
count: 1,
value_or_offset: 99999
}
);
assert_eq!(
entries[2],
IfdEntry {
tag: 0x010F,
tiff_type: 2,
count: 20,
value_or_offset: 500
}
);
}
#[test]
fn read_ascii_simple() {
let data = b"Canon\0";
let r = TiffReader::new(data, ByteOrder::Little);
assert_eq!(read_ascii(&r, 0, 6), Some("Canon".to_string()));
}
#[test]
fn read_ascii_no_nul() {
let data = b"Nikon";
let r = TiffReader::new(data, ByteOrder::Little);
assert_eq!(read_ascii(&r, 0, 5), Some("Nikon".to_string()));
}
#[test]
fn read_ascii_multiple_trailing_nuls() {
let data = b"Test\0\0\0";
let r = TiffReader::new(data, ByteOrder::Little);
assert_eq!(read_ascii(&r, 0, 7), Some("Test".to_string()));
}
#[test]
fn read_ascii_all_nuls() {
let data = [0u8; 4];
let r = TiffReader::new(&data, ByteOrder::Little);
assert_eq!(read_ascii(&r, 0, 4), None);
}
#[test]
fn read_ascii_empty_count() {
let data = b"Hello\0";
let r = TiffReader::new(data, ByteOrder::Little);
assert_eq!(read_ascii(&r, 0, 0), None);
}
#[test]
fn read_ascii_out_of_bounds() {
let data = b"Hi\0";
let r = TiffReader::new(data, ByteOrder::Little);
assert_eq!(read_ascii(&r, 0, 10), None);
}
#[test]
fn read_ascii_at_offset() {
let mut data = vec![0u8; 10];
data.extend_from_slice(b"Sony\0");
let r = TiffReader::new(&data, ByteOrder::Little);
assert_eq!(read_ascii(&r, 10, 5), Some("Sony".to_string()));
}
#[test]
fn read_ascii_datetime_format() {
let data = b"2025:01:15 12:30:00\0";
let r = TiffReader::new(data, ByteOrder::Little);
let result = read_ascii(&r, 0, 20);
assert_eq!(result, Some("2025:01:15 12:30:00".to_string()));
}
#[test]
fn read_ascii_utf8_passthrough() {
let data = "Müller\0".as_bytes();
let r = TiffReader::new(data, ByteOrder::Little);
let result = read_ascii(&r, 0, data.len() as u32);
assert_eq!(result, Some("Müller".to_string()));
}
#[test]
fn read_ascii_latin1_fallback() {
let data = [0x4Du8, 0xFC, 0x6C, 0x6C, 0x65, 0x72, 0x00]; let r = TiffReader::new(&data, ByteOrder::Little);
let result = read_ascii(&r, 0, 7);
assert_eq!(result, Some("Müller".to_string()));
}
#[test]
fn read_ascii_single_char() {
let data = b"A\0";
let r = TiffReader::new(data, ByteOrder::Little);
assert_eq!(read_ascii(&r, 0, 2), Some("A".to_string()));
}
#[test]
fn read_ascii_single_nul() {
let data = [0u8];
let r = TiffReader::new(&data, ByteOrder::Little);
assert_eq!(read_ascii(&r, 0, 1), None);
}
#[test]
fn read_ifd_ascii_inline_short_string() {
let mut data = Vec::new();
data.extend_from_slice(b"OK\0\0");
let r = TiffReader::new(&data, ByteOrder::Little);
let result = read_ifd_ascii(&r, 3, 0 , 0);
assert_eq!(result, Some("OK".to_string()));
}
#[test]
fn read_ifd_ascii_offset_referenced() {
let mut data = vec![0u8; 50];
let s = b"Canon EOS R5\0";
data[30..30 + s.len()].copy_from_slice(s);
let r = TiffReader::new(&data, ByteOrder::Little);
let result = read_ifd_ascii(&r, 13, 30, 0 );
assert_eq!(result, Some("Canon EOS R5".to_string()));
}
#[test]
fn read_ifd_ascii_inline_4_bytes_exact() {
let data = b"RGB\0";
let r = TiffReader::new(data, ByteOrder::Little);
let result = read_ifd_ascii(&r, 4, 0, 0);
assert_eq!(result, Some("RGB".to_string()));
}
#[test]
fn read_ifd_ascii_inline_1_byte() {
let data = b"N\0\0\0";
let r = TiffReader::new(data, ByteOrder::Little);
let result = read_ifd_ascii(&r, 2, 0, 0);
assert_eq!(result, Some("N".to_string()));
}
#[test]
fn parse_tiff_exif_empty() {
assert_eq!(parse_tiff_exif(b""), None);
}
#[test]
fn parse_tiff_exif_too_short() {
assert_eq!(parse_tiff_exif(b"II\x2a\x00"), None);
}
#[test]
fn parse_tiff_exif_valid_le_zero_entries() {
let mut data = vec![0u8; 14];
data[0..2].copy_from_slice(b"II");
data[2..4].copy_from_slice(&42u16.to_le_bytes());
data[4..8].copy_from_slice(&8u32.to_le_bytes());
data[8..10].copy_from_slice(&0u16.to_le_bytes());
data[10..14].copy_from_slice(&0u32.to_le_bytes());
let info = parse_tiff_exif(&data).unwrap();
assert_eq!(info, JpegExifInfo::default());
}
#[test]
fn parse_tiff_exif_matches_parse_exif() {
let data = build_exif_le(&[(0x0112, 3, 1, 3)], &[]);
let from_exif = parse_exif(&data).unwrap();
let from_tiff = parse_tiff_exif(&data[6..]).unwrap();
assert_eq!(from_exif, from_tiff);
}
#[test]
fn dms_to_decimal_north_latitude() {
let result = dms_to_decimal((40, 1), (26, 1), (46, 1), b'N');
let val = result.unwrap();
assert!((val - 40.44611111).abs() < 1e-6, "got {val}");
}
#[test]
fn dms_to_decimal_south_latitude() {
let result = dms_to_decimal((33, 1), (51, 1), (54, 1), b'S');
let val = result.unwrap();
assert!(val < 0.0, "South should be negative");
assert!((val - (-33.865)).abs() < 1e-6, "got {val}");
}
#[test]
fn dms_to_decimal_east_longitude() {
let result = dms_to_decimal((79, 1), (58, 1), (56, 1), b'E');
let val = result.unwrap();
assert!(val > 0.0);
assert!((val - 79.98222222).abs() < 1e-6, "got {val}");
}
#[test]
fn dms_to_decimal_west_longitude() {
let result = dms_to_decimal((73, 1), (59, 1), (11, 1), b'W');
let val = result.unwrap();
assert!(val < 0.0, "West should be negative");
assert!((val - (-73.98638888)).abs() < 1e-6, "got {val}");
}
#[test]
fn dms_to_decimal_zero_coordinates() {
let result = dms_to_decimal((0, 1), (0, 1), (0, 1), b'N');
assert_eq!(result, Some(0.0));
}
#[test]
fn dms_to_decimal_fractional_seconds() {
let result = dms_to_decimal((40, 1), (26, 1), (93, 2), b'N');
let val = result.unwrap();
assert!((val - 40.44625).abs() < 1e-8, "got {val}");
}
#[test]
fn dms_to_decimal_fractional_degrees() {
let result = dms_to_decimal((40446111, 1000000), (0, 1), (0, 1), b'N');
let val = result.unwrap();
assert!((val - 40.446111).abs() < 1e-6, "got {val}");
}
#[test]
fn dms_to_decimal_zero_denominator_degrees() {
assert_eq!(dms_to_decimal((40, 0), (26, 1), (46, 1), b'N'), None);
}
#[test]
fn dms_to_decimal_zero_denominator_minutes() {
assert_eq!(dms_to_decimal((40, 1), (26, 0), (46, 1), b'N'), None);
}
#[test]
fn dms_to_decimal_zero_denominator_seconds() {
assert_eq!(dms_to_decimal((40, 1), (26, 1), (46, 0), b'N'), None);
}
#[test]
fn dms_to_decimal_degrees_out_of_range() {
assert_eq!(dms_to_decimal((360, 1), (0, 1), (0, 1), b'N'), None);
assert_eq!(dms_to_decimal((500, 1), (0, 1), (0, 1), b'E'), None);
}
#[test]
fn dms_to_decimal_minutes_out_of_range() {
assert_eq!(dms_to_decimal((40, 1), (60, 1), (0, 1), b'N'), None);
assert_eq!(dms_to_decimal((40, 1), (99, 1), (0, 1), b'N'), None);
}
#[test]
fn dms_to_decimal_seconds_out_of_range() {
assert_eq!(dms_to_decimal((40, 1), (26, 1), (60, 1), b'N'), None);
assert_eq!(dms_to_decimal((40, 1), (26, 1), (120, 1), b'N'), None);
}
#[test]
fn dms_to_decimal_invalid_ref_char() {
assert_eq!(dms_to_decimal((40, 1), (26, 1), (46, 1), b'X'), None);
assert_eq!(dms_to_decimal((40, 1), (26, 1), (46, 1), b'n'), None);
assert_eq!(dms_to_decimal((40, 1), (26, 1), (46, 1), 0), None);
}
#[test]
fn dms_to_decimal_max_valid() {
let result = dms_to_decimal((359, 1), (59, 1), (59, 1), b'N');
let val = result.unwrap();
assert!(val < 360.0);
assert!(val > 359.99);
}
fn put_u16_le(buf: &mut [u8], offset: usize, val: u16) {
buf[offset..offset + 2].copy_from_slice(&val.to_le_bytes());
}
fn put_u32_le(buf: &mut [u8], offset: usize, val: u32) {
buf[offset..offset + 4].copy_from_slice(&val.to_le_bytes());
}
fn put_u16_be(buf: &mut [u8], offset: usize, val: u16) {
buf[offset..offset + 2].copy_from_slice(&val.to_be_bytes());
}
fn put_u32_be(buf: &mut [u8], offset: usize, val: u32) {
buf[offset..offset + 4].copy_from_slice(&val.to_be_bytes());
}
fn build_exif_le(entries: &[(u16, u16, u32, u32)], extra: &[u8]) -> Vec<u8> {
let ifd0_tiff_offset: u32 = 8; let entry_count = entries.len();
let ifd_size = 2 + entry_count * 12 + 4; let tiff_size = 8 + ifd_size + extra.len(); let total = 6 + tiff_size;
let mut buf = vec![0u8; total];
buf[0..6].copy_from_slice(b"Exif\0\0");
buf[6..8].copy_from_slice(b"II"); put_u16_le(&mut buf, 8, 42); put_u32_le(&mut buf, 10, ifd0_tiff_offset);
let ifd_raw_start = 14;
put_u16_le(&mut buf, ifd_raw_start, entry_count as u16);
for (i, &(tag, typ, count, val)) in entries.iter().enumerate() {
let base = ifd_raw_start + 2 + i * 12;
put_u16_le(&mut buf, base, tag);
put_u16_le(&mut buf, base + 2, typ);
put_u32_le(&mut buf, base + 4, count);
put_u32_le(&mut buf, base + 8, val);
}
let next_ifd_off = ifd_raw_start + 2 + entry_count * 12;
put_u32_le(&mut buf, next_ifd_off, 0);
let extra_start = next_ifd_off + 4;
buf[extra_start..extra_start + extra.len()].copy_from_slice(extra);
buf
}
fn build_exif_be(entries: &[(u16, u16, u32, u32)], extra: &[u8]) -> Vec<u8> {
let ifd0_tiff_offset: u32 = 8;
let entry_count = entries.len();
let ifd_size = 2 + entry_count * 12 + 4;
let tiff_size = 8 + ifd_size + extra.len();
let total = 6 + tiff_size;
let mut buf = vec![0u8; total];
buf[0..6].copy_from_slice(b"Exif\0\0");
buf[6..8].copy_from_slice(b"MM"); put_u16_be(&mut buf, 8, 42);
put_u32_be(&mut buf, 10, ifd0_tiff_offset);
let ifd_raw_start = 14;
put_u16_be(&mut buf, ifd_raw_start, entry_count as u16);
for (i, &(tag, typ, count, val)) in entries.iter().enumerate() {
let base = ifd_raw_start + 2 + i * 12;
put_u16_be(&mut buf, base, tag);
put_u16_be(&mut buf, base + 2, typ);
put_u32_be(&mut buf, base + 4, count);
put_u32_be(&mut buf, base + 8, val);
}
let next_ifd_off = ifd_raw_start + 2 + entry_count * 12;
put_u32_be(&mut buf, next_ifd_off, 0);
let extra_start = next_ifd_off + 4;
buf[extra_start..extra_start + extra.len()].copy_from_slice(extra);
buf
}
fn extra_tiff_offset(entry_count: usize) -> u32 {
(8 + 2 + entry_count * 12 + 4) as u32
}
#[test]
fn parse_exif_completely_empty() {
assert_eq!(parse_exif(b""), None);
}
#[test]
fn parse_exif_too_short() {
assert_eq!(parse_exif(b"Exif\0\0II"), None);
}
#[test]
fn parse_exif_wrong_header() {
let mut data = build_exif_le(&[], &[]);
data[0..4].copy_from_slice(b"JFIF");
assert_eq!(parse_exif(&data), None);
}
#[test]
fn parse_exif_wrong_magic() {
let mut data = build_exif_le(&[], &[]);
put_u16_le(&mut data, 8, 99);
assert_eq!(parse_exif(&data), None);
}
#[test]
fn parse_exif_wrong_byte_order() {
let mut data = build_exif_le(&[], &[]);
data[6..8].copy_from_slice(b"XX");
assert_eq!(parse_exif(&data), None);
}
#[test]
fn parse_exif_valid_header_zero_entries() {
let data = build_exif_le(&[], &[]);
let info = parse_exif(&data).unwrap();
assert_eq!(info, JpegExifInfo::default());
}
#[test]
fn parse_exif_valid_header_zero_entries_be() {
let data = build_exif_be(&[], &[]);
let info = parse_exif(&data).unwrap();
assert_eq!(info, JpegExifInfo::default());
}
#[test]
fn parse_exif_orientation_le() {
let data = build_exif_le(&[(0x0112, 3, 1, 6)], &[]);
let info = parse_exif(&data).unwrap();
assert_eq!(info.orientation, Some(6));
}
#[test]
fn parse_exif_orientation_be() {
let data = build_exif_be(&[(0x0112, 3, 1, 6 << 16)], &[]);
let info = parse_exif(&data).unwrap();
assert_eq!(info.orientation, Some(6));
}
#[test]
fn parse_exif_orientation_all_valid_values() {
for v in 1u16..=8 {
let data = build_exif_le(&[(0x0112, 3, 1, v as u32)], &[]);
let info = parse_exif(&data).unwrap();
assert_eq!(info.orientation, Some(v as u8), "orientation {v}");
}
}
#[test]
fn parse_exif_orientation_zero_invalid() {
let data = build_exif_le(&[(0x0112, 3, 1, 0)], &[]);
let info = parse_exif(&data).unwrap();
assert_eq!(info.orientation, None);
}
#[test]
fn parse_exif_orientation_9_invalid() {
let data = build_exif_le(&[(0x0112, 3, 1, 9)], &[]);
let info = parse_exif(&data).unwrap();
assert_eq!(info.orientation, None);
}
#[test]
fn parse_exif_orientation_wrong_type_ignored() {
let data = build_exif_le(&[(0x0112, 4, 1, 6)], &[]);
let info = parse_exif(&data).unwrap();
assert_eq!(info.orientation, None);
}
#[test]
fn parse_exif_make_inline() {
let val = u32::from_le_bytes([b'H', b'i', b'!', 0]);
let data = build_exif_le(&[(0x010F, 2, 4, val)], &[]);
let info = parse_exif(&data).unwrap();
assert_eq!(info.camera_make, Some("Hi!".to_string()));
}
#[test]
fn parse_exif_model_offset_referenced() {
let extra_off = extra_tiff_offset(1);
let model_bytes = b"Canon\0";
let data = build_exif_le(&[(0x0110, 2, 6, extra_off)], model_bytes);
let info = parse_exif(&data).unwrap();
assert_eq!(info.camera_model, Some("Canon".to_string()));
}
#[test]
fn parse_exif_software_offset_referenced() {
let extra_off = extra_tiff_offset(1);
let sw_bytes = b"Lightroom 6.0\0";
let data = build_exif_le(&[(0x0131, 2, 14, extra_off)], sw_bytes);
let info = parse_exif(&data).unwrap();
assert_eq!(info.software, Some("Lightroom 6.0".to_string()));
}
#[test]
fn parse_exif_datetime_offset_referenced() {
let extra_off = extra_tiff_offset(1);
let dt_bytes = b"2025:01:15 12:30:00\0";
let data = build_exif_le(&[(0x0132, 2, 20, extra_off)], dt_bytes);
let info = parse_exif(&data).unwrap();
assert_eq!(info.datetime, Some("2025:01:15 12:30:00".to_string()));
}
#[test]
fn parse_exif_ascii_wrong_type_ignored() {
let data = build_exif_le(&[(0x010F, 3, 1, 42)], &[]);
let info = parse_exif(&data).unwrap();
assert_eq!(info.camera_make, None);
}
#[test]
fn parse_exif_multiple_ifd0_fields() {
let make_str = b"Nikon\0";
let model_str = b"D850\0\0"; let dt_str = b"2024:06:01 09:15:30\0";
let mut extra = Vec::new();
extra.extend_from_slice(make_str); extra.extend_from_slice(model_str); extra.extend_from_slice(dt_str);
let base_off = extra_tiff_offset(4); let entries = [
(0x010F, 2u16, 6u32, base_off), (0x0110, 2, 6, base_off + 6), (0x0112, 3, 1, 1u32), (0x0132, 2, 20, base_off + 12), ];
let data = build_exif_le(&entries, &extra);
let info = parse_exif(&data).unwrap();
assert_eq!(info.camera_make, Some("Nikon".to_string()));
assert_eq!(info.camera_model, Some("D850".to_string()));
assert_eq!(info.orientation, Some(1));
assert_eq!(info.datetime, Some("2024:06:01 09:15:30".to_string()));
}
fn build_exif_with_sub_ifd_le(
exif_entries: &[(u16, u16, u32, u32)],
extra: &[u8],
) -> (Vec<u8>, u32) {
let ifd0_tiff_off: u32 = 8;
let ifd0_size = 2 + 1 * 12 + 4; let exif_ifd_tiff_off = ifd0_tiff_off + ifd0_size as u32;
let exif_entry_count = exif_entries.len();
let exif_ifd_size = 2 + exif_entry_count * 12 + 4;
let extra_tiff_off = exif_ifd_tiff_off + exif_ifd_size as u32;
let tiff_size = 8 + ifd0_size + exif_ifd_size + extra.len();
let total = 6 + tiff_size;
let mut buf = vec![0u8; total];
buf[0..6].copy_from_slice(b"Exif\0\0");
buf[6..8].copy_from_slice(b"II");
put_u16_le(&mut buf, 8, 42);
put_u32_le(&mut buf, 10, ifd0_tiff_off);
let ifd0_raw = 6 + ifd0_tiff_off as usize;
put_u16_le(&mut buf, ifd0_raw, 1); let e0 = ifd0_raw + 2;
put_u16_le(&mut buf, e0, 0x8769);
put_u16_le(&mut buf, e0 + 2, 4); put_u32_le(&mut buf, e0 + 4, 1); put_u32_le(&mut buf, e0 + 8, exif_ifd_tiff_off);
put_u32_le(&mut buf, e0 + 12, 0);
let exif_raw = 6 + exif_ifd_tiff_off as usize;
put_u16_le(&mut buf, exif_raw, exif_entry_count as u16);
for (i, &(tag, typ, count, val)) in exif_entries.iter().enumerate() {
let base = exif_raw + 2 + i * 12;
put_u16_le(&mut buf, base, tag);
put_u16_le(&mut buf, base + 2, typ);
put_u32_le(&mut buf, base + 4, count);
put_u32_le(&mut buf, base + 8, val);
}
let next_ptr = exif_raw + 2 + exif_entry_count * 12;
put_u32_le(&mut buf, next_ptr, 0);
let extra_raw = 6 + extra_tiff_off as usize;
buf[extra_raw..extra_raw + extra.len()].copy_from_slice(extra);
(buf, extra_tiff_off)
}
#[test]
fn parse_exif_sub_ifd_exposure_time() {
let mut extra = [0u8; 8];
put_u32_le(&mut extra, 0, 1); put_u32_le(&mut extra, 4, 250);
let (data, extra_off) = build_exif_with_sub_ifd_le(
&[(0x829A, 5, 1, 0)], &extra,
);
let mut data = data;
let ifd0_size = 2 + 1 * 12 + 4;
let exif_ifd_tiff_off = 8 + ifd0_size as usize;
let val_off_raw = 6 + exif_ifd_tiff_off + 2 + 8;
put_u32_le(&mut data, val_off_raw, extra_off);
let info = parse_exif(&data).unwrap();
assert_eq!(info.exposure_time, Some((1, 250)));
}
#[test]
fn parse_exif_sub_ifd_fnumber() {
let mut extra = [0u8; 8];
put_u32_le(&mut extra, 0, 28); put_u32_le(&mut extra, 4, 10);
let (mut data, extra_off) = build_exif_with_sub_ifd_le(&[(0x829D, 5, 1, 0)], &extra);
let ifd0_size = 2 + 1 * 12 + 4;
let exif_ifd_tiff_off = 8 + ifd0_size;
let val_off_raw = 6 + exif_ifd_tiff_off + 2 + 8;
put_u32_le(&mut data, val_off_raw, extra_off);
let info = parse_exif(&data).unwrap();
assert_eq!(info.f_number, Some((28, 10)));
}
#[test]
fn parse_exif_sub_ifd_iso_speed() {
let (data, _) = build_exif_with_sub_ifd_le(&[(0x8827, 3, 1, 400)], &[]);
let info = parse_exif(&data).unwrap();
assert_eq!(info.iso_speed, Some(400));
}
#[test]
fn parse_exif_sub_ifd_focal_length() {
let mut extra = [0u8; 8];
put_u32_le(&mut extra, 0, 50); put_u32_le(&mut extra, 4, 1);
let (mut data, extra_off) = build_exif_with_sub_ifd_le(&[(0x920A, 5, 1, 0)], &extra);
let ifd0_size = 2 + 1 * 12 + 4;
let exif_ifd_tiff_off = 8 + ifd0_size;
let val_off_raw = 6 + exif_ifd_tiff_off + 2 + 8;
put_u32_le(&mut data, val_off_raw, extra_off);
let info = parse_exif(&data).unwrap();
assert_eq!(info.focal_length, Some((50, 1)));
}
#[test]
fn parse_exif_sub_ifd_datetime_original() {
let dt = b"2024:12:25 08:00:00\0";
let (mut data, extra_off) = build_exif_with_sub_ifd_le(&[(0x9003, 2, 20, 0)], dt);
let ifd0_size = 2 + 1 * 12 + 4;
let exif_ifd_tiff_off = 8 + ifd0_size;
let val_off_raw = 6 + exif_ifd_tiff_off + 2 + 8;
put_u32_le(&mut data, val_off_raw, extra_off);
let info = parse_exif(&data).unwrap();
assert_eq!(
info.datetime_original,
Some("2024:12:25 08:00:00".to_string())
);
}
#[test]
fn parse_exif_missing_exif_sub_ifd() {
let data = build_exif_le(&[(0x0112, 3, 1, 1)], &[]);
let info = parse_exif(&data).unwrap();
assert_eq!(info.orientation, Some(1));
assert_eq!(info.exposure_time, None);
assert_eq!(info.f_number, None);
assert_eq!(info.iso_speed, None);
assert_eq!(info.focal_length, None);
assert_eq!(info.datetime_original, None);
}
#[test]
fn parse_exif_multiple_exif_sub_ifd_entries() {
let mut extra = [0u8; 8];
put_u32_le(&mut extra, 0, 85); put_u32_le(&mut extra, 4, 1);
let (mut data, extra_off) = build_exif_with_sub_ifd_le(
&[
(0x8827, 3, 1, 200), (0x920A, 5, 1, 0), ],
&extra,
);
let ifd0_size = 2 + 1 * 12 + 4;
let exif_ifd_tiff_off = 8 + ifd0_size;
let val_off_raw = 6 + exif_ifd_tiff_off + 2 + 1 * 12 + 8;
put_u32_le(&mut data, val_off_raw, extra_off);
let info = parse_exif(&data).unwrap();
assert_eq!(info.iso_speed, Some(200));
assert_eq!(info.focal_length, Some((85, 1)));
}
fn build_exif_with_gps_ifd_le(
gps_entries: &[(u16, u16, u32, u32)],
extra: &[u8],
) -> (Vec<u8>, u32) {
let ifd0_tiff_off: u32 = 8;
let ifd0_size = 2 + 1 * 12 + 4; let gps_ifd_tiff_off = ifd0_tiff_off + ifd0_size as u32;
let gps_entry_count = gps_entries.len();
let gps_ifd_size = 2 + gps_entry_count * 12 + 4;
let extra_tiff_off = gps_ifd_tiff_off + gps_ifd_size as u32;
let tiff_size = 8 + ifd0_size + gps_ifd_size + extra.len();
let total = 6 + tiff_size;
let mut buf = vec![0u8; total];
buf[0..6].copy_from_slice(b"Exif\0\0");
buf[6..8].copy_from_slice(b"II");
put_u16_le(&mut buf, 8, 42);
put_u32_le(&mut buf, 10, ifd0_tiff_off);
let ifd0_raw = 6 + ifd0_tiff_off as usize;
put_u16_le(&mut buf, ifd0_raw, 1);
let e0 = ifd0_raw + 2;
put_u16_le(&mut buf, e0, 0x8825);
put_u16_le(&mut buf, e0 + 2, 4); put_u32_le(&mut buf, e0 + 4, 1);
put_u32_le(&mut buf, e0 + 8, gps_ifd_tiff_off);
put_u32_le(&mut buf, e0 + 12, 0);
let gps_raw = 6 + gps_ifd_tiff_off as usize;
put_u16_le(&mut buf, gps_raw, gps_entry_count as u16);
for (i, &(tag, typ, count, val)) in gps_entries.iter().enumerate() {
let base = gps_raw + 2 + i * 12;
put_u16_le(&mut buf, base, tag);
put_u16_le(&mut buf, base + 2, typ);
put_u32_le(&mut buf, base + 4, count);
put_u32_le(&mut buf, base + 8, val);
}
let next_ptr = gps_raw + 2 + gps_entry_count * 12;
put_u32_le(&mut buf, next_ptr, 0);
let extra_raw = 6 + extra_tiff_off as usize;
buf[extra_raw..extra_raw + extra.len()].copy_from_slice(extra);
(buf, extra_tiff_off)
}
#[test]
fn parse_exif_gps_latitude_north() {
let mut extra = [0u8; 24];
put_u32_le(&mut extra, 0, 40);
put_u32_le(&mut extra, 4, 1);
put_u32_le(&mut extra, 8, 26);
put_u32_le(&mut extra, 12, 1);
put_u32_le(&mut extra, 16, 46);
put_u32_le(&mut extra, 20, 1);
let lat_ref_val = u32::from_le_bytes([b'N', 0, 0, 0]);
let (mut data, extra_off) = build_exif_with_gps_ifd_le(
&[
(0x0001, 2, 2, lat_ref_val), (0x0002, 5, 3, 0), ],
&extra,
);
let ifd0_size = 2 + 1 * 12 + 4;
let gps_ifd_tiff_off = (8 + ifd0_size) as usize;
let val_off_raw = 6 + gps_ifd_tiff_off + 2 + 1 * 12 + 8;
put_u32_le(&mut data, val_off_raw, extra_off);
let info = parse_exif(&data).unwrap();
assert!(info.gps_latitude.is_some());
let lat = info.gps_latitude.unwrap();
assert!((lat - 40.44611111).abs() < 1e-6, "got {lat}");
}
#[test]
fn parse_exif_gps_latitude_south_negative() {
let mut extra = [0u8; 24];
put_u32_le(&mut extra, 0, 33);
put_u32_le(&mut extra, 4, 1);
put_u32_le(&mut extra, 8, 51);
put_u32_le(&mut extra, 12, 1);
put_u32_le(&mut extra, 16, 54);
put_u32_le(&mut extra, 20, 1);
let lat_ref_val = u32::from_le_bytes([b'S', 0, 0, 0]);
let (mut data, extra_off) =
build_exif_with_gps_ifd_le(&[(0x0001, 2, 2, lat_ref_val), (0x0002, 5, 3, 0)], &extra);
let ifd0_size = 2 + 1 * 12 + 4;
let gps_ifd_tiff_off = (8 + ifd0_size) as usize;
let val_off_raw = 6 + gps_ifd_tiff_off + 2 + 1 * 12 + 8;
put_u32_le(&mut data, val_off_raw, extra_off);
let info = parse_exif(&data).unwrap();
let lat = info.gps_latitude.unwrap();
assert!(lat < 0.0, "South should be negative, got {lat}");
assert!((lat - (-33.865)).abs() < 1e-6, "got {lat}");
}
#[test]
fn parse_exif_gps_longitude_west_negative() {
let mut extra = [0u8; 24];
put_u32_le(&mut extra, 0, 73);
put_u32_le(&mut extra, 4, 1);
put_u32_le(&mut extra, 8, 59);
put_u32_le(&mut extra, 12, 1);
put_u32_le(&mut extra, 16, 11);
put_u32_le(&mut extra, 20, 1);
let lon_ref_val = u32::from_le_bytes([b'W', 0, 0, 0]);
let (mut data, extra_off) =
build_exif_with_gps_ifd_le(&[(0x0003, 2, 2, lon_ref_val), (0x0004, 5, 3, 0)], &extra);
let ifd0_size = 2 + 1 * 12 + 4;
let gps_ifd_tiff_off = (8 + ifd0_size) as usize;
let val_off_raw = 6 + gps_ifd_tiff_off + 2 + 1 * 12 + 8;
put_u32_le(&mut data, val_off_raw, extra_off);
let info = parse_exif(&data).unwrap();
let lon = info.gps_longitude.unwrap();
assert!(lon < 0.0, "West should be negative, got {lon}");
assert!((lon - (-73.98638888)).abs() < 1e-6, "got {lon}");
}
#[test]
fn parse_exif_gps_altitude_above_sea_level() {
let mut extra = [0u8; 8];
put_u32_le(&mut extra, 0, 100);
put_u32_le(&mut extra, 4, 1);
let (mut data, extra_off) = build_exif_with_gps_ifd_le(
&[
(0x0005, 1, 1, 0), (0x0006, 5, 1, 0), ],
&extra,
);
let ifd0_size = 2 + 1 * 12 + 4;
let gps_ifd_tiff_off = (8 + ifd0_size) as usize;
let val_off_raw = 6 + gps_ifd_tiff_off + 2 + 1 * 12 + 8;
put_u32_le(&mut data, val_off_raw, extra_off);
let info = parse_exif(&data).unwrap();
let alt = info.gps_altitude.unwrap();
assert!((alt - 100.0).abs() < 1e-6, "got {alt}");
}
#[test]
fn parse_exif_gps_altitude_below_sea_level() {
let mut extra = [0u8; 8];
put_u32_le(&mut extra, 0, 50);
put_u32_le(&mut extra, 4, 1);
let (mut data, extra_off) = build_exif_with_gps_ifd_le(
&[
(0x0005, 1, 1, 1), (0x0006, 5, 1, 0),
],
&extra,
);
let ifd0_size = 2 + 1 * 12 + 4;
let gps_ifd_tiff_off = (8 + ifd0_size) as usize;
let val_off_raw = 6 + gps_ifd_tiff_off + 2 + 1 * 12 + 8;
put_u32_le(&mut data, val_off_raw, extra_off);
let info = parse_exif(&data).unwrap();
let alt = info.gps_altitude.unwrap();
assert!((alt - (-50.0)).abs() < 1e-6, "got {alt}");
}
#[test]
fn parse_exif_gps_altitude_no_ref_defaults_above() {
let mut extra = [0u8; 8];
put_u32_le(&mut extra, 0, 200);
put_u32_le(&mut extra, 4, 1);
let (mut data, extra_off) = build_exif_with_gps_ifd_le(
&[
(0x0006, 5, 1, 0), ],
&extra,
);
let ifd0_size = 2 + 1 * 12 + 4;
let gps_ifd_tiff_off = (8 + ifd0_size) as usize;
let val_off_raw = 6 + gps_ifd_tiff_off + 2 + 0 * 12 + 8;
put_u32_le(&mut data, val_off_raw, extra_off);
let info = parse_exif(&data).unwrap();
assert_eq!(info.gps_altitude, Some(200.0));
}
#[test]
fn parse_exif_missing_gps_sub_ifd() {
let data = build_exif_le(&[(0x0112, 3, 1, 1)], &[]);
let info = parse_exif(&data).unwrap();
assert_eq!(info.gps_latitude, None);
assert_eq!(info.gps_longitude, None);
assert_eq!(info.gps_altitude, None);
}
#[test]
fn parse_exif_gps_lat_without_ref_produces_none() {
let mut extra = [0u8; 24];
put_u32_le(&mut extra, 0, 40);
put_u32_le(&mut extra, 4, 1);
put_u32_le(&mut extra, 8, 26);
put_u32_le(&mut extra, 12, 1);
put_u32_le(&mut extra, 16, 46);
put_u32_le(&mut extra, 20, 1);
let (mut data, extra_off) = build_exif_with_gps_ifd_le(
&[
(0x0002, 5, 3, 0), ],
&extra,
);
let ifd0_size = 2 + 1 * 12 + 4;
let gps_ifd_tiff_off = (8 + ifd0_size) as usize;
let val_off_raw = 6 + gps_ifd_tiff_off + 2 + 0 * 12 + 8;
put_u32_le(&mut data, val_off_raw, extra_off);
let info = parse_exif(&data).unwrap();
assert_eq!(info.gps_latitude, None);
}
#[test]
fn parse_exif_gps_full_coordinates() {
let mut extra = [0u8; 56];
put_u32_le(&mut extra, 0, 40);
put_u32_le(&mut extra, 4, 1);
put_u32_le(&mut extra, 8, 26);
put_u32_le(&mut extra, 12, 1);
put_u32_le(&mut extra, 16, 46);
put_u32_le(&mut extra, 20, 1);
put_u32_le(&mut extra, 24, 73);
put_u32_le(&mut extra, 28, 1);
put_u32_le(&mut extra, 32, 59);
put_u32_le(&mut extra, 36, 1);
put_u32_le(&mut extra, 40, 11);
put_u32_le(&mut extra, 44, 1);
put_u32_le(&mut extra, 48, 10);
put_u32_le(&mut extra, 52, 1);
let lat_ref = u32::from_le_bytes([b'N', 0, 0, 0]);
let lon_ref = u32::from_le_bytes([b'W', 0, 0, 0]);
let (mut data, extra_off) = build_exif_with_gps_ifd_le(
&[
(0x0001, 2, 2, lat_ref), (0x0002, 5, 3, 0), (0x0003, 2, 2, lon_ref), (0x0004, 5, 3, 0), (0x0005, 1, 1, 0), (0x0006, 5, 1, 0), ],
&extra,
);
let ifd0_size = 2 + 1 * 12 + 4;
let gps_ifd_tiff_off = (8 + ifd0_size) as usize;
let lat_val_off = 6 + gps_ifd_tiff_off + 2 + 1 * 12 + 8;
put_u32_le(&mut data, lat_val_off, extra_off + 0);
let lon_val_off = 6 + gps_ifd_tiff_off + 2 + 3 * 12 + 8;
put_u32_le(&mut data, lon_val_off, extra_off + 24);
let alt_val_off = 6 + gps_ifd_tiff_off + 2 + 5 * 12 + 8;
put_u32_le(&mut data, alt_val_off, extra_off + 48);
let info = parse_exif(&data).unwrap();
let lat = info.gps_latitude.unwrap();
assert!((lat - 40.44611111).abs() < 1e-6, "lat={lat}");
let lon = info.gps_longitude.unwrap();
assert!(lon < 0.0);
assert!((lon - (-73.98638888)).abs() < 1e-6, "lon={lon}");
let alt = info.gps_altitude.unwrap();
assert!((alt - 10.0).abs() < 1e-6, "alt={alt}");
}
#[test]
fn parse_exif_truncated_after_tiff_header() {
let mut data = vec![0u8; 14];
data[0..6].copy_from_slice(b"Exif\0\0");
data[6..8].copy_from_slice(b"II");
put_u16_le(&mut data, 8, 42);
put_u32_le(&mut data, 10, 200); let info = parse_exif(&data).unwrap();
assert_eq!(info, JpegExifInfo::default());
}
#[test]
fn parse_exif_malformed_orientation_other_fields_still_parse() {
let extra_off = extra_tiff_offset(2);
let make = b"Sony\0\0"; let data = build_exif_le(
&[
(0x0112, 4, 1, 6), (0x010F, 2, 6, extra_off),
],
make,
);
let info = parse_exif(&data).unwrap();
assert_eq!(info.orientation, None, "bad orientation should be None");
assert_eq!(
info.camera_make,
Some("Sony".to_string()),
"Make should still parse"
);
}
#[test]
fn parse_exif_unknown_tags_ignored() {
let data = build_exif_le(
&[
(0xFFFF, 3, 1, 42), (0x0112, 3, 1, 3), (0xABCD, 4, 1, 999), ],
&[],
);
let info = parse_exif(&data).unwrap();
assert_eq!(info.orientation, Some(3));
}
#[test]
fn parse_exif_not_exif_data() {
assert_eq!(parse_exif(b"This is not EXIF data at all"), None);
}
#[test]
fn parse_exif_jfif_not_exif() {
assert_eq!(parse_exif(b"JFIF\0\0IIQQ"), None);
}
#[test]
fn parse_exif_big_endian_full_parse() {
let make_val = u32::from_be_bytes([b'X', 0, 0, 0]);
let data = build_exif_be(
&[
(0x0112, 3, 1, 1 << 16), (0x010F, 2, 1, make_val), ],
&[],
);
let info = parse_exif(&data).unwrap();
assert_eq!(info.orientation, Some(1));
}
#[test]
fn parse_exif_big_endian_make_offset() {
let extra_off = extra_tiff_offset(1);
let make = b"Fujifilm\0";
let data = build_exif_be(&[(0x010F, 2, 9, extra_off)], make);
let _ = &data; let info = parse_exif(&data).unwrap();
assert_eq!(info.camera_make, Some("Fujifilm".to_string()));
}
#[test]
fn parse_exif_gps_altitude_fractional() {
let mut extra = [0u8; 8];
put_u32_le(&mut extra, 0, 1234);
put_u32_le(&mut extra, 4, 10);
let (mut data, extra_off) =
build_exif_with_gps_ifd_le(&[(0x0005, 1, 1, 0), (0x0006, 5, 1, 0)], &extra);
let ifd0_size = 2 + 1 * 12 + 4;
let gps_ifd_tiff_off = (8 + ifd0_size) as usize;
let val_off_raw = 6 + gps_ifd_tiff_off + 2 + 1 * 12 + 8;
put_u32_le(&mut data, val_off_raw, extra_off);
let info = parse_exif(&data).unwrap();
let alt = info.gps_altitude.unwrap();
assert!((alt - 123.4).abs() < 1e-6, "got {alt}");
}
#[test]
fn parse_exif_gps_altitude_zero_denominator_produces_none() {
let mut extra = [0u8; 8];
put_u32_le(&mut extra, 0, 100);
put_u32_le(&mut extra, 4, 0);
let (mut data, extra_off) = build_exif_with_gps_ifd_le(&[(0x0006, 5, 1, 0)], &extra);
let ifd0_size = 2 + 1 * 12 + 4;
let gps_ifd_tiff_off = (8 + ifd0_size) as usize;
let val_off_raw = 6 + gps_ifd_tiff_off + 2 + 0 * 12 + 8;
put_u32_le(&mut data, val_off_raw, extra_off);
let info = parse_exif(&data).unwrap();
assert_eq!(info.gps_altitude, None);
}
#[test]
fn parse_exif_exposure_time_round_trip() {
let mut extra = [0u8; 8];
put_u32_le(&mut extra, 0, 1);
put_u32_le(&mut extra, 4, 8000);
let (mut data, extra_off) = build_exif_with_sub_ifd_le(&[(0x829A, 5, 1, 0)], &extra);
let ifd0_size = 2 + 1 * 12 + 4;
let exif_ifd_tiff_off = 8 + ifd0_size;
let val_off_raw = 6 + exif_ifd_tiff_off + 2 + 8;
put_u32_le(&mut data, val_off_raw, extra_off);
let info = parse_exif(&data).unwrap();
assert_eq!(info.exposure_time, Some((1, 8000)));
}
#[test]
fn parse_exif_iso_speed_max_u16() {
let (data, _) = build_exif_with_sub_ifd_le(&[(0x8827, 3, 1, u16::MAX as u32)], &[]);
let info = parse_exif(&data).unwrap();
assert_eq!(info.iso_speed, Some(u16::MAX));
}
#[test]
fn parse_exif_does_not_panic_on_fuzz_like_data() {
let inputs: &[&[u8]] = &[
b"Exif\0\0II\x2a\x00\x08\x00\x00\x00",
b"Exif\0\0MM\x00\x2a\x00\x00\x00\x08",
b"Exif\0\0II\x2a\x00\xff\xff\xff\xff",
b"Exif\0\0II\x2a\x00\x08\x00\x00\x00\xff\xff",
];
for input in inputs {
let _ = parse_exif(input);
}
}
#[test]
fn scan_com_no_markers() {
let jpeg = build_jpeg_rgb(2, 2, 128, 128, 128);
let _ = scan_com_markers(&jpeg);
}
#[test]
fn scan_com_single_comment() {
let jpeg = build_jpeg_rgb(2, 2, 0, 0, 0);
let jpeg = inject_com_marker(&jpeg, "Hello, world!");
let comments = scan_com_markers(&jpeg);
assert!(comments.contains(&"Hello, world!".to_string()));
}
#[test]
fn scan_com_multiple_comments() {
let jpeg = build_jpeg_rgb(2, 2, 0, 0, 0);
let jpeg = inject_com_marker(&jpeg, "First");
let jpeg = inject_com_marker(&jpeg, "Second");
let comments = scan_com_markers(&jpeg);
assert!(comments.contains(&"First".to_string()));
assert!(comments.contains(&"Second".to_string()));
}
#[test]
fn scan_com_empty_data() {
assert!(scan_com_markers(&[]).is_empty());
}
#[test]
fn scan_com_not_jpeg() {
assert!(scan_com_markers(b"This is not JPEG").is_empty());
}
#[test]
fn scan_com_truncated() {
let data = [0xFF, 0xD8, 0xFF, 0xFE, 0x00];
let comments = scan_com_markers(&data);
assert!(comments.is_empty());
}
#[test]
fn scan_com_latin1_fallback() {
let jpeg = build_jpeg_rgb(1, 1, 0, 0, 0);
let mut injected = Vec::new();
injected.extend_from_slice(&jpeg[..2]); injected.push(0xFF);
injected.push(0xFE); let text = [0xC0, 0xC1, 0xFE, 0xFF]; let seg_len = (text.len() + 2) as u16;
injected.extend_from_slice(&seg_len.to_be_bytes());
injected.extend_from_slice(&text);
injected.extend_from_slice(&jpeg[2..]);
let comments = scan_com_markers(&injected);
assert_eq!(comments.len(), 1);
assert_eq!(comments[0], "\u{00C0}\u{00C1}\u{00FE}\u{00FF}");
assert!(!comments[0].contains('\u{FFFD}'));
}
#[test]
fn scan_jfif_density_dpi() {
let jpeg = build_jpeg_rgb(2, 2, 0, 0, 0);
let jpeg = inject_jfif_app0(&jpeg, 1, 300, 300);
let density = scan_jfif_density(&jpeg);
assert_eq!(density, Some(JpegPixelDensity::Dpi { x: 300, y: 300 }));
}
#[test]
fn scan_jfif_density_dpcm() {
let jpeg = build_jpeg_rgb(2, 2, 0, 0, 0);
let jpeg = inject_jfif_app0(&jpeg, 2, 118, 118);
let density = scan_jfif_density(&jpeg);
assert_eq!(density, Some(JpegPixelDensity::Dpcm { x: 118, y: 118 }));
}
#[test]
fn scan_jfif_density_aspect_ratio() {
let jpeg = build_jpeg_rgb(2, 2, 0, 0, 0);
let jpeg = inject_jfif_app0(&jpeg, 0, 1, 1);
let density = scan_jfif_density(&jpeg);
assert_eq!(density, Some(JpegPixelDensity::AspectRatio { x: 1, y: 1 }));
}
#[test]
fn scan_jfif_density_no_app0() {
assert_eq!(scan_jfif_density(&[]), None);
assert_eq!(scan_jfif_density(b"not jpeg"), None);
}
#[test]
fn scan_jfif_density_non_square() {
let jpeg = build_jpeg_rgb(2, 2, 0, 0, 0);
let jpeg = inject_jfif_app0(&jpeg, 1, 300, 600);
let density = scan_jfif_density(&jpeg);
assert_eq!(density, Some(JpegPixelDensity::Dpi { x: 300, y: 600 }));
}
#[test]
fn decode_rgb_8bit() {
let jpeg = build_jpeg_rgb(4, 3, 200, 100, 50);
let decoded = decode(&jpeg).unwrap();
match &decoded.image {
JpegImage::Srgb8(img) => {
assert_eq!(img.width(), 4);
assert_eq!(img.height(), 3);
}
other => panic!("expected Srgb8, got {:?}", other),
}
assert_eq!(decoded.metadata.source_bit_depth, JpegBitDepth::Eight);
assert_eq!(decoded.metadata.color_space, JpegColorSpace::Srgb);
}
#[test]
fn decode_grayscale_8bit() {
let jpeg = build_jpeg_gray(4, 3, 128);
let decoded = decode(&jpeg).unwrap();
match &decoded.image {
JpegImage::SrgbMono8(img) => {
assert_eq!(img.width(), 4);
assert_eq!(img.height(), 3);
}
other => panic!("expected SrgbMono8, got {:?}", other),
}
assert_eq!(decoded.metadata.source_bit_depth, JpegBitDepth::Eight);
}
#[test]
fn decode_preserves_dimensions_rgb() {
for &(w, h) in &[(1, 1), (2, 2), (8, 8), (16, 9), (100, 50)] {
let jpeg = build_jpeg_rgb(w, h, 0, 0, 0);
let decoded = decode(&jpeg).unwrap();
match &decoded.image {
JpegImage::Srgb8(img) => {
assert_eq!(img.width(), w as usize, "width mismatch for {w}x{h}");
assert_eq!(img.height(), h as usize, "height mismatch for {w}x{h}");
}
other => panic!("expected Srgb8 for {w}x{h}, got {:?}", other),
}
}
}
#[test]
fn decode_preserves_dimensions_gray() {
for &(w, h) in &[(1, 1), (8, 8), (16, 9)] {
let jpeg = build_jpeg_gray(w, h, 0);
let decoded = decode(&jpeg).unwrap();
match &decoded.image {
JpegImage::SrgbMono8(img) => {
assert_eq!(img.width(), w as usize);
assert_eq!(img.height(), h as usize);
}
other => panic!("expected SrgbMono8 for {w}x{h}, got {:?}", other),
}
}
}
#[test]
fn decode_invalid_data_returns_error() {
let result = decode(b"this is not a jpeg");
assert!(result.is_err());
}
#[test]
fn decode_empty_data_returns_error() {
let result = decode(b"");
assert!(result.is_err());
}
#[test]
fn decode_truncated_jpeg_returns_error() {
let jpeg = build_jpeg_rgb(4, 4, 100, 100, 100);
let truncated = &jpeg[..jpeg.len() / 2];
let result = decode(truncated);
assert!(result.is_err());
}
#[test]
fn decode_debug_output_shows_variant_and_dimensions() {
let jpeg = build_jpeg_rgb(8, 6, 0, 0, 0);
let decoded = decode(&jpeg).unwrap();
let dbg = format!("{:?}", decoded.image);
assert_eq!(dbg, "Srgb8(8x6)");
}
#[test]
fn decode_debug_output_gray() {
let jpeg = build_jpeg_gray(3, 2, 0);
let decoded = decode(&jpeg).unwrap();
let dbg = format!("{:?}", decoded.image);
assert_eq!(dbg, "SrgbMono8(3x2)");
}
#[test]
fn decode_metadata_no_exif() {
let jpeg = build_jpeg_rgb(2, 2, 0, 0, 0);
let decoded = decode(&jpeg).unwrap();
let _ = &decoded.metadata.exif;
let _ = &decoded.metadata.raw_exif;
}
#[test]
fn decode_metadata_color_space_srgb_no_icc() {
let jpeg = build_jpeg_rgb(2, 2, 0, 0, 0);
let decoded = decode(&jpeg).unwrap();
if decoded.metadata.icc_profile.is_none() {
assert_eq!(decoded.metadata.color_space, JpegColorSpace::Srgb);
}
}
#[test]
fn decode_with_com_marker() {
let jpeg = build_jpeg_rgb(2, 2, 0, 0, 0);
let jpeg = inject_com_marker(&jpeg, "Test comment");
let decoded = decode(&jpeg).unwrap();
assert!(
decoded
.metadata
.comments
.contains(&"Test comment".to_string())
);
}
#[test]
fn decode_with_jfif_density() {
let jpeg = build_jpeg_rgb(2, 2, 0, 0, 0);
let jpeg = inject_jfif_app0(&jpeg, 1, 72, 72);
let decoded = decode(&jpeg).unwrap();
assert_eq!(
decoded.metadata.pixel_density,
Some(JpegPixelDensity::Dpi { x: 72, y: 72 })
);
}
#[test]
fn decode_reader_rgb_8bit() {
let jpeg = build_jpeg_rgb(4, 3, 200, 100, 50);
let decoded = decode_reader(std::io::Cursor::new(&jpeg)).unwrap();
match &decoded.image {
JpegImage::Srgb8(img) => {
assert_eq!(img.width(), 4);
assert_eq!(img.height(), 3);
}
other => panic!("expected Srgb8, got {:?}", other),
}
}
#[test]
fn decode_reader_gray_8bit() {
let jpeg = build_jpeg_gray(4, 3, 128);
let decoded = decode_reader(std::io::Cursor::new(&jpeg)).unwrap();
match &decoded.image {
JpegImage::SrgbMono8(img) => {
assert_eq!(img.width(), 4);
assert_eq!(img.height(), 3);
}
other => panic!("expected SrgbMono8, got {:?}", other),
}
}
#[test]
fn decode_reader_has_com_and_density() {
let jpeg = build_jpeg_rgb(2, 2, 0, 0, 0);
let jpeg_with_com = inject_com_marker(&jpeg, "visible");
let jpeg_with_both = inject_jfif_app0(&jpeg_with_com, 1, 150, 150);
let decoded = decode_reader(std::io::Cursor::new(&jpeg_with_both)).unwrap();
assert!(decoded.metadata.comments.contains(&"visible".to_string()));
assert_eq!(
decoded.metadata.pixel_density,
Some(JpegPixelDensity::Dpi { x: 150, y: 150 })
);
}
#[test]
fn decode_reader_matches_decode_image() {
let jpeg = build_jpeg_rgb(8, 6, 100, 150, 200);
let d1 = decode(&jpeg).unwrap();
let d2 = decode_reader(std::io::Cursor::new(&jpeg)).unwrap();
match (&d1.image, &d2.image) {
(JpegImage::Srgb8(a), JpegImage::Srgb8(b)) => {
assert_eq!(a.width(), b.width());
assert_eq!(a.height(), b.height());
}
_ => panic!("variant mismatch"),
}
}
#[test]
fn decode_reader_invalid_data_returns_error() {
let result = decode_reader(std::io::Cursor::new(b"not jpeg"));
assert!(result.is_err());
}
#[test]
fn decode_reader_empty_data_returns_error() {
let result = decode_reader(std::io::Cursor::new(b""));
assert!(result.is_err());
}
#[test]
fn decode_pixel_values_approximately_correct() {
let jpeg = build_jpeg_rgb(8, 8, 200, 100, 50);
let decoded = decode(&jpeg).unwrap();
match &decoded.image {
JpegImage::Srgb8(img) => {
let px = img.get(4, 4).unwrap();
assert!((px.r.0 as i16 - 200).unsigned_abs() < 10, "red: {}", px.r.0);
assert!(
(px.g.0 as i16 - 100).unsigned_abs() < 10,
"green: {}",
px.g.0
);
assert!((px.b.0 as i16 - 50).unsigned_abs() < 10, "blue: {}", px.b.0);
}
other => panic!("expected Srgb8, got {:?}", other),
}
}
#[test]
fn decode_pixel_values_gray_approximately_correct() {
let jpeg = build_jpeg_gray(8, 8, 180);
let decoded = decode(&jpeg).unwrap();
match &decoded.image {
JpegImage::SrgbMono8(img) => {
let px = img.get(4, 4).unwrap();
assert!(
(px.0.0 as i16 - 180).unsigned_abs() < 10,
"gray: {}",
px.0.0
);
}
other => panic!("expected SrgbMono8, got {:?}", other),
}
}
#[test]
fn decode_1x1_rgb() {
let jpeg = build_jpeg_rgb(1, 1, 255, 0, 0);
let decoded = decode(&jpeg).unwrap();
match &decoded.image {
JpegImage::Srgb8(img) => {
assert_eq!(img.width(), 1);
assert_eq!(img.height(), 1);
}
other => panic!("expected Srgb8, got {:?}", other),
}
}
#[test]
fn decode_1x1_gray() {
let jpeg = build_jpeg_gray(1, 1, 42);
let decoded = decode(&jpeg).unwrap();
match &decoded.image {
JpegImage::SrgbMono8(img) => {
assert_eq!(img.width(), 1);
assert_eq!(img.height(), 1);
}
other => panic!("expected SrgbMono8, got {:?}", other),
}
}
#[test]
fn decode_metadata_source_bit_depth_8_for_rgb() {
let jpeg = build_jpeg_rgb(2, 2, 0, 0, 0);
let decoded = decode(&jpeg).unwrap();
assert_eq!(decoded.metadata.source_bit_depth, JpegBitDepth::Eight);
}
#[test]
fn decode_metadata_source_bit_depth_8_for_gray() {
let jpeg = build_jpeg_gray(2, 2, 0);
let decoded = decode(&jpeg).unwrap();
assert_eq!(decoded.metadata.source_bit_depth, JpegBitDepth::Eight);
}
#[test]
fn decode_error_maps_io_error() {
let io_err = std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "test");
let jpeg_err = jpeg_decoder::Error::Io(io_err);
let mapped = decode_error(jpeg_err);
match mapped {
IoError::Io(e) => assert_eq!(e.kind(), std::io::ErrorKind::UnexpectedEof),
other => panic!("expected IoError::Io, got {:?}", other),
}
}
#[test]
fn decode_error_maps_format_error() {
let jpeg_err = jpeg_decoder::Error::Format("bad format".to_string());
let mapped = decode_error(jpeg_err);
match mapped {
IoError::DecodeFailed { .. } => {} other => panic!("expected IoError::DecodeFailed, got {:?}", other),
}
}
#[test]
fn decode_multiple_com_markers() {
let jpeg = build_jpeg_rgb(2, 2, 0, 0, 0);
let jpeg = inject_com_marker(&jpeg, "Comment A");
let jpeg = inject_com_marker(&jpeg, "Comment B");
let decoded = decode(&jpeg).unwrap();
assert!(decoded.metadata.comments.len() >= 2);
assert!(decoded.metadata.comments.contains(&"Comment A".to_string()));
assert!(decoded.metadata.comments.contains(&"Comment B".to_string()));
}
#[test]
fn decode_exhaustive_image_match() {
let jpeg = build_jpeg_rgb(2, 2, 0, 0, 0);
let decoded = decode(&jpeg).unwrap();
match decoded.image {
JpegImage::SrgbMono8(_) => {}
JpegImage::SrgbMono16(_) => {}
JpegImage::Srgb8(_) => {}
}
}
#[test]
fn decode_large_image() {
let jpeg = build_jpeg_rgb(256, 256, 100, 200, 50);
let decoded = decode(&jpeg).unwrap();
match &decoded.image {
JpegImage::Srgb8(img) => {
assert_eq!(img.width(), 256);
assert_eq!(img.height(), 256);
}
other => panic!("expected Srgb8, got {:?}", other),
}
}
#[test]
fn ifd_entry_struct_fields() {
let entry = IfdEntry {
tag: 0x0112,
tiff_type: 3,
count: 1,
value_or_offset: 6,
};
assert_eq!(entry.tag, 0x0112);
assert_eq!(entry.tiff_type, 3);
assert_eq!(entry.count, 1);
assert_eq!(entry.value_or_offset, 6);
}
#[test]
fn ifd_entry_debug() {
let entry = IfdEntry {
tag: 0x010F,
tiff_type: 2,
count: 5,
value_or_offset: 100,
};
let dbg = format!("{:?}", entry);
assert!(dbg.contains("tag"));
assert!(dbg.contains("tiff_type"));
}
#[test]
fn ifd_entry_eq() {
let a = IfdEntry {
tag: 1,
tiff_type: 2,
count: 3,
value_or_offset: 4,
};
let b = IfdEntry {
tag: 1,
tiff_type: 2,
count: 3,
value_or_offset: 4,
};
assert_eq!(a, b);
}
#[test]
fn ifd_entry_copy() {
let a = IfdEntry {
tag: 1,
tiff_type: 2,
count: 3,
value_or_offset: 4,
};
let b = a;
assert_eq!(a, b);
}
#[test]
fn jpeg_sampling_factor_variants() {
let f1 = JpegSamplingFactor::F1x1;
let f2 = JpegSamplingFactor::F2x1;
let f3 = JpegSamplingFactor::F2x2;
assert_ne!(f1, f2);
assert_ne!(f2, f3);
assert_ne!(f1, f3);
}
#[test]
fn jpeg_sampling_factor_is_copy() {
let f = JpegSamplingFactor::F1x1;
let f2 = f;
assert_eq!(f, f2);
}
#[test]
fn jpeg_sampling_factor_debug() {
assert_eq!(format!("{:?}", JpegSamplingFactor::F1x1), "F1x1");
assert_eq!(format!("{:?}", JpegSamplingFactor::F2x1), "F2x1");
assert_eq!(format!("{:?}", JpegSamplingFactor::F2x2), "F2x2");
}
#[test]
fn jpeg_encode_options_default() {
let opts = JpegEncodeOptions::default();
assert_eq!(opts.quality, 85);
assert_eq!(opts.sampling_factor, None);
assert!(!opts.progressive);
}
#[test]
fn jpeg_encode_options_custom() {
let opts = JpegEncodeOptions {
quality: 95,
sampling_factor: Some(JpegSamplingFactor::F1x1),
progressive: true,
};
assert_eq!(opts.quality, 95);
assert_eq!(opts.sampling_factor, Some(JpegSamplingFactor::F1x1));
assert!(opts.progressive);
}
#[test]
fn jpeg_encode_options_debug() {
let opts = JpegEncodeOptions::default();
let dbg = format!("{:?}", opts);
assert!(dbg.contains("quality"));
assert!(dbg.contains("85"));
}
#[test]
fn jpeg_encode_options_clone() {
let opts = JpegEncodeOptions {
quality: 50,
sampling_factor: Some(JpegSamplingFactor::F2x2),
progressive: true,
};
let opts2 = opts.clone();
assert_eq!(opts2.quality, 50);
assert_eq!(opts2.sampling_factor, Some(JpegSamplingFactor::F2x2));
assert!(opts2.progressive);
}
#[test]
fn jpeg_pixel_srgb_mono8() {
assert_eq!(SrgbMono8::JPEG_COLOR_TYPE, jpeg_encoder::ColorType::Luma);
}
#[test]
fn jpeg_pixel_srgb8() {
assert_eq!(Srgb8::JPEG_COLOR_TYPE, jpeg_encoder::ColorType::Rgb);
}
#[test]
fn encode_srgb8_roundtrip() {
let img = Image::fill(8, 8, Srgb8::new(200, 100, 50));
let bytes = encode(&img, &JpegEncodeOptions::default()).unwrap();
let decoded = decode(&bytes).unwrap();
match &decoded.image {
JpegImage::Srgb8(dec_img) => {
assert_eq!(dec_img.width(), 8);
assert_eq!(dec_img.height(), 8);
}
other => panic!("expected Srgb8, got {:?}", other),
}
}
#[test]
fn encode_srgb_mono8_roundtrip() {
let img = Image::fill(8, 8, SrgbMono8::new(128));
let bytes = encode(&img, &JpegEncodeOptions::default()).unwrap();
let decoded = decode(&bytes).unwrap();
match &decoded.image {
JpegImage::SrgbMono8(dec_img) => {
assert_eq!(dec_img.width(), 8);
assert_eq!(dec_img.height(), 8);
}
other => panic!("expected SrgbMono8, got {:?}", other),
}
}
#[test]
fn encode_quality_affects_size() {
let img = Image::fill(64, 64, Srgb8::new(128, 64, 32));
let low = encode(
&img,
&JpegEncodeOptions {
quality: 10,
..Default::default()
},
)
.unwrap();
let high = encode(
&img,
&JpegEncodeOptions {
quality: 100,
..Default::default()
},
)
.unwrap();
assert!(
high.len() > low.len(),
"high quality ({}) should be larger than low quality ({})",
high.len(),
low.len()
);
}
#[test]
fn encode_writer_byte_identical_to_encode() {
let img = Image::fill(4, 4, Srgb8::new(100, 150, 200));
let opts = JpegEncodeOptions::default();
let bytes_encode = encode(&img, &opts).unwrap();
let mut bytes_writer = Vec::new();
encode_writer(&img, &mut bytes_writer, &opts).unwrap();
assert_eq!(bytes_encode, bytes_writer);
}
#[test]
fn encode_dimensions_roundtrip() {
for &(w, h) in &[(1u16, 1), (2, 2), (8, 6), (16, 9), (100, 50)] {
let img = Image::fill(w as usize, h as usize, Srgb8::new(0, 0, 0));
let bytes = encode(&img, &JpegEncodeOptions::default()).unwrap();
let decoded = decode(&bytes).unwrap();
match &decoded.image {
JpegImage::Srgb8(dec_img) => {
assert_eq!(dec_img.width(), w as usize, "width for {w}x{h}");
assert_eq!(dec_img.height(), h as usize, "height for {w}x{h}");
}
other => panic!("expected Srgb8 for {w}x{h}, got {:?}", other),
}
}
}
#[test]
fn encode_progressive_accepted() {
let img = Image::fill(8, 8, Srgb8::new(0, 0, 0));
let opts = JpegEncodeOptions {
progressive: true,
..Default::default()
};
let bytes = encode(&img, &opts).unwrap();
let decoded = decode(&bytes).unwrap();
match &decoded.image {
JpegImage::Srgb8(_) => {}
other => panic!("expected Srgb8, got {:?}", other),
}
}
#[test]
fn encode_sampling_factor_f1x1() {
let img = Image::fill(8, 8, Srgb8::new(0, 0, 0));
let opts = JpegEncodeOptions {
sampling_factor: Some(JpegSamplingFactor::F1x1),
..Default::default()
};
let bytes = encode(&img, &opts).unwrap();
let decoded = decode(&bytes).unwrap();
match &decoded.image {
JpegImage::Srgb8(_) => {}
other => panic!("expected Srgb8, got {:?}", other),
}
}
#[test]
fn encode_sampling_factor_f2x1() {
let img = Image::fill(8, 8, Srgb8::new(0, 0, 0));
let opts = JpegEncodeOptions {
sampling_factor: Some(JpegSamplingFactor::F2x1),
..Default::default()
};
let bytes = encode(&img, &opts).unwrap();
let decoded = decode(&bytes).unwrap();
match &decoded.image {
JpegImage::Srgb8(_) => {}
other => panic!("expected Srgb8, got {:?}", other),
}
}
#[test]
fn encode_sampling_factor_f2x2() {
let img = Image::fill(8, 8, Srgb8::new(0, 0, 0));
let opts = JpegEncodeOptions {
sampling_factor: Some(JpegSamplingFactor::F2x2),
..Default::default()
};
let bytes = encode(&img, &opts).unwrap();
let decoded = decode(&bytes).unwrap();
match &decoded.image {
JpegImage::Srgb8(_) => {}
other => panic!("expected Srgb8, got {:?}", other),
}
}
#[test]
fn encode_jpeg_image_srgb8() {
let img = JpegImage::Srgb8(Image::fill(4, 4, Srgb8::new(128, 64, 32)));
let bytes = encode_jpeg_image(&img, &JpegEncodeOptions::default()).unwrap();
let decoded = decode(&bytes).unwrap();
match &decoded.image {
JpegImage::Srgb8(dec_img) => {
assert_eq!(dec_img.width(), 4);
assert_eq!(dec_img.height(), 4);
}
other => panic!("expected Srgb8, got {:?}", other),
}
}
#[test]
fn encode_jpeg_image_srgb_mono8() {
let img = JpegImage::SrgbMono8(Image::fill(4, 4, SrgbMono8::new(200)));
let bytes = encode_jpeg_image(&img, &JpegEncodeOptions::default()).unwrap();
let decoded = decode(&bytes).unwrap();
match &decoded.image {
JpegImage::SrgbMono8(_) => {}
other => panic!("expected SrgbMono8, got {:?}", other),
}
}
#[test]
fn encode_jpeg_image_srgb_mono16_unsupported() {
let img = JpegImage::SrgbMono16(Image::fill(2, 2, SrgbMono16::new(4000)));
let result = encode_jpeg_image(&img, &JpegEncodeOptions::default());
match result {
Err(IoError::UnsupportedFeature { .. }) => {} other => panic!("expected UnsupportedFeature, got {:?}", other),
}
}
#[test]
fn encode_pixel_values_approximately_correct() {
let img = Image::fill(8, 8, Srgb8::new(200, 100, 50));
let opts = JpegEncodeOptions {
quality: 100,
..Default::default()
};
let bytes = encode(&img, &opts).unwrap();
let decoded = decode(&bytes).unwrap();
match &decoded.image {
JpegImage::Srgb8(dec_img) => {
let px = dec_img.get(4, 4).unwrap();
assert!((px.r.0 as i16 - 200).unsigned_abs() < 5, "red: {}", px.r.0);
assert!(
(px.g.0 as i16 - 100).unsigned_abs() < 5,
"green: {}",
px.g.0
);
assert!((px.b.0 as i16 - 50).unsigned_abs() < 5, "blue: {}", px.b.0);
}
other => panic!("expected Srgb8, got {:?}", other),
}
}
#[test]
fn encode_error_maps_io_error() {
let io_err = std::io::Error::new(std::io::ErrorKind::BrokenPipe, "test");
let enc_err = jpeg_encoder::EncodingError::IoError(io_err);
let mapped = encode_error(enc_err);
match mapped {
IoError::Io(e) => assert_eq!(e.kind(), std::io::ErrorKind::BrokenPipe),
other => panic!("expected IoError::Io, got {:?}", other),
}
}
#[test]
fn encode_error_maps_encoding_error() {
let enc_err = jpeg_encoder::EncodingError::BadImageData {
length: 10,
required: 12,
};
let mapped = encode_error(enc_err);
match mapped {
IoError::EncodeFailed { .. } => {} other => panic!("expected IoError::EncodeFailed, got {:?}", other),
}
}
#[test]
fn encode_rejects_width_exceeding_u16_max() {
let img: Image<SrgbMono8> = Image::fill(u16::MAX as usize + 1, 1, SrgbMono8::new(0));
let result = encode(&img, &JpegEncodeOptions::default());
match result {
Err(IoError::UnsupportedFeature { reason }) => {
assert!(
reason.contains("65535") || reason.contains("dimensions"),
"unexpected reason: {reason}"
);
}
other => panic!("expected UnsupportedFeature, got {:?}", other),
}
}
#[test]
fn encode_rejects_height_exceeding_u16_max() {
let img: Image<SrgbMono8> = Image::fill(1, u16::MAX as usize + 1, SrgbMono8::new(0));
let result = encode(&img, &JpegEncodeOptions::default());
assert!(
matches!(result, Err(IoError::UnsupportedFeature { .. })),
"expected UnsupportedFeature, got {:?}",
result
);
}
#[test]
fn encode_accepts_max_valid_dimension() {
let img: Image<SrgbMono8> = Image::fill(u16::MAX as usize, 1, SrgbMono8::new(42));
let result = encode(&img, &JpegEncodeOptions::default());
assert!(
result.is_ok(),
"expected Ok for max valid width, got {:?}",
result.err()
);
}
#[test]
fn roundtrip_srgb8_encode_decode() {
let img = Image::fill(16, 12, Srgb8::new(100, 150, 200));
let bytes = encode(&img, &JpegEncodeOptions::default()).unwrap();
let decoded = decode(&bytes).unwrap();
match &decoded.image {
JpegImage::Srgb8(dec_img) => {
assert_eq!(dec_img.width(), 16);
assert_eq!(dec_img.height(), 12);
}
other => panic!("expected Srgb8, got {:?}", other),
}
assert_eq!(decoded.metadata.source_bit_depth, JpegBitDepth::Eight);
}
#[test]
fn roundtrip_srgb_mono8_encode_decode() {
let img = Image::fill(16, 12, SrgbMono8::new(180));
let bytes = encode(&img, &JpegEncodeOptions::default()).unwrap();
let decoded = decode(&bytes).unwrap();
match &decoded.image {
JpegImage::SrgbMono8(dec_img) => {
assert_eq!(dec_img.width(), 16);
assert_eq!(dec_img.height(), 12);
}
other => panic!("expected SrgbMono8, got {:?}", other),
}
}
#[test]
fn roundtrip_via_encode_jpeg_image() {
let orig = JpegImage::Srgb8(Image::fill(8, 8, Srgb8::new(50, 100, 150)));
let bytes = encode_jpeg_image(&orig, &JpegEncodeOptions::default()).unwrap();
let decoded = decode(&bytes).unwrap();
match (&orig, &decoded.image) {
(JpegImage::Srgb8(a), JpegImage::Srgb8(b)) => {
assert_eq!(a.width(), b.width());
assert_eq!(a.height(), b.height());
}
_ => panic!("variant mismatch"),
}
}
#[test]
fn decode_reader_matches_decode_fully() {
let jpeg = build_jpeg_rgb(8, 6, 100, 150, 200);
let jpeg = inject_com_marker(&jpeg, "reader test");
let jpeg = inject_jfif_app0(&jpeg, 1, 72, 72);
let d1 = decode(&jpeg).unwrap();
let d2 = decode_reader(std::io::Cursor::new(&jpeg)).unwrap();
match (&d1.image, &d2.image) {
(JpegImage::Srgb8(a), JpegImage::Srgb8(b)) => {
assert_eq!(a.width(), b.width());
assert_eq!(a.height(), b.height());
}
_ => panic!("variant mismatch"),
}
assert_eq!(d1.metadata.comments, d2.metadata.comments);
assert_eq!(d1.metadata.pixel_density, d2.metadata.pixel_density);
assert_eq!(d1.metadata.source_bit_depth, d2.metadata.source_bit_depth);
assert_eq!(d1.metadata.color_space, d2.metadata.color_space);
}
}