djvu-rs 0.24.3

Pure-Rust DjVu codec — decode and encode DjVu documents. MIT licensed, no GPL dependencies.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
//! DjVu to TIFF exporter — phase 4 format extension.
//!
//! Converts DjVu documents to multi-page TIFF files.
//!
//! ## Key public types
//!
//! - [`TiffOptions`] — export parameters (color vs. bilevel mode)
//! - [`djvu_to_tiff_writer`] — low-memory writer API backed by row-streaming
//! - [`TiffError`] — errors from TIFF conversion
//!
//! ## Modes
//!
//! - **Color** (`TiffMode::Color`): each page is rendered to an RGB Pixmap
//!   and written as a 24-bit RGB TIFF strip.
//! - **Bilevel** (`TiffMode::Bilevel`): the JB2 mask is extracted and written
//!   as an 8-bit grayscale TIFF strip (0 = white, 255 = black). Pages with no
//!   JB2 mask fall back to a blank white page.
//!
//! ## Example
//!
//! ```no_run
//! use djvu_rs::djvu_document::DjVuDocument;
//! use djvu_rs::tiff_export::{djvu_to_tiff, TiffOptions, TiffMode};
//!
//! let data = std::fs::read("input.djvu").unwrap();
//! let doc = DjVuDocument::parse(&data).unwrap();
//! let tiff_bytes = djvu_to_tiff(&doc, &TiffOptions::default()).unwrap();
//! std::fs::write("output.tiff", tiff_bytes).unwrap();
//! ```

use std::io::{Cursor, Seek, Write};

use tiff::encoder::{Rational, TiffEncoder, colortype, compression::Deflate};
use tiff::tags::ResolutionUnit;

use crate::{
    djvu_document::{DjVuDocument, DjVuPage, DocError},
    djvu_render::{self, RenderError, RenderOptions},
};

// ---- Error ------------------------------------------------------------------

