djvu-rs 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,803 @@
1
+ # djvu-rs
2
+
3
+ [![Crates.io](https://badgen.net/crates/v/djvu-rs)](https://crates.io/crates/djvu-rs)
4
+ [![docs.rs](https://docs.rs/djvu-rs/badge.svg)](https://docs.rs/djvu-rs)
5
+ [![CI](https://github.com/matyushkin/djvu-rs/actions/workflows/ci.yml/badge.svg)](https://github.com/matyushkin/djvu-rs/actions/workflows/ci.yml)
6
+ [![Benchmarks](https://img.shields.io/badge/benchmarks-dashboard-blue)](https://matyushkin.github.io/djvu-rs/dev/bench/)
7
+ [![Conformance](https://img.shields.io/badge/conformance-dashboard-blue)](https://matyushkin.github.io/djvu-rs/dev/conformance/)
8
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
9
+
10
+ Read, render, convert, and create DjVu files. Pure-Rust library with a CLI,
11
+ WebAssembly, and Python bindings — MIT licensed, no GPL dependencies, written
12
+ from the public DjVu v3 specification.
13
+
14
+ | Your task | How |
15
+ |-----------|-----|
16
+ | Convert DjVu → PDF, EPUB, TIFF, PNG, CBZ | [`djvu render`](#cli) or [`djvu_to_pdf`](#pdf-export) / [`djvu_to_epub`](#epub-export) / [`djvu_to_tiff`](#tiff-export) |
17
+ | Extract text (plain, hOCR, ALTO XML) | [`djvu text`](#cli) or [`page.text()`](#text-extraction), [`to_hocr` / `to_alto`](#hocr-and-alto-xml-export) |
18
+ | Render pages to RGBA pixels | [`render_pixmap`](#quick-start) — sync, [async](#async-render), or [parallel](#feature-flags) |
19
+ | Show DjVu in the browser | [WebAssembly bindings](#webassembly), incl. lazy HTTP-Range loading |
20
+ | Read DjVu from Python | [PyO3 bindings, built from source](#python) |
21
+ | Create DjVu from images (PNG/JPEG/TIFF) | [`djvu encode`](#cli) or [`PageEncoder`](#encoding--low-level-api) |
22
+ | Add an OCR text layer to a scan | [`djvu ocr`](#ocr-recognition-backends) (Tesseract) |
23
+ | Merge, split, edit documents | [`djvu merge` / `djvu split`](#cli), `DocumentEditor`, `DjVuDocumentMut` |
24
+ | Stream huge books page-by-page | [Lazy async loading](#lazy-async-loading) — first pixel after ~29 KB of a 100 MB file |
25
+
26
+ Every Rust example below is a complete program, compiled as a doctest on every
27
+ CI run — what you copy-paste is guaranteed to build against the current API.
28
+
29
+ ## Quick start
30
+
31
+ ```rust,no_run
32
+ use djvu_rs::{DjVuDocument, djvu_render::{render_pixmap, RenderOptions}};
33
+
34
+ fn main() -> Result<(), Box<dyn std::error::Error>> {
35
+ let data = std::fs::read("file.djvu")?;
36
+ let doc = DjVuDocument::parse(&data)?;
37
+
38
+ println!("{} pages", doc.page_count());
39
+
40
+ let page = doc.page(0)?;
41
+ println!("{}×{} @ {} dpi", page.width(), page.height(), page.dpi());
42
+
43
+ let target_dpi = 150u32;
44
+ let opts = RenderOptions {
45
+ width: ((page.width() as u32 * target_dpi) / page.dpi() as u32).max(1),
46
+ height: ((page.height() as u32 * target_dpi) / page.dpi() as u32).max(1),
47
+ ..Default::default()
48
+ };
49
+ let pixmap = render_pixmap(page, &opts)?;
50
+ // pixmap.data — RGBA bytes (width × height × 4), row-major
51
+ Ok(())
52
+ }
53
+ ```
54
+
55
+ ## Text extraction
56
+
57
+ ```rust,no_run
58
+ use djvu_rs::DjVuDocument;
59
+
60
+ fn main() -> Result<(), Box<dyn std::error::Error>> {
61
+ let data = std::fs::read("scanned.djvu")?;
62
+ let doc = DjVuDocument::parse(&data)?;
63
+ let page = doc.page(0)?;
64
+
65
+ if let Some(text) = page.text()? {
66
+ println!("{text}");
67
+ }
68
+ Ok(())
69
+ }
70
+ ```
71
+
72
+ ## PDF export
73
+
74
+ Requires the `pdf` feature flag: `djvu-rs = { version = "…", features = ["pdf"] }`.
75
+ The PDF keeps selectable text, bookmarks, and hyperlinks, and embeds the
76
+ IW44/JB2 image data losslessly.
77
+
78
+ ```rust,no_run
79
+ use djvu_rs::{DjVuDocument, pdf::djvu_to_pdf};
80
+
81
+ fn main() -> Result<(), Box<dyn std::error::Error>> {
82
+ let data = std::fs::read("book.djvu")?;
83
+ let doc = DjVuDocument::parse(&data)?;
84
+
85
+ let pdf_bytes = djvu_to_pdf(&doc)?;
86
+ std::fs::write("book.pdf", pdf_bytes)?;
87
+ Ok(())
88
+ }
89
+ ```
90
+
91
+ ## CLI
92
+
93
+ The `djvu` binary is enabled by the `cli` feature.
94
+
95
+ ```sh
96
+ # Install
97
+ cargo install djvu-rs --features cli
98
+
99
+ # Document info (--json for machine-readable output, --count for page count only)
100
+ djvu info file.djvu
101
+
102
+ # Inspect IFF chunk identities, offsets, sizes, and bundled component relationships
103
+ djvu inspect book.djvu --json
104
+
105
+ # Layered validation: structural, dependency, codec, and resource findings with
106
+ # stable codes (--strict makes warnings fail the exit code; --decode-pages adds
107
+ # full codec decodes; --limits gates size/page/pixel/memory budgets before decode)
108
+ djvu validate book.djvu --strict --decode-pages --limits server.json --json
109
+
110
+ # Semantic comparison of two documents: pages, text, annotations, metadata,
111
+ # bookmarks, and the component graph (--plane filters the compared planes)
112
+ djvu diff a.djvu b.djvu --plane text --json
113
+
114
+ # Render page 1 to PNG at 200 DPI
115
+ djvu render file.djvu --dpi 200 --output page1.png
116
+
117
+ # Render all pages to a PDF, EPUB, or CBZ
118
+ djvu render file.djvu --all --format pdf --output out.pdf
119
+ djvu render file.djvu --all --format epub --output out.epub
120
+ djvu render file.djvu --all --format cbz --output out.cbz
121
+
122
+ # Render a single layer (mask, foreground, background), with optional rotation
123
+ djvu render file.djvu --layer mask --rotate cw90 --output mask.png
124
+
125
+ # Extract text from page 2 (plain), or from all pages as hOCR / ALTO XML
126
+ djvu text file.djvu --page 2
127
+ djvu text file.djvu --all --format hocr --output out.hocr
128
+
129
+ # Merge documents into a bundled DJVM / extract a page range
130
+ djvu merge a.djvu b.djvu --output merged.djvu
131
+ djvu split book.djvu --pages 10-25 --output chapter.djvu
132
+
133
+ # Preview safe cleanup as machine-readable JSON, or write an optimized copy
134
+ djvu optimize book.djvu --output optimized.djvu --preset lossless-cleanup --dry-run
135
+ djvu optimize book.djvu --output optimized.djvu --preset lossless-cleanup
136
+ djvu optimize book.djvu --output optimized.djvu --preset archival --target-size 26214400
137
+ djvu optimize book.djvu --output optimized.djvu --max-ssim-loss 0.001
138
+
139
+ # Encode an image (PNG, JPEG, or TIFF) into a single-page DjVu (bilevel JB2, lossless)
140
+ # TIFF input requires building/installing with --features tiff (cli alone does not enable it).
141
+ djvu encode scan.png --output scan.djvu --dpi 300
142
+
143
+ # Opt into a DjVuLibre-compatible G4/MMR mask for fax/scanner workflows
144
+ djvu encode scan.png --quality lossless --bilevel-codec smmr --output scan.djvu
145
+
146
+ # Encode into a layered lossy DjVu (JB2 mask + IW44 background + FGbz foreground color)
147
+ djvu encode scan.jpg --quality quality --output scan.djvu --dpi 300
148
+
149
+ # Use the conservative archival color profile
150
+ djvu encode scan.png --quality archival --output scan.djvu --dpi 300
151
+
152
+ # Opt into adaptive mask segmentation for uneven scans
153
+ djvu encode scan.png --quality quality --binarization sauvola --bg-inpaint --output scan.djvu
154
+
155
+ # Cap the IW44 background at a bits-per-pixel budget (smaller file, lower quality)
156
+ djvu encode scan.jpg --quality quality --bg-bpp 0.8 --output scan.djvu
157
+
158
+ # Encode a directory of images into a bundled DJVM with shared Djbz
159
+ djvu encode pages/ --output book.djvu --shared-dict-pages 2
160
+
161
+ # Embed TH44 color thumbnails while bundling (multi-page layered)
162
+ djvu encode pages/ --quality quality --thumbnails --output book.djvu
163
+
164
+ # Raw BZZ compression utilities
165
+ djvu bzz-encode notes.txt --output notes.bzz
166
+ djvu bzz-decode notes.bzz --output notes.txt
167
+ ```
168
+
169
+ For single image input (PNG, JPEG, or TIFF), `--quality lossless`
170
+ luminance-thresholds the image into a JB2 mask and writes `INFO + Sjbz`.
171
+ `--bilevel-codec smmr` is an explicit single-image opt-in that writes a
172
+ DjVuLibre-compatible `Smmr` G4/MMR mask instead; it preserves the default JB2
173
+ path and is not available for directory bundles. The Smmr path is intended for
174
+ fax/scanner interoperability and is usually larger than JB2.
175
+ `--quality quality` uses the layered encoder (`INFO + Sjbz + BG44...` plus
176
+ `FGbz` when colored foreground is detected) for color input. `--quality
177
+ archival` uses the same layered shape with a denser background sample grid.
178
+ Directory input supports all three profiles, and both directory paths share a
179
+ Djbz symbol dictionary across pages: `lossless` uses the shared-Djbz
180
+ multi-page JB2 path, while `quality` / `archival` bundle layered pages that
181
+ keep their own `Sjbz`, `BG44`, and optional `FGbz` chunks on top of the shared
182
+ dictionary. `--shared-dict-pages` sets the page-count threshold for promoting
183
+ a symbol into the shared dictionary on either path.
184
+
185
+ Layered `quality` / `archival` encodes default to fixed BT.601 thresholding.
186
+ `--binarization sauvola` opts into adaptive local thresholding for mixed or
187
+ uneven lighting; tune it with `--sauvola-window` and `--sauvola-k`.
188
+ `--bg-inpaint` fills fully masked background blocks from neighbouring unmasked
189
+ pixels, which can reduce dark boxes under heavy text strokes. These knobs are
190
+ opt-in, only affect layered profiles, and do not change lossless JB2 defaults.
191
+ Library callers can use the same controls with `PageEncoder::with_segment_options`.
192
+ For newly encoded pages, `PageEncoder::with_metadata` emits a `METz` chunk;
193
+ for existing documents, `DjVuDocumentMut::page_mut(...).set_metadata(...)`
194
+ performs a mutation while preserving untouched chunks. These are deliberately
195
+ separate fresh-encode and mutation APIs.
196
+
197
+ ## Python
198
+
199
+ ```sh
200
+ pip install djvu-rs
201
+ ```
202
+
203
+ ```python
204
+ import djvu_rs as djvu
205
+
206
+ doc = djvu.Document.open('scan.djvu')
207
+ print(f'{doc.page_count()} pages')
208
+
209
+ page = doc.page(0)
210
+ img = page.render(dpi=150).to_pil() # or .to_numpy()
211
+ img.save('page.png')
212
+
213
+ text = page.text()
214
+ ```
215
+
216
+ PyO3 bindings live in [`djvu-py/`](djvu-py/). Wheels track the crate version
217
+ (CPython 3.9–3.13 on manylinux/musllinux, macOS, and Windows). The bindings
218
+ cover the reading surface: open documents, render pages (including region and
219
+ progressive rendering, with zero-copy numpy/PIL paths), and extract the text
220
+ layer. Encode, mutation, and PDF/EPUB/TIFF export stay on the Rust crate / CLI
221
+ for now. See [`djvu-py/README.md`](djvu-py/README.md) and
222
+ [`docs/packaging.md`](docs/packaging.md).
223
+
224
+ ## WebAssembly
225
+
226
+ ```sh
227
+ npm install djvu-rs
228
+ ```
229
+
230
+ ```js
231
+ import init, { WasmDocument, selectedWasmVariant } from 'djvu-rs';
232
+
233
+ await init();
234
+ console.log(`djvu-rs wasm variant: ${selectedWasmVariant()}`);
235
+
236
+ const doc = WasmDocument.from_bytes(new Uint8Array(arrayBuffer));
237
+ console.log(doc.page_count());
238
+
239
+ const page = doc.page(0);
240
+ const pixels = page.render(150); // Uint8ClampedArray, RGBA
241
+ const img = new ImageData(pixels, page.width_at(150), page.height_at(150));
242
+ ctx.putImageData(img, 0, 0);
243
+ ```
244
+
245
+ The npm package ships TypeScript declarations plus scalar and `simd128` wasm
246
+ artifacts; at runtime a tiny `WebAssembly.validate()` probe selects SIMD when
247
+ supported. Package versions match the Rust crate — see
248
+ [`docs/packaging.md`](docs/packaging.md).
249
+
250
+ To rebuild the package from this repository:
251
+
252
+ ```sh
253
+ make wasm # → examples/wasm/pkg (dual scalar + simd128 loader)
254
+ ```
255
+
256
+ See [`examples/wasm/`](examples/wasm/) for a complete drag-and-drop demo, and
257
+ [`examples/wasm/range_lazy.md`](examples/wasm/range_lazy.md) for lazy loading
258
+ over HTTP `Range` requests (`wasm-lazy` feature) — the browser fetches only
259
+ the index plus the pages actually opened.
260
+
261
+ ## Advanced usage
262
+
263
+ ### TIFF export
264
+
265
+ Requires the `tiff` feature flag: `djvu-rs = { version = "…", features = ["tiff"] }`.
266
+
267
+ ```rust,no_run
268
+ use djvu_rs::{DjVuDocument, tiff_export::{djvu_to_tiff, TiffOptions}};
269
+
270
+ fn main() -> Result<(), Box<dyn std::error::Error>> {
271
+ let data = std::fs::read("scan.djvu")?;
272
+ let doc = DjVuDocument::parse(&data)?;
273
+
274
+ let tiff_bytes = djvu_to_tiff(&doc, &TiffOptions::default())?;
275
+ std::fs::write("scan.tiff", tiff_bytes)?;
276
+ Ok(())
277
+ }
278
+ ```
279
+
280
+ ### EPUB export
281
+
282
+ Requires the `epub` feature flag: `djvu-rs = { version = "…", features = ["epub"] }`.
283
+ Produces EPUB 3 with page images, an invisible text overlay, and bookmarks as
284
+ navigation.
285
+
286
+ ```rust,no_run
287
+ use djvu_rs::{DjVuDocument, epub::{djvu_to_epub, EpubOptions}};
288
+
289
+ fn main() -> Result<(), Box<dyn std::error::Error>> {
290
+ let data = std::fs::read("book.djvu")?;
291
+ let doc = DjVuDocument::parse(&data)?;
292
+
293
+ let epub_bytes = djvu_to_epub(&doc, &EpubOptions::default())?;
294
+ std::fs::write("book.epub", epub_bytes)?;
295
+ Ok(())
296
+ }
297
+ ```
298
+
299
+ ### hOCR and ALTO XML export
300
+
301
+ ```rust,no_run
302
+ use djvu_rs::{DjVuDocument, text_serialize::{to_hocr, to_alto, HocrOptions, AltoOptions}};
303
+
304
+ fn main() -> Result<(), Box<dyn std::error::Error>> {
305
+ let data = std::fs::read("scanned.djvu")?;
306
+ let doc = DjVuDocument::parse(&data)?;
307
+
308
+ // hOCR — compatible with Tesseract, ABBYY, and most OCR toolchains
309
+ let hocr = to_hocr(&doc, &HocrOptions::default())?;
310
+ std::fs::write("output.hocr", hocr)?;
311
+
312
+ // ALTO XML — used by libraries and archives (DFG, Europeana, etc.)
313
+ let alto = to_alto(&doc, &AltoOptions::default())?;
314
+ std::fs::write("output.xml", alto)?;
315
+ Ok(())
316
+ }
317
+ ```
318
+
319
+ ### Async render
320
+
321
+ Requires the `async` feature flag: `djvu-rs = { version = "…", features = ["async"] }`.
322
+
323
+ The render entry points are synchronous and CPU-bound; run them on the
324
+ blocking thread pool with `tokio::task::spawn_blocking` so they stay off the
325
+ async runtime. The render error type stays the typed `RenderError` — there is
326
+ no wrapper enum.
327
+
328
+ ```rust,no_run
329
+ use djvu_rs::{DjVuDocument, djvu_render::{self, RenderOptions}};
330
+
331
+ #[tokio::main(flavor = "current_thread")]
332
+ async fn main() -> Result<(), Box<dyn std::error::Error>> {
333
+ let data = std::fs::read("file.djvu")?;
334
+ let doc = DjVuDocument::parse(&data)?;
335
+ let page = doc.page(0)?.clone();
336
+
337
+ let target_dpi = 150u32;
338
+ let opts = RenderOptions {
339
+ width: ((page.width() as u32 * target_dpi) / page.dpi() as u32).max(1),
340
+ height: ((page.height() as u32 * target_dpi) / page.dpi() as u32).max(1),
341
+ ..Default::default()
342
+ };
343
+ let pixmap = tokio::task::spawn_blocking(move || {
344
+ djvu_render::render_pixmap(&page, &opts)
345
+ })
346
+ .await??; // outer `?`: join error (panic); inner `?`: RenderError
347
+ println!("{} bytes", pixmap.data.len());
348
+ Ok(())
349
+ }
350
+ ```
351
+
352
+ For progressive (per-BG44-chunk) rendering, `djvu_async::render_progressive_stream`
353
+ yields a `Stream` of frames, each produced on the blocking pool.
354
+
355
+ ### Lazy async loading
356
+
357
+ Requires the `async` feature flag. The lazy loader keeps a seekable async
358
+ reader and fetches page/component byte ranges only when `page_async(i)` is
359
+ called. Parsed pages are cached as `Arc<DjVuPage>`.
360
+
361
+ ```rust,no_run
362
+ use djvu_rs::djvu_async::from_async_reader_lazy;
363
+
364
+ #[tokio::main(flavor = "current_thread")]
365
+ async fn main() -> Result<(), Box<dyn std::error::Error>> {
366
+ let file = tokio::fs::File::open("book.djvu").await?;
367
+ let doc = from_async_reader_lazy(file).await?;
368
+ println!("{} pages", doc.page_count());
369
+
370
+ let page = doc.page_async(0).await?;
371
+ println!("first page: {}×{}", page.width(), page.height());
372
+ Ok(())
373
+ }
374
+ ```
375
+
376
+ Supported shapes: single-page `FORM:DJVU` and bundled `FORM:DJVM`, including
377
+ shared `DJVI` dictionaries referenced via `INCL`. For browser-local `!Send`
378
+ readers on `wasm32`, use `from_async_reader_lazy_local`.
379
+
380
+ See [`examples/async_lazy_first_page.rs`](examples/async_lazy_first_page.rs)
381
+ for a native first-page latency probe and
382
+ [`examples/wasm/range_lazy.md`](examples/wasm/range_lazy.md) for the HTTP
383
+ `Range: bytes=start-end` integration shape.
384
+
385
+ ### Serde support
386
+
387
+ Requires the `serde` feature flag: `djvu-rs = { version = "…", features = ["serde"] }`.
388
+
389
+ All public data types (`DjVuBookmark`, `TextZone`, `MapArea`, `PageInfo`, etc.) implement
390
+ `Serialize` and `Deserialize`.
391
+
392
+ ```rust,no_run
393
+ use djvu_rs::DjVuDocument;
394
+
395
+ fn main() -> Result<(), Box<dyn std::error::Error>> {
396
+ let data = std::fs::read("book.djvu")?;
397
+ let doc = DjVuDocument::parse(&data)?;
398
+
399
+ let json = serde_json::to_string_pretty(doc.bookmarks())?;
400
+ println!("{json}");
401
+ Ok(())
402
+ }
403
+ ```
404
+
405
+ ### image-rs integration
406
+
407
+ Requires the `image` feature flag: `djvu-rs = { version = "…", features = ["image"] }`.
408
+
409
+ ```rust,no_run
410
+ use djvu_rs::{DjVuDocument, image_compat::DjVuDecoder};
411
+ use image::DynamicImage;
412
+
413
+ fn main() -> Result<(), Box<dyn std::error::Error>> {
414
+ let data = std::fs::read("file.djvu")?;
415
+ let doc = DjVuDocument::parse(&data)?;
416
+ let page = doc.page(0)?;
417
+
418
+ let decoder = DjVuDecoder::new(page)?.with_size(1200, 1600);
419
+ let img = DynamicImage::from_decoder(decoder)?;
420
+ img.save("page.png")?;
421
+ Ok(())
422
+ }
423
+ ```
424
+
425
+ ## Encoding & low-level API
426
+
427
+ ### JB2 bilevel image encoder
428
+
429
+ ```rust
430
+ use djvu_rs::{Bitmap, jb2_encode::encode_jb2};
431
+
432
+ fn main() {
433
+ let mut bm = Bitmap::new(800, 1000);
434
+ // ... fill bitmap pixels ...
435
+ let sjbz_payload = encode_jb2(&bm);
436
+ // Wrap in a Sjbz IFF chunk and embed in a DjVu FORM:DJVU.
437
+ assert!(!sjbz_payload.is_empty());
438
+ }
439
+ ```
440
+
441
+ ### IW44 wavelet encoder
442
+
443
+ ```rust
444
+ use djvu_rs::{Pixmap, iw44_encode::{encode_iw44_color, encode_iw44_gray, Iw44EncodeOptions}};
445
+
446
+ fn main() {
447
+ // Color: encode a Pixmap (RGBA) into BG44 chunk payloads.
448
+ let pixmap = Pixmap::new(640, 480, 255, 255, 255, 255);
449
+ let chunks: Vec<Vec<u8>> = encode_iw44_color(&pixmap, &Iw44EncodeOptions::default());
450
+ // Each Vec<u8> is a BG44 chunk payload; wrap each in a BG44 IFF tag.
451
+
452
+ // Grayscale: encode a GrayPixmap the same way.
453
+ let gray = pixmap.to_gray8();
454
+ let gray_chunks: Vec<Vec<u8>> = encode_iw44_gray(&gray, &Iw44EncodeOptions::default());
455
+ assert!(!chunks.is_empty() && !gray_chunks.is_empty());
456
+ }
457
+ ```
458
+
459
+ `Iw44EncodeOptions` fields (all have sensible defaults):
460
+
461
+ | Field | Default | Description |
462
+ |-------|---------|-------------|
463
+ | `slices_per_chunk` | 10 | Slices packed into each BG44/FG44 chunk |
464
+ | `total_slices` | 100 | Total refinement slices to encode |
465
+ | `chroma_delay` | 0 | Y slices before Cb/Cr encoding begins |
466
+ | `chroma_half` | false | Legacy no-op; IW44 v1.2 always emits full-resolution chroma |
467
+
468
+ ### Bookmark encoder
469
+
470
+ ```rust
471
+ use djvu_rs::{djvu_document::DjVuBookmark, navm_encode::encode_navm};
472
+
473
+ fn main() -> Result<(), Box<dyn std::error::Error>> {
474
+ let bookmarks = vec![
475
+ DjVuBookmark { title: "Chapter 1".into(), url: "#page=1".into(), children: vec![] },
476
+ ];
477
+ let navm_payload = encode_navm(&bookmarks)?;
478
+ assert!(!navm_payload.is_empty());
479
+ Ok(())
480
+ }
481
+ ```
482
+
483
+ ### Annotation encoder
484
+
485
+ ```rust
486
+ use djvu_rs::annotation::{Annotation, MapArea, encode_annotations, encode_annotations_bzz};
487
+
488
+ fn main() {
489
+ let ann = Annotation::default();
490
+ let areas: Vec<MapArea> = vec![];
491
+
492
+ let anta_payload = encode_annotations(&ann, &areas); // uncompressed ANTa
493
+ let antz_payload = encode_annotations_bzz(&ann, &areas); // BZZ-compressed ANTz
494
+ assert!(anta_payload.len() <= antz_payload.len() || !antz_payload.is_empty());
495
+ }
496
+ ```
497
+
498
+ ### Indirect multi-page documents
499
+
500
+ Create an indirect DJVM index file that references per-page `.djvu` files:
501
+
502
+ ```rust,no_run
503
+ use djvu_rs::djvm::create_indirect;
504
+
505
+ fn main() -> Result<(), Box<dyn std::error::Error>> {
506
+ let index = create_indirect(&["page001.djvu", "page002.djvu", "page003.djvu"])?;
507
+ std::fs::write("book.djvu", index)?;
508
+ // Distribute book.djvu alongside the individual page files.
509
+ Ok(())
510
+ }
511
+ ```
512
+
513
+ Load an indirect document by resolving component files from a directory:
514
+
515
+ ```rust,no_run
516
+ use djvu_rs::DjVuDocument;
517
+
518
+ fn main() -> Result<(), Box<dyn std::error::Error>> {
519
+ let index = std::fs::read("book.djvu")?;
520
+ let doc = DjVuDocument::parse_from_dir(&index, "/path/to/pages")?;
521
+ println!("{} pages", doc.page_count());
522
+ Ok(())
523
+ }
524
+ ```
525
+
526
+ Applications that need the full DIRM component identity can use
527
+ `DjVuDocument::parse_with_component_resolver`. Its
528
+ `ComponentResolver` receives a `ComponentId` containing both the external name
529
+ and its `ComponentKind` (`Page`, `Shared`, or `Thumbnail`), and is called for
530
+ every directory entry. Page/shared/thumbnail FORM mismatches and resolver
531
+ failures surface as typed errors; shared `Djbz` dictionaries referenced by
532
+ `INCL` are connected to the parsed pages. See
533
+ [`docs/indirect-djvm-resolver.md`](docs/indirect-djvm-resolver.md).
534
+
535
+ Two mutation paths cover indirect documents:
536
+ `DjVuDocumentMut::from_indirect_resolved` resolves the component files and
537
+ rebundles them into a mutable bundled document, and `IndirectRewritePlan`
538
+ rewrites individual component files on disk while keeping the document
539
+ indirect (each file is renamed atomically, but the multi-file commit as a
540
+ whole is not transactional). Opening an indirect index directly with
541
+ `DjVuDocumentMut::from_bytes` and calling `page_mut` remains unsupported; see
542
+ [`docs/indirect-djvm-mutation.md`](docs/indirect-djvm-mutation.md).
543
+
544
+ ### Typed document editing
545
+
546
+ `DocumentEditor` provides a versioned, typed operation list with a semantic
547
+ dry-run plan and validation of every operation before bytes are emitted. The
548
+ current schema covers page text, page annotations, page/document METa/METz
549
+ metadata, and bundled-document NAVM bookmarks:
550
+
551
+ ```rust,no_run
552
+ use djvu_rs::{DocumentEditor, EditOperation, EditRequest};
553
+ use djvu_rs::metadata::DjVuMetadata;
554
+
555
+ fn main() -> Result<(), Box<dyn std::error::Error>> {
556
+ let input = std::fs::read("book.djvu")?;
557
+ let request = EditRequest::new(vec![EditOperation::SetDocumentMetadata {
558
+ metadata: DjVuMetadata {
559
+ title: Some("Updated title".into()),
560
+ ..Default::default()
561
+ },
562
+ }]);
563
+
564
+ let plan = DocumentEditor::plan(&input, &request)?;
565
+ println!("{} operation(s), {} page(s)", plan.operations.len(), plan.page_count);
566
+ let edited = DocumentEditor::apply(&input, &request)?;
567
+ std::fs::write("edited.djvu", edited)?;
568
+ Ok(())
569
+ }
570
+ ```
571
+
572
+ `DocumentEditor::apply_to_path` stages output beside the destination and
573
+ renames it only after validation, serialization, and sync succeed. The first
574
+ slice intentionally does not yet cover the declarative CLI, XMP, thumbnails,
575
+ page insertion/deletion/reordering/extraction, semantic diff, or multi-file
576
+ indirect-DJVM commits; those require separate operation and commit contracts.
577
+ With the `serde` feature, requests and plans are JSON-serializable using the
578
+ versioned schema.
579
+
580
+ ### Low-level IFF access
581
+
582
+ ```rust,no_run
583
+ use djvu_rs::iff::parse_form;
584
+
585
+ fn main() -> Result<(), Box<dyn std::error::Error>> {
586
+ let data = std::fs::read("file.djvu")?;
587
+ let form = parse_form(&data)?;
588
+ println!("FORM type: {:?}", std::str::from_utf8(&form.form_type));
589
+ for chunk in &form.chunks {
590
+ println!(" chunk {:?} ({} bytes)", std::str::from_utf8(&chunk.id), chunk.data.len());
591
+ }
592
+ Ok(())
593
+ }
594
+ ```
595
+
596
+ ### OCR recognition backends
597
+
598
+ The supported OCR recognition path is the `ocr-tesseract` feature, which uses a
599
+ system Tesseract installation and tessdata files. Recognized text is embedded
600
+ into the output document as a compressed `TXTz` text layer, page by page:
601
+
602
+ ```sh
603
+ cargo build --features cli,ocr-tesseract
604
+ # Requires Tesseract + the requested language data, e.g. eng.traineddata.
605
+ djvu ocr scanned.djvu --backend tesseract --lang eng --output with-text.djvu
606
+ ```
607
+
608
+ Library callers can attach recognized text at encode time instead, via
609
+ `PageEncoder::with_ocr_text_layer` (or `with_text_layer` for an existing
610
+ `TextLayer`).
611
+
612
+ `ocr-onnx` is experimental but now CLI-live (#693): `--backend onnx` runs the
613
+ full PP-OCR neural pipeline — DBNet text detection plus Cyrillic PP-OCRv5 CTC
614
+ line recognition (its pinned dictionary also covers Latin, digits, and
615
+ punctuation) assembled into a `page → line → word` text layer with heuristic
616
+ word rectangles. Models come only from the pinned manifest with mandatory
617
+ SHA-256 verification (`docs/ocr-model-manifest.toml`, fetched explicitly via
618
+ `scripts/fetch_ocr_models.sh` — weights are never committed and never
619
+ downloaded implicitly; directory override: `DJVU_OCR_MODELS_DIR`). The
620
+ `--model` flag is not used by this backend, and `OcrOptions`
621
+ (`languages`/`dpi`) are advisory and ignored. Recognition quality of the
622
+ pinned models is gated by a deterministic synthetic corpus with a recorded
623
+ CER/WER/IoU baseline (`docs/ocr-model-metrics.md`). `ocr-neural` is a placeholder only: `CandleBackend` now
624
+ returns a clear unsupported-backend error instead of constructing a backend that
625
+ always fails at recognition time. The compatibility feature name
626
+ `ocr-neural-candle` is a no-op and no longer pulls Candle/tokenizers into
627
+ `--all-features` builds.
628
+
629
+ ## Format coverage
630
+
631
+ Chunk-level coverage of the DjVu v3 format, for readers who need to know
632
+ exactly what decodes and what encodes:
633
+
634
+ The fresh-encode versus existing-document mutation contract is expanded in
635
+ [`docs/writer-coverage.md`](docs/writer-coverage.md).
636
+
637
+ | Format element | Decode | Encode |
638
+ |----------------|--------|--------|
639
+ | IFF container (`FORM:DJVU`, `FORM:DJVM`) | ✓ zero-copy parser | ✓ |
640
+ | JB2 bilevel images (`Sjbz`), shared dictionaries (`Djbz` via `INCL`) | ✓ ZP arithmetic coding + symbol dictionary | ✓ incl. multi-page shared Djbz |
641
+ | IW44 wavelet images (`BG44` / `FG44`) | ✓ planar YCbCr, multiple refinement chunks | ✓ color and grayscale |
642
+ | G4/MMR fax images (`Smmr`, ITU-T T.6) | ✓ | ✓ (explicit `BilevelCodec::Smmr` / `--bilevel-codec smmr`) |
643
+ | JPEG background/foreground (`BGjp` / `FGjp`) | ✓ | — (encoder emits IW44) |
644
+ | Foreground palette (`FGbz`) | ✓ | ✓ (layered encoder) |
645
+ | BZZ compression (BWT + MTF + ZP) | ✓ | ✓ |
646
+ | Text layer (`TXTa` / `TXTz`), zone hierarchy down to characters | ✓ | ✓ (incl. OCR injection) |
647
+ | Annotations (`ANTa` / `ANTz`): hyperlinks, map areas, colors | ✓ | ✓ |
648
+ | Bookmarks (`NAVM`) | ✓ | ✓ |
649
+ | Multi-page directory (`DIRM`), bundled and indirect | ✓ | ✓ (DjVuLibre-clean directory v1) |
650
+ | Thumbnails (`TH44`) | ✓ | ✓ (`--thumbnails`) |
651
+ | Metadata (`METa` / `METz`) | ✓ | ✓ (`PageEncoder::with_metadata`; `PageMut::set_metadata`) |
652
+ | Legacy standalone `FORM:BM44` / `FORM:PM44` files | ✓ | — |
653
+ | Unknown chunk IDs | preserved byte-exact for round-trip | n/a |
654
+
655
+ The codec internals are also published as standalone workspace crates for
656
+ focused consumers: [`djvu-iff`](crates/djvu-iff), [`djvu-bzz`](crates/djvu-bzz),
657
+ [`djvu-bitmap`](crates/djvu-bitmap), [`djvu-jb2`](crates/djvu-jb2),
658
+ [`djvu-pixmap`](crates/djvu-pixmap), [`djvu-iw44`](crates/djvu-iw44), and
659
+ [`djvu-zp`](crates/djvu-zp). All of them (and the codec modules of the main
660
+ crate) are `no_std`-compatible with `alloc` only, and are continuously fuzzed
661
+ via in-tree libFuzzer targets and OSS-Fuzz project files.
662
+
663
+ ## Status & limitations
664
+
665
+ Honest boundaries, so you can decide fast:
666
+
667
+ - **Library + CLI, not a viewer.** There is no GUI; the WASM demo is the
668
+ closest thing to one.
669
+ - **Python bindings are source-only for now.** The `djvu-py` package is not
670
+ published to PyPI yet — install it from the repository checkout.
671
+ - **Indirect DJVM mutation is indirect-only via two paths.**
672
+ `DjVuDocumentMut::from_bytes` + `page_mut` on an indirect index errors;
673
+ use `from_indirect_resolved` (rebundles) or `IndirectRewritePlan` (rewrites
674
+ component files; per-file atomic, whole-commit not transactional).
675
+ - **Lazy async loading does not cover indirect DJVM** — bundled `FORM:DJVM`
676
+ and single-page `FORM:DJVU` only; indirect returns a clean `Unsupported`
677
+ error.
678
+ - **`create_indirect` does not emit shared `DJVI` dictionary components** —
679
+ build a bundled document with `djvu merge` when pages share a dictionary.
680
+ - **Encoder size parity is corpus- and profile-dependent.** Run the
681
+ reproducible [`encoder parity scorecard`](docs/encoder-parity.md) to compare
682
+ the same raster through DjVuLibre 3.5.29's `c44`/`cjb2` and the archival-safe
683
+ `PageEncoder` profiles. The 2026-07-16 snapshot ranges from 1.025–1.040×
684
+ `c44` for IW44 photo pages — at matched-or-better fidelity (decoded PSNR/SSIM
685
+ meet or exceed `c44` on the measured pages) — and 0.952–2.100× `cjb2` for the
686
+ public direct JB2 lossless profile; every measured output passed its
687
+ interop/fidelity gate. The earlier IW44 gap (up to 1.345×, and lower fidelity)
688
+ came from two encoder bugs since fixed: an activation threshold that stranded
689
+ dense-page coefficients (`IW44_LUMA_PLATEAU`) and a colour transform that did
690
+ not match the decoder's Pigeon `YCbCr` basis (`IW44_PIGEON_COLOR`); see
691
+ `PERF_EXPERIMENTS.md`. Same-size record-6 and lossy rec-7 remain experimental
692
+ and are tracked in [`docs/jb2-size-gap-plan.md`](docs/jb2-size-gap-plan.md).
693
+ - **Document optimization is conservative in the first slice.** `djvu optimize`
694
+ currently removes only semantically inert `FREE` padding and reports unmet
695
+ size targets; archival codec search, progress callbacks, and cancellation
696
+ remain planned. See [`docs/optimizer.md`](docs/optimizer.md); it always writes
697
+ a separate output file.
698
+ - **OCR: Tesseract is the supported recognition backend.** `OcrOptions`
699
+ (languages, dpi) are honored by Tesseract only; the `ocr-onnx` neural
700
+ pipeline is CLI-live but experimental (fixed pinned models, options
701
+ ignored) and `ocr-neural` is a placeholder that returns an error.
702
+
703
+ ## Feature flags
704
+
705
+ | Flag | Default | Description |
706
+ |------|---------|-------------|
707
+ | `std` | enabled | `DjVuDocument`, file I/O, rendering — the decode-only surface |
708
+ | `pdf` | disabled | PDF export via `djvu_to_pdf` (owns `miniz_oxide` + `jpeg-encoder`) |
709
+ | `cli` | disabled | Build the `djvu` command-line binary (implies `pdf` and `cbz`) |
710
+ | `cbz` | disabled | CBZ (comic-book ZIP) export — backs `render --format cbz` (owns `zip`) |
711
+ | `tiff` | disabled | TIFF export (`djvu_to_tiff`) **and** TIFF encode input for `djvu encode` / `decode_image_to_pixmap` |
712
+ | `async` | disabled | Async render API and lazy `AsyncRead + AsyncSeek` document loading |
713
+ | `parallel` | disabled | Parallel multi-page render via `rayon` (`render_pages_parallel`) |
714
+ | `jpeg` | disabled | Standalone JPEG decode without full `std` (JPEG is included in `std` by default) |
715
+ | `mmap` | disabled | Memory-mapped file I/O via `memmap2` (`MmapDocument::open`) |
716
+ | `serde` | disabled | `Serialize` + `Deserialize` for all public data types |
717
+ | `image` | disabled | `image::ImageDecoder` impl via `DjVuDecoder` — integrates with the `image` crate |
718
+ | `epub` | disabled | EPUB 3 export via `djvu_to_epub` — page images, text overlay, bookmarks as nav (owns `zip`) |
719
+ | `wasm` | disabled | WebAssembly bindings via `wasm-bindgen` (`WasmDocument`, `WasmPage`) |
720
+ | `wasm-lazy` | disabled | Lazy Range-based document loading in the browser: a JS `(offset, len)` reader fetches only the pages you open |
721
+ | `wasm-threads` | disabled | wasm32 thread pool (rayon via Web Workers); requires a nightly toolchain, not part of the stable CI gate |
722
+ | `ocr-tesseract` | disabled | OCR recognition via a system Tesseract installation (the supported OCR backend) |
723
+ | `ocr-onnx` | disabled | Experimental neural OCR via `tract-onnx` (#693): pinned manifest + SHA-256-verified weights, DBNet detection, Cyrillic CTC recognition, CLI `--backend onnx` |
724
+ | `ocr-neural` | disabled | Placeholder backend only — `CandleBackend::load` returns a clear unsupported error |
725
+ | `ocr-neural-candle` | disabled | Deprecated no-op alias for `ocr-neural` |
726
+ | `experimental` | disabled | Experimental JB2 encoder paths used by internal example binaries |
727
+ | `iw44-probe` | disabled | IW44 encoder diagnostics probe (dev-only) |
728
+ | `alloc-profile` | disabled | dhat allocation-profiling harness for `examples/alloc_profile.rs` (dev-only) |
729
+
730
+ Without `std`, the crate provides IFF parsing, BZZ decompression, JB2/IW44 decoding,
731
+ text/annotation parsing — all codec primitives that work on byte slices.
732
+
733
+ ## API stability & compatibility
734
+
735
+ The full contract lives in [`docs/api-compatibility.md`](docs/api-compatibility.md)
736
+ (policy) and [`docs/feature-matrix.md`](docs/feature-matrix.md) (supported
737
+ combinations and targets), and is enforced in CI. In short:
738
+
739
+ - **Stable surface** — the document model (`Document`/`Page`, `DjVuDocument`/
740
+ `DjVuPage`), the render entry points, the codec entry points, the parsers, and
741
+ the writer `djvu_to_*` functions. Follows SemVer; breakage is caught by
742
+ `cargo-semver-checks`.
743
+ - **Experimental / placeholder** — `experimental`, `iw44-probe`, `alloc-profile`,
744
+ `ocr-onnx`, `wasm-threads`, and the `ocr-neural` placeholder. These may change
745
+ in any release and are the ones marked *Experimental*/*Placeholder* in the
746
+ feature table above.
747
+ - **Deprecated (kept for ≥ 2 minor releases / 90 days)** — the `bzz_new` and
748
+ `iw44_new` module aliases and the `ocr-neural-candle` feature alias.
749
+ - **MSRV** — Rust 1.88, a required CI gate.
750
+ - **Thread-safety** — `Document`, `DjVuDocument`, `DjVuPage`, pixel buffers, and
751
+ the parsed content/error types are `Send + Sync`; the mutable editor is
752
+ `Send`; `LazyDocument<R>` inherits its thread-safety from `R`. Asserted in
753
+ [`tests/send_sync_contract.rs`](tests/send_sync_contract.rs).
754
+ - **Untrusted input** — no public parse/decode/render entry point panics on any
755
+ input; malformed bytes surface as typed errors. Covered by
756
+ [`tests/panic_free_corpus.rs`](tests/panic_free_corpus.rs), proptests, and
757
+ libFuzzer/OSS-Fuzz targets.
758
+ - **Resource limits** — decode/render inherit documented, bounded memory/work
759
+ ceilings; exceeding one returns a typed error naming the codec and axis. See
760
+ [`SECURITY.md`](SECURITY.md#decode-time-resource-ceilings).
761
+
762
+ ## Performance
763
+
764
+ See [BENCHMARKS_RESULTS.md](BENCHMARKS_RESULTS.md) for Criterion numbers,
765
+ methodology, and a DjVuLibre comparison (run via
766
+ [`scripts/bench_djvulibre.sh`](scripts/bench_djvulibre.sh) +
767
+ [`scripts/djvulibre_compare.py`](scripts/djvulibre_compare.py)).
768
+ Historical multi-platform results are in [BENCHMARKS.md](BENCHMARKS.md),
769
+ including the local WASM scalar-vs-simd128 harness.
770
+
771
+ Recent targeted experiments are recorded in
772
+ [PERF_EXPERIMENTS.md](PERF_EXPERIMENTS.md), including:
773
+
774
+ - **#233 lazy async loading:** a 100 MiB padded 520-page DJVM reached first
775
+ pixel in **491.469 ms** while reading only **28,578 bytes** at simulated
776
+ 12.5 MiB/s throughput.
777
+ - **#189 x86-64-v3 AVX2 validation:** existing AVX2 decode paths showed
778
+ `iw44_decode_corpus_color` **-18.88%** and `iw44_decode_first_chunk`
779
+ **-4.85%** on GitHub-hosted x86_64, with one sub4 partial-decode regression
780
+ recorded for follow-up.
781
+ - **#258 shared-Djbz clustering:** Hamming shared clustering was rejected as
782
+ default; byte-exact shared-Djbz remains the measured safe path.
783
+
784
+ ## Minimum supported Rust version (MSRV)
785
+
786
+ Rust **1.88** (edition 2024 — let-chains stabilized in 1.88)
787
+
788
+ ## Roadmap
789
+
790
+ See [GitHub milestones](https://github.com/matyushkin/djvu-rs/milestones) for the full roadmap and progress tracking.
791
+
792
+ ## License
793
+
794
+ MIT. See [LICENSE](LICENSE).
795
+
796
+ ## Specification
797
+
798
+ Written from the public DjVu v3 specification:
799
+ - https://www.sndjvu.org/spec.html
800
+ - https://djvu.sourceforge.net/spec/DjVu3Spec.djvu (the spec is itself a DjVu file)
801
+
802
+ No code derived from GPL-licensed DjVuLibre or any other GPL source.
803
+ All algorithms are independent implementations from the spec.