Skip to main content

djvu_rs/
lib.rs

1//! Pure-Rust DjVu decoder written from the DjVu v3 public specification.
2//!
3//! This crate implements the full DjVu v3 document format in safe Rust,
4//! including IFF container parsing, JB2 bilevel decoding, IW44 wavelet
5//! decoding, BZZ decompression, text layer extraction, and annotation parsing.
6//! All algorithms are written from the public DjVu spec with no GPL code.
7//!
8//! # Key public types
9//!
10//! - [`DjVuError`] — top-level error enum (wraps [`IffError`], etc.)
11//! - [`IffError`] — errors from the IFF container parser
12//! - [`PageInfo`] — page metadata parsed from the INFO chunk
13//! - [`Rotation`] — page rotation enum (None, Ccw90, Rot180, Cw90)
14//! - [`DjVuDocument`] — high-level document model (IFF/BZZ/IW44 based)
15//! - [`DjVuPage`] — lazy page handle
16//! - [`DjVuBookmark`] — NAVM bookmark (table of contents)
17//! - [`DocError`] — error type for the document model
18//! - [`djvu_render::RenderOptions`] — render parameters
19//! - [`djvu_render::RenderError`] — render pipeline error type
20//! - [`text::TextLayer`] — text layer from TXTz/TXTa chunks
21//! - [`text::TextZone`] — a zone node in the text layer hierarchy
22//! - [`annotation::Annotation`] — page-level annotation
23//! - [`annotation::MapArea`] — clickable area with URL and shape
24//! - [`Pixmap`] — RGBA pixel buffer returned by render methods
25//! - [`Bitmap`] — 1-bit bitmap for JB2 mask layers
26//! - [`Document`] — owned DjVu document (high-level std API, requires std feature)
27//! - [`Page`] — a page within a [`Document`]
28//!
29//! # Quick start
30//!
31//! ```no_run
32//! use djvu_rs::Document;
33//!
34//! let doc = Document::open("file.djvu").unwrap();
35//! println!("{} pages", doc.page_count());
36//!
37//! let page = doc.page(0).unwrap();
38//! println!("{}x{} @ {} dpi", page.width(), page.height(), page.dpi());
39//!
40//! let pixmap = page.render().unwrap();
41//! // pixmap.data: RGBA bytes
42//! ```
43//!
44//! # IFF parser
45//!
46//! ```no_run
47//! use djvu_rs::iff::parse_form;
48//!
49//! let data = std::fs::read("file.djvu").unwrap();
50//! let form = parse_form(&data).unwrap();
51//! println!("form type: {:?}", std::str::from_utf8(&form.form_type));
52//! ```
53
54#![cfg_attr(not(feature = "std"), no_std)]
55#![deny(unsafe_code)]
56#[cfg(not(feature = "std"))]
57extern crate alloc;
58
59// Compile every ```rust block in README.md as a doctest, so the README cannot
60// drift from the public API (the `from_mmap` class of staleness). Gated on the
61// feature union the README examples need; exercised by
62// `cargo test --doc --features cli,tiff,async,serde,image,epub`
63// in scripts/check.sh and CI (nextest does not run doctests).
64#[cfg(all(
65    doctest,
66    feature = "pdf",
67    feature = "tiff",
68    feature = "async",
69    feature = "serde",
70    feature = "image",
71    feature = "epub"
72))]
73#[doc = include_str!("../README.md")]
74pub struct ReadmeDoctests;
75
76// ---- Phase-1 modules -------------------------------------------------------
77//
78// Clean-room implementations written from the DjVu spec.
79
80/// IFF container parser (phase 1, written from spec).
81pub mod iff;
82
83/// Typed error hierarchy for the new implementation (phase 1).
84///
85/// Key types: `DjVuError`, `IffError`, `BzzError`, `Jb2Error`, `Iw44Error`,
86/// `LegacyError`. See also `text::TextError` and `annotation::AnnotationError`.
87pub mod error;
88
89/// INFO chunk parser (phase 1).
90pub(crate) mod info;
91
92/// ZP arithmetic coder — clean-room implementation.
93///
94/// Lives in the standalone [`djvu_zp`] sub-crate (workspace member) since
95/// PR1 of #229.  Re-exported here as `zp_impl` for backwards compatibility
96/// with the historical internal path used by BZZ, JB2, and IW44.
97#[allow(unused_imports)]
98pub(crate) use djvu_zp as zp_impl;
99
100/// BZZ decompressor — clean-room implementation.
101///
102/// Provides `bzz::bzz_decode` for decompressing DjVu BZZ streams
103/// (DIRM, NAVM, ANTz chunks).
104#[allow(dead_code)]
105pub mod bzz;
106
107/// Compatibility alias for the former `bzz_new` module name. The `_new` suffix
108/// fossilised the codec-extraction migration (there was a pre-extraction
109/// `bzz.rs`); the module is now simply [`bzz`]. Prefer `bzz` in new code.
110pub use bzz as bzz_new;
111
112/// BZZ compressor — encoding counterpart to `bzz`.
113#[cfg(feature = "std")]
114pub mod bzz_encode;
115
116/// DJVM document merge and split operations.
117#[cfg(feature = "std")]
118pub mod djvm;
119
120/// `DIRM` directory-chunk model — the single owner of the DIRM byte layout,
121/// shared by the read model, the byte-preserving mutator, and DJVM merge/split.
122pub(crate) mod dirm;
123
124/// JB2 bilevel image decoder — clean-room implementation.
125///
126/// Decodes JB2-encoded bitonal images from DjVu Sjbz and Djbz chunks using
127/// ZP adaptive arithmetic coding with a symbol dictionary.
128///
129/// Key public types: `jb2::Jb2Dict`, `jb2::decode`, `jb2::decode_dict`.
130pub mod jb2;
131
132/// IW44 wavelet image decoder — clean-room implementation (phase 2c).
133///
134/// Provides `iw44::Iw44Image` for decoding BG44/FG44/TH44 chunks.
135/// Uses planar YCbCr storage and a ZP arithmetic coder.
136/// RGB conversion happens only in `iw44::Iw44Image::to_rgb`.
137pub mod iw44;
138
139/// Compatibility alias for the former `iw44_new` module name. The `_new` suffix
140/// fossilised the codec-extraction migration; the module is now simply
141/// [`iw44`]. Prefer `iw44` in new code.
142pub use iw44 as iw44_new;
143
144/// IW44 wavelet encoder — produces BG44/FG44/TH44 chunk payloads.
145///
146/// Provides [`iw44_encode::encode_iw44_color`] and [`iw44_encode::encode_iw44_gray`].
147#[cfg(feature = "std")]
148pub mod iw44_encode;
149
150/// JB2 bilevel image encoder — produces Sjbz chunk payloads.
151///
152/// Provides [`jb2_encode::encode_jb2`] (single record-type-3 direct encoding) and
153/// [`jb2_encode::encode_jb2_dict`] (connected-component symbol-dictionary encoding).
154#[cfg(feature = "std")]
155pub mod jb2_encode;
156
157/// FGbz foreground-palette encoder — produces FGbz chunk payloads.
158///
159/// Provides [`fgbz_encode::encode_fgbz`] (palette + optional per-blit
160/// index table) and [`fgbz_encode::decode_fgbz`] (inverse), plus the
161/// [`fgbz_encode::FgbzColor`] palette-entry type.
162#[cfg(feature = "std")]
163pub mod fgbz_encode;
164
165/// High-level page encoder — composes the codec primitives into a
166/// complete `FORM:DJVU` page.
167///
168/// Provides [`djvu_encode::PageEncoder`] (builder-style entry point),
169/// [`djvu_encode::EncodeQuality`] (Lossless / Quality / Archival / Photo
170/// profiles), [`djvu_encode::BilevelCodec`] (JB2 or explicit Smmr), and
171/// [`djvu_encode::EncodeError`].
172#[cfg(feature = "std")]
173pub mod djvu_encode;
174
175/// TH44 thumbnail generation for multi-page bundles.
176///
177/// Provides [`thumbnail::encode_th44_color`] and
178/// [`thumbnail::encode_th44_gray_from_bitmap`] for embedding small IW44
179/// thumbnails as `TH44` chunks inside a page's `FORM:DJVU` component.
180#[cfg(feature = "std")]
181pub mod thumbnail;
182
183/// Photometric foreground/background segmentation — splits an RGBA
184/// page into a bilevel ink mask and a sub-sampled background pixmap.
185///
186/// Provides [`segment::segment_page`], [`segment::SegmentOptions`],
187/// [`segment::Binarization`], and [`segment::SegmentedPage`]. The default is
188/// fixed-threshold + block-averaged BG; optional Sauvola binarisation and
189/// fully-masked BG-block inpainting are available through `SegmentOptions`.
190#[cfg(feature = "std")]
191pub mod segment;
192
193/// Encoder ingest policy — alpha, bit-depth down-conversion, and related knobs
194/// (#694). See `docs/encoder-ingestion.md` for the supported input matrix.
195#[cfg(feature = "std")]
196pub mod ingest;
197
198/// PNG file → [`Pixmap`] decoder.
199///
200/// Provides [`png_io::decode_png_to_pixmap`] for decoding PNG inputs into the
201/// RGBA [`Pixmap`] format used throughout djvu-rs.
202#[cfg(feature = "std")]
203pub mod png_io;
204
205/// Shared configurable resource limits for parse, render, and validate.
206pub mod resource_limits;
207
208/// New document model — phase 3.
209///
210/// Provides [`DjVuDocument`] (high-level document API built on the new IFF/BZZ/IW44
211/// clean-room implementations), [`DjVuPage`] (lazy page handle), and
212/// [`DjVuBookmark`] (NAVM table-of-contents entry).
213pub mod djvu_document;
214
215/// In-place document mutation — byte-preserving rewrite primitive (PR1 of #222).
216///
217/// Provides [`djvu_mut::DjVuDocumentMut`] for editing a DjVu document while
218/// preserving the bytes of every chunk that wasn't touched.
219#[cfg(feature = "std")]
220pub mod djvu_mut;
221
222/// High-level document optimization planning and safe structural cleanup.
223#[cfg(feature = "std")]
224pub mod optimizer;
225
226/// Versioned, typed document editing operations with validation and atomic
227/// output handoff.
228#[cfg(feature = "std")]
229pub mod editor;
230
231/// Validated dependency graph for bundled `FORM:DJVM` components.
232#[cfg(feature = "std")]
233pub mod component_graph;
234
235/// Layered, non-rendering DjVu document validation.
236///
237/// Structural, dependency, and codec checks are available now. Semantic and
238/// resource layers are reserved for later validator slices so callers can rely
239/// on a stable finding schema from the first release.
240#[cfg(feature = "std")]
241pub mod validate;
242
243/// Semantic comparison of two documents (#696): page properties, text,
244/// annotations, metadata, bookmarks, and the component graph.
245#[cfg(feature = "std")]
246pub mod semantic_diff;
247
248/// Rendering pipeline for [`DjVuPage`] — phase 5.
249///
250/// Provides `djvu_render::RenderOptions`, `djvu_render::RenderRect`,
251/// `djvu_render::render_into`, `djvu_render::render_pixmap`,
252/// `djvu_render::render_region`, `djvu_render::render_coarse`, and
253/// `djvu_render::render_progressive`.
254pub mod djvu_render;
255
256/// Process-wide ceiling for the page render caches (READ_CACHE_BOUNDED).
257///
258/// Provides `render_cache::budget`, `render_cache::set_budget`,
259/// `render_cache::resident_bytes`, `render_cache::enforce` and
260/// `render_cache::clear`. Since 0.33 the render caches are bounded by default
261/// (`render_cache::DEFAULT_BUDGET`, 256 MiB); `set_budget(usize::MAX)` restores
262/// the unbounded behaviour of earlier releases.
263#[cfg(feature = "std")]
264pub mod render_cache;
265
266/// Tile-first rendering API for viewer engines (#691).
267///
268/// Provides `djvu_tile::TileLayout`, `djvu_tile::TileRect`,
269/// `djvu_tile::render_tile`, and `djvu_tile::render_tile_cached` — a
270/// display-space tile grid over the region renderer, with byte-identical
271/// assembly and order-independent tile pixels — plus tile-granular cache
272/// control (`djvu_tile::tile_cache_usage`, `djvu_tile::set_tile_cache_budget`,
273/// `djvu_tile::clear_tile_cache`, `djvu_tile::invalidate_tile_region`),
274/// progressive quality steps and cooperative cancellation
275/// (`djvu_tile::render_tile_with`, `djvu_tile::TileRenderControls`,
276/// `djvu_tile::TileCancelToken`) and, with the `parallel` feature, bounded
277/// background `djvu_tile::prefetch_tiles` /
278/// `djvu_tile::prefetch_tiles_cancellable`. Contract:
279/// `docs/tile-rendering.md`.
280pub mod djvu_tile;
281
282/// Perceptual image-quality metrics (PSNR, SSIM) for render experiments.
283///
284/// Judges whether a render change is perceptually better/worse against a
285/// reference, which the arithmetic pixel-diff tooling cannot. See
286/// [`quality::compare`]. Std-only (render-side).
287#[cfg(feature = "std")]
288pub mod quality;
289
290/// FGbz foreground-palette parser — decodes the `FGbz` chunk into a color
291/// palette and per-blit index table so [`djvu_render`] receives already-decoded
292/// data and never calls `bzz_decode` directly.
293pub(crate) mod fgbz;
294
295/// DjVu text layer — data model and parser.
296///
297/// Defines [`text::TextLayer`], [`text::TextZone`], [`text::TextZoneKind`],
298/// [`text::Rect`], [`text::Paragraph`], and [`text::TextError`], and provides
299/// the pure [`text::parse_text_layer`] parser for TXTa/TXTz chunks.
300/// BZZ-compressed `TXTz` payloads are decompressed upstream by
301/// [`DjVuPage::chunk_payload`].
302pub mod text;
303
304/// Annotation parser for DjVu ANTz/ANTa chunks — phase 4.
305///
306/// Provides the pure [`annotation::parse_annotations`] parser plus typed
307/// structs [`annotation::Annotation`], [`annotation::MapArea`],
308/// [`annotation::Shape`], and [`annotation::Color`].  BZZ-compressed `ANTz`
309/// payloads are decompressed upstream by [`DjVuPage::chunk_payload`].
310pub mod annotation;
311
312/// Document metadata parser for METa/METz chunks — phase 4 extension.
313///
314/// Provides the pure [`metadata::parse_metadata`] parser plus
315/// [`metadata::DjVuMetadata`] and [`metadata::MetadataError`].  BZZ-compressed
316/// `METz` payloads are decompressed upstream by [`DjVuDocument::chunk_payload`].
317pub mod metadata;
318
319/// Shared, depth-guarded S-expression reader used by the annotation and
320/// metadata parsers (#368). Owns the tokenizer and recursive-descent reader so
321/// the recursion-depth guard protects both consumers.
322mod sexp;
323
324/// Lenient (UTF-8 with CP1252 fallback) decoding for non-structural DjVu
325/// strings (#524): NAVM bookmark titles/URLs, TXTz/TXTa text layers, METa
326/// values. One bad legacy byte in a bookmark must not abort `Document::open`.
327mod lenient_text;
328
329/// Shared document-to-export traversal primitives (#345) used by the PDF,
330/// EPUB, TIFF, and OCR exporters: the per-page loop, the scale→size kernel,
331/// the leaf word/character zone-walk, and the vertical coordinate flip.
332#[cfg(feature = "std")]
333mod export_common;
334
335/// Shared progress reporting and cooperative cancellation for export writers.
336#[cfg(feature = "std")]
337pub mod export_control;
338
339/// Test-only sinks for exercising streaming exporter failure contracts.
340#[cfg(all(test, feature = "std"))]
341pub(crate) mod export_test_support;
342
343/// DjVu to PDF converter — phase 6.
344///
345/// Converts DjVu documents to PDF preserving structure: rasterized page images,
346/// invisible text layer (searchable), bookmarks (PDF outline), and hyperlinks
347/// (PDF link annotations).
348///
349/// Key function: [`pdf::djvu_to_pdf`].
350#[cfg(feature = "pdf")]
351pub mod pdf;
352
353/// DjVu to EPUB 3 exporter.
354///
355/// Converts DjVu documents to EPUB 3 while preserving page images,
356/// invisible text overlay for search/copy, and NAVM bookmarks as navigation.
357///
358/// Key function: [`epub::djvu_to_epub`].
359#[cfg(feature = "epub")]
360pub mod epub;
361
362/// DjVu to CBZ (comic book archive) exporter.
363///
364/// A ZIP of per-page PNGs; page rendering/encoding parallelises under the
365/// `parallel` feature (render-parallel, write-serial — #598).
366///
367/// Key function: [`cbz::djvu_to_cbz`].
368#[cfg(feature = "cbz")]
369pub mod cbz;
370
371/// DjVu to TIFF exporter — phase 4 format extension.
372///
373/// Converts DjVu documents to multi-page TIFF files in color (RGB8) or
374/// bilevel (Gray8) modes.
375///
376/// Key function: [`tiff_export::djvu_to_tiff`].
377#[cfg(feature = "tiff")]
378pub mod tiff_export;
379
380/// Async render surface for [`DjVuPage`] — phase 5 extension.
381///
382/// CPU-bound IW44/JB2 work belongs on the blocking thread pool: call the
383/// synchronous render entry points inside [`tokio::task::spawn_blocking`]
384/// rather than on the async runtime thread (see the module docs for the
385/// one-line pattern).
386///
387/// Key abstractions: [`djvu_async::LazyDocument`],
388/// [`djvu_async::render_progressive_stream`],
389/// [`djvu_async::render_tile_async`],
390/// [`djvu_async::render_tile_progressive_stream`],
391/// [`djvu_async::load_document_async_streaming`].
392#[cfg(feature = "async")]
393pub mod djvu_async;
394
395/// Async adapters for the synchronous PDF and DJVM streaming writers.
396#[cfg(feature = "async")]
397pub mod export_async;
398
399/// `image::ImageDecoder` integration — allows DjVu pages to be used as
400/// first-class image sources in the `image` crate ecosystem.
401///
402/// Key types: [`image_compat::DjVuDecoder`], [`image_compat::ImageCompatError`].
403#[cfg(feature = "image")]
404pub mod image_compat;
405
406/// hOCR and ALTO XML serialization for the text layer.
407///
408/// This serializes an existing text layer; it does **not** run OCR (see the
409/// [`ocr`] cluster for recognition).
410///
411/// Key functions: [`text_serialize::to_hocr`], [`text_serialize::to_alto`].
412/// Key types: [`text_serialize::HocrOptions`], [`text_serialize::AltoOptions`],
413/// [`text_serialize::TextSerializeError`].
414#[cfg(feature = "std")]
415pub mod text_serialize;
416
417/// Pluggable OCR backend trait and error types.
418///
419/// Provides [`ocr::OcrBackend`] — recognize text in rendered page images.
420/// Backend implementations are gated behind feature flags.
421#[cfg(feature = "std")]
422pub mod ocr;
423
424/// Tesseract OCR backend (requires `ocr-tesseract` feature).
425#[cfg(feature = "ocr-tesseract")]
426pub mod ocr_tesseract;
427
428/// Experimental ONNX OCR helper via tract (requires `ocr-onnx` feature).
429#[cfg(feature = "ocr-onnx")]
430pub mod ocr_onnx;
431
432/// Experimental neural OCR placeholder (requires `ocr-neural` feature).
433#[cfg(feature = "ocr-neural")]
434pub mod ocr_neural;
435
436/// TXTa/TXTz text layer encoder — writes [`text::TextLayer`] back to DjVu binary format.
437#[cfg(feature = "std")]
438pub mod text_encode;
439
440/// NAVM bookmark encoder — serializes [`djvu_document::DjVuBookmark`] trees to BZZ-compressed binary.
441#[cfg(feature = "std")]
442pub mod navm_encode;
443
444/// Smmr chunk codec — ITU-T G4 (MMR) bilevel image compression.
445///
446/// Provides [`decode_smmr`](crate::smmr::decode_smmr) (chunk → [`Bitmap`]) and
447/// [`encode_smmr`](crate::smmr::encode_smmr) ([`Bitmap`] → chunk). Useful as an alternative
448/// to JB2 for fax-style scans without recurring glyph structure.
449pub mod smmr;
450
451/// Chunk-encoder seam — one `(id, payload)` interface ([`chunk_encode::ChunkEncoder`])
452/// over the per-chunk encoders, with a single [`chunk_encode::EncodeError`]
453/// discipline (no panic, no silent truncation).
454#[cfg(feature = "std")]
455pub mod chunk_encode;
456
457#[cfg(feature = "wasm")]
458pub mod wasm;
459
460/// Shared core for the foreign-language bindings (`ffi` and `wasm`).
461///
462/// Owns the open→size→render→text flow and the `DjVuError`→`(code, message)`
463/// taxonomy; `ffi` and `wasm` are thin target-specific caps over it.
464#[cfg(feature = "std")]
465mod foreign;
466
467/// C FFI bindings for foreign language integration.
468///
469/// Provides `extern "C"` functions with no-panic guarantees.
470/// Key functions: `djvu_doc_open`, `djvu_doc_free`, `djvu_page_render`,
471/// `djvu_pixmap_free`, `djvu_page_text`.
472#[cfg(feature = "std")]
473#[allow(unsafe_code)]
474pub mod ffi;
475
476// Re-export new phase-1 error types
477pub use error::{BzzError, DjVuError, IffError, Iw44Error, Jb2Error};
478
479// Re-export new phase-3 document model
480pub use djvu_document::{
481    ComponentDirectoryEntry, ComponentId, ComponentKind, ComponentResolveError, ComponentResolver,
482    DjVuBookmark, DjVuDocument, DjVuPage, DocError,
483};
484
485// Re-export the validated editor entry points for callers that do not need to
486// distinguish the module from the rest of the high-level API.
487#[cfg(feature = "std")]
488pub use editor::{
489    DocumentEditor, EDIT_SCHEMA_VERSION, EditError, EditOperation, EditOperationKind, EditPlan,
490    EditRequest, EditTarget, PlannedEdit,
491};
492
493// Re-export the bundled component-graph API for callers that do not need to
494// distinguish the module from the rest of the high-level document API.
495#[cfg(feature = "std")]
496pub use component_graph::{ComponentGraph, ComponentNode, ComponentNodeKind, GraphError};
497
498/// Re-export the semantic diff API for callers that prefer the crate root.
499#[cfg(feature = "std")]
500pub use semantic_diff::{PlaneDiff, PlaneStatus, SemanticDiff, semantic_diff};
501
502pub use resource_limits::{
503    DEFAULT_MAX_RENDER_PIXELS, ParseOptions, ResourceLimitAxis, ResourceLimitExceeded,
504    ResourceLimits,
505};
506/// Re-export the layered validation API for callers that prefer the crate root.
507#[cfg(feature = "std")]
508pub use validate::{
509    Finding, Layer as ValidationLayer, ResourceEstimate, Severity, ValidateOptions,
510    ValidationReport, ValidationSummary,
511};
512
513/// Shared export progress and cancellation types.
514#[cfg(feature = "std")]
515pub use export_control::{ExportObserver, NoOpObserver};
516
517// Re-export new phase-1 page info types
518pub use info::{PageInfo, Rotation};
519
520// ---- Rendering / document modules ------------------------------------------
521//
522// These modules implement the rendering pipeline. They depend on bitmap,
523// pixmap, iw44, jb2, bzz. They require std (std::io, std::path, Vec, etc.)
524// so they are gated behind #[cfg(feature = "std")].
525
526#[doc(hidden)]
527pub(crate) mod bitmap;
528
529#[doc(hidden)]
530pub(crate) mod pixmap;
531
532pub use bitmap::Bitmap;
533pub use pixmap::{GrayPixmap, Pixmap, PixmapError};
534
535// Re-export text types from the new pipeline
536#[cfg(feature = "std")]
537pub use text::{TextLayer, TextZone, TextZoneKind};
538
539// Bookmark type alias — same shape as DjVuBookmark
540#[cfg(feature = "std")]
541pub type Bookmark = DjVuBookmark;
542
543// Legacy error type (re-exported from legacy_error module included via error.rs)
544#[cfg(feature = "std")]
545pub use error::LegacyError as Error;
546
547/// A parsed DjVu document. Owns the parsed structure.
548///
549/// Parsing happens once at construction time. All subsequent `page()` and
550/// `render()` calls reuse the parsed chunk tree with zero re-parsing overhead.
551#[cfg(feature = "std")]
552pub struct Document {
553    doc: DjVuDocument,
554}
555
556#[cfg(feature = "std")]
557impl Document {
558    /// Open a DjVu file from disk.
559    ///
560    /// For **indirect** multi-page DJVM files (where component pages live in
561    /// separate files next to the index), use [`Document::open_dir`] instead.
562    /// This method uses `DjVuDocument::parse` which only handles bundled
563    /// (self-contained) files; it will return an error for indirect documents.
564    pub fn open(path: impl AsRef<std::path::Path>) -> Result<Self, Error> {
565        Self::open_with_options(path, &crate::resource_limits::ParseOptions::default())
566    }
567
568    /// Open a DjVu file with configurable resource limits.
569    pub fn open_with_options(
570        path: impl AsRef<std::path::Path>,
571        opts: &crate::resource_limits::ParseOptions,
572    ) -> Result<Self, Error> {
573        let data = std::fs::read(path.as_ref())
574            .map_err(|e| Error::FormatError(format!("failed to read file: {}", e)))?;
575        Self::from_bytes_with_options(data, opts)
576    }
577
578    /// Open an indirect DJVM document from disk, resolving component pages
579    /// from the same directory as the index file.
580    ///
581    /// Use this when the DjVu file is an *indirect* multi-page document where
582    /// individual page files (e.g. `page001.djvu`) live alongside the index.
583    /// For self-contained (bundled) files, [`Document::open`] is sufficient.
584    pub fn open_dir(path: impl AsRef<std::path::Path>) -> Result<Self, Error> {
585        Self::open_dir_with_options(path, &crate::resource_limits::ParseOptions::default())
586    }
587
588    /// Open an indirect DJVM document with configurable resource limits.
589    pub fn open_dir_with_options(
590        path: impl AsRef<std::path::Path>,
591        opts: &crate::resource_limits::ParseOptions,
592    ) -> Result<Self, Error> {
593        let path = path.as_ref();
594        let data = std::fs::read(path)
595            .map_err(|e| Error::FormatError(format!("failed to read file: {}", e)))?;
596        let base_dir = path.parent().unwrap_or(std::path::Path::new("."));
597        let doc = DjVuDocument::parse_from_dir_with_options(&data, base_dir, opts)
598            .map_err(|e| Error::FormatError(e.to_string()))?;
599        Ok(Document { doc })
600    }
601
602    /// Parse a DjVu document from a reader (reads all bytes into memory).
603    pub fn from_reader(reader: impl std::io::Read) -> Result<Self, Error> {
604        let mut reader = reader;
605        let mut data = Vec::new();
606        reader
607            .read_to_end(&mut data)
608            .map_err(|e| Error::FormatError(format!("failed to read: {}", e)))?;
609        Self::from_bytes(data)
610    }
611
612    /// Parse a DjVu document from owned bytes.
613    ///
614    /// The bytes are moved into a shared backing so bundled multi-page documents
615    /// can construct pages lazily (chunk bytes are materialised on first access
616    /// rather than copied at open time; see `DjVuDocument::parse_backed`).
617    pub fn from_bytes(data: Vec<u8>) -> Result<Self, Error> {
618        Self::from_bytes_with_options(data, &crate::resource_limits::ParseOptions::default())
619    }
620
621    /// Parse a DjVu document from owned bytes with configurable resource limits.
622    pub fn from_bytes_with_options(
623        data: Vec<u8>,
624        opts: &crate::resource_limits::ParseOptions,
625    ) -> Result<Self, Error> {
626        let backing: std::sync::Arc<dyn AsRef<[u8]> + Send + Sync> = std::sync::Arc::new(data);
627        let doc = DjVuDocument::parse_backed_with_options(backing, opts)
628            .map_err(|e| Error::FormatError(e.to_string()))?;
629        Ok(Document { doc })
630    }
631
632    /// The parsed document this handle owns.
633    ///
634    /// The export entry points — [`crate::pdf::djvu_to_pdf_with_options`],
635    /// [`crate::epub::djvu_to_epub`], [`crate::cbz::djvu_to_cbz`],
636    /// [`crate::tiff_export::djvu_to_tiff`] and their writer forms — all take a
637    /// [`DjVuDocument`]. This hands them the one this `Document` already
638    /// parsed, instead of asking a caller to parse the bytes a second time.
639    pub fn inner(&self) -> &DjVuDocument {
640        &self.doc
641    }
642
643    /// Configurable resource limits supplied at parse/open time, if any.
644    pub fn resource_limits(&self) -> Option<crate::resource_limits::ResourceLimits> {
645        self.doc.resource_limits()
646    }
647
648    /// Parse the NAVM bookmarks (table of contents).
649    pub fn bookmarks(&self) -> Result<Vec<Bookmark>, Error> {
650        Ok(self.doc.bookmarks().to_vec())
651    }
652
653    /// Number of pages.
654    pub fn page_count(&self) -> usize {
655        self.doc.page_count()
656    }
657
658    /// Access a page by 0-based index.
659    pub fn page(&self, index: usize) -> Result<Page<'_>, Error> {
660        let page = self
661            .doc
662            .page(index)
663            .map_err(|e| Error::FormatError(e.to_string()))?;
664        Ok(Page { page, index })
665    }
666
667    /// Build a thumbnail for every page, each scaled to fit within a
668    /// `max_w × max_h` box (aspect-preserving). Uses [`ThumbnailStrategy::Auto`]:
669    /// the page's embedded `TH44` preview when present (20–30× faster than a
670    /// real render, per the `D5_TH44_PREVIEW` experiment), otherwise a
671    /// downscaled real render via [`djvu_render::RenderOptions::fit_to_box`].
672    ///
673    /// # Quality note
674    ///
675    /// A `TH44` thumbnail is the **encoder's own separately-lossy preview**,
676    /// not a faithful downscale of the real page — it is a small (long side ≤
677    /// 128 px) IW44 encode baked in at *encode* time, so its content can
678    /// diverge from what a real render of the same page produces (different
679    /// resampling, different quantisation, sometimes a stale image if the
680    /// page was re-encoded without regenerating the thumbnail). Measured SSIM
681    /// vs. a real render is **0.50–0.68** (`D5_TH44_PREVIEW`); this is a real
682    /// quality trade, not a free 20–30× win — acceptable for a dense
683    /// thumbnail *grid* where throughput dominates and the user can open the
684    /// real page to look closely, but not a substitute for
685    /// [`Page::render`]/[`Page::render_to_size`] as a faithful preview.
686    ///
687    /// With the `parallel` feature, pages are built concurrently on rayon
688    /// (mirrors the PDF/EPUB parallel exporters); each page's `Result` is
689    /// independent so one broken page doesn't fail the whole grid.
690    ///
691    /// Returns `Vec<Result<Pixmap, Error>>` rather than `Vec<Option<Pixmap>>`:
692    /// with the render fallback in place there is no longer a legitimate "no
693    /// thumbnail" case for a valid page, only success or a real decode error,
694    /// and the latter must not be silently swallowed.
695    pub fn thumbnails(&self, max_w: u32, max_h: u32) -> Vec<Result<Pixmap, Error>> {
696        self.thumbnails_with_strategy(max_w, max_h, ThumbnailStrategy::Auto)
697    }
698
699    /// Like [`Document::thumbnails`] but with explicit control over the
700    /// TH44-vs-render policy — see [`ThumbnailStrategy`].
701    pub fn thumbnails_with_strategy(
702        &self,
703        max_w: u32,
704        max_h: u32,
705        strategy: ThumbnailStrategy,
706    ) -> Vec<Result<Pixmap, Error>> {
707        let indices: Vec<usize> = (0..self.page_count()).collect();
708
709        let build = |&i: &usize| -> Result<Pixmap, Error> {
710            let page = self.page(i)?;
711            page.thumbnail_with_strategy(max_w, max_h, strategy)
712        };
713
714        #[cfg(feature = "parallel")]
715        {
716            use rayon::prelude::*;
717            indices.par_iter().map(build).collect()
718        }
719        #[cfg(not(feature = "parallel"))]
720        {
721            indices.iter().map(build).collect()
722        }
723    }
724}
725
726/// Policy for [`Document::thumbnails_with_strategy`]: how to source each
727/// page's thumbnail.
728#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
729pub enum ThumbnailStrategy {
730    /// Use the page's embedded `TH44` preview when present; otherwise fall
731    /// back to a downscaled real render. Fastest and the default — see the
732    /// quality note on [`Document::thumbnails`].
733    #[default]
734    Auto,
735    /// Always render the full page and downscale, ignoring any `TH44` chunk.
736    /// Slower but faithful; useful for quality comparisons against the
737    /// `TH44` path and for documents where a stale/absent thumbnail must
738    /// never be shown.
739    RenderOnly,
740    /// Require an embedded `TH44` chunk; returns an error for pages that
741    /// lack one instead of falling back to render. Never touches the page's
742    /// BG44/JB2 background decode. Useful to prove a corpus's thumbnail grid
743    /// stays on the fast path, or to fail loudly rather than silently render.
744    Th44Only,
745}
746
747/// A page within a DjVu document.
748#[cfg(feature = "std")]
749pub struct Page<'a> {
750    page: &'a DjVuPage,
751    index: usize,
752}
753
754#[cfg(feature = "std")]
755impl<'a> Page<'a> {
756    /// Page width in pixels (before rotation).
757    pub fn width(&self) -> u32 {
758        self.page.width() as u32
759    }
760
761    /// Page height in pixels (before rotation).
762    pub fn height(&self) -> u32 {
763        self.page.height() as u32
764    }
765
766    /// Effective page width after rotation.
767    pub fn display_width(&self) -> u32 {
768        self.display_dims().0
769    }
770
771    /// Effective page height after rotation.
772    pub fn display_height(&self) -> u32 {
773        self.display_dims().1
774    }
775
776    fn display_dims(&self) -> (u32, u32) {
777        djvu_render::display_dimensions(self.page)
778    }
779
780    /// Build options that render the (rotation-aware) display size scaled by
781    /// `scale`. The single home for the `display × scale` size idiom the
782    /// scale-based render methods share.
783    fn opts_for_scale(&self, scale: f32) -> djvu_render::RenderOptions {
784        let (dw, dh) = self.display_dims();
785        let w = ((dw as f32 * scale).round() as u32).max(1);
786        let h = ((dh as f32 * scale).round() as u32).max(1);
787        // The pipeline re-derives the decode scale from `w` and the page's
788        // display width, so we set only the size; `scale` is no longer an input.
789        djvu_render::RenderOptions {
790            width: w,
791            height: h,
792            ..Default::default()
793        }
794    }
795
796    /// Build options for an explicit target `width × height`, deriving the
797    /// rotation-aware `scale` from [`RenderOptions::fit_to_width`] and then
798    /// honoring the caller's exact `height` (which need not preserve aspect).
799    /// The single home for the size-based scale idiom.
800    fn opts_for_size(&self, width: u32, height: u32) -> djvu_render::RenderOptions {
801        let mut opts = djvu_render::RenderOptions::fit_to_width(self.page, width);
802        opts.height = height;
803        opts
804    }
805
806    /// Render with caller-supplied [`RenderOptions`] — the single entry point
807    /// the convenience methods funnel through.
808    ///
809    /// Build `opts` with the rotation-aware
810    /// [`RenderOptions::fit_to_width`](djvu_render::RenderOptions::fit_to_width)
811    /// / `fit_to_height` / `fit_to_box` constructors, then set
812    /// `bold` / `aa` / `resampling` as needed. This supersedes the bespoke
813    /// `render_bold` / `render_aa` / `render_scaled*` methods.
814    pub fn render_with(&self, opts: &djvu_render::RenderOptions) -> Result<Pixmap, Error> {
815        djvu_render::render_pixmap_with_limits(self.page, opts, self.page.resource_limits())
816            .map_err(Self::render_err)
817    }
818
819    /// Render with caller-supplied [`RenderOptions`] and an optional limit override.
820    pub fn render_with_limits(
821        &self,
822        opts: &djvu_render::RenderOptions,
823        limits: Option<crate::resource_limits::ResourceLimits>,
824    ) -> Result<Pixmap, Error> {
825        djvu_render::render_pixmap_with_limits(self.page, opts, limits).map_err(Self::render_err)
826    }
827
828    /// Page resolution in dots per inch.
829    pub fn dpi(&self) -> u16 {
830        self.page.dpi()
831    }
832
833    /// Pixel `(width, height)` this page renders to at `target_dpi`.
834    ///
835    /// The rounding/clamping policy lives in one place (the crate-internal
836    /// `export_common` sizing helper); a `0`-DPI page is treated as 1 DPI so the
837    /// scale stays finite.
838    pub fn size_at_dpi(&self, target_dpi: f32) -> (u32, u32) {
839        export_common::size_at_dpi(self.page, target_dpi)
840    }
841
842    /// The 0-based index of this page within the document.
843    pub fn index(&self) -> usize {
844        self.index
845    }
846
847    /// Page rotation from the INFO chunk.
848    pub fn rotation(&self) -> info::Rotation {
849        self.page.rotation()
850    }
851
852    fn render_err(e: djvu_render::RenderError) -> Error {
853        Error::FormatError(e.to_string())
854    }
855
856    /// Decode the JB2/G4 mask layer only (no compositing).
857    ///
858    /// Returns `None` when the page has no mask chunk (pure IW44 background page).
859    pub fn decode_mask(&self) -> Result<Option<Bitmap>, Error> {
860        self.page
861            .extract_mask()
862            .map_err(|e| Error::FormatError(e.to_string()))
863    }
864
865    /// Render the page to an RGBA pixmap at native resolution.
866    pub fn render(&self) -> Result<Pixmap, Error> {
867        self.render_with(&self.opts_for_scale(1.0))
868    }
869
870    /// Render the page to an RGBA pixmap at a target size.
871    pub fn render_to_size(&self, width: u32, height: u32) -> Result<Pixmap, Error> {
872        self.render_with(&self.opts_for_size(width, height))
873    }
874
875    /// Render a rectangular region of the page.
876    ///
877    /// `full_w × full_h` set the full-render output size the region is cut
878    /// from (the zoom level); `(x, y, w, h)` select the viewport within that
879    /// space. Routed through the composited-tile cache
880    /// ([`djvu_render::render_region_tiled`]) so viewer-style pans and
881    /// revisits reuse tiles (C4_TILE_CACHE / TILE_LRU) — O(viewport) work
882    /// instead of O(page).
883    pub fn render_region(
884        &self,
885        full_w: u32,
886        full_h: u32,
887        x: u32,
888        y: u32,
889        w: u32,
890        h: u32,
891    ) -> Result<Pixmap, Error> {
892        let opts = self.opts_for_size(full_w, full_h);
893        djvu_render::render_region_tiled(
894            self.page,
895            djvu_render::RenderRect {
896                x,
897                y,
898                width: w,
899                height: h,
900            },
901            &opts,
902        )
903        .map_err(Self::render_err)
904    }
905
906    /// Fast coarse render — decodes only the first BG44 chunk (a blurry but
907    /// near-instant preview). Returns `Ok(None)` for bilevel-only pages.
908    pub fn render_coarse(&self, width: u32, height: u32) -> Result<Option<Pixmap>, Error> {
909        let opts = self.opts_for_size(width, height);
910        djvu_render::render_coarse(self.page, &opts).map_err(Self::render_err)
911    }
912
913    /// Progressive render: decode BG44 chunks `0..=chunk_n` plus all
914    /// foreground layers. `chunk_n = bg44_chunk_count() - 1` equals the full
915    /// render; each lower value is a coarser refinement stage.
916    pub fn render_progressive(
917        &self,
918        width: u32,
919        height: u32,
920        chunk_n: usize,
921    ) -> Result<Pixmap, Error> {
922        let opts = self.opts_for_size(width, height);
923        djvu_render::render_progressive(self.page, &opts, chunk_n).map_err(Self::render_err)
924    }
925
926    /// Number of BG44 refinement chunks on this page (0 for bilevel pages).
927    pub fn bg44_chunk_count(&self) -> usize {
928        self.page.bg44_chunks().len()
929    }
930
931    /// Decode the page thumbnail, if available.
932    pub fn thumbnail(&self) -> Result<Option<Pixmap>, Error> {
933        self.page
934            .thumbnail()
935            .map_err(|e| Error::FormatError(e.to_string()))
936    }
937
938    /// Build this page's thumbnail scaled to fit within `max_w × max_h`
939    /// (aspect-preserving), sourced per `strategy`. The single-page building
940    /// block behind [`Document::thumbnails`] / `thumbnails_with_strategy`;
941    /// see those for the TH44-vs-render quality/speed trade.
942    pub fn thumbnail_with_strategy(
943        &self,
944        max_w: u32,
945        max_h: u32,
946        strategy: ThumbnailStrategy,
947    ) -> Result<Pixmap, Error> {
948        match strategy {
949            ThumbnailStrategy::RenderOnly => self.render_fit_to_box(max_w, max_h),
950            ThumbnailStrategy::Th44Only => {
951                let pm = self.thumbnail()?.ok_or_else(|| {
952                    Error::FormatError(format!(
953                        "page {} has no embedded TH44 thumbnail (Th44Only strategy)",
954                        self.index
955                    ))
956                })?;
957                Self::fit_within(pm, max_w, max_h)
958            }
959            ThumbnailStrategy::Auto => match self.thumbnail()? {
960                Some(pm) => Self::fit_within(pm, max_w, max_h),
961                None => self.render_fit_to_box(max_w, max_h),
962            },
963        }
964    }
965
966    /// Render-fallback path: full decode + downscale to fit `max_w × max_h`.
967    /// Never reads a `TH44` chunk.
968    fn render_fit_to_box(&self, max_w: u32, max_h: u32) -> Result<Pixmap, Error> {
969        let opts = djvu_render::RenderOptions::fit_to_box(self.page, max_w, max_h);
970        self.render_with(&opts)
971    }
972
973    /// If `pm` exceeds `max_w × max_h`, Lanczos-3 downscale it to fit
974    /// (preserving aspect); otherwise return it unchanged. A `TH44`
975    /// thumbnail is capped at `THUMBNAIL_MAX_SIDE` (128 px) by the encoder,
976    /// so this is a no-op whenever the caller's box is at least that big —
977    /// the common case for a thumbnail grid. It is never *upscaled* to fill
978    /// a larger box: doing so would add cost without adding real detail.
979    fn fit_within(pm: Pixmap, max_w: u32, max_h: u32) -> Result<Pixmap, Error> {
980        let max_w = max_w.max(1);
981        let max_h = max_h.max(1);
982        if pm.width <= max_w && pm.height <= max_h {
983            return Ok(pm);
984        }
985        let scale_w = max_w as f64 / pm.width.max(1) as f64;
986        let scale_h = max_h as f64 / pm.height.max(1) as f64;
987        let scale = scale_w.min(scale_h);
988        let tw = ((pm.width as f64 * scale).round() as u32).max(1);
989        let th = ((pm.height as f64 * scale).round() as u32).max(1);
990        crate::pixmap::scale_lanczos3(&pm, tw, th)
991            .map_err(|e| Self::render_err(djvu_render::RenderError::from(e)))
992    }
993
994    /// Extract the text layer (TXTz/TXTa) with zone hierarchy.
995    pub fn text_layer(&self) -> Result<Option<TextLayer>, Error> {
996        self.page
997            .text_layer()
998            .map_err(|e| Error::FormatError(e.to_string()))
999    }
1000
1001    /// Extract the plain text content of the page.
1002    pub fn text(&self) -> Result<Option<String>, Error> {
1003        Ok(self.text_layer()?.map(|tl| tl.text))
1004    }
1005}
1006
1007// Compile-time assertions: Document is Send + Sync.
1008#[cfg(feature = "std")]
1009#[allow(dead_code)]
1010const _: () = {
1011    fn assert_send<T: Send>() {}
1012    fn assert_sync<T: Sync>() {}
1013    fn assertions() {
1014        assert_send::<Document>();
1015        assert_sync::<Document>();
1016    }
1017};
1018
1019#[cfg(test)]
1020mod tests {
1021    use super::*;
1022
1023    fn chicken() -> &'static std::path::Path {
1024        std::path::Path::new("references/djvujs/library/assets/chicken.djvu")
1025    }
1026
1027    fn chicken_bytes() -> Vec<u8> {
1028        std::fs::read(chicken()).unwrap()
1029    }
1030
1031    #[test]
1032    fn document_open_succeeds() {
1033        let doc = Document::open(chicken()).unwrap();
1034        assert!(doc.page_count() > 0);
1035    }
1036
1037    #[test]
1038    fn document_from_reader_succeeds() {
1039        let f = std::fs::File::open(chicken()).unwrap();
1040        let doc = Document::from_reader(f).unwrap();
1041        assert!(doc.page_count() > 0);
1042    }
1043
1044    #[test]
1045    fn document_from_bytes_succeeds() {
1046        let doc = Document::from_bytes(chicken_bytes()).unwrap();
1047        assert!(doc.page_count() > 0);
1048    }
1049
1050    #[test]
1051    fn document_bookmarks_returns_vec() {
1052        let doc = Document::open(chicken()).unwrap();
1053        let bm = doc.bookmarks().unwrap();
1054        // chicken.djvu has no bookmarks
1055        assert!(bm.is_empty() || !bm.is_empty());
1056    }
1057
1058    #[test]
1059    fn document_page_out_of_bounds_returns_error() {
1060        let doc = Document::open(chicken()).unwrap();
1061        assert!(doc.page(999).is_err());
1062    }
1063
1064    #[test]
1065    fn page_dimensions_and_dpi() {
1066        let doc = Document::open(chicken()).unwrap();
1067        let page = doc.page(0).unwrap();
1068        assert!(page.width() > 0);
1069        assert!(page.height() > 0);
1070        assert_eq!(page.display_width(), page.width());
1071        assert_eq!(page.display_height(), page.height());
1072        assert!(page.dpi() > 0);
1073        assert_eq!(page.index(), 0);
1074        let _ = page.rotation();
1075    }
1076
1077    #[test]
1078    fn page_size_at_dpi() {
1079        let doc = Document::open(chicken()).unwrap();
1080        let page = doc.page(0).unwrap();
1081        let (w, h) = page.size_at_dpi(72.0);
1082        assert!(w > 0 && h > 0);
1083    }
1084
1085    #[test]
1086    fn page_render_and_render_to_size() {
1087        let doc = Document::open(chicken()).unwrap();
1088        let page = doc.page(0).unwrap();
1089        let pm = page.render().unwrap();
1090        assert!(pm.width > 0 && pm.height > 0);
1091        let pm2 = page.render_to_size(50, 60).unwrap();
1092        assert_eq!(pm2.width, 50);
1093        assert_eq!(pm2.height, 60);
1094    }
1095
1096    #[test]
1097    fn page_render_with_opts() {
1098        use crate::djvu_render::RenderOptions;
1099        let doc = Document::open(chicken()).unwrap();
1100        let page = doc.page(0).unwrap();
1101        let opts = RenderOptions {
1102            width: 32,
1103            height: 32,
1104            ..Default::default()
1105        };
1106        let pm = page.render_with(&opts).unwrap();
1107        assert!(pm.width > 0);
1108    }
1109
1110    #[test]
1111    fn page_decode_mask_does_not_panic() {
1112        let doc = Document::open(chicken()).unwrap();
1113        let page = doc.page(0).unwrap();
1114        let _ = page.decode_mask().unwrap();
1115    }
1116
1117    #[test]
1118    fn page_thumbnail_does_not_panic() {
1119        let doc = Document::open(chicken()).unwrap();
1120        let page = doc.page(0).unwrap();
1121        let _ = page.thumbnail().unwrap();
1122    }
1123
1124    #[test]
1125    fn page_text_layer_and_text() {
1126        let doc = Document::open(chicken()).unwrap();
1127        let page = doc.page(0).unwrap();
1128        let _ = page.text_layer().unwrap();
1129        let _ = page.text().unwrap();
1130    }
1131
1132    #[test]
1133    fn page_render_with_invalid_dims_returns_error() {
1134        use crate::djvu_render::RenderOptions;
1135        let doc = Document::open(chicken()).unwrap();
1136        let page = doc.page(0).unwrap();
1137        let opts = RenderOptions {
1138            width: 0,
1139            height: 0,
1140            ..Default::default()
1141        };
1142        let err = page.render_with(&opts).unwrap_err();
1143        assert!(
1144            matches!(err, Error::FormatError(_)),
1145            "invalid dims must return FormatError, got {err:?}"
1146        );
1147    }
1148
1149    #[test]
1150    fn document_open_dir_bundled_succeeds() {
1151        // chicken.djvu is bundled, so open_dir also works
1152        let doc = Document::open_dir(chicken()).unwrap();
1153        assert!(doc.page_count() > 0);
1154    }
1155
1156    // ---- TH44 thumbnail-grid API (Document::thumbnails) -----------------
1157
1158    /// Build a tiny 2-page bilevel bundle with TH44 thumbnails embedded via
1159    /// the #476 encoder (`encode_djvm_bundle_jb2_with_thumbnails`).  A high
1160    /// `shared_dict_page_threshold` keeps the encoding path simple (no
1161    /// DJVI/INCL shared dict) so there is exactly one `Sjbz` chunk per page.
1162    fn th44_bundle_bytes() -> Vec<u8> {
1163        let mut p1 = Bitmap::new(64, 48);
1164        for y in 4..44 {
1165            p1.set_black(8, y);
1166            p1.set_black(y % 60, 8);
1167        }
1168        let mut p2 = Bitmap::new(64, 48);
1169        for x in 4..60 {
1170            p2.set_black(x, 20);
1171        }
1172        crate::jb2_encode::encode_djvm_bundle_jb2_with_thumbnails(
1173            &[p1, p2],
1174            100, // threshold higher than page count: no symbol sharing
1175            crate::jb2_encode::BUNDLE_DEFAULT_DPI,
1176            true,
1177        )
1178    }
1179
1180    /// Corrupt every `Sjbz` chunk's payload bytes in place (length field and
1181    /// framing untouched, so the IFF container still parses) so JB2 decode
1182    /// of the background/mask fails while the container and its `TH44`
1183    /// chunks remain intact.
1184    fn corrupt_all_sjbz_payloads(bundle: &mut [u8]) {
1185        let mut search_from = 0usize;
1186        let mut corrupted = 0u32;
1187        while let Some(rel) = bundle[search_from..].windows(4).position(|w| w == b"Sjbz") {
1188            let pos = search_from + rel;
1189            let len_start = pos + 4;
1190            let len = u32::from_be_bytes([
1191                bundle[len_start],
1192                bundle[len_start + 1],
1193                bundle[len_start + 2],
1194                bundle[len_start + 3],
1195            ]) as usize;
1196            let payload_start = len_start + 4;
1197            for b in &mut bundle[payload_start..payload_start + len] {
1198                *b = 0xFF;
1199            }
1200            corrupted += 1;
1201            search_from = payload_start + len;
1202        }
1203        assert!(corrupted > 0, "test bundle must contain Sjbz chunks");
1204    }
1205
1206    #[test]
1207    fn thumbnails_auto_uses_th44_and_matches_th44_only() {
1208        let bundle = th44_bundle_bytes();
1209        let doc = Document::from_bytes(bundle).unwrap();
1210        assert_eq!(doc.page_count(), 2);
1211
1212        let auto = doc.thumbnails(128, 128);
1213        let th44_only = doc.thumbnails_with_strategy(128, 128, ThumbnailStrategy::Th44Only);
1214        assert_eq!(auto.len(), 2);
1215        assert_eq!(th44_only.len(), 2);
1216        for i in 0..2 {
1217            let a = auto[i].as_ref().expect("Auto must succeed");
1218            let t = th44_only[i].as_ref().expect("Th44Only must succeed");
1219            // Auto must have taken the TH44 branch (not render-fallback):
1220            // its pixmap is byte-identical to the explicit Th44Only result.
1221            assert_eq!(a.width, t.width);
1222            assert_eq!(a.height, t.height);
1223            assert_eq!(
1224                a.data, t.data,
1225                "Auto must match Th44Only when TH44 is present"
1226            );
1227        }
1228    }
1229
1230    /// Structural proof that the TH44 path never touches BG44/JB2 decode:
1231    /// corrupt every page's `Sjbz` payload (the JB2-encoded mask/background)
1232    /// so any render attempt fails, while leaving the `TH44` chunks intact.
1233    /// `RenderOnly` must then fail for every page (proving render really
1234    /// depends on the corrupted chunk), while `Th44Only` and `Auto` must
1235    /// still succeed — the only way that's possible is if they never
1236    /// decoded the corrupted Sjbz chunk at all.
1237    #[test]
1238    fn thumbnails_th44_path_never_touches_corrupted_jb2_background() {
1239        let mut bundle = th44_bundle_bytes();
1240        corrupt_all_sjbz_payloads(&mut bundle);
1241        let doc = Document::from_bytes(bundle).unwrap();
1242        assert_eq!(doc.page_count(), 2);
1243
1244        let render_only = doc.thumbnails_with_strategy(128, 128, ThumbnailStrategy::RenderOnly);
1245        for (i, r) in render_only.iter().enumerate() {
1246            assert!(
1247                r.is_err(),
1248                "page {i}: RenderOnly must fail against a corrupted Sjbz chunk"
1249            );
1250        }
1251
1252        let th44_only = doc.thumbnails_with_strategy(128, 128, ThumbnailStrategy::Th44Only);
1253        for (i, r) in th44_only.iter().enumerate() {
1254            assert!(
1255                r.is_ok(),
1256                "page {i}: Th44Only must succeed even with a corrupted Sjbz chunk, got {r:?}"
1257            );
1258        }
1259
1260        let auto = doc.thumbnails(128, 128);
1261        for (i, r) in auto.iter().enumerate() {
1262            assert!(
1263                r.is_ok(),
1264                "page {i}: Auto must take the TH44 fast path and never touch the corrupted background, got {r:?}"
1265            );
1266        }
1267    }
1268
1269    #[test]
1270    fn thumbnails_th44_only_errors_without_th44() {
1271        // chicken.djvu has no embedded TH44 chunks.
1272        let doc = Document::open(chicken()).unwrap();
1273        let results = doc.thumbnails_with_strategy(128, 128, ThumbnailStrategy::Th44Only);
1274        assert_eq!(results.len(), doc.page_count());
1275        for r in &results {
1276            assert!(r.is_err(), "Th44Only must error when no TH44 chunk exists");
1277        }
1278    }
1279
1280    #[test]
1281    fn thumbnails_auto_falls_back_to_render_without_th44() {
1282        // chicken.djvu has no embedded TH44 chunks, so Auto must render.
1283        let doc = Document::open(chicken()).unwrap();
1284        let auto = doc.thumbnails(64, 64);
1285        let render_only = doc.thumbnails_with_strategy(64, 64, ThumbnailStrategy::RenderOnly);
1286        assert_eq!(auto.len(), render_only.len());
1287        for (a, r) in auto.iter().zip(render_only.iter()) {
1288            let a = a.as_ref().expect("Auto must succeed via render fallback");
1289            let r = r.as_ref().expect("RenderOnly must succeed");
1290            assert_eq!(a.width, r.width);
1291            assert_eq!(a.height, r.height);
1292            assert_eq!(
1293                a.data, r.data,
1294                "Auto fallback must match RenderOnly exactly"
1295            );
1296        }
1297    }
1298
1299    #[test]
1300    fn thumbnails_never_upscale_a_small_th44_thumbnail() {
1301        // Request a box bigger than the embedded 128px-cap TH44 thumbnail;
1302        // the result must stay at the TH44's native (small) size, not be
1303        // upscaled to fill the requested box.
1304        let bundle = th44_bundle_bytes();
1305        let doc = Document::from_bytes(bundle).unwrap();
1306        let page = doc.page(0).unwrap();
1307        let native = page.thumbnail().unwrap().expect("page has TH44");
1308        let grid = page
1309            .thumbnail_with_strategy(4096, 4096, ThumbnailStrategy::Th44Only)
1310            .unwrap();
1311        assert_eq!(grid.width, native.width);
1312        assert_eq!(grid.height, native.height);
1313    }
1314
1315    #[test]
1316    fn thumbnails_downscale_an_oversized_th44_thumbnail() {
1317        // Request a box smaller than the embedded thumbnail: it must be
1318        // downscaled to fit, preserving aspect ratio.
1319        let bundle = th44_bundle_bytes();
1320        let doc = Document::from_bytes(bundle).unwrap();
1321        let page = doc.page(0).unwrap();
1322        let native = page.thumbnail().unwrap().expect("page has TH44");
1323        assert!(native.width > 16 || native.height > 16);
1324        let grid = page
1325            .thumbnail_with_strategy(16, 16, ThumbnailStrategy::Th44Only)
1326            .unwrap();
1327        assert!(grid.width <= 16 && grid.height <= 16);
1328        assert!(grid.width >= 1 && grid.height >= 1);
1329    }
1330}