/// Errors from TIFF conversion.
#[derive(Debug, thiserror::Error)]
pub enum TiffError {
    /// Document model error.
    #[error("document error: {0}")]
    Doc(#[from] DocError),

    /// Render error.
    #[error("render error: {0}")]
    Render(#[from] RenderError),

    /// TIFF encoding error.
    #[error("TIFF encoding error: {0}")]
    Encode(String),
}

impl From<tiff::TiffError> for TiffError {
    fn from(e: tiff::TiffError) -> Self {
        TiffError::Encode(e.to_string())
    }
}

// ---- Options ----------------------------------------------------------------

/// Rendering mode for TIFF export.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TiffMode {
    /// Render each page as a full-color RGB image (24-bit per pixel).
    #[default]
    Color,
    /// Extract the JB2 foreground mask as an 8-bit grayscale image.
    ///
    /// Pixels set in the JB2 mask are exported as black (255); background as
    /// white (0).  Pages with no JB2 mask are written as blank white pages.
    Bilevel,
}

/// Options for DjVu → TIFF conversion.
#[derive(Debug, Clone)]
pub struct TiffOptions {
    /// Rendering mode.
    pub mode: TiffMode,
    /// Scale factor for color rendering (1.0 = native resolution).
    pub scale: f32,
}

impl Default for TiffOptions {
    fn default() -> Self {
        TiffOptions {
            mode: TiffMode::Color,
            scale: 1.0,
        }
    }
}

// ---- Entry point ------------------------------------------------------------

/// Convert a DjVu document to a multi-page TIFF byte buffer.
///
/// Each page in `doc` produces one IFD in the output TIFF.  Color pages use the
/// row-streaming renderer when the requested options do not require a full-image
/// post-processing pass; unsupported render options automatically fall back to
/// the full-pixmap path.
pub fn djvu_to_tiff(doc: &DjVuDocument, opts: &TiffOptions) -> Result<Vec<u8>, TiffError> {
    let mut buf: Vec<u8> = Vec::new();
    {
        let cursor = Cursor::new(&mut buf);
        djvu_to_tiff_writer(doc, opts, cursor)?;
    }
    Ok(buf)
}

/// Write a DjVu document as a multi-page TIFF to `writer`.
///
/// This is the lowest-memory TIFF export entry point: when color rendering is
/// streamable, rows are passed directly from [`djvu_render::render_streaming`]
/// into TIFF strips without constructing a full output [`crate::Pixmap`] or an
/// intermediate full RGB image.
pub fn djvu_to_tiff_writer<W: Write + Seek>(
    doc: &DjVuDocument,
    opts: &TiffOptions,
    writer: W,
) -> Result<(), TiffError> {
    let mut encoder = TiffEncoder::new(writer)?;

    for i in crate::export_common::page_indices(doc, None) {
        let page = doc.page(i)?;
        match opts.mode {
            TiffMode::Color => write_color_page(&mut encoder, page, opts.scale)?,
            TiffMode::Bilevel => write_bilevel_page(&mut encoder, page)?,
        }
    }
    Ok(())
}

// ---- Per-page helpers -------------------------------------------------------

/// Render `page` as RGB and append one IFD to `encoder`.
fn write_color_page<W: Write + Seek>(
    encoder: &mut TiffEncoder<W>,
    page: &DjVuPage,
    scale: f32,
) -> Result<(), TiffError> {
    let (w, h, opts) = color_render_options(page, scale);
    let dpi = (page.dpi() as f32 * scale).round() as u32;

    if opts.can_stream(page) {
        write_color_page_streaming(encoder, page, &opts, w, h, dpi)
    } else {
        write_color_page_pixmap(encoder, page, &opts, w, h, dpi)
    }
}

fn color_render_options(page: &DjVuPage, scale: f32) -> (u32, u32, RenderOptions) {
    let (w, h) =
        crate::export_common::scaled_size(page.width() as u32, page.height() as u32, scale);

    // Only the size is set; the pipeline derives the decode scale from `width`.
    // The remaining fields (bold/aa/rotation/permissive/resampling) are the
    // `RenderOptions` defaults.
    let opts = RenderOptions {
        width: w,
        height: h,
        ..RenderOptions::default()
    };
    (w, h, opts)
}

fn write_color_page_streaming<W: Write + Seek>(
    encoder: &mut TiffEncoder<W>,
    page: &DjVuPage,
    opts: &RenderOptions,
    w: u32,
    h: u32,
    dpi: u32,
) -> Result<(), TiffError> {
    let mut img = encoder.new_image::<colortype::RGB8>(w, h)?;
    img.resolution(ResolutionUnit::Inch, Rational { n: dpi, d: 1 });

    let mut next_strip_samples = img.next_strip_sample_count() as usize;
    let mut strip = Vec::with_capacity(next_strip_samples);
    let mut encode_error: Option<tiff::TiffError> = None;

    djvu_render::render_streaming(page, opts, |_, rgba_row| {
        if encode_error.is_some() {
            return;
        }

        crate::export_common::rgba_row_to_rgb(&mut strip, rgba_row);

        if strip.len() > next_strip_samples {
            encode_error = Some(
                std::io::Error::new(
                    std::io::ErrorKind::InvalidData,
                    "streamed RGB strip exceeded expected TIFF strip size",
                )
                .into(),
            );
            return;
        }

        if strip.len() == next_strip_samples {
            if let Err(e) = img.write_strip(&strip) {
                encode_error = Some(e);
                return;
            }
            strip.clear();
            next_strip_samples = img.next_strip_sample_count() as usize;
        }
    })?;

    if let Some(e) = encode_error {
        return Err(e.into());
    }
    if !strip.is_empty() || img.next_strip_sample_count() != 0 {
        return Err(TiffError::Encode(
            "streamed render ended before all TIFF strips were written".to_string(),
        ));
    }

    img.finish()?;
    Ok(())
}

fn write_color_page_pixmap<W: Write + Seek>(
    encoder: &mut TiffEncoder<W>,
    page: &DjVuPage,
    opts: &RenderOptions,
    w: u32,
    h: u32,
    dpi: u32,
) -> Result<(), TiffError> {
    let pixmap = djvu_render::render_pixmap(page, opts)?;
    let rgb = pixmap.to_rgb();

    let mut img = encoder.new_image::<colortype::RGB8>(w, h)?;
    img.resolution(ResolutionUnit::Inch, Rational { n: dpi, d: 1 });
    img.write_data(&rgb)?;
    Ok(())
}

/// Extract the JB2 mask from `page` as an 8-bit grayscale strip and append
/// one IFD to `encoder`.
///
/// Black pixels in the mask are written as 255; white background as 0.
/// Pages without a JB2 mask get a blank white page.
fn write_bilevel_page<W: std::io::Write + std::io::Seek>(
    encoder: &mut TiffEncoder<W>,
    page: &DjVuPage,
) -> Result<(), TiffError> {
    let w = page.width() as u32;
    let h = page.height() as u32;

    // Try to extract the JB2 mask directly from the page chunks.
    let gray = extract_bilevel_pixels(page, w, h)?;
    let dpi = page.dpi() as u32;
    // Bilevel content is just 0x00 / 0xFF bytes with long runs (text on white),
    // so Deflate shrinks the Gray8 strip by ~20–50× — far past the 8× of a true
    // 1-bit packing, which the `tiff` crate's high-level encoder cannot emit (no
    // 1-bit ColorType). Deflate (tag 8) is universally readable.
    let mut img =
        encoder.new_image_with_compression::<colortype::Gray8, _>(w, h, Deflate::default())?;
    img.resolution(ResolutionUnit::Inch, Rational { n: dpi, d: 1 });
    img.write_data(&gray)?;
    Ok(())
}

/// Extract the JB2 Sjbz mask as 8-bit grayscale (0=white, 255=black).
///
/// Returns a blank white buffer if no Sjbz chunk is present (pure IW44 page).
/// Returns `Err` if an Sjbz chunk exists but decoding fails.
fn extract_bilevel_pixels(page: &DjVuPage, w: u32, h: u32) -> Result<Vec<u8>, TiffError> {
    let sjbz = match page.find_chunk(b"Sjbz") {
        Some(d) => d,
        None => return Ok(vec![0u8; (w * h) as usize]),
    };

    let dict = page
        .find_chunk(b"Djbz")
        .and_then(|djbz| crate::jb2::decode_dict(djbz, None).ok());

    let bm = crate::jb2::decode(sjbz, dict.as_ref())
        .map_err(|e| TiffError::Encode(format!("JB2 decode failed: {e}")))?;

    let wq = w as usize;
    let mut pixels = vec![0u8; wq * h as usize];

    // LUT byte-expansion: when the decoded mask covers the page, expand each
    // packed mask byte to 8 Gray8 pixels via a 256-entry table instead of one
    // `bm.get()` (stride mult + bit-extract) per pixel. MSB-first packing: pixel
    // x is bit (7 - x%8) of byte x/8, matching `BILEVEL_GRAY8`.
    if bm.width >= w && bm.height >= h {
        let stride = bm.row_stride();
        let nb_full = wq / 8;
        let rem = wq % 8;
        for y in 0..h as usize {
            let row = &bm.data[y * stride..];
            let out = &mut pixels[y * wq..(y + 1) * wq];
            for bi in 0..nb_full {
                out[bi * 8..bi * 8 + 8].copy_from_slice(&BILEVEL_GRAY8[row[bi] as usize]);
            }
            if rem > 0 {
                let src = &BILEVEL_GRAY8[row[nb_full] as usize];
                out[nb_full * 8..nb_full * 8 + rem].copy_from_slice(&src[..rem]);
            }
        }
        return Ok(pixels);
    }

    // Fallback for the unexpected case where the mask is smaller than the page.
    // Bitmap pixels: true = black foreground, false = white background.
    for y in 0..h {
        for x in 0..w {
            pixels[(y * w + x) as usize] = if bm.get(x, y) { 255u8 } else { 0u8 };
        }
    }
    Ok(pixels)
}

/// Maps each packed mask byte (MSB-first) to its 8 expanded Gray8 pixels —
/// bit set (black) → 255, bit clear (white) → 0.
const BILEVEL_GRAY8: [[u8; 8]; 256] = {
    let mut lut = [[0u8; 8]; 256];
    let mut mb = 0usize;
    while mb < 256 {
        let mut j = 0usize;
        while j < 8 {
            lut[mb][j] = if (mb >> (7 - j)) & 1 != 0 { 255u8 } else { 0u8 };
            j += 1;
        }
        mb += 1;
    }
    lut
};

// ---- Tests ------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;
    use crate::djvu_render;

