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