Skip to main content

djvu_rs/
info.rs

1//! Parser for the DjVu INFO chunk, which contains per-page metadata.
2//!
3//! INFO chunk layout (from sndjvu.org spec):
4//!
5//! ```text
6//! Offset  Size  Field
7//! 0       2     width            big-endian u16
8//! 2       2     height           big-endian u16
9//! 4       1     minor_version
10//! 5       1     major_version
11//! 6       2     dpi              little-endian u16
12//! 8       1     gamma_byte       actual_gamma = gamma_byte / 10.0
13//! 9       1     flags            bits 0-1: rotation, bit 6: orientation
14//! ```
15//!
16//! The minimum INFO chunk size is 10 bytes; some older files may omit the
17//! trailing fields.
18
19use crate::error::IffError;
20
21/// Page rotation encoded in INFO flags bits 0–1.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
24pub enum Rotation {
25    /// 0° — natural orientation.
26    None,
27    /// 90° counter-clockwise.
28    Ccw90,
29    /// 180° rotation.
30    Rot180,
31    /// 90° clockwise (270° counter-clockwise).
32    Cw90,
33}
34
35/// Metadata from the INFO chunk of a DjVu page.
36#[derive(Debug, Clone, PartialEq)]
37#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
38pub struct PageInfo {
39    /// Page width in pixels.
40    pub width: u16,
41    /// Page height in pixels.
42    pub height: u16,
43    /// Resolution in dots per inch.
44    pub dpi: u16,
45    /// Display gamma (e.g. 2.2).
46    pub gamma: f32,
47    /// Page rotation.
48    pub rotation: Rotation,
49}
50
51impl PageInfo {
52    /// Parse a [`PageInfo`] from the raw bytes of an INFO chunk.
53    ///
54    /// # Errors
55    ///
56    /// Returns [`IffError::Truncated`] if the data is shorter than 10 bytes.
57    pub fn parse(data: &[u8]) -> Result<Self, IffError> {
58        if data.len() < 10 {
59            return Err(IffError::Truncated);
60        }
61
62        // width and height are big-endian u16
63        let width = u16::from_be_bytes(data[0..2].try_into().map_err(|_| IffError::Truncated)?);
64        let height = u16::from_be_bytes(data[2..4].try_into().map_err(|_| IffError::Truncated)?);
65
66        // DPI is little-endian u16 at offset 6
67        let dpi = u16::from_le_bytes(data[6..8].try_into().map_err(|_| IffError::Truncated)?);
68
69        // Gamma: byte value / 10.0 (e.g. 22 → 2.2)
70        let gamma_byte = data[8];
71        let gamma = if gamma_byte == 0 {
72            2.2_f32 // default gamma when not specified
73        } else {
74            gamma_byte as f32 / 10.0
75        };
76
77        // Flags byte, bits 0–2: rotation per DjVu spec.
78        // Real-world DjVu files use three specific flag values:
79        //   5 → CW 90°    2 → 180°    6 → CW 270° (= CCW 90°)
80        // Other values (including 1, 3) are treated as no rotation,
81        // matching DjVuLibre behavior.
82        let flags = data[9];
83        let rotation = match flags & 0x07 {
84            5 => Rotation::Cw90,
85            2 => Rotation::Rot180,
86            6 => Rotation::Ccw90,
87            _ => Rotation::None,
88        };
89
90        Ok(PageInfo {
91            width,
92            height,
93            dpi,
94            gamma,
95            rotation,
96        })
97    }
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103
104    /// INFO bytes for chicken.djvu page: 181×240, 100 dpi, gamma 2.2, no rotation.
105    fn chicken_info_bytes() -> [u8; 10] {
106        [
107            0x00, 0xB5, // width = 181
108            0x00, 0xF0, // height = 240
109            0x18, // minor version
110            0x00, // major version
111            0x64, 0x00, // dpi = 100 (little-endian)
112            0x16, // gamma byte = 22 → 2.2
113            0x00, // flags: no rotation
114        ]
115    }
116
117    #[test]
118    fn parse_chicken_info() {
119        let info = PageInfo::parse(&chicken_info_bytes()).expect("should parse");
120        assert_eq!(info.width, 181);
121        assert_eq!(info.height, 240);
122        assert_eq!(info.dpi, 100);
123        assert!((info.gamma - 2.2).abs() < 0.01, "gamma should be 2.2");
124        assert_eq!(info.rotation, Rotation::None);
125    }
126
127    #[test]
128    fn too_short_is_error() {
129        let data = [0u8; 9]; // one byte short
130        assert_eq!(PageInfo::parse(&data).unwrap_err(), IffError::Truncated);
131    }
132
133    #[test]
134    fn empty_is_error() {
135        assert_eq!(PageInfo::parse(&[]).unwrap_err(), IffError::Truncated);
136    }
137
138    #[test]
139    fn rotation_none() {
140        let mut bytes = chicken_info_bytes();
141        bytes[9] = 0x00; // flags bits 0-1 = 0
142        let info = PageInfo::parse(&bytes).unwrap();
143        assert_eq!(info.rotation, Rotation::None);
144    }
145
146    #[test]
147    fn rotation_flag1_is_none() {
148        let mut bytes = chicken_info_bytes();
149        bytes[9] = 0x01;
150        let info = PageInfo::parse(&bytes).unwrap();
151        assert_eq!(info.rotation, Rotation::None);
152    }
153
154    #[test]
155    fn rotation_flag2_is_180() {
156        let mut bytes = chicken_info_bytes();
157        bytes[9] = 0x02;
158        let info = PageInfo::parse(&bytes).unwrap();
159        assert_eq!(info.rotation, Rotation::Rot180);
160    }
161
162    #[test]
163    fn rotation_flag5_is_cw90() {
164        let mut bytes = chicken_info_bytes();
165        bytes[9] = 0x05;
166        let info = PageInfo::parse(&bytes).unwrap();
167        assert_eq!(info.rotation, Rotation::Cw90);
168    }
169
170    #[test]
171    fn rotation_flag6_is_ccw90() {
172        let mut bytes = chicken_info_bytes();
173        bytes[9] = 0x06;
174        let info = PageInfo::parse(&bytes).unwrap();
175        assert_eq!(info.rotation, Rotation::Ccw90);
176    }
177
178    #[test]
179    fn gamma_zero_defaults_to_2_2() {
180        let mut bytes = chicken_info_bytes();
181        bytes[8] = 0x00; // gamma_byte = 0
182        let info = PageInfo::parse(&bytes).unwrap();
183        assert!(
184            (info.gamma - 2.2).abs() < 0.01,
185            "default gamma should be 2.2"
186        );
187    }
188
189    #[test]
190    fn parse_real_chicken_info_from_iff() {
191        // Load the real chicken.djvu and verify INFO chunk parses correctly
192        let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
193            .join("references/djvujs/library/assets/chicken.djvu");
194        let data = std::fs::read(&path).expect("chicken.djvu must exist");
195        let form = crate::iff::parse_form(&data).expect("IFF parse failed");
196
197        let info_chunk = form
198            .chunks
199            .iter()
200            .find(|c| &c.id == b"INFO")
201            .expect("INFO chunk must be present");
202
203        let info = PageInfo::parse(info_chunk.data).expect("INFO parse failed");
204        assert_eq!(info.width, 181);
205        assert_eq!(info.height, 240);
206        assert_eq!(info.dpi, 100);
207    }
208}