    fn assets_path() -> std::path::PathBuf {
        std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
            .join("references/djvujs/library/assets")
    }

    fn load_doc(filename: &str) -> DjVuDocument {
        let data = std::fs::read(assets_path().join(filename))
            .unwrap_or_else(|_| panic!("{filename} must exist"));
        DjVuDocument::parse(&data).unwrap_or_else(|e| panic!("parse failed: {e}"))
    }

    fn decode_first_tiff_rgb(tiff_bytes: &[u8]) -> (u32, u32, Vec<u8>) {
        let cursor = std::io::Cursor::new(tiff_bytes);
        let mut decoder = tiff::decoder::Decoder::new(cursor).expect("tiff must be decodable");
        let (w, h) = decoder.dimensions().expect("must have dimensions");
        let img = decoder.read_image().expect("image must decode");
        let tiff::decoder::DecodingResult::U8(pixels) = img else {
            panic!("expected RGB8 TIFF pixels");
        };
        (w, h, pixels)
    }

    fn assert_streamed_color_tiff_matches_render_pixmap(filename: &str) {
        let doc = load_doc(filename);
        let page = doc.page(0).unwrap();
        let (_, _, render_opts) = color_render_options(page, 1.0);
        assert!(
            render_opts.can_stream(page),
            "fixture should use the streaming TIFF color path"
        );

        let mut cursor = std::io::Cursor::new(Vec::new());
        djvu_to_tiff_writer(&doc, &TiffOptions::default(), &mut cursor)
            .expect("streamed TIFF writer must succeed");
        let tiff_bytes = cursor.into_inner();
        let (w, h, pixels) = decode_first_tiff_rgb(&tiff_bytes);

        assert_eq!((w, h), (render_opts.width, render_opts.height));
        let expected = djvu_render::render_pixmap(page, &render_opts)
            .expect("render_pixmap must succeed")
            .to_rgb();
        assert_eq!(pixels, expected);
    }

