Skip to main content

djvu_rs/
chunk_encode.rs

1//! Chunk-encoder seam — one `(id, payload)` interface over the per-chunk
2//! encoders.
3//!
4//! The individual encoders (`encode_navm`, `encode_fgbz`, `encode_text_layer`,
5//! `encode_smmr`, `encode_jb2`) each own a single DjVu chunk's wire format but
6//! historically diverged on two axes the consumer had to track by hand:
7//!
8//! * **the chunk id** — `NAVM`, `FGbz`, … was out-of-band knowledge, repeated
9//!   at every `Chunk::Leaf { id: *b"…", .. }` construction site.
10//! * **the error discipline** — `encode_fgbz` *panicked* on an oversized
11//!   palette, `encode_navm` *silently truncated* a node with > 255 children,
12//!   while the rest were infallible.
13//!
14//! This module is the one place that pairs each encoder with its id and routes
15//! every fallible case through a single [`EncodeError`]. A wrapper implements
16//! [`ChunkEncoder`] and yields an [`EncodedChunk`]; [`EncodedChunk::into_leaf`]
17//! bridges to the `iff` emission seam ([`Chunk::Leaf`]) so framing and
18//! word-alignment padding stay centralised there (see issue #367).
19
20use crate::bitmap::Bitmap;
21use crate::djvu_document::DjVuBookmark;
22use crate::fgbz_encode::{FgbzColor, encode_fgbz};
23use crate::iff::{Chunk, ChunkId};
24use crate::jb2_encode::encode_jb2;
25use crate::navm_encode::encode_navm;
26use crate::smmr::encode_smmr;
27use crate::text::TextLayer;
28use crate::text_encode::encode_text_layer;
29
30/// A chunk that could not be encoded because a value exceeds the wire format's
31/// fixed-width count field.
32///
33/// This is the single error discipline shared by every encoder behind the
34/// seam: no encoder panics and none silently truncates — the overflowing
35/// count is surfaced to the caller instead.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum EncodeError {
38    /// A `NAVM` bookmark node has more children than the `u8` child-count
39    /// field can express (limit 255).
40    BookmarkChildrenOverflow {
41        /// The offending child count.
42        found: usize,
43    },
44    /// The `NAVM` bookmark tree has more total nodes than the `u16` count
45    /// field can express (limit 65 535).
46    BookmarkCountOverflow {
47        /// The offending total node count.
48        found: usize,
49    },
50    /// An `FGbz` palette has more entries than the `u16` size field can
51    /// express (limit 65 535).
52    PaletteOverflow {
53        /// The offending palette length.
54        found: usize,
55    },
56    /// An `FGbz` index table has more entries than the `u24` count field can
57    /// express (limit 2²⁴ − 1).
58    IndexCountOverflow {
59        /// The offending index count.
60        found: usize,
61    },
62}
63
64impl core::fmt::Display for EncodeError {
65    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
66        match self {
67            EncodeError::BookmarkChildrenOverflow { found } => write!(
68                f,
69                "NAVM bookmark node has {found} children, exceeds wire-format limit 255"
70            ),
71            EncodeError::BookmarkCountOverflow { found } => write!(
72                f,
73                "NAVM bookmark tree has {found} nodes, exceeds wire-format limit 65535"
74            ),
75            EncodeError::PaletteOverflow { found } => write!(
76                f,
77                "FGbz palette size {found} exceeds wire-format limit 65535"
78            ),
79            EncodeError::IndexCountOverflow { found } => write!(
80                f,
81                "FGbz index count {found} exceeds wire-format limit 2^24 - 1"
82            ),
83        }
84    }
85}
86
87impl std::error::Error for EncodeError {}
88
89/// An encoded chunk: its 4-byte id paired with the wire payload.
90///
91/// The id is no longer out-of-band — it travels with the bytes from the
92/// encoder that produced them.
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct EncodedChunk {
95    /// The chunk's 4-byte IFF id (e.g. `*b"NAVM"`).
96    pub id: ChunkId,
97    /// The chunk body, ready to be framed by the emission seam.
98    pub payload: Vec<u8>,
99}
100
101impl EncodedChunk {
102    /// Bridge to the `iff` emission seam as a leaf chunk.
103    ///
104    /// Framing (the 4-byte id + big-endian length + word-alignment padding)
105    /// is applied by [`crate::iff::emit`], not here.
106    pub fn into_leaf(self) -> Chunk {
107        Chunk::Leaf {
108            id: self.id,
109            data: self.payload,
110        }
111    }
112}
113
114/// A value that can be serialized into a single DjVu chunk.
115///
116/// Implementors pair a chunk id with a payload encoder and route every
117/// fallible case through [`EncodeError`].
118pub trait ChunkEncoder {
119    /// Encode `self` into its `(id, payload)` chunk.
120    fn encode_chunk(&self) -> Result<EncodedChunk, EncodeError>;
121}
122
123/// Build the 10-byte `INFO` chunk body — the **canonical** serializer.
124///
125/// Mirrors the layout parsed by `crate::info::PageInfo::parse`: width and
126/// height are big-endian, dpi is little-endian, then the version pair, a gamma
127/// byte (`22` → 2.2), and a flags byte (no rotation). This is the single place
128/// the field values are spelled — both the single-page/layered encoder
129/// ([`crate::djvu_encode`]) and the bundled-mask encoder
130/// ([`crate::jb2_encode`]) route through it instead of hand-rolling the bytes
131/// (which previously diverged: the bundle path hard-coded dpi 100 and gamma
132/// byte 1 ≈ 0.1).
133pub fn encode_info(width: u16, height: u16, dpi: u16) -> Vec<u8> {
134    let mut b = vec![0u8; 10];
135    b[0..2].copy_from_slice(&width.to_be_bytes());
136    b[2..4].copy_from_slice(&height.to_be_bytes());
137    b[4] = 0x18; // minor version
138    b[5] = 0x00; // major version
139    b[6..8].copy_from_slice(&dpi.to_le_bytes()); // dpi: little-endian
140    b[8] = 22; // gamma byte: 22 → 2.2
141    b[9] = 0x00; // flags: no rotation
142    b
143}
144
145/// `INFO` — a page's dimensions, resolution, gamma, and rotation flags.
146pub struct InfoChunk {
147    /// Page width in pixels (wire field is `u16`).
148    pub width: u16,
149    /// Page height in pixels (wire field is `u16`).
150    pub height: u16,
151    /// Resolution in dots per inch.
152    pub dpi: u16,
153}
154
155impl ChunkEncoder for InfoChunk {
156    fn encode_chunk(&self) -> Result<EncodedChunk, EncodeError> {
157        Ok(EncodedChunk {
158            id: *b"INFO",
159            payload: encode_info(self.width, self.height, self.dpi),
160        })
161    }
162}
163
164/// `NAVM` — a document's bookmark tree.
165pub struct NavmChunk<'a>(pub &'a [DjVuBookmark]);
166
167impl ChunkEncoder for NavmChunk<'_> {
168    fn encode_chunk(&self) -> Result<EncodedChunk, EncodeError> {
169        Ok(EncodedChunk {
170            id: *b"NAVM",
171            payload: encode_navm(self.0)?,
172        })
173    }
174}
175
176/// `FGbz` — a foreground palette plus optional per-blit index table.
177pub struct FgbzChunk<'a> {
178    /// The 24-bit foreground palette.
179    pub palette: &'a [FgbzColor],
180    /// Optional per-blit palette indices (the index table).
181    pub indices: Option<&'a [i16]>,
182}
183
184impl ChunkEncoder for FgbzChunk<'_> {
185    fn encode_chunk(&self) -> Result<EncodedChunk, EncodeError> {
186        Ok(EncodedChunk {
187            id: *b"FGbz",
188            payload: encode_fgbz(self.palette, self.indices)?,
189        })
190    }
191}
192
193/// `TXTa` — an uncompressed text layer.
194///
195/// The bottom-left coordinate flip needs the page height, so it is a named
196/// field here rather than a bare positional parameter. Callers that want
197/// `TXTz` compress the payload and re-id the leaf themselves.
198pub struct TextChunk<'a> {
199    /// The text layer to serialize.
200    pub layer: &'a TextLayer,
201    /// Page height in pixels, for the top-left → bottom-left coordinate flip.
202    pub page_height: u32,
203}
204
205impl ChunkEncoder for TextChunk<'_> {
206    fn encode_chunk(&self) -> Result<EncodedChunk, EncodeError> {
207        Ok(EncodedChunk {
208            id: *b"TXTa",
209            payload: encode_text_layer(self.layer, self.page_height),
210        })
211    }
212}
213
214/// `Smmr` — a bilevel mask encoded with G4/MMR.
215pub struct SmmrChunk<'a>(pub &'a Bitmap);
216
217impl ChunkEncoder for SmmrChunk<'_> {
218    fn encode_chunk(&self) -> Result<EncodedChunk, EncodeError> {
219        Ok(EncodedChunk {
220            id: *b"Smmr",
221            payload: encode_smmr(self.0),
222        })
223    }
224}
225
226/// `Sjbz` — a bilevel mask encoded with JB2.
227pub struct Jb2Chunk<'a>(pub &'a Bitmap);
228
229impl ChunkEncoder for Jb2Chunk<'_> {
230    fn encode_chunk(&self) -> Result<EncodedChunk, EncodeError> {
231        Ok(EncodedChunk {
232            id: *b"Sjbz",
233            payload: encode_jb2(self.0),
234        })
235    }
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241
242    fn bm(title: &str, children: Vec<DjVuBookmark>) -> DjVuBookmark {
243        DjVuBookmark {
244            title: title.to_string(),
245            url: "#page=1".to_string(),
246            children,
247        }
248    }
249
250    #[test]
251    fn encode_error_display_messages() {
252        assert!(
253            EncodeError::BookmarkChildrenOverflow { found: 256 }
254                .to_string()
255                .contains("256")
256        );
257        assert!(
258            EncodeError::BookmarkCountOverflow { found: 65536 }
259                .to_string()
260                .contains("65536")
261        );
262        assert!(
263            EncodeError::PaletteOverflow { found: 70000 }
264                .to_string()
265                .contains("70000")
266        );
267        assert!(
268            EncodeError::IndexCountOverflow { found: 1 << 24 }
269                .to_string()
270                .contains("16777216")
271        );
272    }
273
274    #[test]
275    fn text_chunk_yields_txta_id() {
276        use crate::text::{Rect, TextLayer, TextZone, TextZoneKind};
277        let layer = TextLayer {
278            text: "hi".into(),
279            zones: vec![TextZone {
280                kind: TextZoneKind::Page,
281                rect: Rect {
282                    x: 0,
283                    y: 0,
284                    width: 100,
285                    height: 200,
286                },
287                text: "hi".into(),
288                children: vec![],
289            }],
290        };
291        let chunk = TextChunk {
292            layer: &layer,
293            page_height: 200,
294        }
295        .encode_chunk()
296        .unwrap();
297        assert_eq!(&chunk.id, b"TXTa");
298        assert!(!chunk.payload.is_empty());
299    }
300
301    #[test]
302    fn navm_chunk_yields_navm_id_and_bridges_to_leaf() {
303        let bookmarks = vec![bm("Chapter 1", vec![])];
304        let chunk = NavmChunk(&bookmarks).encode_chunk().unwrap();
305        assert_eq!(&chunk.id, b"NAVM");
306        assert!(!chunk.payload.is_empty());
307
308        match chunk.clone().into_leaf() {
309            Chunk::Leaf { id, data } => {
310                assert_eq!(&id, b"NAVM");
311                assert_eq!(data, chunk.payload);
312            }
313            other => panic!("expected leaf, got {other:?}"),
314        }
315    }
316
317    #[test]
318    fn navm_rejects_more_than_255_children() {
319        let children: Vec<DjVuBookmark> = (0..256).map(|_| bm("child", vec![])).collect();
320        let root = vec![bm("root", children)];
321        let err = NavmChunk(&root).encode_chunk().unwrap_err();
322        assert_eq!(err, EncodeError::BookmarkChildrenOverflow { found: 256 });
323    }
324
325    #[test]
326    fn navm_rejects_more_than_65535_nodes() {
327        // The top-level node count is carried in the u16 total field, not a
328        // per-node u8 child count, so a flat list of 65 536 childless nodes
329        // overflows only the total — no node trips the children limit.
330        let bookmarks: Vec<DjVuBookmark> = (0..(u16::MAX as usize) + 1)
331            .map(|_| bm("x", vec![]))
332            .collect();
333        let err = NavmChunk(&bookmarks).encode_chunk().unwrap_err();
334        assert_eq!(err, EncodeError::BookmarkCountOverflow { found: 65536 });
335    }
336
337    #[test]
338    fn fgbz_chunk_yields_fgbz_id() {
339        let palette = [FgbzColor { r: 1, g: 2, b: 3 }];
340        let chunk = FgbzChunk {
341            palette: &palette,
342            indices: None,
343        }
344        .encode_chunk()
345        .unwrap();
346        assert_eq!(&chunk.id, b"FGbz");
347    }
348
349    #[test]
350    fn fgbz_rejects_oversized_palette() {
351        // 65 536 entries — one past the u16 limit. Build cheaply.
352        let palette = vec![FgbzColor::default(); (u16::MAX as usize) + 1];
353        let err = FgbzChunk {
354            palette: &palette,
355            indices: None,
356        }
357        .encode_chunk()
358        .unwrap_err();
359        assert_eq!(err, EncodeError::PaletteOverflow { found: 65536 });
360    }
361
362    #[test]
363    fn smmr_and_jb2_yield_their_ids() {
364        let mut mask = Bitmap::new(4, 4);
365        mask.set_black(1, 1);
366        assert_eq!(&SmmrChunk(&mask).encode_chunk().unwrap().id, b"Smmr");
367        assert_eq!(&Jb2Chunk(&mask).encode_chunk().unwrap().id, b"Sjbz");
368    }
369
370    #[test]
371    fn encode_info_has_canonical_layout() {
372        // 181×240, 100 dpi — matches the round-trip fixture in `info.rs`:
373        // width/height big-endian, dpi little-endian, gamma byte 22 (= 2.2).
374        let info = encode_info(181, 240, 100);
375        assert_eq!(
376            info,
377            vec![
378                0x00, 0xB5, // width 181, big-endian
379                0x00, 0xF0, // height 240, big-endian
380                0x18, 0x00, // version (minor, major)
381                0x64, 0x00, // dpi 100, little-endian
382                22,   // gamma → 2.2
383                0x00, // flags: no rotation
384            ]
385        );
386    }
387
388    #[test]
389    fn info_chunk_yields_info_id_and_matches_free_fn() {
390        let chunk = InfoChunk {
391            width: 640,
392            height: 480,
393            dpi: 300,
394        }
395        .encode_chunk()
396        .unwrap();
397        assert_eq!(&chunk.id, b"INFO");
398        assert_eq!(chunk.payload, encode_info(640, 480, 300));
399        // dpi is the only differing field vs. the canonical 100-dpi fixture's
400        // resolution slot: 300 little-endian = 0x2C, 0x01.
401        assert_eq!(&chunk.payload[6..8], &[0x2C, 0x01]);
402    }
403}