use crate::error::IffError;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum Rotation {
None,
Ccw90,
Rot180,
Cw90,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct PageInfo {
pub width: u16,
pub height: u16,
pub dpi: u16,
pub gamma: f32,
pub rotation: Rotation,
}
impl PageInfo {
pub fn parse(data: &[u8]) -> Result<Self, IffError> {
if data.len() < 4 {
return Err(IffError::Truncated);
}
let width = u16::from_be_bytes(data[0..2].try_into().map_err(|_| IffError::Truncated)?);
let height = u16::from_be_bytes(data[2..4].try_into().map_err(|_| IffError::Truncated)?);
let version: u16 = match (data.get(4), data.get(5)) {
(Some(&minor), Some(&major)) if major != 0xff => ((major as u16) << 8) | minor as u16,
(Some(&minor), _) => minor as u16,
(None, _) => 0,
};
if version >= 50 {
return Err(IffError::UnsupportedVersion { version });
}
let dpi = match data.get(6..8) {
Some(b) => u16::from_le_bytes(b.try_into().map_err(|_| IffError::Truncated)?),
None => 300,
};
let gamma = match data.get(8) {
Some(&gamma_byte) => (gamma_byte as f32 / 10.0).clamp(0.3, 5.0),
None => 2.2_f32,
};
let flags = data.get(9).copied().unwrap_or(0);
let rotation = match flags & 0x07 {
5 => Rotation::Cw90,
2 => Rotation::Rot180,
6 => Rotation::Ccw90,
_ => Rotation::None,
};
Ok(PageInfo {
width,
height,
dpi,
gamma,
rotation,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn chicken_info_bytes() -> [u8; 10] {
[
0x00, 0xB5, 0x00, 0xF0, 0x18, 0x00, 0x64, 0x00, 0x16, 0x00, ]
}
#[test]
fn parse_chicken_info() {
let info = PageInfo::parse(&chicken_info_bytes()).expect("should parse");
assert_eq!(info.width, 181);
assert_eq!(info.height, 240);
assert_eq!(info.dpi, 100);
assert!((info.gamma - 2.2).abs() < 0.01, "gamma should be 2.2");
assert_eq!(info.rotation, Rotation::None);
}
#[test]
fn shorter_than_width_height_is_error() {
let data = [0u8; 3]; assert_eq!(PageInfo::parse(&data).unwrap_err(), IffError::Truncated);
}
#[test]
fn empty_is_error() {
assert_eq!(PageInfo::parse(&[]).unwrap_err(), IffError::Truncated);
}
#[test]
fn version_50_is_rejected() {
let mut bytes = chicken_info_bytes();
bytes[4] = 50; assert_eq!(
PageInfo::parse(&bytes).unwrap_err(),
IffError::UnsupportedVersion { version: 50 }
);
}
#[test]
fn version_49_is_accepted() {
let mut bytes = chicken_info_bytes();
bytes[4] = 49;
assert!(PageInfo::parse(&bytes).is_ok());
}
#[test]
fn high_major_version_byte_is_rejected_via_combined_version() {
let mut bytes = chicken_info_bytes();
bytes[4] = 0; bytes[5] = 1; assert_eq!(
PageInfo::parse(&bytes).unwrap_err(),
IffError::UnsupportedVersion { version: 256 }
);
}
#[test]
fn major_version_0xff_sentinel_uses_minor_byte_only() {
let mut bytes = chicken_info_bytes();
bytes[4] = 24; bytes[5] = 0xff; let info = PageInfo::parse(&bytes).expect("version 24 must parse");
assert_eq!(info.width, 181);
}
#[test]
fn short_info_chunk_with_version_over_ceiling_is_rejected() {
let data = [0x10, 0x68, 0x09, 0xFC, 50]; assert_eq!(
PageInfo::parse(&data).unwrap_err(),
IffError::UnsupportedVersion { version: 50 }
);
}
#[test]
fn carte_style_five_byte_info_parses_with_defaults() {
let data = [0x10, 0x68, 0x09, 0xFC, 0x11]; let info = PageInfo::parse(&data).expect("short INFO chunk should parse");
assert_eq!(info.width, 4200);
assert_eq!(info.height, 2556);
assert_eq!(info.dpi, 300, "dpi should default when absent");
assert!(
(info.gamma - 2.2).abs() < 0.01,
"gamma should default to 2.2 when absent"
);
assert_eq!(info.rotation, Rotation::None, "flags absent → no rotation");
}
#[test]
fn nine_bytes_parses_dpi_and_gamma_defaults_only_flags() {
let mut data = chicken_info_bytes().to_vec();
data.truncate(9);
let info = PageInfo::parse(&data).expect("should parse");
assert_eq!(info.width, 181);
assert_eq!(info.height, 240);
assert_eq!(info.dpi, 100);
assert!((info.gamma - 2.2).abs() < 0.01);
assert_eq!(info.rotation, Rotation::None);
}
#[test]
fn rotation_none() {
let mut bytes = chicken_info_bytes();
bytes[9] = 0x00; let info = PageInfo::parse(&bytes).unwrap();
assert_eq!(info.rotation, Rotation::None);
}
#[test]
fn rotation_flag1_is_none() {
let mut bytes = chicken_info_bytes();
bytes[9] = 0x01;
let info = PageInfo::parse(&bytes).unwrap();
assert_eq!(info.rotation, Rotation::None);
}
#[test]
fn rotation_flag2_is_180() {
let mut bytes = chicken_info_bytes();
bytes[9] = 0x02;
let info = PageInfo::parse(&bytes).unwrap();
assert_eq!(info.rotation, Rotation::Rot180);
}
#[test]
fn rotation_flag5_is_cw90() {
let mut bytes = chicken_info_bytes();
bytes[9] = 0x05;
let info = PageInfo::parse(&bytes).unwrap();
assert_eq!(info.rotation, Rotation::Cw90);
}
#[test]
fn rotation_flag6_is_ccw90() {
let mut bytes = chicken_info_bytes();
bytes[9] = 0x06;
let info = PageInfo::parse(&bytes).unwrap();
assert_eq!(info.rotation, Rotation::Ccw90);
}
#[test]
fn gamma_byte_present_and_zero_clamps_to_0_3_not_2_2() {
let mut bytes = chicken_info_bytes();
bytes[8] = 0x00; let info = PageInfo::parse(&bytes).unwrap();
assert!(
(info.gamma - 0.3).abs() < 0.01,
"present-but-zero gamma byte should clamp to 0.3, got {}",
info.gamma
);
}
#[test]
fn gamma_byte_above_50_clamps_to_5_0() {
let mut bytes = chicken_info_bytes();
bytes[8] = 150; let info = PageInfo::parse(&bytes).unwrap();
assert!(
(info.gamma - 5.0).abs() < 0.01,
"gamma byte 150 should clamp to 5.0, got {}",
info.gamma
);
}
#[test]
fn parse_real_chicken_info_from_iff() {
let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("references/djvujs/library/assets/chicken.djvu");
let data = std::fs::read(&path).expect("chicken.djvu must exist");
let form = crate::iff::parse_form(&data).expect("IFF parse failed");
let info_chunk = form
.chunks
.iter()
.find(|c| &c.id == b"INFO")
.expect("INFO chunk must be present");
let info = PageInfo::parse(info_chunk.data).expect("INFO parse failed");
assert_eq!(info.width, 181);
assert_eq!(info.height, 240);
assert_eq!(info.dpi, 100);
}
}