    // ── TDD tests ─────────────────────────────────────────────────────────────

    /// `djvu_to_tiff` produces non-empty bytes for a color document.
    #[test]
    fn color_export_produces_bytes() {
        let doc = load_doc("chicken.djvu");
        let tiff = djvu_to_tiff(&doc, &TiffOptions::default()).expect("color export must succeed");
        assert!(!tiff.is_empty(), "TIFF output must not be empty");
    }

    /// TIFF output starts with the standard TIFF magic bytes (little-endian II or big-endian MM).
    #[test]
    fn output_starts_with_tiff_magic() {
        let doc = load_doc("chicken.djvu");
        let tiff = djvu_to_tiff(&doc, &TiffOptions::default()).unwrap();
        let magic = &tiff[..4];
        assert!(
            magic == b"II\x2A\x00" || magic == b"MM\x00\x2A",
            "must start with TIFF magic, got: {magic:?}"
        );
    }

    /// Bilevel export produces non-empty bytes.
    #[test]
    fn bilevel_export_produces_bytes() {
        let doc = load_doc("boy_jb2.djvu");
        let opts = TiffOptions {
            mode: TiffMode::Bilevel,
            ..Default::default()
        };
        let tiff = djvu_to_tiff(&doc, &opts).expect("bilevel export must succeed");
        assert!(!tiff.is_empty());
    }

    /// Bilevel export also starts with TIFF magic.
    #[test]
    fn bilevel_output_starts_with_tiff_magic() {
        let doc = load_doc("boy_jb2.djvu");
        let opts = TiffOptions {
            mode: TiffMode::Bilevel,
            ..Default::default()
        };
        let tiff = djvu_to_tiff(&doc, &opts).unwrap();
        let magic = &tiff[..4];
        assert!(magic == b"II\x2A\x00" || magic == b"MM\x00\x2A");
    }

    /// Multi-page export: two pages produce more output than one page.
    #[test]
    fn multipage_larger_than_single_page() {
        // Build a two-page DjVu document by concatenating two single-page exports
        // as separate DjVuDocument instances and comparing their individual outputs.
        let doc_a = load_doc("chicken.djvu");
        let doc_b = load_doc("boy.djvu");
        let opts = TiffOptions::default();

        let tiff_a = djvu_to_tiff(&doc_a, &opts).expect("page A export must succeed");
        let tiff_b = djvu_to_tiff(&doc_b, &opts).expect("page B export must succeed");

        // Both single-page TIFFs must be non-trivially sized
        assert!(tiff_a.len() > 100, "page A TIFF must be non-trivial");
        assert!(tiff_b.len() > 100, "page B TIFF must be non-trivial");
    }

    /// Two different single-page documents produce differently-sized TIFFs.
    #[test]
    fn different_pages_produce_different_sizes() {
        let doc_a = load_doc("chicken.djvu");
        let doc_b = load_doc("boy.djvu");
        let opts = TiffOptions::default();

        let tiff_a = djvu_to_tiff(&doc_a, &opts).unwrap();
        let tiff_b = djvu_to_tiff(&doc_b, &opts).unwrap();
        // Different pages have different content, so their TIFFs should differ
        assert_ne!(
            tiff_a.len(),
            tiff_b.len(),
            "different pages must produce different TIFF sizes"
        );
    }

    /// Color export at 0.5 scale produces a smaller file than at 1.0 scale.
    #[test]
    fn scale_factor_reduces_file_size() {
        let doc = load_doc("chicken.djvu");
        let full = djvu_to_tiff(&doc, &TiffOptions::default()).unwrap();
        let half = djvu_to_tiff(
            &doc,
            &TiffOptions {
                scale: 0.5,
                ..Default::default()
            },
        )
        .unwrap();
        assert!(
            half.len() < full.len(),
            "half-scale TIFF must be smaller: half={} full={}",
            half.len(),
            full.len()
        );
    }

