Skip to main content

djvu_rs/
tiff_export.rs

1//! DjVu to TIFF exporter — phase 4 format extension.
2//!
3//! Converts DjVu documents to multi-page TIFF files.
4//!
5//! ## Key public types
6//!
7//! - [`TiffOptions`] — export parameters (color vs. bilevel mode)
8//! - [`djvu_to_tiff_writer`] — low-memory writer API backed by row-streaming
9//! - [`TiffError`] — errors from TIFF conversion
10//!
11//! ## Modes
12//!
13//! - **Color** (`TiffMode::Color`): each page is rendered to an RGB Pixmap
14//!   and written as a 24-bit RGB TIFF strip.
15//! - **Bilevel** (`TiffMode::Bilevel`): the JB2 mask is extracted and written
16//!   as an 8-bit grayscale TIFF strip (0 = white, 255 = black). Pages with no
17//!   JB2 mask fall back to a blank white page.
18//!
19//! ## Example
20//!
21//! ```no_run
22//! use djvu_rs::djvu_document::DjVuDocument;
23//! use djvu_rs::tiff_export::{djvu_to_tiff, TiffOptions, TiffMode};
24//!
25//! let data = std::fs::read("input.djvu").unwrap();
26//! let doc = DjVuDocument::parse(&data).unwrap();
27//! let tiff_bytes = djvu_to_tiff(&doc, &TiffOptions::default()).unwrap();
28//! std::fs::write("output.tiff", tiff_bytes).unwrap();
29//! ```
30
31use std::io::{Cursor, Seek, Write};
32
33use tiff::encoder::{Rational, TiffEncoder, colortype, compression::Deflate};
34use tiff::tags::ResolutionUnit;
35
36use crate::{
37    djvu_document::{DjVuDocument, DjVuPage, DocError},
38    djvu_render::{self, RenderError, RenderOptions},
39    export_control::{ExportObserver, NoOpObserver},
40};
41
42// ---- Error ------------------------------------------------------------------
43
44/// Errors from TIFF conversion.
45#[derive(Debug, thiserror::Error)]
46#[non_exhaustive]
47pub enum TiffError {
48    /// Document model error.
49    #[error("document error: {0}")]
50    Doc(#[from] DocError),
51
52    /// Render error.
53    #[error("render error: {0}")]
54    Render(#[from] RenderError),
55
56    /// TIFF encoding error.
57    #[error("TIFF encoding error: {0}")]
58    Encode(String),
59
60    /// Export was cancelled by its observer.
61    #[error("export cancelled")]
62    Cancelled,
63}
64
65impl From<tiff::TiffError> for TiffError {
66    fn from(e: tiff::TiffError) -> Self {
67        TiffError::Encode(e.to_string())
68    }
69}
70
71// ---- Options ----------------------------------------------------------------
72
73/// Rendering mode for TIFF export.
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
75pub enum TiffMode {
76    /// Render each page as a full-color RGB image (24-bit per pixel).
77    #[default]
78    Color,
79    /// Extract the JB2 foreground mask as an 8-bit grayscale image.
80    ///
81    /// Pixels set in the JB2 mask are exported as black (255); background as
82    /// white (0).  Pages with no JB2 mask are written as blank white pages.
83    Bilevel,
84}
85
86/// Compression choice for [`TiffMode::Bilevel`] pages (#579).
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
88pub enum TiffBilevelCompression {
89    /// 8-bit grayscale strips with Deflate — the historical default.
90    #[default]
91    Deflate,
92    /// 1-bit CCITT Group 4 (T.6) via [`crate::smmr::encode_g4`] — the native
93    /// archival compression for bilevel scans. Written by a minimal in-crate
94    /// IFD writer (the `tiff` crate has no CCITT encoder); validated against
95    /// libtiff/Pillow. Ignored in [`TiffMode::Color`].
96    G4,
97}
98
99/// Options for DjVu → TIFF conversion.
100#[derive(Debug, Clone)]
101pub struct TiffOptions {
102    /// Rendering mode.
103    pub mode: TiffMode,
104    /// Scale factor for color rendering (1.0 = native resolution).
105    pub scale: f32,
106    /// Compression for bilevel pages (default: the historical Deflate).
107    pub bilevel_compression: TiffBilevelCompression,
108}
109
110impl Default for TiffOptions {
111    fn default() -> Self {
112        TiffOptions {
113            mode: TiffMode::Color,
114            scale: 1.0,
115            bilevel_compression: TiffBilevelCompression::default(),
116        }
117    }
118}
119
120// ---- Entry point ------------------------------------------------------------
121
122/// Convert a DjVu document to a multi-page TIFF byte buffer.
123///
124/// Each page in `doc` produces one IFD in the output TIFF.  Color pages use the
125/// row-streaming renderer when the requested options do not require a full-image
126/// post-processing pass; unsupported render options automatically fall back to
127/// the full-pixmap path.
128pub fn djvu_to_tiff(doc: &DjVuDocument, opts: &TiffOptions) -> Result<Vec<u8>, TiffError> {
129    let mut buf: Vec<u8> = Vec::new();
130    {
131        let cursor = Cursor::new(&mut buf);
132        djvu_to_tiff_writer(doc, opts, cursor)?;
133    }
134    Ok(buf)
135}
136
137/// Write a DjVu document as a multi-page TIFF to `writer`.
138///
139/// This is the lowest-memory TIFF export entry point: when color rendering is
140/// streamable, rows are passed directly from [`djvu_render::render_streaming`]
141/// into TIFF strips without constructing a full output [`crate::Pixmap`] or an
142/// intermediate full RGB image. Bilevel G4 export first makes a sizing pass
143/// that renders and encodes each page only to retain its dimensions, DPI, and
144/// encoded length; it then renders and encodes each page again while writing
145/// its strip and IFD. This keeps only one G4 payload active at a time, at the
146/// cost of doubling G4 render-and-encode CPU. Progress for that mode is
147/// reported only during the second, writing pass.
148///
149/// # Errors
150///
151/// On error, `writer` may contain a partial TIFF; the library does not clean it
152/// up or provide atomic replacement (that policy belongs to the CLI/application
153/// layer).
154pub fn djvu_to_tiff_writer<W: Write + Seek>(
155    doc: &DjVuDocument,
156    opts: &TiffOptions,
157    writer: W,
158) -> Result<(), TiffError> {
159    let mut observer = NoOpObserver;
160    djvu_to_tiff_writer_with_observer(doc, opts, writer, &mut observer)
161}
162
163/// Convert a DjVu document to TIFF while reporting progress through `observer`.
164///
165/// With the `parallel` feature, cancellation is polled before the parallel
166/// image-build batch. Work already scheduled in that batch may complete before
167/// the cancellation is observed.
168///
169/// On error, `writer` may contain a partial TIFF; the library does not clean it
170/// up or provide atomic replacement (that policy belongs to the CLI/application
171/// layer).
172pub fn djvu_to_tiff_writer_with_observer<W: Write + Seek>(
173    doc: &DjVuDocument,
174    opts: &TiffOptions,
175    writer: W,
176    observer: &mut dyn ExportObserver,
177) -> Result<(), TiffError> {
178    if opts.mode == TiffMode::Bilevel && opts.bilevel_compression == TiffBilevelCompression::G4 {
179        return write_bilevel_g4_tiff(doc, writer, observer);
180    }
181    let mut encoder = TiffEncoder::new(writer)?;
182    let indices: Vec<usize> = crate::export_common::page_indices(doc, None).collect();
183    let total = indices.len();
184
185    // Building a page's pixel buffer (color: render → RGB; bilevel: JB2 decode →
186    // Gray8) is independent and CPU-heavy per page; only appending IFDs to the
187    // single `TiffEncoder` must stay serial. With the `parallel` feature, build
188    // every page's image concurrently via rayon, then write them in index order
189    // — the same shape as the PDF/EPUB parallel exporters. Output is byte-
190    // identical: the materialised RGB matches the streaming path (asserted by
191    // `streamed_color_tiff_matches_render_pixmap*`), and the encoder produces the
192    // same IFDs from the same pixels. This trades the sequential path's
193    // row-streaming O(1)-page memory for wall-time, so it is gated to the feature.
194    #[cfg(feature = "parallel")]
195    {
196        use rayon::prelude::*;
197        if observer.cancelled() {
198            return Err(TiffError::Cancelled);
199        }
200        let images: Vec<PageImage> = indices
201            .par_iter()
202            .map(|&i| {
203                // #629: cold clone — decode caches drop with the page.
204                let page = doc.page(i)?.clone();
205                build_page_image(&page, opts)
206            })
207            .collect::<Result<Vec<_>, TiffError>>()?;
208        for (done, img) in images.iter().enumerate() {
209            if observer.cancelled() {
210                return Err(TiffError::Cancelled);
211            }
212            write_page_image(&mut encoder, img)?;
213            observer.on_progress(done + 1, total);
214        }
215    }
216
217    #[cfg(not(feature = "parallel"))]
218    for (done, &i) in indices.iter().enumerate() {
219        if observer.cancelled() {
220            return Err(TiffError::Cancelled);
221        }
222        // #629: cold clone — decode caches drop with the page.
223        let page = doc.page(i)?.clone();
224        match opts.mode {
225            TiffMode::Color => write_color_page(&mut encoder, &page, opts.scale)?,
226            TiffMode::Bilevel => write_bilevel_page(&mut encoder, &page)?,
227        }
228        observer.on_progress(done + 1, total);
229    }
230    Ok(())
231}
232
233/// A page's fully-materialised pixel buffer, ready to append as one TIFF IFD.
234/// This is the `Send`-safe unit the parallel exporter builds off-thread; writing
235/// it into the shared `TiffEncoder` is the serial tail.
236#[cfg(feature = "parallel")]
237struct PageImage {
238    w: u32,
239    h: u32,
240    dpi: u32,
241    data: PageImageData,
242}
243
244#[cfg(feature = "parallel")]
245enum PageImageData {
246    /// 24-bit RGB strip (color mode).
247    Rgb(Vec<u8>),
248    /// 8-bit grayscale strip written with Deflate compression (bilevel mode).
249    GrayDeflate(Vec<u8>),
250}
251
252/// Produce one page's pixel buffer without touching the encoder. Mirrors the
253/// sequential per-mode dispatch so the resulting IFD is byte-identical.
254#[cfg(feature = "parallel")]
255fn build_page_image(page: &DjVuPage, opts: &TiffOptions) -> Result<PageImage, TiffError> {
256    match opts.mode {
257        TiffMode::Color => {
258            let (w, h, ropts) = color_render_options(page, opts.scale);
259            let dpi = (page.dpi() as f32 * opts.scale).round() as u32;
260            // Materialise the same RGB the sequential path writes: collect the
261            // streamed rows when streamable (byte-identical to the strip path),
262            // else fall back to the full-pixmap path for non-streamable options.
263            let rgb = if ropts.can_stream(page) {
264                let mut rgb = Vec::with_capacity(w as usize * h as usize * 3);
265                djvu_render::render_streaming(page, &ropts, |_, rgba_row| {
266                    crate::export_common::rgba_row_to_rgb(&mut rgb, rgba_row);
267                })?;
268                rgb
269            } else {
270                djvu_render::render_pixmap(page, &ropts)?.to_rgb()
271            };
272            Ok(PageImage {
273                w,
274                h,
275                dpi,
276                data: PageImageData::Rgb(rgb),
277            })
278        }
279        TiffMode::Bilevel => {
280            let w = page.width() as u32;
281            let h = page.height() as u32;
282            let gray = extract_bilevel_pixels(page, w, h)?;
283            let dpi = page.dpi() as u32;
284            Ok(PageImage {
285                w,
286                h,
287                dpi,
288                data: PageImageData::GrayDeflate(gray),
289            })
290        }
291    }
292}
293
294/// Append one pre-built page image to the encoder as a single IFD.
295#[cfg(feature = "parallel")]
296fn write_page_image<W: Write + Seek>(
297    encoder: &mut TiffEncoder<W>,
298    img: &PageImage,
299) -> Result<(), TiffError> {
300    match &img.data {
301        PageImageData::Rgb(rgb) => {
302            let mut image = encoder.new_image::<colortype::RGB8>(img.w, img.h)?;
303            image.resolution(ResolutionUnit::Inch, Rational { n: img.dpi, d: 1 });
304            image.write_data(rgb)?;
305        }
306        PageImageData::GrayDeflate(gray) => {
307            let mut image = encoder.new_image_with_compression::<colortype::Gray8, _>(
308                img.w,
309                img.h,
310                Deflate::default(),
311            )?;
312            image.resolution(ResolutionUnit::Inch, Rational { n: img.dpi, d: 1 });
313            image.write_data(gray)?;
314        }
315    }
316    Ok(())
317}
318
319// ---- Per-page helpers -------------------------------------------------------
320
321/// Render `page` as RGB and append one IFD to `encoder`.
322#[cfg(not(feature = "parallel"))]
323fn write_color_page<W: Write + Seek>(
324    encoder: &mut TiffEncoder<W>,
325    page: &DjVuPage,
326    scale: f32,
327) -> Result<(), TiffError> {
328    let (w, h, opts) = color_render_options(page, scale);
329    let dpi = (page.dpi() as f32 * scale).round() as u32;
330
331    if opts.can_stream(page) {
332        write_color_page_streaming(encoder, page, &opts, w, h, dpi)
333    } else {
334        write_color_page_pixmap(encoder, page, &opts, w, h, dpi)
335    }
336}
337
338fn color_render_options(page: &DjVuPage, scale: f32) -> (u32, u32, RenderOptions) {
339    let (w, h) =
340        crate::export_common::scaled_size(page.width() as u32, page.height() as u32, scale);
341
342    // Only the size is set; the pipeline derives the decode scale from `width`.
343    // The remaining fields (bold/aa/rotation/permissive/resampling) are the
344    // `RenderOptions` defaults.
345    let opts = RenderOptions {
346        width: w,
347        height: h,
348        ..RenderOptions::default()
349    };
350    (w, h, opts)
351}
352
353#[cfg(not(feature = "parallel"))]
354fn write_color_page_streaming<W: Write + Seek>(
355    encoder: &mut TiffEncoder<W>,
356    page: &DjVuPage,
357    opts: &RenderOptions,
358    w: u32,
359    h: u32,
360    dpi: u32,
361) -> Result<(), TiffError> {
362    let mut img = encoder.new_image::<colortype::RGB8>(w, h)?;
363    img.resolution(ResolutionUnit::Inch, Rational { n: dpi, d: 1 });
364
365    let mut next_strip_samples = img.next_strip_sample_count() as usize;
366    let mut strip = Vec::with_capacity(next_strip_samples);
367    let mut encode_error: Option<tiff::TiffError> = None;
368
369    djvu_render::render_streaming(page, opts, |_, rgba_row| {
370        if encode_error.is_some() {
371            return;
372        }
373
374        crate::export_common::rgba_row_to_rgb(&mut strip, rgba_row);
375
376        if strip.len() > next_strip_samples {
377            encode_error = Some(
378                std::io::Error::new(
379                    std::io::ErrorKind::InvalidData,
380                    "streamed RGB strip exceeded expected TIFF strip size",
381                )
382                .into(),
383            );
384            return;
385        }
386
387        if strip.len() == next_strip_samples {
388            if let Err(e) = img.write_strip(&strip) {
389                encode_error = Some(e);
390                return;
391            }
392            strip.clear();
393            next_strip_samples = img.next_strip_sample_count() as usize;
394        }
395    })?;
396
397    if let Some(e) = encode_error {
398        return Err(e.into());
399    }
400    if !strip.is_empty() || img.next_strip_sample_count() != 0 {
401        return Err(TiffError::Encode(
402            "streamed render ended before all TIFF strips were written".to_string(),
403        ));
404    }
405
406    img.finish()?;
407    Ok(())
408}
409
410#[cfg(not(feature = "parallel"))]
411fn write_color_page_pixmap<W: Write + Seek>(
412    encoder: &mut TiffEncoder<W>,
413    page: &DjVuPage,
414    opts: &RenderOptions,
415    w: u32,
416    h: u32,
417    dpi: u32,
418) -> Result<(), TiffError> {
419    let pixmap = djvu_render::render_pixmap(page, opts)?;
420    let rgb = pixmap.to_rgb();
421
422    let mut img = encoder.new_image::<colortype::RGB8>(w, h)?;
423    img.resolution(ResolutionUnit::Inch, Rational { n: dpi, d: 1 });
424    img.write_data(&rgb)?;
425    Ok(())
426}
427
428/// Extract the JB2 mask from `page` as an 8-bit grayscale strip and append
429/// one IFD to `encoder`.
430///
431/// Black pixels in the mask are written as 255; white background as 0.
432/// Pages without a JB2 mask get a blank white page.
433#[cfg(not(feature = "parallel"))]
434fn write_bilevel_page<W: std::io::Write + std::io::Seek>(
435    encoder: &mut TiffEncoder<W>,
436    page: &DjVuPage,
437) -> Result<(), TiffError> {
438    let w = page.width() as u32;
439    let h = page.height() as u32;
440
441    // Try to extract the JB2 mask directly from the page chunks.
442    let gray = extract_bilevel_pixels(page, w, h)?;
443    let dpi = page.dpi() as u32;
444    // Bilevel content is just 0x00 / 0xFF bytes with long runs (text on white),
445    // so Deflate shrinks the Gray8 strip by ~20–50× — far past the 8× of a true
446    // 1-bit packing, which the `tiff` crate's high-level encoder cannot emit (no
447    // 1-bit ColorType). Deflate (tag 8) is universally readable.
448    let mut img =
449        encoder.new_image_with_compression::<colortype::Gray8, _>(w, h, Deflate::default())?;
450    img.resolution(ResolutionUnit::Inch, Rational { n: dpi, d: 1 });
451    img.write_data(&gray)?;
452    Ok(())
453}
454
455/// Extract the JB2 Sjbz mask as 8-bit grayscale (0=white, 255=black).
456///
457/// Returns a blank white buffer if no Sjbz chunk is present (pure IW44 page).
458/// Returns `Err` if an Sjbz chunk exists but decoding fails.
459// ---- Bilevel G4 writer (#579) ------------------------------------------------
460//
461// The `tiff` crate (0.9) has no CCITT encoder, so the G4 path hand-rolls the
462// minimal multi-page bilevel TIFF: little-endian header, one IFD per page with
463// the 11 tags libtiff expects for a G4 fax image, strip data = the raw
464// `smmr::encode_g4` T.6 payload (1 strip per page). Photometric is
465// min-is-white (0), matching T.6's white-first run convention and our mask's
466// 1 = black. To keep the writer memory-bounded, the first pass retains only
467// per-page layout metadata; the second pass re-encodes and emits one strip at
468// a time. The two passes intentionally trade G4 CPU for O(1)-page memory.
469#[derive(Clone, Copy)]
470struct G4PagePlan {
471    width: u32,
472    height: u32,
473    dpi: u32,
474    encoded_len: u32,
475}
476
477#[derive(Clone, Copy)]
478struct G4PageLayout {
479    strip_offset: u32,
480    rational_offset: u32,
481    ifd_offset: u32,
482}
483
484/// Render the page's bilevel mask and encode it as a G4 strip.
485///
486/// The returned metadata is sufficient to plan the TIFF layout. Callers doing
487/// size discovery must drop the payload and retain only the metadata.
488fn encode_bilevel_g4_page(
489    doc: &DjVuDocument,
490    page_index: usize,
491) -> Result<(G4PagePlan, Vec<u8>), TiffError> {
492    // #629: cold clone — the mask decode cache drops with the page.
493    let page = doc.page(page_index)?.clone();
494    let width = page.width() as u32;
495    let height = page.height() as u32;
496    let dpi = page.dpi().max(1) as u32;
497    let mask = page
498        .extract_mask()
499        .map_err(TiffError::Doc)?
500        .filter(|m| m.width >= width && m.height >= height)
501        .unwrap_or_else(|| crate::bitmap::Bitmap::new(width, height));
502    let encoded = crate::smmr::encode_g4(&mask);
503    let plan = G4PagePlan {
504        width,
505        height,
506        dpi,
507        encoded_len: encoded.len() as u32,
508    };
509    Ok((plan, encoded))
510}
511
512fn write_bilevel_g4_tiff<W: Write + Seek>(
513    doc: &DjVuDocument,
514    mut w: W,
515    observer: &mut dyn ExportObserver,
516) -> Result<(), TiffError> {
517    let indices: Vec<usize> = crate::export_common::page_indices(doc, None).collect();
518
519    // Pass 1: discover the exact G4 strip sizes needed to resolve the complete
520    // IFD chain. The encoded payload is dropped after each page, leaving only
521    // small per-page metadata records in memory.
522    let mut plans = Vec::with_capacity(indices.len());
523    for &page_index in &indices {
524        if observer.cancelled() {
525            return Err(TiffError::Cancelled);
526        }
527        let (plan, encoded) = encode_bilevel_g4_page(doc, page_index)?;
528        drop(encoded);
529        plans.push(plan);
530    }
531
532    let io = |e: std::io::Error| TiffError::Encode(e.to_string());
533
534    // Layout: header (8) → per page [strip data, then 8-byte-aligned IFD].
535    const NTAGS: u16 = 11;
536    let ifd_size = 2 + NTAGS as u32 * 12 + 4; // count + entries + next-IFD ptr
537    let mut offset: u32 = 8;
538    let mut layout = Vec::with_capacity(plans.len());
539    for plan in &plans {
540        let strip_offset = offset;
541        // XResolution/YResolution rationals (2×8 bytes) live after the strip.
542        let rational_offset = (strip_offset + plan.encoded_len).div_ceil(2) * 2;
543        let ifd_offset = (rational_offset + 16).div_ceil(2) * 2;
544        layout.push(G4PageLayout {
545            strip_offset,
546            rational_offset,
547            ifd_offset,
548        });
549        offset = ifd_offset + ifd_size;
550    }
551
552    // Header: little-endian, magic 42, first IFD offset.
553    w.write_all(b"II\x2a\x00").map_err(io)?;
554    w.write_all(&layout[0].ifd_offset.to_le_bytes())
555        .map_err(io)?;
556
557    let entry = |tag: u16, typ: u16, count: u32, value: u32| -> [u8; 12] {
558        let mut e = [0u8; 12];
559        e[0..2].copy_from_slice(&tag.to_le_bytes());
560        e[2..4].copy_from_slice(&typ.to_le_bytes());
561        e[4..8].copy_from_slice(&count.to_le_bytes());
562        e[8..12].copy_from_slice(&value.to_le_bytes());
563        e
564    };
565
566    // Pass 2: re-encode and emit one page at a time. We use pass-1 metadata
567    // for the IFD so the output layout remains byte-for-byte unchanged.
568    let mut pos: u32 = 8;
569    for (idx, ((&page_index, plan), page_layout)) in
570        indices.iter().zip(&plans).zip(&layout).enumerate()
571    {
572        if observer.cancelled() {
573            return Err(TiffError::Cancelled);
574        }
575        let (emitted_plan, encoded) = encode_bilevel_g4_page(doc, page_index)?;
576        debug_assert_eq!(plan.encoded_len, emitted_plan.encoded_len);
577        if plan.encoded_len != emitted_plan.encoded_len {
578            return Err(TiffError::Encode(format!(
579                "G4 encoded length diverged between sizing and emission passes for page {}",
580                idx + 1
581            )));
582        }
583
584        debug_assert_eq!(pos, page_layout.strip_offset);
585        w.write_all(&encoded).map_err(io)?;
586        pos += emitted_plan.encoded_len;
587        while pos < page_layout.rational_offset {
588            w.write_all(&[0]).map_err(io)?;
589            pos += 1;
590        }
591        // X/Y resolution rationals (dpi / 1).
592        for _ in 0..2 {
593            w.write_all(&plan.dpi.to_le_bytes()).map_err(io)?;
594            w.write_all(&1u32.to_le_bytes()).map_err(io)?;
595        }
596        pos += 16;
597        while pos < page_layout.ifd_offset {
598            w.write_all(&[0]).map_err(io)?;
599            pos += 1;
600        }
601
602        w.write_all(&NTAGS.to_le_bytes()).map_err(io)?;
603        // Types: 3 = SHORT, 4 = LONG, 5 = RATIONAL.
604        w.write_all(&entry(256, 4, 1, plan.width)).map_err(io)?; // ImageWidth
605        w.write_all(&entry(257, 4, 1, plan.height)).map_err(io)?; // ImageLength
606        w.write_all(&entry(258, 3, 1, 1)).map_err(io)?; // BitsPerSample
607        w.write_all(&entry(259, 3, 1, 4)).map_err(io)?; // Compression = CCITT G4
608        w.write_all(&entry(262, 3, 1, 0)).map_err(io)?; // Photometric = WhiteIsZero
609        w.write_all(&entry(273, 4, 1, page_layout.strip_offset))
610            .map_err(io)?; // StripOffsets
611        w.write_all(&entry(277, 3, 1, 1)).map_err(io)?; // SamplesPerPixel
612        w.write_all(&entry(278, 4, 1, plan.height)).map_err(io)?; // RowsPerStrip
613        w.write_all(&entry(279, 4, 1, plan.encoded_len))
614            .map_err(io)?; // StripByteCounts
615        w.write_all(&entry(282, 5, 1, page_layout.rational_offset))
616            .map_err(io)?; // XResolution
617        w.write_all(&entry(283, 5, 1, page_layout.rational_offset + 8))
618            .map_err(io)?; // YResolution
619        let next = if idx + 1 < layout.len() {
620            layout[idx + 1].ifd_offset
621        } else {
622            0
623        };
624        w.write_all(&next.to_le_bytes()).map_err(io)?;
625        pos = page_layout.ifd_offset + ifd_size;
626        observer.on_progress(idx + 1, plans.len());
627    }
628    w.flush().map_err(io)?;
629    Ok(())
630}
631
632fn extract_bilevel_pixels(page: &DjVuPage, w: u32, h: u32) -> Result<Vec<u8>, TiffError> {
633    let sjbz = match page.find_chunk(b"Sjbz") {
634        Some(d) => d,
635        None => return Ok(vec![0u8; (w * h) as usize]),
636    };
637
638    let dict = page
639        .find_chunk(b"Djbz")
640        .and_then(|djbz| crate::jb2::decode_dict(djbz, None).ok());
641
642    let bm = crate::jb2::decode(sjbz, dict.as_ref())
643        .map_err(|e| TiffError::Encode(format!("JB2 decode failed: {e}")))?;
644
645    let wq = w as usize;
646    let mut pixels = vec![0u8; wq * h as usize];
647
648    // LUT byte-expansion: when the decoded mask covers the page, expand each
649    // packed mask byte to 8 Gray8 pixels via a 256-entry table instead of one
650    // `bm.get()` (stride mult + bit-extract) per pixel. MSB-first packing: pixel
651    // x is bit (7 - x%8) of byte x/8, matching `BILEVEL_GRAY8`.
652    if bm.width >= w && bm.height >= h {
653        let stride = bm.row_stride();
654        let nb_full = wq / 8;
655        let rem = wq % 8;
656        for y in 0..h as usize {
657            let row = &bm.data[y * stride..];
658            let out = &mut pixels[y * wq..(y + 1) * wq];
659            for bi in 0..nb_full {
660                out[bi * 8..bi * 8 + 8].copy_from_slice(&BILEVEL_GRAY8[row[bi] as usize]);
661            }
662            if rem > 0 {
663                let src = &BILEVEL_GRAY8[row[nb_full] as usize];
664                out[nb_full * 8..nb_full * 8 + rem].copy_from_slice(&src[..rem]);
665            }
666        }
667        return Ok(pixels);
668    }
669
670    // Fallback for the unexpected case where the mask is smaller than the page.
671    // Bitmap pixels: true = black foreground, false = white background.
672    for y in 0..h {
673        for x in 0..w {
674            pixels[(y * w + x) as usize] = if bm.get(x, y) { 255u8 } else { 0u8 };
675        }
676    }
677    Ok(pixels)
678}
679
680/// Maps each packed mask byte (MSB-first) to its 8 expanded Gray8 pixels —
681/// bit set (black) → 255, bit clear (white) → 0.
682const BILEVEL_GRAY8: [[u8; 8]; 256] = {
683    let mut lut = [[0u8; 8]; 256];
684    let mut mb = 0usize;
685    while mb < 256 {
686        let mut j = 0usize;
687        while j < 8 {
688            lut[mb][j] = if (mb >> (7 - j)) & 1 != 0 { 255u8 } else { 0u8 };
689            j += 1;
690        }
691        mb += 1;
692    }
693    lut
694};
695
696// ---- Tests ------------------------------------------------------------------
697
698#[cfg(test)]
699mod tests {
700    use super::*;
701    use std::cell::Cell;
702
703    use crate::djvu_render;
704
705    fn assets_path() -> std::path::PathBuf {
706        std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
707            .join("references/djvujs/library/assets")
708    }
709
710    fn load_doc(filename: &str) -> DjVuDocument {
711        let data = std::fs::read(assets_path().join(filename))
712            .unwrap_or_else(|_| panic!("{filename} must exist"));
713        DjVuDocument::parse(&data).unwrap_or_else(|e| panic!("parse failed: {e}"))
714    }
715
716    #[derive(Default)]
717    struct RecordingObserver {
718        progress: Vec<(usize, usize)>,
719        cancel_after: Option<usize>,
720    }
721
722    impl ExportObserver for RecordingObserver {
723        fn on_progress(&mut self, done: usize, total: usize) {
724            self.progress.push((done, total));
725        }
726
727        fn cancelled(&self) -> bool {
728            self.cancel_after
729                .is_some_and(|after| self.progress.len() >= after)
730        }
731    }
732
733    /// Cancels on a specific call to `cancelled`, allowing the tests to target
734    /// either the G4 sizing pass or its subsequent emission pass.
735    struct CancelOnPollObserver {
736        cancel_on_poll: usize,
737        polls: Cell<usize>,
738        progress: Vec<(usize, usize)>,
739    }
740
741    impl ExportObserver for CancelOnPollObserver {
742        fn on_progress(&mut self, done: usize, total: usize) {
743            self.progress.push((done, total));
744        }
745
746        fn cancelled(&self) -> bool {
747            let polls = self.polls.get() + 1;
748            self.polls.set(polls);
749            polls >= self.cancel_on_poll
750        }
751    }
752
753    fn load_fixture_doc(filename: &str) -> DjVuDocument {
754        let data = std::fs::read(
755            std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
756                .join("tests/fixtures")
757                .join(filename),
758        )
759        .unwrap();
760        DjVuDocument::parse(&data).unwrap_or_else(|e| panic!("parse failed: {e}"))
761    }
762
763    fn g4_options() -> TiffOptions {
764        TiffOptions {
765            mode: TiffMode::Bilevel,
766            bilevel_compression: TiffBilevelCompression::G4,
767            ..Default::default()
768        }
769    }
770
771    fn load_multipage_g4_doc() -> DjVuDocument {
772        let data = std::fs::read(
773            std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
774                .join("tests/corpus/cable_1973_100133.djvu"),
775        )
776        .unwrap();
777        DjVuDocument::parse(&data).unwrap()
778    }
779
780    /// Exact pre-#690 G4 writer, retained only to lock the two-pass writer's
781    /// byte layout to the former all-in-memory implementation.
782    fn legacy_all_in_memory_g4_tiff(doc: &DjVuDocument) -> Result<Vec<u8>, TiffError> {
783        let indices: Vec<usize> = crate::export_common::page_indices(doc, None).collect();
784        let mut pages = Vec::with_capacity(indices.len());
785        for page_index in indices {
786            pages.push(encode_bilevel_g4_page(doc, page_index)?);
787        }
788
789        let io = |e: std::io::Error| TiffError::Encode(e.to_string());
790        const NTAGS: u16 = 11;
791        let ifd_size = 2 + NTAGS as u32 * 12 + 4;
792        let mut offset: u32 = 8;
793        let mut layout = Vec::with_capacity(pages.len());
794        for (plan, g4) in &pages {
795            let strip_off = offset;
796            let rat_off = (strip_off + g4.len() as u32).div_ceil(2) * 2;
797            let ifd_off = (rat_off + 16).div_ceil(2) * 2;
798            layout.push((strip_off, rat_off, ifd_off));
799            offset = ifd_off + ifd_size;
800            assert_eq!(plan.encoded_len, g4.len() as u32);
801        }
802
803        let mut w = std::io::Cursor::new(Vec::new());
804        w.write_all(b"II\x2a\x00").map_err(io)?;
805        w.write_all(&layout[0].2.to_le_bytes()).map_err(io)?;
806
807        let entry = |tag: u16, typ: u16, count: u32, value: u32| -> [u8; 12] {
808            let mut e = [0u8; 12];
809            e[0..2].copy_from_slice(&tag.to_le_bytes());
810            e[2..4].copy_from_slice(&typ.to_le_bytes());
811            e[4..8].copy_from_slice(&count.to_le_bytes());
812            e[8..12].copy_from_slice(&value.to_le_bytes());
813            e
814        };
815
816        let mut pos: u32 = 8;
817        for (idx, ((plan, g4), &(strip_off, rat_off, ifd_off))) in
818            pages.iter().zip(&layout).enumerate()
819        {
820            debug_assert_eq!(pos, strip_off);
821            w.write_all(g4).map_err(io)?;
822            pos += g4.len() as u32;
823            while pos < rat_off {
824                w.write_all(&[0]).map_err(io)?;
825                pos += 1;
826            }
827            for _ in 0..2 {
828                w.write_all(&plan.dpi.to_le_bytes()).map_err(io)?;
829                w.write_all(&1u32.to_le_bytes()).map_err(io)?;
830            }
831            pos += 16;
832            while pos < ifd_off {
833                w.write_all(&[0]).map_err(io)?;
834                pos += 1;
835            }
836
837            w.write_all(&NTAGS.to_le_bytes()).map_err(io)?;
838            w.write_all(&entry(256, 4, 1, plan.width)).map_err(io)?;
839            w.write_all(&entry(257, 4, 1, plan.height)).map_err(io)?;
840            w.write_all(&entry(258, 3, 1, 1)).map_err(io)?;
841            w.write_all(&entry(259, 3, 1, 4)).map_err(io)?;
842            w.write_all(&entry(262, 3, 1, 0)).map_err(io)?;
843            w.write_all(&entry(273, 4, 1, strip_off)).map_err(io)?;
844            w.write_all(&entry(277, 3, 1, 1)).map_err(io)?;
845            w.write_all(&entry(278, 4, 1, plan.height)).map_err(io)?;
846            w.write_all(&entry(279, 4, 1, g4.len() as u32))
847                .map_err(io)?;
848            w.write_all(&entry(282, 5, 1, rat_off)).map_err(io)?;
849            w.write_all(&entry(283, 5, 1, rat_off + 8)).map_err(io)?;
850            let next = if idx + 1 < layout.len() {
851                layout[idx + 1].2
852            } else {
853                0
854            };
855            w.write_all(&next.to_le_bytes()).map_err(io)?;
856            pos = ifd_off + ifd_size;
857        }
858        w.flush().map_err(io)?;
859        Ok(w.into_inner())
860    }
861
862    #[test]
863    fn tiff_writer_observer_reports_each_page_in_order() {
864        let doc = load_fixture_doc("vega.djvu");
865        let total = doc.page_count();
866        let mut observer = RecordingObserver::default();
867
868        djvu_to_tiff_writer_with_observer(
869            &doc,
870            &TiffOptions::default(),
871            std::io::Cursor::new(Vec::new()),
872            &mut observer,
873        )
874        .expect("observer export must succeed");
875
876        assert_eq!(
877            observer.progress,
878            (1..=total).map(|done| (done, total)).collect::<Vec<_>>()
879        );
880    }
881
882    #[test]
883    fn tiff_writer_cancellation_stops_after_completed_page() {
884        let doc = load_fixture_doc("vega.djvu");
885        assert!(doc.page_count() > 1, "fixture must contain multiple pages");
886        let mut observer = RecordingObserver {
887            cancel_after: Some(1),
888            ..RecordingObserver::default()
889        };
890
891        let error = djvu_to_tiff_writer_with_observer(
892            &doc,
893            &TiffOptions::default(),
894            std::io::Cursor::new(Vec::new()),
895            &mut observer,
896        )
897        .expect_err("observer must cancel the export");
898
899        assert!(matches!(error, TiffError::Cancelled));
900        assert_eq!(observer.progress.len(), 1);
901    }
902
903    #[test]
904    fn tiff_default_writer_delegates_to_noop_observer() {
905        let doc = load_fixture_doc("vega.djvu");
906        let opts = TiffOptions::default();
907
908        let mut default_cursor = std::io::Cursor::new(Vec::new());
909        djvu_to_tiff_writer(&doc, &opts, &mut default_cursor).unwrap();
910
911        let mut observed_cursor = std::io::Cursor::new(Vec::new());
912        let mut observer = NoOpObserver;
913        djvu_to_tiff_writer_with_observer(&doc, &opts, &mut observed_cursor, &mut observer)
914            .unwrap();
915
916        assert_eq!(observed_cursor.into_inner(), default_cursor.into_inner());
917    }
918
919    #[test]
920    fn tiff_writer_failing_sink_returns_io_error() {
921        let doc = load_fixture_doc("chicken.djvu");
922        let error = djvu_to_tiff_writer(
923            &doc,
924            &TiffOptions::default(),
925            crate::export_test_support::FailingWriter::after(2),
926        )
927        .expect_err("injected sink failure must be returned");
928
929        assert!(
930            matches!(error, TiffError::Encode(message) if message.contains("injected sink failure"))
931        );
932    }
933
934    fn decode_first_tiff_rgb(tiff_bytes: &[u8]) -> (u32, u32, Vec<u8>) {
935        let cursor = std::io::Cursor::new(tiff_bytes);
936        let mut decoder = tiff::decoder::Decoder::new(cursor).expect("tiff must be decodable");
937        let (w, h) = decoder.dimensions().expect("must have dimensions");
938        let img = decoder.read_image().expect("image must decode");
939        let tiff::decoder::DecodingResult::U8(pixels) = img else {
940            panic!("expected RGB8 TIFF pixels");
941        };
942        (w, h, pixels)
943    }
944
945    fn assert_streamed_color_tiff_matches_render_pixmap(filename: &str) {
946        let doc = load_doc(filename);
947        let page = doc.page(0).unwrap();
948        let (_, _, render_opts) = color_render_options(page, 1.0);
949        assert!(
950            render_opts.can_stream(page),
951            "fixture should use the streaming TIFF color path"
952        );
953
954        let mut cursor = std::io::Cursor::new(Vec::new());
955        djvu_to_tiff_writer(&doc, &TiffOptions::default(), &mut cursor)
956            .expect("streamed TIFF writer must succeed");
957        let tiff_bytes = cursor.into_inner();
958        let (w, h, pixels) = decode_first_tiff_rgb(&tiff_bytes);
959
960        assert_eq!((w, h), (render_opts.width, render_opts.height));
961        let expected = djvu_render::render_pixmap(page, &render_opts)
962            .expect("render_pixmap must succeed")
963            .to_rgb();
964        assert_eq!(pixels, expected);
965    }
966
967    // ── TDD tests ─────────────────────────────────────────────────────────────
968
969    /// `djvu_to_tiff` produces non-empty bytes for a color document.
970    #[test]
971    fn color_export_produces_bytes() {
972        let doc = load_doc("chicken.djvu");
973        let tiff = djvu_to_tiff(&doc, &TiffOptions::default()).expect("color export must succeed");
974        assert!(!tiff.is_empty(), "TIFF output must not be empty");
975    }
976
977    /// TIFF output starts with the standard TIFF magic bytes (little-endian II or big-endian MM).
978    #[test]
979    fn output_starts_with_tiff_magic() {
980        let doc = load_doc("chicken.djvu");
981        let tiff = djvu_to_tiff(&doc, &TiffOptions::default()).unwrap();
982        let magic = &tiff[..4];
983        assert!(
984            magic == b"II\x2A\x00" || magic == b"MM\x00\x2A",
985            "must start with TIFF magic, got: {magic:?}"
986        );
987    }
988
989    /// Bilevel export produces non-empty bytes.
990    #[test]
991    fn bilevel_export_produces_bytes() {
992        let doc = load_doc("boy_jb2.djvu");
993        let opts = TiffOptions {
994            mode: TiffMode::Bilevel,
995            ..Default::default()
996        };
997        let tiff = djvu_to_tiff(&doc, &opts).expect("bilevel export must succeed");
998        assert!(!tiff.is_empty());
999    }
1000
1001    /// Bilevel export also starts with TIFF magic.
1002    #[test]
1003    fn bilevel_output_starts_with_tiff_magic() {
1004        let doc = load_doc("boy_jb2.djvu");
1005        let opts = TiffOptions {
1006            mode: TiffMode::Bilevel,
1007            ..Default::default()
1008        };
1009        let tiff = djvu_to_tiff(&doc, &opts).unwrap();
1010        let magic = &tiff[..4];
1011        assert!(magic == b"II\x2A\x00" || magic == b"MM\x00\x2A");
1012    }
1013
1014    /// Multi-page export: two pages produce more output than one page.
1015    #[test]
1016    fn multipage_larger_than_single_page() {
1017        // Build a two-page DjVu document by concatenating two single-page exports
1018        // as separate DjVuDocument instances and comparing their individual outputs.
1019        let doc_a = load_doc("chicken.djvu");
1020        let doc_b = load_doc("boy.djvu");
1021        let opts = TiffOptions::default();
1022
1023        let tiff_a = djvu_to_tiff(&doc_a, &opts).expect("page A export must succeed");
1024        let tiff_b = djvu_to_tiff(&doc_b, &opts).expect("page B export must succeed");
1025
1026        // Both single-page TIFFs must be non-trivially sized
1027        assert!(tiff_a.len() > 100, "page A TIFF must be non-trivial");
1028        assert!(tiff_b.len() > 100, "page B TIFF must be non-trivial");
1029    }
1030
1031    /// Two different single-page documents produce differently-sized TIFFs.
1032    #[test]
1033    fn different_pages_produce_different_sizes() {
1034        let doc_a = load_doc("chicken.djvu");
1035        let doc_b = load_doc("boy.djvu");
1036        let opts = TiffOptions::default();
1037
1038        let tiff_a = djvu_to_tiff(&doc_a, &opts).unwrap();
1039        let tiff_b = djvu_to_tiff(&doc_b, &opts).unwrap();
1040        // Different pages have different content, so their TIFFs should differ
1041        assert_ne!(
1042            tiff_a.len(),
1043            tiff_b.len(),
1044            "different pages must produce different TIFF sizes"
1045        );
1046    }
1047
1048    /// Color export at 0.5 scale produces a smaller file than at 1.0 scale.
1049    #[test]
1050    fn scale_factor_reduces_file_size() {
1051        let doc = load_doc("chicken.djvu");
1052        let full = djvu_to_tiff(&doc, &TiffOptions::default()).unwrap();
1053        let half = djvu_to_tiff(
1054            &doc,
1055            &TiffOptions {
1056                scale: 0.5,
1057                ..Default::default()
1058            },
1059        )
1060        .unwrap();
1061        assert!(
1062            half.len() < full.len(),
1063            "half-scale TIFF must be smaller: half={} full={}",
1064            half.len(),
1065            full.len()
1066        );
1067    }
1068
1069    /// Round-trip: exported TIFF can be re-decoded by the `tiff` crate.
1070    #[test]
1071    fn color_tiff_round_trips_via_tiff_decoder() {
1072        let doc = load_doc("chicken.djvu");
1073        let tiff_bytes = djvu_to_tiff(&doc, &TiffOptions::default()).unwrap();
1074
1075        let cursor = std::io::Cursor::new(&tiff_bytes);
1076        let mut decoder = tiff::decoder::Decoder::new(cursor).expect("tiff must be decodable");
1077        // The first IFD must decode without error and have reasonable dimensions.
1078        let (w, h) = decoder.dimensions().expect("must have dimensions");
1079        let page = doc.page(0).unwrap();
1080        assert_eq!(w, page.width() as u32);
1081        assert_eq!(h, page.height() as u32);
1082    }
1083
1084    /// Streamed color TIFF export matches the existing full-pixmap render path on a color page.
1085    #[test]
1086    fn streamed_color_tiff_matches_render_pixmap_color_page() {
1087        assert_streamed_color_tiff_matches_render_pixmap("chicken.djvu");
1088    }
1089
1090    /// Streamed color TIFF export also matches the full-pixmap path on a bilevel page.
1091    #[test]
1092    fn streamed_color_tiff_matches_render_pixmap_bilevel_page() {
1093        assert_streamed_color_tiff_matches_render_pixmap("boy_jb2.djvu");
1094    }
1095
1096    /// Bilevel pages with JB2 mask have non-uniform pixel values (some black pixels).
1097    #[test]
1098    fn bilevel_jb2_page_has_black_pixels() {
1099        let doc = load_doc("boy_jb2.djvu");
1100        let opts = TiffOptions {
1101            mode: TiffMode::Bilevel,
1102            ..Default::default()
1103        };
1104        let tiff_bytes = djvu_to_tiff(&doc, &opts).unwrap();
1105
1106        let cursor = std::io::Cursor::new(&tiff_bytes);
1107        let mut decoder = tiff::decoder::Decoder::new(cursor).unwrap();
1108        let img = decoder.read_image().unwrap();
1109        if let tiff::decoder::DecodingResult::U8(pixels) = img {
1110            let has_black = pixels.contains(&255);
1111            assert!(
1112                has_black,
1113                "bilevel JB2 page must have at least one black pixel"
1114            );
1115        }
1116    }
1117
1118    /// Bilevel export on a page without JB2 mask returns a blank (all-white) page.
1119    #[test]
1120    fn bilevel_blank_when_no_jb2_mask() {
1121        // chicken.djvu is a color-only document with no JB2 mask
1122        let doc = load_doc("chicken.djvu");
1123        let page = doc.page(0).unwrap();
1124        let w = page.width() as u32;
1125        let h = page.height() as u32;
1126
1127        let pixels = extract_bilevel_pixels(page, w, h).unwrap();
1128        assert!(
1129            pixels.iter().all(|&p| p == 0),
1130            "page without JB2 must be all-white (0)"
1131        );
1132    }
1133
1134    /// Color export on a rotated page forces the pixmap path (can_stream returns
1135    /// false when page.rotation() != None), exercising write_color_page_pixmap.
1136    #[test]
1137    fn color_export_rotated_page_uses_pixmap_path() {
1138        let doc = load_doc("boy_jb2_rotate90.djvu");
1139        let page = doc.page(0).unwrap();
1140        // Confirm rotation is set so can_stream returns false
1141        assert_ne!(
1142            page.rotation(),
1143            crate::info::Rotation::None,
1144            "fixture must have rotation set"
1145        );
1146        let tiff =
1147            djvu_to_tiff(&doc, &TiffOptions::default()).expect("rotated color export must succeed");
1148        assert!(!tiff.is_empty());
1149        let magic = &tiff[..4];
1150        assert!(magic == b"II\x2A\x00" || magic == b"MM\x00\x2A");
1151    }
1152
1153    /// `TiffOptions::default()` selects color mode at 1.0 scale.
1154    #[test]
1155    fn tiff_options_default() {
1156        let opts = TiffOptions::default();
1157        assert_eq!(opts.mode, TiffMode::Color);
1158        assert!((opts.scale - 1.0).abs() < 1e-6);
1159    }
1160
1161    /// `From<tiff::TiffError> for TiffError` wraps the error message.
1162    #[test]
1163    fn from_tiff_error_wraps_message() {
1164        let io_err = std::io::Error::other("test tiff failure");
1165        let tiff_err: tiff::TiffError = io_err.into();
1166        let djvu_tiff_err: TiffError = tiff_err.into();
1167        let s = djvu_tiff_err.to_string();
1168        assert!(
1169            s.contains("TIFF encoding error"),
1170            "must mention TIFF encoding error: {s}"
1171        );
1172    }
1173
1174    /// `TiffError::Encode` display includes the inner message.
1175    #[test]
1176    fn tiff_error_display() {
1177        let e = TiffError::Encode("something went wrong".to_string());
1178        assert!(e.to_string().contains("something went wrong"));
1179    }
1180
1181    /// #579: the G4 bilevel TIFF is structurally sound (LE header, one IFD per
1182    /// page, CCITT G4 compression tag) and its strip round-trips through our
1183    /// own T.6 decoder to the exact page mask.
1184    #[test]
1185    fn bilevel_g4_tiff_round_trips_through_own_decoder() {
1186        let data = std::fs::read(
1187            std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1188                .join("tests/fixtures/boy_jb2.djvu"),
1189        )
1190        .unwrap();
1191        let doc = DjVuDocument::parse(&data).unwrap();
1192        let tiff = djvu_to_tiff(
1193            &doc,
1194            &TiffOptions {
1195                mode: TiffMode::Bilevel,
1196                bilevel_compression: TiffBilevelCompression::G4,
1197                ..Default::default()
1198            },
1199        )
1200        .unwrap();
1201
1202        assert_eq!(&tiff[0..4], b"II\x2a\x00", "little-endian TIFF header");
1203        let rd32 = |o: usize| u32::from_le_bytes(tiff[o..o + 4].try_into().unwrap());
1204        let rd16 = |o: usize| u16::from_le_bytes(tiff[o..o + 2].try_into().unwrap());
1205        let ifd = rd32(4) as usize;
1206        let ntags = rd16(ifd) as usize;
1207        let mut tags = std::collections::BTreeMap::new();
1208        for i in 0..ntags {
1209            let e = ifd + 2 + i * 12;
1210            tags.insert(rd16(e), rd32(e + 8));
1211        }
1212        assert_eq!(tags[&259], 4, "Compression must be CCITT G4");
1213        assert_eq!(tags[&258], 1, "BitsPerSample must be 1");
1214        assert_eq!(tags[&262], 0, "Photometric must be min-is-white");
1215        assert_eq!(rd32(ifd + 2 + ntags * 12), 0, "single page: next IFD = 0");
1216
1217        let (w, h) = (tags[&256], tags[&257]);
1218        let off = tags[&273] as usize;
1219        let len = tags[&279] as usize;
1220        let mut chunk = Vec::with_capacity(4 + len);
1221        chunk.extend_from_slice(&(w as u16).to_be_bytes());
1222        chunk.extend_from_slice(&(h as u16).to_be_bytes());
1223        chunk.extend_from_slice(&tiff[off..off + len]);
1224        let decoded = crate::smmr::decode_smmr(&chunk).expect("G4 strip must decode");
1225
1226        let mask = doc.page(0).unwrap().extract_mask().unwrap().unwrap();
1227        assert_eq!((decoded.width, decoded.height), (w, h));
1228        for y in 0..h {
1229            for x in 0..w {
1230                assert_eq!(decoded.get(x, y), mask.get(x, y), "pixel ({x},{y})");
1231            }
1232        }
1233    }
1234
1235    /// #579: multi-page G4 TIFF chains IFDs for every page.
1236    #[test]
1237    fn bilevel_g4_tiff_multipage_chains_ifds() {
1238        let data = std::fs::read(
1239            std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1240                .join("tests/corpus/cable_1973_100133.djvu"),
1241        )
1242        .unwrap();
1243        let doc = DjVuDocument::parse(&data).unwrap();
1244        let tiff = djvu_to_tiff(
1245            &doc,
1246            &TiffOptions {
1247                mode: TiffMode::Bilevel,
1248                bilevel_compression: TiffBilevelCompression::G4,
1249                ..Default::default()
1250            },
1251        )
1252        .unwrap();
1253        let rd32 = |o: usize| u32::from_le_bytes(tiff[o..o + 4].try_into().unwrap());
1254        let rd16 = |o: usize| u16::from_le_bytes(tiff[o..o + 2].try_into().unwrap());
1255        let mut ifd = rd32(4) as usize;
1256        let mut pages = 0;
1257        while ifd != 0 {
1258            let ntags = rd16(ifd) as usize;
1259            pages += 1;
1260            ifd = rd32(ifd + 2 + ntags * 12) as usize;
1261        }
1262        assert_eq!(pages, doc.page_count(), "one IFD per page");
1263    }
1264
1265    /// #690: the bounded-memory writer preserves the original strip/IFD byte
1266    /// layout of the pre-refactor all-in-memory G4 writer.
1267    #[test]
1268    fn bilevel_g4_two_pass_matches_legacy_all_in_memory_bytes() {
1269        let doc = load_multipage_g4_doc();
1270        let expected = legacy_all_in_memory_g4_tiff(&doc).unwrap();
1271        let actual = djvu_to_tiff(&doc, &g4_options()).unwrap();
1272
1273        assert_eq!(actual, expected);
1274    }
1275
1276    /// #690: G4 reports progress only for pages fully written in pass 2.
1277    #[test]
1278    fn bilevel_g4_two_pass_reports_each_written_page_in_order() {
1279        let doc = load_multipage_g4_doc();
1280        let total = doc.page_count();
1281        let mut observer = RecordingObserver::default();
1282
1283        djvu_to_tiff_writer_with_observer(
1284            &doc,
1285            &g4_options(),
1286            std::io::Cursor::new(Vec::new()),
1287            &mut observer,
1288        )
1289        .expect("G4 export must succeed");
1290
1291        assert_eq!(
1292            observer.progress,
1293            (1..=total).map(|done| (done, total)).collect::<Vec<_>>()
1294        );
1295    }
1296
1297    /// #690: cancellation before a later page in the sizing pass returns
1298    /// `Cancelled` before any page is reported as written.
1299    #[test]
1300    fn bilevel_g4_two_pass_cancels_during_sizing_pass() {
1301        let doc = load_multipage_g4_doc();
1302        assert!(doc.page_count() > 1, "fixture must contain multiple pages");
1303        let mut observer = CancelOnPollObserver {
1304            cancel_on_poll: 2,
1305            polls: Cell::new(0),
1306            progress: Vec::new(),
1307        };
1308
1309        let error = djvu_to_tiff_writer_with_observer(
1310            &doc,
1311            &g4_options(),
1312            std::io::Cursor::new(Vec::new()),
1313            &mut observer,
1314        )
1315        .expect_err("G4 sizing pass must observe cancellation");
1316
1317        assert!(matches!(error, TiffError::Cancelled));
1318        assert_eq!(observer.polls.get(), 2);
1319        assert!(observer.progress.is_empty());
1320    }
1321
1322    /// #690: cancellation before a later page in the emission pass preserves
1323    /// progress for only the already-written pages.
1324    #[test]
1325    fn bilevel_g4_two_pass_cancels_during_emission_pass() {
1326        let doc = load_multipage_g4_doc();
1327        let total = doc.page_count();
1328        assert!(total > 1, "fixture must contain multiple pages");
1329        let mut observer = CancelOnPollObserver {
1330            // `total` sizing polls, then one written page, then cancellation
1331            // immediately before the second emission page.
1332            cancel_on_poll: total + 2,
1333            polls: Cell::new(0),
1334            progress: Vec::new(),
1335        };
1336
1337        let error = djvu_to_tiff_writer_with_observer(
1338            &doc,
1339            &g4_options(),
1340            std::io::Cursor::new(Vec::new()),
1341            &mut observer,
1342        )
1343        .expect_err("G4 emission pass must observe cancellation");
1344
1345        assert!(matches!(error, TiffError::Cancelled));
1346        assert_eq!(observer.polls.get(), total + 2);
1347        assert_eq!(observer.progress, vec![(1, total)]);
1348    }
1349}