Skip to main content

djvu_iff/
lib.rs

1//! IFF (Interchange File Format) container parser for DjVu files.
2//!
3//! This module provides two APIs:
4//!
5//! 1. **New spec-based parser** (`parse_form`) — zero-copy, borrowing slices from
6//!    the input byte buffer. Written from the sndjvu.org specification.
7//!
8//! 2. **Legacy API** (`parse`, `Chunk`, `DjvuFile`) — the original tree-based parser
9//!    kept for internal backward compatibility while the rewrite is in progress.
10//!
11//! ## DjVu IFF layout
12//!
13//! ```text
14//! [4] magic   = "AT&T"
15//! [4] id      = "FORM"
16//! [4] length  (big-endian u32, covers form_type + all chunks)
17//! [4] form_type = "DJVU" | "DJVM" | "BM44" | "PM44"
18//! ... chunks
19//! ```
20//!
21//! Each inner chunk:
22//! ```text
23//! [4] id
24//! [4] length  (big-endian u32)
25//! [n] data    (padded to even number of bytes if length is odd)
26//! ```
27
28#![cfg_attr(not(feature = "std"), no_std)]
29#![deny(unsafe_code)]
30
31#[cfg(not(feature = "std"))]
32extern crate alloc;
33
34#[cfg(not(feature = "std"))]
35use alloc::{string::String, vec::Vec};
36use core::{fmt::Write as _, str};
37#[cfg(feature = "std")]
38use std::{string::String, vec::Vec};
39
40// ---- Error types ------------------------------------------------------------
41
42/// Errors that can occur while parsing the IFF container.
43#[derive(Debug, thiserror::Error, PartialEq, Eq)]
44#[non_exhaustive]
45pub enum IffError {
46    /// Input data is too short to contain a valid IFF file.
47    #[error("input is too short to be a valid IFF file")]
48    TooShort,
49
50    /// The `AT&T` magic bytes were not found at the start of the file.
51    #[error("bad magic bytes: expected AT&T, got {got:?}")]
52    BadMagic { got: [u8; 4] },
53
54    /// The FORM type identifier is not a recognised DjVu type.
55    ///
56    /// Note: this is *not* an error — callers may encounter unknown form types
57    /// in bundled documents and should handle them gracefully.
58    #[error("unknown FORM type: {id:?}")]
59    UnknownFormType { id: [u8; 4] },
60
61    /// A chunk header claims more bytes than are available in the buffer.
62    #[error(
63        "chunk {:?} claims {} bytes but only {} are available",
64        id,
65        claimed,
66        available
67    )]
68    ChunkTooLong {
69        id: [u8; 4],
70        claimed: u32,
71        available: usize,
72    },
73
74    /// A FORM nesting chain exceeded the structural inspection limit.
75    #[error("IFF FORM nesting exceeds the maximum depth of {max}")]
76    DepthLimitExceeded {
77        /// Maximum permitted FORM nesting depth.
78        max: usize,
79    },
80
81    /// The input ended unexpectedly in the middle of a chunk.
82    #[error("unexpected end of input (truncated IFF data)")]
83    Truncated,
84
85    /// The INFO chunk declares a format version DjVuLibre itself refuses to
86    /// decode. DjVuLibre's `DJVUVERSION_TOO_NEW` forward-compatibility
87    /// ceiling (`libdjvu/DjVuInfo.h`) is 50: `if (info->version >= 50) throw
88    /// "Cannot decode DjVu files with version>= 50"`. We match that ceiling
89    /// rather than silently decoding files DjVuLibre itself refuses.
90    #[error("unsupported DjVu format version {version} (DjVuLibre rejects version >= 50)")]
91    UnsupportedVersion { version: u16 },
92}
93
94/// Original error type used by the legacy implementation.
95#[derive(Debug, Clone, PartialEq, Eq)]
96#[non_exhaustive]
97pub enum LegacyError {
98    /// Input data is shorter than expected.
99    UnexpectedEof,
100    /// A required magic number or tag was not found.
101    InvalidMagic,
102    /// A chunk or field has an invalid length.
103    InvalidLength,
104    /// A required chunk is missing.
105    MissingChunk(&'static str),
106    /// An unsupported feature or version was encountered.
107    Unsupported(&'static str),
108    /// Generic format violation.
109    FormatError(String),
110}
111
112impl core::fmt::Display for LegacyError {
113    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
114        match self {
115            LegacyError::UnexpectedEof => write!(f, "unexpected end of input"),
116            LegacyError::InvalidMagic => write!(f, "invalid magic number"),
117            LegacyError::InvalidLength => write!(f, "invalid length"),
118            LegacyError::MissingChunk(id) => write!(f, "missing required chunk: {}", id),
119            LegacyError::Unsupported(msg) => write!(f, "unsupported: {}", msg),
120            LegacyError::FormatError(msg) => write!(f, "format error: {}", msg),
121        }
122    }
123}
124
125#[cfg(feature = "std")]
126impl std::error::Error for LegacyError {}
127
128/// Alias for [`LegacyError`].
129pub use LegacyError as Error;
130
131// ---- IFF chunk types --------------------------------------------------------
132
133/// The 4-byte magic that prefixes every on-disk DjVu IFF stream.
134///
135/// The single source of the literal: writers prepend `&MAGIC` rather than
136/// re-spelling `b"AT&T"`, so the emission seam owns the framing bytes. (A
137/// guard test rejects raw `b"AT&T"`/`b"FORM"` assembly outside this crate.)
138pub const MAGIC: [u8; 4] = *b"AT&T";
139
140/// A 4-byte chunk identifier (e.g., b"FORM", b"INFO", b"Sjbz").
141pub type ChunkId = [u8; 4];
142
143/// One chunk found by [`walk_chunks`].
144///
145/// `offset` is the absolute byte offset of the 8-byte IFF chunk header,
146/// measured from the start of the `AT&T` magic. `length` is the declared
147/// payload length from that header; for a `FORM`, it includes the four-byte
148/// secondary type. `depth` is zero for the root FORM. `path` is its child-index
149/// path, so the root path is empty and the third child of the root has path
150/// `[2]`.
151#[derive(Debug, Clone, PartialEq, Eq)]
152pub struct ChunkRecord {
153    /// The four-byte IFF chunk identifier.
154    pub id: ChunkId,
155    /// The secondary FORM type when `id` is `FORM`.
156    pub form_type: Option<ChunkId>,
157    /// Absolute byte offset of the chunk header.
158    pub offset: usize,
159    /// Declared payload length from the chunk header.
160    pub length: usize,
161    /// FORM nesting depth, with the root FORM at zero.
162    pub depth: usize,
163    /// Zero-based child indexes from the root FORM to this chunk.
164    pub path: Vec<usize>,
165}
166
167/// A parsed IFF chunk — either a FORM container or a leaf data chunk.
168#[derive(Debug, Clone)]
169pub enum Chunk {
170    /// A FORM container with a secondary ID and child chunks.
171    Form {
172        /// The secondary ID (e.g., b"DJVU", b"DJVM", b"DJVI", b"THUM").
173        secondary_id: ChunkId,
174        /// Total byte length of the FORM payload (from the IFF length field).
175        /// Includes the 4-byte secondary ID and all child chunk bytes.
176        length: u32,
177        /// Child chunks within this FORM.
178        children: Vec<Chunk>,
179    },
180    /// A leaf chunk with raw data.
181    Leaf {
182        /// The chunk ID (e.g., b"INFO", b"Sjbz", b"BG44").
183        id: ChunkId,
184        /// The raw chunk payload bytes.
185        data: Vec<u8>,
186    },
187}
188
189impl Chunk {
190    /// For leaf chunks, return the data slice. For FORM chunks, returns empty slice.
191    pub fn data(&self) -> &[u8] {
192        match self {
193            Chunk::Form { .. } => &[],
194            Chunk::Leaf { data, .. } => data,
195        }
196    }
197
198    /// For FORM chunks, return children. For leaf chunks, returns empty slice.
199    pub fn children(&self) -> &[Chunk] {
200        match self {
201            Chunk::Form { children, .. } => children,
202            Chunk::Leaf { .. } => &[],
203        }
204    }
205
206    /// Return the declared payload length from the IFF length field.
207    ///
208    /// For `Form` chunks, this is the value read from the IFF header — it
209    /// covers the secondary ID (4 bytes) and all children.  For `Leaf`
210    /// chunks, this equals `data().len()`.
211    pub fn payload_length(&self) -> u32 {
212        match self {
213            Chunk::Form { length, .. } => *length,
214            Chunk::Leaf { data, .. } => data.len() as u32,
215        }
216    }
217
218    /// Find the first leaf chunk with the given ID in direct children.
219    pub fn find_first(&self, target_id: &[u8; 4]) -> Option<&Chunk> {
220        self.children().iter().find(|c| match c {
221            Chunk::Leaf { id, .. } => id == target_id,
222            _ => false,
223        })
224    }
225
226    /// Find all leaf chunks with the given ID in direct children.
227    pub fn find_all(&self, target_id: &[u8; 4]) -> Vec<&Chunk> {
228        self.children()
229            .iter()
230            .filter(|c| match c {
231                Chunk::Leaf { id, .. } => id == target_id,
232                _ => false,
233            })
234            .collect()
235    }
236}
237
238/// A parsed DjVu document (the root FORM chunk).
239#[derive(Debug, Clone)]
240pub struct DjvuFile {
241    pub root: Chunk,
242}
243
244/// Parse a DjVu file from raw bytes (legacy tree-based parser).
245///
246/// Expects the file to begin with "AT&T" magic followed by a root FORM chunk.
247pub fn parse(data: &[u8]) -> Result<DjvuFile, Error> {
248    if data.len() < 4 {
249        return Err(Error::UnexpectedEof);
250    }
251    // Check for "AT&T" magic
252    let (magic, rest) = if &data[..4] == b"AT&T" {
253        (&data[..4], &data[4..])
254    } else {
255        // Some files may not have AT&T prefix (bare FORM)
256        (&data[..0], data)
257    };
258    let _ = magic;
259
260    let (root, _) = parse_chunk(rest, 0, 0)?;
261    Ok(DjvuFile { root })
262}
263
264/// Maximum FORM nesting depth. Real DjVu files nest a handful of levels
265/// (DJVM → DJVU → chunks); a deeper chain is malformed and, without this bound,
266/// drives `parse_chunk`/`parse_children` into unbounded recursion → stack
267/// overflow on crafted input.
268const MAX_IFF_DEPTH: u32 = 64;
269
270/// Walk every IFF chunk in a DjVu document without constructing a chunk tree.
271///
272/// The walk is pre-order: a `FORM` record appears immediately before its child
273/// records. Offsets are absolute file-byte offsets pointing at each chunk's
274/// four-byte identifier, including the root `FORM` at offset four. The walker
275/// accepts harmless trailing bytes after the root FORM and trailing fragments
276/// shorter than a chunk header inside a FORM, matching the legacy parser's
277/// diagnostic tolerance. It rejects a truncated header or a chunk payload that
278/// exceeds its enclosing FORM.
279///
280/// This API uses only `alloc` and is available in `no_std` builds.
281pub fn walk_chunks(data: &[u8]) -> Result<Vec<ChunkRecord>, IffError> {
282    // Match `parse_form`'s framing requirements and errors for the document
283    // prologue, then use our own recursive walk to retain byte positions.
284    if data.len() < 16 {
285        return Err(IffError::TooShort);
286    }
287
288    let magic = read_4(data, 0)?;
289    if magic != MAGIC {
290        return Err(IffError::BadMagic { got: magic });
291    }
292    if read_4(data, 4)? != *b"FORM" {
293        return Err(IffError::Truncated);
294    }
295
296    let mut records = Vec::new();
297    walk_chunk(data, 4, data.len(), 0, Vec::new(), &mut records)?;
298    Ok(records)
299}
300
301/// Walk one chunk and return the offset immediately following its optional
302/// word-alignment padding.
303fn walk_chunk(
304    data: &[u8],
305    offset: usize,
306    limit: usize,
307    depth: usize,
308    path: Vec<usize>,
309    records: &mut Vec<ChunkRecord>,
310) -> Result<usize, IffError> {
311    if depth > MAX_IFF_DEPTH as usize {
312        return Err(IffError::DepthLimitExceeded {
313            max: MAX_IFF_DEPTH as usize,
314        });
315    }
316
317    let header_end = offset.checked_add(8).ok_or(IffError::Truncated)?;
318    if header_end > limit {
319        return Err(IffError::Truncated);
320    }
321
322    let id = read_4(data, offset)?;
323    let length = read_u32_be(data, offset + 4)? as usize;
324    // A length whose payload end overflows `usize` (reachable on 32-bit targets,
325    // where `usize` is only as wide as the u32 length) is just an extreme case
326    // of a chunk claiming more bytes than exist — report it as `ChunkTooLong`
327    // with the chunk's identity intact, not a generic `Truncated`, so the two
328    // pointer widths agree.
329    let payload_end = match header_end.checked_add(length) {
330        Some(payload_end) if payload_end <= limit => payload_end,
331        _ => {
332            return Err(IffError::ChunkTooLong {
333                id,
334                claimed: length as u32,
335                available: limit.saturating_sub(header_end),
336            });
337        }
338    };
339
340    if id == *b"FORM" && length < 4 {
341        return Err(IffError::Truncated);
342    }
343    let form_type = if id == *b"FORM" {
344        Some(read_4(data, header_end)?)
345    } else {
346        None
347    };
348
349    records.push(ChunkRecord {
350        id,
351        form_type,
352        offset,
353        length,
354        depth,
355        path: path.clone(),
356    });
357
358    if id == *b"FORM" {
359        let mut child_offset = header_end + 4;
360        let mut child_index = 0;
361        while child_offset < payload_end {
362            // Like `parse_children`, preserve the ability to inspect a FORM
363            // that has an unframed trailing fragment while still rejecting a
364            // complete header whose claimed payload extends too far.
365            if payload_end - child_offset < 8 {
366                break;
367            }
368
369            let mut child_path = path.clone();
370            child_path.push(child_index);
371            child_offset = walk_chunk(
372                data,
373                child_offset,
374                payload_end,
375                depth + 1,
376                child_path,
377                records,
378            )?;
379            child_index += 1;
380        }
381    }
382
383    payload_end
384        .checked_add(length & 1)
385        .ok_or(IffError::Truncated)
386}
387
388/// Parse a single chunk starting at `offset` within `data`.
389/// Returns the parsed chunk and the number of bytes consumed (including padding).
390fn parse_chunk(data: &[u8], offset: usize, depth: u32) -> Result<(Chunk, usize), Error> {
391    if depth > MAX_IFF_DEPTH {
392        return Err(Error::InvalidLength);
393    }
394    if offset.checked_add(8).is_none_or(|end| end > data.len()) {
395        return Err(Error::UnexpectedEof);
396    }
397
398    let id: ChunkId = [
399        data[offset],
400        data[offset + 1],
401        data[offset + 2],
402        data[offset + 3],
403    ];
404    let length = u32::from_be_bytes([
405        data[offset + 4],
406        data[offset + 5],
407        data[offset + 6],
408        data[offset + 7],
409    ]);
410
411    let payload_start = offset + 8;
412    // `length` is attacker-controlled (u32, up to 4 GiB). On 32-bit targets
413    // (wasm32) `payload_start + length` can wrap, defeating the bounds check
414    // below and causing a slice panic or a runaway loop. Use checked math.
415    let payload_end = payload_start
416        .checked_add(length as usize)
417        .ok_or(Error::InvalidLength)?;
418
419    if payload_end > data.len() {
420        return Err(Error::UnexpectedEof);
421    }
422
423    // Word-align: next chunk starts at even offset
424    let total = 8usize
425        .checked_add(length as usize)
426        .ok_or(Error::InvalidLength)?;
427    let padded_total = total.checked_add(total % 2).ok_or(Error::InvalidLength)?;
428
429    if &id == b"FORM" {
430        if length < 4 {
431            return Err(Error::InvalidLength);
432        }
433        let secondary_id: ChunkId = [
434            data[payload_start],
435            data[payload_start + 1],
436            data[payload_start + 2],
437            data[payload_start + 3],
438        ];
439
440        let children_start = payload_start + 4;
441        let children = parse_children(data, children_start, payload_end, depth + 1)?;
442
443        Ok((
444            Chunk::Form {
445                secondary_id,
446                length,
447                children,
448            },
449            padded_total,
450        ))
451    } else {
452        let chunk_data = data[payload_start..payload_end].to_vec();
453        Ok((
454            Chunk::Leaf {
455                id,
456                data: chunk_data,
457            },
458            padded_total,
459        ))
460    }
461}
462
463/// Parse sequential chunks within a range of bytes.
464fn parse_children(data: &[u8], start: usize, end: usize, depth: u32) -> Result<Vec<Chunk>, Error> {
465    let mut chunks = Vec::new();
466    let mut pos = start;
467
468    while pos < end {
469        if pos + 8 > end {
470            // Trailing bytes — some files have junk at end; tolerate it
471            break;
472        }
473        let (chunk, consumed) = parse_chunk(data, pos, depth)?;
474        chunks.push(chunk);
475        // `consumed` is padded_total ≥ 8, so this always advances; checked to
476        // stay sound under any future change and on 32-bit targets.
477        pos = pos.checked_add(consumed).ok_or(Error::InvalidLength)?;
478    }
479
480    Ok(chunks)
481}
482
483// ---- Legacy emitter (round-trip support, #195) ------------------------------
484
485/// Serialise a `DjvuFile` (legacy parser) back into the on-disk IFF byte
486/// stream, including the leading "AT&T" magic.
487///
488/// Parser/emitter contract: `parse(emit(file)) == file` for any tree
489/// previously produced by `parse(...)`. This is used by property-based
490/// round-trip tests under `tests/proptest_codecs.rs` (#195) and is small
491/// enough to keep alongside the parser; not intended as a general-purpose
492/// DjVu writer.
493pub fn emit(file: &DjvuFile) -> Vec<u8> {
494    let mut out = Vec::with_capacity(64);
495    out.extend_from_slice(&MAGIC);
496    emit_chunk(&file.root, &mut out);
497    out
498}
499
500fn emit_chunk(chunk: &Chunk, out: &mut Vec<u8>) {
501    emit_chunk_inner(chunk, out, false);
502}
503
504fn emit_chunk_inner(chunk: &Chunk, out: &mut Vec<u8>, suppress_inner_pad: bool) {
505    match chunk {
506        Chunk::Form {
507            secondary_id,
508            length: stored_length,
509            children,
510        } => {
511            // Two valid IFF layouts exist for a FORM whose last child has odd
512            // payload length:
513            //   (A) FORM declared length is odd, no pad after last child;
514            //       the outer/parent loop writes the alignment byte.
515            //   (B) FORM declared length is even, includes a pad byte after
516            //       the last child inside the FORM body.
517            // Real DjVu files mix both styles. Preserve the parser's stored
518            // length parity so unmutated subtrees round-trip byte-identical.
519            let suppress_last_pad = (*stored_length & 1) == 1;
520            let mut payload: Vec<u8> = Vec::new();
521            payload.extend_from_slice(secondary_id);
522            let n = children.len();
523            for (i, child) in children.iter().enumerate() {
524                let last = i + 1 == n;
525                emit_chunk_inner(child, &mut payload, last && suppress_last_pad);
526            }
527            let len = payload.len() as u32;
528            out.extend_from_slice(b"FORM");
529            out.extend_from_slice(&len.to_be_bytes());
530            out.extend_from_slice(&payload);
531            // Outer pad to align the next sibling in our parent. Skip when
532            // our parent told us they'll provide alignment for us.
533            let total = 8 + payload.len();
534            if !suppress_inner_pad && total % 2 == 1 {
535                out.push(0);
536            }
537        }
538        Chunk::Leaf { id, data } => {
539            let len = data.len() as u32;
540            out.extend_from_slice(id);
541            out.extend_from_slice(&len.to_be_bytes());
542            out.extend_from_slice(data);
543            let total = 8 + data.len();
544            if !suppress_inner_pad && total % 2 == 1 {
545                out.push(0);
546            }
547        }
548    }
549}
550
551/// Number of bytes [`emit`] writes for `chunk`: the 8-byte header, the payload,
552/// and any word-alignment pad byte.
553///
554/// This is the single source of the framing/size arithmetic. It walks the same
555/// `suppress_last_pad` parity rule as [`emit_chunk_inner`], so `emitted_size`
556/// and `emit` can never disagree — a guarantee callers that pre-compute byte
557/// offsets (e.g. DIRM offset recomputation in the document mutator) rely on for
558/// correctness.
559pub fn emitted_size(chunk: &Chunk) -> usize {
560    emitted_size_inner(chunk, false)
561}
562
563/// Byte length of `chunk` as [`emit`] frames it, without the trailing
564/// word-alignment pad: the 8-byte header plus the declared length.
565///
566/// This is the component size a bundled `DIRM` records for a `FORM`.
567pub fn framed_size(chunk: &Chunk) -> usize {
568    emitted_size_inner(chunk, true)
569}
570
571fn emitted_size_inner(chunk: &Chunk, suppress_inner_pad: bool) -> usize {
572    match chunk {
573        Chunk::Form {
574            length: stored_length,
575            children,
576            ..
577        } => {
578            let suppress_last_pad = (*stored_length & 1) == 1;
579            let n = children.len();
580            let mut payload = 4usize; // secondary_id
581            for (i, child) in children.iter().enumerate() {
582                let last = i + 1 == n;
583                payload += emitted_size_inner(child, last && suppress_last_pad);
584            }
585            let total = 8 + payload;
586            total + usize::from(!suppress_inner_pad && total % 2 == 1)
587        }
588        Chunk::Leaf { data, .. } => {
589            let total = 8 + data.len();
590            total + usize::from(!suppress_inner_pad && total % 2 == 1)
591        }
592    }
593}
594
595/// One child for [`partial_emit`]: a parsed [`Chunk`] to re-frame, a verbatim
596/// byte slice copied as-is, or a nested `FORM` container framed from its body.
597pub enum EmitPart<'a> {
598    /// Re-frame this chunk through the canonical emitter (8-byte header,
599    /// payload, word-alignment pad).
600    Chunk(&'a Chunk),
601    /// Copy these bytes into the FORM payload verbatim. Use this for children
602    /// whose bytes must be preserved exactly (the byte-preserving path); any
603    /// word-alignment pad is added by [`partial_emit`] if the slice has odd
604    /// length, so callers may pass either padded or unpadded child blocks.
605    Verbatim(&'a [u8]),
606    /// Frame a nested `FORM` container whose *body* is given verbatim. `body`
607    /// starts with the 4-byte secondary id (`DJVU`/`DJVI`/`THUM`/…); the seam
608    /// writes the `FORM` tag, the big-endian length, the body, and the
609    /// word-alignment pad. Use this for the component sub-FORMs of a bundle so
610    /// the `FORM` framing is never hand-rolled at the call site (and so the
611    /// component's start offset is reported by [`partial_emit_with_offsets`]).
612    Form(&'a [u8]),
613}
614
615/// Emit a complete DjVu file (`AT&T` magic + one root `FORM`) whose children
616/// are a mix of re-framed chunks and verbatim original slices.
617///
618/// This is the byte-preserving counterpart to [`emit`]: untouched children pass
619/// through as [`EmitPart::Verbatim`] (their original bytes), while edited
620/// children are re-framed as [`EmitPart::Chunk`]. Every child is word-aligned
621/// inside the payload, and the FORM length is computed here — through the same
622/// framing rules as [`emit`] / [`emitted_size`], so the three can't drift.
623///
624/// Returns `None` if the assembled FORM payload exceeds `u32::MAX`.
625pub fn partial_emit(secondary_id: ChunkId, parts: &[EmitPart<'_>]) -> Option<Vec<u8>> {
626    partial_emit_with_offsets(secondary_id, parts).map(|(bytes, _)| bytes)
627}
628
629/// Like [`partial_emit`], but also returns the absolute file-byte offset of
630/// each part within the returned buffer: `offsets[i]` is the index at which
631/// `parts[i]`'s framing begins, measured from the start of the leading `AT&T`
632/// magic.
633///
634/// This is the seam for writers that must record an external index of where
635/// each component landed — most notably a bundled `FORM:DJVM`, whose `DIRM`
636/// offset table stores the file offset of every component `FORM`. Those
637/// offsets live *inside* one part (the `DIRM`) yet describe the *others*, so
638/// such a writer is inherently two-pass: emit once to learn the offsets, write
639/// them into the `DIRM`, then emit again. The second pass yields identical
640/// offsets — a part's position depends only on the sizes of the parts before
641/// it, and a fixed-width offset table does not change size when its values
642/// change — so the two passes cannot disagree.
643///
644/// Returns `None` if the assembled FORM payload (or any [`EmitPart::Form`]
645/// body) exceeds `u32::MAX`.
646pub fn partial_emit_with_offsets(
647    secondary_id: ChunkId,
648    parts: &[EmitPart<'_>],
649) -> Option<(Vec<u8>, Vec<usize>)> {
650    // The file prologue before the payload is AT&T(4) + FORM(4) + length(4) =
651    // 12 bytes, so a part written while the payload already holds `k` bytes
652    // begins at file offset 12 + k.
653    const PROLOGUE: usize = 12;
654    let mut payload = Vec::new();
655    payload.extend_from_slice(&secondary_id); // even start (4 bytes)
656    let mut offsets = Vec::with_capacity(parts.len());
657    for part in parts {
658        offsets.push(PROLOGUE + payload.len());
659        match part {
660            EmitPart::Chunk(chunk) => emit_chunk(chunk, &mut payload),
661            EmitPart::Verbatim(bytes) => {
662                payload.extend_from_slice(bytes);
663                if payload.len() % 2 == 1 {
664                    payload.push(0);
665                }
666            }
667            EmitPart::Form(body) => {
668                let len = u32::try_from(body.len()).ok()?;
669                payload.extend_from_slice(b"FORM");
670                payload.extend_from_slice(&len.to_be_bytes());
671                payload.extend_from_slice(body);
672                if payload.len() % 2 == 1 {
673                    payload.push(0);
674                }
675            }
676        }
677    }
678    let len = u32::try_from(payload.len()).ok()?;
679    let mut out = Vec::with_capacity(8 + payload.len());
680    out.extend_from_slice(&MAGIC);
681    out.extend_from_slice(b"FORM");
682    out.extend_from_slice(&len.to_be_bytes());
683    out.extend_from_slice(&payload);
684    // Payload stays even (even start + self-aligned parts), so no outer pad is
685    // ever needed; guard defensively to keep the invariant explicit.
686    if (8 + payload.len()) % 2 == 1 {
687        out.push(0);
688    }
689    Some((out, offsets))
690}
691
692// ---- New spec-based IFF parser (phase 1) ------------------------------------
693//
694// `parse_form` is a new zero-copy parser written from the sndjvu.org spec.
695// It returns `Form` and `IffChunk` types (distinct from the legacy `Chunk`).
696
697/// A parsed IFF chunk from the new spec-based parser: a 4-byte identifier
698/// plus a zero-copy slice into the original byte buffer.
699#[derive(Debug, Clone, Copy)]
700pub struct IffChunk<'a> {
701    /// The 4-byte ASCII chunk identifier.
702    pub id: [u8; 4],
703    /// The raw data bytes of this chunk (not including id or length header).
704    pub data: &'a [u8],
705}
706
707/// The top-level FORM structure parsed by the spec-based parser.
708#[derive(Debug)]
709pub struct Form<'a> {
710    /// The 4-byte FORM type (e.g. `DJVU`, `DJVM`, `BM44`, `PM44`).
711    pub form_type: [u8; 4],
712    /// All chunks contained within the FORM, in order.
713    pub chunks: Vec<IffChunk<'a>>,
714}
715
716/// Parse a DjVu IFF byte stream into a [`Form`].
717///
718/// This is the new spec-based zero-copy parser. It returns borrowed data
719/// from the input slice.
720///
721/// # Errors
722///
723/// Returns [`IffError`] if:
724/// - The data does not begin with the `AT&T` magic bytes
725/// - The FORM chunk header is missing or malformed
726/// - Any chunk extends beyond the available data
727pub fn parse_form(data: &[u8]) -> Result<Form<'_>, IffError> {
728    // Need at least: magic(4) + FORM id(4) + length(4) + form_type(4) = 16 bytes
729    if data.len() < 16 {
730        return Err(IffError::TooShort);
731    }
732
733    // Verify AT&T magic prefix
734    let magic = read_4(data, 0)?;
735    if &magic != b"AT&T" {
736        return Err(IffError::BadMagic { got: magic });
737    }
738
739    // Read FORM chunk id
740    let form_id = read_4(data, 4)?;
741    if &form_id != b"FORM" {
742        return Err(IffError::Truncated);
743    }
744
745    // Read FORM length (big-endian u32)
746    let form_len = read_u32_be(data, 8)? as usize;
747
748    // FORM data starts at byte 12 and must fit within the buffer
749    let form_data_end = 12_usize.checked_add(form_len).ok_or(IffError::Truncated)?;
750    if form_data_end > data.len() {
751        return Err(IffError::ChunkTooLong {
752            id: *b"FORM",
753            claimed: form_len as u32,
754            available: data.len().saturating_sub(12),
755        });
756    }
757
758    // Read form_type (first 4 bytes of FORM data)
759    if form_len < 4 {
760        return Err(IffError::Truncated);
761    }
762    let form_type = read_4(data, 12)?;
763
764    // Parse chunks from the FORM body (after form_type)
765    let body = data.get(16..form_data_end).ok_or(IffError::Truncated)?;
766
767    let chunks = parse_form_body(body)?;
768
769    Ok(Form { form_type, chunks })
770}
771
772/// Parse a sequence of IFF chunks from a FORM body (the bytes *after* the
773/// 4-byte form type), returning zero-copy [`IffChunk`] slices.
774///
775/// Each chunk is: `[4-byte id][4-byte big-endian length][length bytes data]`,
776/// with data padded to an even byte boundary. This is the single chunk-walker
777/// shared by the document reader, the mutator, and DJVM merge/split — callers
778/// that already stripped the `AT&T`/`FORM`/length/form-type prologue (e.g. a
779/// sub-FORM body, or a `FORM:DJVU` page extracted from a bundle) pass the
780/// remaining bytes here instead of re-implementing the walk.
781pub fn parse_form_body(mut buf: &[u8]) -> Result<Vec<IffChunk<'_>>, IffError> {
782    let mut chunks = Vec::new();
783
784    while buf.len() >= 8 {
785        let id = read_4(buf, 0)?;
786        let data_len = read_u32_be(buf, 4)? as usize;
787
788        let data_start = 8_usize;
789        let data_end = data_start
790            .checked_add(data_len)
791            .ok_or(IffError::Truncated)?;
792
793        if data_end > buf.len() {
794            return Err(IffError::ChunkTooLong {
795                id,
796                claimed: data_len as u32,
797                available: buf.len().saturating_sub(data_start),
798            });
799        }
800
801        let chunk_data = buf.get(data_start..data_end).ok_or(IffError::Truncated)?;
802        chunks.push(IffChunk {
803            id,
804            data: chunk_data,
805        });
806
807        // Advance past this chunk; pad to even boundary
808        let padded_len = data_len + (data_len & 1);
809        let next = data_start
810            .checked_add(padded_len)
811            .ok_or(IffError::Truncated)?;
812
813        // Clamp to buf length to handle trailing padding gracefully
814        buf = buf.get(next.min(buf.len())..).ok_or(IffError::Truncated)?;
815    }
816
817    Ok(chunks)
818}
819
820/// Read 4 bytes from `data` at `offset` as a `[u8; 4]`.
821#[inline]
822fn read_4(data: &[u8], offset: usize) -> Result<[u8; 4], IffError> {
823    data.get(offset..offset + 4)
824        .and_then(|s| s.try_into().ok())
825        .ok_or(IffError::Truncated)
826}
827
828/// Read a big-endian `u32` from `data` at `offset`.
829#[inline]
830fn read_u32_be(data: &[u8], offset: usize) -> Result<u32, IffError> {
831    let b = read_4(data, offset)?;
832    Ok(u32::from_be_bytes(b))
833}
834
835// ---- Legacy dump helper -----------------------------------------------------
836
837/// Produce a structural dump of the chunk tree.
838///
839/// This preserves the established djvudump-like text format used by the golden
840/// tests. Use [`walk_chunks`] when byte offsets are required.
841pub fn dump(file: &DjvuFile) -> String {
842    let mut out = String::new();
843    dump_chunk(&file.root, 1, &mut out);
844    out
845}
846
847fn dump_chunk(chunk: &Chunk, depth: usize, out: &mut String) {
848    for _ in 0..depth {
849        out.push_str("  ");
850    }
851    match chunk {
852        Chunk::Form {
853            secondary_id,
854            length,
855            children,
856        } => {
857            let sec = str::from_utf8(secondary_id).unwrap_or("????");
858            let _ = writeln!(out, "FORM:{sec} [{length}] ");
859            for child in children {
860                dump_chunk(child, depth + 1, out);
861            }
862        }
863        Chunk::Leaf { id, data } => {
864            let id_str = str::from_utf8(id).unwrap_or("????");
865            let _ = writeln!(out, "{id_str} [{}] ", data.len());
866        }
867    }
868}
869
870#[cfg(test)]
871mod tests {
872    use super::*;
873
874    /// A chunk length whose payload end overflows `usize` (the failure mode on
875    /// 32-bit targets, where `usize` is only as wide as the u32 length) must be
876    /// reported as `ChunkTooLong` with the chunk identity intact — the same
877    /// outcome as a merely-too-long length on 64-bit — not a bare `Truncated`.
878    #[test]
879    fn overflowing_chunk_length_reports_chunk_too_long_with_identity() {
880        // AT&T + FORM + len + DJVU, then a BG44 chunk claiming u32::MAX bytes.
881        let mut buf = Vec::new();
882        buf.extend_from_slice(&MAGIC);
883        buf.extend_from_slice(b"FORM");
884        let inner_len = 4u32 + 8; // "DJVU" + one 8-byte chunk header
885        buf.extend_from_slice(&inner_len.to_be_bytes());
886        buf.extend_from_slice(b"DJVU");
887        buf.extend_from_slice(b"BG44");
888        buf.extend_from_slice(&u32::MAX.to_be_bytes());
889
890        match walk_chunks(&buf) {
891            Err(IffError::ChunkTooLong { id, claimed, .. }) => {
892                assert_eq!(&id, b"BG44");
893                assert_eq!(claimed, u32::MAX);
894            }
895            other => panic!("expected ChunkTooLong, got {other:?}"),
896        }
897    }
898
899    /// A chain of FORMs nested far deeper than `MAX_IFF_DEPTH` must be rejected,
900    /// not recurse until the stack overflows (security finding).
901    #[test]
902    fn deeply_nested_forms_are_rejected_not_overflow() {
903        // innermost: FORM + len(4) + secondary "DJVU" (even-length, no padding)
904        let mut buf = Vec::new();
905        buf.extend_from_slice(b"FORM");
906        buf.extend_from_slice(&4u32.to_be_bytes());
907        buf.extend_from_slice(b"DJVU");
908        for _ in 0..200 {
909            let inner = buf;
910            let len = 4 + inner.len();
911            let mut outer = Vec::new();
912            outer.extend_from_slice(b"FORM");
913            outer.extend_from_slice(&(len as u32).to_be_bytes());
914            outer.extend_from_slice(b"DJVU");
915            outer.extend_from_slice(&inner);
916            buf = outer;
917        }
918        let mut full = Vec::from(*b"AT&T");
919        full.extend_from_slice(&buf);
920        assert!(
921            parse(&full).is_err(),
922            "deep nesting must error, not overflow"
923        );
924    }
925
926    /// A chunk length that overflows `usize` math must be rejected (32-bit / wasm32
927    /// safety — `payload_start + length` must not wrap).
928    #[test]
929    fn overflowing_chunk_length_is_rejected() {
930        let mut data = Vec::from(*b"AT&T");
931        data.extend_from_slice(b"JUNK");
932        data.extend_from_slice(&u32::MAX.to_be_bytes()); // 4 GiB claimed length
933        data.extend_from_slice(b"\x00\x00");
934        assert!(parse(&data).is_err());
935    }
936
937    #[test]
938    fn walk_chunks_reports_nested_offsets_lengths_and_paths() {
939        // AT&T + FORM:DJVM containing DIRM and an embedded FORM:DJVU. Every
940        // payload is even-sized so the offsets can be inspected directly from
941        // the framing arithmetic below without depending on padding quirks.
942        let mut data = Vec::from(*b"AT&T");
943        data.extend_from_slice(b"FORM");
944        data.extend_from_slice(&36u32.to_be_bytes());
945        data.extend_from_slice(b"DJVM");
946        data.extend_from_slice(b"DIRM");
947        data.extend_from_slice(&2u32.to_be_bytes());
948        data.extend_from_slice(b"id");
949        data.extend_from_slice(b"FORM");
950        data.extend_from_slice(&14u32.to_be_bytes());
951        data.extend_from_slice(b"DJVU");
952        data.extend_from_slice(b"INFO");
953        data.extend_from_slice(&2u32.to_be_bytes());
954        data.extend_from_slice(b"xy");
955
956        let records = walk_chunks(&data).expect("synthetic document walks");
957        let dirm_offset = 16;
958        let nested_form_offset = dirm_offset + 8 + 2;
959        let info_offset = nested_form_offset + 8 + 4;
960
961        assert_eq!(
962            records,
963            vec![
964                ChunkRecord {
965                    id: *b"FORM",
966                    form_type: Some(*b"DJVM"),
967                    offset: 4,
968                    length: 36,
969                    depth: 0,
970                    path: vec![],
971                },
972                ChunkRecord {
973                    id: *b"DIRM",
974                    form_type: None,
975                    offset: dirm_offset,
976                    length: 2,
977                    depth: 1,
978                    path: vec![0],
979                },
980                ChunkRecord {
981                    id: *b"FORM",
982                    form_type: Some(*b"DJVU"),
983                    offset: nested_form_offset,
984                    length: 14,
985                    depth: 1,
986                    path: vec![1],
987                },
988                ChunkRecord {
989                    id: *b"INFO",
990                    form_type: None,
991                    offset: info_offset,
992                    length: 2,
993                    depth: 2,
994                    path: vec![1, 0],
995                },
996            ]
997        );
998    }
999
1000    #[test]
1001    fn walk_chunks_offsets_match_real_bundled_fixture() {
1002        let data = std::fs::read(
1003            std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1004                .join("../../tests/fixtures/DjVu3Spec_bundled.djvu"),
1005        )
1006        .expect("bundled fixture exists");
1007        let parsed = parse_form(&data).expect("fixture parses");
1008        let records = walk_chunks(&data).expect("fixture walks");
1009
1010        let payload_offset = |payload: &[u8]| payload.as_ptr() as usize - data.as_ptr() as usize;
1011        let dirm = parsed
1012            .chunks
1013            .iter()
1014            .find(|chunk| chunk.id == *b"DIRM")
1015            .expect("fixture has DIRM");
1016        let component = parsed
1017            .chunks
1018            .iter()
1019            .find(|chunk| chunk.id == *b"FORM")
1020            .expect("fixture has an embedded component FORM");
1021
1022        let dirm_record = records
1023            .iter()
1024            .find(|record| record.id == *b"DIRM")
1025            .expect("walk reports DIRM");
1026        assert_eq!(dirm_record.offset, payload_offset(dirm.data) - 8);
1027        assert_eq!(dirm_record.length, dirm.data.len());
1028
1029        let component_record = records
1030            .iter()
1031            .find(|record| record.depth == 1 && record.id == *b"FORM")
1032            .expect("walk reports embedded component FORM");
1033        assert_eq!(component_record.offset, payload_offset(component.data) - 8);
1034        assert_eq!(component_record.length, component.data.len());
1035        assert_eq!(component_record.path.len(), 1);
1036    }
1037
1038    fn assets_path() -> std::path::PathBuf {
1039        std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1040            .join("../../references/djvujs/library/assets")
1041    }
1042
1043    fn golden_path() -> std::path::PathBuf {
1044        std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../tests/golden/iff")
1045    }
1046
1047    // ---- Legacy parser tests ------------------------------------------------
1048
1049    /// Parse our structural dump and djvudump output to comparable lines.
1050    fn normalize_dump(input: &str) -> Vec<String> {
1051        input
1052            .lines()
1053            .filter(|l| !l.trim().is_empty())
1054            .map(|line| {
1055                let trimmed = line.trim_end();
1056                if let Some(bracket_end) = trimmed.find(']') {
1057                    let structural = &trimmed[..=bracket_end];
1058                    structural.trim_end().to_string()
1059                } else {
1060                    trimmed.to_string()
1061                }
1062            })
1063            .collect()
1064    }
1065
1066    fn assert_structure_matches(djvu_file: &str, golden_file: &str) {
1067        let data = std::fs::read(assets_path().join(djvu_file)).unwrap();
1068        let file = parse(&data).unwrap();
1069        let actual = dump(&file);
1070        let expected = std::fs::read_to_string(golden_path().join(golden_file)).unwrap();
1071
1072        let actual_lines = normalize_dump(&actual);
1073        let expected_lines = normalize_dump(&expected);
1074
1075        assert_eq!(
1076            actual_lines.len(),
1077            expected_lines.len(),
1078            "Line count mismatch for {} ({} vs {})",
1079            djvu_file,
1080            actual_lines.len(),
1081            expected_lines.len()
1082        );
1083
1084        for (i, (a, e)) in actual_lines.iter().zip(expected_lines.iter()).enumerate() {
1085            assert_eq!(
1086                a,
1087                e,
1088                "Line {} mismatch for {}\n  actual:   {:?}\n  expected: {:?}",
1089                i + 1,
1090                djvu_file,
1091                a,
1092                e
1093            );
1094        }
1095    }
1096
1097    #[test]
1098    fn parse_boy_jb2_legacy() {
1099        let data = std::fs::read(assets_path().join("boy_jb2.djvu")).unwrap();
1100        let file = parse(&data).unwrap();
1101
1102        match &file.root {
1103            Chunk::Form {
1104                secondary_id,
1105                children,
1106                ..
1107            } => {
1108                assert_eq!(secondary_id, b"DJVU");
1109                assert_eq!(children.len(), 2);
1110            }
1111            _ => panic!("expected FORM root"),
1112        }
1113    }
1114
1115    #[test]
1116    fn structure_boy_jb2() {
1117        assert_structure_matches("boy_jb2.djvu", "boy_jb2.dump");
1118    }
1119
1120    #[test]
1121    fn structure_boy() {
1122        assert_structure_matches("boy.djvu", "boy.dump");
1123    }
1124
1125    #[test]
1126    fn structure_chicken() {
1127        assert_structure_matches("chicken.djvu", "chicken.dump");
1128    }
1129
1130    #[test]
1131    fn structure_carte() {
1132        assert_structure_matches("carte.djvu", "carte.dump");
1133    }
1134
1135    #[test]
1136    fn structure_navm_fgbz() {
1137        assert_structure_matches("navm_fgbz.djvu", "navm_fgbz.dump");
1138    }
1139
1140    #[test]
1141    fn structure_colorbook() {
1142        assert_structure_matches("colorbook.djvu", "colorbook.dump");
1143    }
1144
1145    #[test]
1146    fn structure_djvu3spec_bundled() {
1147        assert_structure_matches("DjVu3Spec_bundled.djvu", "djvu3spec_bundled.dump");
1148    }
1149
1150    #[test]
1151    fn structure_big_scanned_page() {
1152        assert_structure_matches("big-scanned-page.djvu", "big_scanned_page.dump");
1153    }
1154
1155    // ---- emitted_size / partial_emit ----------------------------------------
1156
1157    /// `emitted_size(root)` must equal the bytes `emit` writes for that root
1158    /// (the whole file minus the 4-byte `AT&T` magic) — the invariant DIRM
1159    /// offset recomputation relies on. Checked across the real-asset corpus,
1160    /// which mixes odd- and even-length FORM declarations.
1161    fn assert_emitted_size_matches_emit(name: &str) {
1162        let Ok(data) = std::fs::read(assets_path().join(name)) else {
1163            return; // asset not vendored in this checkout
1164        };
1165        let file = parse(&data).unwrap();
1166        let emitted = emit(&file);
1167        assert_eq!(
1168            emitted_size(&file.root),
1169            emitted.len() - 4,
1170            "emitted_size disagrees with emit() for {name}"
1171        );
1172    }
1173
1174    #[test]
1175    fn emitted_size_matches_emit_corpus() {
1176        for name in [
1177            "boy_jb2.djvu",
1178            "boy.djvu",
1179            "chicken.djvu",
1180            "carte.djvu",
1181            "navm_fgbz.djvu",
1182            "colorbook.djvu",
1183            "DjVu3Spec_bundled.djvu",
1184            "big-scanned-page.djvu",
1185        ] {
1186            assert_emitted_size_matches_emit(name);
1187        }
1188    }
1189
1190    #[test]
1191    fn partial_emit_verbatim_matches_chunk_framing() {
1192        // A child copied verbatim from a canonical emit must produce the same
1193        // bytes as re-framing that child through EmitPart::Chunk — i.e. the
1194        // byte-preserving path and the re-emit path agree. Build an even-parity
1195        // tree (root length 0) so emit word-aligns every child, the convention
1196        // partial_emit also uses.
1197        let tree = DjvuFile {
1198            root: Chunk::Form {
1199                secondary_id: *b"DJVU",
1200                length: 0,
1201                children: vec![
1202                    Chunk::Leaf {
1203                        id: *b"INFO",
1204                        data: vec![0xAA; 5], // odd → forces a pad
1205                    },
1206                    Chunk::Leaf {
1207                        id: *b"Sjbz",
1208                        data: vec![0xBB; 4], // even
1209                    },
1210                ],
1211            },
1212        };
1213        let canonical = emit(&tree); // AT&T + FORM + DJVU + framed children
1214
1215        let Chunk::Form { children, .. } = &tree.root else {
1216            unreachable!()
1217        };
1218        // Re-emit each child into its own framed block to slice verbatim spans.
1219        let mut info_bytes = Vec::new();
1220        emit_chunk(&children[0], &mut info_bytes);
1221        let mut sjbz_bytes = Vec::new();
1222        emit_chunk(&children[1], &mut sjbz_bytes);
1223
1224        let via_verbatim = partial_emit(
1225            *b"DJVU",
1226            &[
1227                EmitPart::Verbatim(&info_bytes),
1228                EmitPart::Verbatim(&sjbz_bytes),
1229            ],
1230        )
1231        .expect("fits in u32");
1232        let via_chunks = partial_emit(
1233            *b"DJVU",
1234            &[EmitPart::Chunk(&children[0]), EmitPart::Chunk(&children[1])],
1235        )
1236        .expect("fits in u32");
1237
1238        assert_eq!(via_verbatim, canonical, "verbatim path must match emit");
1239        assert_eq!(via_chunks, canonical, "chunk path must match emit");
1240    }
1241
1242    #[test]
1243    fn partial_emit_pads_odd_verbatim_child() {
1244        // A 3-byte verbatim child must be padded to an even boundary inside the
1245        // payload, exactly like an emitted odd-length chunk.
1246        let parts = [EmitPart::Verbatim(&[1u8, 2, 3])];
1247        let out = partial_emit(*b"DJVU", &parts).unwrap();
1248        // AT&T(4) FORM(4) len(4) DJVU(4) + 3 data + 1 pad = 20 bytes.
1249        assert_eq!(out.len(), 20);
1250        assert_eq!(&out[..8], b"AT&TFORM");
1251        // FORM length = DJVU(4) + 3 + 1 pad = 8.
1252        assert_eq!(u32::from_be_bytes(out[8..12].try_into().unwrap()), 8);
1253        assert_eq!(&out[12..16], b"DJVU");
1254        assert_eq!(&out[16..19], &[1, 2, 3]);
1255        assert_eq!(out[19], 0);
1256    }
1257
1258    #[test]
1259    fn partial_emit_form_part_frames_nested_form() {
1260        // An `EmitPart::Form` body must be framed as `FORM` + len + body + pad,
1261        // identical to copying a pre-framed FORM chunk verbatim.
1262        let body: &[u8] = b"DJVUxyz"; // 7 bytes (odd) → forces a pad
1263        let via_form = partial_emit(*b"DJVM", &[EmitPart::Form(body)]).unwrap();
1264
1265        // Hand-frame the same component to compare against the seam output.
1266        let mut framed = Vec::new();
1267        framed.extend_from_slice(b"FORM");
1268        framed.extend_from_slice(&(body.len() as u32).to_be_bytes());
1269        framed.extend_from_slice(body);
1270        framed.push(0); // odd body → pad
1271        let via_verbatim = partial_emit(*b"DJVM", &[EmitPart::Verbatim(&framed)]).unwrap();
1272
1273        assert_eq!(via_form, via_verbatim, "Form part must match framed FORM");
1274        // Spot-check the literal bytes too.
1275        assert_eq!(&via_form[..8], b"AT&TFORM");
1276        assert_eq!(&via_form[12..16], b"DJVM");
1277        assert_eq!(&via_form[16..20], b"FORM");
1278        assert_eq!(u32::from_be_bytes(via_form[20..24].try_into().unwrap()), 7);
1279        assert_eq!(&via_form[24..31], body);
1280        assert_eq!(via_form[31], 0); // pad
1281    }
1282
1283    #[test]
1284    fn partial_emit_with_offsets_reports_part_starts() {
1285        // Each reported offset must point at the byte where that part's framing
1286        // begins (the `FORM`/leaf-id tag), measured from the `AT&T` magic.
1287        let dirm = Chunk::Leaf {
1288            id: *b"DIRM",
1289            data: vec![0xAB; 5], // odd → the DIRM chunk gets a pad
1290        };
1291        let comp0: &[u8] = b"DJVU0000"; // 8 bytes (even)
1292        let comp1: &[u8] = b"DJVIaa"; // 6 bytes (even)
1293        let parts = [
1294            EmitPart::Chunk(&dirm),
1295            EmitPart::Form(comp0),
1296            EmitPart::Form(comp1),
1297        ];
1298        let (bytes, offsets) = partial_emit_with_offsets(*b"DJVM", &parts).unwrap();
1299
1300        assert_eq!(offsets.len(), 3);
1301        // DIRM: AT&T(4)+FORM(4)+len(4)+DJVM(4) = 16.
1302        assert_eq!(offsets[0], 16);
1303        assert_eq!(&bytes[offsets[0]..offsets[0] + 4], b"DIRM");
1304        // Component FORM tags land exactly where the offset table says.
1305        for &off in &offsets[1..] {
1306            assert_eq!(&bytes[off..off + 4], b"FORM", "offset must point at FORM");
1307        }
1308        // comp1 sits after comp0's full framing: 8 (header) + 8 (even body).
1309        assert_eq!(offsets[2] - offsets[1], 16);
1310    }
1311
1312    // ---- New spec-based parser tests ----------------------------------------
1313
1314    /// Build a minimal valid single-page DjVu file in memory for testing.
1315    fn minimal_djvu_bytes() -> Vec<u8> {
1316        let info_data: &[u8] = &[
1317            0x00, 0xB5, // width = 181
1318            0x00, 0xF0, // height = 240
1319            0x18, // minor version
1320            0x00, // major version
1321            0x64, 0x00, // dpi = 100 (little-endian)
1322            0x16, // gamma byte = 22 → 2.2
1323            0x00, // flags: no rotation
1324        ];
1325        let info_len = info_data.len() as u32;
1326
1327        let mut chunk = Vec::new();
1328        chunk.extend_from_slice(b"INFO");
1329        chunk.extend_from_slice(&info_len.to_be_bytes());
1330        chunk.extend_from_slice(info_data);
1331
1332        let mut form_body = Vec::new();
1333        form_body.extend_from_slice(b"DJVU");
1334        form_body.extend_from_slice(&chunk);
1335
1336        let form_len = form_body.len() as u32;
1337
1338        let mut file = Vec::new();
1339        file.extend_from_slice(b"AT&T");
1340        file.extend_from_slice(b"FORM");
1341        file.extend_from_slice(&form_len.to_be_bytes());
1342        file.extend_from_slice(&form_body);
1343
1344        file
1345    }
1346
1347    #[test]
1348    fn empty_input_is_error() {
1349        let result = parse_form(&[]);
1350        assert!(result.is_err());
1351        assert_eq!(result.unwrap_err(), IffError::TooShort);
1352    }
1353
1354    #[test]
1355    fn short_input_is_error() {
1356        let result = parse_form(&[0u8; 10]);
1357        assert!(result.is_err());
1358        assert_eq!(result.unwrap_err(), IffError::TooShort);
1359    }
1360
1361    #[test]
1362    fn bad_magic_is_error() {
1363        let mut data = minimal_djvu_bytes();
1364        data[0] = 0xFF;
1365        data[1] = 0xFF;
1366        data[2] = 0xFF;
1367        data[3] = 0xFF;
1368
1369        let result = parse_form(&data);
1370        assert!(result.is_err());
1371        assert_eq!(
1372            result.unwrap_err(),
1373            IffError::BadMagic {
1374                got: [0xFF, 0xFF, 0xFF, 0xFF]
1375            }
1376        );
1377    }
1378
1379    #[test]
1380    fn valid_single_page_parses() {
1381        let data = minimal_djvu_bytes();
1382        let form = parse_form(&data).expect("should parse successfully");
1383
1384        assert_eq!(&form.form_type, b"DJVU");
1385        assert_eq!(form.chunks.len(), 1);
1386        assert_eq!(&form.chunks[0].id, b"INFO");
1387        assert_eq!(form.chunks[0].data.len(), 10);
1388    }
1389
1390    #[test]
1391    fn truncated_chunk_is_error() {
1392        let mut data = minimal_djvu_bytes();
1393        let new_len = data.len() - 4;
1394        data.truncate(new_len);
1395
1396        let result = parse_form(&data);
1397        assert!(result.is_err());
1398        match result.unwrap_err() {
1399            IffError::ChunkTooLong { .. } | IffError::Truncated => {}
1400            other => panic!("expected ChunkTooLong or Truncated, got {:?}", other),
1401        }
1402    }
1403
1404    #[test]
1405    fn non_form_root_chunk_is_truncated_error() {
1406        // Line 556: AT&T magic present but root chunk id is not FORM
1407        let mut data = Vec::new();
1408        data.extend_from_slice(b"AT&T");
1409        data.extend_from_slice(b"INFO"); // not FORM
1410        data.extend_from_slice(&10u32.to_be_bytes());
1411        data.extend_from_slice(&[0u8; 10]);
1412        assert_eq!(parse_form(&data).unwrap_err(), IffError::Truncated);
1413    }
1414
1415    #[test]
1416    fn form_too_short_for_secondary_id() {
1417        // Line 574: FORM length < 4 (not enough bytes for the secondary_id).
1418        // parse_form requires >= 16 bytes total, so pad to 16 while keeping length=3.
1419        let mut data = Vec::new();
1420        data.extend_from_slice(b"AT&T");
1421        data.extend_from_slice(b"FORM");
1422        data.extend_from_slice(&3u32.to_be_bytes()); // length = 3 < 4
1423        data.extend_from_slice(b"XYZ\x00"); // 4 bytes to reach 16 total
1424        assert_eq!(parse_form(&data).unwrap_err(), IffError::Truncated);
1425    }
1426
1427    #[test]
1428    fn sub_chunk_length_exceeds_body() {
1429        // Lines 608-610: a sub-chunk in parse_form_body claims more bytes than available
1430        // Build a minimal DJVU FORM: AT&T + FORM(length) + DJVU + INFO(claimed 100, actual 2)
1431        let mut body = Vec::new();
1432        body.extend_from_slice(b"DJVU"); // form_type
1433        body.extend_from_slice(b"INFO");
1434        body.extend_from_slice(&100u32.to_be_bytes()); // claimed length: 100
1435        body.extend_from_slice(&[0u8; 2]); // only 2 actual bytes
1436        let mut data = Vec::new();
1437        data.extend_from_slice(b"AT&T");
1438        data.extend_from_slice(b"FORM");
1439        data.extend_from_slice(&(body.len() as u32).to_be_bytes());
1440        data.extend_from_slice(&body);
1441        match parse_form(&data).unwrap_err() {
1442            IffError::ChunkTooLong { .. } => {}
1443            other => panic!("expected ChunkTooLong, got {other:?}"),
1444        }
1445    }
1446
1447    #[test]
1448    fn unknown_form_type_allowed() {
1449        let mut data = minimal_djvu_bytes();
1450        data[12] = b'X';
1451        data[13] = b'X';
1452        data[14] = b'X';
1453        data[15] = b'X';
1454
1455        let form = parse_form(&data).expect("unknown form type should still parse");
1456        assert_eq!(&form.form_type, b"XXXX");
1457    }
1458
1459    #[test]
1460    fn real_chicken_djvu_parses() {
1461        let path = assets_path().join("chicken.djvu");
1462        let data = std::fs::read(&path).expect("chicken.djvu must exist");
1463        let form = parse_form(&data).expect("chicken.djvu should parse");
1464
1465        assert_eq!(&form.form_type, b"DJVU");
1466        assert!(!form.chunks.is_empty(), "must have at least one chunk");
1467        assert_eq!(&form.chunks[0].id, b"INFO");
1468        assert!(form.chunks[0].data.len() >= 10);
1469    }
1470
1471    #[test]
1472    fn real_multipage_djvu_parses() {
1473        let path = assets_path().join("navm_fgbz.djvu");
1474        let data = std::fs::read(&path).expect("navm_fgbz.djvu must exist");
1475        let form = parse_form(&data).expect("navm_fgbz.djvu should parse");
1476
1477        assert_eq!(&form.form_type, b"DJVM");
1478        assert!(!form.chunks.is_empty());
1479    }
1480
1481    // Lines 95-102: LegacyError Display variants
1482    #[test]
1483    fn legacy_error_display_variants() {
1484        assert_eq!(
1485            LegacyError::UnexpectedEof.to_string(),
1486            "unexpected end of input"
1487        );
1488        assert_eq!(
1489            LegacyError::InvalidMagic.to_string(),
1490            "invalid magic number"
1491        );
1492        assert_eq!(LegacyError::InvalidLength.to_string(), "invalid length");
1493        assert_eq!(
1494            LegacyError::MissingChunk("INFO").to_string(),
1495            "missing required chunk: INFO"
1496        );
1497        assert_eq!(LegacyError::Unsupported("x").to_string(), "unsupported: x");
1498        assert_eq!(
1499            LegacyError::FormatError("y".to_string()).to_string(),
1500            "format error: y"
1501        );
1502    }
1503
1504    // Lines 151, 169-172, 180, 185-190: Chunk accessor methods on Form/Leaf
1505    #[test]
1506    fn chunk_accessors_form_and_leaf() {
1507        let leaf = Chunk::Leaf {
1508            id: *b"INFO",
1509            data: vec![1, 2, 3],
1510        };
1511        let form = Chunk::Form {
1512            secondary_id: *b"DJVU",
1513            length: 10,
1514            children: vec![leaf.clone()],
1515        };
1516
1517        // data(): Form returns empty, Leaf returns data
1518        assert_eq!(form.data(), &[] as &[u8]);
1519        assert_eq!(leaf.data(), &[1u8, 2, 3]);
1520
1521        // children(): Form returns children, Leaf returns empty
1522        assert_eq!(form.children().len(), 1);
1523        assert!(leaf.children().is_empty());
1524
1525        // payload_length(): Form returns declared length, Leaf returns data.len()
1526        assert_eq!(form.payload_length(), 10);
1527        assert_eq!(leaf.payload_length(), 3);
1528
1529        // find_first(): on Leaf returns None (no children)
1530        assert!(leaf.find_first(b"INFO").is_none());
1531
1532        // find_first() on Form with no matching child returns None
1533        let form2 = Chunk::Form {
1534            secondary_id: *b"DJVU",
1535            length: 0,
1536            children: vec![],
1537        };
1538        assert!(form2.find_first(b"INFO").is_none());
1539    }
1540
1541    #[test]
1542    fn find_all_returns_all_matching_leaves() {
1543        let leaf1 = Chunk::Leaf {
1544            id: *b"INFO",
1545            data: vec![1],
1546        };
1547        let leaf2 = Chunk::Leaf {
1548            id: *b"INFO",
1549            data: vec![2],
1550        };
1551        let leaf3 = Chunk::Leaf {
1552            id: *b"BG44",
1553            data: vec![3],
1554        };
1555        // A Form child — find_all should skip it (the _ => false branch)
1556        let child_form = Chunk::Form {
1557            secondary_id: *b"DJVU",
1558            length: 0,
1559            children: vec![],
1560        };
1561        let form = Chunk::Form {
1562            secondary_id: *b"DJVU",
1563            length: 0,
1564            children: vec![leaf1, leaf2, leaf3, child_form],
1565        };
1566        let all_info = form.find_all(b"INFO");
1567        assert_eq!(all_info.len(), 2);
1568        let all_bg44 = form.find_all(b"BG44");
1569        assert_eq!(all_bg44.len(), 1);
1570        let all_none = form.find_all(b"NONE");
1571        assert!(all_none.is_empty());
1572    }
1573
1574    #[test]
1575    fn find_first_skips_form_children() {
1576        // A Form whose first child is itself a Form — the `_ => false` branch
1577        // in find_first skips it and finds the Leaf later.
1578        let child_form = Chunk::Form {
1579            secondary_id: *b"DJVU",
1580            length: 0,
1581            children: vec![],
1582        };
1583        let leaf = Chunk::Leaf {
1584            id: *b"INFO",
1585            data: vec![42],
1586        };
1587        let form = Chunk::Form {
1588            secondary_id: *b"DJVU",
1589            length: 0,
1590            children: vec![child_form, leaf],
1591        };
1592        let found = form.find_first(b"INFO").expect("should find INFO");
1593        assert!(matches!(found, Chunk::Leaf { id, .. } if id == b"INFO"));
1594    }
1595
1596    #[test]
1597    fn parse_empty_input_returns_unexpected_eof() {
1598        // Line 207: data.len() < 4
1599        assert!(matches!(parse(b""), Err(Error::UnexpectedEof)));
1600        assert!(matches!(parse(b"AT"), Err(Error::UnexpectedEof)));
1601    }
1602
1603    #[test]
1604    fn parse_form_length_too_small_returns_invalid_length() {
1605        // Line 255: FORM chunk with length field < 4
1606        // AT&T + FORM + length(3) + 3 bytes payload = 15 bytes total
1607        let mut data = vec![];
1608        data.extend_from_slice(b"AT&T");
1609        data.extend_from_slice(b"FORM");
1610        data.extend_from_slice(&3u32.to_be_bytes()); // length < 4
1611        data.extend_from_slice(b"XYZ");
1612        assert!(matches!(parse(&data), Err(Error::InvalidLength)));
1613    }
1614
1615    #[test]
1616    fn parse_children_skips_trailing_bytes() {
1617        // Line 295: FORM with trailing bytes (pos + 8 > end but pos < end)
1618        // Construct a FORM with 4 bytes secondary_id + 5 bytes trailing junk
1619        // (5 < 8, so parse_children will break out of its loop)
1620        let mut data = vec![];
1621        data.extend_from_slice(b"AT&T");
1622        data.extend_from_slice(b"FORM");
1623        let secondary_plus_junk = b"DJVU\x01\x02\x03\x04\x05"; // 4 + 5 = 9 bytes
1624        data.extend_from_slice(&(secondary_plus_junk.len() as u32).to_be_bytes());
1625        data.extend_from_slice(secondary_plus_junk);
1626        let result = parse(&data);
1627        // Should succeed (not error) and produce a Form with 0 children
1628        let djvu = result.expect("trailing bytes must not cause an error");
1629        assert!(matches!(djvu.root, Chunk::Form { .. }));
1630        assert!(djvu.root.children().is_empty());
1631    }
1632
1633    #[test]
1634    fn odd_length_chunk_padding() {
1635        let chunk1_data: &[u8] = &[0xAA, 0xBB, 0xCC, 0xDD, 0xEE]; // 5 bytes → padded to 6
1636        let chunk2_data: &[u8] = &[0x01, 0x02]; // 2 bytes
1637
1638        let mut form_body: Vec<u8> = Vec::new();
1639        form_body.extend_from_slice(b"DJVU");
1640
1641        form_body.extend_from_slice(b"TST1");
1642        form_body.extend_from_slice(&5u32.to_be_bytes());
1643        form_body.extend_from_slice(chunk1_data);
1644        form_body.push(0x00); // padding byte
1645
1646        form_body.extend_from_slice(b"TST2");
1647        form_body.extend_from_slice(&2u32.to_be_bytes());
1648        form_body.extend_from_slice(chunk2_data);
1649
1650        let form_len = form_body.len() as u32;
1651
1652        let mut file: Vec<u8> = Vec::new();
1653        file.extend_from_slice(b"AT&T");
1654        file.extend_from_slice(b"FORM");
1655        file.extend_from_slice(&form_len.to_be_bytes());
1656        file.extend_from_slice(&form_body);
1657
1658        let form = parse_form(&file).expect("should parse padded chunk");
1659        assert_eq!(form.chunks.len(), 2);
1660        assert_eq!(&form.chunks[0].id, b"TST1");
1661        assert_eq!(form.chunks[0].data, chunk1_data);
1662        assert_eq!(&form.chunks[1].id, b"TST2");
1663        assert_eq!(form.chunks[1].data, chunk2_data);
1664    }
1665}