    /// Round-trip: exported TIFF can be re-decoded by the `tiff` crate.
    #[test]
    fn color_tiff_round_trips_via_tiff_decoder() {
        let doc = load_doc("chicken.djvu");
        let tiff_bytes = djvu_to_tiff(&doc, &TiffOptions::default()).unwrap();

        let cursor = std::io::Cursor::new(&tiff_bytes);
        let mut decoder = tiff::decoder::Decoder::new(cursor).expect("tiff must be decodable");
        // The first IFD must decode without error and have reasonable dimensions.
        let (w, h) = decoder.dimensions().expect("must have dimensions");
        let page = doc.page(0).unwrap();
        assert_eq!(w, page.width() as u32);
        assert_eq!(h, page.height() as u32);
    }

    /// Streamed color TIFF export matches the existing full-pixmap render path on a color page.
    #[test]
    fn streamed_color_tiff_matches_render_pixmap_color_page() {
        assert_streamed_color_tiff_matches_render_pixmap("chicken.djvu");
    }

    /// Streamed color TIFF export also matches the full-pixmap path on a bilevel page.
    #[test]
    fn streamed_color_tiff_matches_render_pixmap_bilevel_page() {
        assert_streamed_color_tiff_matches_render_pixmap("boy_jb2.djvu");
    }

    /// Bilevel pages with JB2 mask have non-uniform pixel values (some black pixels).
    #[test]
    fn bilevel_jb2_page_has_black_pixels() {
        let doc = load_doc("boy_jb2.djvu");
        let opts = TiffOptions {
            mode: TiffMode::Bilevel,
            ..Default::default()
        };
        let tiff_bytes = djvu_to_tiff(&doc, &opts).unwrap();

        let cursor = std::io::Cursor::new(&tiff_bytes);
        let mut decoder = tiff::decoder::Decoder::new(cursor).unwrap();
        let img = decoder.read_image().unwrap();
        if let tiff::decoder::DecodingResult::U8(pixels) = img {
            let has_black = pixels.contains(&255);
            assert!(
                has_black,
                "bilevel JB2 page must have at least one black pixel"
            );
        }
    }

    /// Bilevel export on a page without JB2 mask returns a blank (all-white) page.
    #[test]
    fn bilevel_blank_when_no_jb2_mask() {
        // chicken.djvu is a color-only document with no JB2 mask
        let doc = load_doc("chicken.djvu");
        let page = doc.page(0).unwrap();
        let w = page.width() as u32;
        let h = page.height() as u32;

        let pixels = extract_bilevel_pixels(page, w, h).unwrap();
        assert!(
            pixels.iter().all(|&p| p == 0),
            "page without JB2 must be all-white (0)"
        );
    }

    /// Color export on a rotated page forces the pixmap path (can_stream returns
    /// false when page.rotation() != None), exercising write_color_page_pixmap.
    #[test]
    fn color_export_rotated_page_uses_pixmap_path() {
        let doc = load_doc("boy_jb2_rotate90.djvu");
        let page = doc.page(0).unwrap();
        // Confirm rotation is set so can_stream returns false
        assert_ne!(
            page.rotation(),
            crate::info::Rotation::None,
            "fixture must have rotation set"
        );
        let tiff =
            djvu_to_tiff(&doc, &TiffOptions::default()).expect("rotated color export must succeed");
        assert!(!tiff.is_empty());
        let magic = &tiff[..4];
        assert!(magic == b"II\x2A\x00" || magic == b"MM\x00\x2A");
    }

    /// `TiffOptions::default()` selects color mode at 1.0 scale.
    #[test]
    fn tiff_options_default() {
        let opts = TiffOptions::default();
        assert_eq!(opts.mode, TiffMode::Color);
        assert!((opts.scale - 1.0).abs() < 1e-6);
    }

    /// `From<tiff::TiffError> for TiffError` wraps the error message.
    #[test]
    fn from_tiff_error_wraps_message() {
        let io_err = std::io::Error::other("test tiff failure");
        let tiff_err: tiff::TiffError = io_err.into();
        let djvu_tiff_err: TiffError = tiff_err.into();
        let s = djvu_tiff_err.to_string();
        assert!(
            s.contains("TIFF encoding error"),
            "must mention TIFF encoding error: {s}"
        );
    }

    /// `TiffError::Encode` display includes the inner message.
    #[test]
    fn tiff_error_display() {
        let e = TiffError::Encode("something went wrong".to_string());
        assert!(e.to_string().contains("something went wrong"));
    }
}