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}