Skip to main content

djvu_rs/
djvu_document.rs

1//! New document model for DjVu files — phase 3.
2//!
3//! This module provides the high-level `DjVuDocument` API built on top of the
4//! clean-room IFF parser (phase 1), BZZ decompressor (phase 2a), and IW44 decoder
5//! (phase 2c).
6//!
7//! ## Key public types
8//!
9//! - [`DjVuDocument`] — opened DjVu document (single-page or multi-page)
10//! - [`DjVuPage`] — lazy page handle (raw chunks stored until `thumbnail()` is called)
11//! - [`DjVuBookmark`] — table-of-contents entry from the NAVM chunk
12//! - [`DocError`] — typed errors for this module
13//!
14//! ## Document kinds
15//!
16//! - **FORM:DJVU** — single-page document
17//! - **FORM:BM44** / **FORM:PM44** — legacy standalone IW44 photo documents
18//!   (grayscale / color); exposed as one-page documents without an INFO chunk
19//! - **FORM:DJVM + DIRM** — bundled multi-page document with an in-file page index
20//! - **FORM:DJVM + DIRM (indirect)** — components live in separate files; the
21//!   typed [`ComponentResolver`] contract identifies each page, shared, or
22//!   thumbnail component
23//!
24//! ## Lazy decoding contract
25//!
26//! `DjVuPage` stores only the raw chunk bytes. No image decoding happens until
27//! the caller explicitly calls `thumbnail()` (which invokes the IW44 decoder).
28
29#[cfg(not(feature = "std"))]
30use alloc::{
31    string::{String, ToString},
32    vec,
33    vec::Vec,
34};
35
36use crate::{
37    annotation::{Annotation, AnnotationError, MapArea},
38    bzz::bzz_decode,
39    dirm::{DirmComponent, DirmComponentKind, DirmPayload},
40    error::{BzzError, IffError, Iw44Error, Jb2Error},
41    iff::{IffChunk, parse_form, parse_form_body},
42    info::PageInfo,
43    iw44::Iw44Image,
44    jb2::Jb2Dict,
45    metadata::{DjVuMetadata, MetadataError},
46    pixmap::Pixmap,
47    text::{TextError, TextLayer},
48};
49
50#[cfg(not(feature = "std"))]
51use alloc::sync::Arc;
52#[cfg(feature = "std")]
53use std::sync::Arc;
54
55/// The kind of an external component listed by an indirect `FORM:DJVM`.
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
58pub enum ComponentKind {
59    /// A renderable `FORM:DJVU` page.
60    Page,
61    /// A shared `FORM:DJVI` component, such as a JB2 symbol dictionary.
62    Shared,
63    /// A `FORM:THUM` thumbnail component.
64    Thumbnail,
65}
66
67/// Stable identity of one component in an indirect `FORM:DJVM` directory.
68#[derive(Debug, Clone, PartialEq, Eq)]
69#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
70pub struct ComponentId {
71    /// Resolver key from the DIRM directory.
72    pub name: String,
73    /// DIRM classification for this component.
74    pub kind: ComponentKind,
75}
76
77impl ComponentId {
78    /// Construct a component identity from its resolver key and DIRM kind.
79    pub fn new(name: impl Into<String>, kind: ComponentKind) -> Self {
80        Self {
81            name: name.into(),
82            kind,
83        }
84    }
85}
86
87/// Typed failures returned by a [`ComponentResolver`].
88#[derive(Debug, Clone, thiserror::Error)]
89#[non_exhaustive]
90pub enum ComponentResolveError {
91    /// The requested component is not available to the resolver.
92    #[error("indirect component {component:?} is missing")]
93    Missing {
94        /// Identity of the missing component.
95        component: ComponentId,
96    },
97
98    /// The resolver could not read or construct the requested component.
99    #[error("failed to resolve indirect component {component:?}: {reason}")]
100    Failed {
101        /// Identity of the component that could not be resolved.
102        component: ComponentId,
103        /// Human-readable resolver detail.
104        reason: String,
105    },
106}
107
108/// Synchronous resolver contract for indirect DJVM components.
109///
110/// The resolver is called once for every DIRM entry, including pages, shared
111/// components, and thumbnails. The typed [`ComponentId`] keeps the component
112/// identity and its DIRM classification together so sync, async, and mutable
113/// adapters can share the same vocabulary as they are added.
114pub trait ComponentResolver {
115    /// Return the complete IFF bytes for one external component.
116    fn resolve(&self, component: &ComponentId) -> Result<Vec<u8>, ComponentResolveError>;
117}
118
119impl<F> ComponentResolver for F
120where
121    F: Fn(&ComponentId) -> Result<Vec<u8>, ComponentResolveError>,
122{
123    fn resolve(&self, component: &ComponentId) -> Result<Vec<u8>, ComponentResolveError> {
124        self(component)
125    }
126}
127
128// ---- Error type -------------------------------------------------------------
129
130/// Errors that can occur when working with the DjVuDocument API.
131#[derive(Debug, thiserror::Error)]
132#[non_exhaustive]
133pub enum DocError {
134    /// IFF container parse error.
135    #[error("IFF error: {0}")]
136    Iff(#[from] IffError),
137
138    /// BZZ decompression error.
139    #[error("BZZ error: {0}")]
140    Bzz(#[from] BzzError),
141
142    /// IW44 wavelet decoding error.
143    #[error("IW44 error: {0}")]
144    Iw44(#[from] Iw44Error),
145
146    /// JB2 bilevel image decoding error.
147    #[error("JB2 error: {0}")]
148    Jb2(#[from] Jb2Error),
149
150    /// The file is not a supported DjVu format.
151    #[error("not a DjVu file: found form type {0:?}")]
152    NotDjVu([u8; 4]),
153
154    /// A required chunk is missing.
155    #[error("missing required chunk: {0}")]
156    MissingChunk(&'static str),
157
158    /// The document is malformed (description included).
159    #[error("malformed DjVu document: {0}")]
160    Malformed(&'static str),
161
162    /// An indirect page reference could not be resolved.
163    #[error("failed to resolve indirect page '{0}'")]
164    IndirectResolve(String),
165
166    /// A typed resolver could not provide one indirect component.
167    #[error("component resolution failed: {0}")]
168    ComponentResolve(#[from] ComponentResolveError),
169
170    /// A resolved component's FORM type disagrees with its DIRM classification.
171    #[error("indirect component {component:?} has FORM:{found:?}, expected kind {expected:?}")]
172    ComponentKindMismatch {
173        /// Identity from the DIRM entry.
174        component: ComponentId,
175        /// FORM type found in the resolved bytes.
176        found: [u8; 4],
177        /// FORM type required by the DIRM kind.
178        expected: ComponentKind,
179    },
180
181    /// Page index is out of range.
182    #[error("page index {index} is out of range (document has {count} pages)")]
183    PageOutOfRange { index: usize, count: usize },
184
185    /// Invalid UTF-8 in a string field.
186    ///
187    /// No longer produced since #524: NAVM bookmark strings are decoded
188    /// leniently (CP1252 fallback). Kept so matching code keeps compiling.
189    #[error("invalid UTF-8 in DjVu metadata")]
190    InvalidUtf8,
191
192    /// The resolver callback is required for indirect documents but was not provided.
193    #[error("indirect DjVu document requires a resolver callback")]
194    NoResolver,
195
196    /// I/O error when reading file data (only with `std` feature).
197    #[cfg(feature = "std")]
198    #[error("I/O error: {0}")]
199    Io(#[from] std::io::Error),
200
201    /// G4/MMR mask decoding error.
202    #[error("Smmr decode error: {0}")]
203    Smmr(String),
204
205    /// Text layer parse error.
206    #[error("text layer error: {0}")]
207    Text(#[from] TextError),
208
209    /// Annotation parse error.
210    #[error("annotation error: {0}")]
211    Annotation(#[from] AnnotationError),
212
213    /// Metadata parse error.
214    #[error("metadata error: {0}")]
215    Metadata(#[from] MetadataError),
216
217    /// A configured resource limit was exceeded during document parse/open.
218    #[error("{0}")]
219    ResourceLimit(#[from] crate::resource_limits::ResourceLimitExceeded),
220}
221
222// ---- Bookmark ---------------------------------------------------------------
223
224/// A table-of-contents entry from the NAVM chunk.
225#[derive(Debug, Clone)]
226#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
227pub struct DjVuBookmark {
228    /// Display title.
229    pub title: String,
230    /// Target URL (DjVu internal URL format).
231    pub url: String,
232    /// Nested child entries.
233    pub children: Vec<DjVuBookmark>,
234}
235
236/// One entry from a document `DIRM` directory (or a synthesized single-page view).
237///
238/// Kind letters follow DjVuLibre `djvused ls`: `P` page, `I` shared/include,
239/// `T` thumbnail.
240#[derive(Debug, Clone, PartialEq, Eq)]
241#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
242pub struct ComponentDirectoryEntry {
243    /// Component classification letter (`P`, `I`, or `T`).
244    pub kind: char,
245    /// Resolver / directory id string.
246    pub id: String,
247}
248
249// ---- Page -------------------------------------------------------------------
250
251/// A raw chunk extracted from a page FORM:DJVU.
252#[derive(Debug, Clone)]
253struct RawChunk {
254    id: [u8; 4],
255    data: Vec<u8>,
256}
257
258/// Shared, owned backing store for a document's bytes (an owned `Vec<u8>` from
259/// [`crate::Document::from_bytes`], or a `memmap2::Mmap`). Lazily-constructed
260/// pages ([`ChunkStore::Lazy`]) hold an `Arc` clone of this so their chunk bytes
261/// can be materialised on first access without copying them at open time.
262#[cfg(feature = "std")]
263pub(crate) type Backing = Arc<dyn AsRef<[u8]> + Send + Sync>;
264
265/// The bytes behind a [`Backing`].
266#[cfg(feature = "std")]
267fn backing_bytes(b: &Backing) -> &[u8] {
268    (**b).as_ref()
269}
270
271/// Where a page's chunk list comes from.
272///
273/// `Eager` holds the copied chunks (the historical behaviour, used for
274/// single-page, indirect, and `no_std` documents). `Lazy` defers the per-chunk
275/// `to_vec` copy until first access: it keeps the shared document backing and
276/// this page's `FORM` byte range, and materialises the chunks once on demand.
277/// This is what makes opening a large bundled document O(1) in copies instead of
278/// O(total bytes) when only some pages are ever rendered (LAZY_PAGE_CONSTRUCT).
279#[cfg(feature = "std")]
280enum ChunkStore {
281    Eager(Vec<RawChunk>),
282    Lazy {
283        backing: Backing,
284        range: core::ops::Range<usize>,
285        cache: std::sync::OnceLock<Vec<RawChunk>>,
286    },
287}
288
289#[cfg(feature = "std")]
290impl ChunkStore {
291    /// The page's chunks, materialising them from the backing on first call for
292    /// the `Lazy` variant. A corrupt/out-of-range slice yields an empty list
293    /// (permissive, matching the render path's error handling).
294    fn get(&self) -> &[RawChunk] {
295        match self {
296            ChunkStore::Eager(v) => v,
297            ChunkStore::Lazy {
298                backing,
299                range,
300                cache,
301            } => cache.get_or_init(|| {
302                let Some(sub) = backing_bytes(backing).get(range.clone()) else {
303                    return Vec::new();
304                };
305                match parse_sub_form(sub) {
306                    Ok(chunks) => chunks
307                        .iter()
308                        .map(|c| RawChunk {
309                            id: c.id,
310                            data: c.data.to_vec(),
311                        })
312                        .collect(),
313                    Err(_) => Vec::new(),
314                }
315            }),
316        }
317    }
318}
319
320#[cfg(feature = "std")]
321impl Clone for ChunkStore {
322    fn clone(&self) -> Self {
323        match self {
324            ChunkStore::Eager(v) => ChunkStore::Eager(v.clone()),
325            // A cloned page re-defers: same backing + range, fresh cache.
326            ChunkStore::Lazy { backing, range, .. } => ChunkStore::Lazy {
327                backing: backing.clone(),
328                range: range.clone(),
329                cache: std::sync::OnceLock::new(),
330            },
331        }
332    }
333}
334
335/// Decode the payload of a paired `*z` (BZZ-compressed) / `*a` (raw) chunk.
336///
337/// DjVu stores most variable-length payloads as a pair of chunk ids: a
338/// BZZ-compressed `*z` variant (`TXTz`, `ANTz`, `METz`, …) and a raw `*a`
339/// variant (`TXTa`, `ANTa`, `METa`, …).  This is the single place that owns
340/// the "is it compressed?" decision: it prefers the compressed chunk, falls
341/// back to the raw chunk, and treats a present-but-empty chunk as "no payload"
342/// (DjVu uses a zero-length chunk as a placeholder).  Callers receive already
343/// decoded bytes, so the format parsers stay pure `&[u8]` functions that never
344/// touch compression.
345fn decode_paired_payload(z: Option<&[u8]>, a: Option<&[u8]>) -> Result<Option<Vec<u8>>, BzzError> {
346    if let Some(z) = z {
347        return if z.is_empty() {
348            Ok(None)
349        } else {
350            Ok(Some(bzz_decode(z)?))
351        };
352    }
353    if let Some(a) = a {
354        return Ok(if a.is_empty() { None } else { Some(a.to_vec()) });
355    }
356    Ok(None)
357}
358
359/// A lazy DjVu page handle.
360///
361/// Raw chunk data is stored on construction. No image decoding is performed
362/// until the caller invokes `thumbnail()` or a render function.
363///
364/// The fully decoded BG44 wavelet image is cached after the first render so
365/// that subsequent renders skip the expensive ZP arithmetic decode and only
366/// run the wavelet inverse-transform and compositor.
367///
368/// ## Caching
369///
370/// [`decoded_bg44`](Self::decoded_bg44), [`decoded_mask`](Self::decoded_mask),
371/// and [`decoded_fg44`](Self::decoded_fg44) cache their results in a
372/// `std::sync::OnceLock` after the first call. Prefer these over the
373/// `extract_*` methods in performance-sensitive loops.
374///
375/// **`Clone` resets the cache.** A cloned `DjVuPage` starts with empty caches;
376/// the first render on the clone re-runs the full decode.
377/// A shared JB2 symbol dictionary referenced by one or more pages via their
378/// INCL chunk.
379///
380/// The raw `Djbz` bytes and the lazily-decoded [`Jb2Dict`] are wrapped in the
381/// **same** `Arc`, which every page referencing this DJVI component clones. So
382/// when many pages share one dictionary (the common case for bundled scans —
383/// e.g. 85 pages over 2 dictionaries), the ZP arithmetic decode runs **once per
384/// document** rather than once per page. Previously the raw bytes were shared
385/// (via `Arc`) but each page decoded them into its own per-page cache.
386#[cfg(feature = "std")]
387pub(crate) struct SharedDict {
388    raw: Vec<u8>,
389    decoded: std::sync::OnceLock<Option<Jb2Dict>>,
390}
391
392#[cfg(feature = "std")]
393impl SharedDict {
394    /// Wrap raw `Djbz` bytes; the dictionary is decoded lazily on first use.
395    pub(crate) fn new(raw: Vec<u8>) -> Self {
396        Self {
397            raw,
398            decoded: std::sync::OnceLock::new(),
399        }
400    }
401
402    /// Decode the dictionary on first call and return it, caching the result so
403    /// every page sharing this `Arc` reuses the single decode.
404    fn get(&self) -> Option<&Jb2Dict> {
405        self.decoded
406            .get_or_init(|| crate::jb2::decode_dict(&self.raw, None).ok())
407            .as_ref()
408    }
409
410    /// Length of the raw `Djbz` bytes (for `Debug`).
411    fn raw_len(&self) -> usize {
412        self.raw.len()
413    }
414}
415
416pub struct DjVuPage {
417    /// Page info parsed from the INFO chunk.
418    info: PageInfo,
419    /// All raw chunks from this page's FORM:DJVU, in order. In `std` builds this
420    /// may be lazily materialised from the document backing (see [`ChunkStore`]);
421    /// `no_std` always holds the eagerly-copied chunks.
422    #[cfg(feature = "std")]
423    chunks: ChunkStore,
424    #[cfg(not(feature = "std"))]
425    chunks: Vec<RawChunk>,
426    /// Page index within the document (0-based).
427    index: usize,
428    /// Raw Djbz data from the DJVI shared dictionary component referenced via
429    /// the page's INCL chunk, if present.  Stored here so that `extract_mask`
430    /// can decode it without access to the parent document.
431    ///
432    /// Wrapped in `Arc` so that multi-page documents share one allocation —
433    /// and, via [`SharedDict`], one *decode* — instead of cloning the bytes and
434    /// re-decoding per page.
435    #[cfg(feature = "std")]
436    shared_djbz: Option<Arc<SharedDict>>,
437    #[cfg(not(feature = "std"))]
438    shared_djbz: Option<Vec<u8>>,
439    /// Render-tier cache of this page's decoded layers (background, mask,
440    /// quarter-resolution mask, foreground).  The decode logic and the
441    /// compositor-subsampling concern live in
442    /// [`crate::djvu_render::PageLayers`]; the page only holds the handle so
443    /// repeated renders reuse the decode.  Populated on first render.
444    /// Only available when the `std` feature is enabled (`OnceLock` requires std).
445    #[cfg(feature = "std")]
446    render_layers: std::sync::OnceLock<Arc<crate::djvu_render::PageLayers>>,
447    /// Resource limits inherited from the parent document at parse time.
448    resource_limits: Option<crate::resource_limits::ResourceLimits>,
449}
450
451impl Clone for DjVuPage {
452    fn clone(&self) -> Self {
453        DjVuPage {
454            info: self.info.clone(),
455            chunks: self.chunks.clone(),
456            index: self.index,
457            shared_djbz: self.shared_djbz.clone(),
458            // The render cache is not cloned — it is lazily recomputed. The
459            // shared-dict decode lives inside the `shared_djbz` Arc, so a cloned
460            // page keeps sharing the single decode (the dict is immutable).
461            #[cfg(feature = "std")]
462            render_layers: std::sync::OnceLock::new(),
463            resource_limits: self.resource_limits,
464        }
465    }
466}
467
468impl core::fmt::Debug for DjVuPage {
469    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
470        #[cfg(feature = "std")]
471        let dbz_len = self.shared_djbz.as_ref().map(|v| v.raw_len());
472        #[cfg(not(feature = "std"))]
473        let dbz_len = self.shared_djbz.as_ref().map(|v| v.len());
474        f.debug_struct("DjVuPage")
475            .field("info", &self.info)
476            .field("chunks", &self.chunk_slice())
477            .field("index", &self.index)
478            .field("shared_djbz", &dbz_len)
479            .finish_non_exhaustive()
480    }
481}
482
483impl DjVuPage {
484    /// Page width in pixels.
485    pub fn width(&self) -> u16 {
486        self.info.width
487    }
488
489    /// Page height in pixels.
490    pub fn height(&self) -> u16 {
491        self.info.height
492    }
493
494    /// Page resolution in dots per inch.
495    pub fn dpi(&self) -> u16 {
496        self.info.dpi
497    }
498
499    /// Display gamma from the INFO chunk.
500    pub fn gamma(&self) -> f32 {
501        self.info.gamma
502    }
503
504    /// Page rotation from the INFO chunk.
505    pub fn rotation(&self) -> crate::info::Rotation {
506        self.info.rotation
507    }
508
509    /// 0-based page index within the document.
510    pub fn index(&self) -> usize {
511        self.index
512    }
513
514    /// Resource limits inherited from the parent document at parse time.
515    pub fn resource_limits(&self) -> Option<crate::resource_limits::ResourceLimits> {
516        self.resource_limits
517    }
518
519    /// Dimensions as `(width, height)`.
520    pub fn dimensions(&self) -> (u16, u16) {
521        (self.info.width, self.info.height)
522    }
523
524    /// Decode the thumbnail for this page from TH44 chunks, if present.
525    ///
526    /// No image data is decoded until this method is called (lazy contract).
527    ///
528    /// Returns `Ok(None)` if the page has no TH44 thumbnail.
529    pub fn thumbnail(&self) -> Result<Option<Pixmap>, DocError> {
530        let th44_chunks: Vec<&[u8]> = self
531            .chunk_slice()
532            .iter()
533            .filter(|c| &c.id == b"TH44")
534            .map(|c| c.data.as_slice())
535            .collect();
536
537        if th44_chunks.is_empty() {
538            return Ok(None);
539        }
540
541        let mut img = Iw44Image::new();
542        for chunk_data in &th44_chunks {
543            img.decode_chunk(chunk_data)?;
544        }
545        let pixmap = img.to_rgb()?;
546        Ok(Some(pixmap))
547    }
548
549    /// Drop this page's render-tier decode cache, reclaiming its per-page memory.
550    ///
551    /// A rendered page memoises its decoded background (including the full-res
552    /// RGB pixmap — up to `width × height × 4` bytes), mask, and foreground in a
553    /// [`crate::djvu_render::PageLayers`] that lives as long as the owning
554    /// document. Rendering many pages of a large document therefore accumulates
555    /// one such cache per page — the peak RSS grows linearly with pages rendered
556    /// (measured ≈ 11 MB/page on `colorbook.djvu`), which can exhaust memory in a
557    /// long-lived viewer over a big book.
558    ///
559    /// This resets the cache so the memory is reclaimed; it rebuilds lazily on
560    /// the next render of this page. A viewer can call it on pages scrolled
561    /// off-screen (or use [`DjVuDocument::retain_render_caches`]) to bound memory.
562    ///
563    /// Takes `&self` since 0.33 (READ_CACHE_BOUNDED), so it can run from inside
564    /// a render. A render already holding a layer keeps it until it finishes.
565    #[cfg(feature = "std")]
566    pub fn evict_render_cache(&self) {
567        if let Some(layers) = self.render_layers.get() {
568            layers.evict_shared();
569        }
570    }
571
572    /// C5_COMPRESS: cheaper alternative to [`evict_render_cache`](Self::evict_render_cache)
573    /// — instead of dropping this page's entire render cache, drop only the
574    /// expensive full-resolution derivations (the BG44 coefficient image,
575    /// the full-res RGB pixmap, mask, foreground, and composited tiles) while
576    /// keeping any already-cached downscaled RGB pixmap (`bg_rgb_s2` /
577    /// `bg_rgb_s4`, populated by a prior 150-DPI-class or thumbnail render).
578    ///
579    /// A later downscaled render of this page (subsample ≥ 2) then stays
580    /// warm instead of re-paying the full BG44 ZP decode; a full-resolution
581    /// render still cold-decodes, same as after a full
582    /// [`evict_render_cache`](Self::evict_render_cache) — see
583    /// PERF_EXPERIMENTS.md C5_COMPRESS for why a cheap sub=2→sub=1 "upgrade"
584    /// is not possible. No-op if the page was never rendered.
585    ///
586    /// Takes `&self` since 0.33, for the same reason as
587    /// [`evict_render_cache`](Self::evict_render_cache).
588    #[cfg(feature = "std")]
589    pub fn downgrade_render_cache(&self) {
590        if let Some(layers) = self.render_layers.get() {
591            layers.downgrade();
592        }
593    }
594
595    /// Return the raw bytes of the first chunk with the given 4-byte ID.
596    ///
597    /// Returns `None` if no chunk with that ID exists.  The returned slice
598    /// points into the owned chunk storage — zero copy.
599    ///
600    /// # Example
601    ///
602    /// ```ignore
603    /// let sjbz = page.raw_chunk(b"Sjbz").expect("page must have a JB2 chunk");
604    /// ```
605    /// This page's raw chunk list, materialising lazily-stored chunks on first
606    /// access (`std`) or returning the eagerly-copied list (`no_std`).
607    #[cfg(feature = "std")]
608    fn chunk_slice(&self) -> &[RawChunk] {
609        self.chunks.get()
610    }
611    #[cfg(not(feature = "std"))]
612    fn chunk_slice(&self) -> &[RawChunk] {
613        &self.chunks
614    }
615
616    pub fn raw_chunk(&self, id: &[u8; 4]) -> Option<&[u8]> {
617        self.chunk_slice()
618            .iter()
619            .find(|c| &c.id == id)
620            .map(|c| c.data.as_slice())
621    }
622
623    /// Return the raw bytes of all chunks with the given 4-byte ID, in order.
624    ///
625    /// Returns an empty `Vec` if no such chunk exists.
626    ///
627    /// # Example
628    ///
629    /// ```ignore
630    /// let bg44_chunks = page.all_chunks(b"BG44");
631    /// assert!(!bg44_chunks.is_empty(), "colour page must have BG44 data");
632    /// ```
633    pub fn all_chunks(&self, id: &[u8; 4]) -> Vec<&[u8]> {
634        self.chunk_slice()
635            .iter()
636            .filter(|c| &c.id == id)
637            .map(|c| c.data.as_slice())
638            .collect()
639    }
640
641    /// Return the IDs of all chunks present on this page, in order.
642    ///
643    /// Duplicate IDs appear multiple times (once per chunk).
644    pub fn chunk_ids(&self) -> Vec<[u8; 4]> {
645        self.chunk_slice().iter().map(|c| c.id).collect()
646    }
647
648    /// Deprecated alias for [`Self::raw_chunk`]; kept for internal callers.
649    #[doc(hidden)]
650    pub fn find_chunk(&self, id: &[u8; 4]) -> Option<&[u8]> {
651        self.raw_chunk(id)
652    }
653
654    /// Deprecated alias for [`Self::all_chunks`]; kept for internal callers.
655    #[doc(hidden)]
656    pub fn find_chunks(&self, id: &[u8; 4]) -> Vec<&[u8]> {
657        self.all_chunks(id)
658    }
659
660    /// Decode the payload of a paired `*z` (BZZ-compressed) / `*a` (raw) chunk,
661    /// e.g. `chunk_payload(b"TXTz", b"TXTa")` for the text layer.
662    ///
663    /// This is the single seam that owns the BZZ-or-raw decision for every
664    /// paired chunk on a page; the per-format parsers receive the returned
665    /// already-decoded bytes.  Returns `Ok(None)` when neither chunk is present
666    /// (or the present chunk is empty), `Err` only if BZZ decompression fails.
667    pub fn chunk_payload(
668        &self,
669        id_z: &[u8; 4],
670        id_a: &[u8; 4],
671    ) -> Result<Option<Vec<u8>>, DocError> {
672        Ok(decode_paired_payload(
673            self.raw_chunk(id_z),
674            self.raw_chunk(id_a),
675        )?)
676    }
677
678    /// Return all BG44 background chunk data slices, in order.
679    ///
680    /// Legacy standalone `FORM:BM44` / `FORM:PM44` documents store the same
681    /// IW44 bitstream under `BM44` / `PM44` chunk ids; those are returned here
682    /// so the existing render pipeline can decode them without a separate path.
683    pub fn bg44_chunks(&self) -> Vec<&[u8]> {
684        let bg44 = self.find_chunks(b"BG44");
685        if !bg44.is_empty() {
686            return bg44;
687        }
688        let bm44 = self.find_chunks(b"BM44");
689        if !bm44.is_empty() {
690            return bm44;
691        }
692        self.find_chunks(b"PM44")
693    }
694
695    /// The render-tier layer cache for this page (decoded on first render).
696    ///
697    /// The page holds the handle; the decode logic, the cached forms, and the
698    /// compositor-subsampling concern all live in
699    /// [`crate::djvu_render::PageLayers`].
700    #[cfg(feature = "std")]
701    pub(crate) fn render_layers(&self) -> &crate::djvu_render::PageLayers {
702        let layers = self.render_layers.get_or_init(|| {
703            // Held behind an `Arc` so `crate::render_cache` can keep a weak
704            // reference and sweep this cache when the process goes over its
705            // ceiling. Two threads racing here both build one; the loser is
706            // dropped and its weak entry is pruned by the next sweep.
707            let layers = Arc::new(crate::djvu_render::PageLayers::new());
708            crate::render_cache::register(&layers);
709            layers
710        });
711        // Stamp the page's LRU access tick so `enforce_cache_budget` can evict
712        // the least-recently-rendered pages first. The process-wide governor
713        // ranks individual layers by their own ticks instead (#813).
714        layers.bump_access();
715        layers
716    }
717
718    /// Approximate resident bytes held by this page's render cache (0 if never
719    /// rendered). See [`evict_render_cache`](Self::evict_render_cache).
720    #[cfg(feature = "std")]
721    pub fn render_cache_bytes(&self) -> usize {
722        self.render_layers.get().map_or(0, |l| l.cached_bytes())
723    }
724
725    /// The page's LRU access tick (higher = more recently rendered), read
726    /// without touching the cache. Used by
727    /// [`DjVuDocument::enforce_cache_budget`].
728    #[cfg(feature = "std")]
729    pub(crate) fn render_cache_access_tick(&self) -> u64 {
730        self.render_layers.get().map_or(0, |l| l.access_tick())
731    }
732
733    /// Return the fully decoded BG44 wavelet image, decoding and caching on first call.
734    ///
735    /// Returns `None` if the page has no BG44 chunks or if strict decoding fails.
736    /// This method is infallible; callers that need tolerant recovery should use
737    /// a permissive render path instead.
738    ///
739    /// The result is computed once (all ZP arithmetic decode + block assembly) and
740    /// then cached in the page's render-tier layer cache.  Subsequent
741    /// calls return the cached value immediately.  The wavelet inverse-transform
742    /// and YCbCr→RGB conversion are also cached for subsample=1 (the common
743    /// full-resolution case) via [`decoded_bg_rgb_s1`](Self::decoded_bg_rgb_s1);
744    /// other subsample levels recompute the conversion each call.
745    #[cfg(feature = "std")]
746    pub fn decoded_bg44(&self) -> Option<Arc<Iw44Image>> {
747        self.render_layers().bg44(self)
748    }
749
750    #[cfg(not(feature = "std"))]
751    pub fn decoded_bg44(&self) -> Option<Arc<Iw44Image>> {
752        None
753    }
754
755    /// Return a partially-decoded BG44 background image, decoding and caching
756    /// on first call.  Only the first BG44 chunk is decoded — subsequent
757    /// refinement chunks are skipped.  This gives roughly 4× lower ZP decode
758    /// cost at the expense of coarser quantization, which is imperceptible at
759    /// sub=4 (quarter-resolution) or sub=8 output.
760    ///
761    /// Use this instead of [`Self::decoded_bg44`] when `subsample >= 4`.
762    #[cfg(feature = "std")]
763    pub fn decoded_bg44_partial(&self) -> Option<Arc<Iw44Image>> {
764        self.render_layers().bg44_partial(self)
765    }
766
767    #[cfg(not(feature = "std"))]
768    pub fn decoded_bg44_partial(&self) -> Option<Arc<Iw44Image>> {
769        None
770    }
771
772    /// This page's cached RGB conversion for a `subsample > 4` render, when
773    /// the slot holds exactly that subsample. Never decodes.
774    #[cfg(feature = "std")]
775    pub(crate) fn cached_bg_rgb_subhi(&self, subsample: u32) -> Option<Arc<Pixmap>> {
776        self.render_layers.get()?.bg_rgb_subhi(subsample)
777    }
778
779    /// Memoise the RGB conversion for a `subsample > 4` render. The first
780    /// subsample a page is rendered at wins; later ones reconvert.
781    #[cfg(feature = "std")]
782    pub(crate) fn store_bg_rgb_subhi(&self, subsample: u32, px: Arc<Pixmap>) {
783        self.render_layers().store_bg_rgb_subhi(subsample, px);
784    }
785
786    /// This page's first-chunk BG44 image **only if it is already cached** —
787    /// never decodes. See `PageLayers::bg44_partial_cached` for why the
788    /// subsample > 4 render path peeks instead of memoising.
789    #[cfg(feature = "std")]
790    pub(crate) fn cached_bg44_partial(&self) -> Option<Arc<Iw44Image>> {
791        self.render_layers.get()?.bg44_partial_cached()
792    }
793
794    /// Return the decoded JB2 shared dictionary, decoding and caching on first call.
795    ///
796    /// Returns `None` if the page has no shared dictionary (no INCL reference).
797    /// The result is computed once and then cached so that repeated renders
798    /// do not re-decode the dictionary each time.
799    #[cfg(feature = "std")]
800    pub(crate) fn decoded_shared_dict(&self) -> Option<&Jb2Dict> {
801        // The decode is memoized inside the shared `Arc<SharedDict>`, so all
802        // pages that INCL the same DJVI component share one decode per document.
803        self.shared_djbz.as_ref()?.get()
804    }
805
806    #[cfg(not(feature = "std"))]
807    pub(crate) fn decoded_shared_dict(&self) -> Option<&Jb2Dict> {
808        None
809    }
810
811    /// Return all FG44 foreground chunk data slices, in order.
812    pub fn fg44_chunks(&self) -> Vec<&[u8]> {
813        self.find_chunks(b"FG44")
814    }
815
816    /// Extract the text layer from TXTz (BZZ-compressed) or TXTa (plain) chunks.
817    ///
818    /// Returns `Ok(None)` if the page has no text layer.
819    pub fn text_layer(&self) -> Result<Option<TextLayer>, DocError> {
820        Ok(self.text_layer_shared()?.map(|arc| (*arc).clone()))
821    }
822
823    /// Shared-handle variant of [`text_layer`](Self::text_layer): the decoded
824    /// layer is cached per page (#605), so warm accesses skip the BZZ decode
825    /// and zone-tree rebuild and only bump an `Arc`. Prefer this in loops
826    /// (search, selection overlays); `text_layer` clones out of the same
827    /// cache for callers that need owned data.
828    #[cfg(feature = "std")]
829    pub fn text_layer_shared(&self) -> Result<Option<std::sync::Arc<TextLayer>>, DocError> {
830        self.render_layers().text_layer_cached(|| {
831            let page_height = self.info.height as u32;
832            match self.chunk_payload(b"TXTz", b"TXTa")? {
833                Some(bytes) => Ok(Some(std::sync::Arc::new(crate::text::parse_text_layer(
834                    &bytes,
835                    page_height,
836                )?))),
837                None => Ok(None),
838            }
839        })
840    }
841
842    #[cfg(not(feature = "std"))]
843    pub fn text_layer_shared(&self) -> Result<Option<alloc::sync::Arc<TextLayer>>, DocError> {
844        let page_height = self.info.height as u32;
845        match self.chunk_payload(b"TXTz", b"TXTa")? {
846            Some(bytes) => Ok(Some(alloc::sync::Arc::new(crate::text::parse_text_layer(
847                &bytes,
848                page_height,
849            )?))),
850            None => Ok(None),
851        }
852    }
853
854    /// Parse the text layer and transform all zone rectangles to match a
855    /// rendered page of size `render_w × render_h`.
856    ///
857    /// This is a convenience wrapper around [`Self::text_layer`] followed by
858    /// [`TextLayer::transform`].  It applies the page's own rotation (from the
859    /// INFO chunk) and scales coordinates proportionally to the requested
860    /// render size, so callers can use the returned rects directly for text
861    /// selection / copy-paste overlays without any additional maths.
862    ///
863    /// Returns `Ok(None)` if the page has no text layer.
864    pub fn text_layer_at_size(
865        &self,
866        render_w: u32,
867        render_h: u32,
868    ) -> Result<Option<TextLayer>, DocError> {
869        let page_w = self.info.width as u32;
870        let page_h = self.info.height as u32;
871        let rotation = self.info.rotation;
872        Ok(self
873            .text_layer()?
874            .map(|tl| tl.transform(page_w, page_h, rotation, render_w, render_h)))
875    }
876
877    /// Extract the plain text content of the page (convenience wrapper).
878    ///
879    /// Returns `Ok(None)` if the page has no text layer.
880    pub fn text(&self) -> Result<Option<String>, DocError> {
881        Ok(self.text_layer()?.map(|tl| tl.text))
882    }
883
884    /// Parse the annotation layer from ANTz (BZZ-compressed) or ANTa (plain) chunks.
885    ///
886    /// Returns `Ok(None)` if the page has no annotation chunk.
887    pub fn annotations(&self) -> Result<Option<(Annotation, Vec<MapArea>)>, DocError> {
888        Ok(self.annotations_shared()?.map(|arc| (*arc).clone()))
889    }
890
891    /// Shared-handle variant of [`annotations`](Self::annotations), cached per
892    /// page (#605) — warm accesses skip the BZZ decode and parse.
893    #[cfg(feature = "std")]
894    pub fn annotations_shared(
895        &self,
896    ) -> Result<Option<crate::djvu_render::SharedAnnotations>, DocError> {
897        self.render_layers()
898            .annotations_cached(|| match self.chunk_payload(b"ANTz", b"ANTa")? {
899                Some(bytes) => Ok(Some(std::sync::Arc::new(
900                    crate::annotation::parse_annotations(&bytes)?,
901                ))),
902                None => Ok(None),
903            })
904    }
905
906    #[cfg(not(feature = "std"))]
907    pub fn annotations_shared(
908        &self,
909    ) -> Result<Option<alloc::sync::Arc<(Annotation, Vec<MapArea>)>>, DocError> {
910        match self.chunk_payload(b"ANTz", b"ANTa")? {
911            Some(bytes) => Ok(Some(alloc::sync::Arc::new(
912                crate::annotation::parse_annotations(&bytes)?,
913            ))),
914            None => Ok(None),
915        }
916    }
917
918    /// Return all hyperlinks (MapAreas with a non-empty URL) on this page.
919    pub fn hyperlinks(&self) -> Result<Vec<MapArea>, DocError> {
920        match self.annotations()? {
921            None => Ok(Vec::new()),
922            Some((_, mapareas)) => Ok(mapareas.into_iter().filter(|m| !m.url.is_empty()).collect()),
923        }
924    }
925
926    /// Decode the JB2 foreground mask as a 1-bit [`Bitmap`](crate::bitmap::Bitmap).
927    ///
928    /// Returns `Ok(None)` if the page has no Sjbz (JB2 mask) chunk.
929    /// Decode the foreground mask layer.
930    ///
931    /// Handles both JB2 (`Sjbz`) and G4/MMR (`Smmr`) encoded masks.
932    /// Returns `Ok(None)` if the page has neither chunk.
933    ///
934    /// **Performance note:** this method decodes fresh on every call. Prefer
935    /// [`decoded_mask`](Self::decoded_mask) in hot paths — it caches the result
936    /// after the first call. `extract_mask` remains useful when you need a
937    /// uniquely owned `Bitmap` or call it only once.
938    pub fn extract_mask(&self) -> Result<Option<crate::bitmap::Bitmap>, DocError> {
939        if let Some(sjbz) = self.find_chunk(b"Sjbz") {
940            // Prefer an inline Djbz chunk (decoded fresh — rare, usually small).
941            // Otherwise use the cached shared dictionary to avoid repeated multi-MB
942            // allocations on every render.
943            let inline_dict;
944            let dict_ref = if let Some(djbz) = self.find_chunk(b"Djbz") {
945                inline_dict = crate::jb2::decode_dict(djbz, None)?;
946                Some(&inline_dict)
947            } else {
948                self.decoded_shared_dict()
949            };
950            let bm = crate::jb2::decode(sjbz, dict_ref)?;
951            return Ok(Some(bm));
952        }
953        if let Some(smmr) = self.find_chunk(b"Smmr") {
954            let bm = crate::smmr::decode_smmr(smmr).map_err(|e| DocError::Smmr(e.to_string()))?;
955            return Ok(Some(bm));
956        }
957        Ok(None)
958    }
959
960    /// Decode the foreground mask with per-pixel blit index tracking.
961    ///
962    /// Falls back to a plain `Smmr` mask (without blit indices) when only an
963    /// `Smmr` chunk is present; in that case all blit indices are set to `0`.
964    /// Returns `Ok(None)` if the page has neither chunk.
965    pub fn extract_mask_indexed(
966        &self,
967    ) -> Result<Option<(crate::bitmap::Bitmap, Vec<i32>)>, DocError> {
968        if let Some(sjbz) = self.find_chunk(b"Sjbz") {
969            let inline_dict;
970            let dict_ref = if let Some(djbz) = self.find_chunk(b"Djbz") {
971                inline_dict = crate::jb2::decode_dict(djbz, None)?;
972                Some(&inline_dict)
973            } else {
974                self.decoded_shared_dict()
975            };
976            let (bm, blit_map) = crate::jb2::decode_indexed(sjbz, dict_ref)?;
977            return Ok(Some((bm, blit_map)));
978        }
979        if let Some(smmr) = self.find_chunk(b"Smmr") {
980            let bm = crate::smmr::decode_smmr(smmr).map_err(|e| DocError::Smmr(e.to_string()))?;
981            let len = (bm.width * bm.height) as usize;
982            return Ok(Some((bm, vec![0i32; len])));
983        }
984        Ok(None)
985    }
986
987    /// Decode the foreground mask directly at 1/4 resolution (2 bits shifted
988    /// off each axis), OR-reducing (max-pooling) instead of allocating a
989    /// full-resolution [`Bitmap`] and downsampling it afterward.
990    ///
991    /// Bit-for-bit identical to `downsample_mask_4x(extract_mask()?)` (see the
992    /// `djvu-jb2` crate's `decode_downsampled` equivalence tests and
993    /// `mask_sub4_matches_extract_mask_then_downsample` below) — it exists to
994    /// skip the full-resolution JB2 canvas allocation for callers that only
995    /// ever need the coarse mask (the thumbnail / heavy-downscale compositor
996    /// path, [`crate::djvu_render::PageLayers::mask_sub4`]).
997    ///
998    /// Returns `Ok(None)` if the page has neither an Sjbz nor an Smmr chunk.
999    ///
1000    /// `std`-only: its only caller, [`crate::djvu_render::PageLayers`], is
1001    /// itself `std`-only (it caches decoded layers behind `std::sync::OnceLock`),
1002    /// and the Smmr fallback below reuses the `std`-only
1003    /// [`crate::djvu_render::downsample_mask_4x`].
1004    #[cfg(feature = "std")]
1005    pub(crate) fn extract_mask_sub4(&self) -> Result<Option<crate::bitmap::Bitmap>, DocError> {
1006        if let Some(sjbz) = self.find_chunk(b"Sjbz") {
1007            let inline_dict;
1008            let dict_ref = if let Some(djbz) = self.find_chunk(b"Djbz") {
1009                inline_dict = crate::jb2::decode_dict(djbz, None)?;
1010                Some(&inline_dict)
1011            } else {
1012                self.decoded_shared_dict()
1013            };
1014            let bm = crate::jb2::decode_downsampled(sjbz, dict_ref, 2)?;
1015            return Ok(Some(bm));
1016        }
1017        if let Some(smmr) = self.find_chunk(b"Smmr") {
1018            // No reduced-scale G4/MMR decoder exists; fall back to a full
1019            // decode + the same max-pool reduction `mask_sub4` would apply.
1020            // Smmr masks are rare in practice (Sjbz is the common case).
1021            let bm = crate::smmr::decode_smmr(smmr).map_err(|e| DocError::Smmr(e.to_string()))?;
1022            return Ok(Some(crate::djvu_render::downsample_mask_4x(&bm)));
1023        }
1024        Ok(None)
1025    }
1026
1027    /// Decode the IW44 foreground layer (FG44 chunks) if present.
1028    ///
1029    /// Returns `Ok(None)` if the page has no FG44 chunks.
1030    ///
1031    /// **Performance note:** this method allocates a fresh `Pixmap` on every call.
1032    /// Prefer [`decoded_fg44`](Self::decoded_fg44) in hot paths — it returns a
1033    /// cached reference after the first call.
1034    pub fn extract_foreground(&self) -> Result<Option<Pixmap>, DocError> {
1035        let chunks = self.fg44_chunks();
1036        if chunks.is_empty() {
1037            return Ok(None);
1038        }
1039
1040        let mut img = Iw44Image::new();
1041        for chunk_data in &chunks {
1042            img.decode_chunk(chunk_data)?;
1043        }
1044        let pixmap = img.to_rgb()?;
1045        Ok(Some(pixmap))
1046    }
1047
1048    /// Return the decoded JB2 mask (Sjbz), decoding and caching on first call.
1049    ///
1050    /// Unlike [`Self::extract_mask`] this method caches the result (in the
1051    /// page's [`crate::djvu_render::PageLayers`]) so that repeated renders of
1052    /// the same page — e.g. at different DPI levels — do not re-run the ZP
1053    /// arithmetic + symbol decode.
1054    ///
1055    /// Returns `None` if the page has no Sjbz chunk or if decoding fails.
1056    #[cfg(feature = "std")]
1057    pub fn decoded_mask(&self) -> Option<Arc<crate::bitmap::Bitmap>> {
1058        self.render_layers().mask(self)
1059    }
1060
1061    #[cfg(not(feature = "std"))]
1062    pub fn decoded_mask(&self) -> Option<Arc<crate::bitmap::Bitmap>> {
1063        None
1064    }
1065
1066    /// Return the decoded FG44 foreground color layer, decoding and caching on
1067    /// first call.  Subsequent renders reuse the cached `Pixmap`.
1068    ///
1069    /// Returns `None` if the page has no FG44 chunks or if decoding fails.
1070    #[cfg(feature = "std")]
1071    pub fn decoded_fg44(&self) -> Option<Arc<Pixmap>> {
1072        self.render_layers().fg44(self)
1073    }
1074
1075    #[cfg(not(feature = "std"))]
1076    pub fn decoded_fg44(&self) -> Option<Arc<Pixmap>> {
1077        None
1078    }
1079
1080    /// Return the full-resolution (subsample=1) RGB `Pixmap` derived from the
1081    /// BG44 wavelet background, decoding and caching on first call.
1082    ///
1083    /// This caches both the ZP arithmetic decode (via [`decoded_bg44`](Self::decoded_bg44))
1084    /// and the IW44 inverse-transform + YCbCr→RGB conversion, so repeated
1085    /// renders at native resolution pay neither cost after the first call.
1086    ///
1087    /// Returns `None` if the page has no BG44 layer or if decoding fails.
1088    #[cfg(feature = "std")]
1089    pub(crate) fn decoded_bg_rgb_s1(&self) -> Option<Arc<Pixmap>> {
1090        self.render_layers().bg_rgb_s1(self)
1091    }
1092
1093    #[cfg(not(feature = "std"))]
1094    pub(crate) fn decoded_bg_rgb_s1(&self) -> Option<Arc<Pixmap>> {
1095        None
1096    }
1097
1098    /// Return the half-resolution (subsample=2) RGB `Pixmap` derived from the
1099    /// BG44 wavelet background, decoding and caching on first call.
1100    ///
1101    /// Mirrors [`decoded_bg_rgb_s1`](Self::decoded_bg_rgb_s1) for the common
1102    /// 150-from-300-DPI render: caches both the ZP arithmetic decode and the
1103    /// IW44 inverse-transform + YCbCr→RGB conversion at subsample 2.
1104    ///
1105    /// Returns `None` if the page has no BG44 layer or if decoding fails.
1106    #[cfg(feature = "std")]
1107    pub(crate) fn decoded_bg_rgb_s2(&self) -> Option<Arc<Pixmap>> {
1108        self.render_layers().bg_rgb_s2(self)
1109    }
1110
1111    #[cfg(not(feature = "std"))]
1112    pub(crate) fn decoded_bg_rgb_s2(&self) -> Option<Arc<Pixmap>> {
1113        None
1114    }
1115
1116    /// Return the quarter-resolution (subsample=4) RGB `Pixmap` derived from the
1117    /// partial BG44 wavelet background, decoding and caching on first call.
1118    ///
1119    /// Mirrors [`decoded_bg_rgb_s2`](Self::decoded_bg_rgb_s2) for the common
1120    /// heavy-downscale / thumbnail render (e.g. 150-from-400-DPI): caches both
1121    /// the ZP arithmetic decode (first chunk only) and the IW44 inverse-transform
1122    /// + YCbCr→RGB conversion at subsample 4.
1123    ///
1124    /// Returns `None` if the page has no BG44 layer or if decoding fails.
1125    #[cfg(feature = "std")]
1126    pub(crate) fn decoded_bg_rgb_s4(&self) -> Option<Arc<Pixmap>> {
1127        self.render_layers().bg_rgb_s4(self)
1128    }
1129
1130    #[cfg(not(feature = "std"))]
1131    pub(crate) fn decoded_bg_rgb_s4(&self) -> Option<Arc<Pixmap>> {
1132        None
1133    }
1134
1135    /// Return the decoded JB2 mask + per-pixel blit-index map for FGbz-palette
1136    /// pages, decoding and caching on first call.
1137    ///
1138    /// Caches both the JB2 ZP arithmetic decode and the page-sized blit map so
1139    /// that repeated palette renders pay neither cost after the first call.
1140    /// Returns `None` if the page has no Sjbz/Smmr chunk or decoding fails.
1141    #[cfg(feature = "std")]
1142    pub(crate) fn decoded_mask_indexed(&self) -> Option<Arc<crate::djvu_render::IndexedMask>> {
1143        self.render_layers().mask_indexed(self)
1144    }
1145
1146    #[cfg(not(feature = "std"))]
1147    pub(crate) fn decoded_mask_indexed(&self) -> Option<Arc<crate::djvu_render::IndexedMask>> {
1148        None
1149    }
1150
1151    /// Decode the IW44 background layer (BG44 chunks) if present.
1152    ///
1153    /// Returns `Ok(None)` if the page has no BG44 chunks.
1154    ///
1155    /// **Performance note:** this method allocates a fresh `Pixmap` on every call.
1156    /// Prefer [`decoded_bg44`](Self::decoded_bg44) in hot paths — it returns a
1157    /// cached reference after the first call.
1158    pub fn extract_background(&self) -> Result<Option<Pixmap>, DocError> {
1159        let chunks = self.bg44_chunks();
1160        if chunks.is_empty() {
1161            return Ok(None);
1162        }
1163
1164        let mut img = Iw44Image::new();
1165        for chunk_data in &chunks {
1166            img.decode_chunk(chunk_data)?;
1167        }
1168        let pixmap = img.to_rgb()?;
1169        Ok(Some(pixmap))
1170    }
1171
1172    /// Render this page into a pre-allocated RGBA buffer using the given options.
1173    ///
1174    /// This is the zero-allocation render path: no heap allocation occurs when
1175    /// `buf` is already sized to `opts.width * opts.height * 4` bytes.
1176    ///
1177    /// # Errors
1178    ///
1179    /// - [`crate::djvu_render::RenderError::BufTooSmall`] if buffer is too small
1180    /// - [`crate::djvu_render::RenderError::InvalidDimensions`] if width/height is 0
1181    /// - Propagates IW44 / JB2 decode errors
1182    pub fn render_into(
1183        &self,
1184        opts: &crate::djvu_render::RenderOptions,
1185        buf: &mut [u8],
1186    ) -> Result<(), crate::djvu_render::RenderError> {
1187        crate::djvu_render::render_into(self, opts, buf)
1188    }
1189}
1190
1191// ---- Document ---------------------------------------------------------------
1192
1193/// Options for [`DjVuDocument::enforce_cache_budget_with`].
1194///
1195/// Default (`downgrade_before_drop: false`) is byte-identical to
1196/// [`DjVuDocument::enforce_cache_budget`]'s all-or-nothing eviction.
1197#[cfg(feature = "std")]
1198#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1199pub struct CacheBudgetOptions {
1200    /// C5_COMPRESS's cheaper middle tier: when true, a least-recently-used
1201    /// page over budget is first [`DjVuPage::downgrade_render_cache`]d —
1202    /// keeping its already-cached downscaled RGB pixmap
1203    /// (`bg_rgb_s2`/`bg_rgb_s4`) alive while dropping the expensive
1204    /// full-resolution derivations — rather than fully dropped. If the
1205    /// document is still over budget after downgrading every eligible page
1206    /// (or a page has nothing left to downgrade), the sweep falls back to a
1207    /// full drop, LRU-first, exactly like `enforce_cache_budget`. So the byte
1208    /// ceiling is honoured identically either way; only the *shape* of what
1209    /// stays cached changes.
1210    pub downgrade_before_drop: bool,
1211}
1212
1213/// An opened DjVu document.
1214///
1215/// Supports single-page FORM:DJVU, bundled multi-page FORM:DJVM, and indirect
1216/// multi-page FORM:DJVM (via resolver callback).
1217#[derive(Debug)]
1218pub struct DjVuDocument {
1219    /// All pages, indexed by 0-based page number.
1220    pages: Vec<DjVuPage>,
1221    /// Parsed NAVM bookmarks, or empty if none.
1222    bookmarks: Vec<DjVuBookmark>,
1223    /// Raw document-level chunks (NAVM, DIRM, etc.) from the DJVM container,
1224    /// or from the top-level DJVU form for single-page documents.
1225    global_chunks: Vec<RawChunk>,
1226    /// Byte ranges of each page's outer FORM chunk inside the original
1227    /// document buffer, in page order. Populated only for bundled DJVM
1228    /// documents parsed from a contiguous slice; empty otherwise (single-page
1229    /// DJVU, indirect DJVM, or when offsets were unavailable).
1230    ///
1231    /// Used by [`DjVuDocument::page_byte_range`] (#196 Phase 2). Lets a
1232    /// future HTTP-Range fetcher (#196 Phase 3) request exactly the bytes
1233    /// for a given page.
1234    page_byte_ranges: Vec<core::ops::Range<u64>>,
1235    /// Configurable resource limits supplied at parse/open time.
1236    resource_limits: Option<crate::resource_limits::ResourceLimits>,
1237}
1238
1239#[cfg(feature = "std")]
1240fn attach_resource_limits(
1241    mut document: DjVuDocument,
1242    limits: Option<crate::resource_limits::ResourceLimits>,
1243) -> DjVuDocument {
1244    document.resource_limits = limits;
1245    if let Some(limits) = limits {
1246        for page in &mut document.pages {
1247            page.resource_limits = Some(limits);
1248        }
1249    }
1250    document
1251}
1252
1253#[cfg(feature = "std")]
1254fn check_parse_limits(
1255    data: &[u8],
1256    limits: Option<crate::resource_limits::ResourceLimits>,
1257) -> Result<(), DocError> {
1258    if let Some(limits) = limits.filter(|limits| !limits.is_empty()) {
1259        let _ = crate::validate::check_document_limits(data, &limits, "document.parse")?;
1260    }
1261    Ok(())
1262}
1263
1264impl DjVuDocument {
1265    /// Parse a DjVu document from a byte slice.
1266    ///
1267    /// For indirect documents (INCL references to external files), a resolver
1268    /// must be supplied via [`DjVuDocument::parse_with_resolver`].
1269    ///
1270    /// # Errors
1271    ///
1272    /// Returns `DocError::NoResolver` if the document is indirect and no resolver
1273    /// was provided.
1274    pub fn parse(data: &[u8]) -> Result<Self, DocError> {
1275        #[cfg(feature = "std")]
1276        {
1277            Self::parse_with_options(data, &crate::resource_limits::ParseOptions::default())
1278        }
1279        #[cfg(not(feature = "std"))]
1280        {
1281            Self::parse_with_resolver(data, None::<fn(&str) -> Result<Vec<u8>, DocError>>)
1282        }
1283    }
1284
1285    /// Parse a DjVu document with configurable resource limits.
1286    ///
1287    /// When [`ParseOptions::limits`] is set, header-only estimates are checked
1288    /// before the document is fully parsed. The same limits are stored on the
1289    /// returned document and inherited by subsequent render calls unless
1290    /// overridden via [`render_pixmap_with_limits`](crate::djvu_render::render_pixmap_with_limits).
1291    #[cfg(feature = "std")]
1292    pub fn parse_with_options(
1293        data: &[u8],
1294        opts: &crate::resource_limits::ParseOptions,
1295    ) -> Result<Self, DocError> {
1296        Self::parse_with_resolver_and_options(
1297            data,
1298            None::<fn(&str) -> Result<Vec<u8>, DocError>>,
1299            opts,
1300        )
1301    }
1302
1303    /// Parse with an optional resolver and configurable resource limits.
1304    #[cfg(feature = "std")]
1305    pub fn parse_with_resolver_and_options<R>(
1306        data: &[u8],
1307        resolver: Option<R>,
1308        opts: &crate::resource_limits::ParseOptions,
1309    ) -> Result<Self, DocError>
1310    where
1311        R: Fn(&str) -> Result<Vec<u8>, DocError>,
1312    {
1313        check_parse_limits(data, opts.limits)?;
1314        let document = Self::parse_with_resolver(data, resolver)?;
1315        Ok(attach_resource_limits(document, opts.limits))
1316    }
1317
1318    /// Configurable resource limits supplied at parse/open time, if any.
1319    pub fn resource_limits(&self) -> Option<crate::resource_limits::ResourceLimits> {
1320        self.resource_limits
1321    }
1322
1323    /// Parse from an owned, shared backing store (an owned `Vec<u8>` or an
1324    /// `Mmap`), constructing **lazy** pages for bundled DJVM documents.
1325    ///
1326    /// For a bundled document only the cheap per-page `INFO` header is parsed up
1327    /// front; each page's chunk bytes are materialised from `backing` on first
1328    /// access instead of being copied at open time (LAZY_PAGE_CONSTRUCT). This
1329    /// makes "open a 500-page book, render page 1" O(1) in copies rather than
1330    /// O(total document bytes). For `mmap` backings the copy is avoided entirely
1331    /// until a page is touched.
1332    ///
1333    /// Single-page, non-DJVM, and indirect documents fall back to the eager
1334    /// [`parse`](Self::parse) path (they are small or need a resolver), so this
1335    /// is safe to call for any input. Keep the bundled loop below in sync with
1336    /// the eager one in [`parse_with_resolver`](Self::parse_with_resolver).
1337    #[cfg(feature = "std")]
1338    pub(crate) fn parse_backed_with_options(
1339        backing: Backing,
1340        opts: &crate::resource_limits::ParseOptions,
1341    ) -> Result<Self, DocError> {
1342        check_parse_limits(backing_bytes(&backing), opts.limits)?;
1343        let data = backing_bytes(&backing);
1344        let form = parse_form(data)?;
1345        if &form.form_type != b"DJVM" {
1346            return Self::parse_with_resolver_and_options(
1347                data,
1348                None::<fn(&str) -> Result<Vec<u8>, DocError>>,
1349                opts,
1350            );
1351        }
1352        let Some(dirm_chunk) = form.chunks.iter().find(|c| &c.id == b"DIRM") else {
1353            return Self::parse_with_resolver_and_options(
1354                data,
1355                None::<fn(&str) -> Result<Vec<u8>, DocError>>,
1356                opts,
1357            );
1358        };
1359        let payload = DirmPayload::decode(dirm_chunk.data).map_err(DocError::Malformed)?;
1360        if !payload.is_bundled() {
1361            // Indirect: needs a resolver — defer to the eager path (which errors
1362            // consistently with the previous behaviour).
1363            return Self::parse_with_resolver_and_options(
1364                data,
1365                None::<fn(&str) -> Result<Vec<u8>, DocError>>,
1366                opts,
1367            );
1368        }
1369
1370        let entries = payload.components();
1371        let comp_offsets = &payload.offsets;
1372        let bookmarks = parse_navm_bookmarks(&form.chunks)?;
1373        let global_chunks: Vec<RawChunk> = form
1374            .chunks
1375            .iter()
1376            .filter(|c| &c.id != b"FORM")
1377            .map(|c| RawChunk {
1378                id: c.id,
1379                data: c.data.to_vec(),
1380            })
1381            .collect();
1382
1383        let sub_forms: Vec<&IffChunk<'_>> =
1384            form.chunks.iter().filter(|c| &c.id == b"FORM").collect();
1385
1386        use std::collections::BTreeMap;
1387        let djvi_djbz: BTreeMap<String, Arc<SharedDict>> = entries
1388            .iter()
1389            .enumerate()
1390            .filter(|(_, e)| e.kind == DirmComponentKind::Shared)
1391            .filter_map(|(comp_idx, entry)| {
1392                let sf = sub_forms.get(comp_idx)?;
1393                let chunks = parse_sub_form(sf.data).ok()?;
1394                let djbz = chunks.iter().find(|c| &c.id == b"Djbz")?;
1395                Some((
1396                    entry.id.clone(),
1397                    Arc::new(SharedDict::new(djbz.data.to_vec())),
1398                ))
1399            })
1400            .collect();
1401
1402        let base = data.as_ptr() as usize;
1403        let mut pages = Vec::new();
1404        let mut page_byte_ranges = Vec::new();
1405        let mut page_idx = 0usize;
1406        for (comp_idx, entry) in entries.iter().enumerate() {
1407            if entry.kind != DirmComponentKind::Page {
1408                continue;
1409            }
1410            let sub_form = sub_forms.get(comp_idx).ok_or(DocError::Malformed(
1411                "DIRM entry count exceeds FORM children",
1412            ))?;
1413            let sub_chunks = parse_sub_form(sub_form.data)?;
1414            let shared_djbz = sub_chunks
1415                .iter()
1416                .find(|c| &c.id == b"INCL")
1417                .and_then(|incl| core::str::from_utf8(incl.data.trim_ascii_end()).ok())
1418                .and_then(|name| djvi_djbz.get(name))
1419                .cloned();
1420
1421            // The page's FORM sub-form is a slice of `data`, which is `backing`'s
1422            // bytes — so its offset within `backing` lets the lazy store re-slice
1423            // and parse it on demand.
1424            let off = sub_form.data.as_ptr() as usize - base;
1425            let range = off..off + sub_form.data.len();
1426            let page = parse_page_lazy(&sub_chunks, page_idx, shared_djbz, backing.clone(), range)?;
1427            pages.push(page);
1428
1429            if let Some(off2) = comp_offsets.get(comp_idx) {
1430                let start = *off2 as usize;
1431                if let Some(size_bytes) = data.get(start + 4..start + 8) {
1432                    let size_be = [size_bytes[0], size_bytes[1], size_bytes[2], size_bytes[3]];
1433                    page_byte_ranges.push(crate::dirm::form_byte_range(*off2, size_be));
1434                }
1435            }
1436            page_idx += 1;
1437        }
1438        if page_byte_ranges.len() != pages.len() {
1439            page_byte_ranges.clear();
1440        }
1441
1442        Ok(attach_resource_limits(
1443            DjVuDocument {
1444                pages,
1445                bookmarks,
1446                global_chunks,
1447                page_byte_ranges,
1448                resource_limits: None,
1449            },
1450            opts.limits,
1451        ))
1452    }
1453
1454    /// Parse a DjVu document using the typed sync component resolver contract.
1455    ///
1456    /// For an indirect `FORM:DJVM`, the resolver is called once for every DIRM
1457    /// entry in declaration order. That includes `Page`, `Shared`, and
1458    /// `Thumbnail` components; shared `Djbz` dictionaries referenced by page
1459    /// `INCL` chunks are attached to the resulting pages just as they are for
1460    /// bundled documents. Single-page and bundled documents do not call the
1461    /// resolver.
1462    ///
1463    /// The older [`Self::parse_with_resolver`] API remains available for
1464    /// callers whose resolver is keyed only by a string page name.
1465    pub fn parse_with_component_resolver<R>(data: &[u8], resolver: &R) -> Result<Self, DocError>
1466    where
1467        R: ComponentResolver + ?Sized,
1468    {
1469        let form = parse_form(data)?;
1470        if form.form_type != *b"DJVM" {
1471            // Preserve the existing single-page and non-DjVu behavior. The
1472            // resolver is intentionally unused for a standalone FORM:DJVU.
1473            return Self::parse(data);
1474        }
1475
1476        let dirm_chunk = form
1477            .chunks
1478            .iter()
1479            .find(|c| &c.id == b"DIRM")
1480            .ok_or(DocError::MissingChunk("DIRM"))?;
1481        let payload = DirmPayload::decode(dirm_chunk.data).map_err(DocError::Malformed)?;
1482        if payload.is_bundled() {
1483            // Bundled components are already in the index bytes and therefore
1484            // do not need an external resolver.
1485            return Self::parse(data);
1486        }
1487
1488        let entries = payload.components();
1489        let bookmarks = parse_navm_bookmarks(&form.chunks)?;
1490        let global_chunks: Vec<RawChunk> = form
1491            .chunks
1492            .iter()
1493            .filter(|c| &c.id != b"FORM")
1494            .map(|c| RawChunk {
1495                id: c.id,
1496                data: c.data.to_vec(),
1497            })
1498            .collect();
1499
1500        #[cfg(not(feature = "std"))]
1501        use alloc::collections::BTreeMap;
1502        #[cfg(feature = "std")]
1503        use std::collections::BTreeMap;
1504
1505        #[cfg(feature = "std")]
1506        let mut shared_djbz: BTreeMap<String, Arc<SharedDict>> = BTreeMap::new();
1507        #[cfg(not(feature = "std"))]
1508        let mut shared_djbz: BTreeMap<String, Vec<u8>> = BTreeMap::new();
1509        let mut page_components: Vec<(ComponentId, Vec<u8>)> = Vec::new();
1510
1511        for entry in &entries {
1512            let component = component_id_from_dirm(entry);
1513            let component_kind = component.kind;
1514            let resolved = resolver
1515                .resolve(&component)
1516                .map_err(DocError::ComponentResolve)?;
1517            let resolved_form = parse_form(&resolved)?;
1518            let expected = expected_component_form(component_kind);
1519            if resolved_form.form_type != expected {
1520                return Err(DocError::ComponentKindMismatch {
1521                    component,
1522                    found: resolved_form.form_type,
1523                    expected: component_kind,
1524                });
1525            }
1526
1527            match component_kind {
1528                ComponentKind::Page => page_components.push((component, resolved)),
1529                ComponentKind::Shared => {
1530                    // A DJVI may contain annotations or other shared data that
1531                    // this page model does not consume yet. Keep the resolver
1532                    // contract broad, but index the Djbz form when present.
1533                    if let Some(djbz) = resolved_form.chunks.iter().find(|c| &c.id == b"Djbz") {
1534                        #[cfg(feature = "std")]
1535                        shared_djbz.insert(
1536                            component.name,
1537                            Arc::new(SharedDict::new(djbz.data.to_vec())),
1538                        );
1539                        #[cfg(not(feature = "std"))]
1540                        shared_djbz.insert(component.name, djbz.data.to_vec());
1541                    }
1542                }
1543                ComponentKind::Thumbnail => {}
1544            }
1545        }
1546
1547        let mut pages = Vec::with_capacity(page_components.len());
1548        for (page_idx, (_component, resolved)) in page_components.iter().enumerate() {
1549            let page_form = parse_form(resolved)?;
1550            let shared_for_page = page_form
1551                .chunks
1552                .iter()
1553                .filter(|c| &c.id == b"INCL")
1554                .filter_map(|incl| core::str::from_utf8(incl.data.trim_ascii_end()).ok())
1555                .find_map(|name| shared_djbz.get(name))
1556                .cloned();
1557            pages.push(parse_page_from_chunks(
1558                &page_form.chunks,
1559                page_idx,
1560                shared_for_page,
1561            )?);
1562        }
1563
1564        Ok(DjVuDocument {
1565            pages,
1566            bookmarks,
1567            global_chunks,
1568            // Indirect component bytes live outside the index buffer.
1569            page_byte_ranges: Vec::new(),
1570            resource_limits: None,
1571        })
1572    }
1573
1574    /// Parse a DjVu document with an optional resolver for indirect pages.
1575    ///
1576    /// The resolver receives the `name` field from each INCL chunk and must
1577    /// return the raw bytes of that external component file.
1578    pub fn parse_with_resolver<R>(data: &[u8], resolver: Option<R>) -> Result<Self, DocError>
1579    where
1580        R: Fn(&str) -> Result<Vec<u8>, DocError>,
1581    {
1582        let form = parse_form(data)?;
1583
1584        match &form.form_type {
1585            b"DJVU" => {
1586                // Single-page document — expose all top-level chunks as global
1587                let global_chunks: Vec<RawChunk> = form
1588                    .chunks
1589                    .iter()
1590                    .map(|c| RawChunk {
1591                        id: c.id,
1592                        data: c.data.to_vec(),
1593                    })
1594                    .collect();
1595                let page = parse_page_from_chunks(&form.chunks, 0, None)?;
1596                // Single-page document spans the entire buffer.
1597                #[allow(clippy::single_range_in_vec_init)]
1598                let page_byte_ranges = vec![0u64..(data.len() as u64)];
1599                Ok(DjVuDocument {
1600                    pages: vec![page],
1601                    bookmarks: vec![],
1602                    global_chunks,
1603                    page_byte_ranges,
1604                    resource_limits: None,
1605                })
1606            }
1607            b"BM44" | b"PM44" => {
1608                let page = parse_legacy_iw44_page(&form.form_type, &form.chunks, 0)?;
1609                #[allow(clippy::single_range_in_vec_init)]
1610                let page_byte_ranges = vec![0u64..(data.len() as u64)];
1611                Ok(DjVuDocument {
1612                    pages: vec![page],
1613                    bookmarks: vec![],
1614                    global_chunks: Vec::new(),
1615                    page_byte_ranges,
1616                    resource_limits: None,
1617                })
1618            }
1619            b"DJVM" => {
1620                // Multi-page document — parse DIRM first
1621                let dirm_chunk = form
1622                    .chunks
1623                    .iter()
1624                    .find(|c| &c.id == b"DIRM")
1625                    .ok_or(DocError::MissingChunk("DIRM"))?;
1626
1627                let payload = DirmPayload::decode(dirm_chunk.data).map_err(DocError::Malformed)?;
1628                let entries = payload.components();
1629                let is_bundled = payload.is_bundled();
1630                let comp_offsets = payload.offsets;
1631
1632                // Collect NAVM bookmarks (BZZ-compressed)
1633                let bookmarks = parse_navm_bookmarks(&form.chunks)?;
1634
1635                // Store non-FORM global chunks (DIRM, NAVM, etc.)
1636                let global_chunks: Vec<RawChunk> = form
1637                    .chunks
1638                    .iter()
1639                    .filter(|c| &c.id != b"FORM")
1640                    .map(|c| RawChunk {
1641                        id: c.id,
1642                        data: c.data.to_vec(),
1643                    })
1644                    .collect();
1645
1646                if is_bundled {
1647                    // Bundled: FORM:DJVU / FORM:DJVI sub-forms follow DIRM in sequence.
1648                    let sub_forms: Vec<&IffChunk<'_>> =
1649                        form.chunks.iter().filter(|c| &c.id == b"FORM").collect();
1650
1651                    // Build a map of DJVI component ID → raw Djbz bytes for
1652                    // shared symbol dictionaries (referenced via INCL chunks).
1653                    // Use BTreeMap so this compiles in no_std (alloc::collections::BTreeMap
1654                    // is available; std::collections::HashMap is not).
1655                    #[cfg(not(feature = "std"))]
1656                    use alloc::collections::BTreeMap;
1657                    #[cfg(feature = "std")]
1658                    use std::collections::BTreeMap;
1659                    // Wrap shared dict bytes in Arc (std) so all pages that
1660                    // reference the same DJVI component share one allocation.
1661                    #[cfg(feature = "std")]
1662                    let djvi_djbz: BTreeMap<String, Arc<SharedDict>> = entries
1663                        .iter()
1664                        .enumerate()
1665                        .filter(|(_, e)| e.kind == DirmComponentKind::Shared)
1666                        .filter_map(|(comp_idx, entry)| {
1667                            let sf = sub_forms.get(comp_idx)?;
1668                            let chunks = parse_sub_form(sf.data).ok()?;
1669                            let djbz = chunks.iter().find(|c| &c.id == b"Djbz")?;
1670                            Some((
1671                                entry.id.clone(),
1672                                Arc::new(SharedDict::new(djbz.data.to_vec())),
1673                            ))
1674                        })
1675                        .collect();
1676                    #[cfg(not(feature = "std"))]
1677                    let djvi_djbz: BTreeMap<String, Vec<u8>> = entries
1678                        .iter()
1679                        .enumerate()
1680                        .filter(|(_, e)| e.kind == DirmComponentKind::Shared)
1681                        .filter_map(|(comp_idx, entry)| {
1682                            let sf = sub_forms.get(comp_idx)?;
1683                            let chunks = parse_sub_form(sf.data).ok()?;
1684                            let djbz = chunks.iter().find(|c| &c.id == b"Djbz")?;
1685                            Some((entry.id.clone(), djbz.data.to_vec()))
1686                        })
1687                        .collect();
1688
1689                    let mut pages = Vec::new();
1690                    let mut page_byte_ranges = Vec::new();
1691                    let mut page_idx = 0usize;
1692                    for (comp_idx, entry) in entries.iter().enumerate() {
1693                        if entry.kind != DirmComponentKind::Page {
1694                            continue;
1695                        }
1696                        let sub_form = sub_forms.get(comp_idx).ok_or(DocError::Malformed(
1697                            "DIRM entry count exceeds FORM children",
1698                        ))?;
1699                        let sub_chunks = parse_sub_form(sub_form.data)?;
1700
1701                        // Resolve the page's INCL references to a shared DJVI
1702                        // dictionary. A page may include several components
1703                        // (e.g. a shared-annotation DJVI *and* the symbol
1704                        // dictionary — czech.djvu carries three INCLs, #624),
1705                        // so scan them all and take the first whose target
1706                        // actually holds a Djbz.
1707                        let shared_djbz = sub_chunks
1708                            .iter()
1709                            .filter(|c| &c.id == b"INCL")
1710                            .filter_map(|incl| {
1711                                core::str::from_utf8(incl.data.trim_ascii_end()).ok()
1712                            })
1713                            .find_map(|name| djvi_djbz.get(name))
1714                            .cloned();
1715
1716                        let page = parse_page_from_chunks(&sub_chunks, page_idx, shared_djbz)?;
1717                        pages.push(page);
1718
1719                        // Record the byte range of this page's outer FORM. The
1720                        // offset→range arithmetic lives in `dirm::form_byte_range`;
1721                        // here we just supply the four size bytes from the in-memory
1722                        // FORM header.
1723                        if let Some(off) = comp_offsets.get(comp_idx) {
1724                            let start = *off as usize;
1725                            // `start` is an untrusted DIRM offset; `start + 8`
1726                            // overflows `usize` on 32-bit targets for a crafted
1727                            // out-of-bounds offset. Guard the header slice.
1728                            if let Some(size_bytes) = start
1729                                .checked_add(8)
1730                                .and_then(|end| data.get(start + 4..end))
1731                            {
1732                                let size_be =
1733                                    [size_bytes[0], size_bytes[1], size_bytes[2], size_bytes[3]];
1734                                page_byte_ranges.push(crate::dirm::form_byte_range(*off, size_be));
1735                            }
1736                        }
1737                        page_idx += 1;
1738                    }
1739
1740                    // Only expose offsets if we got one for every page; partial
1741                    // tables would surprise callers iterating by page index.
1742                    if page_byte_ranges.len() != pages.len() {
1743                        page_byte_ranges.clear();
1744                    }
1745
1746                    Ok(DjVuDocument {
1747                        pages,
1748                        bookmarks,
1749                        global_chunks,
1750                        page_byte_ranges,
1751                        resource_limits: None,
1752                    })
1753                } else {
1754                    // Indirect: pages must be resolved by name
1755                    let resolver = resolver.ok_or(DocError::NoResolver)?;
1756
1757                    let mut pages = Vec::new();
1758                    let mut page_idx = 0usize;
1759                    for entry in &entries {
1760                        if entry.kind != DirmComponentKind::Page {
1761                            continue;
1762                        }
1763                        let resolved_data = resolver(&entry.id)
1764                            .map_err(|_| DocError::IndirectResolve(entry.id.clone()))?;
1765                        let sub_form = parse_form(&resolved_data)?;
1766                        let page = parse_page_from_chunks(&sub_form.chunks, page_idx, None)?;
1767                        pages.push(page);
1768                        page_idx += 1;
1769                    }
1770
1771                    Ok(DjVuDocument {
1772                        pages,
1773                        bookmarks,
1774                        global_chunks,
1775                        // Indirect: per-page bytes live in external files, not the
1776                        // index buffer — no meaningful range to expose here.
1777                        page_byte_ranges: Vec::new(),
1778                        resource_limits: None,
1779                    })
1780                }
1781            }
1782            other => Err(DocError::NotDjVu(*other)),
1783        }
1784    }
1785
1786    #[cfg(all(feature = "std", feature = "async"))]
1787    pub(crate) fn parse_single_page_with_shared(
1788        data: &[u8],
1789        index: usize,
1790        shared_djbz: Option<Arc<SharedDict>>,
1791    ) -> Result<DjVuPage, DocError> {
1792        let form = parse_form(data)?;
1793        if form.form_type != *b"DJVU" {
1794            return Err(DocError::NotDjVu(form.form_type));
1795        }
1796        parse_page_from_chunks(&form.chunks, index, shared_djbz)
1797    }
1798
1799    /// Number of pages.
1800    pub fn page_count(&self) -> usize {
1801        self.pages.len()
1802    }
1803
1804    /// Byte range of `page`'s outer FORM chunk inside the original document
1805    /// buffer (#196 Phase 2).
1806    ///
1807    /// Returns `Some(start..end)` where `start` is the absolute offset of the
1808    /// 4-byte `FORM` magic and `end` is one past the last byte of the chunk
1809    /// payload. The range is suitable for an HTTP `Range:` request that
1810    /// fetches exactly the bytes needed to decode that page (assuming any
1811    /// referenced shared `DJVI` dictionaries are already in hand — those
1812    /// have their own ranges too, but `page_byte_range` only covers pages).
1813    ///
1814    /// Returns `None` for:
1815    /// - `index >= page_count()`
1816    /// - Indirect DJVM documents (per-page bytes live in external files)
1817    /// - Bundled DJVM documents whose DIRM offset table couldn't be matched
1818    ///   to every page
1819    ///
1820    /// Single-page DJVU documents always return the full buffer range.
1821    pub fn page_byte_range(&self, index: usize) -> Option<core::ops::Range<u64>> {
1822        self.page_byte_ranges.get(index).cloned()
1823    }
1824
1825    /// Speculatively decode page `index`'s render layers (background, mask,
1826    /// foreground) on a background thread pool, so that a **later**,
1827    /// synchronous [`crate::djvu_render::render_pixmap`] call at native
1828    /// resolution finds the caches already warm (the B7 next-page prefetch
1829    /// lever — e.g. call `doc.prefetch_page(k + 1)` right after rendering
1830    /// page `k`, while the reader is still looking at it).
1831    ///
1832    /// Requires an `Arc<DjVuDocument>` so the spawned task can outlive this
1833    /// call — the background closure holds its own clone of the `Arc` and
1834    /// writes into the *same* page's existing `OnceLock`-backed
1835    /// [`crate::djvu_render::PageLayers`] cache, so there is no separate
1836    /// "prefetch buffer" to race against: whichever caller (the background
1837    /// task or a later foreground render) reaches `get_or_init` first does
1838    /// the decode, the other observes the cached result. Out-of-range
1839    /// `index` is a no-op. This is a hint, not a guarantee — if the
1840    /// background task hasn't finished by the time the page is rendered, the
1841    /// caller still gets correct output, just without the latency win.
1842    ///
1843    /// Requires the `parallel` feature (spawns onto the shared rayon pool).
1844    #[cfg(all(feature = "std", feature = "parallel"))]
1845    pub fn prefetch_page(self: &Arc<Self>, index: usize) {
1846        if index >= self.page_count() {
1847            return;
1848        }
1849        let doc = Arc::clone(self);
1850        rayon::spawn(move || {
1851            let Ok(page) = doc.page(index) else {
1852                return;
1853            };
1854            // Mirrors the common full-resolution render path's dependency
1855            // chain: mask/fg44 are independent of bg; bg_rgb_s1 subsumes the
1856            // bg44 ZP arithmetic decode (see `PageLayers::bg_rgb_s1`), so this
1857            // warms every cache slot a native-resolution `render_pixmap` call
1858            // reads from. (A page too large to hold its background whole
1859            // gets no RGB pixmap — #811 — but the bg44 slot is still warmed.)
1860            let _ = page.decoded_mask();
1861            let _ = page.decoded_fg44();
1862            let _ = page.decoded_bg_rgb_s1();
1863        });
1864    }
1865
1866    /// Access a page by 0-based index.
1867    ///
1868    /// # Errors
1869    ///
1870    /// Returns `DocError::PageOutOfRange` if `index >= page_count()`.
1871    pub fn page(&self, index: usize) -> Result<&DjVuPage, DocError> {
1872        self.pages.get(index).ok_or(DocError::PageOutOfRange {
1873            index,
1874            count: self.pages.len(),
1875        })
1876    }
1877
1878    /// Drop every page's render-tier decode cache, reclaiming all per-page
1879    /// render memory in one call.
1880    ///
1881    /// See [`DjVuPage::evict_render_cache`]: rendered pages memoise their decoded
1882    /// layers for the document's lifetime, so peak RSS grows linearly with pages
1883    /// rendered. This frees all of it (each page rebuilds lazily on next render).
1884    #[cfg(feature = "std")]
1885    pub fn evict_render_caches(&mut self) {
1886        for p in &mut self.pages {
1887            p.evict_render_cache();
1888        }
1889    }
1890
1891    /// Drop the render cache of every page **except** those whose index is in
1892    /// `keep`, bounding memory to a working set (e.g. the visible pages plus a
1893    /// small prefetch window) in a long-lived viewer.
1894    #[cfg(feature = "std")]
1895    pub fn retain_render_caches(&self, keep: &[usize]) {
1896        for (i, p) in self.pages.iter().enumerate() {
1897            if !keep.contains(&i) {
1898                p.evict_render_cache();
1899            }
1900        }
1901    }
1902
1903    /// Approximate total resident bytes held by all pages' render caches.
1904    ///
1905    /// Sum of [`DjVuPage::render_cache_bytes`]; use it to decide when to call
1906    /// [`enforce_cache_budget`](Self::enforce_cache_budget).
1907    #[cfg(feature = "std")]
1908    pub fn render_cache_bytes(&self) -> usize {
1909        self.pages.iter().map(|p| p.render_cache_bytes()).sum()
1910    }
1911
1912    /// Evict least-recently-rendered pages' caches until the total render-cache
1913    /// memory is at most `max_bytes`, never evicting a page whose index is in
1914    /// `protect`. Returns the bytes freed.
1915    ///
1916    /// This is the automatic form of [`retain_render_caches`](Self::retain_render_caches):
1917    /// instead of naming exactly which pages to keep, the caller sets a memory
1918    /// ceiling and a small protected working set (e.g. the visible pages), and
1919    /// the least-recently-used cached pages are dropped first (via the per-page
1920    /// LRU access tick stamped on every render). A viewer can call it after each
1921    /// page render to hold memory near a fixed budget. No-op (returns 0) when
1922    /// already under budget. Evicted caches rebuild lazily and identically.
1923    #[cfg(feature = "std")]
1924    pub fn enforce_cache_budget(&self, max_bytes: usize, protect: &[usize]) -> usize {
1925        let mut total = self.render_cache_bytes();
1926        if total <= max_bytes {
1927            return 0;
1928        }
1929        // Evictable pages (cached, not protected), least-recently-used first.
1930        let mut cands: Vec<(usize, u64, usize)> = self
1931            .pages
1932            .iter()
1933            .enumerate()
1934            .filter(|(i, p)| !protect.contains(i) && p.render_cache_bytes() > 0)
1935            .map(|(i, p)| (i, p.render_cache_access_tick(), p.render_cache_bytes()))
1936            .collect();
1937        cands.sort_by_key(|&(_, tick, _)| tick);
1938
1939        let mut freed = 0usize;
1940        for (i, _, bytes) in cands {
1941            if total <= max_bytes {
1942                break;
1943            }
1944            self.pages[i].evict_render_cache();
1945            freed += bytes;
1946            total = total.saturating_sub(bytes);
1947        }
1948        freed
1949    }
1950
1951    /// C5_COMPRESS: like [`downgrade_render_caches`](Self::downgrade_render_caches)
1952    /// applied to every page — downgrade instead of drop.
1953    #[cfg(feature = "std")]
1954    pub fn downgrade_render_caches(&self) {
1955        for p in &self.pages {
1956            p.downgrade_render_cache();
1957        }
1958    }
1959
1960    /// Like [`enforce_cache_budget`](Self::enforce_cache_budget), but taking
1961    /// [`CacheBudgetOptions`] to opt into the C5_COMPRESS downgrade-before-drop
1962    /// tier. Returns the bytes freed (net of any bytes still held by
1963    /// downgraded — not fully dropped — pages).
1964    #[cfg(feature = "std")]
1965    pub fn enforce_cache_budget_with(
1966        &self,
1967        max_bytes: usize,
1968        protect: &[usize],
1969        opts: CacheBudgetOptions,
1970    ) -> usize {
1971        if !opts.downgrade_before_drop {
1972            return self.enforce_cache_budget(max_bytes, protect);
1973        }
1974        let mut total = self.render_cache_bytes();
1975        if total <= max_bytes {
1976            return 0;
1977        }
1978        let starting_total = total;
1979
1980        // Pass 1: downgrade LRU-first (cheap tier) until under budget or no
1981        // eligible candidates remain.
1982        let mut cands: Vec<(usize, u64, usize)> = self
1983            .pages
1984            .iter()
1985            .enumerate()
1986            .filter(|(i, p)| !protect.contains(i) && p.render_cache_bytes() > 0)
1987            .map(|(i, p)| (i, p.render_cache_access_tick(), p.render_cache_bytes()))
1988            .collect();
1989        cands.sort_by_key(|&(_, tick, _)| tick);
1990
1991        for &(i, _, before) in &cands {
1992            if total <= max_bytes {
1993                break;
1994            }
1995            self.pages[i].downgrade_render_cache();
1996            let after = self.pages[i].render_cache_bytes();
1997            total = total.saturating_sub(before.saturating_sub(after));
1998        }
1999
2000        // Pass 2: still over budget (downgrading wasn't enough, e.g. many
2001        // small pages or nothing left to shrink) — fall back to full drops,
2002        // LRU-first, same as `enforce_cache_budget`.
2003        if total > max_bytes {
2004            let mut cands2: Vec<(usize, u64, usize)> = self
2005                .pages
2006                .iter()
2007                .enumerate()
2008                .filter(|(i, p)| !protect.contains(i) && p.render_cache_bytes() > 0)
2009                .map(|(i, p)| (i, p.render_cache_access_tick(), p.render_cache_bytes()))
2010                .collect();
2011            cands2.sort_by_key(|&(_, tick, _)| tick);
2012
2013            for (i, _, bytes) in cands2 {
2014                if total <= max_bytes {
2015                    break;
2016                }
2017                self.pages[i].evict_render_cache();
2018                total = total.saturating_sub(bytes);
2019            }
2020        }
2021
2022        starting_total.saturating_sub(total)
2023    }
2024
2025    /// The NAVM table of contents, or an empty slice if not present.
2026    pub fn bookmarks(&self) -> &[DjVuBookmark] {
2027        &self.bookmarks
2028    }
2029
2030    /// Parse document-level metadata from a METz (BZZ-compressed) or METa
2031    /// (plain text) chunk.
2032    ///
2033    /// Returns `Ok(None)` if no METa/METz chunk is present.
2034    pub fn metadata(&self) -> Result<Option<DjVuMetadata>, DocError> {
2035        match self.chunk_payload(b"METz", b"METa")? {
2036            Some(bytes) => Ok(Some(crate::metadata::parse_metadata(&bytes)?)),
2037            None => Ok(None),
2038        }
2039    }
2040
2041    /// Component directory from the document `DIRM` chunk.
2042    ///
2043    /// Returns an empty vector when no `DIRM` is present (typical single-page
2044    /// `FORM:DJVU`). Kind letters match DjVuLibre `djvused ls`: `P` page,
2045    /// `I` shared/include, `T` thumbnail.
2046    pub fn component_directory(&self) -> Result<Vec<ComponentDirectoryEntry>, DocError> {
2047        let Some(data) = self.raw_chunk(b"DIRM") else {
2048            return Ok(Vec::new());
2049        };
2050        let payload = DirmPayload::decode(data).map_err(DocError::Malformed)?;
2051        Ok(payload
2052            .components()
2053            .into_iter()
2054            .map(|component| ComponentDirectoryEntry {
2055                kind: match component.kind {
2056                    DirmComponentKind::Page => 'P',
2057                    DirmComponentKind::Thumbnail => 'T',
2058                    DirmComponentKind::Shared => 'I',
2059                },
2060                id: component.id,
2061            })
2062            .collect())
2063    }
2064
2065    /// Return the raw bytes of the first document-level chunk with the given
2066    /// 4-byte ID.
2067    ///
2068    /// For single-page DJVU files this covers all top-level chunks (INFO,
2069    /// Sjbz, BG44, …).  For multi-page DJVM files this covers non-page chunks
2070    /// such as DIRM and NAVM.  Per-page chunks are accessed via
2071    /// [`DjVuPage::raw_chunk`].
2072    ///
2073    /// Returns `None` if no such chunk exists.
2074    pub fn raw_chunk(&self, id: &[u8; 4]) -> Option<&[u8]> {
2075        self.global_chunks
2076            .iter()
2077            .find(|c| &c.id == id)
2078            .map(|c| c.data.as_slice())
2079    }
2080
2081    /// Return the raw bytes of all document-level chunks with the given ID.
2082    ///
2083    /// Returns an empty `Vec` if no such chunk exists.
2084    pub fn all_chunks(&self, id: &[u8; 4]) -> Vec<&[u8]> {
2085        self.global_chunks
2086            .iter()
2087            .filter(|c| &c.id == id)
2088            .map(|c| c.data.as_slice())
2089            .collect()
2090    }
2091
2092    /// Return the IDs of all document-level chunks, in order.
2093    ///
2094    /// For multi-page DJVM files this is the sequence of non-page chunks
2095    /// (DIRM, NAVM, …).  Duplicate IDs appear once per chunk.
2096    pub fn chunk_ids(&self) -> Vec<[u8; 4]> {
2097        self.global_chunks.iter().map(|c| c.id).collect()
2098    }
2099
2100    /// Decode the payload of a paired `*z` (BZZ-compressed) / `*a` (raw)
2101    /// document-level chunk, e.g. `chunk_payload(b"METz", b"METa")` for
2102    /// document metadata.
2103    ///
2104    /// The document-level counterpart of [`DjVuPage::chunk_payload`]; it owns
2105    /// the BZZ-or-raw decision once so the format parsers stay pure.
2106    pub fn chunk_payload(
2107        &self,
2108        id_z: &[u8; 4],
2109        id_a: &[u8; 4],
2110    ) -> Result<Option<Vec<u8>>, DocError> {
2111        Ok(decode_paired_payload(
2112            self.raw_chunk(id_z),
2113            self.raw_chunk(id_a),
2114        )?)
2115    }
2116
2117    /// Parse an indirect DjVu document from bytes, resolving component files
2118    /// relative to `base_dir`.
2119    ///
2120    /// For bundled documents this is equivalent to [`DjVuDocument::parse`].
2121    /// For indirect documents, component names from the DIRM are resolved as
2122    /// paths under `base_dir`, and each referenced file is read from disk.
2123    ///
2124    /// # Errors
2125    ///
2126    /// Returns `DocError::Io` if a component file cannot be read, or any parse
2127    /// error from the component data.
2128    #[cfg(feature = "std")]
2129    pub fn parse_from_dir(
2130        data: &[u8],
2131        base_dir: impl AsRef<std::path::Path>,
2132    ) -> Result<Self, DocError> {
2133        Self::parse_from_dir_with_options(
2134            data,
2135            base_dir,
2136            &crate::resource_limits::ParseOptions::default(),
2137        )
2138    }
2139
2140    /// Parse an indirect document from a directory with configurable resource limits.
2141    #[cfg(feature = "std")]
2142    pub fn parse_from_dir_with_options(
2143        data: &[u8],
2144        base_dir: impl AsRef<std::path::Path>,
2145        opts: &crate::resource_limits::ParseOptions,
2146    ) -> Result<Self, DocError> {
2147        let base = base_dir.as_ref().to_path_buf();
2148        let resolver = move |name: &str| -> Result<Vec<u8>, DocError> {
2149            // Strip any "file://" prefix
2150            let name = name.strip_prefix("file://").unwrap_or(name);
2151            let path = if std::path::Path::new(name).is_absolute() {
2152                std::path::PathBuf::from(name)
2153            } else {
2154                base.join(name)
2155            };
2156            std::fs::read(&path).map_err(|_| DocError::IndirectResolve(name.to_string()))
2157        };
2158        Self::parse_with_resolver_and_options(data, Some(resolver), opts)
2159    }
2160}
2161
2162// ---- Memory-mapped document -------------------------------------------------
2163
2164/// A DjVu document backed by a memory-mapped file.
2165///
2166/// Instead of copying the entire file into a `Vec<u8>`, this type maps the file
2167/// into the process address space using the OS virtual-memory subsystem.  The
2168/// kernel pages data from disk on demand, which can significantly reduce peak
2169/// memory usage for large multi-volume scans (100+ MB).
2170///
2171/// # Safety contract
2172///
2173/// **The underlying file must not be modified or truncated while the mapping is
2174/// alive.**  Mutating a memory-mapped file is undefined behaviour on most
2175/// platforms (SIGBUS on Linux/macOS, access violation on Windows).  The caller
2176/// is responsible for ensuring file immutability for the lifetime of this
2177/// struct.
2178///
2179/// Requires the `mmap` feature flag.
2180#[cfg(feature = "mmap")]
2181pub struct MmapDocument {
2182    /// The memory mapping, wrapped in the shared [`Backing`] type and kept alive
2183    /// for the document's lifetime. For bundled documents the parsed pages are
2184    /// **lazy** and read their chunk bytes directly from this mapping on demand
2185    /// (zero-copy open); for single-page / indirect documents the pages own copies
2186    /// and this simply outlives the parse. Held via the same `Arc` the pages
2187    /// clone, so the mapping cannot be dropped while a lazy page still needs it.
2188    _backing: Backing,
2189    /// The same mapping, kept as its concrete type (an extra `Arc` clone of
2190    /// the identical allocation `_backing` erases) so
2191    /// [`MmapDocument::advise_page_willneed`] can call `memmap2::Mmap::advise_range`
2192    /// directly — the type-erased `Backing` alias can't expose that method.
2193    mmap: Arc<memmap2::Mmap>,
2194    doc: DjVuDocument,
2195}
2196
2197#[cfg(feature = "mmap")]
2198impl MmapDocument {
2199    /// Open a DjVu file via memory-mapped I/O.
2200    ///
2201    /// # Safety contract
2202    ///
2203    /// The file at `path` **must not be modified or truncated** while the
2204    /// returned `MmapDocument` is alive.  See the struct-level documentation
2205    /// for details.
2206    ///
2207    /// # Errors
2208    ///
2209    /// Returns `DocError::Io` if the file cannot be opened or mapped, or any
2210    /// parse error from [`DjVuDocument::parse`].
2211    pub fn open(path: impl AsRef<std::path::Path>) -> Result<Self, DocError> {
2212        let file = std::fs::File::open(path.as_ref())?;
2213
2214        // SAFETY: The caller guarantees the file is not modified while mapped.
2215        // memmap2::Mmap provides a &[u8] view of the file contents.
2216        #[allow(unsafe_code)]
2217        let mmap = unsafe { memmap2::Mmap::map(&file) }?;
2218
2219        // Move the mapping into the shared backing; bundled pages read from it
2220        // lazily (zero-copy open), and the `Arc` keeps it alive for them. Keep a
2221        // second, concretely-typed clone (same allocation, just another strong
2222        // ref) for `advise_page_willneed`.
2223        let mmap = Arc::new(mmap);
2224        let backing: Backing = mmap.clone();
2225        let doc = DjVuDocument::parse_backed_with_options(
2226            backing.clone(),
2227            &crate::resource_limits::ParseOptions::default(),
2228        )?;
2229        Ok(MmapDocument {
2230            _backing: backing,
2231            mmap,
2232            doc,
2233        })
2234    }
2235
2236    /// Open a DjVu file with automatic filesystem resolution for indirect pages.
2237    ///
2238    /// For bundled documents this is identical to [`MmapDocument::open`].
2239    /// For indirect DJVM documents, component files named in the DIRM are
2240    /// resolved relative to the directory containing `path`.
2241    ///
2242    /// # Safety contract
2243    ///
2244    /// The file at `path` **must not be modified or truncated** while the
2245    /// returned `MmapDocument` is alive.
2246    pub fn open_indirect(path: impl AsRef<std::path::Path>) -> Result<Self, DocError> {
2247        let path = path.as_ref();
2248        let file = std::fs::File::open(path)?;
2249        #[allow(unsafe_code)]
2250        let mmap = unsafe { memmap2::Mmap::map(&file) }?;
2251
2252        let base_dir = path
2253            .parent()
2254            .map(|p| p.to_path_buf())
2255            .unwrap_or_else(|| std::path::PathBuf::from("."));
2256        // Indirect documents resolve external component files, so pages are eager
2257        // here; the mapping is still held via the shared backing for uniformity.
2258        let doc = DjVuDocument::parse_from_dir(&mmap, &base_dir)?;
2259        let mmap = Arc::new(mmap);
2260        let backing: Backing = mmap.clone();
2261        Ok(MmapDocument {
2262            _backing: backing,
2263            mmap,
2264            doc,
2265        })
2266    }
2267
2268    /// Access the parsed [`DjVuDocument`].
2269    pub fn document(&self) -> &DjVuDocument {
2270        &self.doc
2271    }
2272
2273    /// Number of pages in the document.
2274    pub fn page_count(&self) -> usize {
2275        self.doc.page_count()
2276    }
2277
2278    /// Access a page by 0-based index.
2279    pub fn page(&self, index: usize) -> Result<&DjVuPage, DocError> {
2280        self.doc.page(index)
2281    }
2282
2283    /// Hint to the OS that page `index`'s own bytes will be needed soon
2284    /// (`MADV_WILLNEED`). The cold-open (B6) lever: call this right after
2285    /// [`MmapDocument::open`], before the first render, so the kernel can
2286    /// start readahead I/O while the caller does anything else (parse
2287    /// metadata, build UI, etc.) instead of only starting on the page fault
2288    /// the render's first chunk read triggers.
2289    ///
2290    /// # Measured (COLD_OPEN, round 36)
2291    ///
2292    /// On a local NVMe SSD (M1) with `pathogenic_bacteria_1896.djvu` (517
2293    /// pages, 26 MB — small per-page FORM ranges, tens of KB), this hint is a
2294    /// wash: ±0–2%, inside measurement noise, whether issued for page 0 or a
2295    /// page deep in the file. Two things account for it: (1) most of a
2296    /// bundled document's structural cost (walking every page's IFF chunk
2297    /// headers to build `page_byte_range`) is already paid synchronously
2298    /// inside [`MmapDocument::open`], before this hint can be issued — there
2299    /// isn't much cold-read work left to schedule ahead by the time the
2300    /// caller gets a `MmapDocument` back; (2) a single page's FORM range is
2301    /// small enough that on fast local storage the demand-fault path is
2302    /// already close to the readahead path's latency. **An earlier version
2303    /// of this method advised the whole `0..range.end` prefix (covering the
2304    /// header/DIRM region too) and *reproducibly regressed cold open by
2305    /// ~12%*** (low dispersion, not noise) — advising far more than what's
2306    /// about to be read is actively harmful, not just wasted effort. Scoped
2307    /// to just `range` (this page's own bytes) it's harmless but unproven on
2308    /// this host; likely worth revisiting on higher-latency storage (network
2309    /// mounts, spinning disks) where a real win is more plausible. See
2310    /// `examples/cold_open_bench.rs --mode madvise`.
2311    ///
2312    /// Best-effort — a `madvise` failure (unsupported platform, unmapped
2313    /// range) is surfaced as `Err` but changes no state; correctness never
2314    /// depends on the hint landing. A `None` from
2315    /// [`DjVuDocument::page_byte_range`] (out-of-range index, indirect
2316    /// document, or an unmatched DIRM offset table) is treated as a no-op
2317    /// `Ok(())` rather than an error, since there is nothing wrong to report
2318    /// — there's just no known byte range to advise on.
2319    ///
2320    /// Only supported on Unix (the underlying `memmap2::Mmap::advise_range`
2321    /// is `#[cfg(unix)]`); a no-op stub is not provided for other platforms —
2322    /// gate calls with `#[cfg(unix)]` if you need to build for Windows too.
2323    #[cfg(unix)]
2324    pub fn advise_page_willneed(&self, index: usize) -> std::io::Result<()> {
2325        let Some(range) = self.doc.page_byte_range(index) else {
2326            return Ok(());
2327        };
2328        let start = (range.start as usize).min(self.mmap.len());
2329        let end = (range.end as usize).min(self.mmap.len());
2330        if end <= start {
2331            return Ok(());
2332        }
2333        self.mmap
2334            .advise_range(memmap2::Advice::WillNeed, start, end - start)
2335    }
2336
2337    /// Consume this `MmapDocument`, returning the owned [`DjVuDocument`].
2338    ///
2339    /// Bundled documents' lazily-constructed pages ([`ChunkStore::Lazy`])
2340    /// hold their own `Arc` clone of the memory mapping, so it stays mapped
2341    /// for as long as any page needs it — dropping this wrapper's own
2342    /// reference here is safe (indirect documents' pages are eager and don't
2343    /// reference the mapping at all after parsing). Useful to obtain an owned
2344    /// value to wrap in `Arc<DjVuDocument>`, which [`DjVuDocument::prefetch_page`]
2345    /// requires so a background task can share ownership of the same page
2346    /// caches the foreground render uses.
2347    pub fn into_document(self) -> DjVuDocument {
2348        self.doc
2349    }
2350}
2351
2352#[cfg(feature = "mmap")]
2353impl core::ops::Deref for MmapDocument {
2354    type Target = DjVuDocument;
2355    fn deref(&self) -> &DjVuDocument {
2356        &self.doc
2357    }
2358}
2359
2360// ---- Internal parsing helpers -----------------------------------------------
2361
2362fn component_id_from_dirm(component: &DirmComponent) -> ComponentId {
2363    let kind = match component.kind {
2364        DirmComponentKind::Page => ComponentKind::Page,
2365        DirmComponentKind::Shared => ComponentKind::Shared,
2366        DirmComponentKind::Thumbnail => ComponentKind::Thumbnail,
2367    };
2368    ComponentId::new(component.id.clone(), kind)
2369}
2370
2371fn expected_component_form(kind: ComponentKind) -> [u8; 4] {
2372    match kind {
2373        ComponentKind::Page => *b"DJVU",
2374        ComponentKind::Shared => *b"DJVI",
2375        ComponentKind::Thumbnail => *b"THUM",
2376    }
2377}
2378
2379/// Parse a `DjVuPage` from the chunks of a FORM:DJVU.
2380///
2381/// `shared_djbz` is the raw `Djbz` data from a referenced DJVI component
2382/// (resolved from the page's INCL chunk by the caller); pass `None` if no
2383/// shared dictionary is available.
2384/// Build a page whose chunk bytes are materialised lazily from `backing`.
2385///
2386/// Only the cheap fixed-size `INFO` header is parsed now; the per-chunk copy is
2387/// deferred to first [`DjVuPage::chunk_slice`] access. `range` is the page's
2388/// `FORM` sub-form byte range within `backing`.
2389#[cfg(feature = "std")]
2390fn parse_page_lazy(
2391    chunks: &[IffChunk<'_>],
2392    index: usize,
2393    shared_djbz: Option<Arc<SharedDict>>,
2394    backing: Backing,
2395    range: core::ops::Range<usize>,
2396) -> Result<DjVuPage, DocError> {
2397    let info_chunk = chunks
2398        .iter()
2399        .find(|c| &c.id == b"INFO")
2400        .ok_or(DocError::MissingChunk("INFO"))?;
2401    let info = PageInfo::parse(info_chunk.data)?;
2402    Ok(DjVuPage {
2403        info,
2404        chunks: ChunkStore::Lazy {
2405            backing,
2406            range,
2407            cache: std::sync::OnceLock::new(),
2408        },
2409        index,
2410        shared_djbz,
2411        render_layers: std::sync::OnceLock::new(),
2412        resource_limits: None,
2413    })
2414}
2415
2416#[cfg(feature = "std")]
2417fn parse_page_from_chunks(
2418    chunks: &[IffChunk<'_>],
2419    index: usize,
2420    shared_djbz: Option<Arc<SharedDict>>,
2421) -> Result<DjVuPage, DocError> {
2422    let info_chunk = chunks
2423        .iter()
2424        .find(|c| &c.id == b"INFO")
2425        .ok_or(DocError::MissingChunk("INFO"))?;
2426
2427    let info = PageInfo::parse(info_chunk.data)?;
2428
2429    // Copy all chunks to owned storage for lazy decode later.
2430    let raw_chunks: Vec<RawChunk> = chunks
2431        .iter()
2432        .map(|c| RawChunk {
2433            id: c.id,
2434            data: c.data.to_vec(),
2435        })
2436        .collect();
2437
2438    Ok(DjVuPage {
2439        info,
2440        chunks: ChunkStore::Eager(raw_chunks),
2441        index,
2442        shared_djbz,
2443        render_layers: std::sync::OnceLock::new(),
2444        resource_limits: None,
2445    })
2446}
2447
2448/// Build [`PageInfo`] from the first IW44 chunk header of a legacy BM44/PM44
2449/// document (no INFO chunk). DjVuLibre reports 100 dpi for these photo forms.
2450fn page_info_from_iw44_first_chunk(
2451    form_type: &[u8; 4],
2452    payload: &[u8],
2453) -> Result<PageInfo, DocError> {
2454    if payload.len() < 9 {
2455        return Err(DocError::Malformed(
2456            "legacy IW44 first chunk header truncated",
2457        ));
2458    }
2459    let serial = payload[0];
2460    if serial != 0 {
2461        return Err(DocError::Malformed(
2462            "legacy IW44 first chunk must have serial 0",
2463        ));
2464    }
2465    let majver = payload[2];
2466    let is_grayscale = (majver >> 7) != 0;
2467    match (form_type, is_grayscale) {
2468        (b"BM44", true) | (b"PM44", false) => {}
2469        (b"BM44", false) => {
2470            return Err(DocError::Malformed(
2471                "FORM:BM44 requires a grayscale IW44 bitstream",
2472            ));
2473        }
2474        (b"PM44", true) => {
2475            return Err(DocError::Malformed(
2476                "FORM:PM44 requires a color IW44 bitstream",
2477            ));
2478        }
2479        _ => {
2480            return Err(DocError::Malformed("unexpected legacy IW44 form type"));
2481        }
2482    }
2483    let width = u16::from_be_bytes([payload[4], payload[5]]);
2484    let height = u16::from_be_bytes([payload[6], payload[7]]);
2485    if width == 0 || height == 0 {
2486        return Err(DocError::Malformed("legacy IW44 zero dimension"));
2487    }
2488    let pixels = u64::from(width) * u64::from(height);
2489    if pixels > 64 * 1024 * 1024 {
2490        return Err(DocError::Malformed("legacy IW44 image too large"));
2491    }
2492    Ok(PageInfo {
2493        width,
2494        height,
2495        dpi: 100,
2496        gamma: 2.2,
2497        rotation: crate::info::Rotation::None,
2498    })
2499}
2500
2501/// Parse a legacy standalone `FORM:BM44` or `FORM:PM44` page.
2502fn parse_legacy_iw44_page(
2503    form_type: &[u8; 4],
2504    chunks: &[IffChunk<'_>],
2505    index: usize,
2506) -> Result<DjVuPage, DocError> {
2507    let expected_id = match form_type {
2508        b"BM44" => *b"BM44",
2509        b"PM44" => *b"PM44",
2510        _ => {
2511            return Err(DocError::Malformed(
2512                "parse_legacy_iw44_page requires BM44 or PM44",
2513            ));
2514        }
2515    };
2516    if chunks.is_empty() {
2517        return Err(DocError::MissingChunk(match form_type {
2518            b"BM44" => "BM44",
2519            _ => "PM44",
2520        }));
2521    }
2522    for chunk in chunks {
2523        if chunk.id != expected_id {
2524            return Err(DocError::Malformed(
2525                "legacy IW44 form contains unexpected chunk id",
2526            ));
2527        }
2528    }
2529    let info = page_info_from_iw44_first_chunk(form_type, chunks[0].data)?;
2530    let raw_chunks: Vec<RawChunk> = chunks
2531        .iter()
2532        .map(|c| RawChunk {
2533            id: c.id,
2534            data: c.data.to_vec(),
2535        })
2536        .collect();
2537    #[cfg(feature = "std")]
2538    {
2539        Ok(DjVuPage {
2540            info,
2541            chunks: ChunkStore::Eager(raw_chunks),
2542            index,
2543            shared_djbz: None,
2544            render_layers: std::sync::OnceLock::new(),
2545            resource_limits: None,
2546        })
2547    }
2548    #[cfg(not(feature = "std"))]
2549    {
2550        Ok(DjVuPage {
2551            info,
2552            chunks: raw_chunks,
2553            index,
2554            shared_djbz: None,
2555            resource_limits: None,
2556        })
2557    }
2558}
2559
2560#[cfg(not(feature = "std"))]
2561fn parse_page_from_chunks(
2562    chunks: &[IffChunk<'_>],
2563    index: usize,
2564    shared_djbz: Option<Vec<u8>>,
2565) -> Result<DjVuPage, DocError> {
2566    let info_chunk = chunks
2567        .iter()
2568        .find(|c| &c.id == b"INFO")
2569        .ok_or(DocError::MissingChunk("INFO"))?;
2570
2571    let info = PageInfo::parse(info_chunk.data)?;
2572
2573    let raw_chunks: Vec<RawChunk> = chunks
2574        .iter()
2575        .map(|c| RawChunk {
2576            id: c.id,
2577            data: c.data.to_vec(),
2578        })
2579        .collect();
2580
2581    Ok(DjVuPage {
2582        info,
2583        chunks: raw_chunks,
2584        index,
2585        shared_djbz,
2586        resource_limits: None,
2587    })
2588}
2589
2590/// Parse sub-form chunks from the data portion of a FORM chunk.
2591///
2592/// The `data` bytes start with a 4-byte form type (e.g. `DJVU`), followed by
2593/// sequential IFF chunks.
2594fn parse_sub_form(data: &[u8]) -> Result<Vec<IffChunk<'_>>, DocError> {
2595    if data.len() < 4 {
2596        return Err(DocError::Malformed("sub-form data too short"));
2597    }
2598    // data[0..4] = form type (DJVU / DJVI / THUM …)
2599    // data[4..] = sequential chunks
2600    let body = data
2601        .get(4..)
2602        .ok_or(DocError::Malformed("sub-form body missing"))?;
2603    let chunks = parse_form_body(body).map_err(DocError::Iff)?;
2604    Ok(chunks)
2605}
2606
2607/// Maximum NAVM bookmark nesting depth (real outlines are a few levels deep).
2608/// Bounds `parse_bookmark_entry` recursion so a crafted deep chain can't overflow
2609/// the stack.
2610const MAX_NAVM_DEPTH: u32 = 256;
2611
2612/// Parse NAVM bookmarks from the chunk list of a FORM:DJVM.
2613///
2614/// Returns an empty Vec if there is no NAVM chunk.
2615fn parse_navm_bookmarks(chunks: &[IffChunk<'_>]) -> Result<Vec<DjVuBookmark>, DocError> {
2616    let navm_data = match chunks.iter().find(|c| &c.id == b"NAVM") {
2617        Some(c) => c.data,
2618        None => return Ok(vec![]),
2619    };
2620
2621    let decoded = bzz_decode(navm_data)?;
2622
2623    if decoded.len() < 2 {
2624        return Ok(vec![]);
2625    }
2626
2627    let b0 = *decoded
2628        .first()
2629        .ok_or(DocError::Malformed("NAVM total count byte 0"))?;
2630    let b1 = *decoded
2631        .get(1)
2632        .ok_or(DocError::Malformed("NAVM total count byte 1"))?;
2633    let total_count = u16::from_be_bytes([b0, b1]) as usize;
2634
2635    let mut pos = 2usize;
2636    let mut bookmarks = Vec::new();
2637    let mut decoded_count = 0usize;
2638
2639    while decoded_count < total_count {
2640        let bm = parse_bookmark_entry(&decoded, &mut pos, &mut decoded_count, 0)?;
2641        bookmarks.push(bm);
2642    }
2643
2644    Ok(bookmarks)
2645}
2646
2647/// Recursively parse one bookmark entry and its children.
2648///
2649/// `total_counter` is a shared counter for ALL bookmark nodes across all recursion
2650/// levels, matching the DjVu NAVM format's flat total-count field.
2651fn parse_bookmark_entry(
2652    data: &[u8],
2653    pos: &mut usize,
2654    total_counter: &mut usize,
2655    depth: u32,
2656) -> Result<DjVuBookmark, DocError> {
2657    // `total_counter` bounds the *number* of nodes but not the *depth*: a crafted
2658    // chain of single-child entries recurses as deep as the node count (up to
2659    // ~65 535), overflowing the stack. Real bookmark trees are a few levels deep.
2660    if depth > MAX_NAVM_DEPTH {
2661        return Err(DocError::Malformed("NAVM bookmark nesting too deep"));
2662    }
2663    if *pos >= data.len() {
2664        return Err(DocError::Malformed("NAVM bookmark entry truncated"));
2665    }
2666
2667    // n_children is a single byte in the NAVM format
2668    let n_children = *data
2669        .get(*pos)
2670        .ok_or(DocError::Malformed("NAVM children count"))? as usize;
2671    *pos += 1;
2672
2673    let title = read_navm_str(data, pos)?;
2674    let url = read_navm_str(data, pos)?;
2675    *total_counter += 1;
2676
2677    // Children: fixed count, recurse with the same global total_counter
2678    let mut children = Vec::with_capacity(n_children);
2679    for _ in 0..n_children {
2680        let child = parse_bookmark_entry(data, pos, total_counter, depth + 1)?;
2681        children.push(child);
2682    }
2683
2684    Ok(DjVuBookmark {
2685        title,
2686        url,
2687        children,
2688    })
2689}
2690
2691/// Read a length-prefixed string from NAVM data.
2692///
2693/// Format: `[be_u24 length][text bytes]`. Nominally UTF-8, but legacy files
2694/// (DjVuLibre on Windows) carry CP1252 bytes in bookmark titles; decoded
2695/// leniently so one bad byte cannot abort `Document::open` (#524).
2696fn read_navm_str(data: &[u8], pos: &mut usize) -> Result<String, DocError> {
2697    if *pos + 3 > data.len() {
2698        return Err(DocError::Malformed("NAVM string length truncated"));
2699    }
2700    let len = ((*data.get(*pos).ok_or(DocError::Malformed("NAVM str"))? as usize) << 16)
2701        | ((*data.get(*pos + 1).ok_or(DocError::Malformed("NAVM str"))? as usize) << 8)
2702        | (*data.get(*pos + 2).ok_or(DocError::Malformed("NAVM str"))? as usize);
2703    *pos += 3;
2704
2705    let bytes = data
2706        .get(*pos..*pos + len)
2707        .ok_or(DocError::Malformed("NAVM string bytes truncated"))?;
2708    *pos += len;
2709
2710    Ok(crate::lenient_text::decode_lossy_string(bytes))
2711}
2712
2713// ---- Tests ------------------------------------------------------------------
2714
2715#[cfg(test)]
2716mod tests {
2717    use super::*;
2718
2719    fn fixture_bytes(name: &str) -> Vec<u8> {
2720        std::fs::read(
2721            std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
2722                .join(format!("tests/fixtures/{name}")),
2723        )
2724        .unwrap_or_else(|_| panic!("fixture {name} should exist"))
2725    }
2726
2727    /// #624: a page may carry several `INCL` chunks (czech.djvu: shared
2728    /// annotations + two symbol-dictionary includes). Resolution must scan
2729    /// them all and pick the include that actually holds a `Djbz` — taking
2730    /// only the first INCL left every czech mask undecodable
2731    /// (`MissingSharedDict`). The expected mask is byte-identical to
2732    /// DjVuLibre's `ddjvu -mode=mask` output.
2733    #[test]
2734    fn multi_incl_page_resolves_shared_dict() {
2735        let path =
2736            std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/czech.djvu");
2737        let data = std::fs::read(path).unwrap();
2738        let doc = DjVuDocument::parse(&data).unwrap();
2739        let mask = doc
2740            .page(1)
2741            .unwrap()
2742            .extract_mask()
2743            .expect("mask decode must succeed")
2744            .expect("page 1 has an Sjbz mask");
2745        assert_eq!((mask.width, mask.height), (1095, 1750));
2746        let black: u64 = (0..mask.height)
2747            .map(|y| (0..mask.width).filter(|&x| mask.get(x, y)).count() as u64)
2748            .sum();
2749        assert_eq!(
2750            black, 308_624,
2751            "mask content must match the ddjvu reference"
2752        );
2753    }
2754
2755    /// A NAVM bookmark chain nested far deeper than `MAX_NAVM_DEPTH` must error,
2756    /// not recurse until the stack overflows (security finding). Drives the
2757    /// internal entry parser directly with a crafted decoded buffer.
2758    #[test]
2759    fn deeply_nested_bookmarks_are_rejected_not_overflow() {
2760        // [total_count u16 = 1] then one entry that is a 400-deep single-child
2761        // chain: each node = [n_children=1][title len3=0][url len3=0]; deepest =
2762        // [n_children=0][..][..].
2763        let mut decoded = vec![0x00, 0x01];
2764        for _ in 0..400 {
2765            decoded.push(1); // n_children
2766            decoded.extend_from_slice(&[0, 0, 0]); // empty title (3-byte len)
2767            decoded.extend_from_slice(&[0, 0, 0]); // empty url
2768        }
2769        decoded.push(0); // deepest: no children
2770        decoded.extend_from_slice(&[0, 0, 0]);
2771        decoded.extend_from_slice(&[0, 0, 0]);
2772
2773        let mut pos = 2usize;
2774        let mut counter = 0usize;
2775        let r = parse_bookmark_entry(&decoded, &mut pos, &mut counter, 0);
2776        assert!(r.is_err(), "deep bookmark chain must error, not overflow");
2777    }
2778
2779    fn assets_path() -> std::path::PathBuf {
2780        std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
2781            .join("references/djvujs/library/assets")
2782    }
2783
2784    // ---- TDD: failing tests written first (Red phase) -----------------------
2785
2786    /// Single-page FORM:DJVU — basic parse, page count, dimensions, DPI.
2787    #[test]
2788    fn single_page_parse_and_metadata() {
2789        let data =
2790            std::fs::read(assets_path().join("chicken.djvu")).expect("chicken.djvu must exist");
2791        let doc = DjVuDocument::parse(&data).expect("parse should succeed");
2792
2793        assert_eq!(doc.page_count(), 1);
2794        let page = doc.page(0).expect("page 0 must exist");
2795        assert_eq!(page.width(), 181);
2796        assert_eq!(page.height(), 240);
2797        assert_eq!(page.dpi(), 100);
2798        assert!((page.gamma() - 2.2).abs() < 0.01, "gamma should be ~2.2");
2799    }
2800
2801    /// Single-page document: page index out of range.
2802    #[test]
2803    fn single_page_out_of_range() {
2804        let data =
2805            std::fs::read(assets_path().join("chicken.djvu")).expect("chicken.djvu must exist");
2806        let doc = DjVuDocument::parse(&data).expect("parse should succeed");
2807        let err = doc.page(1).expect_err("page 1 should be out of range");
2808        assert!(
2809            matches!(err, DocError::PageOutOfRange { index: 1, count: 1 }),
2810            "unexpected error: {err:?}"
2811        );
2812    }
2813
2814    // ---- #342: chunk-payload dispatch (compressed / raw / missing) ----------
2815    //
2816    // These exercise the single BZZ-or-raw seam directly, decoupled from any
2817    // format parser: `decode_paired_payload` (the free function) and the
2818    // `DjVuPage::chunk_payload` accessor built on it.
2819
2820    #[test]
2821    fn paired_payload_prefers_compressed_z_chunk() {
2822        let raw = b"the quick brown fox".as_slice();
2823        let z = crate::bzz_encode::bzz_encode(raw);
2824        // Both present: the compressed `*z` chunk wins.
2825        let out = decode_paired_payload(Some(&z), Some(b"ignored raw"))
2826            .expect("bzz decode should succeed");
2827        assert_eq!(out.as_deref(), Some(raw));
2828    }
2829
2830    #[test]
2831    fn paired_payload_falls_back_to_raw_a_chunk() {
2832        let raw = b"plain uncompressed payload".as_slice();
2833        let out = decode_paired_payload(None, Some(raw)).expect("raw passthrough");
2834        assert_eq!(out.as_deref(), Some(raw));
2835    }
2836
2837    #[test]
2838    fn paired_payload_missing_both_is_none() {
2839        assert_eq!(decode_paired_payload(None, None).expect("none"), None);
2840    }
2841
2842    #[test]
2843    fn paired_payload_empty_chunk_is_placeholder_none() {
2844        // DjVu uses a zero-length chunk as a "no payload" placeholder for both
2845        // the compressed and raw variants.
2846        assert_eq!(
2847            decode_paired_payload(Some(&[]), None).expect("empty z"),
2848            None
2849        );
2850        assert_eq!(
2851            decode_paired_payload(None, Some(&[])).expect("empty a"),
2852            None
2853        );
2854    }
2855
2856    #[test]
2857    fn paired_payload_invalid_bzz_errors() {
2858        // A non-empty `*z` chunk that is not valid BZZ must surface the error,
2859        // not be silently treated as missing.
2860        let result = decode_paired_payload(Some(&[0xff, 0x00, 0x13, 0x37]), None);
2861        assert!(result.is_err(), "invalid BZZ must error, got {result:?}");
2862    }
2863
2864    /// Build a minimal valid INFO chunk payload (10 bytes) for the given size.
2865    fn make_info(width: u16, height: u16) -> Vec<u8> {
2866        let mut v = Vec::with_capacity(10);
2867        v.extend_from_slice(&width.to_be_bytes());
2868        v.extend_from_slice(&height.to_be_bytes());
2869        v.extend_from_slice(&[0, 0]); // version bytes (unused here)
2870        v.extend_from_slice(&100u16.to_le_bytes()); // dpi (little-endian)
2871        v.push(22); // gamma byte → 2.2
2872        v.push(0); // flags → no rotation
2873        v
2874    }
2875
2876    /// Build a `DjVuPage` directly from hand-made chunks (INFO + extras), so the
2877    /// accessor can be tested without a full file round-trip through a parser.
2878    /// #605: repeated metadata access returns identical results through the
2879    /// cache, and the shared handles point at one allocation.
2880    #[test]
2881    fn metadata_cache_repeated_access_is_consistent() {
2882        let data = std::fs::read(
2883            std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/links.djvu"),
2884        )
2885        .unwrap();
2886        let doc = DjVuDocument::parse(&data).unwrap();
2887        let page = doc.page(0).unwrap();
2888
2889        let a1 = page.annotations().unwrap();
2890        let a2 = page.annotations().unwrap();
2891        assert_eq!(
2892            a1.as_ref().map(|(_, m)| m.len()),
2893            a2.as_ref().map(|(_, m)| m.len())
2894        );
2895        let s1 = page.annotations_shared().unwrap();
2896        let s2 = page.annotations_shared().unwrap();
2897        if let (Some(s1), Some(s2)) = (s1, s2) {
2898            assert!(
2899                std::sync::Arc::ptr_eq(&s1, &s2),
2900                "warm hits must share one decode"
2901            );
2902        }
2903        let h1 = page.hyperlinks().unwrap();
2904        let h2 = page.hyperlinks().unwrap();
2905        assert_eq!(h1.len(), h2.len());
2906    }
2907
2908    /// #605: malformed TXTz keeps erroring on every call (errors are not
2909    /// cached), matching the pre-cache behaviour.
2910    #[test]
2911    fn metadata_cache_does_not_cache_errors() {
2912        // TXTz payload that BZZ-decodes but fails structured parse — or fails
2913        // BZZ outright; either way both calls must return Err.
2914        let bogus = [0xFFu8, 0x00, 0x12, 0x34, 0x56];
2915        let page = page_with_chunks(&[(b"TXTz", &bogus)]);
2916        assert!(page.text_layer().is_err());
2917        assert!(
2918            page.text_layer().is_err(),
2919            "second call must error identically"
2920        );
2921    }
2922
2923    fn page_with_chunks(extra: &[(&[u8; 4], &[u8])]) -> DjVuPage {
2924        let info = make_info(64, 48);
2925        let mut chunks = Vec::new();
2926        chunks.push(IffChunk {
2927            id: *b"INFO",
2928            data: &info,
2929        });
2930        for (id, data) in extra {
2931            chunks.push(IffChunk { id: **id, data });
2932        }
2933        parse_page_from_chunks(&chunks, 0, None).expect("page should build")
2934    }
2935
2936    #[test]
2937    fn parse_with_options_rejects_exceeded_page_count_before_decode() {
2938        let data = fixture_bytes("boy.djvu");
2939        let err = DjVuDocument::parse_with_options(
2940            &data,
2941            &crate::resource_limits::ParseOptions {
2942                limits: Some(crate::resource_limits::ResourceLimits {
2943                    max_pages: Some(0),
2944                    ..Default::default()
2945                }),
2946            },
2947        )
2948        .expect_err("parse should fail on page-count limit");
2949        assert!(matches!(err, DocError::ResourceLimit(_)));
2950    }
2951
2952    #[test]
2953    fn parse_with_options_stores_limits_for_render_inheritance() {
2954        let data = fixture_bytes("boy.djvu");
2955        let limits = crate::resource_limits::ResourceLimits {
2956            max_render_pixels: Some(100_000),
2957            ..Default::default()
2958        };
2959        let doc = DjVuDocument::parse_with_options(
2960            &data,
2961            &crate::resource_limits::ParseOptions {
2962                limits: Some(limits),
2963            },
2964        )
2965        .expect("parse should succeed");
2966        assert_eq!(doc.resource_limits(), Some(limits));
2967        assert_eq!(doc.page(0).unwrap().resource_limits(), Some(limits));
2968    }
2969
2970    #[test]
2971    fn chunk_payload_decodes_compressed_txtz() {
2972        let raw = b"decoded text-layer payload".as_slice();
2973        let z = crate::bzz_encode::bzz_encode(raw);
2974        let page = page_with_chunks(&[(b"TXTz", &z)]);
2975        let out = page
2976            .chunk_payload(b"TXTz", b"TXTa")
2977            .expect("chunk_payload should succeed");
2978        assert_eq!(out.as_deref(), Some(raw));
2979    }
2980
2981    #[test]
2982    fn chunk_payload_passes_through_raw_txta() {
2983        let raw = b"raw text-layer payload".as_slice();
2984        let page = page_with_chunks(&[(b"TXTa", raw)]);
2985        let out = page
2986            .chunk_payload(b"TXTz", b"TXTa")
2987            .expect("chunk_payload should succeed");
2988        assert_eq!(out.as_deref(), Some(raw));
2989    }
2990
2991    #[test]
2992    fn chunk_payload_missing_chunk_is_none() {
2993        let page = page_with_chunks(&[]); // INFO only, no TXT* chunks
2994        let out = page
2995            .chunk_payload(b"TXTz", b"TXTa")
2996            .expect("chunk_payload should succeed");
2997        assert_eq!(out, None);
2998    }
2999
3000    /// Single-page document: no thumbnails expected.
3001    #[test]
3002    fn single_page_no_thumbnail() {
3003        let data =
3004            std::fs::read(assets_path().join("chicken.djvu")).expect("chicken.djvu must exist");
3005        let doc = DjVuDocument::parse(&data).expect("parse should succeed");
3006        let page = doc.page(0).expect("page 0 must exist");
3007        // Data is not decoded until thumbnail() is called — verify lazy contract
3008        let thumb = page.thumbnail().expect("thumbnail() should not error");
3009        assert!(
3010            thumb.is_none(),
3011            "single-page chicken.djvu has no TH44 chunks"
3012        );
3013    }
3014
3015    /// Single-page: dimensions helper.
3016    #[test]
3017    fn single_page_dimensions() {
3018        let data =
3019            std::fs::read(assets_path().join("chicken.djvu")).expect("chicken.djvu must exist");
3020        let doc = DjVuDocument::parse(&data).expect("parse should succeed");
3021        let page = doc.page(0).unwrap();
3022        assert_eq!(page.dimensions(), (181, 240));
3023    }
3024
3025    /// Bundled multi-page FORM:DJVM — page count and DIRM parsing.
3026    #[test]
3027    fn multipage_bundled_page_count() {
3028        let data = std::fs::read(assets_path().join("DjVu3Spec_bundled.djvu"))
3029            .expect("DjVu3Spec_bundled.djvu must exist");
3030        let doc = DjVuDocument::parse(&data).expect("bundled parse should succeed");
3031        // The bundled spec PDF has many pages — just check > 1
3032        assert!(
3033            doc.page_count() > 1,
3034            "bundled document should have more than 1 page, got {}",
3035            doc.page_count()
3036        );
3037    }
3038
3039    /// Bundled multi-page: each page should have valid metadata.
3040    #[test]
3041    fn multipage_bundled_page_metadata() {
3042        let data = std::fs::read(assets_path().join("DjVu3Spec_bundled.djvu"))
3043            .expect("DjVu3Spec_bundled.djvu must exist");
3044        let doc = DjVuDocument::parse(&data).expect("bundled parse should succeed");
3045
3046        let page0 = doc.page(0).expect("page 0 must exist");
3047        assert!(page0.width() > 0, "page width must be non-zero");
3048        assert!(page0.height() > 0, "page height must be non-zero");
3049        assert!(page0.dpi() > 0, "page dpi must be non-zero");
3050    }
3051
3052    /// NAVM bookmarks from a document that contains them.
3053    #[test]
3054    fn navm_bookmarks_present() {
3055        let data =
3056            std::fs::read(assets_path().join("navm_fgbz.djvu")).expect("navm_fgbz.djvu must exist");
3057        let doc = DjVuDocument::parse(&data).expect("parse should succeed");
3058        // navm_fgbz.djvu has NAVM chunk — should return at least one bookmark
3059        let bm = doc.bookmarks();
3060        assert!(
3061            !bm.is_empty(),
3062            "navm_fgbz.djvu should have at least one bookmark"
3063        );
3064    }
3065
3066    /// Documents without NAVM should return empty bookmark list.
3067    #[test]
3068    fn no_navm_returns_empty_bookmarks() {
3069        let data =
3070            std::fs::read(assets_path().join("chicken.djvu")).expect("chicken.djvu must exist");
3071        let doc = DjVuDocument::parse(&data).expect("parse should succeed");
3072        assert!(
3073            doc.bookmarks().is_empty(),
3074            "chicken.djvu has no NAVM — bookmarks should be empty"
3075        );
3076    }
3077
3078    /// Indirect document: parse with resolver callback.
3079    ///
3080    /// We simulate an indirect document by constructing a DJVM DIRM that marks
3081    /// entries as non-bundled and supplying a resolver that returns the bytes of
3082    /// the real chicken.djvu page.
3083    #[test]
3084    fn indirect_document_with_resolver() {
3085        // Load chicken.djvu — we'll use it as the "resolved" page.
3086        let chicken_data =
3087            std::fs::read(assets_path().join("chicken.djvu")).expect("chicken.djvu must exist");
3088        // Build a minimal indirect DJVM document referencing "chicken.djvu"
3089        let djvm_data = build_indirect_djvm_bytes("chicken.djvu");
3090
3091        let resolver = |name: &str| -> Result<Vec<u8>, DocError> {
3092            if name == "chicken.djvu" {
3093                Ok(chicken_data.clone())
3094            } else {
3095                Err(DocError::IndirectResolve(name.to_string()))
3096            }
3097        };
3098
3099        let doc = DjVuDocument::parse_with_resolver(&djvm_data, Some(resolver))
3100            .expect("indirect parse should succeed");
3101
3102        assert_eq!(doc.page_count(), 1);
3103        let page = doc.page(0).unwrap();
3104        assert_eq!(page.width(), 181);
3105        assert_eq!(page.height(), 240);
3106    }
3107
3108    /// Indirect document without resolver must return NoResolver error.
3109    #[test]
3110    fn indirect_document_no_resolver_returns_error() {
3111        let djvm_data = build_indirect_djvm_bytes("chicken.djvu");
3112        let err = DjVuDocument::parse(&djvm_data).expect_err("should fail without resolver");
3113        assert!(
3114            matches!(err, DocError::NoResolver),
3115            "expected NoResolver, got {err:?}"
3116        );
3117    }
3118
3119    /// Page must not decode image data before thumbnail() is called.
3120    ///
3121    /// We verify laziness by confirming that constructing the document and
3122    /// accessing `page()` without calling `thumbnail()` does not involve
3123    /// any IW44 decoder side-effects.  We test this by calling thumbnail()
3124    /// on a page with no TH44 chunks and verifying we get Ok(None).
3125    #[test]
3126    fn page_is_lazy_no_decode_before_thumbnail() {
3127        let data =
3128            std::fs::read(assets_path().join("boy_jb2.djvu")).expect("boy_jb2.djvu must exist");
3129        let doc = DjVuDocument::parse(&data).expect("parse should succeed");
3130        let page = doc.page(0).expect("page 0 must exist");
3131
3132        // Chunks are available (materialised on access for lazy pages) but no
3133        // IW44 decoding has happened yet.
3134        assert!(!page.chunk_slice().is_empty(), "chunks must be available");
3135
3136        // thumbnail() triggers decode — but there's no TH44 chunk in boy_jb2.djvu
3137        let thumb = page.thumbnail().expect("thumbnail() should not error");
3138        assert!(thumb.is_none());
3139    }
3140
3141    /// Non-DjVu file returns NotDjVu error.
3142    #[test]
3143    fn not_djvu_returns_error() {
3144        // Construct a valid IFF with a non-DjVu form type ("XXXX" + 4 dummy
3145        // bytes), routed through the emission seam.
3146        let data = crate::iff::partial_emit(*b"XXXX", &[crate::iff::EmitPart::Verbatim(b"XXXX")])
3147            .expect("fits within u32");
3148        let err = DjVuDocument::parse(&data).expect_err("should fail");
3149        assert!(
3150            matches!(err, DocError::NotDjVu(_) | DocError::Iff(_)),
3151            "expected NotDjVu or Iff error, got {err:?}"
3152        );
3153    }
3154
3155    // ---- Helpers: build minimal DJVM documents for indirect tests -----------
3156
3157    /// Build a minimal indirect FORM:DJVM with 1 page component named "chicken.djvu".
3158    ///
3159    /// DIRM format: flags=0x00 (not bundled), nfiles=1, followed by BZZ-compressed
3160    /// metadata. The BZZ bytes below were pre-computed using the reference `bzz -e`
3161    /// tool encoding the metadata:
3162    ///   `\x00\x00\x00` (size, 3 bytes) + `\x01` (Page flag) + `chicken.djvu\x00`
3163    fn build_indirect_djvm_bytes(_page_name: &str) -> Vec<u8> {
3164        // BZZ-encoded DIRM metadata for 1 Page component named "chicken.djvu".
3165        // Generated with: printf '\x00\x00\x00\x01chicken.djvu\x00' | bzz -e - file.bzz
3166        // Verified to decode back to the original 17-byte meta block.
3167        let bzz_meta: &[u8] = &[
3168            0xff, 0xff, 0xed, 0xbf, 0x8a, 0x1f, 0xbe, 0xad, 0x14, 0x57, 0x10, 0xc9, 0x63, 0x19,
3169            0x11, 0xf0, 0x85, 0x28, 0x12, 0x8a, 0xbf,
3170        ];
3171
3172        let mut dirm_data = Vec::new();
3173        dirm_data.push(0x00); // flags: not bundled (is_bundled bit = 0)
3174        dirm_data.push(0x00); // nfiles high byte
3175        dirm_data.push(0x01); // nfiles low byte = 1
3176        dirm_data.extend_from_slice(bzz_meta);
3177
3178        build_djvm_with_dirm(&dirm_data)
3179    }
3180
3181    fn build_djvm_with_dirm(dirm_data: &[u8]) -> Vec<u8> {
3182        // A FORM:DJVM carrying a single DIRM chunk, built through the seam.
3183        let dirm = crate::iff::Chunk::Leaf {
3184            id: *b"DIRM",
3185            data: dirm_data.to_vec(),
3186        };
3187        crate::iff::partial_emit(*b"DJVM", &[crate::iff::EmitPart::Chunk(&dirm)])
3188            .expect("fits within u32")
3189    }
3190
3191    /// Sub-FORM with < 4 bytes of data: parse_sub_form returns Malformed (line 1225).
3192    #[test]
3193    fn parse_bundled_djvm_with_short_sub_form_returns_malformed() {
3194        use crate::dirm::DirmPayload;
3195        // Bundled DIRM with 1 Page entry (flags=0x80 = bundled, flag=0x01=Page)
3196        let dirm_payload = DirmPayload::build_bundled(1, &[0x01], &["p0001.djvu".to_string()], &[]);
3197        let dirm = crate::iff::Chunk::Leaf {
3198            id: *b"DIRM",
3199            data: dirm_payload.encode(),
3200        };
3201        // Short sub-FORM: FORM ID (4 bytes) + length=2 (4 bytes) + 2 data bytes
3202        // When the IFF parser reads this, data.len() = 2 < 4 → parse_sub_form Err
3203        let short_form_bytes: &[u8] = b"FORM\x00\x00\x00\x02AB";
3204        let djvm = crate::iff::partial_emit(
3205            *b"DJVM",
3206            &[
3207                crate::iff::EmitPart::Chunk(&dirm),
3208                crate::iff::EmitPart::Verbatim(short_form_bytes),
3209            ],
3210        )
3211        .expect("fits within u32");
3212
3213        let err = DjVuDocument::parse(&djvm).expect_err("short sub-form must error");
3214        assert!(
3215            matches!(err, DocError::Malformed(_)),
3216            "expected Malformed, got {err:?}"
3217        );
3218    }
3219
3220    // ── raw chunk API (Issue #43) ────────────────────────────────────────────
3221
3222    /// `DjVuPage::raw_chunk` returns bytes for known chunk types.
3223    #[test]
3224    fn page_raw_chunk_info_present() {
3225        let data =
3226            std::fs::read(assets_path().join("chicken.djvu")).expect("chicken.djvu must exist");
3227        let doc = DjVuDocument::parse(&data).expect("parse must succeed");
3228        let page = doc.page(0).expect("page 0 must exist");
3229
3230        // INFO chunk must be present
3231        let info = page.raw_chunk(b"INFO").expect("INFO chunk must be present");
3232        assert_eq!(info.len(), 10, "INFO chunk is always 10 bytes");
3233    }
3234
3235    /// `DjVuPage::raw_chunk` returns None for absent chunk types.
3236    #[test]
3237    fn page_raw_chunk_absent() {
3238        let data =
3239            std::fs::read(assets_path().join("chicken.djvu")).expect("chicken.djvu must exist");
3240        let doc = DjVuDocument::parse(&data).expect("parse must succeed");
3241        let page = doc.page(0).expect("page 0 must exist");
3242
3243        assert!(
3244            page.raw_chunk(b"XXXX").is_none(),
3245            "unknown chunk type must return None"
3246        );
3247    }
3248
3249    /// `DjVuPage::all_chunks` returns multiple BG44 chunks in order.
3250    #[test]
3251    fn page_all_chunks_bg44_multiple() {
3252        // big-scanned-page.djvu has 4 progressive BG44 chunks
3253        let data = std::fs::read(
3254            std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
3255                .join("tests/fixtures/big-scanned-page.djvu"),
3256        )
3257        .expect("big-scanned-page.djvu must exist");
3258        let doc = DjVuDocument::parse(&data).expect("parse must succeed");
3259        let page = doc.page(0).expect("page 0 must exist");
3260
3261        let bg44 = page.all_chunks(b"BG44");
3262        assert!(
3263            bg44.len() >= 2,
3264            "colour page must have ≥2 BG44 chunks, got {}",
3265            bg44.len()
3266        );
3267
3268        // Chunks must be non-empty
3269        for (i, chunk) in bg44.iter().enumerate() {
3270            assert!(!chunk.is_empty(), "BG44 chunk {i} must not be empty");
3271        }
3272    }
3273
3274    /// `DjVuPage::chunk_ids` lists all chunk IDs in order.
3275    #[test]
3276    fn page_chunk_ids_includes_info() {
3277        let data =
3278            std::fs::read(assets_path().join("chicken.djvu")).expect("chicken.djvu must exist");
3279        let doc = DjVuDocument::parse(&data).expect("parse must succeed");
3280        let page = doc.page(0).expect("page 0 must exist");
3281
3282        let ids = page.chunk_ids();
3283        assert!(!ids.is_empty(), "chunk_ids must not be empty");
3284        assert!(
3285            ids.contains(b"INFO"),
3286            "chunk_ids must include INFO, got: {:?}",
3287            ids.iter()
3288                .map(|id| std::str::from_utf8(id).unwrap_or("????"))
3289                .collect::<Vec<_>>()
3290        );
3291    }
3292
3293    /// `DjVuDocument::raw_chunk` works for single-page DJVU files.
3294    #[test]
3295    fn document_raw_chunk_single_page() {
3296        let data =
3297            std::fs::read(assets_path().join("chicken.djvu")).expect("chicken.djvu must exist");
3298        let doc = DjVuDocument::parse(&data).expect("parse must succeed");
3299
3300        // Single-page DJVU exposes all top-level chunks at document level too
3301        let info = doc
3302            .raw_chunk(b"INFO")
3303            .expect("document must expose INFO chunk");
3304        assert_eq!(info.len(), 10);
3305    }
3306
3307    // ── DJVI shared dictionary / INCL chunks (Issue #45) ────────────────────
3308
3309    /// DjVu3Spec_bundled.djvu has shared DJVI symbol dictionaries.
3310    /// Parsing must succeed and pages with INCL references must carry the dict.
3311    #[test]
3312    fn djvi_shared_dict_parsed_from_bundled_djvm() {
3313        let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
3314            .join("tests/fixtures/DjVu3Spec_bundled.djvu");
3315        let data = std::fs::read(&path).expect("DjVu3Spec_bundled.djvu must exist");
3316        let doc = DjVuDocument::parse(&data).expect("parse must succeed");
3317
3318        assert!(doc.page_count() > 0, "document must have pages");
3319
3320        // At least one page should have a shared dict loaded (shared_djbz Some)
3321        let pages_with_dict = doc.pages.iter().filter(|p| p.shared_djbz.is_some()).count();
3322        assert!(
3323            pages_with_dict > 0,
3324            "at least one page must have a resolved shared DJVI dict"
3325        );
3326    }
3327
3328    /// Pages with INCL references must render their mask without error.
3329    #[test]
3330    fn djvi_incl_page_mask_renders_ok() {
3331        let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
3332            .join("tests/fixtures/DjVu3Spec_bundled.djvu");
3333        let data = std::fs::read(&path).expect("DjVu3Spec_bundled.djvu must exist");
3334        let doc = DjVuDocument::parse(&data).expect("parse must succeed");
3335
3336        // Find first page with a shared dict and render its mask
3337        let page = doc
3338            .pages
3339            .iter()
3340            .find(|p| p.shared_djbz.is_some())
3341            .expect("at least one page must have a shared dict");
3342
3343        let mask = page
3344            .extract_mask()
3345            .expect("extract_mask must succeed for INCL page");
3346        assert!(mask.is_some(), "INCL page must have a JB2 mask");
3347        let bm = mask.unwrap();
3348        assert!(
3349            bm.width > 0 && bm.height > 0,
3350            "mask must have non-zero dimensions"
3351        );
3352    }
3353
3354    /// `extract_mask_sub4` must be bit-for-bit identical to decoding the full
3355    /// mask and then max-pool-downsampling it by 4 (round 89 follow-up: this
3356    /// is what lets the thumbnail path skip the full-resolution JB2 canvas).
3357    #[test]
3358    fn mask_sub4_matches_extract_mask_then_downsample() {
3359        let data = std::fs::read(
3360            std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
3361                .join("tests/fixtures/boy_jb2.djvu"),
3362        )
3363        .expect("boy_jb2.djvu must exist");
3364        let doc = DjVuDocument::parse(&data).expect("parse must succeed");
3365        let page = doc.page(0).expect("page 0 must exist");
3366
3367        let full = page
3368            .extract_mask()
3369            .expect("extract_mask must succeed")
3370            .expect("boy_jb2.djvu page must have a JB2 mask");
3371        let expected = crate::djvu_render::downsample_mask_4x(&full);
3372
3373        let actual = page
3374            .extract_mask_sub4()
3375            .expect("extract_mask_sub4 must succeed")
3376            .expect("boy_jb2.djvu page must have a JB2 mask");
3377
3378        assert_eq!(expected.width, actual.width);
3379        assert_eq!(expected.height, actual.height);
3380        assert_eq!(expected.data, actual.data, "sub4 mask mismatch");
3381    }
3382
3383    /// Same equivalence check on a page with a shared dictionary (INCL /
3384    /// Djbz), which `extract_mask_sub4` resolves the same way `extract_mask`
3385    /// does before decoding.
3386    #[test]
3387    fn mask_sub4_matches_extract_mask_then_downsample_shared_dict() {
3388        let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
3389            .join("tests/fixtures/DjVu3Spec_bundled.djvu");
3390        let data = std::fs::read(&path).expect("DjVu3Spec_bundled.djvu must exist");
3391        let doc = DjVuDocument::parse(&data).expect("parse must succeed");
3392        let page = doc
3393            .pages
3394            .iter()
3395            .find(|p| p.shared_djbz.is_some())
3396            .expect("at least one page must have a shared dict");
3397
3398        let full = page
3399            .extract_mask()
3400            .expect("extract_mask must succeed")
3401            .expect("page must have a JB2 mask");
3402        let expected = crate::djvu_render::downsample_mask_4x(&full);
3403
3404        let actual = page
3405            .extract_mask_sub4()
3406            .expect("extract_mask_sub4 must succeed")
3407            .expect("page must have a JB2 mask");
3408
3409        assert_eq!(expected.width, actual.width);
3410        assert_eq!(expected.height, actual.height);
3411        assert_eq!(expected.data, actual.data, "sub4 mask mismatch");
3412    }
3413
3414    /// Pages without INCL still render correctly (no regression).
3415    #[test]
3416    fn no_regression_non_incl_pages() {
3417        // boy_jb2.djvu has a Sjbz mask and no INCL reference
3418        let data = std::fs::read(
3419            std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
3420                .join("tests/fixtures/boy_jb2.djvu"),
3421        )
3422        .expect("boy_jb2.djvu must exist");
3423        let doc = DjVuDocument::parse(&data).expect("parse must succeed");
3424        let page = doc.page(0).expect("page 0 must exist");
3425        assert!(
3426            page.shared_djbz.is_none(),
3427            "single-page DJVU has no shared dict"
3428        );
3429        let mask = page.extract_mask().expect("extract_mask must succeed");
3430        assert!(mask.is_some(), "boy_jb2.djvu page must have a JB2 mask");
3431    }
3432
3433    /// `carte.djvu` has a 5-byte INFO chunk (width, height, version byte —
3434    /// no dpi/gamma/flags) instead of the canonical 10-byte layout. The file
3435    /// itself is intact (byte-exact IFF framing; `djvudump`/`ddjvu` from
3436    /// DjVuLibre parse and render it without complaint), so `DjVuDocument::parse`
3437    /// rejecting it as `Iff(Truncated)` was a parser-strictness bug, not a
3438    /// corrupt fixture. Regression test for that bug (see `info.rs`'s
3439    /// `carte_style_five_byte_info_parses_with_defaults` for the unit-level
3440    /// check).
3441    #[test]
3442    fn parse_carte_with_short_info_chunk() {
3443        let data = std::fs::read(
3444            std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/carte.djvu"),
3445        )
3446        .expect("carte.djvu must exist");
3447        let doc = DjVuDocument::parse(&data).expect("carte.djvu must parse despite short INFO");
3448        assert_eq!(doc.page_count(), 1);
3449        let page = doc.page(0).expect("page 0 must exist");
3450        assert_eq!(page.width(), 4200);
3451        assert_eq!(page.height(), 2556);
3452    }
3453
3454    /// Round-trip: bytes from `raw_chunk` re-parse to the same metadata.
3455    #[test]
3456    fn page_raw_chunk_info_roundtrip() {
3457        let data =
3458            std::fs::read(assets_path().join("chicken.djvu")).expect("chicken.djvu must exist");
3459        let doc = DjVuDocument::parse(&data).expect("parse must succeed");
3460        let page = doc.page(0).expect("page 0 must exist");
3461
3462        let raw_info = page.raw_chunk(b"INFO").expect("INFO chunk must be present");
3463        let reparsed = crate::info::PageInfo::parse(raw_info).expect("re-parse must succeed");
3464        assert_eq!(reparsed.width, page.width() as u16);
3465        assert_eq!(reparsed.height, page.height() as u16);
3466        assert_eq!(reparsed.dpi, page.dpi());
3467    }
3468
3469    // ── #196 Phase 2: page_byte_range ────────────────────────────────────────
3470
3471    /// Single-page DJVU: byte range covers the entire input buffer.
3472    #[test]
3473    fn page_byte_range_single_page_covers_full_buffer() {
3474        let data =
3475            std::fs::read(assets_path().join("chicken.djvu")).expect("chicken.djvu must exist");
3476        let doc = DjVuDocument::parse(&data).expect("parse must succeed");
3477
3478        let r = doc.page_byte_range(0).expect("page 0 must have a range");
3479        assert_eq!(r.start, 0);
3480        assert_eq!(r.end, data.len() as u64);
3481
3482        assert!(
3483            doc.page_byte_range(1).is_none(),
3484            "out-of-range index returns None"
3485        );
3486    }
3487
3488    /// Bundled DJVM: every page's byte range is non-empty, in-bounds,
3489    /// non-overlapping with neighbours, and re-parseable as a FORM.
3490    #[test]
3491    fn page_byte_range_bundled_djvm_round_trips() {
3492        let path = assets_path().join("DjVu3Spec_bundled.djvu");
3493        let Ok(data) = std::fs::read(&path) else {
3494            eprintln!("skip: {} missing", path.display());
3495            return;
3496        };
3497        let doc = DjVuDocument::parse(&data).expect("bundled DJVM parse must succeed");
3498
3499        let mut prev_end = 0u64;
3500        for i in 0..doc.page_count() {
3501            let r = doc
3502                .page_byte_range(i)
3503                .unwrap_or_else(|| panic!("page {i} must have a range"));
3504            assert!(r.end <= data.len() as u64, "page {i} range OOB");
3505            assert!(r.start < r.end, "page {i} range empty");
3506            assert!(r.start >= prev_end, "page {i} overlaps previous");
3507            prev_end = r.end;
3508
3509            // The range must start with `b"FORM"` magic.
3510            let slice = &data[r.start as usize..r.end as usize];
3511            assert_eq!(&slice[..4], b"FORM", "page {i} range must start with FORM");
3512        }
3513    }
3514
3515    #[test]
3516    fn page_thumbnail_with_th44_data() {
3517        // Extract real TH44 chunk bytes from carte.djvu (which contains TH44 data)
3518        // and embed them in a synthetic page to cover the thumbnail decode path.
3519        let carte = std::fs::read(assets_path().join("carte.djvu")).unwrap();
3520        // Find TH44 in the raw bytes and extract chunk payload
3521        let th44_pos = carte.windows(4).position(|w| w == b"TH44");
3522        if let Some(pos) = th44_pos
3523            && pos + 8 <= carte.len()
3524        {
3525            let chunk_len = u32::from_be_bytes([
3526                carte[pos + 4],
3527                carte[pos + 5],
3528                carte[pos + 6],
3529                carte[pos + 7],
3530            ]) as usize;
3531            let chunk_data = carte.get(pos + 8..pos + 8 + chunk_len).unwrap_or(&[]);
3532            if !chunk_data.is_empty() {
3533                let page = page_with_chunks(&[(b"TH44", chunk_data)]);
3534                // This should decode successfully (covers lines 298-303)
3535                let thumb = page.thumbnail();
3536                assert!(thumb.is_ok(), "thumbnail decode should not error");
3537                // The thumbnail may or may not be Some depending on IW44 data validity
3538            }
3539        }
3540    }
3541
3542    #[test]
3543    fn extract_mask_from_smmr_chunk() {
3544        // Build a page with an Smmr chunk (G4/MMR-encoded mask). This covers the
3545        // Smmr decode path in extract_mask() (lines 545-546).
3546        use crate::chunk_encode::{ChunkEncoder, SmmrChunk};
3547        let mut bm = crate::bitmap::Bitmap::new(8, 8);
3548        bm.set_black(2, 2);
3549        let smmr_chunk = SmmrChunk(&bm).encode_chunk().unwrap();
3550        let page = page_with_chunks(&[(b"Smmr", &smmr_chunk.payload)]);
3551        let result = page.extract_mask().unwrap();
3552        assert!(result.is_some(), "Smmr page should have a mask");
3553        assert_eq!(result.unwrap().width, 8);
3554    }
3555
3556    #[test]
3557    fn extract_background_returns_none_for_jb2_only_page() {
3558        // A page with only Sjbz (no BG44) → extract_background returns Ok(None)
3559        // This covers lines 638-641 in djvu_document.rs.
3560        let jb2_data = std::fs::read(assets_path().join("boy_jb2.djvu")).unwrap();
3561        let doc = DjVuDocument::parse(&jb2_data).unwrap();
3562        let page = doc.page(0).unwrap();
3563        let bg = page.extract_background().unwrap();
3564        assert!(bg.is_none(), "JB2-only page should have no background");
3565    }
3566
3567    #[test]
3568    fn extract_mask_indexed_smmr_path() {
3569        // Page with Smmr chunk: extract_mask_indexed takes the Smmr path (lines 570-575).
3570        use crate::chunk_encode::{ChunkEncoder, SmmrChunk};
3571        let mut bm = crate::bitmap::Bitmap::new(4, 4);
3572        bm.set_black(1, 1);
3573        let smmr_chunk = SmmrChunk(&bm).encode_chunk().unwrap();
3574        let page = page_with_chunks(&[(b"Smmr", &smmr_chunk.payload)]);
3575        let result = page.extract_mask_indexed().unwrap();
3576        assert!(result.is_some());
3577        let (mask, indices) = result.unwrap();
3578        assert_eq!(mask.width, 4);
3579        assert_eq!(indices.len(), 4 * 4);
3580    }
3581
3582    #[test]
3583    fn extract_mask_indexed_no_chunks_returns_none() {
3584        // Page with no Sjbz or Smmr → Ok(None) (line 588).
3585        let page = page_with_chunks(&[]);
3586        let result = page.extract_mask_indexed().unwrap();
3587        assert!(result.is_none());
3588    }
3589
3590    #[test]
3591    fn extract_background_decodes_iw44_from_color_page() {
3592        // chicken.djvu has BG44 → extract_background decodes IW44 (lines 644-649).
3593        let data = std::fs::read(assets_path().join("chicken.djvu")).unwrap();
3594        let doc = DjVuDocument::parse(&data).unwrap();
3595        let page = doc.page(0).unwrap();
3596        let bg = page.extract_background().unwrap();
3597        assert!(bg.is_some(), "chicken.djvu page should have a background");
3598        let pm = bg.unwrap();
3599        assert!(pm.width > 0 && pm.height > 0);
3600    }
3601
3602    #[test]
3603    fn djvu_page_debug_impl_does_not_panic() {
3604        let data = std::fs::read(assets_path().join("chicken.djvu")).unwrap();
3605        let doc = DjVuDocument::parse(&data).unwrap();
3606        let page = doc.page(0).unwrap();
3607        let s = format!("{page:?}");
3608        assert!(s.contains("DjVuPage"));
3609    }
3610
3611    #[test]
3612    fn page_index_returns_zero_for_first_page() {
3613        let data = std::fs::read(assets_path().join("chicken.djvu")).unwrap();
3614        let doc = DjVuDocument::parse(&data).unwrap();
3615        let page = doc.page(0).unwrap();
3616        assert_eq!(page.index(), 0);
3617    }
3618
3619    #[test]
3620    fn page_text_returns_some_for_text_page() {
3621        let data = std::fs::read(assets_path().join("colorbook.djvu")).unwrap();
3622        let doc = DjVuDocument::parse(&data).unwrap();
3623        let page = doc.page(0).unwrap();
3624        let t = page.text().unwrap();
3625        assert!(t.is_some(), "colorbook page 0 should have text");
3626    }
3627
3628    /// Out-of-range page index returns None.
3629    #[test]
3630    fn page_byte_range_out_of_range() {
3631        let data =
3632            std::fs::read(assets_path().join("chicken.djvu")).expect("chicken.djvu must exist");
3633        let doc = DjVuDocument::parse(&data).expect("parse must succeed");
3634        assert!(doc.page_byte_range(99).is_none());
3635    }
3636
3637    /// MmapDocument opens a file and parses identically to in-memory parse.
3638    #[test]
3639    #[cfg(feature = "mmap")]
3640    fn mmap_document_matches_parse() {
3641        let path = assets_path().join("chicken.djvu");
3642        let mmap_doc = MmapDocument::open(&path).expect("mmap open should succeed");
3643        let data = std::fs::read(&path).expect("read should succeed");
3644        let mem_doc = DjVuDocument::parse(&data).expect("parse should succeed");
3645
3646        assert_eq!(mmap_doc.page_count(), mem_doc.page_count());
3647        for i in 0..mmap_doc.page_count() {
3648            let mp = mmap_doc.page(i).unwrap();
3649            let pp = mem_doc.page(i).unwrap();
3650            assert_eq!(mp.width(), pp.width());
3651            assert_eq!(mp.height(), pp.height());
3652            assert_eq!(mp.dpi(), pp.dpi());
3653        }
3654    }
3655
3656    #[test]
3657    fn extract_foreground_returns_none_when_no_fg44() {
3658        // JB2-only page has no FG44 chunks — extract_foreground returns Ok(None).
3659        let data = std::fs::read(assets_path().join("boy_jb2.djvu")).unwrap();
3660        let doc = DjVuDocument::parse(&data).unwrap();
3661        let fg = doc.page(0).unwrap().extract_foreground().unwrap();
3662        assert!(fg.is_none());
3663    }
3664
3665    #[test]
3666    fn metadata_returns_none_for_doc_without_meta_chunk() {
3667        let data = std::fs::read(assets_path().join("chicken.djvu")).unwrap();
3668        let doc = DjVuDocument::parse(&data).unwrap();
3669        let meta = doc.metadata().unwrap();
3670        // chicken.djvu has no METa/METz chunk
3671        assert!(meta.is_none());
3672    }
3673
3674    #[test]
3675    fn all_chunks_returns_matching_chunks() {
3676        let data = std::fs::read(assets_path().join("chicken.djvu")).unwrap();
3677        let doc = DjVuDocument::parse(&data).unwrap();
3678        // INFO is a global chunk for single-page DJVU
3679        let info = doc.all_chunks(b"INFO");
3680        assert!(!info.is_empty());
3681        // Non-existent chunk returns empty
3682        let none = doc.all_chunks(b"XXXX");
3683        assert!(none.is_empty());
3684    }
3685
3686    #[test]
3687    fn chunk_ids_returns_nonempty_for_djvu() {
3688        let data = std::fs::read(assets_path().join("chicken.djvu")).unwrap();
3689        let doc = DjVuDocument::parse(&data).unwrap();
3690        let ids = doc.chunk_ids();
3691        assert!(!ids.is_empty());
3692    }
3693
3694    #[test]
3695    #[cfg(feature = "mmap")]
3696    fn mmap_open_indirect_on_bundled_doc_succeeds() {
3697        let path = assets_path().join("chicken.djvu");
3698        let doc = MmapDocument::open_indirect(&path).expect("open_indirect should work on bundled");
3699        assert!(doc.page_count() > 0);
3700    }
3701
3702    #[test]
3703    #[cfg(feature = "mmap")]
3704    fn mmap_document_method_and_deref_are_reachable() {
3705        let path = assets_path().join("chicken.djvu");
3706        let mmap_doc = MmapDocument::open(&path).expect("mmap open should succeed");
3707        // document() accessor (line 1128-1129)
3708        assert!(mmap_doc.document().page_count() > 0);
3709        // Deref to &DjVuDocument (lines 1146-1147)
3710        let inner: &DjVuDocument = &mmap_doc;
3711        assert!(inner.page_count() > 0);
3712    }
3713
3714    /// `advise_page_willneed` is a best-effort hint: it must not error on a
3715    /// real bundled document and must be a harmless no-op for an
3716    /// out-of-range index (COLD_OPEN B6).
3717    #[test]
3718    #[cfg(all(feature = "mmap", unix))]
3719    fn mmap_advise_page_willneed_in_range_and_out_of_range() {
3720        let path = assets_path().join("chicken.djvu");
3721        let mmap_doc = MmapDocument::open(&path).expect("mmap open should succeed");
3722        mmap_doc
3723            .advise_page_willneed(0)
3724            .expect("advise on page 0 should not error");
3725        // Out of range: page_byte_range returns None, so this must be a no-op
3726        // Ok(()), not an error.
3727        mmap_doc
3728            .advise_page_willneed(9_999)
3729            .expect("advise on an out-of-range page must be a harmless no-op");
3730    }
3731
3732    /// `into_document` must yield a document that still renders correctly —
3733    /// the lazily-constructed pages hold their own `Arc` clone of the
3734    /// mapping, so dropping `MmapDocument`'s own reference must not unmap the
3735    /// file out from under them (COLD_OPEN B6/B7 prerequisite).
3736    #[test]
3737    #[cfg(feature = "mmap")]
3738    fn mmap_into_document_pages_still_render_after_wrapper_dropped() {
3739        let path = assets_path().join("chicken.djvu");
3740        let mmap_doc = MmapDocument::open(&path).expect("mmap open should succeed");
3741        let doc = mmap_doc.into_document();
3742        let page = doc.page(0).expect("page 0 should exist");
3743        let pm = crate::djvu_render::render_pixmap(
3744            page,
3745            &crate::djvu_render::RenderOptions {
3746                width: page.width() as u32,
3747                height: page.height() as u32,
3748                ..crate::djvu_render::RenderOptions::default()
3749            },
3750        )
3751        .expect("render after into_document should succeed");
3752        assert!(pm.width > 0 && pm.height > 0);
3753    }
3754
3755    /// `prefetch_page` must actually warm the page's render caches before the
3756    /// caller does a synchronous render (COLD_OPEN B7). Not a timing
3757    /// assertion (that's what the `cold_open_bench` example measures) — just
3758    /// correctness: the background decode must land in the same cache a
3759    /// subsequent `render_pixmap` reads, and out-of-range indices must be a
3760    /// no-op rather than a panic.
3761    #[test]
3762    #[cfg(all(feature = "mmap", feature = "parallel"))]
3763    fn prefetch_page_warms_cache_and_ignores_out_of_range() {
3764        let path = assets_path().join("chicken.djvu");
3765        let mmap_doc = MmapDocument::open(&path).expect("mmap open should succeed");
3766        let doc = Arc::new(mmap_doc.into_document());
3767
3768        doc.prefetch_page(9_999); // out of range: must not panic
3769        doc.prefetch_page(0);
3770
3771        // Give the background task a moment to finish (this test only checks
3772        // correctness, not latency — a generous sleep avoids flakiness).
3773        std::thread::sleep(std::time::Duration::from_millis(200));
3774
3775        let page = doc.page(0).unwrap();
3776        // Cache should already be warm: render_layers() bytes > 0 without us
3777        // having called any decoded_* accessor on this thread ourselves.
3778        assert!(
3779            page.render_cache_bytes() > 0,
3780            "prefetch_page should have populated the render cache"
3781        );
3782
3783        // A subsequent render must still succeed and be unaffected.
3784        let pm = crate::djvu_render::render_pixmap(
3785            page,
3786            &crate::djvu_render::RenderOptions {
3787                width: page.width() as u32,
3788                height: page.height() as u32,
3789                ..crate::djvu_render::RenderOptions::default()
3790            },
3791        )
3792        .expect("render after prefetch should succeed");
3793        assert!(pm.width > 0 && pm.height > 0);
3794    }
3795
3796    #[test]
3797    fn metadata_returns_some_for_doc_with_meta_chunk() {
3798        // Build a synthetic FORM:DJVU containing an INFO chunk and a METa chunk.
3799        use crate::iff::{Chunk, DjvuFile, emit};
3800        use crate::metadata::{DjVuMetadata, encode_metadata};
3801
3802        let info = make_info(100, 100);
3803        let meta = DjVuMetadata {
3804            author: Some("TestAuthor".into()),
3805            ..DjVuMetadata::default()
3806        };
3807        let meta_bytes = encode_metadata(&meta);
3808        if meta_bytes.is_empty() {
3809            return; // encode returned empty — nothing to test
3810        }
3811
3812        let file = DjvuFile {
3813            root: Chunk::Form {
3814                secondary_id: *b"DJVU",
3815                length: 0, // emit recalculates
3816                children: vec![
3817                    Chunk::Leaf {
3818                        id: *b"INFO",
3819                        data: info,
3820                    },
3821                    Chunk::Leaf {
3822                        id: *b"METa",
3823                        data: meta_bytes,
3824                    },
3825                ],
3826            },
3827        };
3828        let bytes = emit(&file);
3829        let doc = DjVuDocument::parse(&bytes).expect("parse should succeed");
3830        let m = doc.metadata().expect("metadata() should not error");
3831        assert!(
3832            m.is_some(),
3833            "metadata should be Some for a doc with METa chunk"
3834        );
3835        assert_eq!(m.unwrap().author.as_deref(), Some("TestAuthor"));
3836    }
3837
3838    #[test]
3839    fn extract_mask_uses_inline_djbz_when_present() {
3840        // Build a page with both Sjbz (using shared shapes) and an inline Djbz.
3841        // This hits the `find_chunk(b"Djbz")` branch in extract_mask (lines 535-537).
3842        use crate::jb2_encode::{
3843            cluster_shared_symbols, encode_jb2_dict_with_shared, encode_jb2_djbz,
3844        };
3845
3846        let mut shape = crate::bitmap::Bitmap::new(8, 8);
3847        shape.set_black(2, 2);
3848        shape.set_black(3, 3);
3849        let shapes = cluster_shared_symbols(&[shape.clone(), shape.clone()], 2);
3850        if shapes.is_empty() {
3851            return; // no shared shapes; skip
3852        }
3853        let djbz_data = encode_jb2_djbz(&shapes);
3854        let sjbz_data = encode_jb2_dict_with_shared(&shape, &shapes);
3855
3856        let page = page_with_chunks(&[(b"Djbz", &djbz_data), (b"Sjbz", &sjbz_data)]);
3857        let result = page.extract_mask();
3858        assert!(
3859            result.is_ok(),
3860            "extract_mask with inline Djbz should succeed"
3861        );
3862    }
3863
3864    #[test]
3865    fn extract_mask_indexed_uses_inline_djbz_when_present() {
3866        // Same as above but for extract_mask_indexed (lines 561-563).
3867        use crate::jb2_encode::{
3868            cluster_shared_symbols, encode_jb2_dict_with_shared, encode_jb2_djbz,
3869        };
3870
3871        let mut shape = crate::bitmap::Bitmap::new(8, 8);
3872        shape.set_black(2, 2);
3873        shape.set_black(3, 3);
3874        let shapes = cluster_shared_symbols(&[shape.clone(), shape.clone()], 2);
3875        if shapes.is_empty() {
3876            return;
3877        }
3878        let djbz_data = encode_jb2_djbz(&shapes);
3879        let sjbz_data = encode_jb2_dict_with_shared(&shape, &shapes);
3880
3881        let page = page_with_chunks(&[(b"Djbz", &djbz_data), (b"Sjbz", &sjbz_data)]);
3882        let result = page.extract_mask_indexed();
3883        assert!(
3884            result.is_ok(),
3885            "extract_mask_indexed with inline Djbz should succeed"
3886        );
3887    }
3888
3889    /// NAVM with BZZ-decoded payload shorter than 2 bytes returns Ok([]).
3890    #[test]
3891    fn parse_navm_bookmarks_short_decoded_returns_empty() {
3892        use crate::bzz_encode::bzz_encode;
3893        // Encode a single byte — decoded is 1 byte < 2 → line 1248
3894        let bzz = bzz_encode(b"x");
3895        let chunk = crate::iff::IffChunk {
3896            id: *b"NAVM",
3897            data: &bzz,
3898        };
3899        let result = parse_navm_bookmarks(&[chunk]).unwrap();
3900        assert!(
3901            result.is_empty(),
3902            "NAVM with decoded < 2 bytes must yield empty bookmarks"
3903        );
3904    }
3905
3906    /// NAVM with total_count > 0 but no actual entries → truncated entry error.
3907    #[test]
3908    fn parse_navm_bookmarks_truncated_entry_returns_error() {
3909        use crate::bzz_encode::bzz_encode;
3910        // Declare total_count = 1 (2 bytes) but no bookmark data follows → line 1281
3911        let payload = vec![0x00, 0x01]; // total_count = 1
3912        let bzz = bzz_encode(&payload);
3913        let chunk = crate::iff::IffChunk {
3914            id: *b"NAVM",
3915            data: &bzz,
3916        };
3917        let result = parse_navm_bookmarks(&[chunk]);
3918        assert!(
3919            result.is_err(),
3920            "NAVM with declared count > 0 but no entry data must error"
3921        );
3922    }
3923
3924    /// NAVM bookmark title with CP1252 bytes (0x96 en dash — DjVuLibre on
3925    /// Windows) must decode leniently instead of aborting the open (#524).
3926    #[test]
3927    fn parse_navm_bookmarks_cp1252_title_is_lenient() {
3928        use crate::bzz_encode::bzz_encode;
3929        // [total_count u16 = 1][n_children u8 = 0]
3930        // [title: u24 len + bytes][url: u24 len + bytes]
3931        let title = b"Chapter 1 \x96 Intro";
3932        let mut payload = vec![0x00, 0x01, 0x00];
3933        payload.extend_from_slice(&[0x00, 0x00, title.len() as u8]);
3934        payload.extend_from_slice(title);
3935        payload.extend_from_slice(&[0x00, 0x00, 0x02]);
3936        payload.extend_from_slice(b"#1");
3937        let bzz = bzz_encode(&payload);
3938        let chunk = crate::iff::IffChunk {
3939            id: *b"NAVM",
3940            data: &bzz,
3941        };
3942        let bookmarks = parse_navm_bookmarks(&[chunk]).expect("CP1252 title must not abort");
3943        assert_eq!(bookmarks.len(), 1);
3944        assert_eq!(bookmarks[0].title, "Chapter 1 \u{2013} Intro");
3945        assert_eq!(bookmarks[0].url, "#1");
3946    }
3947
3948    /// NAVM entry whose n_children byte is present but the title string's 3-byte
3949    /// length prefix is cut off → read_navm_str returns Malformed (line 1313).
3950    #[test]
3951    fn parse_navm_bookmarks_string_length_truncated_returns_error() {
3952        use crate::bzz_encode::bzz_encode;
3953        // Decoded layout: [total_count u16 = 1][n_children u8 = 0]
3954        // After reading n_children (pos=3), read_navm_str needs 3 more bytes
3955        // for the length prefix but data.len()=3 → 3+3>3 → Malformed (line 1313).
3956        let payload = vec![0x00, 0x01, 0x00]; // total_count=1, n_children=0
3957        let bzz = bzz_encode(&payload);
3958        let chunk = crate::iff::IffChunk {
3959            id: *b"NAVM",
3960            data: &bzz,
3961        };
3962        let result = parse_navm_bookmarks(&[chunk]);
3963        assert!(
3964            result.is_err(),
3965            "NAVM with truncated string length must error"
3966        );
3967    }
3968
3969    /// Indirect DJVM with a shared DJVI component entry: the shared entry must
3970    /// be skipped (line 876 `continue`) and the page resolved via the resolver.
3971    #[test]
3972    fn indirect_djvm_with_shared_djvi_entry_skips_to_page() {
3973        use crate::dirm::DirmPayload;
3974        let chicken_data =
3975            std::fs::read(assets_path().join("chicken.djvu")).expect("chicken.djvu must exist");
3976
3977        // Build DIRM: entry 0 = Shared (flag=0x00), entry 1 = Page (flag=0x01)
3978        let dirm_payload = DirmPayload::build_indirect(
3979            2,
3980            &[0x00, 0x01],
3981            &["shared.djvi".to_string(), "page.djvu".to_string()],
3982        );
3983        let dirm_data = dirm_payload.encode();
3984        let djvm_data = build_djvm_with_dirm(&dirm_data);
3985
3986        let resolver = |name: &str| -> Result<Vec<u8>, DocError> {
3987            if name == "page.djvu" {
3988                Ok(chicken_data.clone())
3989            } else {
3990                Err(DocError::IndirectResolve(name.to_string()))
3991            }
3992        };
3993
3994        let doc = DjVuDocument::parse_with_resolver(&djvm_data, Some(resolver))
3995            .expect("indirect DJVM with shared entry must parse");
3996        assert_eq!(doc.page_count(), 1);
3997        let page = doc.page(0).unwrap();
3998        assert_eq!(page.width(), 181);
3999    }
4000
4001    /// The typed resolver sees shared entries as well as pages, and a resolved
4002    /// DJVI dictionary is connected to the page through its INCL reference.
4003    #[test]
4004    fn typed_indirect_resolver_loads_shared_djvi_component() {
4005        use std::cell::RefCell;
4006
4007        use crate::dirm::DirmPayload;
4008        use crate::iff::{Chunk, EmitPart};
4009
4010        let chicken_data =
4011            std::fs::read(assets_path().join("chicken.djvu")).expect("chicken.djvu exists");
4012
4013        // Add an INCL reference to the otherwise ordinary fixture page.
4014        let mut page_file = crate::iff::parse(&chicken_data).expect("parse page fixture");
4015        match &mut page_file.root {
4016            Chunk::Form {
4017                secondary_id,
4018                children,
4019                ..
4020            } if secondary_id == b"DJVU" => {
4021                children.insert(
4022                    1,
4023                    Chunk::Leaf {
4024                        id: *b"INCL",
4025                        data: b"shared.djvi".to_vec(),
4026                    },
4027                );
4028            }
4029            _ => panic!("fixture must be FORM:DJVU"),
4030        }
4031        let page_bytes = crate::iff::emit(&page_file);
4032
4033        let dict_chunk = Chunk::Leaf {
4034            id: *b"Djbz",
4035            data: vec![0x01, 0x02],
4036        };
4037        let shared_bytes = crate::iff::partial_emit(*b"DJVI", &[EmitPart::Chunk(&dict_chunk)])
4038            .expect("shared component fits");
4039        let thumbnail_bytes = crate::iff::partial_emit(*b"THUM", &[]).expect("thumbnail fits");
4040
4041        let dirm = DirmPayload::build_indirect(
4042            3,
4043            &[0x00, 0x01, 0x02],
4044            &[
4045                "shared.djvi".to_string(),
4046                "page.djvu".to_string(),
4047                "thumb.thum".to_string(),
4048            ],
4049        );
4050        let dirm_chunk = Chunk::Leaf {
4051            id: *b"DIRM",
4052            data: dirm.encode(),
4053        };
4054        let djvm = crate::iff::partial_emit(*b"DJVM", &[EmitPart::Chunk(&dirm_chunk)])
4055            .expect("index fits");
4056
4057        let seen = RefCell::new(Vec::new());
4058        let resolver = |component: &ComponentId| {
4059            seen.borrow_mut().push(component.clone());
4060            match component.name.as_str() {
4061                "shared.djvi" => Ok(shared_bytes.clone()),
4062                "page.djvu" => Ok(page_bytes.clone()),
4063                "thumb.thum" => Ok(thumbnail_bytes.clone()),
4064                _ => Err(ComponentResolveError::Missing {
4065                    component: component.clone(),
4066                }),
4067            }
4068        };
4069
4070        let doc = DjVuDocument::parse_with_component_resolver(&djvm, &resolver)
4071            .expect("typed indirect parse");
4072        assert_eq!(doc.page_count(), 1);
4073        assert!(doc.pages[0].shared_djbz.is_some());
4074        assert_eq!(
4075            seen.borrow().as_slice(),
4076            &[
4077                ComponentId::new("shared.djvi", ComponentKind::Shared),
4078                ComponentId::new("page.djvu", ComponentKind::Page),
4079                ComponentId::new("thumb.thum", ComponentKind::Thumbnail),
4080            ]
4081        );
4082    }
4083
4084    /// parse_from_dir with a DIRM component named as an absolute path (line 1040).
4085    #[test]
4086    fn parse_from_dir_resolves_absolute_component_path() {
4087        use crate::dirm::DirmPayload;
4088        use crate::iff::{self as iff_mod, Chunk, EmitPart};
4089
4090        // Write a single-page DJVU to a temp file at an absolute path.
4091        let chicken =
4092            std::fs::read(assets_path().join("chicken.djvu")).expect("chicken.djvu must exist");
4093        let tmp_dir = std::env::temp_dir();
4094        let abs_name = tmp_dir.join("djvu_rs_test_abs_component.djvu");
4095        std::fs::write(&abs_name, &chicken).expect("write tmp component");
4096        let abs_name_str = abs_name.to_str().unwrap().to_string();
4097
4098        let dirm_payload =
4099            DirmPayload::build_indirect(1, &[0x01], std::slice::from_ref(&abs_name_str));
4100        let dirm = Chunk::Leaf {
4101            id: *b"DIRM",
4102            data: dirm_payload.encode(),
4103        };
4104        let djvm =
4105            iff_mod::partial_emit(*b"DJVM", &[EmitPart::Chunk(&dirm)]).expect("fits within u32");
4106
4107        let doc = DjVuDocument::parse_from_dir(&djvm, &tmp_dir)
4108            .expect("absolute-path component must resolve");
4109        assert_eq!(doc.page_count(), 1);
4110        let _ = std::fs::remove_file(&abs_name);
4111    }
4112
4113    /// parse_single_page_with_shared: form type is not DJVU → NotDjVu error (line 908).
4114    #[cfg(all(feature = "std", feature = "async"))]
4115    #[test]
4116    fn parse_single_page_with_shared_wrong_form_type_returns_not_djvu() {
4117        use crate::iff::{self as iff_mod, Chunk, DjvuFile};
4118
4119        let bytes = iff_mod::emit(&DjvuFile {
4120            root: Chunk::Form {
4121                secondary_id: *b"DJVI",
4122                length: 0,
4123                children: vec![],
4124            },
4125        });
4126        let err = DjVuDocument::parse_single_page_with_shared(&bytes, 0, None)
4127            .expect_err("FORM:DJVI must not be accepted as a page");
4128        assert!(
4129            matches!(err, DocError::NotDjVu(_)),
4130            "expected NotDjVu, got {err:?}"
4131        );
4132    }
4133
4134    /// DIRM offset points outside the file bytes, so the byte-range lookup
4135    /// for the page fails and `page_byte_ranges.clear()` (line 859) fires.
4136    /// The document still parses successfully (the IFF tree is intact); the
4137    /// page is accessible but `page_byte_range` returns None.
4138    #[test]
4139    fn bundled_djvm_out_of_bounds_dirm_offset_clears_page_byte_ranges() {
4140        use crate::dirm::DirmPayload;
4141        use crate::iff::{self as iff_mod, Chunk, EmitPart};
4142
4143        let chicken =
4144            std::fs::read(assets_path().join("chicken.djvu")).expect("chicken.djvu must exist");
4145
4146        // Build a bundled DIRM with one Page entry but set its offset to a value
4147        // far beyond the end of the file so the byte-range lookup fails.
4148        let mut dirm_payload = DirmPayload::build_bundled(1, &[0x01], &["p.djvu".to_string()], &[]);
4149        dirm_payload.offsets[0] = 0xFFFF_FFFF; // points well outside the file
4150        let dirm_data = dirm_payload.encode();
4151
4152        let dirm = Chunk::Leaf {
4153            id: *b"DIRM",
4154            data: dirm_data,
4155        };
4156        // Strip the 4-byte AT&T magic from chicken.djvu to get the bare FORM bytes.
4157        let form_bytes = chicken
4158            .strip_prefix(b"AT&T")
4159            .expect("chicken.djvu must start with AT&T");
4160
4161        let djvm = iff_mod::partial_emit(
4162            *b"DJVM",
4163            &[EmitPart::Chunk(&dirm), EmitPart::Verbatim(form_bytes)],
4164        )
4165        .expect("fits within u32");
4166
4167        let doc = DjVuDocument::parse(&djvm).expect("DJVM with bad offset must still parse");
4168        assert_eq!(doc.page_count(), 1, "page must still be accessible");
4169        // page_byte_range is cleared because the offset was out of bounds.
4170        assert!(
4171            doc.page_byte_range(0).is_none(),
4172            "page_byte_range must be None when DIRM offset is out of bounds"
4173        );
4174    }
4175
4176    /// Legacy FORM:BM44 parses as a one-page grayscale IW44 document (#683).
4177    #[test]
4178    fn legacy_bm44_parses_as_one_page() {
4179        let data = std::fs::read(
4180            std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
4181                .join("tests/fixtures/legacy_bm44.djvu"),
4182        )
4183        .expect("legacy_bm44.djvu must exist");
4184        let doc = DjVuDocument::parse(&data).expect("BM44 must parse");
4185        assert_eq!(doc.page_count(), 1);
4186        let page = doc.page(0).unwrap();
4187        assert_eq!(page.dimensions(), (32, 32));
4188        assert_eq!(page.dpi(), 100);
4189        assert_eq!(page.bg44_chunks().len(), 3);
4190    }
4191
4192    /// Legacy FORM:PM44 parses as a one-page color IW44 document (#683).
4193    #[test]
4194    fn legacy_pm44_parses_as_one_page() {
4195        let data = std::fs::read(
4196            std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
4197                .join("tests/fixtures/legacy_pm44.djvu"),
4198        )
4199        .expect("legacy_pm44.djvu must exist");
4200        let doc = DjVuDocument::parse(&data).expect("PM44 must parse");
4201        assert_eq!(doc.page_count(), 1);
4202        let page = doc.page(0).unwrap();
4203        assert_eq!(page.dimensions(), (181, 240));
4204        assert_eq!(page.dpi(), 100);
4205        assert!(!page.bg44_chunks().is_empty());
4206    }
4207
4208    /// Empty BM44 body is a typed missing-chunk error, not a panic.
4209    #[test]
4210    fn legacy_bm44_empty_is_typed_error() {
4211        let data = crate::iff::partial_emit(*b"BM44", &[]).expect("fits within u32");
4212        let err = DjVuDocument::parse(&data).expect_err("empty BM44 must fail");
4213        assert!(matches!(err, DocError::MissingChunk("BM44")), "got {err:?}");
4214    }
4215
4216    /// Truncated first IW44 header fails closed.
4217    #[test]
4218    fn legacy_bm44_truncated_header_is_typed_error() {
4219        use crate::iff::{Chunk, EmitPart};
4220        let chunk = Chunk::Leaf {
4221            id: *b"BM44",
4222            data: vec![0, 1, 0x81],
4223        };
4224        let data = crate::iff::partial_emit(*b"BM44", &[EmitPart::Chunk(&chunk)])
4225            .expect("fits within u32");
4226        let err = DjVuDocument::parse(&data).expect_err("truncated BM44 must fail");
4227        assert!(matches!(err, DocError::Malformed(_)), "got {err:?}");
4228    }
4229
4230    /// FORM:BM44 with a color IW44 bitstream is rejected.
4231    #[test]
4232    fn legacy_bm44_rejects_color_bitstream() {
4233        use crate::iff::{Chunk, EmitPart};
4234        // Color major byte 0x01, 8x8.
4235        let payload = vec![0, 1, 0x01, 2, 0, 8, 0, 8, 0];
4236        let chunk = Chunk::Leaf {
4237            id: *b"BM44",
4238            data: payload,
4239        };
4240        let data = crate::iff::partial_emit(*b"BM44", &[EmitPart::Chunk(&chunk)])
4241            .expect("fits within u32");
4242        let err = DjVuDocument::parse(&data).expect_err("color BM44 must fail");
4243        assert!(matches!(err, DocError::Malformed(_)), "got {err:?}");
4244    }
4245}