Skip to main content

djvu_rs/
djvu_render.rs

1//! Rendering pipeline for the new DjVuPage model (phase 5).
2//!
3//! This module provides the high-level rendering API for [`DjVuPage`] using the
4//! clean-room decoders (IW44, JB2, BZZ) introduced in phases 2–3.
5//!
6//! ## Key public types
7//!
8//! - `RenderOptions` — render parameters (size, scale, bold, AA)
9//! - `RenderError` — typed errors from the render pipeline
10//!
11//! ## Compositing model
12//!
13//! Three layers are composited in this order:
14//!
15//! 1. **Background** — IW44 wavelet-coded YCbCr image (BG44 chunks).
16//!    YCbCr → RGB conversion happens HERE, and nowhere else.
17//! 2. **Mask** — JB2 bilevel image (Sjbz chunk). Black pixels mark foreground.
18//! 3. **Foreground palette** — FGbz-encoded color palette (FGbz chunk).
19//!    Each foreground pixel is colored according to the palette.
20//!
21//! ## Gamma correction
22//!
23//! A `gamma_lut[256]` is precomputed from the INFO chunk `gamma` value using
24//! `lut[i] = (i/255)^(doc_gamma/2.2) * 255`.  For the vast majority of DjVu
25//! files (gamma = 2.2) the exponent is 1.0 → identity, no correction applied.
26//!
27//! ## Scaling
28//!
29//! Bilinear scaling uses 4-bit fixed-point fractional coordinates (FRACBITS=4).
30//! Anti-aliasing downscale averages a 2×2 neighbourhood before outputting.
31//!
32//! ## Progressive rendering
33//!
34//! `render_coarse()` decodes only the first BG44 chunk; subsequent calls to
35//! `render_progressive(chunk_n)` decode one additional chunk, yielding
36//! progressively higher-quality images.
37
38#[cfg(not(feature = "std"))]
39use alloc::{string::String, sync::Arc, vec, vec::Vec};
40#[cfg(feature = "std")]
41use std::sync::Arc;
42
43use crate::djvu_document::DjVuPage;
44use crate::iw44::Iw44Image;
45use crate::pixmap::{GrayPixmap, Pixmap};
46
47// Test-only counter of BG44 `decode_chunk` calls made through this module's
48// two progressive call sites (the naive per-frame `decode_background_chunks`
49// loop and `ProgressiveDecoder::push_bg44_chunk`).
50//
51// Exists to back the B5 structural claim — O(N²) chunk decodes for a
52// per-frame `render_progressive_step` session vs O(N) for the stateful
53// decoder — with an exact call count instead of noisy wall-clock timing.
54// `#[cfg(test)]`-gated so it costs nothing (not even the branch) outside
55// test builds. Thread-local (not a shared global) so parallel test runners
56// (`cargo test`'s default multi-threaded harness) can't have unrelated
57// tests on other threads pollute the count; the decode call sites this
58// counts are not dispatched to other threads under the `cli`/default
59// feature set `make check` tests with (no `parallel` feature).
60#[cfg(test)]
61thread_local! {
62    pub(crate) static BG44_CHUNK_DECODES: core::cell::Cell<usize> = const { core::cell::Cell::new(0) };
63}
64
65#[cfg(test)]
66fn count_bg44_chunk_decode() {
67    BG44_CHUNK_DECODES.with(|c| c.set(c.get() + 1));
68}
69
70// Structural counter for full JB2 mask decodes (test-only), mirroring
71// `BG44_CHUNK_DECODES`. Lets the #607 retained-sub4 test prove that a warm
72// downgraded page's sub≥4 re-render never re-runs the JB2 arithmetic decode.
73#[cfg(test)]
74thread_local! {
75    pub(crate) static JB2_MASK_DECODES: core::cell::Cell<usize> = const { core::cell::Cell::new(0) };
76}
77
78#[cfg(test)]
79fn count_jb2_mask_decode() {
80    JB2_MASK_DECODES.with(|c| c.set(c.get() + 1));
81}
82
83// ── Errors ───────────────────────────────────────────────────────────────────
84
85/// Errors that can occur during DjVuPage rendering.
86#[derive(Debug, thiserror::Error)]
87#[non_exhaustive]
88pub enum RenderError {
89    /// IW44 wavelet decode error.
90    #[error("IW44 decode error: {0}")]
91    Iw44(#[from] crate::error::Iw44Error),
92
93    /// JB2 bilevel decode error.
94    #[error("JB2 decode error: {0}")]
95    Jb2(#[from] crate::error::Jb2Error),
96
97    /// The output buffer provided to `render_into` is too small.
98    #[error("buffer too small: need {need} bytes, got {got}")]
99    BufTooSmall { need: usize, got: usize },
100
101    /// The requested render dimensions are invalid (zero width or height).
102    #[error("invalid render dimensions: {width}x{height}")]
103    InvalidDimensions { width: u32, height: u32 },
104
105    /// `chunk_n` is out of range for progressive rendering.
106    #[error("chunk index {chunk_n} out of range (max {max})")]
107    ChunkOutOfRange { chunk_n: usize, max: usize },
108
109    /// BZZ decompression error (for FGbz palette).
110    #[error("BZZ error: {0}")]
111    Bzz(#[from] crate::error::BzzError),
112
113    /// JPEG decode error (for BGjp/FGjp chunks).
114    #[cfg(feature = "std")]
115    #[error("JPEG decode error: {0}")]
116    Jpeg(String),
117
118    /// Document-level error (e.g. page index out of range).
119    #[error("document error: {0}")]
120    Doc(#[from] crate::djvu_document::DocError),
121
122    /// A configured resource limit was exceeded during rendering.
123    #[error("{0}")]
124    ResourceLimit(#[from] crate::resource_limits::ResourceLimitExceeded),
125
126    /// A render option is incompatible with the chosen entry point.
127    ///
128    /// Returned by [`render_streaming`] when an option requires post-processing
129    /// of a fully-allocated pixmap (anti-aliasing, Lanczos resampling at a
130    /// scaled output, or rotation).
131    #[error("unsupported render option: {0}")]
132    UnsupportedOption(&'static str),
133}
134
135/// A refused output pixmap is a render-output limit.
136///
137/// [`Pixmap::try_new`] caps one pixmap at [`Pixmap::MAX_PIXELS`]; on the render
138/// paths that ceiling belongs to the same axis as the configurable
139/// `max_render_pixels`, so it surfaces as [`RenderError::ResourceLimit`]. A
140/// `usize` overflow of `width * height` is an invalid size, not a limit.
141impl From<crate::pixmap::PixmapError> for RenderError {
142    fn from(e: crate::pixmap::PixmapError) -> Self {
143        match e {
144            crate::pixmap::PixmapError::Overflow { width, height } => {
145                RenderError::InvalidDimensions { width, height }
146            }
147            crate::pixmap::PixmapError::TooLarge {
148                width,
149                height,
150                pixels,
151                max,
152            } => RenderError::ResourceLimit(crate::resource_limits::ResourceLimitExceeded {
153                operation: "render",
154                axis: crate::resource_limits::ResourceLimitAxis::RenderOutputPixels,
155                found: pixels as u64,
156                limit: max as u64,
157                page_number: None,
158                width: Some(width),
159                height: Some(height),
160            }),
161            // `PixmapError` is `#[non_exhaustive]` in a sibling crate; a
162            // variant this version does not know is still a refused size.
163            #[allow(unreachable_patterns)]
164            _ => RenderError::UnsupportedOption("output pixmap size refused"),
165        }
166    }
167}
168
169// ── RenderOptions ─────────────────────────────────────────────────────────────
170
171/// User-requested rotation, applied on top of the INFO chunk rotation.
172///
173/// The final rotation is the sum of the INFO rotation and the user rotation.
174/// For example, if the INFO chunk specifies 90° CW and the user requests 90° CW,
175/// the output will be rotated 180°.
176#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
177pub enum UserRotation {
178    /// No additional rotation (only INFO chunk rotation applies).
179    #[default]
180    None,
181    /// 90° clockwise.
182    Cw90,
183    /// 180°.
184    Rot180,
185    /// 90° counter-clockwise (= 270° clockwise).
186    Ccw90,
187}
188
189/// Resampling algorithm used when scaling a rendered page to the target size.
190///
191/// Applied after full-resolution decode and compositing.
192#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
193pub enum Resampling {
194    /// Bilinear interpolation (default — fast, acceptable quality).
195    #[default]
196    Bilinear,
197    /// Lanczos-3 separable resampling.
198    ///
199    /// Higher quality than bilinear for downscaling (less aliasing, sharper
200    /// text). Slower: two-pass separable filter with a 6-tap kernel.
201    /// The rendered pixmap is produced at full page resolution and then
202    /// downscaled, so memory usage is higher than `Bilinear`.
203    Lanczos3,
204}
205
206/// Rendering parameters passed to `render_into` and related functions.
207///
208/// # Example
209///
210/// ```
211/// use djvu_rs::djvu_render::RenderOptions;
212///
213/// // Set the output size; the pipeline derives the decode scale from `width`.
214/// let opts = RenderOptions {
215///     width: 800,
216///     height: 600,
217///     aa: true,
218///     ..Default::default()
219/// };
220/// ```
221#[derive(Debug, Clone, PartialEq)]
222pub struct RenderOptions {
223    /// Output width in pixels.
224    pub width: u32,
225    /// Output height in pixels.
226    pub height: u32,
227    /// Deprecated and **ignored by the render pipeline**.
228    ///
229    /// Rendering now derives its decode scale from `width` and the page's
230    /// native width (see the internal `decode_scale`), so this field no longer
231    /// controls anything. It is retained for backward compatibility — the
232    /// `fit_to_*` constructors still populate it — but setting it by hand has no
233    /// effect. Build options via
234    /// [`RenderOptions::fit_to_width`] / [`fit_to_box`](RenderOptions::fit_to_box)
235    /// instead of assembling the `(width, height, scale)` triple yourself.
236    #[deprecated(
237        since = "0.20.1",
238        note = "scale is derived from `width` by the render pipeline and is no longer read; \
239                build options via `RenderOptions::fit_to_width`/`fit_to_box`. \
240                This field is ignored and will be removed in a future release."
241    )]
242    pub scale: f32,
243    /// Bold level: number of dilation passes on the JB2 mask (0 = no dilation).
244    pub bold: u8,
245    /// Whether to apply anti-aliasing downscale pass.
246    pub aa: bool,
247    /// User-requested rotation, combined with the INFO chunk rotation.
248    pub rotation: UserRotation,
249    /// When `true`, tolerate corrupted chunks instead of returning an error.
250    ///
251    /// - BG44: decodes chunks until the first decode error; uses whatever
252    ///   was decoded so far (may be empty / blurry).
253    /// - JB2 mask: if decoding fails, renders the background without a mask
254    ///   rather than returning `Err`.
255    ///
256    /// Returns `Ok(pixmap)` even when chunks are skipped. Useful for document
257    /// viewers where a partial render is better than a blank page.
258    ///
259    /// Default: `false` (strict — any decode error propagates as `Err`).
260    pub permissive: bool,
261    /// Resampling algorithm applied when scaling to `width`×`height`.
262    ///
263    /// Default: [`Resampling::Bilinear`] (preserves backward compatibility).
264    pub resampling: Resampling,
265    /// Anti-alias the JB2 bilevel mask's edges when rendering at **upscale**
266    /// (zoom > 1): instead of the hard nearest-bit lookup, bilinearly
267    /// interpolate the mask's 0/255 coverage and blend foreground/background
268    /// colour proportionally — smoother glyph edges under zoom.
269    ///
270    /// A no-op at scale ≤ 1 (native or downscaled renders are unaffected).
271    ///
272    /// **Opt-in, default `false`.** DjVuLibre hard-edges the mask under zoom,
273    /// so enabling this is a deliberate, judged divergence from the reference
274    /// renderer's pixel output — a "prettier than DjVuLibre" quality mode, not
275    /// a faithfulness fix. Leaving it `false` keeps `render_pixmap` and
276    /// friends byte-identical to prior releases.
277    pub mask_aa: bool,
278}
279
280impl Default for RenderOptions {
281    #[allow(deprecated)] // still sets the retained-for-compat `scale` field
282    fn default() -> Self {
283        RenderOptions {
284            width: 0,
285            height: 0,
286            scale: 1.0,
287            bold: 0,
288            aa: false,
289            rotation: UserRotation::None,
290            permissive: false,
291            resampling: Resampling::Bilinear,
292            mask_aa: false,
293        }
294    }
295}
296
297fn effective_max_render_pixels(
298    page: &crate::djvu_document::DjVuPage,
299    limits: Option<crate::resource_limits::ResourceLimits>,
300) -> u64 {
301    limits
302        .and_then(|limits| limits.max_render_pixels)
303        .or(page
304            .resource_limits()
305            .and_then(|limits| limits.max_render_pixels))
306        .unwrap_or(crate::resource_limits::DEFAULT_MAX_RENDER_PIXELS)
307}
308
309fn check_output_pixels(
310    operation: &'static str,
311    page: &crate::djvu_document::DjVuPage,
312    limits: Option<crate::resource_limits::ResourceLimits>,
313    width: u32,
314    height: u32,
315) -> Result<(), RenderError> {
316    if width == 0 || height == 0 {
317        return Err(RenderError::InvalidDimensions { width, height });
318    }
319    let pixels = u64::from(width) * u64::from(height);
320    let limit = effective_max_render_pixels(page, limits);
321    if pixels > limit {
322        return Err(RenderError::ResourceLimit(
323            crate::resource_limits::ResourceLimitExceeded {
324                operation,
325                axis: crate::resource_limits::ResourceLimitAxis::RenderOutputPixels,
326                found: pixels,
327                limit,
328                page_number: Some(page.index() + 1),
329                width: Some(width),
330                height: Some(height),
331            },
332        ));
333    }
334    Ok(())
335}
336
337/// A document layer a permissive render skipped or fell back on (#696).
338#[derive(Debug, Clone, Copy, PartialEq, Eq)]
339pub enum RecoveredLayer {
340    /// The IW44 background (`BG44`), possibly truncated at a corrupt chunk.
341    Background,
342    /// The IW44 foreground detail plane (`FG44`).
343    Foreground,
344    /// The JB2 stencil mask (`Sjbz`).
345    Mask,
346    /// The `FGbz` foreground colour palette.
347    ForegroundPalette,
348}
349
350/// One recovery action a permissive render took to keep going (#696).
351#[derive(Debug, Clone, PartialEq, Eq)]
352pub struct RenderRecovery {
353    /// Which layer was affected.
354    pub layer: RecoveredLayer,
355    /// Human-readable explanation of what was skipped or substituted.
356    pub detail: String,
357}
358
359/// Structured record of what a permissive render skipped or recovered (#696).
360///
361/// Returned alongside the pixmap by [`render_pixmap_with_report`]. An empty
362/// report (`is_clean`) means the page decoded fully with no fallbacks.
363#[derive(Debug, Clone, Default)]
364pub struct RenderReport {
365    /// Recovery actions in the order the renderer took them.
366    pub recoveries: Vec<RenderRecovery>,
367}
368
369impl RenderReport {
370    /// Whether the render needed no recovery (every layer decoded cleanly).
371    pub fn is_clean(&self) -> bool {
372        self.recoveries.is_empty()
373    }
374}
375
376#[cfg(feature = "std")]
377thread_local! {
378    /// Active recovery sink. `Some` only while [`render_pixmap_with_report`] is
379    /// on the stack; otherwise `record_recovery` is a no-op, so the ordinary
380    /// [`render_pixmap`] hot path is untouched.
381    static RECOVERY_SINK: core::cell::RefCell<Option<Vec<RenderRecovery>>> =
382        const { core::cell::RefCell::new(None) };
383}
384
385/// Record a permissive recovery when a report is being collected.
386#[cfg(feature = "std")]
387fn record_recovery(layer: RecoveredLayer, detail: impl Into<String>) {
388    RECOVERY_SINK.with(|sink| {
389        if let Some(list) = sink.borrow_mut().as_mut() {
390            list.push(RenderRecovery {
391                layer,
392                detail: detail.into(),
393            });
394        }
395    });
396}
397
398/// No-op in `no_std`: the report API requires `std` thread-locals.
399#[cfg(not(feature = "std"))]
400fn record_recovery(_layer: RecoveredLayer, _detail: &str) {}
401
402/// Unwrap a permissive layer decode, recording a recovery on failure.
403fn permissive_layer<T>(result: Result<Option<T>, RenderError>, layer: RecoveredLayer) -> Option<T> {
404    match result {
405        Ok(value) => value,
406        Err(error) => {
407            record_recovery(layer, {
408                #[cfg(feature = "std")]
409                {
410                    error.to_string()
411                }
412                #[cfg(not(feature = "std"))]
413                {
414                    let _ = error;
415                    ""
416                }
417            });
418            None
419        }
420    }
421}
422
423#[allow(deprecated)] // the `fit_to_*` constructors still populate `scale` for back-compat
424impl RenderOptions {
425    /// Create render options that scale the page to fit the given width,
426    /// preserving aspect ratio. Respects page rotation from the INFO chunk.
427    pub fn fit_to_width(page: &crate::djvu_document::DjVuPage, width: u32) -> Self {
428        let (dw, dh) = display_dimensions(page);
429        let height = if dw == 0 {
430            width
431        } else {
432            ((dh as f64 * width as f64) / dw as f64).round() as u32
433        }
434        .max(1);
435        let scale = width as f32 / dw.max(1) as f32;
436        RenderOptions {
437            width,
438            height,
439            scale,
440            ..Default::default()
441        }
442    }
443
444    /// Create render options that scale the page to fit the given height,
445    /// preserving aspect ratio. Respects page rotation from the INFO chunk.
446    pub fn fit_to_height(page: &crate::djvu_document::DjVuPage, height: u32) -> Self {
447        let (dw, dh) = display_dimensions(page);
448        let width = if dh == 0 {
449            height
450        } else {
451            ((dw as f64 * height as f64) / dh as f64).round() as u32
452        }
453        .max(1);
454        let scale = height as f32 / dh.max(1) as f32;
455        RenderOptions {
456            width,
457            height,
458            scale,
459            ..Default::default()
460        }
461    }
462
463    /// Create render options that scale the page to fit within a bounding box,
464    /// preserving aspect ratio. Respects page rotation from the INFO chunk.
465    pub fn fit_to_box(
466        page: &crate::djvu_document::DjVuPage,
467        max_width: u32,
468        max_height: u32,
469    ) -> Self {
470        let (dw, dh) = display_dimensions(page);
471        if dw == 0 || dh == 0 {
472            return RenderOptions {
473                width: max_width.max(1),
474                height: max_height.max(1),
475                scale: 1.0,
476                ..Default::default()
477            };
478        }
479        let scale_w = max_width as f64 / dw as f64;
480        let scale_h = max_height as f64 / dh as f64;
481        let scale = if scale_w < scale_h { scale_w } else { scale_h };
482        let width = (dw as f64 * scale).round() as u32;
483        let height = (dh as f64 * scale).round() as u32;
484        RenderOptions {
485            width: width.max(1),
486            height: height.max(1),
487            scale: scale as f32,
488            ..Default::default()
489        }
490    }
491
492    /// Whether `page` can be rendered with [`render_streaming`] under these
493    /// options, producing pixels identical to [`render_pixmap`].
494    ///
495    /// The streaming path emits the page row-by-row without buffering a full
496    /// [`Pixmap`], so callers that only need to forward rows (PDF/TIFF image
497    /// encoders) can avoid the intermediate allocation. It is only equivalent
498    /// to the buffered path when no whole-image post-pass is required: no
499    /// anti-aliasing, no rotation, and either bilinear resampling or a 1:1
500    /// (unscaled) render.
501    ///
502    /// This is the single source of truth for streaming eligibility; export
503    /// paths call it instead of re-deriving the rule.
504    pub fn can_stream(&self, page: &crate::djvu_document::DjVuPage) -> bool {
505        !self.aa
506            && (self.resampling == Resampling::Bilinear
507                || (page.width() as u32 == self.width && page.height() as u32 == self.height))
508            && page.rotation() == crate::info::Rotation::None
509            && self.rotation == UserRotation::None
510    }
511
512    /// The scale the decode pipeline uses to choose the IW44 wavelet subsample
513    /// level (via [`best_iw44_subsample`]), derived from the requested output
514    /// `width` and the page's **native** width.
515    ///
516    /// The compositor scales the native page raster (`page.width()` ×
517    /// `page.height()`) into the `width` × `height` buffer and only *then*
518    /// applies INFO/user rotation (see `composite_rows` and `rotate_pixmap`).
519    /// The IW44 background is decoded in that pre-rotation native orientation, so
520    /// the subsample must be chosen against `width / page.width()`. Dividing by
521    /// the rotation-swapped *display* width would pick the wrong subsample for
522    /// INFO-rotated, non-square pages — over-subsampling a downscaled portrait
523    /// background and under-subsampling a landscape one.
524    ///
525    /// This is the single home of the `scale ≈ width / page-width` invariant that
526    /// every caller used to maintain by hand — and that the PDF exporter got
527    /// wrong, leaving `scale = 1.0` and silently over-decoding at every DPI.
528    /// Rendering reads *this*, never the deprecated public [`scale`](Self::scale)
529    /// field, so a caller can no longer cause a silent over- or under-decode by
530    /// building the size triple inconsistently.
531    pub(crate) fn decode_scale(&self, page: &crate::djvu_document::DjVuPage) -> f32 {
532        self.width as f32 / (page.width() as u32).max(1) as f32
533    }
534}
535
536/// Return `(display_width, display_height)` — dimensions after rotation.
537///
538/// The single source of the INFO-rotation dimension swap; the `fit_to_*`
539/// constructors and `Page::display_dims` both call it instead of re-deriving it.
540pub(crate) fn display_dimensions(page: &crate::djvu_document::DjVuPage) -> (u32, u32) {
541    let w = page.width() as u32;
542    let h = page.height() as u32;
543    match page.rotation() {
544        crate::info::Rotation::Cw90 | crate::info::Rotation::Ccw90 => (h, w),
545        _ => (w, h),
546    }
547}
548
549// ── Gamma LUT ─────────────────────────────────────────────────────────────────
550
551/// Standard sRGB / CRT display gamma assumed for rendering output.
552const DISPLAY_GAMMA: f32 = 2.2;
553
554/// Precompute a gamma-correction look-up table for values 0..255.
555///
556/// Matches DjVuLibre's correction formula: the exponent is
557/// `document_gamma / DISPLAY_GAMMA` so that documents created on a
558/// standard gamma-2.2 device need no correction (identity LUT), while
559/// documents from linear-light (gamma=1.0) sources are brightened to
560/// compensate for the display gamma.
561///
562/// `lut[i] = round(255 * (i/255)^(gamma / DISPLAY_GAMMA))`
563///
564/// When `gamma <= 0.0`, not finite, or approximately equal to
565/// `DISPLAY_GAMMA`, the LUT is the identity function (no correction).
566fn build_gamma_lut(gamma: f32) -> [u8; 256] {
567    let mut lut = [0u8; 256];
568    let exponent = if gamma <= 0.0 || !gamma.is_finite() {
569        1.0_f32 // invalid — no correction
570    } else {
571        gamma / DISPLAY_GAMMA
572    };
573    if (exponent - 1.0).abs() < 1e-4 {
574        // Identity
575        for (i, v) in lut.iter_mut().enumerate() {
576            *v = i as u8;
577        }
578        return lut;
579    }
580    for (i, v) in lut.iter_mut().enumerate() {
581        let linear = i as f32 / 255.0;
582        let corrected = linear.powf(exponent);
583        *v = (corrected * 255.0 + 0.5) as u8;
584    }
585    lut
586}
587
588// ── Bilinear scaling (FRACBITS = 4) ──────────────────────────────────────────
589
590/// Fixed-point fractional bits for bilinear scaling (1 << 4 = 16 subpixels).
591const FRACBITS: u32 = 4;
592const FRAC: u32 = 1 << FRACBITS;
593const FRAC_MASK: u32 = FRAC - 1;
594
595/// Maps each byte value to 8 fg-mask bytes (MSB-first): 0xFF if bit set (fg), 0x00 otherwise.
596const MASK_EXPAND: [[u8; 8]; 256] = {
597    let mut lut = [[0u8; 8]; 256];
598    let mut b = 0usize;
599    while b < 256 {
600        let mut bit = 0usize;
601        while bit < 8 {
602            lut[b][bit] = if (b >> (7 - bit)) & 1 != 0 {
603                0xFF
604            } else {
605                0x00
606            };
607            bit += 1;
608        }
609        b += 1;
610    }
611    lut
612};
613
614/// Maps each mask byte to 8 RGBA pixels for bilevel rendering (MSB-first, 300 DPI 1:1).
615/// fg bit (1) → [0x00, 0x00, 0x00, 0xFF] (black); bg bit (0) → [0xFF, 0xFF, 0xFF, 0xFF] (white).
616/// Table: 256 × 32 = 8 KiB (128 cache lines); persists in L2 across rows.
617const BILEVEL_RGBA: [[u8; 32]; 256] = {
618    let mut lut = [[0u8; 32]; 256];
619    let mut mb = 0usize;
620    while mb < 256 {
621        let mut bit = 0usize;
622        while bit < 8 {
623            let ch = if (mb >> (7 - bit)) & 1 != 0 {
624                0u8
625            } else {
626                255u8
627            };
628            lut[mb][bit * 4] = ch;
629            lut[mb][bit * 4 + 1] = ch;
630            lut[mb][bit * 4 + 2] = ch;
631            lut[mb][bit * 4 + 3] = 255;
632            bit += 1;
633        }
634        mb += 1;
635    }
636    lut
637};
638
639// ── SIMD helpers ──────────────────────────────────────────────────────────────
640
641/// Convert packed RGB bytes to packed RGBA with alpha = 255.
642///
643/// On x86_64 with SSSE3 (available on Core 2+, ~2006): processes 4 pixels per
644/// `_mm_shuffle_epi8` + `_mm_or_si128`.  Falls back to scalar on older targets.
645///
646/// `src` must hold exactly `pixel_count * 3` bytes;
647/// `dst` must hold exactly `pixel_count * 4` bytes.
648#[cfg(feature = "std")]
649#[allow(unsafe_code)]
650#[inline]
651fn rgb_to_rgba(src: &[u8], dst: &mut [u8]) {
652    let pixel_count = src.len() / 3;
653    debug_assert_eq!(dst.len(), pixel_count * 4);
654
655    #[cfg(target_arch = "x86_64")]
656    if is_x86_feature_detected!("ssse3") {
657        // SAFETY: feature detected; bounds are enforced by safe_chunks calculation.
658        unsafe {
659            // Only load 16 bytes where src has at least 16 bytes available:
660            // chunk i reads src[i*12..i*12+16], so we need i*12+16 <= src.len().
661            let safe_chunks = if src.len() >= 16 {
662                ((src.len() - 16) / 12 + 1).min(pixel_count / 4)
663            } else {
664                0
665            };
666            rgb_to_rgba_ssse3(src, dst, pixel_count, safe_chunks);
667        }
668        return;
669    }
670
671    rgb_to_rgba_scalar(src, dst, 0, pixel_count);
672}
673
674#[cfg(all(feature = "std", target_arch = "x86_64"))]
675#[allow(unsafe_code, unsafe_op_in_unsafe_fn)]
676#[target_feature(enable = "ssse3")]
677// SAFETY: caller guarantees SSSE3 availability; safe_chunks * 12 + 16 <= src.len()
678// and safe_chunks * 16 <= dst.len() (enforced by rgb_to_rgba).
679unsafe fn rgb_to_rgba_ssse3(src: &[u8], dst: &mut [u8], pixel_count: usize, safe_chunks: usize) {
680    use core::arch::x86_64::*;
681
682    // Shuffle 12 packed RGB bytes into 16 RGBA bytes (4 pixels), zero in alpha slot.
683    // _mm_set_epi8 arguments are byte 15 (highest) down to byte 0 (lowest).
684    let shuf = _mm_set_epi8(
685        -1, 11, 10, 9, // pixel 3: [R,G,B,0]
686        -1, 8, 7, 6, // pixel 2
687        -1, 5, 4, 3, // pixel 1
688        -1, 2, 1, 0, // pixel 0
689    );
690    let alpha_or = _mm_set1_epi32(0xFF000000u32 as i32);
691
692    for i in 0..safe_chunks {
693        let v = _mm_loadu_si128(src.as_ptr().add(i * 12) as *const __m128i);
694        _mm_storeu_si128(
695            dst.as_mut_ptr().add(i * 16) as *mut __m128i,
696            _mm_or_si128(_mm_shuffle_epi8(v, shuf), alpha_or),
697        );
698    }
699
700    rgb_to_rgba_scalar(src, dst, safe_chunks * 4, pixel_count);
701}
702
703#[cfg(feature = "std")]
704#[inline]
705fn rgb_to_rgba_scalar(src: &[u8], dst: &mut [u8], start: usize, end: usize) {
706    for i in start..end {
707        dst[i * 4] = src[i * 3];
708        dst[i * 4 + 1] = src[i * 3 + 1];
709        dst[i * 4 + 2] = src[i * 3 + 2];
710        dst[i * 4 + 3] = 255;
711    }
712}
713
714/// Generic Q24 ratios `(plane_w << 24) / page_w` and `(plane_h << 24) / page_h`
715/// for converting page-space FRACBITS coords into plane-space FRACBITS coords.
716/// Returns `(0, 0)` when `plane` is `None` or page dims are zero.  The public
717/// FG/BG helpers below apply layer-specific cell-grid adjustments first.
718#[inline]
719fn plane_q24(plane: Option<&Pixmap>, page_w: u32, page_h: u32) -> (u64, u64) {
720    match plane {
721        Some(p) if page_w > 0 && page_h > 0 => (
722            ((p.width as u64) << 24) / page_w as u64,
723            ((p.height as u64) << 24) / page_h as u64,
724        ),
725        _ => (0, 0),
726    }
727}
728
729/// Map page-space fixed-point coordinates to BG44/FG44 plane-space by aligning
730/// pixel centres instead of top-left corners.  This matches the usual image
731/// resampling convention and reduces native-resolution drift against ddjvu for
732/// non-integer page→plane ratios such as colorbook's 2260→754 BG scale.
733#[inline]
734fn map_plane_center_frac(page_frac: u32, q24: u64) -> u32 {
735    let centered = (((page_frac as u64 + (FRAC / 2) as u64) * q24) >> 24) as u32;
736    centered.saturating_sub(FRAC / 2)
737}
738
739#[inline]
740fn fg_q24(fg: Option<&Pixmap>, page_w: u32, page_h: u32) -> (u64, u64) {
741    match fg {
742        Some(p) if page_w > 0 && page_h > 0 && p.width > 0 && p.height > 0 => {
743            // FG44 is a sparse foreground colour map.  Horizontally, the last
744            // encoded column is often padding for a fixed-width colour cell
745            // grid (e.g. 2260px page / 189px FG => 12px cells), so use the
746            // inferred integer cell pitch instead of stretching across the
747            // padded column.  Vertically, use the encoded plane ratio so the
748            // bottom FG row remains reachable when the page height is not an
749            // exact multiple of the cell pitch.
750            let sx = page_w.div_ceil(p.width).max(1);
751            (
752                (1u64 << 24) / sx as u64,
753                ((p.height as u64) << 24) / page_h as u64,
754            )
755        }
756        _ => plane_q24(fg, page_w, page_h),
757    }
758}
759
760/// `bg` is the background plane's `(width, height)` — the whole plane's, even
761/// when the compositor holds only a band of its rows (#811).
762#[inline]
763fn bg_q24(bg: Option<(u32, u32)>, page_w: u32, page_h: u32) -> (u64, u64) {
764    match bg {
765        Some((w, h)) if page_w > 0 && page_h > 0 && w > 0 && h > 0 => {
766            // BG44 planes are cell grids too (usually page/3 for scans).  Use
767            // the inferred integer subsample pitch so the padded right/bottom
768            // edge cells do not stretch across the page during native render.
769            let sx = page_w.div_ceil(w).max(1);
770            let sy = page_h.div_ceil(h).max(1);
771            ((1u64 << 24) / sx as u64, (1u64 << 24) / sy as u64)
772        }
773        _ => (0, 0),
774    }
775}
776
777/// Sample a pixmap at fractional coordinates using bilinear interpolation.
778///
779/// Coordinates are in fixed-point: `fx = x * FRAC`, etc.
780/// Returns (r, g, b).
781#[inline]
782#[cfg_attr(not(test), allow(dead_code))]
783fn sample_bilinear(pm: &Pixmap, fx: u32, fy: u32) -> (u8, u8, u8) {
784    let x0 = (fx >> FRACBITS).min(pm.width.saturating_sub(1));
785    let y0 = (fy >> FRACBITS).min(pm.height.saturating_sub(1));
786    let x1 = (x0 + 1).min(pm.width.saturating_sub(1));
787    let y1 = (y0 + 1).min(pm.height.saturating_sub(1));
788
789    let tx = fx & FRAC_MASK; // 0..15
790    let ty = fy & FRAC_MASK;
791
792    let (r00, g00, b00) = pm.get_rgb(x0, y0);
793    let (r10, g10, b10) = pm.get_rgb(x1, y0);
794    let (r01, g01, b01) = pm.get_rgb(x0, y1);
795    let (r11, g11, b11) = pm.get_rgb(x1, y1);
796
797    let lerp = |a: u8, b: u8, c: u8, d: u8| -> u8 {
798        let top = a as u32 * (FRAC - tx) + b as u32 * tx;
799        let bot = c as u32 * (FRAC - tx) + d as u32 * tx;
800        let numerator = top * (FRAC - ty) + bot * ty;
801        // v ≤ (255*FRAC*FRAC + round) >> (2*FRACBITS) = 255 — no clamp needed.
802        ((numerator + (1 << (2 * FRACBITS - 1))) >> (2 * FRACBITS)) as u8
803    };
804
805    (
806        lerp(r00, r10, r01, r11),
807        lerp(g00, g10, g01, g11),
808        lerp(b00, b10, b01, b11),
809    )
810}
811
812/// Bilinear sample using pre-fetched row slices (avoids repeated y-coord computation).
813/// `ty` is the vertical fractional weight (0..FRAC-1). Row slices are RGBA (4 bytes/pixel).
814#[inline]
815fn bilinear_from_rows(row0: &[u8], row1: &[u8], width: u32, fx: u32, ty: u32) -> (u8, u8, u8) {
816    let w = width.saturating_sub(1) as usize;
817    let x0 = (fx >> FRACBITS) as usize;
818    let x0 = x0.min(w);
819    let x1 = (x0 + 1).min(w);
820    let tx = fx & FRAC_MASK;
821
822    // Read 4 bytes (RGBA) per pixel — a single 32-bit load on all targets.
823    let get = |row: &[u8], x: usize| -> (u8, u8, u8) {
824        let off = x * 4;
825        if let Some(q) = row.get(off..off + 4) {
826            (q[0], q[1], q[2])
827        } else {
828            (0, 0, 0)
829        }
830    };
831    let (r00, g00, b00) = get(row0, x0);
832    let (r10, g10, b10) = get(row0, x1);
833    let (r01, g01, b01) = get(row1, x0);
834    let (r11, g11, b11) = get(row1, x1);
835
836    // Precompute bilinear weights so ty/ity are absorbed into w01/w11 and never
837    // need to be reloaded from the stack during per-channel accumulation.
838    // Weights sum to FRAC*FRAC = 256, so result = dot/256 (>> 8).
839    let itx = FRAC - tx;
840    let ity = FRAC - ty;
841    let w00 = itx * ity;
842    let w10 = tx * ity;
843    let w01 = itx * ty;
844    let w11 = tx * ty;
845    // max dot = 255 * 256 = 65280 + 128 ≤ u32 range; no clamp needed.
846    let blend = |a: u8, b: u8, c: u8, d: u8| -> u8 {
847        ((a as u32 * w00 + b as u32 * w10 + c as u32 * w01 + d as u32 * w11 + 128) >> 8) as u8
848    };
849    (
850        blend(r00, r10, r01, r11),
851        blend(g00, g10, g01, g11),
852        blend(b00, b10, b01, b11),
853    )
854}
855
856#[inline]
857#[cfg_attr(not(test), allow(dead_code))]
858fn sample_nearest(pm: &Pixmap, fx: u32, fy: u32) -> (u8, u8, u8) {
859    let x = ((fx + FRAC / 2) >> FRACBITS).min(pm.width.saturating_sub(1));
860    let y = ((fy + FRAC / 2) >> FRACBITS).min(pm.height.saturating_sub(1));
861    pm.get_rgb(x, y)
862}
863
864/// Area-average (box filter) sample: average all source pixels covered by the
865/// output pixel's footprint.  Used when downscaling (scale < 1.0) for better
866/// anti-aliasing and fewer moire patterns than bilinear.
867///
868/// `fx`, `fy` are the top-left corner of the output pixel in fixed-point.
869/// `fx_step`, `fy_step` are the output pixel size in source coordinates.
870#[inline]
871fn sample_area_avg(pm: &Pixmap, fx: u32, fy: u32, fx_step: u32, fy_step: u32) -> (u8, u8, u8) {
872    let (x0, x1) = area_range(pm.width, fx, fx_step);
873    let (y0, y1) = area_range(pm.height, fy, fy_step);
874    sample_area_avg_bounds(PlaneView::whole(pm), x0, x1, y0, y1)
875}
876
877#[inline]
878fn sample_area_avg_bounds(pm: PlaneView<'_>, x0: u32, x1: u32, y0: u32, y1: u32) -> (u8, u8, u8) {
879    let cols = (x1 - x0) as usize;
880    let rows = (y1 - y0) as usize;
881
882    // Fast path: 1x1 box -> direct read.
883    if cols <= 1 && rows <= 1 {
884        let off = x0 as usize * 4;
885        return pm
886            .row(y0)
887            .get(off..off + 4)
888            .map_or((0, 0, 0), |q| (q[0], q[1], q[2]));
889    }
890
891    let mut r_sum = 0u32;
892    let mut g_sum = 0u32;
893    let mut b_sum = 0u32;
894
895    // One bounds check per row (not per pixel) to let the inner loop vectorize.
896    for sy in y0..y1 {
897        let x_off = x0 as usize * 4;
898        if let Some(row) = pm.row(sy).get(x_off..x_off + cols * 4) {
899            for chunk in row.as_chunks::<4>().0 {
900                r_sum += chunk[0] as u32;
901                g_sum += chunk[1] as u32;
902                b_sum += chunk[2] as u32;
903            }
904        }
905    }
906
907    let count = (rows * cols) as u32;
908    if count == 0 {
909        return (255, 255, 255);
910    }
911
912    // Power-of-2 counts (count=4 at 2x downscale): replace UDIV with shifts.
913    let half = count >> 1;
914    if count.is_power_of_two() {
915        let shift = count.trailing_zeros();
916        (
917            ((r_sum + half) >> shift) as u8,
918            ((g_sum + half) >> shift) as u8,
919            ((b_sum + half) >> shift) as u8,
920        )
921    } else {
922        (
923            ((r_sum + half) / count) as u8,
924            ((g_sum + half) / count) as u8,
925            ((b_sum + half) / count) as u8,
926        )
927    }
928}
929
930/// Coverage-weighted bilevel downscale: returns the fraction of set mask bits in
931/// the output pixel's footprint as a gray value (0 = all background, 255 = all foreground).
932///
933/// Used by `composite_rows_bilevel_one` for anti-aliased text at downscale DPIs.
934#[inline]
935fn mask_box_coverage(
936    mask: &crate::bitmap::Bitmap,
937    fx: u32,
938    fy: u32,
939    fx_step: u32,
940    fy_step: u32,
941) -> u8 {
942    let x0 = (fx >> FRACBITS).min(mask.width.saturating_sub(1));
943    let y0 = (fy >> FRACBITS).min(mask.height.saturating_sub(1));
944    let x1 = ((fx + fx_step) >> FRACBITS).min(mask.width);
945    let y1 = ((fy + fy_step) >> FRACBITS).min(mask.height);
946    let total = (x1 - x0) * (y1 - y0);
947    if total == 0 {
948        return 0;
949    }
950    // Count foreground bits using byte-level popcount instead of individual bit reads.
951    // MSB-first packing: pixel x is at bit (7 - x%8) of byte (x/8).
952    // first_mask keeps pixels [x0, next-byte-boundary); end_mask keeps pixels before x1.
953    let stride = mask.row_stride();
954    let byte_lo = x0 as usize / 8;
955    let byte_hi = (x1 as usize).div_ceil(8); // exclusive
956    let first_mask = 0xFF_u8 >> (x0 % 8);
957    let end_mask = if x1.is_multiple_of(8) {
958        0xFF_u8
959    } else {
960        0xFF_u8 << (8 - x1 % 8)
961    };
962    let mut count = 0u32;
963    if byte_hi == byte_lo + 1 {
964        // Entire x-range fits in one byte.
965        let combined = first_mask & end_mask;
966        for sy in y0..y1 {
967            count += (mask.data[sy as usize * stride + byte_lo] & combined).count_ones();
968        }
969    } else {
970        for sy in y0..y1 {
971            let row = &mask.data[sy as usize * stride..];
972            count += (row[byte_lo] & first_mask).count_ones();
973            for byte in row[(byte_lo + 1)..(byte_hi - 1)].iter() {
974                count += byte.count_ones();
975            }
976            count += (row[byte_hi - 1] & end_mask).count_ones();
977        }
978    }
979    // Widen to u64: on a large mask with aggressive downsampling a single box can
980    // cover > 16.8 M foreground bits, where `count * 255` overflows u32 (wrong
981    // value in release, panic in debug).
982    ((count as u64 * 255 + total as u64 / 2) / total as u64) as u8
983}
984
985/// Bilinearly interpolate the JB2 mask's 0/255 bits as a continuous coverage
986/// field, for anti-aliased glyph edges at **upscale** (zoom > 1).
987///
988/// Treats each set mask bit as full foreground coverage (255) and each clear
989/// bit as full background coverage (0), then blends the four nearest mask
990/// pixels the same way [`sample_bilinear`] blends a [`Pixmap`] — mirroring
991/// `mask_box_coverage`'s box-average approach used at *downscale*, but for the
992/// opposite direction.
993///
994/// Returns the interpolated foreground fraction, 0 (all background) ..= 255
995/// (all foreground) — same convention as `mask_box_coverage`.
996///
997/// Opt-in via [`RenderOptions::mask_aa`]: DjVuLibre hard-edges the mask under
998/// zoom, so this is a deliberate, judged divergence from the reference
999/// renderer, not a faithfulness fix — the default (`mask_aa: false`) path
1000/// never calls this function.
1001#[inline]
1002fn mask_bilinear_coverage(mask: &crate::bitmap::Bitmap, fx: u32, fy: u32) -> u8 {
1003    let x0 = (fx >> FRACBITS).min(mask.width.saturating_sub(1));
1004    let y0 = (fy >> FRACBITS).min(mask.height.saturating_sub(1));
1005    let x1 = (x0 + 1).min(mask.width.saturating_sub(1));
1006    let y1 = (y0 + 1).min(mask.height.saturating_sub(1));
1007
1008    let tx = fx & FRAC_MASK;
1009    let ty = fy & FRAC_MASK;
1010
1011    let bit = |x: u32, y: u32| -> u32 { if mask.get(x, y) { 255 } else { 0 } };
1012    let v00 = bit(x0, y0);
1013    let v10 = bit(x1, y0);
1014    let v01 = bit(x0, y1);
1015    let v11 = bit(x1, y1);
1016
1017    let top = v00 * (FRAC - tx) + v10 * tx;
1018    let bot = v01 * (FRAC - tx) + v11 * tx;
1019    let numerator = top * (FRAC - ty) + bot * ty;
1020    // Same shape as sample_bilinear's lerp: v <= 255 — no clamp needed.
1021    ((numerator + (1 << (2 * FRACBITS - 1))) >> (2 * FRACBITS)) as u8
1022}
1023
1024/// Find the center foreground pixel in a mask box for palette color lookup.
1025#[inline]
1026fn mask_box_center_fg(
1027    mask: &crate::bitmap::Bitmap,
1028    fx: u32,
1029    fy: u32,
1030    fx_step: u32,
1031    fy_step: u32,
1032) -> (u32, u32) {
1033    // Use the center of the box
1034    let cx = (fx + fx_step / 2) >> FRACBITS;
1035    let cy = (fy + fy_step / 2) >> FRACBITS;
1036    (
1037        cx.min(mask.width.saturating_sub(1)),
1038        cy.min(mask.height.saturating_sub(1)),
1039    )
1040}
1041
1042// ── Anti-aliasing downscale ──────────────────────────────────────────────────
1043
1044/// Apply a 2×2 box-filter downscale pass for anti-aliasing.
1045///
1046/// If either dimension of `pm` is 1, the output dimension stays at 1.
1047fn aa_downscale(pm: &Pixmap) -> Pixmap {
1048    let out_w = (pm.width / 2).max(1);
1049    let out_h = (pm.height / 2).max(1);
1050    let mut out = Pixmap::white(out_w, out_h);
1051    for y in 0..out_h {
1052        for x in 0..out_w {
1053            let sx = (x * 2).min(pm.width.saturating_sub(1));
1054            let sy = (y * 2).min(pm.height.saturating_sub(1));
1055            let sx1 = (sx + 1).min(pm.width.saturating_sub(1));
1056            let sy1 = (sy + 1).min(pm.height.saturating_sub(1));
1057
1058            let (r00, g00, b00) = pm.get_rgb(sx, sy);
1059            let (r10, g10, b10) = pm.get_rgb(sx1, sy);
1060            let (r01, g01, b01) = pm.get_rgb(sx, sy1);
1061            let (r11, g11, b11) = pm.get_rgb(sx1, sy1);
1062
1063            let avg = |a: u8, b: u8, c: u8, d: u8| -> u8 {
1064                ((a as u32 + b as u32 + c as u32 + d as u32 + 2) / 4) as u8
1065            };
1066            out.set_rgb(
1067                x,
1068                y,
1069                avg(r00, r10, r01, r11),
1070                avg(g00, g10, g01, g11),
1071                avg(b00, b10, b01, b11),
1072            );
1073        }
1074    }
1075    out
1076}
1077
1078// ── Page rotation ───────────────────────────────────────────────────────────
1079
1080/// Convert a rotation to a number of 90° CW steps (0..3).
1081fn rotation_to_steps(r: crate::info::Rotation) -> u8 {
1082    use crate::info::Rotation;
1083    match r {
1084        Rotation::None => 0,
1085        Rotation::Cw90 => 1,
1086        Rotation::Rot180 => 2,
1087        Rotation::Ccw90 => 3,
1088    }
1089}
1090
1091/// Convert a user rotation to a number of 90° CW steps (0..3).
1092fn user_rotation_to_steps(r: UserRotation) -> u8 {
1093    match r {
1094        UserRotation::None => 0,
1095        UserRotation::Cw90 => 1,
1096        UserRotation::Rot180 => 2,
1097        UserRotation::Ccw90 => 3,
1098    }
1099}
1100
1101/// Combine INFO chunk rotation with user rotation and return the combined
1102/// `info::Rotation` value.
1103pub(crate) fn combine_rotations(
1104    info: crate::info::Rotation,
1105    user: UserRotation,
1106) -> crate::info::Rotation {
1107    use crate::info::Rotation;
1108    let steps = (rotation_to_steps(info) + user_rotation_to_steps(user)) % 4;
1109    match steps {
1110        0 => Rotation::None,
1111        1 => Rotation::Cw90,
1112        2 => Rotation::Rot180,
1113        3 => Rotation::Ccw90,
1114        _ => unreachable!(),
1115    }
1116}
1117
1118/// Apply page rotation to the rendered pixmap.
1119///
1120/// For 90°/270° rotations, width and height are swapped.
1121fn rotate_pixmap(src: Pixmap, rotation: crate::info::Rotation) -> Pixmap {
1122    use crate::info::Rotation;
1123    match rotation {
1124        Rotation::None => src,
1125        Rotation::Cw90 => {
1126            let w = src.height;
1127            let h = src.width;
1128            let mut out = Pixmap::white(w, h);
1129            // #447: 32×32 tiled transpose. The naïve per-pixel write strides the
1130            // destination by `out.width*4` bytes (a cache miss per pixel); tiling
1131            // keeps both the source read and destination write within a few cache
1132            // lines per tile. 4-byte copy is exact: rendered source pixmaps carry
1133            // alpha=255 and `Pixmap::white` pre-fills alpha=255.
1134            const TILE: u32 = 32;
1135            let (sw, sh, ow) = (src.width as usize, src.height as usize, w as usize);
1136            let mut ty = 0;
1137            while ty < src.height {
1138                let mut tx = 0;
1139                while tx < src.width {
1140                    let y_end = (ty + TILE).min(src.height);
1141                    let x_end = (tx + TILE).min(src.width);
1142                    for y in ty..y_end {
1143                        let src_row = y as usize * sw * 4;
1144                        let dst_col = sh - 1 - y as usize;
1145                        for x in tx..x_end {
1146                            let si = src_row + x as usize * 4;
1147                            let di = (x as usize * ow + dst_col) * 4;
1148                            out.data[di..di + 4].copy_from_slice(&src.data[si..si + 4]);
1149                        }
1150                    }
1151                    tx += TILE;
1152                }
1153                ty += TILE;
1154            }
1155            out
1156        }
1157        Rotation::Rot180 => {
1158            let mut out = Pixmap::white(src.width, src.height);
1159            for y in 0..src.height {
1160                for x in 0..src.width {
1161                    let (r, g, b) = src.get_rgb(x, y);
1162                    out.set_rgb(src.width - 1 - x, src.height - 1 - y, r, g, b);
1163                }
1164            }
1165            out
1166        }
1167        Rotation::Ccw90 => {
1168            let w = src.height;
1169            let h = src.width;
1170            let mut out = Pixmap::white(w, h);
1171            // #447: 32×32 tiled transpose (see Cw90).
1172            const TILE: u32 = 32;
1173            let (sw, ow) = (src.width as usize, w as usize);
1174            let mut ty = 0;
1175            while ty < src.height {
1176                let mut tx = 0;
1177                while tx < src.width {
1178                    let y_end = (ty + TILE).min(src.height);
1179                    let x_end = (tx + TILE).min(src.width);
1180                    for y in ty..y_end {
1181                        let src_row = y as usize * sw * 4;
1182                        for x in tx..x_end {
1183                            let si = src_row + x as usize * 4;
1184                            let di = ((sw - 1 - x as usize) * ow + y as usize) * 4;
1185                            out.data[di..di + 4].copy_from_slice(&src.data[si..si + 4]);
1186                        }
1187                    }
1188                    tx += TILE;
1189                }
1190                ty += TILE;
1191            }
1192            out
1193        }
1194    }
1195}
1196
1197// ── FGbz palette parsing ──────────────────────────────────────────────────────
1198
1199// FGbz chunk parsing lives in `crate::fgbz` so this module receives already-decoded
1200// palette data and never calls `bzz_decode` itself. `parse_fgbz` returns a
1201// `BzzError`; callers below propagate it through `RenderError`'s `From<BzzError>`.
1202use crate::fgbz::{FgbzPalette, PaletteColor, parse_fgbz};
1203
1204// ── Core compositor ───────────────────────────────────────────────────────────
1205
1206/// Return the largest power-of-2 IW44 subsample factor for the given render
1207/// scale, allowing up to 1.5× upscaling in the compositor.
1208///
1209/// The compositor samples the decoded background at `pixel / subsample`, so a
1210/// decoded plane that is slightly smaller than the output is fine — the
1211/// compositor's nearest-neighbour lookup handles it naturally.  Allowing up to
1212/// 1.5× upscaling lets us pick a coarser subsample in many common cases
1213/// (e.g. 150 dpi from a 400 dpi source) and skip the high-frequency wavelet
1214/// bands, matching the partial-decode strategy used by DjVuLibre.
1215///
1216/// Examples (with 1.5× tolerance):
1217/// - scale=1.0  → 1 (full resolution)
1218/// - scale=0.5  → 2 (1.5/0.5=3.0 → 2)
1219/// - scale=0.375→ 4 (1.5/0.375=4.0 → 4)   ← was 2 before fix
1220/// - scale=0.25 → 4 (1.5/0.25=6.0 → 4)
1221/// - scale=0.1  → 8 (1.5/0.1=15 → capped at 8)
1222fn best_iw44_subsample(scale: f32) -> u32 {
1223    if scale <= 0.0 || !scale.is_finite() || scale >= 1.0 {
1224        return 1;
1225    }
1226    // Allow up to 1.5× upscaling: the compositor handles the coordinate
1227    // division, so a slightly-too-small decoded plane is fine.
1228    // Round rather than truncate: pixel-rounding of width causes decode_scale
1229    // to differ from the true scale by up to 0.5/page_width (≈0.023% for a
1230    // 2260-px page), which can push 1.5/scale just below an integer and select
1231    // a 2× coarser subsample — e.g. subsample 2 instead of 4 for colorbook.
1232    let max_sub = (1.5_f32 / scale).round() as u32;
1233    let mut s = 1u32;
1234    while s * 2 <= max_sub {
1235        s *= 2;
1236    }
1237    s.min(8)
1238}
1239
1240/// Tile edge length (pixels) used by [`render_region_tiled`]'s composited-
1241/// output cache. 256 keeps a full RGBA tile at 256 KiB — small enough that a
1242/// pan/zoom viewer's working set (a screenful of tiles) stays a few MB, large
1243/// enough that the per-tile `HashMap`/`Mutex` overhead is negligible next to
1244/// the compositor work it avoids repeating.
1245#[cfg(feature = "std")]
1246const TILE_SIZE: u32 = 256;
1247
1248/// Default per-page byte budget for the composited-tile cache — about 32
1249/// full tiles (256×256×4 B = 256 KiB each). Independent of, but counted
1250/// towards, [`PageLayers::cached_bytes`] / `DjVuDocument::enforce_cache_budget`:
1251/// a document-wide budget sweep evicts whole pages (tiles included), while
1252/// this bound keeps one page's own pan history from growing unboundedly
1253/// between sweeps. Overridable per page via
1254/// [`crate::djvu_tile::set_tile_cache_budget`] (#691 slice 2).
1255#[cfg(feature = "std")]
1256pub(crate) const TILE_CACHE_MAX_BYTES: usize = 8 * 1024 * 1024;
1257
1258/// Composited-output tile cache key: `(full_w, full_h, tile_x, tile_y, bold,
1259/// mask_aa)` — the tuple of [`RenderOptions`] fields (plus the tile's
1260/// top-left corner in full-render space) that `composite_into`'s per-pixel
1261/// output actually depends on for a [`render_region_tiled`]-eligible request
1262/// (bilinear resampling, identity rotation, strict decode). Any option that
1263/// can change a composited pixel's bytes must be part of this key.
1264#[cfg(feature = "std")]
1265type TileKey = (u32, u32, u32, u32, u8, bool);
1266
1267/// One cached composited tile: `w × h` (≤ [`TILE_SIZE`], smaller at the
1268/// page's right/bottom edge) RGBA bytes, row-major, stride `w * 4`.
1269#[cfg(feature = "std")]
1270struct TileEntry {
1271    w: u32,
1272    h: u32,
1273    data: Vec<u8>,
1274}
1275
1276/// [`PageLayers`]'s composited-tile store: the tile map, FIFO insertion order
1277/// for eviction, and the running byte total (avoids re-summing on every
1278/// insert/evict).
1279#[cfg(feature = "std")]
1280#[derive(Default)]
1281struct TileCacheState {
1282    map: std::collections::HashMap<TileKey, std::sync::Arc<TileEntry>>,
1283    order: std::collections::VecDeque<TileKey>,
1284    bytes: usize,
1285    /// Per-page budget override (#691 slice 2); `None` means
1286    /// [`TILE_CACHE_MAX_BYTES`]. Kept as an `Option` so `derive(Default)`
1287    /// stays valid and "still on the default" remains observable.
1288    budget: Option<usize>,
1289    /// Last-used tick from [`ACCESS_TICK`], stamped on every hit and insert,
1290    /// so the governor can rank the tile store against the decoded layers.
1291    tick: u64,
1292    /// Hit/miss/eviction telemetry (#576). Test-only so the release lock
1293    /// section stays exactly as cheap as before.
1294    #[cfg(test)]
1295    hits: usize,
1296    #[cfg(test)]
1297    misses: usize,
1298    #[cfg(test)]
1299    evictions: usize,
1300}
1301
1302#[cfg(feature = "std")]
1303impl TileCacheState {
1304    /// The budget this cache currently enforces (override or default).
1305    fn effective_budget(&self) -> usize {
1306        self.budget.unwrap_or(TILE_CACHE_MAX_BYTES)
1307    }
1308
1309    /// Evict oldest-first until `bytes` is back under the effective budget.
1310    fn evict_to_budget(&mut self) {
1311        while self.bytes > self.effective_budget() {
1312            match self.order.pop_front() {
1313                Some(old_key) => {
1314                    if let Some(old) = self.map.remove(&old_key) {
1315                        self.bytes = self.bytes.saturating_sub(old.data.len());
1316                        #[cfg(test)]
1317                        {
1318                            self.evictions += 1;
1319                        }
1320                    }
1321                }
1322                None => break,
1323            }
1324        }
1325    }
1326}
1327
1328/// Render-tier cache of a page's decoded layers.
1329///
1330/// These are the decoded wavelet / bitmap forms the compositor consumes —
1331/// background (`bg44`, plus the first-chunk-only `bg44_partial` used at
1332/// subsample ≥ 4), the JB2 mask (`mask`), its quarter-resolution max-pool
1333/// downsample (`mask_sub4`, a pure compositor concern), and the FG44 colour
1334/// layer (`fg44`). They live here in the render tier rather than on
1335/// [`DjVuPage`] so the page model stays close to its raw bytes and every
1336/// render concern — including compositor subsampling — concentrates in one
1337/// place.
1338///
1339/// Each layer is decoded lazily and cached, so repeated renders of the same
1340/// page (e.g. thumbnails then full resolution) reuse the expensive ZP
1341/// arithmetic decode. A `DjVuPage` holds one of these behind a `OnceLock`;
1342/// the accessors borrow the page only to decode on a cache miss, so the
1343/// returned reference is tied to the cache, not the call.
1344/// Cached decoded ANTz payload (#605): the parsed annotation record plus its
1345/// map areas, shared behind an `Arc` between the per-page cache and callers.
1346#[cfg(feature = "std")]
1347pub(crate) type SharedAnnotations = std::sync::Arc<(
1348    crate::annotation::Annotation,
1349    Vec<crate::annotation::MapArea>,
1350)>;
1351
1352/// A memoisation slot that can also be **cleared through a shared borrow**.
1353///
1354/// `std::sync::OnceLock` can be filled through `&self` but only emptied through
1355/// `&mut self`, and every render entry point holds `&DjVuPage`. So a render
1356/// could grow the page cache and nothing could shrink it: the whole
1357/// cache-budget API (`enforce_cache_budget`, `retain_render_caches`,
1358/// `evict_render_cache`) needed `&mut self`, could not run from inside a
1359/// render, and was therefore opt-in — leaving the read path unbounded by
1360/// default at ~6.1 MB per rendered page. See PERF_EXPERIMENTS.md
1361/// READ_CACHE_BOUNDED.
1362///
1363/// This slot keeps the value behind an `RwLock` and hands out `Arc` clones. An
1364/// eviction drops the cache's handle under `&self`; a render still holding a
1365/// clone keeps its copy alive until it finishes, so eviction is never visible
1366/// as a dangling or half-freed layer.
1367///
1368/// The outer `Option` is "has this been computed?", the inner one is "did the
1369/// computation produce a value?" — a decode that legitimately yields `None`
1370/// (no such chunk on this page) is memoised as a miss, exactly as the
1371/// `OnceLock<Option<T>>` it replaces did.
1372///
1373/// Each slot is one evictable layer to the process-wide governor (#813,
1374/// PERF_EXPERIMENTS.md RENDER_CACHE_LAYER_EVICT): it records its own resident
1375/// size the moment it fills and stamps itself with the global tick on every
1376/// use, so a sweep can rank layers across pages and drop the stalest one
1377/// without touching the others on the same page.
1378#[cfg(feature = "std")]
1379pub(crate) struct CacheSlot<T> {
1380    inner: std::sync::RwLock<Option<Option<std::sync::Arc<T>>>>,
1381    /// How to measure a stored value. Fixed at construction so the slot can
1382    /// record its size when it fills rather than re-measure on every sweep.
1383    size: fn(&T) -> usize,
1384    /// Resident bytes of the stored value; 0 when empty or a memoised miss.
1385    /// Kept beside the lock, not behind it, so the byte accounting and the
1386    /// governor read it without contending with a decode in progress.
1387    bytes: std::sync::atomic::AtomicUsize,
1388    /// Last-used tick from [`ACCESS_TICK`]: stamped on every hit, fill and
1389    /// store. Higher is more recent. A `peek` that finds nothing leaves it.
1390    tick: std::sync::atomic::AtomicU64,
1391}
1392
1393#[cfg(feature = "std")]
1394impl<T> CacheSlot<T> {
1395    /// An empty slot whose values are measured by `size`.
1396    pub(crate) fn new(size: fn(&T) -> usize) -> Self {
1397        Self {
1398            inner: std::sync::RwLock::new(None),
1399            size,
1400            bytes: std::sync::atomic::AtomicUsize::new(0),
1401            tick: std::sync::atomic::AtomicU64::new(0),
1402        }
1403    }
1404
1405    /// Stamp the slot as just used.
1406    fn touch(&self) {
1407        let t = ACCESS_TICK.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1408        self.tick.store(t, std::sync::atomic::Ordering::Relaxed);
1409    }
1410
1411    /// Store `value` under an already-held write lock, recording its size.
1412    fn store(&self, w: &mut Option<Option<std::sync::Arc<T>>>, value: Option<std::sync::Arc<T>>) {
1413        let bytes = value.as_deref().map_or(0, self.size);
1414        self.bytes
1415            .store(bytes, std::sync::atomic::Ordering::Relaxed);
1416        *w = Some(value);
1417        self.touch();
1418    }
1419
1420    fn read(&self) -> std::sync::RwLockReadGuard<'_, Option<Option<std::sync::Arc<T>>>> {
1421        self.inner
1422            .read()
1423            .unwrap_or_else(std::sync::PoisonError::into_inner)
1424    }
1425
1426    fn write(&self) -> std::sync::RwLockWriteGuard<'_, Option<Option<std::sync::Arc<T>>>> {
1427        self.inner
1428            .write()
1429            .unwrap_or_else(std::sync::PoisonError::into_inner)
1430    }
1431
1432    /// The cached value, without ever running the initialiser.
1433    pub(crate) fn peek(&self) -> Option<std::sync::Arc<T>> {
1434        let v = self.read().as_ref()?.clone();
1435        if v.is_some() {
1436            self.touch();
1437        }
1438        v
1439    }
1440
1441    /// Whether the slot has been computed (even to a cached `None`).
1442    pub(crate) fn is_computed(&self) -> bool {
1443        self.read().is_some()
1444    }
1445
1446    /// The cached value, computing it with `init` on the first call.
1447    ///
1448    /// `init` runs **outside** the lock, so a slow decode never blocks a
1449    /// concurrent reader and an initialiser may read other slots without
1450    /// risking a deadlock. The cost is that two threads racing on a cold slot
1451    /// can both decode; the first to store wins and both callers get that one
1452    /// value, so the result is still a single shared copy. Decoding is pure, so
1453    /// the duplicate work is wasted time, never a different answer.
1454    pub(crate) fn get_or_init(
1455        &self,
1456        init: impl FnOnce() -> Option<T>,
1457    ) -> Option<std::sync::Arc<T>> {
1458        if let Some(v) = self.read().as_ref() {
1459            self.touch();
1460            return v.clone();
1461        }
1462        let computed = init().map(std::sync::Arc::new);
1463        let mut w = self.write();
1464        if let Some(v) = w.as_ref() {
1465            self.touch();
1466            return v.clone();
1467        }
1468        self.store(&mut w, computed.clone());
1469        computed
1470    }
1471
1472    /// Store `value` if the slot is still empty; keep the existing entry
1473    /// otherwise. Mirrors `OnceLock::set` — the first writer wins.
1474    pub(crate) fn set_if_empty(&self, value: Option<T>) {
1475        let mut w = self.write();
1476        if w.is_none() {
1477            self.store(&mut w, value.map(std::sync::Arc::new));
1478        }
1479    }
1480
1481    /// Like [`set_if_empty`](Self::set_if_empty) for a value the caller already
1482    /// holds behind an `Arc` (the text and annotation trees are shared with
1483    /// their parsers).
1484    pub(crate) fn set_if_empty_arc(&self, value: Option<std::sync::Arc<T>>) {
1485        let mut w = self.write();
1486        if w.is_none() {
1487            self.store(&mut w, value);
1488        }
1489    }
1490
1491    /// Drop the cached value, reclaiming its memory. The slot goes back to
1492    /// "not computed", so the next access decodes again.
1493    pub(crate) fn clear(&self) {
1494        let mut w = self.write();
1495        *w = None;
1496        self.bytes.store(0, std::sync::atomic::Ordering::Relaxed);
1497    }
1498
1499    /// Resident bytes held by the cached value, as recorded when it was
1500    /// stored. Never computes and never locks.
1501    pub(crate) fn bytes(&self) -> usize {
1502        self.bytes.load(std::sync::atomic::Ordering::Relaxed)
1503    }
1504}
1505
1506/// One evictable unit of a page cache, as the process-wide governor sees it
1507/// (#813). Every [`CacheSlot`] is one, and so is the composited-tile store,
1508/// which the governor treats as a single layer with the tick of its last hit.
1509#[cfg(feature = "std")]
1510pub(crate) trait CacheLayer {
1511    /// Last-used tick from [`ACCESS_TICK`]; higher is more recent.
1512    fn last_used(&self) -> u64;
1513    /// Resident bytes, 0 when empty.
1514    fn resident_bytes(&self) -> usize;
1515    /// Drop the cached data through a shared borrow. The next access rebuilds
1516    /// it; a reader that already holds a handle is unaffected.
1517    fn drop_cached(&self);
1518}
1519
1520#[cfg(feature = "std")]
1521impl<T> CacheLayer for CacheSlot<T> {
1522    fn last_used(&self) -> u64 {
1523        self.tick.load(std::sync::atomic::Ordering::Relaxed)
1524    }
1525    fn resident_bytes(&self) -> usize {
1526        self.bytes()
1527    }
1528    fn drop_cached(&self) {
1529        self.clear();
1530    }
1531}
1532
1533#[cfg(feature = "std")]
1534impl CacheLayer for std::sync::Mutex<TileCacheState> {
1535    fn last_used(&self) -> u64 {
1536        self.lock()
1537            .unwrap_or_else(std::sync::PoisonError::into_inner)
1538            .tick
1539    }
1540    fn resident_bytes(&self) -> usize {
1541        self.lock()
1542            .unwrap_or_else(std::sync::PoisonError::into_inner)
1543            .bytes
1544    }
1545    /// Tiles go, but a per-page budget override survives — it is
1546    /// configuration, not cached data (same rule as `downgrade`).
1547    fn drop_cached(&self) {
1548        let mut tiles = self
1549            .lock()
1550            .unwrap_or_else(std::sync::PoisonError::into_inner);
1551        *tiles = TileCacheState {
1552            budget: tiles.budget,
1553            ..TileCacheState::default()
1554        };
1555    }
1556}
1557
1558#[cfg(feature = "std")]
1559pub(crate) struct PageLayers {
1560    bg44: CacheSlot<Iw44Image>,
1561    bg44_partial: CacheSlot<Iw44Image>,
1562    mask: CacheSlot<crate::bitmap::Bitmap>,
1563    mask_sub4: CacheSlot<crate::bitmap::Bitmap>,
1564    fg44: CacheSlot<Pixmap>,
1565    // Full-resolution (subsample=1) RGB Pixmap derived from bg44. Cached so
1566    // repeated renders of the same page skip the 2–3 ms IW44 IDWT + YCbCr→RGB
1567    // conversion. Populated on first full-resolution render; left empty on
1568    // pages that are never rendered at sub=1 (e.g. thumbnails only).
1569    bg_rgb_s1: CacheSlot<Pixmap>,
1570    // Half-resolution (subsample=2) RGB Pixmap derived from bg44, for the common
1571    // 150-from-300-DPI render. Same memoization as `bg_rgb_s1` but ~4× smaller;
1572    // left empty on pages never rendered at sub=2.
1573    bg_rgb_s2: CacheSlot<Pixmap>,
1574    // Quarter-resolution (subsample=4) RGB Pixmap derived from the *partial*
1575    // bg44 (first chunk only, matching the sub>=4 decode path). Caches the
1576    // IDWT + YCbCr->RGB conversion for the common thumbnail / heavy-downscale
1577    // render (e.g. 150-from-400-DPI, contact-sheet zoom); ~16x smaller than the
1578    // sub=1 cache. Left empty on pages never rendered at sub=4.
1579    bg_rgb_s4: CacheSlot<Pixmap>,
1580    // Decoded JB2 mask + per-pixel blit-index map for FGbz-palette pages. The
1581    // plain `mask` cache does not cover the indexed variant, so without this
1582    // every warm render of a palette page re-runs the full JB2 ZP decode and
1583    // re-allocates the page-sized blit map. Only populated for palette pages.
1584    // THUMB_PARTIAL_MEMO: the converted RGB for the first subsample > 4 this
1585    // page was rendered at, stored as `(subsample, pixmap)`. Thumbnail grids
1586    // and zoomed-out pans land here, and they land on the *same* subsample for
1587    // a given page, so one slot serves them. Tiny — ~90 KB for a 128 px
1588    // thumbnail of a colorbook.djvu page — and it lets the sub > 4 path stop
1589    // memoising the full-size `bg44_partial` coefficient image (5.75 MB) it
1590    // derives from. A render at a different sub > 4 misses and reconverts.
1591    bg_rgb_subhi: CacheSlot<(u32, Arc<Pixmap>)>,
1592    mask_indexed: CacheSlot<IndexedMask>,
1593    // Decoded page metadata (#605): the TXTz/ANTz payloads are BZZ-compressed
1594    // and rebuilt into full zone/annotation trees on every access, yet viewers
1595    // ask for the same metadata repeatedly (search, selection, link overlays).
1596    // Cached behind `Arc` so warm accesses share one decode. Only populated
1597    // for pages whose metadata is actually touched; parse *errors* are not
1598    // cached (malformed chunks keep erroring per call, unchanged behaviour).
1599    text_layer: CacheSlot<crate::text::TextLayer>,
1600    annotations: CacheSlot<(
1601        crate::annotation::Annotation,
1602        Vec<crate::annotation::MapArea>,
1603    )>,
1604    /// Resident bytes this cache last reported to the process-wide total
1605    /// (see [`crate::render_cache`]). Re-measuring every registered page on
1606    /// every cache fill would be O(pages); instead each fill re-measures only
1607    /// its own page and folds the change into one global counter, so the
1608    /// common path stays O(1) and the governor sweeps only when the total is
1609    /// actually over budget.
1610    reported: std::sync::atomic::AtomicUsize,
1611    /// Monotonic last-access tick for LRU cache-budget eviction. Bumped from a
1612    /// process-global counter every time this page's layers are touched; read
1613    /// (without touching) by `DjVuDocument::enforce_cache_budget` to evict the
1614    /// least-recently-used pages first. Not part of the decoded data.
1615    access: std::sync::atomic::AtomicU64,
1616    /// C4_TILE_CACHE: composited-output tiles for [`render_region_tiled`],
1617    /// keyed by `(full_w, full_h, tile_x, tile_y, bold, mask_aa)`. Unlike the
1618    /// layers above (which cache *decoded* data), these cache the *compositor's
1619    /// output* — the per-pixel work `composite_into` repeats on every call is
1620    /// not memoized anywhere else. Bounded to `TILE_CACHE_MAX_BYTES` per page
1621    /// with FIFO eviction; counted in [`cached_bytes`](Self::cached_bytes) so
1622    /// it shares the page's C5 byte-budget accounting, and dropped whenever
1623    /// this whole `PageLayers` is (`evict_render_cache`).
1624    tile_cache: std::sync::Mutex<TileCacheState>,
1625}
1626
1627#[cfg(feature = "std")]
1628impl Drop for PageLayers {
1629    /// Take this cache's bytes out of the process-wide total (see
1630    /// [`crate::render_cache`]). Dropping the page is the one path that frees
1631    /// cached layers without going through `evict_shared`.
1632    fn drop(&mut self) {
1633        let reported = *self.reported.get_mut();
1634        if reported > 0 {
1635            crate::render_cache::adjust_resident(reported, 0);
1636        }
1637    }
1638}
1639
1640/// Process-global monotonic source for the LRU access ticks: one per page
1641/// (`PageLayers::access`, read by the per-document sweep) and one per layer
1642/// (`CacheSlot::tick`, read by the process-wide governor, #813).
1643#[cfg(feature = "std")]
1644static ACCESS_TICK: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1645
1646/// Decode the first BG44 chunk of `page` into a fresh `Iw44Image`.
1647///
1648/// The body of [`PageLayers::bg44_partial`]'s initialiser, factored out so the
1649/// subsample > 4 path can produce the same image without memoising it (see
1650/// [`PageLayers::bg44_partial_cached`]).
1651#[cfg(feature = "std")]
1652fn decode_bg44_partial(page: &DjVuPage) -> Option<Iw44Image> {
1653    let chunks = page.bg44_chunks();
1654    if chunks.is_empty() {
1655        return None;
1656    }
1657    let mut img = Iw44Image::new();
1658    if img.decode_chunk(chunks[0]).is_err() {
1659        return None;
1660    }
1661    if img.width == 0 {
1662        return None;
1663    }
1664    // Same dimension cross-check as `PageLayers::bg44`.
1665    if !iw44_reduction_is_legal(
1666        page.width() as u32,
1667        page.height() as u32,
1668        img.width,
1669        img.height,
1670    ) {
1671        return None;
1672    }
1673    Some(img)
1674}
1675
1676#[cfg(feature = "std")]
1677impl PageLayers {
1678    /// An empty cache. Layers are decoded on first access.
1679    ///
1680    /// Every pixel layer is measured from the `Vec` it owns, not estimated:
1681    /// [`DjVuDocument::enforce_cache_budget`](crate::djvu_document::DjVuDocument::enforce_cache_budget)
1682    /// and the process-wide governor turn these numbers into a memory ceiling,
1683    /// so an estimate here becomes a wrong ceiling for the caller. The BG44
1684    /// coefficient images used to be sized as `w·h·2`, which counts the luma
1685    /// plane and drops the two chroma planes a colour page also keeps — the
1686    /// whole cache reported at ~38 % of the truth, and a 16 MiB budget held
1687    /// ~52 MB (PERF_EXPERIMENTS.md DECODE_CACHE_ACCOUNTING; guarded by
1688    /// `tests/decode_cache_accounting.rs`). The text/annotation trees stay
1689    /// approximate — they are node counts, not buffers, and are small next to
1690    /// the pixel caches.
1691    pub(crate) fn new() -> Self {
1692        Self {
1693            bg44: CacheSlot::new(Iw44Image::heap_bytes),
1694            bg44_partial: CacheSlot::new(Iw44Image::heap_bytes),
1695            mask: CacheSlot::new(|b| b.data.len()),
1696            mask_sub4: CacheSlot::new(|b| b.data.len()),
1697            fg44: CacheSlot::new(|p| p.data.len()),
1698            bg_rgb_s1: CacheSlot::new(|p| p.data.len()),
1699            bg_rgb_s2: CacheSlot::new(|p| p.data.len()),
1700            bg_rgb_s4: CacheSlot::new(|p| p.data.len()),
1701            bg_rgb_subhi: CacheSlot::new(|(_, p)| p.data.len()),
1702            mask_indexed: CacheSlot::new(|(b, v)| b.data.len() + v.len() * 4),
1703            // Metadata caches (#605): approximate — text bytes + a fixed cost
1704            // per zone/map-area node.
1705            text_layer: CacheSlot::new(|t| t.text.len() + count_zones(&t.zones) * 64),
1706            annotations: CacheSlot::new(|a| a.1.len() * 96 + 64),
1707            reported: std::sync::atomic::AtomicUsize::new(0),
1708            access: std::sync::atomic::AtomicU64::new(0),
1709            tile_cache: std::sync::Mutex::new(TileCacheState::default()),
1710        }
1711    }
1712
1713    /// The number of layers [`layers`](Self::layers) returns.
1714    pub(crate) const LAYER_COUNT: usize = 13;
1715
1716    /// Every layer of this cache as the governor sees it (#813): the twelve
1717    /// decoded/derived slots and the composited-tile store as the thirteenth.
1718    /// Order is fixed but carries no meaning; the sweep ranks by tick.
1719    pub(crate) fn layers(&self) -> [&dyn CacheLayer; Self::LAYER_COUNT] {
1720        [
1721            &self.bg44,
1722            &self.bg44_partial,
1723            &self.mask,
1724            &self.mask_sub4,
1725            &self.fg44,
1726            &self.bg_rgb_s1,
1727            &self.bg_rgb_s2,
1728            &self.bg_rgb_s4,
1729            &self.bg_rgb_subhi,
1730            &self.mask_indexed,
1731            &self.text_layer,
1732            &self.annotations,
1733            &self.tile_cache,
1734        ]
1735    }
1736
1737    /// Record an access, stamping this cache with the next global tick (LRU).
1738    pub(crate) fn bump_access(&self) {
1739        let t = ACCESS_TICK.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1740        self.access.store(t, std::sync::atomic::Ordering::Relaxed);
1741    }
1742
1743    /// The last-access tick (higher = more recently used).
1744    pub(crate) fn access_tick(&self) -> u64 {
1745        self.access.load(std::sync::atomic::Ordering::Relaxed)
1746    }
1747
1748    /// Resident bytes held by this page's decoded caches.
1749    ///
1750    /// The sum of what every layer recorded when it filled (see
1751    /// [`new`](Self::new) for how each is measured). Reads no lock but the
1752    /// tile store's, and never initialises anything.
1753    pub(crate) fn cached_bytes(&self) -> usize {
1754        self.layers().iter().map(|l| l.resident_bytes()).sum()
1755    }
1756
1757    /// C5_COMPRESS: drop the expensive full-resolution derivations —
1758    /// `bg44`/`bg44_partial` coefficient images, `bg_rgb_s1`, mask +
1759    /// `mask_sub4`, `fg44`, `mask_indexed`, and the composited-tile cache —
1760    /// while **preserving** any already-cached `bg_rgb_s2` / `bg_rgb_s4`
1761    /// downscaled RGB pixmap.
1762    ///
1763    /// This is the cheaper middle tier between "keep everything" and
1764    /// [`evict_render_cache`](DjVuPage::evict_render_cache)'s full drop: a
1765    /// later downscaled render (subsample ≥ 2 — e.g. a thumbnail, contact
1766    /// sheet, or zoomed-out pan) stays warm, while a full-resolution render
1767    /// still pays a cold decode. Measured (see PERF_EXPERIMENTS.md
1768    /// C5_COMPRESS): the coefficient `Iw44Image` retained by `bg44` is *not*
1769    /// cheaper to keep than the derived RGB pixmap (same size class once
1770    /// colour planes are counted), so there is no cheap "upgrade sub=2→sub=1"
1771    /// path — only the already-decoded downscaled pixmaps are worth keeping.
1772    /// No-op on fields that were never populated.
1773    pub(crate) fn downgrade(&self) {
1774        self.bg44.clear();
1775        self.bg44_partial.clear();
1776        self.mask.clear();
1777        // mask_sub4 intentionally preserved (#607): ~1/16 of the packed mask
1778        // bytes keeps sub>=4 re-renders (thumbnails, zoomed-out pans) warm
1779        // without re-running the JB2 arithmetic decode. `decode_layers`
1780        // consults it before forcing a full mask decode.
1781        self.fg44.clear();
1782        self.mask_indexed.clear();
1783        self.bg_rgb_s1.clear();
1784        // Tiles are dropped, but a per-page budget override (#691 slice 2)
1785        // survives the downgrade — it is configuration, not cached data.
1786        self.tile_cache.drop_cached();
1787        // bg_rgb_s2 / bg_rgb_s4 / bg_rgb_subhi / access tick intentionally
1788        // preserved — all three are the cheap downscaled tiers a later
1789        // zoomed-out render reuses.
1790        self.report_bytes();
1791    }
1792
1793    /// Drop every cached layer through a shared borrow.
1794    ///
1795    /// This is [`DjVuPage::evict_render_cache`]'s whole body. Dropping the
1796    /// `PageLayers` itself needs `&mut DjVuPage`, which no render path has;
1797    /// emptying each slot needs only `&self` (see [`CacheSlot`]) and reclaims
1798    /// the same memory — the struct that stays behind is a few hundred bytes
1799    /// of empty locks.
1800    ///
1801    /// A render that already holds a layer keeps it until it finishes; the
1802    /// next access decodes again.
1803    pub(crate) fn evict_shared(&self) {
1804        for layer in self.layers() {
1805            layer.drop_cached();
1806        }
1807        self.report_bytes();
1808    }
1809
1810    /// Re-measure this cache and fold the change into the process-wide total.
1811    ///
1812    /// Call it after anything that grows or shrinks the cache. Returns the new
1813    /// process-wide total.
1814    pub(crate) fn report_bytes(&self) -> usize {
1815        use std::sync::atomic::Ordering;
1816        let now = self.cached_bytes();
1817        let prev = self.reported.swap(now, Ordering::AcqRel);
1818        crate::render_cache::adjust_resident(prev, now)
1819    }
1820
1821    /// Fill `slot` through `init`, then keep the byte accounting current.
1822    ///
1823    /// Every layer accessor goes through here, so one place both memoises the
1824    /// decode and tells the governor the cache grew (READ_CACHE_BOUNDED). The
1825    /// governor may then drop any layer but the one just filled — including
1826    /// another layer of this page, if it is the stalest in the process (#813).
1827    fn fill<T>(&self, slot: &CacheSlot<T>, init: impl FnOnce() -> Option<T>) -> Option<Arc<T>> {
1828        let was_computed = slot.is_computed();
1829        let value = slot.get_or_init(init);
1830        if !was_computed {
1831            let total = self.report_bytes();
1832            crate::render_cache::sweep_if_over(total, Self::layer_id(slot));
1833        }
1834        value
1835    }
1836
1837    /// The address the governor uses to recognise the layer being filled.
1838    fn layer_id<L: CacheLayer>(layer: &L) -> *const () {
1839        (layer as *const L).cast()
1840    }
1841
1842    /// Bytes currently held by the composited-tile cache (see `tile_cache`).
1843    pub(crate) fn tile_cache_bytes(&self) -> usize {
1844        self.tile_cache
1845            .lock()
1846            .unwrap_or_else(std::sync::PoisonError::into_inner)
1847            .bytes
1848    }
1849
1850    /// Look up a cached composited tile, cloning the `Arc` handle on a hit.
1851    ///
1852    /// LRU (#576): a hit moves the key to the back of the eviction order.
1853    /// Under a back-and-forth pan — the classic reading pattern — FIFO evicts
1854    /// exactly the tiles about to be reused; move-to-back keeps them. The
1855    /// order deque holds ≤ `TILE_CACHE_MAX_BYTES / tile_bytes` ≈ 32 keys, so
1856    /// the linear reposition is a few dozen comparisons per hit.
1857    fn get_tile(&self, key: TileKey) -> Option<std::sync::Arc<TileEntry>> {
1858        let mut state = self
1859            .tile_cache
1860            .lock()
1861            .unwrap_or_else(std::sync::PoisonError::into_inner);
1862        let hit = state.map.get(&key).cloned();
1863        #[cfg(test)]
1864        {
1865            if hit.is_some() {
1866                state.hits += 1;
1867            } else {
1868                state.misses += 1;
1869            }
1870        }
1871        if hit.is_some()
1872            && let Some(pos) = state.order.iter().position(|k| *k == key)
1873        {
1874            state.order.remove(pos);
1875            state.order.push_back(key);
1876            state.tick = ACCESS_TICK.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1877        }
1878        hit
1879    }
1880
1881    /// Insert a freshly composited tile, evicting the oldest tiles (FIFO)
1882    /// until back under the page's effective tile-cache budget.
1883    fn insert_tile(&self, key: TileKey, entry: std::sync::Arc<TileEntry>) {
1884        let mut state = self
1885            .tile_cache
1886            .lock()
1887            .unwrap_or_else(std::sync::PoisonError::into_inner);
1888        if state.map.contains_key(&key) {
1889            return;
1890        }
1891        state.bytes += entry.data.len();
1892        state.map.insert(key, entry);
1893        state.order.push_back(key);
1894        state.tick = ACCESS_TICK.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1895        state.evict_to_budget();
1896        drop(state);
1897        // Tiles are bounded per page already, but they still count against the
1898        // process-wide ceiling (READ_CACHE_BOUNDED).
1899        let total = self.report_bytes();
1900        crate::render_cache::sweep_if_over(total, Self::layer_id(&self.tile_cache));
1901    }
1902
1903    /// The tile-cache budget this page currently enforces (#691 slice 2).
1904    pub(crate) fn tile_cache_budget(&self) -> usize {
1905        self.tile_cache
1906            .lock()
1907            .unwrap_or_else(std::sync::PoisonError::into_inner)
1908            .effective_budget()
1909    }
1910
1911    /// Number of composited tiles currently cached (#691 slice 2).
1912    pub(crate) fn tile_cache_len(&self) -> usize {
1913        self.tile_cache
1914            .lock()
1915            .unwrap_or_else(std::sync::PoisonError::into_inner)
1916            .map
1917            .len()
1918    }
1919
1920    /// Override this page's tile-cache byte budget, evicting oldest-first
1921    /// down to the new bound immediately (#691 slice 2). A budget of `0`
1922    /// effectively disables composited-tile caching for the page.
1923    pub(crate) fn set_tile_cache_budget(&self, max_bytes: usize) {
1924        let mut state = self
1925            .tile_cache
1926            .lock()
1927            .unwrap_or_else(std::sync::PoisonError::into_inner);
1928        state.budget = Some(max_bytes);
1929        state.evict_to_budget();
1930    }
1931
1932    /// Drop every cached composited tile, returning the bytes freed
1933    /// (#691 slice 2). The budget override, if any, is kept.
1934    pub(crate) fn clear_tile_cache(&self) -> usize {
1935        let mut state = self
1936            .tile_cache
1937            .lock()
1938            .unwrap_or_else(std::sync::PoisonError::into_inner);
1939        let freed = state.bytes;
1940        state.map.clear();
1941        state.order.clear();
1942        state.bytes = 0;
1943        freed
1944    }
1945
1946    /// Drop every cached composited tile that intersects `rect`, where `rect`
1947    /// is given in the pre-rotation pixel space of a `canvas_w × canvas_h`
1948    /// full render (#691 slice 2). Cached tiles belonging to *other* render
1949    /// sizes are matched by scaling the rect proportionally (outward, so a
1950    /// boundary-straddling tile is always dropped rather than kept). Returns
1951    /// the bytes freed.
1952    pub(crate) fn remove_tiles_intersecting(
1953        &self,
1954        rect: RenderRect,
1955        canvas_w: u32,
1956        canvas_h: u32,
1957    ) -> usize {
1958        if canvas_w == 0 || canvas_h == 0 || rect.width == 0 || rect.height == 0 {
1959            return 0;
1960        }
1961        let mut state = self
1962            .tile_cache
1963            .lock()
1964            .unwrap_or_else(std::sync::PoisonError::into_inner);
1965        let mut freed = 0usize;
1966        let doomed: Vec<TileKey> = state
1967            .map
1968            .iter()
1969            .filter(|((fw, fh, tx, ty, _, _), entry)| {
1970                // Scale the rect into this entry's (fw × fh) render space,
1971                // rounding outward (floor start, ceil end).
1972                let x0 = u64::from(rect.x) * u64::from(*fw) / u64::from(canvas_w);
1973                let x1 = ((u64::from(rect.x) + u64::from(rect.width)) * u64::from(*fw))
1974                    .div_ceil(u64::from(canvas_w));
1975                let y0 = u64::from(rect.y) * u64::from(*fh) / u64::from(canvas_h);
1976                let y1 = ((u64::from(rect.y) + u64::from(rect.height)) * u64::from(*fh))
1977                    .div_ceil(u64::from(canvas_h));
1978                let (tx0, ty0) = (u64::from(*tx), u64::from(*ty));
1979                let (tx1, ty1) = (tx0 + u64::from(entry.w), ty0 + u64::from(entry.h));
1980                tx0 < x1 && tx1 > x0 && ty0 < y1 && ty1 > y0
1981            })
1982            .map(|(k, _)| *k)
1983            .collect();
1984        for key in doomed {
1985            if let Some(old) = state.map.remove(&key) {
1986                freed += old.data.len();
1987            }
1988            if let Some(pos) = state.order.iter().position(|k| *k == key) {
1989                state.order.remove(pos);
1990            }
1991        }
1992        state.bytes = state.bytes.saturating_sub(freed);
1993        freed
1994    }
1995
1996    /// Tile-cache telemetry snapshot `(hits, misses, evictions)` (#576).
1997    #[cfg(test)]
1998    fn tile_cache_stats(&self) -> (usize, usize, usize) {
1999        let s = self
2000            .tile_cache
2001            .lock()
2002            .unwrap_or_else(std::sync::PoisonError::into_inner);
2003        (s.hits, s.misses, s.evictions)
2004    }
2005
2006    /// The fully decoded BG44 wavelet image (all chunks), decoding on first
2007    /// call. `None` when the page has no BG44 chunks or when any chunk fails
2008    /// strict decoding. The wavelet inverse-transform / YCbCr→RGB conversion is
2009    /// *not* cached here — it runs per render at the requested subsample.
2010    pub(crate) fn bg44(&self, page: &DjVuPage) -> Option<std::sync::Arc<Iw44Image>> {
2011        self.fill(&self.bg44, || {
2012            let chunks = page.bg44_chunks();
2013            if chunks.is_empty() {
2014                return None;
2015            }
2016            // IW44_CHECKPOINT (#608): resume from the cached first-chunk
2017            // decode when a sub>=4 render already paid for it (the common
2018            // thumbnail -> full-view flow). Chunk 0 is the most expensive
2019            // chunk (~16-19% of a 4-chunk full decode on the corpus), the
2020            // clone is ~0.04-0.5 ms, and progressive decode is defined to
2021            // produce byte-identical output to a fresh 0..n decode. Peek
2022            // only (`get`) -- a cold full decode must not populate the
2023            // partial tier as a side effect.
2024            let mut img;
2025            let mut start = 0;
2026            if let Some(partial) = self.bg44_partial.peek() {
2027                img = (*partial).clone();
2028                start = 1;
2029            } else {
2030                img = Iw44Image::new();
2031            }
2032            for chunk_data in &chunks[start.min(chunks.len())..] {
2033                #[cfg(test)]
2034                count_bg44_chunk_decode();
2035                if img.decode_chunk(chunk_data).is_err() {
2036                    return None;
2037                }
2038            }
2039            if img.width == 0 {
2040                return None;
2041            }
2042            // Dimension cross-check (see `iw44_reduction_is_legal`): a
2043            // BG44 plane whose header declares a size that isn't a legal
2044            // 1:1..1:12 reduction of the page's INFO dimensions is
2045            // corrupted/desynced — DjVuLibre rejects the whole page for
2046            // this, so treat it the same as any other BG44 decode
2047            // failure rather than stretching it onto the page.
2048            if !iw44_reduction_is_legal(
2049                page.width() as u32,
2050                page.height() as u32,
2051                img.width,
2052                img.height,
2053            ) {
2054                return None;
2055            }
2056            Some(img)
2057        })
2058    }
2059
2060    /// A partially-decoded BG44 image — first chunk only — decoding on first
2061    /// call. Roughly 4× cheaper to decode; used at subsample ≥ 4 where the
2062    /// high-frequency refinement chunks are imperceptible.
2063    pub(crate) fn bg44_partial(&self, page: &DjVuPage) -> Option<std::sync::Arc<Iw44Image>> {
2064        self.fill(&self.bg44_partial, || decode_bg44_partial(page))
2065    }
2066
2067    /// The first-chunk BG44 image **only if it is already cached** — never
2068    /// decodes, never populates the slot.
2069    ///
2070    /// THUMB_PARTIAL_MEMO: the subsample > 4 render path uses this instead of
2071    /// [`bg44_partial`](Self::bg44_partial). A "partial" `Iw44Image` decodes ~4x
2072    /// faster than a full one but is exactly as large: `PlaneDecoder` allocates
2073    /// every 32x32 coefficient block up front, whichever chunks are decoded
2074    /// into them. Memoising it costs a full-size image per page (5.75 MB on
2075    /// `colorbook.djvu`) and buys nothing at sub > 4, where the derived RGB is
2076    /// not cached either — so a thumbnail sweep retained the whole book's
2077    /// backgrounds and re-ran the conversion anyway. See PERF_EXPERIMENTS.md
2078    /// THUMB_PARTIAL_MEMO.
2079    pub(crate) fn bg44_partial_cached(&self) -> Option<std::sync::Arc<Iw44Image>> {
2080        self.bg44_partial.peek()
2081    }
2082
2083    /// The cached RGB conversion for `subsample`, when this page's single
2084    /// `sub > 4` slot holds exactly that subsample. Never decodes.
2085    pub(crate) fn bg_rgb_subhi(&self, subsample: u32) -> Option<Arc<Pixmap>> {
2086        let slot = self.bg_rgb_subhi.peek()?;
2087        (slot.0 == subsample).then(|| slot.1.clone())
2088    }
2089
2090    /// Fill the `sub > 4` slot if it is still empty. No-op once set, so the
2091    /// first subsample a page is rendered at wins. Takes a shared handle, so
2092    /// storing the conversion costs a refcount bump rather than a pixmap copy.
2093    pub(crate) fn store_bg_rgb_subhi(&self, subsample: u32, px: Arc<Pixmap>) {
2094        self.bg_rgb_subhi.set_if_empty(Some((subsample, px)));
2095        let total = self.report_bytes();
2096        crate::render_cache::sweep_if_over(total, Self::layer_id(&self.bg_rgb_subhi));
2097    }
2098
2099    /// The decoded JB2 / G4 foreground mask, decoding on first call. `None`
2100    /// when the page has no mask chunk or decoding fails.
2101    pub(crate) fn mask(&self, page: &DjVuPage) -> Option<std::sync::Arc<crate::bitmap::Bitmap>> {
2102        self.fill(&self.mask, || {
2103            #[cfg(test)]
2104            count_jb2_mask_decode();
2105            page.extract_mask().ok().flatten()
2106        })
2107    }
2108
2109    /// A 1/4-resolution max-pool downsample of the mask. Each bit is 1 if any
2110    /// bit in the corresponding 4×4 block is set, letting the compositor do
2111    /// one lookup per output pixel at subsample ≥ 4 instead of 4–9. Purely a
2112    /// compositor optimisation, which is why it lives in the render tier
2113    /// rather than on the page.
2114    ///
2115    /// If the full-resolution mask is already cached (e.g. a prior
2116    /// native-resolution render of this page), downsamples that instead of
2117    /// decoding again. Otherwise decodes straight to 1/4 resolution via
2118    /// [`DjVuPage::extract_mask_sub4`] — the thumbnail / heavy-downscale
2119    /// path's common case — skipping the full-resolution JB2 canvas
2120    /// allocation and the full-canvas downsample scan (round 89 follow-up:
2121    /// `extract_mask` was 12.6 MB of the 47 MB thumbnail-sweep allocation
2122    /// total, decoded only to be immediately downsampled and discarded).
2123    pub(crate) fn mask_sub4(
2124        &self,
2125        page: &DjVuPage,
2126    ) -> Option<std::sync::Arc<crate::bitmap::Bitmap>> {
2127        self.fill(&self.mask_sub4, || {
2128            if let Some(full) = self.mask.peek() {
2129                return Some(downsample_mask_4x(&full));
2130            }
2131            page.extract_mask_sub4().ok().flatten()
2132        })
2133    }
2134
2135    /// Peek at an already-built 1/4-resolution mask without triggering any
2136    /// decode. `Some` only when a previous sub>=4 render populated the slot
2137    /// (possibly retained across [`downgrade`](Self::downgrade), #607).
2138    ///
2139    /// Test-only: `decode_layers` used to gate its #607 fast path on this
2140    /// (only firing when already warm); it now calls `mask_sub4` directly so
2141    /// a *cold* sub>=4 render benefits too (round 89 follow-up). Kept as a
2142    /// non-triggering cache-warmth probe for the structural regression tests.
2143    #[cfg(test)]
2144    pub(crate) fn mask_sub4_cached(&self) -> Option<std::sync::Arc<crate::bitmap::Bitmap>> {
2145        self.mask_sub4.peek()
2146    }
2147
2148    /// The decoded FG44 foreground colour layer, decoding on first call.
2149    /// `None` when the page has no FG44 chunks or decoding fails.
2150    pub(crate) fn fg44(&self, page: &DjVuPage) -> Option<std::sync::Arc<Pixmap>> {
2151        self.fill(&self.fg44, || page.extract_foreground().ok().flatten())
2152    }
2153
2154    /// Full-resolution (sub=1) RGB Pixmap from BG44, cached after first call.
2155    ///
2156    /// Builds on the already-cached [`bg44`](Self::bg44) wavelet image so the
2157    /// ZP arithmetic decode is paid at most once per page. The IDWT + YCbCr→RGB
2158    /// conversion (≈2–3 ms for a typical A4 scan) is cached here so that
2159    /// repeated renders at native resolution skip it entirely.
2160    ///
2161    /// `None` when the page has no BG44 layer or the conversion fails — and
2162    /// for a page so large that the renderer composites it from bands of the
2163    /// wavelet image instead (#811, [`Iw44Image::rgb_band_rows`]): such a
2164    /// pixmap would cost hundreds of megabytes and no render would read it.
2165    pub(crate) fn bg_rgb_s1(&self, page: &DjVuPage) -> Option<std::sync::Arc<Pixmap>> {
2166        self.fill(&self.bg_rgb_s1, || {
2167            let img = self.bg44(page)?;
2168            if img.rgb_band_rows().is_some() {
2169                return None;
2170            }
2171            img.to_rgb_subsample(1).ok()
2172        })
2173    }
2174
2175    /// Half-resolution (sub=2) RGB Pixmap from BG44, cached after first call.
2176    ///
2177    /// Mirrors [`bg_rgb_s1`](Self::bg_rgb_s1) for the common 150-from-300-DPI
2178    /// render: builds on the already-cached [`bg44`](Self::bg44) wavelet image so
2179    /// the ZP decode is paid once, then caches the IDWT + YCbCr→RGB conversion at
2180    /// subsample 2 (a ~8 MB Pixmap, 4× smaller than the sub=1 cache).
2181    ///
2182    /// `None` when the page has no BG44 layer or the conversion fails.
2183    pub(crate) fn bg_rgb_s2(&self, page: &DjVuPage) -> Option<std::sync::Arc<Pixmap>> {
2184        self.fill(&self.bg_rgb_s2, || {
2185            let img = self.bg44(page)?;
2186            img.to_rgb_subsample(2).ok()
2187        })
2188    }
2189
2190    /// Quarter-resolution (sub=4) RGB Pixmap from the partial BG44, cached after
2191    /// first call.
2192    ///
2193    /// Mirrors [`bg_rgb_s2`](Self::bg_rgb_s2) for the common heavy-downscale /
2194    /// thumbnail render (e.g. 150-from-400-DPI). Builds on the already-cached
2195    /// [`bg44_partial`](Self::bg44_partial) wavelet image — matching the sub>=4
2196    /// decode path, which uses the first chunk only — so the ZP decode is paid
2197    /// once, then caches the IDWT + YCbCr->RGB conversion at subsample 4.
2198    ///
2199    /// `None` when the page has no BG44 layer or the conversion fails.
2200    pub(crate) fn bg_rgb_s4(&self, page: &DjVuPage) -> Option<std::sync::Arc<Pixmap>> {
2201        self.fill(&self.bg_rgb_s4, || {
2202            let img = self.bg44_partial(page)?;
2203            img.to_rgb_subsample(4).ok()
2204        })
2205    }
2206
2207    /// The decoded JB2 mask + per-pixel blit-index map, decoding on first call.
2208    ///
2209    /// Used for FGbz-palette pages, where the compositor needs the blit index of
2210    /// each foreground pixel to look up its palette colour. Caches the full JB2
2211    /// ZP decode and the page-sized `Vec<i32>` blit map so repeated renders of the
2212    /// same page skip both. `None` when the page has no Sjbz/Smmr chunk or decode
2213    /// fails. The blit map is ~`width*height*4` bytes — only pages actually
2214    /// rendered with a palette ever populate this slot.
2215    /// Cached decoded text layer (#605). `try_init` runs at most once
2216    /// successfully; a parse error is returned without caching.
2217    pub(crate) fn text_layer_cached(
2218        &self,
2219        parse: impl FnOnce() -> Result<
2220            Option<std::sync::Arc<crate::text::TextLayer>>,
2221            crate::djvu_document::DocError,
2222        >,
2223    ) -> Result<Option<std::sync::Arc<crate::text::TextLayer>>, crate::djvu_document::DocError>
2224    {
2225        if self.text_layer.is_computed() {
2226            return Ok(self.text_layer.peek());
2227        }
2228        let v = parse()?;
2229        self.text_layer.set_if_empty_arc(v.clone());
2230        self.report_bytes();
2231        Ok(v)
2232    }
2233
2234    /// Cached decoded annotations (#605); same error semantics as
2235    /// [`text_layer_cached`](Self::text_layer_cached).
2236    pub(crate) fn annotations_cached(
2237        &self,
2238        parse: impl FnOnce() -> Result<Option<SharedAnnotations>, crate::djvu_document::DocError>,
2239    ) -> Result<Option<SharedAnnotations>, crate::djvu_document::DocError> {
2240        if self.annotations.is_computed() {
2241            return Ok(self.annotations.peek());
2242        }
2243        let v = parse()?;
2244        self.annotations.set_if_empty_arc(v.clone());
2245        self.report_bytes();
2246        Ok(v)
2247    }
2248
2249    pub(crate) fn mask_indexed(&self, page: &DjVuPage) -> Option<Arc<IndexedMask>> {
2250        self.fill(&self.mask_indexed, || {
2251            #[cfg(test)]
2252            count_jb2_mask_decode();
2253            page.extract_mask_indexed()
2254                .ok()
2255                .flatten()
2256                .map(|(bm, blits)| (Arc::new(bm), Arc::new(blits)))
2257        })
2258    }
2259}
2260
2261/// Recursive zone count for the metadata-cache byte estimate (#605).
2262#[cfg(feature = "std")]
2263fn count_zones(zones: &[crate::text::TextZone]) -> usize {
2264    zones
2265        .iter()
2266        .map(|z| 1 + count_zones(&z.children))
2267        .sum::<usize>()
2268}
2269
2270/// Max-pool 4× downsample of a bilevel mask.
2271///
2272/// Each output pixel is 1 if any bit in the corresponding 4×4 block of `src`
2273/// is set. Used by [`PageLayers::mask_sub4`] to build the 1/4-resolution mask
2274/// the compositor uses for sub=4 renders instead of `mask_box_any`.
2275#[cfg(feature = "std")]
2276pub(crate) fn downsample_mask_4x(src: &crate::bitmap::Bitmap) -> crate::bitmap::Bitmap {
2277    let out_w = src.width.div_ceil(4);
2278    let out_h = src.height.div_ceil(4);
2279    let mut out = crate::bitmap::Bitmap::new(out_w, out_h);
2280    for oy in 0..out_h {
2281        for ox in 0..out_w {
2282            'outer: for dy in 0..4u32 {
2283                for dx in 0..4u32 {
2284                    let sx = ox * 4 + dx;
2285                    let sy = oy * 4 + dy;
2286                    if sx < src.width && sy < src.height && src.get(sx, sy) {
2287                        out.set(ox, oy, true);
2288                        break 'outer;
2289                    }
2290                }
2291            }
2292        }
2293    }
2294    out
2295}
2296
2297/// The page's render-tier `mask_sub4` layer, or `None` without `std`.
2298///
2299/// Wraps the `std`-only [`PageLayers`] cache so the compositor's sub=4 path
2300/// compiles identically with and without the `std` feature.
2301#[cfg(feature = "std")]
2302fn page_mask_sub4(page: &DjVuPage) -> Option<std::sync::Arc<crate::bitmap::Bitmap>> {
2303    page.render_layers().mask_sub4(page)
2304}
2305
2306/// Is `(plane_w, plane_h)` a legal BG44/FG44 reduction of the page's own
2307/// `(page_w, page_h)` (from the INFO chunk)?
2308///
2309/// Mirrors DjVuLibre's `DjVuFile::get_dpi` cross-check (message
2310/// `DjVuFile.corrupt_BG44`, "Corrupted data (Incorrect size in BG44
2311/// chunk)."): the IW44 plane must be an exact `ceil(page_dim / red)`
2312/// downsample of the page for a *single* common integer reduction factor
2313/// `red` in `1..=12` — i.e. the same `red` must satisfy width *and* height
2314/// simultaneously. A BG44 chunk's own header freely declares its width/height
2315/// (independent of the page's INFO chunk), so without this check a corrupted
2316/// or desynced INFO/BG44 pairing silently maps the plane onto the page using
2317/// mismatched per-axis ratios (see `bg_q24`/`fg_q44`, which compute `sx`/`sy`
2318/// independently) instead of being rejected — producing a visibly stretched/
2319/// distorted composite rather than a clean error, exactly the "no INFO-vs-
2320/// BG44-payload dimension cross-check" gap found by differential fuzzing
2321/// against `ddjvu` (round 45, PERF_EXPERIMENTS.md finding 2).
2322fn iw44_reduction_is_legal(page_w: u32, page_h: u32, plane_w: u32, plane_h: u32) -> bool {
2323    if page_w == 0 || page_h == 0 || plane_w == 0 || plane_h == 0 {
2324        return false;
2325    }
2326    (1..=12u32).any(|red| page_w.div_ceil(red) == plane_w && page_h.div_ceil(red) == plane_h)
2327}
2328
2329/// Decode background from BG44 chunks up to `max_chunks`.
2330///
2331/// `subsample` controls IW44 decode resolution: 1 = full, 2 = half, 4 = quarter.
2332/// Use `best_iw44_subsample(opts.decode_scale(page))` to pick an appropriate value.
2333///
2334/// When `max_chunks == usize::MAX`, the decoded wavelet image is fetched from
2335/// the page's [`PageLayers`] cache, avoiding repeated ZP arithmetic decode.
2336///
2337/// Returns `None` if there are no BG44 chunks.
2338/// `max_chunks = usize::MAX` means decode all chunks.
2339fn decode_background_chunks(
2340    page: &DjVuPage,
2341    max_chunks: usize,
2342    subsample: u32,
2343) -> Result<Background, RenderError> {
2344    // Fast path: use a cached Iw44Image when all chunks are wanted.
2345    // For sub >= 4 we use the partial cache (first chunk only) — the high-frequency
2346    // refinement in later chunks is imperceptible at quarter-scale output, and skipping
2347    // them reduces cold ZP decode cost by ~4×.
2348    // For sub=1 (the most common case — full-resolution render) we also cache the
2349    // decoded RGB Pixmap, saving the 2–3 ms IDWT + YCbCr→RGB conversion per call.
2350    if max_chunks == usize::MAX {
2351        let bg44_chunks = page.bg44_chunks();
2352        if !bg44_chunks.is_empty() {
2353            if subsample == 1 {
2354                // Strict-mode error propagation: if BG44 failed to decode,
2355                // decoded_bg44() returns None; treat that as a hard error.
2356                let img = page
2357                    .decoded_bg44()
2358                    .ok_or(RenderError::Iw44(crate::Iw44Error::Invalid))?;
2359                // #811: a very large page is composited from bands of the
2360                // wavelet image; its whole RGB pixmap is never built (and
2361                // `bg_rgb_s1` never caches one).
2362                if let Some(band_rows) = img.rgb_band_rows() {
2363                    return Ok(Background::Banded {
2364                        image: img,
2365                        band_rows,
2366                    });
2367                }
2368                return Ok(Background::from(page.decoded_bg_rgb_s1()));
2369            }
2370            if subsample == 2 {
2371                // C5_COMPRESS: `PageLayers::downgrade` can clear `bg44` while
2372                // deliberately *keeping* an already-cached `bg_rgb_s2` (the
2373                // cheaper middle tier — see downgrade's doc comment). Check the
2374                // terminal cache first so that case stays warm: `bg_rgb_s2`'s
2375                // own initialiser already routes through `bg44(page)?`, so a
2376                // populated `Some` here can only follow a prior successful
2377                // decode — no need to force `decoded_bg44()` again.
2378                if let Some(cached) = page.decoded_bg_rgb_s2() {
2379                    return Ok(Background::Whole(cached));
2380                }
2381                // Same memoization as sub=1 for the common 150-from-300-DPI render.
2382                // Strict-mode error propagation: if BG44 failed to decode,
2383                // decoded_bg44() returns None; treat that as a hard error.
2384                let _ = page
2385                    .decoded_bg44()
2386                    .ok_or(RenderError::Iw44(crate::Iw44Error::Invalid))?;
2387                return Ok(Background::from(page.decoded_bg_rgb_s2()));
2388            }
2389            if subsample == 4 {
2390                // C5_COMPRESS: mirrors the subsample==2 short-circuit above for
2391                // `bg_rgb_s4` / `bg44_partial`.
2392                if let Some(cached) = page.decoded_bg_rgb_s4() {
2393                    return Ok(Background::Whole(cached));
2394                }
2395                // Cache the sub=4 RGB conversion (built from the partial image,
2396                // matching the sub>=4 path) so repeated thumbnail / downscale
2397                // renders skip the IDWT + YCbCr->RGB conversion.
2398                let _ = page
2399                    .decoded_bg44_partial()
2400                    .ok_or(RenderError::Iw44(crate::Iw44Error::Invalid))?;
2401                return Ok(Background::from(page.decoded_bg_rgb_s4()));
2402            }
2403            // subsample > 4 (subsample == 4 returned above). THUMB_PARTIAL_MEMO:
2404            // reuse an already-cached partial image, but do not *populate* the
2405            // cache from here. A partial `Iw44Image` is exactly as large as a
2406            // full one (see `PageLayers::bg44_partial_cached`) and this branch
2407            // caches nothing it derives, so memoising it made a thumbnail sweep
2408            // retain the whole book's backgrounds for no repeat saving.
2409            #[cfg(feature = "std")]
2410            if let Some(cached) = page.cached_bg_rgb_subhi(subsample) {
2411                return Ok(Background::Whole(cached));
2412            }
2413            let img = if subsample >= 4 {
2414                #[cfg(feature = "std")]
2415                {
2416                    // Decoded here and dropped with this call when the page has
2417                    // no cached partial image. `no_std` has no layer cache at
2418                    // all, so it keeps the plain accessor (a stub returning
2419                    // `None`).
2420                    match page.cached_bg44_partial() {
2421                        Some(cached) => Some(cached),
2422                        None => decode_bg44_partial(page).map(Arc::new),
2423                    }
2424                }
2425                #[cfg(not(feature = "std"))]
2426                {
2427                    page.decoded_bg44_partial()
2428                }
2429            } else {
2430                page.decoded_bg44()
2431            };
2432            let img = img.ok_or(RenderError::Iw44(crate::Iw44Error::Invalid))?;
2433            let rgb = Arc::new(img.to_rgb_subsample(subsample)?);
2434            #[cfg(feature = "std")]
2435            if subsample > 4 {
2436                page.store_bg_rgb_subhi(subsample, rgb.clone());
2437            }
2438            return Ok(Background::Whole(rgb));
2439        }
2440        // No BG44 chunks — fall through to the JPEG fallback below.
2441    } else {
2442        let bg44_chunks = page.bg44_chunks();
2443        if !bg44_chunks.is_empty() {
2444            let mut img = Iw44Image::new();
2445            for chunk_data in bg44_chunks.iter().take(max_chunks) {
2446                #[cfg(test)]
2447                count_bg44_chunk_decode();
2448                img.decode_chunk(chunk_data)?;
2449            }
2450            // Same dimension cross-check as the cached path in `PageLayers::bg44`.
2451            if !iw44_reduction_is_legal(
2452                page.width() as u32,
2453                page.height() as u32,
2454                img.width,
2455                img.height,
2456            ) {
2457                return Err(RenderError::Iw44(crate::Iw44Error::Invalid));
2458            }
2459            return Background::from_iw44(img, subsample);
2460        }
2461    }
2462
2463    // Fall back to JPEG-encoded background if present.
2464    #[cfg(feature = "std")]
2465    if let Some(pm) = decode_bgjp(page)? {
2466        return Ok(Background::Whole(Arc::new(pm)));
2467    }
2468
2469    Ok(Background::None)
2470}
2471
2472/// Permissive variant: decode BG44 chunks until the first error, then stop.
2473///
2474/// Returns whatever was decoded so far (may be blurry / incomplete).
2475/// Returns `None` only when there are no BG44 chunks at all or even the
2476/// first chunk fails to produce a valid image.
2477fn decode_background_chunks_permissive(
2478    page: &DjVuPage,
2479    max_chunks: usize,
2480    subsample: u32,
2481) -> Background {
2482    let bg44_chunks = page.bg44_chunks();
2483    if !bg44_chunks.is_empty() {
2484        let mut img = Iw44Image::new();
2485        let wanted = bg44_chunks.len().min(max_chunks);
2486        // `decoded` is the number of chunks decoded before the first error,
2487        // which is exactly the failing chunk's `enumerate` index.
2488        for (decoded, chunk_data) in bg44_chunks.iter().take(max_chunks).enumerate() {
2489            if img.decode_chunk(chunk_data).is_err() {
2490                // stop on first error, use what we have
2491                record_recovery(RecoveredLayer::Background, {
2492                    #[cfg(feature = "std")]
2493                    {
2494                        format!(
2495                            "BG44 truncated at chunk {} of {wanted}; \
2496                                 kept {decoded} decoded chunk(s)",
2497                            decoded + 1
2498                        )
2499                    }
2500                    #[cfg(not(feature = "std"))]
2501                    {
2502                        let _ = (decoded, wanted);
2503                        ""
2504                    }
2505                });
2506                break;
2507            }
2508        }
2509        return Background::from_iw44(img, subsample).unwrap_or(Background::None);
2510    }
2511
2512    // Fall back to JPEG-encoded background if present.
2513    #[cfg(feature = "std")]
2514    {
2515        Background::from(decode_bgjp(page).ok().flatten().map(Arc::new))
2516    }
2517    #[cfg(not(feature = "std"))]
2518    Background::None
2519}
2520
2521/// Decode the JB2 mask (Sjbz chunk) without blit tracking.
2522///
2523/// Uses the page-level cache (`decoded_mask`) so that repeated renders of the
2524/// same page (e.g. at different DPI levels) skip the ZP arithmetic decode.
2525/// Returns the cached handle (zero-copy) on a cache hit, and a freshly
2526/// decoded bitmap on a cold decode or when no Sjbz chunk is present.
2527fn decode_mask(page: &DjVuPage) -> Result<Option<Arc<crate::bitmap::Bitmap>>, RenderError> {
2528    match page.decoded_mask() {
2529        Some(bm) => Ok(Some(bm)),
2530        None if page.find_chunk(b"Sjbz").is_some() => {
2531            // Cache miss means decode failed; propagate via fresh decode for the error.
2532            page.extract_mask()
2533                .map_err(RenderError::from)
2534                .map(|opt| opt.map(Arc::new))
2535        }
2536        None => Ok(None),
2537    }
2538}
2539
2540/// Decode the JB2 mask with per-pixel blit index tracking.
2541///
2542/// Delegates to [`DjVuPage::extract_mask_indexed`] so that the shared DJVI
2543/// dictionary (`shared_djbz`) is used as a fallback when there is no inline
2544/// Djbz chunk.
2545/// A decoded indexed mask: the JB2 bitmap plus its per-pixel blit-index map.
2546///
2547/// Both halves are shared handles. The page cache stores exactly this pair, so
2548/// a cache hit hands out the buffers without a copy, and the cache can drop its
2549/// own reference while a render still holds one.
2550pub(crate) type IndexedMask = (Arc<crate::bitmap::Bitmap>, Arc<Vec<i32>>);
2551
2552fn decode_mask_indexed(page: &DjVuPage) -> Result<Option<IndexedMask>, RenderError> {
2553    match page.decoded_mask_indexed() {
2554        Some(pair) => Ok(Some((pair.0.clone(), pair.1.clone()))),
2555        // Cache miss with a mask chunk present means decode failed; re-run to
2556        // surface the error (mirrors `decode_mask`). A genuine no-chunk page
2557        // returns Ok(None) without a re-decode.
2558        None if page.find_chunk(b"Sjbz").is_some() || page.find_chunk(b"Smmr").is_some() => page
2559            .extract_mask_indexed()
2560            .map_err(RenderError::from)
2561            .map(|opt| opt.map(|(bm, blit)| (Arc::new(bm), Arc::new(blit)))),
2562        None => Ok(None),
2563    }
2564}
2565
2566/// Decode the FGbz foreground palette with per-blit color indices.
2567fn decode_fg_palette_full(page: &DjVuPage) -> Result<Option<FgbzPalette>, RenderError> {
2568    let fgbz = match page.find_chunk(b"FGbz") {
2569        Some(data) => data,
2570        None => return Ok(None),
2571    };
2572
2573    let pal = parse_fgbz(fgbz)?;
2574    if pal.colors.is_empty() {
2575        return Ok(None);
2576    }
2577    Ok(Some(pal))
2578}
2579
2580/// Decode the FG44 foreground layer.
2581///
2582/// Uses the page-level cache (`decoded_fg44`) so that repeated renders skip
2583/// the IW44 ZP decode. Falls back to FGjp (JPEG) when no FG44 chunks are present.
2584fn decode_fg44(page: &DjVuPage) -> Result<Option<Arc<Pixmap>>, RenderError> {
2585    let fg44_chunks = page.fg44_chunks();
2586    if !fg44_chunks.is_empty() {
2587        return match page.decoded_fg44() {
2588            Some(pm) => Ok(Some(pm)),
2589            // `decoded_fg44()` is a shared cache used by both strict and
2590            // permissive callers, so it swallows the underlying decode error
2591            // and returns `None`. With chunks present, a cache miss can only
2592            // mean decode failed (never "no foreground") — re-run the decode
2593            // for the real error and propagate it (mirrors `decode_mask`'s
2594            // Sjbz handling below, round 577). Permissive callers already wrap
2595            // this call in `.ok().flatten()`, recovering the old "no
2596            // foreground" behavior.
2597            None => page
2598                .extract_foreground()
2599                .map_err(RenderError::from)
2600                .map(|opt| opt.map(Arc::new)),
2601        };
2602    }
2603
2604    // Fall back to JPEG-encoded foreground if present.
2605    #[cfg(feature = "std")]
2606    if let Some(pm) = decode_fgjp(page)? {
2607        return Ok(Some(Arc::new(pm)));
2608    }
2609
2610    Ok(None)
2611}
2612
2613/// The page layers decoded for a full (non-progressive) composite: background,
2614/// foreground palette, mask, optional indexed blit map, and the FG44/FGjp
2615/// foreground pixmap.
2616struct DecodedLayers {
2617    bg: Background,
2618    fg_palette: Option<FgbzPalette>,
2619    mask: Option<Arc<crate::bitmap::Bitmap>>,
2620    blit_map: Option<Arc<Vec<i32>>>,
2621    fg44: Option<Arc<Pixmap>>,
2622}
2623
2624/// The page background a composite reads (#811).
2625enum Background {
2626    /// No background layer: the compositor paints white.
2627    None,
2628    /// The whole background as one RGB pixmap — the ordinary case.
2629    Whole(Arc<Pixmap>),
2630    /// A page too large to hold its background as one RGB pixmap. The
2631    /// compositor pulls `band_rows` output rows at a time from the wavelet
2632    /// image with [`Iw44Image::rgb_rows`] and never holds more than one band;
2633    /// see [`for_each_bg_band`].
2634    Banded {
2635        image: Arc<Iw44Image>,
2636        band_rows: u32,
2637    },
2638}
2639
2640impl Background {
2641    /// The whole pixmap, when the background is held whole.
2642    fn whole(&self) -> Option<&Pixmap> {
2643        match self {
2644            Background::Whole(px) => Some(px),
2645            _ => None,
2646        }
2647    }
2648
2649    /// `true` when there is any background at all.
2650    fn is_some(&self) -> bool {
2651        !matches!(self, Background::None)
2652    }
2653
2654    /// The background a freshly decoded (uncached) wavelet image gives at
2655    /// `subsample`: banded when the image asks for it at full resolution,
2656    /// else converted whole.
2657    fn from_iw44(img: Iw44Image, subsample: u32) -> Result<Self, RenderError> {
2658        Self::from_shared_iw44(&Arc::new(img), subsample)
2659    }
2660
2661    /// [`Self::from_iw44`] for an image the caller keeps (the streaming
2662    /// [`ProgressiveDecoder`] refines the same image chunk after chunk).
2663    fn from_shared_iw44(img: &Arc<Iw44Image>, subsample: u32) -> Result<Self, RenderError> {
2664        if subsample == 1
2665            && let Some(band_rows) = img.rgb_band_rows()
2666        {
2667            return Ok(Background::Banded {
2668                image: img.clone(),
2669                band_rows,
2670            });
2671        }
2672        Ok(Background::Whole(Arc::new(
2673            img.to_rgb_subsample(subsample)?,
2674        )))
2675    }
2676}
2677
2678impl From<Option<Arc<Pixmap>>> for Background {
2679    fn from(px: Option<Arc<Pixmap>>) -> Self {
2680        px.map_or(Background::None, Background::Whole)
2681    }
2682}
2683
2684/// The rows of a colour plane the compositor reads: the whole plane, or one
2685/// band of its rows when the page is too large to hold whole (#811).
2686///
2687/// Row lookups take plane coordinates either way, so the compositor is the
2688/// same code for both. `height` is the whole plane's, which keeps the row
2689/// clamping at the plane's edge rather than the band's.
2690#[derive(Clone, Copy)]
2691struct PlaneView<'a> {
2692    px: &'a Pixmap,
2693    /// Height of the whole plane; `px.height` when the plane is held whole.
2694    height: u32,
2695    /// The plane row held in row 0 of `px`.
2696    row0: u32,
2697}
2698
2699impl<'a> PlaneView<'a> {
2700    fn whole(px: &'a Pixmap) -> Self {
2701        PlaneView {
2702            px,
2703            height: px.height,
2704            row0: 0,
2705        }
2706    }
2707
2708    /// One band of a plane `height` rows tall, holding plane rows
2709    /// `row0..row0 + px.height`.
2710    fn band(px: &'a Pixmap, height: u32, row0: u32) -> Self {
2711        PlaneView { px, height, row0 }
2712    }
2713
2714    #[inline]
2715    fn width(&self) -> u32 {
2716        self.px.width
2717    }
2718
2719    #[inline]
2720    fn height(&self) -> u32 {
2721        self.height
2722    }
2723
2724    /// Plane row `y` as RGBA bytes, or an empty slice when it is not in
2725    /// memory. Every caller clamps `y` to the plane first; a row outside the
2726    /// band would mean [`bg_rows_needed`] planned the band wrong, which debug
2727    /// builds report rather than paint as black.
2728    #[inline]
2729    fn row(&self, y: u32) -> &'a [u8] {
2730        let i = y.wrapping_sub(self.row0) as usize;
2731        debug_assert!(
2732            y >= self.row0 && i < self.px.height as usize,
2733            "plane row {y} is outside the band {}..{}",
2734            self.row0,
2735            self.row0 + self.px.height
2736        );
2737        let stride = self.px.width as usize * 4;
2738        i.checked_mul(stride)
2739            .and_then(|off| self.px.data.get(off..off + stride))
2740            .unwrap_or(&[])
2741    }
2742}
2743
2744/// Decode every layer needed for a full composite at `bg_subsample` — the one
2745/// home for the permissive-vs-strict decode decision.
2746///
2747/// In permissive mode each step swallows errors (`.ok().flatten()`) and the
2748/// background stops at the first corrupt chunk; in strict mode any decode error
2749/// propagates. The returned `mask` already has `opts.bold` dilation applied,
2750/// since both callers do that immediately after decoding.
2751///
2752/// Both [`render_rows`] (the row path behind `render_pixmap` / `render_into` /
2753/// `render_streaming`) and [`render_region`] build their `CompositeContext`
2754/// from this, keeping their decode logic identical. The progressive path
2755/// decodes differently (partial background up to `chunk_n`) and is not routed
2756/// through here.
2757fn decode_layers(
2758    page: &DjVuPage,
2759    opts: &RenderOptions,
2760    bg_subsample: u32,
2761    bg_chunk_limit: usize,
2762) -> Result<DecodedLayers, RenderError> {
2763    // #607 (round 89 follow-up): an eligible sub>=4 render skips the full JB2
2764    // decode entirely, whether `mask_sub4` is already warm or still cold.
2765    // Eligibility mirrors `resolve_sub4_mask` (no bold dilation, no FGbz
2766    // palette — those need full-resolution mask semantics); the compositor
2767    // then reads only the sub4 plane, so output is pixel-identical to the
2768    // full-decode path by construction.
2769    //
2770    // `mask_sub4(page)` (rather than the passive `mask_sub4_cached()`) is what
2771    // makes this fire on a *cold* first render too: when nothing is cached yet
2772    // it decodes straight to 1/4 resolution via `extract_mask_sub4` instead of
2773    // decoding the full-resolution canvas and downsampling it afterward — the
2774    // 12.6 MB `extract_mask` allocation round 89 flagged in the thumbnail
2775    // sweep. A warm full-resolution mask (from a prior sub=1 render of this
2776    // page) is still reused by downsampling it in place, never re-decoded.
2777    //
2778    // Restricted to full-background decodes (`bg_chunk_limit == usize::MAX`):
2779    // the progressive path composites with the mask returned *here* (it never
2780    // consults `resolve_sub4_mask`), so handing it a maskless layer set made
2781    // `render_progressive` frames silently drop the text layer whenever this
2782    // cache happened to be warm — output depended on cache warmth (#691
2783    // slice 3 regression test `render_progressive_ignores_mask_sub4_warmth`).
2784    #[cfg(feature = "std")]
2785    if bg_chunk_limit == usize::MAX
2786        && bg_subsample >= 4
2787        && opts.bold == 0
2788        && page.find_chunk(b"FGbz").is_none()
2789        && page.render_layers().mask_sub4(page).is_some()
2790    {
2791        let (bg, fg44) = if opts.permissive {
2792            (
2793                decode_background_chunks_permissive(page, bg_chunk_limit, bg_subsample),
2794                permissive_layer(decode_fg44(page), RecoveredLayer::Foreground),
2795            )
2796        } else {
2797            (
2798                decode_background_chunks(page, bg_chunk_limit, bg_subsample)?,
2799                decode_fg44(page)?,
2800            )
2801        };
2802        return Ok(DecodedLayers {
2803            bg,
2804            fg_palette: None,
2805            mask: None,
2806            blit_map: None,
2807            fg44,
2808        });
2809    }
2810
2811    let bg;
2812    let fg_palette;
2813    let mask;
2814    let blit_map;
2815    let fg44;
2816
2817    if opts.permissive {
2818        bg = decode_background_chunks_permissive(page, bg_chunk_limit, bg_subsample);
2819        fg_palette = permissive_layer(
2820            decode_fg_palette_full(page),
2821            RecoveredLayer::ForegroundPalette,
2822        );
2823        let indexed = if fg_palette.is_some() {
2824            permissive_layer(decode_mask_indexed(page), RecoveredLayer::Mask)
2825        } else {
2826            None
2827        };
2828        if let Some((bm, bm_map)) = indexed {
2829            mask = Some(bm);
2830            blit_map = Some(bm_map);
2831        } else {
2832            mask = permissive_layer(decode_mask(page), RecoveredLayer::Mask);
2833            blit_map = None;
2834        }
2835        fg44 = permissive_layer(decode_fg44(page), RecoveredLayer::Foreground);
2836    } else {
2837        // #440: background (BG44 ZP + IDWT) and foreground (JB2 mask + FG44) decode
2838        // touch disjoint OnceLock fields, so on a cold render they can run on two
2839        // rayon threads — the FG JB2 decode overlaps the BG ZP phase (when the pool
2840        // is otherwise idle, before IW44_PAR's IDWT join kicks in). Warm renders hit
2841        // the caches and return immediately, so the join is paid only when cold.
2842        #[cfg(feature = "parallel")]
2843        let (bg_res, fg_res) = rayon::join(
2844            || decode_background_chunks(page, bg_chunk_limit, bg_subsample),
2845            || decode_foreground_strict(page),
2846        );
2847        #[cfg(not(feature = "parallel"))]
2848        let (bg_res, fg_res) = (
2849            decode_background_chunks(page, bg_chunk_limit, bg_subsample),
2850            decode_foreground_strict(page),
2851        );
2852        bg = bg_res?;
2853        let fg = fg_res?;
2854        fg_palette = fg.fg_palette;
2855        mask = fg.mask;
2856        blit_map = fg.blit_map;
2857        fg44 = fg.fg44;
2858    }
2859
2860    let mask = if opts.bold > 0 {
2861        mask.map(|m| Arc::new(Arc::unwrap_or_clone(m).dilate_n(opts.bold as u32)))
2862    } else {
2863        mask
2864    };
2865
2866    Ok(DecodedLayers {
2867        bg,
2868        fg_palette,
2869        mask,
2870        blit_map,
2871        fg44,
2872    })
2873}
2874
2875/// The foreground layers (mask + blit map, FGbz palette, FG44 colour) decoded in
2876/// strict mode, *before* any bold dilation.
2877struct ForegroundLayers {
2878    fg_palette: Option<FgbzPalette>,
2879    mask: Option<Arc<crate::bitmap::Bitmap>>,
2880    blit_map: Option<Arc<Vec<i32>>>,
2881    fg44: Option<Arc<Pixmap>>,
2882}
2883
2884/// Strict-mode decode of a page's foreground layers, shared by the full
2885/// [`decode_layers`] path and [`render_progressive`] (which decodes a partial
2886/// background but the same foreground).
2887///
2888/// Owns the "indexed mask when an FGbz palette is present, plain mask
2889/// otherwise" decision so the two callers cannot drift apart. Bold dilation is
2890/// applied by the caller, since `decode_layers` shares one dilation step across
2891/// its permissive and strict branches.
2892fn decode_foreground_strict(page: &DjVuPage) -> Result<ForegroundLayers, RenderError> {
2893    let fg_palette = decode_fg_palette_full(page)?;
2894    let (mask, blit_map) = if fg_palette.is_some() {
2895        match decode_mask_indexed(page)? {
2896            Some((bm, bm_map)) => (Some(bm), Some(bm_map)),
2897            None => (None, None),
2898        }
2899    } else {
2900        (decode_mask(page)?, None)
2901    };
2902    let fg44 = decode_fg44(page)?;
2903    Ok(ForegroundLayers {
2904        fg_palette,
2905        mask,
2906        blit_map,
2907        fg44,
2908    })
2909}
2910
2911/// Decode a BGjp (JPEG-encoded background) chunk into an RGB [`Pixmap`].
2912///
2913/// Returns `None` when the page has no `BGjp` chunk.
2914/// Only available with the `std` feature (requires `zune-jpeg`).
2915#[cfg(feature = "std")]
2916fn decode_bgjp(page: &DjVuPage) -> Result<Option<Pixmap>, RenderError> {
2917    let data = match page.find_chunk(b"BGjp") {
2918        Some(d) => d,
2919        None => return Ok(None),
2920    };
2921    Ok(Some(decode_jpeg_to_pixmap(data)?))
2922}
2923
2924/// Decode an FGjp (JPEG-encoded foreground) chunk into an RGB [`Pixmap`].
2925///
2926/// Returns `None` when the page has no `FGjp` chunk.
2927/// Only available with the `std` feature (requires `zune-jpeg`).
2928#[cfg(feature = "std")]
2929fn decode_fgjp(page: &DjVuPage) -> Result<Option<Pixmap>, RenderError> {
2930    let data = match page.find_chunk(b"FGjp") {
2931        Some(d) => d,
2932        None => return Ok(None),
2933    };
2934    Ok(Some(decode_jpeg_to_pixmap(data)?))
2935}
2936
2937/// Decode raw JPEG bytes into an RGBA [`Pixmap`].
2938///
2939/// Uses `zune-jpeg` for decoding. The JPEG is decoded to RGB and then
2940/// converted to RGBA (alpha = 255).
2941#[cfg(feature = "std")]
2942fn decode_jpeg_to_pixmap(data: &[u8]) -> Result<Pixmap, RenderError> {
2943    use zune_jpeg::JpegDecoder;
2944    use zune_jpeg::zune_core::bytestream::ZCursor;
2945
2946    let cursor = ZCursor::new(data);
2947    let mut decoder = JpegDecoder::new(cursor);
2948    decoder
2949        .decode_headers()
2950        .map_err(|e| RenderError::Jpeg(format!("{e:?}")))?;
2951    let info = decoder
2952        .info()
2953        .ok_or_else(|| RenderError::Jpeg("missing image info after decode_headers".to_owned()))?;
2954    let w = info.width as usize;
2955    let h = info.height as usize;
2956    let rgb = decoder
2957        .decode()
2958        .map_err(|e| RenderError::Jpeg(format!("{e:?}")))?;
2959
2960    // zune-jpeg returns packed RGB; convert to RGBA with alpha = 255.
2961    let pixel_count = w * h;
2962    let rgb = if rgb.len() >= pixel_count * 3 {
2963        rgb
2964    } else {
2965        // Truncated JPEG — pad with zeros so rgb_to_rgba stays in bounds.
2966        let mut padded = rgb;
2967        padded.resize(pixel_count * 3, 0);
2968        padded
2969    };
2970    let mut rgba = vec![0u8; pixel_count * 4];
2971    rgb_to_rgba(&rgb[..pixel_count * 3], &mut rgba);
2972    Ok(Pixmap {
2973        width: w as u32,
2974        height: h as u32,
2975        data: rgba,
2976    })
2977}
2978
2979/// A sub-rectangle within the full rendered output.
2980///
2981/// Used by [`render_region`] to select which portion of the page to render.
2982/// `x` and `y` are pixel offsets within the output at `opts.width × opts.height`
2983/// resolution.
2984#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2985pub struct RenderRect {
2986    /// X offset in output pixels.
2987    pub x: u32,
2988    /// Y offset in output pixels.
2989    pub y: u32,
2990    /// Width of the output region in pixels.
2991    pub width: u32,
2992    /// Height of the output region in pixels.
2993    pub height: u32,
2994}
2995
2996/// All decoded layers and options passed to the compositor.
2997///
2998/// `Clone`/`Copy`: every field is a reference or a `Copy` primitive, so
2999/// [`render_region_tiled`] can stamp out one context per tile (only
3000/// `offset_x`/`offset_y`/`out_w`/`out_h` differ) without rebuilding the q24 /
3001/// gamma-LUT plumbing each time.
3002#[derive(Clone, Copy)]
3003struct CompositeContext<'a> {
3004    opts: &'a RenderOptions,
3005    page_w: u32,
3006    page_h: u32,
3007    bg: Option<PlaneView<'a>>,
3008    /// Q24 ratio for converting page-space FRACBITS coordinates to BG-plane
3009    /// FRACBITS coordinates.  Uses the inferred integer BG44 cell pitch so
3010    /// padded edge cells do not stretch across the native render.  `0` when
3011    /// `bg` is `None`.
3012    bg_x_q24: u64,
3013    bg_y_q24: u64,
3014    mask: Option<&'a crate::bitmap::Bitmap>,
3015    /// `mask_sub.trailing_zeros()` where mask_sub is 1 (full-res) or 4 (1/4-res).
3016    /// Using a shift instead of division avoids a UDIV instruction in the hot path.
3017    mask_shift: u32,
3018    fg_palette: Option<&'a FgbzPalette>,
3019    /// Per-pixel blit index map (same dimensions as mask). `-1` = no blit.
3020    blit_map: Option<&'a [i32]>,
3021    fg44: Option<&'a Pixmap>,
3022    /// Q24 ratio for converting page-space FRACBITS coordinates to FG44-space
3023    /// FRACBITS coordinates.  The horizontal ratio uses the inferred integer
3024    /// foreground colour-cell pitch; the vertical ratio uses the encoded plane
3025    /// height so the bottom row remains reachable.  `0` when `fg44` is `None`.
3026    fg_x_q24: u64,
3027    fg_y_q24: u64,
3028    gamma_lut: &'a [u8; 256],
3029    /// True when gamma_lut is the identity mapping (lut[i] == i for all i).
3030    gamma_is_identity: bool,
3031    /// X offset within the full render (for region renders; 0 for full page).
3032    offset_x: u32,
3033    /// Y offset within the full render (for region renders; 0 for full page).
3034    offset_y: u32,
3035    /// Output width (may be smaller than opts.width for region renders).
3036    out_w: u32,
3037    /// Output height (may be smaller than opts.height for region renders).
3038    out_h: u32,
3039}
3040
3041#[derive(Clone, Copy)]
3042struct AreaAvgX {
3043    fx: u32,
3044    bg_x0: u32,
3045    bg_x1: u32,
3046}
3047
3048// Exclusive upper bounds: the output pixel covers source [x0, x1) x [y0, y1).
3049// The old inclusive formula gave a 3x3 box at 2x downscale because x1 landed on
3050// the first pixel of the next output cell; exclusive gives the correct 2x2.
3051fn area_range(limit: u32, f: u32, step: u32) -> (u32, u32) {
3052    (
3053        (f >> FRACBITS).min(limit.saturating_sub(1)),
3054        ((f + step) >> FRACBITS).min(limit),
3055    )
3056}
3057
3058fn precompute_area_avg_x(
3059    ctx: &CompositeContext<'_>,
3060    fx_step: u32,
3061    bg_fx_step: u32,
3062) -> Vec<AreaAvgX> {
3063    let mut xs = Vec::with_capacity(ctx.out_w as usize);
3064    let bg_w = ctx.bg.map_or(0, |bg| bg.width());
3065    for ox in 0..ctx.out_w {
3066        let fx = (ox + ctx.offset_x) * fx_step;
3067        let bg_fx = ((fx as u64 * ctx.bg_x_q24) >> 24) as u32;
3068        let (bg_x0, bg_x1) = area_range(bg_w, bg_fx, bg_fx_step);
3069        xs.push(AreaAvgX { fx, bg_x0, bg_x1 });
3070    }
3071    xs
3072}
3073
3074/// Per-output-column bg sampling data for the bilinear (upscale / 1:1) path:
3075/// clamped source columns `x0`/`x1` and the horizontal fractional weight `tx`.
3076/// Column mapping never depends on the row, so this is computed once per
3077/// render instead of once per pixel — the AreaAvgX analog for upscaling.
3078#[derive(Clone, Copy)]
3079struct BilinearX {
3080    x0: u32,
3081    x1: u32,
3082    tx: u32,
3083}
3084
3085/// Build the per-column table for [`composite_rows_bilinear_one`]. Walks the
3086/// exact Q48 fixed-point accumulator of the in-loop fallback (`bg_fx_q`), so
3087/// table lookups and the fallback produce byte-identical coordinates.
3088fn precompute_bilinear_x(ctx: &CompositeContext<'_>, fx_step: u32) -> Option<Vec<BilinearX>> {
3089    let bg = ctx.bg?;
3090    let clamp_w = bg.width().saturating_sub(1);
3091    let bg_fx_step_q: u64 = fx_step as u64 * ctx.bg_x_q24;
3092    let mut bg_fx_q: u64 = (ctx.offset_x as u64 * fx_step as u64 + FRAC as u64 / 2) * ctx.bg_x_q24;
3093    let mut xs = Vec::with_capacity(ctx.out_w as usize);
3094    for _ in 0..ctx.out_w {
3095        let bg_fx = ((bg_fx_q >> 24) as u32).saturating_sub(FRAC / 2);
3096        let x0 = (bg_fx >> FRACBITS).min(clamp_w);
3097        xs.push(BilinearX {
3098            x0,
3099            x1: (x0 + 1).min(clamp_w),
3100            tx: bg_fx & FRAC_MASK,
3101        });
3102        bg_fx_q = bg_fx_q.wrapping_add(bg_fx_step_q);
3103    }
3104    Some(xs)
3105}
3106
3107impl<'a> CompositeContext<'a> {
3108    /// Build a composite context from already-decoded layers.
3109    ///
3110    /// This is the single home for the per-render wiring that was copy-pasted
3111    /// across all five render entry points: the q24 cell-pitch arithmetic (where
3112    /// the #199 BG/FG alignment fix lives), the page-dimension lookup, and the
3113    /// gamma-LUT / offset / output-size plumbing. Callers decode their layers
3114    /// (full or partial background, optionally sub-4 mask) and hand them here so
3115    /// a q24 / offset / gamma bug can only ever be fixed in one place.
3116    ///
3117    /// The `(mask, mask_shift)` pair is passed in rather than derived because it
3118    /// legitimately varies per entry point — the full-page paths swap in the
3119    /// 1/4-resolution mask via [`resolve_sub4_mask`], while region / coarse /
3120    /// progressive renders always composite against the full-resolution mask.
3121    #[allow(clippy::too_many_arguments)]
3122    fn from_layers(
3123        page: &DjVuPage,
3124        opts: &'a RenderOptions,
3125        bg: Option<PlaneView<'a>>,
3126        mask: Option<&'a crate::bitmap::Bitmap>,
3127        mask_shift: u32,
3128        fg_palette: Option<&'a FgbzPalette>,
3129        blit_map: Option<&'a [i32]>,
3130        fg44: Option<&'a Pixmap>,
3131        gamma_lut: &'a [u8; 256],
3132        offset: (u32, u32),
3133        out: (u32, u32),
3134    ) -> Self {
3135        let page_w = page.width() as u32;
3136        let page_h = page.height() as u32;
3137        let (fg_x_q24, fg_y_q24) = fg_q24(fg44, page_w, page_h);
3138        let (bg_x_q24, bg_y_q24) = bg_q24(bg.map(|b| (b.width(), b.height())), page_w, page_h);
3139        CompositeContext {
3140            opts,
3141            page_w,
3142            page_h,
3143            bg,
3144            bg_x_q24,
3145            bg_y_q24,
3146            mask,
3147            mask_shift,
3148            fg_palette,
3149            blit_map,
3150            fg44,
3151            fg_x_q24,
3152            fg_y_q24,
3153            gamma_lut,
3154            gamma_is_identity: gamma_lut.iter().enumerate().all(|(i, &v)| v == i as u8),
3155            offset_x: offset.0,
3156            offset_y: offset.1,
3157            out_w: out.0,
3158            out_h: out.1,
3159        }
3160    }
3161
3162    /// The same context reading `bg` instead — a band of the plane, or a
3163    /// different band. `bg` must report the whole plane's size so the
3164    /// plane pitch stays what [`Self::from_layers`] computed.
3165    fn with_bg<'b>(&self, bg: Option<PlaneView<'b>>) -> CompositeContext<'b>
3166    where
3167        'a: 'b,
3168    {
3169        let (bg_x_q24, bg_y_q24) = bg_q24(
3170            bg.map(|b| (b.width(), b.height())),
3171            self.page_w,
3172            self.page_h,
3173        );
3174        CompositeContext {
3175            bg,
3176            bg_x_q24,
3177            bg_y_q24,
3178            ..*self
3179        }
3180    }
3181}
3182
3183/// Pick the mask plane and shift for a full-page composite.
3184///
3185/// At background subsample ≥ 4 (and only when no bold dilation or FGbz palette
3186/// is in play, since those need full-resolution lookups) the compositor reads a
3187/// pre-downsampled 1/4-resolution mask — one bit lookup per output pixel instead
3188/// of 4–9. The returned shift is `mask_sub.trailing_zeros()` (2 for the 1/4-res
3189/// mask, 0 for the full-res mask). This is the single home for that decision,
3190/// previously copy-pasted into `render_rows` and `render_into`.
3191/// The mask plane chosen by [`resolve_sub4_mask`].
3192///
3193/// The full-resolution mask is borrowed from the caller's decoded layer set,
3194/// but the 1/4-resolution mask is a shared handle out of the page cache: the
3195/// cache can drop its own copy at any time (see `CacheSlot`), so the plane has
3196/// to keep the buffer alive itself. Callers bind the plane to a local and read
3197/// the mask with [`MaskPlane::get`].
3198enum MaskPlane<'a> {
3199    Borrowed(Option<&'a crate::bitmap::Bitmap>),
3200    #[cfg(feature = "std")]
3201    Shared(Option<std::sync::Arc<crate::bitmap::Bitmap>>),
3202}
3203
3204impl MaskPlane<'_> {
3205    #[inline]
3206    fn get(&self) -> Option<&crate::bitmap::Bitmap> {
3207        match self {
3208            MaskPlane::Borrowed(m) => *m,
3209            #[cfg(feature = "std")]
3210            MaskPlane::Shared(m) => m.as_deref(),
3211        }
3212    }
3213}
3214
3215#[cfg(feature = "std")]
3216fn resolve_sub4_mask<'a>(
3217    page: &'a DjVuPage,
3218    bg_subsample: u32,
3219    opts: &RenderOptions,
3220    full_mask: Option<&'a crate::bitmap::Bitmap>,
3221    fg_palette: Option<&FgbzPalette>,
3222) -> (MaskPlane<'a>, u32) {
3223    if bg_subsample >= 4 && opts.bold == 0 && fg_palette.is_none() {
3224        (MaskPlane::Shared(page_mask_sub4(page)), 2)
3225    } else {
3226        (MaskPlane::Borrowed(full_mask), 0)
3227    }
3228}
3229
3230#[cfg(not(feature = "std"))]
3231fn resolve_sub4_mask<'a>(
3232    _page: &'a DjVuPage,
3233    _bg_subsample: u32,
3234    _opts: &RenderOptions,
3235    full_mask: Option<&'a crate::bitmap::Bitmap>,
3236    _fg_palette: Option<&FgbzPalette>,
3237) -> (MaskPlane<'a>, u32) {
3238    (MaskPlane::Borrowed(full_mask), 0)
3239}
3240
3241/// Look up the palette color for a foreground pixel at (px, py).
3242///
3243/// Uses the blit map to find the per-glyph blit index, then maps it through
3244/// the FGbz index table to get the final color. Falls back to `palette[0]` when
3245/// no index table is present, and to black when lookup fails.
3246#[inline]
3247fn lookup_palette_color(
3248    pal: &FgbzPalette,
3249    blit_map: Option<&[i32]>,
3250    mask: Option<&crate::bitmap::Bitmap>,
3251    px: u32,
3252    py: u32,
3253) -> PaletteColor {
3254    if let Some(bm) = blit_map
3255        && let Some(m) = mask
3256    {
3257        let mi = py as usize * m.width as usize + px as usize;
3258        if mi < bm.len() {
3259            let blit_idx = bm[mi];
3260            if blit_idx >= 0 {
3261                if !pal.indices.is_empty() {
3262                    // Two-level indirection: blit_idx → color_idx → color
3263                    let bi = blit_idx as usize;
3264                    if bi < pal.indices.len() {
3265                        let ci = pal.indices[bi] as usize;
3266                        if ci < pal.colors.len() {
3267                            return pal.colors[ci];
3268                        }
3269                    }
3270                } else {
3271                    // No index table: use blit_idx directly as color index
3272                    let ci = blit_idx as usize;
3273                    if ci < pal.colors.len() {
3274                        return pal.colors[ci];
3275                    }
3276                }
3277            }
3278        }
3279    }
3280    // Fallback: first palette color or black
3281    pal.colors.first().copied().unwrap_or_default()
3282}
3283
3284/// The background-plane rows the compositor reads for output rows `rows`
3285/// (absolute rows of the `full_w × full_h` render), as `lo..hi` (#811).
3286///
3287/// Mirrors the row arithmetic of [`composite_rows_bilinear_one`] and
3288/// [`composite_rows_area_avg_one`] for the first and last output row; both
3289/// mappings are monotone, so the rows in between fall inside. The bilinear
3290/// path reads rows `y0` and `y0 + 1` (clamped); the area path reads
3291/// `[y0, y1)` with at least `y0` itself.
3292fn bg_rows_needed(
3293    (page_w, page_h): (u32, u32),
3294    (full_w, full_h): (u32, u32),
3295    plane: (u32, u32),
3296    rows: core::ops::Range<u32>,
3297) -> (u32, u32) {
3298    let plane_h = plane.1;
3299    if rows.is_empty() || plane_h == 0 {
3300        return (0, 0);
3301    }
3302    let fx_step = ((page_w as u64 * FRAC as u64) / full_w.max(1) as u64) as u32;
3303    let fy_step = ((page_h as u64 * FRAC as u64) / full_h.max(1) as u64) as u32;
3304    let (_, bg_y_q24) = bg_q24(Some(plane), page_w, page_h);
3305    let first = rows.start;
3306    let last = rows.end - 1;
3307    if fx_step > FRAC || fy_step > FRAC {
3308        let bg_fy_step = ((fy_step as u64 * bg_y_q24) >> 24) as u32;
3309        let row_of = |oy: u32| (((oy * fy_step) as u64 * bg_y_q24) >> 24) as u32;
3310        let (lo, _) = area_range(plane_h, row_of(first), bg_fy_step);
3311        let (y0, y1) = area_range(plane_h, row_of(last), bg_fy_step);
3312        (lo, y1.max(y0 + 1))
3313    } else {
3314        let clamp_h = plane_h - 1;
3315        let row_of =
3316            |oy: u32| (map_plane_center_frac(oy * fy_step, bg_y_q24) >> FRACBITS).min(clamp_h);
3317        let lo = row_of(first);
3318        let hi = (row_of(last) + 1).min(clamp_h) + 1;
3319        (lo, hi)
3320    }
3321}
3322
3323/// How many output rows one background band covers, so that the plane rows
3324/// [`bg_rows_needed`] asks for stay within `band_rows` — the memory budget
3325/// [`Iw44Image::rgb_band_rows`] sized. `offset_y`/`out_h` are the output
3326/// rows of the whole composite.
3327fn bg_band_out_rows(
3328    page: (u32, u32),
3329    full: (u32, u32),
3330    plane: (u32, u32),
3331    offset_y: u32,
3332    out_h: u32,
3333    band_rows: u32,
3334) -> u32 {
3335    let fy_step = ((page.1 as u64 * FRAC as u64) / full.1.max(1) as u64) as u32;
3336    let (_, bg_y_q24) = bg_q24(Some(plane), page.0, page.1);
3337    // Plane rows per output row, Q(FRACBITS + 24); a few rows of slack for
3338    // the clamped neighbour rows the samplers read.
3339    let per_out = (fy_step as u64 * bg_y_q24).max(1);
3340    let rows = ((band_rows.saturating_sub(4) as u64) << (FRACBITS + 24)) / per_out;
3341    let mut rows = (rows.min(out_h as u64) as u32).max(1);
3342    // Safety net: never exceed the budget, whatever rounding did above.
3343    loop {
3344        let (lo, hi) = bg_rows_needed(page, full, plane, offset_y..offset_y + rows);
3345        if hi - lo <= band_rows || rows == 1 {
3346            return rows;
3347        }
3348        rows = (rows * 7 / 8).max(1);
3349    }
3350}
3351
3352/// Run `f` once per background band of a composite (#811).
3353///
3354/// For a whole (or missing) background this is one call with the ordinary
3355/// context. For a [`Background::Banded`] page the output rows are walked in
3356/// bands: each band pulls exactly the plane rows it reads with
3357/// [`Iw44Image::rgb_rows`], composites through a context whose `offset_y`
3358/// and `out_h` are narrowed to that band, and is dropped before the next
3359/// one, so the peak memory is one band, not the whole background pixmap.
3360/// `f` receives the context and the band's first output row relative to
3361/// `out`; it writes those rows of its own output.
3362#[allow(clippy::too_many_arguments)]
3363fn for_each_bg_band<F>(
3364    page: &DjVuPage,
3365    opts: &RenderOptions,
3366    bg: &Background,
3367    mask: Option<&crate::bitmap::Bitmap>,
3368    mask_shift: u32,
3369    fg_palette: Option<&FgbzPalette>,
3370    blit_map: Option<&[i32]>,
3371    fg44: Option<&Pixmap>,
3372    gamma_lut: &[u8; 256],
3373    offset: (u32, u32),
3374    out: (u32, u32),
3375    mut f: F,
3376) -> Result<(), RenderError>
3377where
3378    F: FnMut(&CompositeContext<'_>, u32) -> Result<(), RenderError>,
3379{
3380    let (image, band_rows) = match bg {
3381        Background::Banded { image, band_rows } => (image, *band_rows),
3382        _ => {
3383            let ctx = CompositeContext::from_layers(
3384                page,
3385                opts,
3386                bg.whole().map(PlaneView::whole),
3387                mask,
3388                mask_shift,
3389                fg_palette,
3390                blit_map,
3391                fg44,
3392                gamma_lut,
3393                offset,
3394                out,
3395            );
3396            return f(&ctx, 0);
3397        }
3398    };
3399    let plane = (image.width, image.height);
3400    let page_dims = (page.width() as u32, page.height() as u32);
3401    let full = (opts.width, opts.height);
3402    let template = CompositeContext::from_layers(
3403        page, opts, None, mask, mask_shift, fg_palette, blit_map, fg44, gamma_lut, offset, out,
3404    );
3405    let mut oy0 = 0u32;
3406    while oy0 < out.1 {
3407        let rows = bg_band_out_rows(
3408            page_dims,
3409            full,
3410            plane,
3411            offset.1 + oy0,
3412            out.1 - oy0,
3413            band_rows,
3414        );
3415        let oy1 = oy0 + rows;
3416        let (lo, hi) = bg_rows_needed(page_dims, full, plane, offset.1 + oy0..offset.1 + oy1);
3417        let band = image.rgb_rows(lo..hi)?;
3418        let view = PlaneView::band(&band, plane.1, lo);
3419        let mut ctx = template.with_bg(Some(view));
3420        ctx.offset_y = offset.1 + oy0;
3421        ctx.out_h = rows;
3422        f(&ctx, oy0)?;
3423        oy0 = oy1;
3424    }
3425    Ok(())
3426}
3427
3428/// Composite one page into `buf` (RGBA, pre-allocated) using the given context.
3429///
3430/// This is a zero-allocation render path when `buf` is already the right size.
3431/// For region renders, `ctx.out_w`/`ctx.out_h` give the output dimensions and
3432/// `ctx.offset_x`/`ctx.offset_y` give the starting offset within the full render.
3433///
3434/// Iterates the output rows over the same single-row composite bodies used by
3435/// [`composite_rows`], writing each row directly into its slice of `buf` with no
3436/// intermediate copy. The two paths therefore share one per-pixel decision tree.
3437fn composite_into(ctx: &CompositeContext<'_>, buf: &mut [u8]) -> Result<(), RenderError> {
3438    let full_w = ctx.opts.width;
3439    let full_h = ctx.opts.height;
3440
3441    // Fixed-point step: how many source pixels per full-render output pixel
3442    let fx_step = ((ctx.page_w as u64 * FRAC as u64) / full_w.max(1) as u64) as u32;
3443    let fy_step = ((ctx.page_h as u64 * FRAC as u64) / full_h.max(1) as u64) as u32;
3444
3445    let row_stride = ctx.out_w as usize * 4;
3446
3447    // Bilevel fast path: JB2-only page (no IW44 bg, no FG44, no palette).
3448    // Skips bilinear sampling and gamma LUT — just white fill + black mask writes.
3449    // Writes alpha=255 inline; returns early before the general compositor loop.
3450    if ctx.bg.is_none() && ctx.fg44.is_none() && ctx.fg_palette.is_none() {
3451        #[cfg(feature = "parallel")]
3452        {
3453            use rayon::prelude::*;
3454            let n = ctx.out_h as usize * row_stride;
3455            buf[..n]
3456                .par_chunks_exact_mut(row_stride)
3457                .enumerate()
3458                .for_each(|(oy, row)| {
3459                    composite_rows_bilevel_one(ctx, oy as u32, fx_step, fy_step, row);
3460                });
3461        }
3462        #[cfg(not(feature = "parallel"))]
3463        for (oy, row) in buf[..ctx.out_h as usize * row_stride]
3464            .chunks_exact_mut(row_stride)
3465            .enumerate()
3466        {
3467            composite_rows_bilevel_one(ctx, oy as u32, fx_step, fy_step, row);
3468        }
3469        return Ok(());
3470    }
3471
3472    let downscale = fx_step > FRAC || fy_step > FRAC;
3473    // Precompute bg-space step for the area-average path (avoids per-pixel multiply).
3474    let bg_fx_step = ((fx_step as u64 * ctx.bg_x_q24) >> 24) as u32;
3475    let bg_fy_step = ((fy_step as u64 * ctx.bg_y_q24) >> 24) as u32;
3476    let area_avg_x = downscale.then(|| precompute_area_avg_x(ctx, fx_step, bg_fx_step));
3477    let bilinear_x = (!downscale)
3478        .then(|| precompute_bilinear_x(ctx, fx_step))
3479        .flatten();
3480
3481    #[cfg(feature = "parallel")]
3482    {
3483        use rayon::prelude::*;
3484        let n = ctx.out_h as usize * row_stride;
3485        buf[..n]
3486            .par_chunks_exact_mut(row_stride)
3487            .enumerate()
3488            .for_each_init(Vec::new, |vblend, (oy, row)| {
3489                if downscale {
3490                    composite_rows_area_avg_one(
3491                        ctx,
3492                        oy as u32,
3493                        fx_step,
3494                        fy_step,
3495                        bg_fx_step,
3496                        bg_fy_step,
3497                        row,
3498                        area_avg_x.as_deref(),
3499                    );
3500                } else {
3501                    composite_rows_bilinear_one(
3502                        ctx,
3503                        oy as u32,
3504                        fx_step,
3505                        fy_step,
3506                        row,
3507                        bilinear_x.as_deref(),
3508                        vblend,
3509                    );
3510                }
3511            });
3512    }
3513    #[cfg(not(feature = "parallel"))]
3514    {
3515        let mut vblend = Vec::new();
3516        for (oy, row) in buf[..ctx.out_h as usize * row_stride]
3517            .chunks_exact_mut(row_stride)
3518            .enumerate()
3519        {
3520            if downscale {
3521                composite_rows_area_avg_one(
3522                    ctx,
3523                    oy as u32,
3524                    fx_step,
3525                    fy_step,
3526                    bg_fx_step,
3527                    bg_fy_step,
3528                    row,
3529                    area_avg_x.as_deref(),
3530                );
3531            } else {
3532                composite_rows_bilinear_one(
3533                    ctx,
3534                    oy as u32,
3535                    fx_step,
3536                    fy_step,
3537                    row,
3538                    bilinear_x.as_deref(),
3539                    &mut vblend,
3540                );
3541            }
3542        }
3543    }
3544
3545    // All render paths (bilevel, bilinear, area-average) write alpha=255 inline;
3546    // no separate fill_alpha_255 post-pass is needed.
3547
3548    Ok(())
3549}
3550
3551/// Drive the composite hot path row-by-row, calling `sink(row_index, &row_rgba)`
3552/// once per output row.
3553///
3554/// Each call to `sink` receives a 4-byte-per-pixel RGBA slice of width
3555/// `ctx.out_w`.  A single scratch row is allocated up-front (not per-row), so
3556/// peak additional heap use is `out_w * 4` bytes regardless of page height.
3557///
3558/// This is the internal streaming primitive used by [`render_rows`]; callers
3559/// that already hold a flat output buffer should prefer [`composite_into`],
3560/// which writes directly without an intermediate copy.
3561fn composite_rows<F>(ctx: &CompositeContext<'_>, mut sink: F) -> Result<(), RenderError>
3562where
3563    F: FnMut(usize, &[u8]),
3564{
3565    let full_w = ctx.opts.width;
3566    let full_h = ctx.opts.height;
3567
3568    // Fixed-point step: how many source pixels per full-render output pixel.
3569    let fx_step = ((ctx.page_w as u64 * FRAC as u64) / full_w.max(1) as u64) as u32;
3570    let fy_step = ((ctx.page_h as u64 * FRAC as u64) / full_h.max(1) as u64) as u32;
3571
3572    let row_stride = ctx.out_w as usize * 4;
3573
3574    // Bilevel fast path: JB2-only page (no IW44 bg, no FG44, no palette).
3575    if ctx.bg.is_none() && ctx.fg44.is_none() && ctx.fg_palette.is_none() {
3576        let mut row_buf = vec![0u8; row_stride];
3577        for oy in 0..ctx.out_h {
3578            composite_rows_bilevel_one(ctx, oy, fx_step, fy_step, &mut row_buf);
3579            sink(oy as usize, &row_buf);
3580        }
3581        return Ok(());
3582    }
3583
3584    let mut row_buf = vec![0u8; row_stride];
3585    let downscale = fx_step > FRAC || fy_step > FRAC;
3586
3587    // Precompute bg-space step for area-average path (avoids per-pixel multiply).
3588    let bg_fx_step = ((fx_step as u64 * ctx.bg_x_q24) >> 24) as u32;
3589    let bg_fy_step = ((fy_step as u64 * ctx.bg_y_q24) >> 24) as u32;
3590    let area_avg_x = downscale.then(|| precompute_area_avg_x(ctx, fx_step, bg_fx_step));
3591    let bilinear_x = (!downscale)
3592        .then(|| precompute_bilinear_x(ctx, fx_step))
3593        .flatten();
3594    let mut vblend = Vec::new();
3595
3596    for oy in 0..ctx.out_h {
3597        if downscale {
3598            composite_rows_area_avg_one(
3599                ctx,
3600                oy,
3601                fx_step,
3602                fy_step,
3603                bg_fx_step,
3604                bg_fy_step,
3605                &mut row_buf,
3606                area_avg_x.as_deref(),
3607            );
3608        } else {
3609            composite_rows_bilinear_one(
3610                ctx,
3611                oy,
3612                fx_step,
3613                fy_step,
3614                &mut row_buf,
3615                bilinear_x.as_deref(),
3616                &mut vblend,
3617            );
3618        }
3619        sink(oy as usize, &row_buf);
3620    }
3621
3622    Ok(())
3623}
3624
3625/// Write one bilevel row into `row_buf`.
3626#[inline]
3627fn composite_rows_bilevel_one(
3628    ctx: &CompositeContext<'_>,
3629    oy: u32,
3630    fx_step: u32,
3631    fy_step: u32,
3632    row_buf: &mut [u8],
3633) {
3634    let mask = match ctx.mask {
3635        Some(m) => m,
3636        None => {
3637            for chunk in row_buf.as_chunks_mut::<4>().0 {
3638                chunk[0] = 255;
3639                chunk[1] = 255;
3640                chunk[2] = 255;
3641                chunk[3] = 255;
3642            }
3643            return;
3644        }
3645    };
3646
3647    // 1:1 scale fast path.
3648    if fx_step == FRAC && fy_step == FRAC {
3649        let stride = mask.row_stride();
3650        // Same shape as the column clamp below: `mask.data` holds `mask.height`
3651        // rows, not `page_h` rows. An INFO chunk that declares a page taller
3652        // than the bilevel mask it ships walked `py` past the end of the data
3653        // and panicked on the range. Clamp to the mask's own height too, and
3654        // take the row through `get`, so a short or empty mask renders white
3655        // instead of unwinding.
3656        //
3657        // Both bounds checks for this row are here, once, rather than per
3658        // pixel: `mask_row` is exactly `stride` bytes and `stride` is
3659        // `ceil(mask.width / 8)`, so once a column is clamped to
3660        // `mask.width - 1` its byte index cannot leave the row. That is what
3661        // lets the fallback loop below keep indexing directly. A zero-width
3662        // mask has no byte to read at all, so it leaves with the empty row.
3663        let py = (oy + ctx.offset_y)
3664            .min(ctx.page_h.saturating_sub(1))
3665            .min(mask.height.saturating_sub(1)) as usize;
3666        let Some(mask_row) = mask.data.get(py * stride..(py + 1) * stride) else {
3667            row_buf.fill(255);
3668            return;
3669        };
3670        if mask_row.is_empty() {
3671            row_buf.fill(255);
3672            return;
3673        }
3674
3675        // I3: whole-row white fast path. If the mask row has no foreground bits (page
3676        // margins, blank inter-line gaps — typically 25-35% of rows in text scans),
3677        // fill the output row with white in one NEON-vectorised store instead of running
3678        // the per-pixel bit-extraction loop.
3679        if !mask_row.iter().any(|&b| b != 0) {
3680            row_buf.fill(255);
3681            return;
3682        }
3683
3684        // P2: BILEVEL_RGBA table fast path. One table lookup per mask byte produces
3685        // 8 pre-packed RGBA pixels (32 bytes); copy_from_slice compiles to 2 NEON vst1
3686        // stores, replacing 8×16 scalar instructions. Works whenever offset_x is
3687        // byte-aligned (offset_x % 8 == 0) so output byte `i` maps to source mask byte
3688        // `offset_x/8 + i` with no per-pixel bit shuffle — covers full-page renders
3689        // (offset_x==0) and byte-aligned `render_region` viewports. Guard
3690        // `offset_x + out_w <= mask.width` keeps the source index in bounds and makes
3691        // the `.min(page_w-1)` clamp a no-op (mask.width == page_w for the 1:1 mask).
3692        let out_w = row_buf.len() / 4;
3693        let ox0 = ctx.offset_x as usize;
3694        if ctx.offset_x.is_multiple_of(8) && ox0 + out_w <= mask.width as usize {
3695            let mb0 = ox0 / 8; // first source mask byte (0 for full-page renders)
3696            let nb_full = out_w / 8; // number of full mask bytes (8 pixels each)
3697            let nb_rem = out_w % 8; // trailing pixels from a partial mask byte
3698            for byte_idx in 0..nb_full {
3699                let mb = mask_row[mb0 + byte_idx];
3700                let src = &BILEVEL_RGBA[mb as usize];
3701                row_buf[byte_idx * 32..(byte_idx + 1) * 32].copy_from_slice(src);
3702            }
3703            if nb_rem > 0 {
3704                let mb = mask_row[mb0 + nb_full];
3705                let src = &BILEVEL_RGBA[mb as usize];
3706                let base = nb_full * 32;
3707                row_buf[base..base + nb_rem * 4].copy_from_slice(&src[..nb_rem * 4]);
3708            }
3709            return;
3710        }
3711
3712        // Fallback: branchless per-pixel expansion with .min() clamp for partial/offset views.
3713        //
3714        // `mask_row` is `mask.width` pixels wide, not `page_w` wide: an INFO chunk that
3715        // declares a page wider than the bilevel mask it ships (a fuzzed or malformed file)
3716        // used to walk `px` past the end of `mask_row` here and panic on the index. The
3717        // fast path above already guards this (`ox0 + out_w <= mask.width`); clamp to the
3718        // mask's own width too.
3719        //
3720        // The clamp is the bounds check, and it is the only one this loop needs:
3721        // `last_col <= mask.width - 1` and `mask_row` is `ceil(mask.width / 8)`
3722        // bytes, so `px >> 3` is always a byte of this row. Reading it through
3723        // `get(..).map_or(..)` instead cost 5.7 % on `render_region_bilevel` —
3724        // the per-pixel branch is what the word "branchless" above is about.
3725        let last_col = ctx
3726            .page_w
3727            .saturating_sub(1)
3728            .min(mask.width.saturating_sub(1));
3729        for (ox, pixel) in row_buf.as_chunks_mut::<4>().0.iter_mut().enumerate() {
3730            let px = (ox as u32 + ctx.offset_x).min(last_col) as usize;
3731            let is_fg = ((mask_row[px >> 3] >> (7 - (px & 7))) & 1) as u32;
3732            let ch = (is_fg.wrapping_sub(1) & 0xFF) as u8; // 0 when fg, 255 when bg
3733            pixel[0] = ch;
3734            pixel[1] = ch;
3735            pixel[2] = ch;
3736            pixel[3] = 255;
3737        }
3738        return;
3739    }
3740
3741    let downscale = fx_step > FRAC || fy_step > FRAC;
3742    let fy = (oy + ctx.offset_y) * fy_step;
3743    let py = (fy >> FRACBITS).min(ctx.page_h.saturating_sub(1));
3744
3745    // I3-downscale: all-white band fast path for the anti-aliased path. The
3746    // source mask band [y0, y1) for this output row is row-invariant (fy is
3747    // fixed), so if it has no foreground bits every output pixel's coverage is 0
3748    // → white. One NEON-vectorised scan replaces out_w `mask_box_coverage` calls
3749    // (each of which itself scans the band). Only `mask_shift == 0` is covered —
3750    // the max-pool sub-path indexes the mask at a coarser resolution. The y range
3751    // matches `mask_box_coverage` exactly; `y1 <= y0` is the degenerate
3752    // zero-coverage case (also white).
3753    if downscale && ctx.mask_shift == 0 {
3754        let stride = mask.row_stride();
3755        let y0 = (fy >> FRACBITS).min(mask.height.saturating_sub(1)) as usize;
3756        let y1 = ((fy + fy_step) >> FRACBITS).min(mask.height) as usize;
3757        if y1 <= y0 || !mask.data[y0 * stride..y1 * stride].iter().any(|&b| b != 0) {
3758            row_buf.fill(255);
3759            return;
3760        }
3761    }
3762
3763    for (ox, pixel) in row_buf.as_chunks_mut::<4>().0.iter_mut().enumerate() {
3764        let fx = (ox as u32 + ctx.offset_x) * fx_step;
3765        let px = (fx >> FRACBITS).min(ctx.page_w.saturating_sub(1));
3766
3767        // ch = 0 → black (foreground), 255 → white (background).
3768        // At downscale, count the fraction of foreground mask bits in the output pixel's
3769        // footprint (anti-aliased). At 1:1, use the exact mask bit (binary).
3770        let ch = if downscale {
3771            if ctx.mask_shift > 0 {
3772                // Subsampled max-pool mask: one boolean covers the footprint already.
3773                let dpx = fx >> (FRACBITS + ctx.mask_shift);
3774                let dpy = fy >> (FRACBITS + ctx.mask_shift);
3775                if dpx < mask.width && dpy < mask.height && mask.get(dpx, dpy) {
3776                    0u8
3777                } else {
3778                    255u8
3779                }
3780            } else {
3781                // Anti-aliased: coverage fraction → gray.
3782                255 - mask_box_coverage(mask, fx, fy, fx_step, fy_step)
3783            }
3784        } else if ctx.opts.mask_aa && ctx.mask_shift == 0 {
3785            // D_AA_ZOOM (opt-in): this branch is only reachable past the exact
3786            // 1:1 early return above, so `!downscale` here means a genuine
3787            // upscale (zoom > 1) in at least one axis. Bilinearly interpolate
3788            // the mask's 0/255 bits instead of the hard nearest-bit lookup —
3789            // smooths glyph edges under zoom. Default `mask_aa: false` never
3790            // takes this branch, so the fast nearest path below is untouched.
3791            255 - mask_bilinear_coverage(mask, fx, fy)
3792        } else if px < mask.width && py < mask.height && mask.get(px, py) {
3793            0u8
3794        } else {
3795            255u8
3796        };
3797        pixel[0] = ch;
3798        pixel[1] = ch;
3799        pixel[2] = ch;
3800        pixel[3] = 255;
3801    }
3802}
3803
3804/// Write one bilinear row into `row_buf` (upscale / 1:1).
3805///
3806/// `bx` is the optional per-column table from [`precompute_bilinear_x`]
3807/// (`None` falls back to the in-loop fixed-point walk — byte-identical).
3808/// `vblend` is caller-owned scratch for the vertically pre-blended bg row;
3809/// reusing it across rows avoids a per-row allocation.
3810#[inline]
3811fn composite_rows_bilinear_one(
3812    ctx: &CompositeContext<'_>,
3813    oy: u32,
3814    fx_step: u32,
3815    fy_step: u32,
3816    row_buf: &mut [u8],
3817    bx: Option<&[BilinearX]>,
3818    vblend: &mut Vec<u16>,
3819) {
3820    let (page_w, page_h) = (ctx.page_w, ctx.page_h);
3821    let fy = (oy + ctx.offset_y) * fy_step;
3822    let py = (fy >> FRACBITS).min(page_h.saturating_sub(1));
3823
3824    // 1:1 fast path: fx and fy land on exact pixel centres (tx = ty = 0), so
3825    // bilinear interpolation degrades to nearest-neighbour. Guard on the bg
3826    // plane ratio too: if bg is at subsample > 1, the bg coordinates are not
3827    // integer-aligned even at native scale and bilinear blending is needed.
3828    if fx_step == FRAC && fy_step == FRAC && ctx.bg_x_q24 == (1 << 24) && ctx.bg_y_q24 == (1 << 24)
3829    {
3830        // Extra-tight path for the common corpus case: bg present, mask
3831        // present, no palette, no FG44, zero horizontal offset. Precompute
3832        // the bg row and mask row slices so the inner loop only touches
3833        // sequential memory with no per-pixel coordinate mapping calls.
3834        if ctx.offset_x == 0
3835            && ctx.fg_palette.is_none()
3836            && ctx.fg44.is_none()
3837            && let Some(bg) = ctx.bg
3838        {
3839            let bg_row = bg.row(py.min(bg.height().saturating_sub(1)));
3840            let lut = &ctx.gamma_lut;
3841
3842            if let Some(mask) = ctx.mask {
3843                // Has mask: check each pixel for foreground (black).
3844                let mask_stride = mask.row_stride();
3845                let mask_py = py.min(mask.height.saturating_sub(1)) as usize;
3846                let mask_row = mask.data.get(mask_py * mask_stride..).unwrap_or(&[]);
3847
3848                // A2: pre-expand mask bits to bytes via LUT, then branchless blend.
3849                let bg_max_px = (bg.width() as usize).saturating_sub(1);
3850                let mask_limit = mask.width as usize;
3851                let out_w = row_buf.len() / 4;
3852                // D1: hoist gamma identity check outside the pixel loop.
3853                macro_rules! a2_has_mask_loop {
3854                    ($write:expr) => {
3855                        for mb_idx in 0..out_w.div_ceil(8) {
3856                            let mb = mask_row.get(mb_idx).copied().unwrap_or(0);
3857                            let exp = &MASK_EXPAND[mb as usize];
3858                            for j in 0..8usize {
3859                                let ox = mb_idx * 8 + j;
3860                                if ox >= out_w {
3861                                    break;
3862                                }
3863                                let fg_m = if ox < mask_limit { exp[j] } else { 0u8 };
3864                                let px = ox.min(bg_max_px);
3865                                let off = px * 4;
3866                                let pixel = &mut row_buf[ox * 4..(ox + 1) * 4];
3867                                let (r, g, b) = if let Some(q) = bg_row.get(off..off + 4) {
3868                                    (q[0] & !fg_m, q[1] & !fg_m, q[2] & !fg_m)
3869                                } else {
3870                                    (!fg_m, !fg_m, !fg_m)
3871                                };
3872                                $write(pixel, r, g, b);
3873                            }
3874                        }
3875                    };
3876                }
3877                if ctx.gamma_is_identity {
3878                    a2_has_mask_loop!(|pixel: &mut [u8], r, g, b| {
3879                        pixel[0] = r;
3880                        pixel[1] = g;
3881                        pixel[2] = b;
3882                        pixel[3] = 255;
3883                    });
3884                } else {
3885                    a2_has_mask_loop!(|pixel: &mut [u8], r, g, b| {
3886                        pixel[0] = lut[r as usize];
3887                        pixel[1] = lut[g as usize];
3888                        pixel[2] = lut[b as usize];
3889                        pixel[3] = 255;
3890                    });
3891                }
3892            } else {
3893                // No mask: pure background copy with gamma correction.
3894                // D1: hoist gamma identity check outside the pixel loop.
3895                if ctx.gamma_is_identity {
3896                    let out_w = row_buf.len() / 4;
3897                    // E1: when bg covers the full output width, bulk-copy the row
3898                    // via memcpy — bg Pixmap always has alpha=255 from YCbCr decode.
3899                    if bg.width() as usize >= out_w {
3900                        row_buf[..out_w * 4].copy_from_slice(&bg_row[..out_w * 4]);
3901                    } else {
3902                        for (ox, pixel) in row_buf.as_chunks_mut::<4>().0.iter_mut().enumerate() {
3903                            let px = ox.min((bg.width() as usize).saturating_sub(1));
3904                            let off = px * 4;
3905                            if let Some(q) = bg_row.get(off..off + 4) {
3906                                pixel[0] = q[0];
3907                                pixel[1] = q[1];
3908                                pixel[2] = q[2];
3909                            } else {
3910                                pixel[0] = 255;
3911                                pixel[1] = 255;
3912                                pixel[2] = 255;
3913                            }
3914                            pixel[3] = 255;
3915                        }
3916                    }
3917                } else {
3918                    for (ox, pixel) in row_buf.as_chunks_mut::<4>().0.iter_mut().enumerate() {
3919                        let px = ox.min((bg.width() as usize).saturating_sub(1));
3920                        let off = px * 4;
3921                        if let Some(q) = bg_row.get(off..off + 4) {
3922                            pixel[0] = lut[q[0] as usize];
3923                            pixel[1] = lut[q[1] as usize];
3924                            pixel[2] = lut[q[2] as usize];
3925                        } else {
3926                            pixel[0] = 255;
3927                            pixel[1] = 255;
3928                            pixel[2] = 255;
3929                        }
3930                        pixel[3] = 255;
3931                    }
3932                }
3933            }
3934            return;
3935        }
3936
3937        // General 1:1 nearest-neighbour path (offset, palette, or FG44 present).
3938
3939        // C2: Pre-hoist FG44 y-rows (row-invariant, analogous to bg_rows in B-series path).
3940        // Eliminates per-fg-pixel: map_plane_center_frac(fy), y0/y1/ty computation, row lookups.
3941        let fg_rows_1x1 = ctx.fg44.filter(|_| ctx.fg_palette.is_none()).map(|fg| {
3942            let fg_fy = map_plane_center_frac(fy, ctx.fg_y_q24);
3943            let y0 = (fg_fy >> FRACBITS).min(fg.height.saturating_sub(1)) as usize;
3944            let y1 = (y0 + 1).min(fg.height.saturating_sub(1) as usize);
3945            let ty = fg_fy & FRAC_MASK;
3946            let stride = fg.width as usize * 4;
3947            let row0 = fg.data.get(y0 * stride..).unwrap_or(&[]);
3948            let row1 = fg.data.get(y1 * stride..).unwrap_or(&[]);
3949            (row0, row1, fg.width, ty)
3950        });
3951        // C2b: Pre-hoist bg row slice (bg_x_q24 == bg_y_q24 == 1<<24 guaranteed by outer
3952        // condition, so bg_fx == fx and the bg row index == py clamped to bg.height).
3953        let bg_row_1x1 = ctx
3954            .bg
3955            .map(|bg| (bg.row(py.min(bg.height().saturating_sub(1))), bg.width()));
3956        // C3: Pre-hoist mask row (py is row-invariant; eliminates y*stride multiply per pixel).
3957        let mask_row_1x1 = ctx.mask.and_then(|m| {
3958            if py >= m.height {
3959                return None;
3960            }
3961            let stride = m.row_stride();
3962            m.data.get(py as usize * stride..).map(|row| (row, m.width))
3963        });
3964
3965        // F2: whole-row background fast path.
3966        // If the mask row has no foreground bits (page margins, blank inter-line gaps — typically
3967        // 30-40% of rows in text documents), bulk-copy from bg_row instead of dispatching
3968        // per-pixel between FG44 bilinear and BG44 lookup.
3969        {
3970            let row_is_all_bg = match mask_row_1x1 {
3971                None => true,
3972                Some((mask_row, mask_w)) => {
3973                    let check_bytes = (mask_w as usize).div_ceil(8).min(mask_row.len());
3974                    mask_row[..check_bytes].iter().all(|&b| b == 0)
3975                }
3976            };
3977            if row_is_all_bg && ctx.gamma_is_identity {
3978                let out_w = row_buf.len() / 4;
3979                let offset_x = ctx.offset_x as usize;
3980                if let Some((bg_row, bg_w)) = bg_row_1x1 {
3981                    if offset_x + out_w <= bg_w as usize {
3982                        row_buf.copy_from_slice(&bg_row[offset_x * 4..(offset_x + out_w) * 4]);
3983                        return;
3984                    }
3985                    // Edge case (out_w clamped beyond bg_w): fall through to per-pixel loop.
3986                } else {
3987                    row_buf.fill(255);
3988                    return;
3989                }
3990            } else if row_is_all_bg {
3991                // #443: F2 for non-identity gamma. An all-bg row is just the gamma
3992                // LUT applied to the bg row (or to white when there is no bg) — a
3993                // sequential LUT pass that skips the G1 pre-expansion + per-pixel
3994                // dispatch. Byte-identical to the per-pixel loop for these rows.
3995                let out_w = row_buf.len() / 4;
3996                let offset_x = ctx.offset_x as usize;
3997                let lut = &ctx.gamma_lut;
3998                if let Some((bg_row, bg_w)) = bg_row_1x1 {
3999                    if offset_x + out_w <= bg_w as usize {
4000                        let src = &bg_row[offset_x * 4..(offset_x + out_w) * 4];
4001                        for (chunk, s) in row_buf
4002                            .as_chunks_mut::<4>()
4003                            .0
4004                            .iter_mut()
4005                            .zip(src.as_chunks::<4>().0)
4006                        {
4007                            chunk[0] = lut[s[0] as usize];
4008                            chunk[1] = lut[s[1] as usize];
4009                            chunk[2] = lut[s[2] as usize];
4010                            chunk[3] = 255;
4011                        }
4012                        return;
4013                    }
4014                    // Edge case (out_w clamped beyond bg_w): fall through.
4015                } else {
4016                    let white = lut[255];
4017                    for chunk in row_buf.as_chunks_mut::<4>().0 {
4018                        chunk[0] = white;
4019                        chunk[1] = white;
4020                        chunk[2] = white;
4021                        chunk[3] = 255;
4022                    }
4023                    return;
4024                }
4025            }
4026        }
4027
4028        // G1: Pre-expand the mask row from bit-packed to per-pixel bytes via MASK_EXPAND LUT.
4029        // Reduces per-pixel mask check from ~7 ops (shift, bounds-check, bit-extract) to a
4030        // single byte load + compare. Buffer covers up to 600 DPI A4/US-letter (≤4096px);
4031        // oversized pages fall through to the original bit-extraction path.
4032        const G1_MAX: usize = 4096;
4033        let mut g1_buf = [0u8; G1_MAX];
4034        let g1_mask: &[u8] = if let Some((mask_row, mask_w)) = mask_row_1x1 {
4035            let mw = mask_w as usize;
4036            if mw <= G1_MAX {
4037                let nb = mw.div_ceil(8);
4038                for (i, &mb) in mask_row[..nb].iter().enumerate() {
4039                    let exp = &MASK_EXPAND[mb as usize];
4040                    let base = i * 8;
4041                    // Write 8 bytes; g1_mask = &g1_buf[..mw] prevents reads past mask_w.
4042                    g1_buf[base..base + 8].copy_from_slice(exp);
4043                }
4044                &g1_buf[..mw]
4045            } else {
4046                &g1_buf[..0] // page too wide: use fallback bit-extraction below
4047            }
4048        } else {
4049            &g1_buf[..0] // no mask: all pixels are background
4050        };
4051
4052        for (ox, pixel) in row_buf.as_chunks_mut::<4>().0.iter_mut().enumerate() {
4053            let fx = (ox as u32 + ctx.offset_x) * fx_step;
4054            let px = (fx >> FRACBITS).min(page_w.saturating_sub(1));
4055
4056            // G1 fast path: single byte load. Falls back to bit-extraction when g1_mask
4057            // is empty (no mask, or page wider than G1_MAX). LLVM hoists the is_empty()
4058            // branch as loop-invariant and generates two loop versions.
4059            let is_fg = if g1_mask.is_empty() {
4060                mask_row_1x1.is_some_and(|(row, mask_w)| {
4061                    let pxu = px as usize;
4062                    pxu < mask_w as usize
4063                        && (row.get(pxu >> 3).copied().unwrap_or(0) >> (7 - (pxu & 7))) & 1 != 0
4064                })
4065            } else {
4066                g1_mask.get(px as usize).copied().unwrap_or(0) != 0
4067            };
4068
4069            let (r, g, b) = if is_fg {
4070                if let Some(pal) = ctx.fg_palette {
4071                    let color = lookup_palette_color(pal, ctx.blit_map, ctx.mask, px, py);
4072                    (color.r, color.g, color.b)
4073                } else if let Some((fg_row0, fg_row1, fg_w, fg_ty)) = fg_rows_1x1 {
4074                    let fg_fx = map_plane_center_frac(fx, ctx.fg_x_q24);
4075                    bilinear_from_rows(fg_row0, fg_row1, fg_w, fg_fx, fg_ty)
4076                } else {
4077                    (0, 0, 0)
4078                }
4079            } else if let Some((bg_row, bg_w)) = bg_row_1x1 {
4080                let bx = (px as usize).min(bg_w.saturating_sub(1) as usize);
4081                let off = bx * 4;
4082                bg_row
4083                    .get(off..off + 4)
4084                    .map_or((255, 255, 255), |q| (q[0], q[1], q[2]))
4085            } else {
4086                (255, 255, 255)
4087            };
4088
4089            if ctx.gamma_is_identity {
4090                pixel[0] = r;
4091                pixel[1] = g;
4092                pixel[2] = b;
4093            } else {
4094                pixel[0] = ctx.gamma_lut[r as usize];
4095                pixel[1] = ctx.gamma_lut[g as usize];
4096                pixel[2] = ctx.gamma_lut[b as usize];
4097            }
4098            pixel[3] = 255;
4099        }
4100        return;
4101    }
4102
4103    // B1: hoist bg_fy (row-invariant) and replace per-pixel u64 mul for bg_fx with
4104    // an exact u64 accumulator (add per pixel instead of multiply).
4105    // bg_fx_q tracks (page_frac + FRAC/2) * bg_x_q24 in Q48; >> 24 gives the
4106    // FRAC-fixed-point coordinate; subtract FRAC/2 to get the centered sample pos.
4107    let bg_fy_hoist = ctx.bg.map(|_| map_plane_center_frac(fy, ctx.bg_y_q24));
4108    let bg_fx_step_q: u64 = fx_step as u64 * ctx.bg_x_q24;
4109    let mut bg_fx_q: u64 = (ctx.offset_x as u64 * fx_step as u64 + FRAC as u64 / 2) * ctx.bg_x_q24;
4110
4111    // B2b: pre-hoist mask row slice for py (eliminates y*stride multiply per pixel).
4112    let mask_hoist = ctx.mask.and_then(|m| {
4113        if py >= m.height {
4114            return None;
4115        }
4116        let stride = m.row_stride();
4117        m.data.get(py as usize * stride..).map(|row| (row, m.width))
4118    });
4119
4120    // #435: row-level all-bg fast path (F2 analog for the B-series path). Pre-scan
4121    // the hoisted mask row once; if it has no foreground bits (blank margins /
4122    // inter-line gaps), `is_fg` is constant-false for the whole row, so the
4123    // `!mask_all_bg &&` short-circuit lets LLVM unswitch the loop and drop the
4124    // per-pixel bit-extraction. Unlike F2 the bg pixels still need per-pixel
4125    // resampling, so this saves only the is_fg check (not the whole bg copy).
4126    let mask_all_bg = match mask_hoist {
4127        None => true,
4128        Some((mask_row, mask_w)) => {
4129            let nb = (mask_w as usize).div_ceil(8).min(mask_row.len());
4130            !mask_row[..nb].iter().any(|&b| b != 0)
4131        }
4132    };
4133
4134    // B2/B3: precompute bg row slices (y0/y1 are row-invariant), then run the
4135    // vertical half of the separable bilinear blend once per bg column: with
4136    // oy fixed, ty and both source rows never change across the row, so
4137    // v = p0*ity + p1*ty (<= 255*FRAC, exact in u16) is shared by every output
4138    // pixel sampling that column. The horizontal half later computes
4139    // (v0*itx + v1*tx + 128) >> 8, which expands to the original 4-term dot
4140    // product of `bilinear_from_rows` — byte-identical by algebra.
4141    //
4142    // The pre-blend only covers the bg columns this row actually samples
4143    // ([col_start, col_end], from the monotonic accumulator's endpoints) —
4144    // a region render must not pay for the full bg width (#region bench).
4145    let vb: Option<(&[[u16; 4]], u32, u32)> = match ctx.bg {
4146        None => None,
4147        Some(bg) => {
4148            let bg_fy = bg_fy_hoist.unwrap_or(0);
4149            let clamp_h = bg.height().saturating_sub(1);
4150            let y0 = (bg_fy >> FRACBITS).min(clamp_h);
4151            let y1 = (y0 + 1).min(clamp_h);
4152            let ty = bg_fy & FRAC_MASK;
4153            let ity = FRAC - ty;
4154            let row0 = bg.row(y0);
4155            let row1 = bg.row(y1);
4156            let clamp_w = bg.width().saturating_sub(1);
4157            let fx_at = |q: u64| ((q >> 24) as u32).saturating_sub(FRAC / 2);
4158            let col_start = (fx_at(bg_fx_q) >> FRACBITS).min(clamp_w);
4159            let last_q =
4160                bg_fx_q.wrapping_add(bg_fx_step_q.wrapping_mul(ctx.out_w.saturating_sub(1) as u64));
4161            let col_end = ((fx_at(last_q) >> FRACBITS).min(clamp_w) + 1).min(clamp_w);
4162            let ncols = (col_end - col_start + 1) as usize;
4163            vblend.clear();
4164            vblend.resize(ncols * 4, 0);
4165            for (i, v) in vblend.as_chunks_mut::<4>().0.iter_mut().enumerate() {
4166                // Truncated rows (partial/streaming decode) contribute zeros,
4167                // exactly like bilinear_from_rows' out-of-range corners.
4168                let off = (col_start as usize + i) * 4;
4169                let p0 = row0.get(off..off + 4);
4170                let p1 = row1.get(off..off + 4);
4171                for ch in 0..4 {
4172                    let a = p0.map_or(0, |q| q[ch] as u32);
4173                    let b = p1.map_or(0, |q| q[ch] as u32);
4174                    v[ch] = (a * ity + b * ty) as u16;
4175                }
4176            }
4177            Some((vblend.as_chunks::<4>().0, bg.width(), col_start))
4178        }
4179    };
4180
4181    // Horizontal half of the separable blend. The column entry comes from the
4182    // precomputed table when available, else from the Q48 accumulator — the
4183    // same walk `precompute_bilinear_x` replicates. `col_start` shifts full-bg
4184    // column indices into the windowed `vb_row`.
4185    let hblend =
4186        |vb_row: &[[u16; 4]], bg_w: u32, col_start: u32, e: Option<BilinearX>, bg_fx_q: u64| {
4187            let e = e.unwrap_or_else(|| {
4188                let bg_fx = ((bg_fx_q >> 24) as u32).saturating_sub(FRAC / 2);
4189                let clamp_w = bg_w.saturating_sub(1);
4190                let x0 = (bg_fx >> FRACBITS).min(clamp_w);
4191                BilinearX {
4192                    x0,
4193                    x1: (x0 + 1).min(clamp_w),
4194                    tx: bg_fx & FRAC_MASK,
4195                }
4196            });
4197            let v0 = vb_row
4198                .get(e.x0.saturating_sub(col_start) as usize)
4199                .copied()
4200                .unwrap_or([0; 4]);
4201            let v1 = vb_row
4202                .get(e.x1.saturating_sub(col_start) as usize)
4203                .copied()
4204                .unwrap_or([0; 4]);
4205            let itx = FRAC - e.tx;
4206            let f = |i: usize| ((v0[i] as u32 * itx + v1[i] as u32 * e.tx + 128) >> 8) as u8;
4207            (f(0), f(1), f(2))
4208        };
4209
4210    // D_AA_ZOOM (opt-in): this function is only invoked when `!downscale`
4211    // (composite_into/composite_rows dispatch downscale to the area-average
4212    // path), but that includes an exact page-level 1:1 render whose *bg*
4213    // plane is subsampled (bg_x_q24/bg_y_q24 != 1<<24) — very common for
4214    // scanned BG44 pages — which fails the "extra-tight" 1:1 fast path above
4215    // and falls through here too. Mask AA must only kick in on a genuine
4216    // zoom (upscale in at least one axis), never on that native 1:1 case, so
4217    // gate on `fx_step`/`fy_step` directly rather than reusing `!downscale`.
4218    let mask_upscale =
4219        ctx.opts.mask_aa && ctx.mask_shift == 0 && (fx_step < FRAC || fy_step < FRAC);
4220
4221    for (ox, pixel) in row_buf.as_chunks_mut::<4>().0.iter_mut().enumerate() {
4222        let fx = (ox as u32 + ctx.offset_x) * fx_step;
4223        let px = (fx >> FRACBITS).min(page_w.saturating_sub(1));
4224
4225        // `coverage` generalises the binary `is_fg` lookup to a 0..=255
4226        // foreground fraction: 0 = fully background, 255 = fully foreground,
4227        // matching `mask_bilinear_coverage`'s convention. With `mask_aa`
4228        // disabled (default) it only ever takes the values 0 or 255 via the
4229        // exact same nearest-bit test as before, and the two special cases
4230        // below reproduce the original is_fg true/false branches exactly —
4231        // byte-identical output.
4232        let coverage: u8 = if mask_upscale {
4233            if mask_all_bg {
4234                0
4235            } else {
4236                ctx.mask.map_or(0, |m| mask_bilinear_coverage(m, fx, fy))
4237            }
4238        } else if !mask_all_bg
4239            && mask_hoist.is_some_and(|(mask_row, mask_w)| {
4240                let pxu = px as usize;
4241                pxu < mask_w as usize
4242                    && (mask_row.get(pxu >> 3).copied().unwrap_or(0) >> (7 - (pxu & 7))) & 1 != 0
4243            })
4244        {
4245            255
4246        } else {
4247            0
4248        };
4249
4250        let (r, g, b) = if coverage == 0 {
4251            if let Some((vb_row, bg_w, col_start)) = vb {
4252                hblend(
4253                    vb_row,
4254                    bg_w,
4255                    col_start,
4256                    bx.and_then(|t| t.get(ox).copied()),
4257                    bg_fx_q,
4258                )
4259            } else {
4260                (255, 255, 255)
4261            }
4262        } else {
4263            let (fr, fg_g, fb) = if let Some(pal) = ctx.fg_palette {
4264                let color = lookup_palette_color(pal, ctx.blit_map, ctx.mask, px, py);
4265                (color.r, color.g, color.b)
4266            } else if let Some(fg) = ctx.fg44 {
4267                let fg_fx = map_plane_center_frac(fx, ctx.fg_x_q24);
4268                let fg_fy = map_plane_center_frac(fy, ctx.fg_y_q24);
4269                sample_bilinear(fg, fg_fx, fg_fy)
4270            } else {
4271                (0, 0, 0)
4272            };
4273            if coverage == 255 {
4274                (fr, fg_g, fb)
4275            } else {
4276                // Partial coverage (mask_aa only): blend fg/bg proportionally
4277                // to the interpolated mask coverage for a smoothed glyph edge.
4278                let (br, bg_g, bb) = if let Some((vb_row, bg_w, col_start)) = vb {
4279                    hblend(
4280                        vb_row,
4281                        bg_w,
4282                        col_start,
4283                        bx.and_then(|t| t.get(ox).copied()),
4284                        bg_fx_q,
4285                    )
4286                } else {
4287                    (255, 255, 255)
4288                };
4289                let cov = coverage as u32;
4290                let inv = 255 - cov;
4291                let blend =
4292                    |f: u8, b: u8| -> u8 { ((f as u32 * cov + b as u32 * inv + 127) / 255) as u8 };
4293                (blend(fr, br), blend(fg_g, bg_g), blend(fb, bb))
4294            }
4295        };
4296
4297        // D1: skip LUT scatter reads when gamma is the identity mapping.
4298        if ctx.gamma_is_identity {
4299            pixel[0] = r;
4300            pixel[1] = g;
4301            pixel[2] = b;
4302        } else {
4303            pixel[0] = ctx.gamma_lut[r as usize];
4304            pixel[1] = ctx.gamma_lut[g as usize];
4305            pixel[2] = ctx.gamma_lut[b as usize];
4306        }
4307        pixel[3] = 255;
4308        bg_fx_q = bg_fx_q.wrapping_add(bg_fx_step_q);
4309    }
4310}
4311
4312/// Write one area-average row into `row_buf` (downscale).
4313#[inline]
4314#[allow(clippy::too_many_arguments)]
4315fn composite_rows_area_avg_one(
4316    ctx: &CompositeContext<'_>,
4317    oy: u32,
4318    fx_step: u32,
4319    fy_step: u32,
4320    bg_fx_step: u32,
4321    bg_fy_step: u32,
4322    row_buf: &mut [u8],
4323    area_avg_x: Option<&[AreaAvgX]>,
4324) {
4325    let fy = (oy + ctx.offset_y) * fy_step;
4326    let bg_fy = ((fy as u64 * ctx.bg_y_q24) >> 24) as u32;
4327    let bg_y = ctx.bg.map(|bg| area_range(bg.height(), bg_fy, bg_fy_step));
4328
4329    // #438: row-level all-bg fast path (F2/I3 analog for the area-avg path). The
4330    // mask footprint's y-band [y0, y1) is row-invariant; if it has no foreground
4331    // bits, `mask_box_any` would return false for every output pixel, so the
4332    // `!mask_all_bg &&` short-circuit skips the per-pixel footprint scan entirely.
4333    // Only the `mask_shift == 0` (mask_box_any) path is covered — the max-pool
4334    // sub-path indexes a coarser mask. The y range matches `mask_box_any`.
4335    let mask_all_bg = ctx.mask_shift == 0
4336        && ctx.mask.is_none_or(|m| {
4337            let stride = m.row_stride();
4338            let y0 = (fy >> FRACBITS).min(m.height.saturating_sub(1)) as usize;
4339            let y1 = ((fy + fy_step) >> FRACBITS).min(m.height) as usize;
4340            y1 <= y0 || !m.data[y0 * stride..y1 * stride].iter().any(|&b| b != 0)
4341        });
4342
4343    for (ox, pixel) in row_buf.as_chunks_mut::<4>().0.iter_mut().enumerate() {
4344        let fallback;
4345        let ax = if let Some(ax) = area_avg_x.and_then(|xs| xs.get(ox)) {
4346            *ax
4347        } else {
4348            let fx = (ox as u32 + ctx.offset_x) * fx_step;
4349            let bg_fx = ((fx as u64 * ctx.bg_x_q24) >> 24) as u32;
4350            let (bg_x0, bg_x1) = area_range(ctx.bg.map_or(0, |bg| bg.width()), bg_fx, bg_fx_step);
4351            fallback = AreaAvgX { fx, bg_x0, bg_x1 };
4352            fallback
4353        };
4354        let fx = ax.fx;
4355
4356        // #439: anti-aliased colour downscale. `coverage` (0..255) is the fraction
4357        // of the output pixel's footprint that is foreground; blend fg/bg
4358        // proportionally so colour text edges get a smooth gradient instead of the
4359        // blocky halos the old binary `mask_box_any` produced (colour analog of the
4360        // AA experiment for bilevel). The max-pool sub-path (mask_shift > 0) stays
4361        // binary. #438's `mask_all_bg` skips the coverage scan for blank rows.
4362        let coverage: u8 = if mask_all_bg {
4363            0
4364        } else if let Some(m) = ctx.mask {
4365            if ctx.mask_shift > 0 {
4366                let px = fx >> (FRACBITS + ctx.mask_shift);
4367                let pym = fy >> (FRACBITS + ctx.mask_shift);
4368                if px < m.width && pym < m.height && m.get(px, pym) {
4369                    255
4370                } else {
4371                    0
4372                }
4373            } else {
4374                mask_box_coverage(m, fx, fy, fx_step, fy_step)
4375            }
4376        } else {
4377            0
4378        };
4379
4380        let bg_sample = || -> (u8, u8, u8) {
4381            // `bg_y` is `Some` exactly when `ctx.bg` is.
4382            match (ctx.bg, bg_y) {
4383                (Some(bg), Some((bg_y0, bg_y1))) => {
4384                    sample_area_avg_bounds(bg, ax.bg_x0, ax.bg_x1, bg_y0, bg_y1)
4385                }
4386                _ => (255, 255, 255),
4387            }
4388        };
4389        let fg_sample = || -> (u8, u8, u8) {
4390            if let Some(pal) = ctx.fg_palette {
4391                let (cx, cy) = mask_box_center_fg(ctx.mask.unwrap(), fx, fy, fx_step, fy_step);
4392                let color = lookup_palette_color(pal, ctx.blit_map, ctx.mask, cx, cy);
4393                (color.r, color.g, color.b)
4394            } else if let Some(fg) = ctx.fg44 {
4395                let fg_fx = ((fx as u64 * ctx.fg_x_q24) >> 24) as u32;
4396                let fg_fy = ((fy as u64 * ctx.fg_y_q24) >> 24) as u32;
4397                let fg_fx_step = ((fx_step as u64 * ctx.fg_x_q24) >> 24) as u32;
4398                let fg_fy_step = ((fy_step as u64 * ctx.fg_y_q24) >> 24) as u32;
4399                sample_area_avg(fg, fg_fx, fg_fy, fg_fx_step, fg_fy_step)
4400            } else {
4401                (0, 0, 0)
4402            }
4403        };
4404
4405        let (r, g, b) = if coverage == 0 {
4406            bg_sample()
4407        } else if coverage == 255 {
4408            fg_sample()
4409        } else {
4410            // Partially covered edge pixel: blend fg over bg by coverage.
4411            let (fr, fg_, fb) = fg_sample();
4412            let (br, bg_, bb) = bg_sample();
4413            let c = coverage as u32;
4414            let ic = 255 - c;
4415            let mix = |f: u8, b: u8| ((c * f as u32 + ic * b as u32 + 127) / 255) as u8;
4416            (mix(fr, br), mix(fg_, bg_), mix(fb, bb))
4417        };
4418
4419        if ctx.gamma_is_identity {
4420            pixel[0] = r;
4421            pixel[1] = g;
4422            pixel[2] = b;
4423        } else {
4424            pixel[0] = ctx.gamma_lut[r as usize];
4425            pixel[1] = ctx.gamma_lut[g as usize];
4426            pixel[2] = ctx.gamma_lut[b as usize];
4427        }
4428        pixel[3] = 255;
4429    }
4430}
4431
4432/// Render a `DjVuPage` row by row, calling `sink(row_index, &rgba_row)` for
4433/// each output row in top-to-bottom order.
4434///
4435/// `rgba_row` contains `opts.width * 4` bytes (RGBA, alpha = 255).
4436///
4437/// This is the internal streaming primitive used by [`render_streaming`] and by
4438/// permissive [`render_pixmap`] fallback. Public callers that need full-pixmap
4439/// post-processing should use [`render_pixmap`].
4440///
4441/// # Errors
4442///
4443/// - [`RenderError::InvalidDimensions`] if `width == 0 || height == 0`
4444/// - Propagates IW44 / JB2 decode errors.
4445pub(crate) fn render_rows<F>(
4446    page: &DjVuPage,
4447    opts: &RenderOptions,
4448    limits: Option<crate::resource_limits::ResourceLimits>,
4449    sink: F,
4450) -> Result<(), RenderError>
4451where
4452    F: FnMut(usize, &[u8]),
4453{
4454    let w = opts.width;
4455    let h = opts.height;
4456
4457    check_output_pixels("render_rows", page, limits, w, h)?;
4458
4459    let gamma_lut = build_gamma_lut(page.gamma());
4460
4461    let bg_subsample = best_iw44_subsample(opts.decode_scale(page));
4462
4463    let DecodedLayers {
4464        bg,
4465        fg_palette,
4466        mask,
4467        blit_map,
4468        fg44,
4469    } = decode_layers(page, opts, bg_subsample, usize::MAX)?;
4470
4471    let (mask_plane, mask_shift) = resolve_sub4_mask(
4472        page,
4473        bg_subsample,
4474        opts,
4475        mask.as_deref(),
4476        fg_palette.as_ref(),
4477    );
4478    let ctx_mask = mask_plane.get();
4479    let mut sink = sink;
4480    for_each_bg_band(
4481        page,
4482        opts,
4483        &bg,
4484        ctx_mask,
4485        mask_shift,
4486        fg_palette.as_ref(),
4487        blit_map.as_deref().map(Vec::as_slice),
4488        fg44.as_deref(),
4489        &gamma_lut,
4490        (0, 0),
4491        (w, h),
4492        |ctx, oy0| composite_rows(ctx, |y, row| sink(y + oy0 as usize, row)),
4493    )
4494}
4495
4496// ── Public API ────────────────────────────────────────────────────────────────
4497
4498/// Render a `DjVuPage` into a pre-allocated RGBA buffer.
4499///
4500/// This is the zero-allocation render path when `buf` is reused across calls
4501/// with the same dimensions. The buffer must be at least `width * height * 4`
4502/// bytes.
4503///
4504/// # Errors
4505///
4506/// - [`RenderError::BufTooSmall`] if `buf.len() < width * height * 4`
4507/// - [`RenderError::InvalidDimensions`] if `width == 0 || height == 0`
4508/// - Propagates IW44 / JB2 decode errors.
4509pub fn render_into(
4510    page: &DjVuPage,
4511    opts: &RenderOptions,
4512    buf: &mut [u8],
4513) -> Result<(), RenderError> {
4514    render_into_with_limits(page, opts, None, buf)
4515}
4516
4517/// Like [`render_into`], with an optional caller-supplied resource limit override.
4518pub fn render_into_with_limits(
4519    page: &DjVuPage,
4520    opts: &RenderOptions,
4521    limits: Option<crate::resource_limits::ResourceLimits>,
4522    buf: &mut [u8],
4523) -> Result<(), RenderError> {
4524    let w = opts.width;
4525    let h = opts.height;
4526
4527    check_output_pixels("render_into", page, limits, w, h)?;
4528
4529    let need = (w as usize)
4530        .checked_mul(h as usize)
4531        .and_then(|n| n.checked_mul(4))
4532        .unwrap_or(usize::MAX);
4533
4534    if buf.len() < need {
4535        return Err(RenderError::BufTooSmall {
4536            need,
4537            got: buf.len(),
4538        });
4539    }
4540
4541    let gamma_lut = build_gamma_lut(page.gamma());
4542
4543    // Decode all layers (shared permissive/strict seam, same as render_rows).
4544    let bg_subsample = best_iw44_subsample(opts.decode_scale(page));
4545    let DecodedLayers {
4546        bg,
4547        fg_palette,
4548        mask,
4549        blit_map,
4550        fg44,
4551    } = decode_layers(page, opts, bg_subsample, usize::MAX)?;
4552
4553    // Use pre-downsampled 1/4-res mask for sub=4 renders (single bit lookup vs
4554    // 4-9 lookups per pixel in the full-res mask).
4555    let (mask_plane, mask_shift) = resolve_sub4_mask(
4556        page,
4557        bg_subsample,
4558        opts,
4559        mask.as_deref(),
4560        fg_palette.as_ref(),
4561    );
4562    let ctx_mask = mask_plane.get();
4563    for_each_bg_band(
4564        page,
4565        opts,
4566        &bg,
4567        ctx_mask,
4568        mask_shift,
4569        fg_palette.as_ref(),
4570        blit_map.as_deref().map(Vec::as_slice),
4571        fg44.as_deref(),
4572        &gamma_lut,
4573        (0, 0),
4574        (w, h),
4575        |ctx, oy0| composite_into(ctx, band_rows_mut(buf, w, oy0, ctx.out_h)),
4576    )
4577}
4578
4579/// Output rows `oy0..oy0 + rows` of an RGBA buffer `w` pixels wide.
4580#[inline]
4581fn band_rows_mut(buf: &mut [u8], w: u32, oy0: u32, rows: u32) -> &mut [u8] {
4582    let stride = w as usize * 4;
4583    &mut buf[oy0 as usize * stride..(oy0 as usize + rows as usize) * stride]
4584}
4585
4586/// Build the options for the native-resolution pre-pass that feeds the
4587/// Lanczos-3 post-filter: full page size, no scaling, no AA, no rotation, and
4588/// bilinear resampling (so the recursive render never re-enters this path).
4589///
4590/// Bold and permissive flags are carried through so the high-resolution
4591/// re-render matches the requested render in everything but the final
4592/// resampling step.
4593fn native_render_opts(page: &DjVuPage, opts: &RenderOptions) -> RenderOptions {
4594    // Full page size (decode scale derives to 1.0), bilinear resampling so the
4595    // recursive render never re-enters this path, no AA / no rotation. Bold and
4596    // permissive are carried through.
4597    RenderOptions {
4598        width: page.width() as u32,
4599        height: page.height() as u32,
4600        bold: opts.bold,
4601        permissive: opts.permissive,
4602        ..Default::default()
4603    }
4604}
4605
4606/// Apply the shared Lanczos-3 post-pass to a freshly composited pixmap.
4607///
4608/// When `opts.resampling` is [`Resampling::Lanczos3`] *and* actual scaling
4609/// happened (`page` native size differs from `full_w × full_h`), the page is
4610/// re-rendered at native resolution via `render_native` and downscaled to
4611/// `out_w × out_h` with [`crate::pixmap::scale_lanczos3`]. Otherwise `pm` is
4612/// returned unchanged — covering both the non-Lanczos case and the 1:1 case
4613/// where Lanczos would be a no-op. If the native re-render fails, the original
4614/// (bilinear) `pm` is kept, matching the previous per-call behaviour.
4615///
4616/// `full` is the full output size used to decide whether scaling occurred;
4617/// `out` is the target the result is scaled to. They differ only for
4618/// [`render_region`], where the comparison is against the full page render but
4619/// the output is the (smaller) region.
4620fn apply_lanczos_postpass<F>(
4621    pm: Pixmap,
4622    page: &DjVuPage,
4623    opts: &RenderOptions,
4624    full: (u32, u32),
4625    out: (u32, u32),
4626    render_native: F,
4627) -> Result<Pixmap, RenderError>
4628where
4629    F: FnOnce(&RenderOptions) -> Result<Pixmap, RenderError>,
4630{
4631    if opts.resampling != Resampling::Lanczos3 {
4632        return Ok(pm);
4633    }
4634    let (full_w, full_h) = full;
4635    let need_scale = page.width() as u32 != full_w || page.height() as u32 != full_h;
4636    if !need_scale {
4637        return Ok(pm);
4638    }
4639    let native_opts = native_render_opts(page, opts);
4640    match render_native(&native_opts) {
4641        // The scaler refuses an output above `Pixmap::MAX_PIXELS`; that is a
4642        // render-output limit, reported as one rather than as a blank page.
4643        Ok(native_pm) => Ok(crate::pixmap::scale_lanczos3(&native_pm, out.0, out.1)?),
4644        // Native render failed — keep the bilinear result already in `pm`.
4645        Err(_) => Ok(pm),
4646    }
4647}
4648
4649/// Render a `DjVuPage` to a new [`Pixmap`] using the given options.
4650///
4651/// Strict renders composite directly into the full pixmap. Permissive renders
4652/// reuse the row path so decode-error recovery remains shared with
4653/// [`render_streaming`].
4654/// Render a page and return the pixmap together with a [`RenderReport`] of any
4655/// layers a permissive render skipped or recovered (#696).
4656///
4657/// The pixmap is byte-identical to [`render_pixmap`]. In strict mode the report
4658/// is always clean (decode errors propagate instead of being recovered); in
4659/// permissive mode it lists each background truncation, dropped mask, or
4660/// skipped foreground/palette in the order the renderer took them.
4661#[cfg(feature = "std")]
4662pub fn render_pixmap_with_report(
4663    page: &DjVuPage,
4664    opts: &RenderOptions,
4665) -> Result<(Pixmap, RenderReport), RenderError> {
4666    // Install a per-thread recovery sink; the guard clears it on every exit
4667    // path (including panics) so a normal `render_pixmap` never records.
4668    struct SinkGuard;
4669    impl Drop for SinkGuard {
4670        fn drop(&mut self) {
4671            RECOVERY_SINK.with(|sink| *sink.borrow_mut() = None);
4672        }
4673    }
4674    RECOVERY_SINK.with(|sink| *sink.borrow_mut() = Some(Vec::new()));
4675    let _guard = SinkGuard;
4676
4677    let pixmap = render_pixmap(page, opts)?;
4678    let recoveries = RECOVERY_SINK.with(|sink| sink.borrow_mut().take().unwrap_or_default());
4679    Ok((pixmap, RenderReport { recoveries }))
4680}
4681
4682pub fn render_pixmap(page: &DjVuPage, opts: &RenderOptions) -> Result<Pixmap, RenderError> {
4683    render_pixmap_with_limits(page, opts, None)
4684}
4685
4686/// Render a page to an owned RGBA pixmap with an optional resource limit override.
4687///
4688/// When `limits` is `None`, limits inherited from the parent document at parse
4689/// time apply (see [`ParseOptions::limits`](crate::resource_limits::ParseOptions::limits)).
4690/// Per-render overrides use [`render_pixmap_with_limits`] /
4691/// [`render_into_with_limits`].
4692pub fn render_pixmap_with_limits(
4693    page: &DjVuPage,
4694    opts: &RenderOptions,
4695    limits: Option<crate::resource_limits::ResourceLimits>,
4696) -> Result<Pixmap, RenderError> {
4697    let w = opts.width;
4698    let h = opts.height;
4699
4700    // Bound the output allocation. `w`/`h` flow from the (untrusted) INFO chunk on
4701    // a default render; w*h*4 of 65535² is ~17 GB, which either OOMs (64-bit) or
4702    // wraps `Pixmap::new` to an empty buffer that the permissive copy below then
4703    // indexes out of bounds. Reject up front.
4704    check_output_pixels("render_pixmap", page, limits, w, h)?;
4705
4706    let mut pm = Pixmap::white(w, h);
4707
4708    if opts.permissive {
4709        let row_stride = w as usize * 4;
4710        // Permissive rendering has its own decode-error recovery path in
4711        // render_rows; keep that behaviour and copy each recovered row.
4712        render_rows(page, opts, limits, |y, row| {
4713            let start = y * row_stride;
4714            pm.data[start..start + row_stride].copy_from_slice(row);
4715        })?;
4716    } else {
4717        // Strict renders can composite directly into the output Pixmap,
4718        // avoiding the scratch row + row copy used by the streaming adapter.
4719        render_into_with_limits(page, opts, limits, &mut pm.data)?;
4720    }
4721
4722    if opts.aa {
4723        pm = aa_downscale(&pm);
4724    }
4725
4726    // Apply the shared Lanczos-3 post-pass (re-render at native size, then
4727    // downscale) when requested and scaling actually happened.
4728    let pm = apply_lanczos_postpass(pm, page, opts, (w, h), (w, h), |native_opts| {
4729        render_pixmap_with_limits(page, native_opts, limits)
4730    })?;
4731
4732    Ok(rotate_pixmap(
4733        pm,
4734        combine_rotations(page.rotation(), opts.rotation),
4735    ))
4736}
4737
4738/// Render a `DjVuPage` row by row, calling `sink(row_index, &rgba_row)` for
4739/// each output row in top-to-bottom order. Each `rgba_row` slice has length
4740/// `opts.width * 4` bytes (RGBA, alpha = 255).
4741///
4742/// This is the constant-memory render path for low-memory targets (mobile,
4743/// WASM, embedded) and for streaming consumers (TIFF/PDF row-encoders, the
4744/// browser `OffscreenCanvas` row blit). Internally it allocates a single
4745/// `opts.width * 4` byte scratch row and reuses it across all rows; peak heap
4746/// usage during compositing is bounded by that scratch plus the decoded
4747/// background (BG44) and mask (JB2) buffers. For a page whose full-resolution
4748/// background would not fit the `djvu-iw44` band budget (128 MiB) the
4749/// background is never built whole: the rows are composited from bands of the
4750/// wavelet image, one band in memory at a time (#811), so the peak stays near
4751/// one band regardless of the page size.
4752///
4753/// Output is byte-identical to [`render_pixmap`] when both produce a result.
4754///
4755/// # Constraints
4756///
4757/// [`render_pixmap`] applies anti-aliasing, Lanczos-3 resampling, and rotation
4758/// as **post-processing on the full pixmap**. The streaming path cannot
4759/// support those modes without buffering the entire output, defeating its
4760/// purpose. The following must hold or [`RenderError::UnsupportedOption`] is
4761/// returned:
4762///
4763/// - `opts.aa == false`
4764/// - `opts.resampling == Resampling::Bilinear`, *or* the output dimensions
4765///   match the page's native dimensions (in which case Lanczos becomes a
4766///   no-op and `Bilinear` produces the same bytes anyway)
4767/// - `opts.rotation == UserRotation::None` *and* the page's INFO rotation is
4768///   `Rotation::None` (i.e. the combined rotation is identity)
4769///
4770/// For any of those modes, use [`render_pixmap`] instead.
4771///
4772/// # Errors
4773///
4774/// - [`RenderError::InvalidDimensions`] if `opts.width == 0 || opts.height == 0`
4775/// - [`RenderError::UnsupportedOption`] if a post-processing option is set
4776/// - Propagates IW44 / JB2 decode errors.
4777///
4778/// # Example
4779///
4780/// ```no_run
4781/// use djvu_rs::djvu_render::{render_streaming, RenderOptions};
4782/// # let doc = djvu_rs::djvu_document::DjVuDocument::parse(&[]).unwrap();
4783/// # let page = doc.page(0).unwrap();
4784/// let opts = RenderOptions::fit_to_width(page, 1024);
4785/// render_streaming(page, &opts, |y, rgba_row| {
4786///     // hand the row off to an encoder, GPU upload, network write, etc
4787///     # let _ = (y, rgba_row);
4788/// }).unwrap();
4789/// ```
4790pub fn render_streaming<F>(
4791    page: &DjVuPage,
4792    opts: &RenderOptions,
4793    sink: F,
4794) -> Result<(), RenderError>
4795where
4796    F: FnMut(usize, &[u8]),
4797{
4798    if opts.aa {
4799        return Err(RenderError::UnsupportedOption(
4800            "anti-aliasing requires a full pixmap; use render_pixmap",
4801        ));
4802    }
4803    let lanczos_with_scaling = opts.resampling == Resampling::Lanczos3
4804        && (page.width() as u32 != opts.width || page.height() as u32 != opts.height);
4805    if lanczos_with_scaling {
4806        return Err(RenderError::UnsupportedOption(
4807            "Lanczos-3 resampling at scaled output requires a full pixmap; use render_pixmap",
4808        ));
4809    }
4810    if combine_rotations(page.rotation(), opts.rotation) != crate::info::Rotation::None {
4811        return Err(RenderError::UnsupportedOption(
4812            "rotation requires a full pixmap; use render_pixmap",
4813        ));
4814    }
4815    render_rows(page, opts, None, sink)
4816}
4817
4818/// Render a sub-rectangle of a page into a new [`Pixmap`].
4819///
4820/// Unlike [`render_pixmap`], which always allocates `opts.width × opts.height`
4821/// pixels, `render_region` only allocates `region.width × region.height` pixels.
4822/// This makes it efficient for thumbnails, viewport clips, and tile rendering.
4823///
4824/// `opts.width` and `opts.height` still define the **full-page** render dimensions
4825/// used for scale calculation. `region` selects which sub-rectangle of that
4826/// full render to output. The returned `Pixmap` has dimensions
4827/// `region.width × region.height`.
4828///
4829/// # Errors
4830///
4831/// - [`RenderError::InvalidDimensions`] if `region.width == 0 || region.height == 0`
4832/// - Propagates IW44 / JB2 decode errors.
4833pub fn render_region(
4834    page: &DjVuPage,
4835    region: RenderRect,
4836    opts: &RenderOptions,
4837) -> Result<Pixmap, RenderError> {
4838    check_output_pixels("render_region", page, None, region.width, region.height)?;
4839
4840    let full_w = opts.width.max(1);
4841    let full_h = opts.height.max(1);
4842    let gamma_lut = build_gamma_lut(page.gamma());
4843
4844    let bg_subsample = best_iw44_subsample(opts.decode_scale(page));
4845    let DecodedLayers {
4846        bg,
4847        fg_palette,
4848        mask,
4849        blit_map,
4850        fg44,
4851    } = decode_layers(page, opts, bg_subsample, usize::MAX)?;
4852
4853    let out_w = region.width;
4854    let out_h = region.height;
4855    let mut pm = Pixmap::white(out_w, out_h);
4856
4857    let region_opts = RenderOptions {
4858        width: full_w,
4859        height: full_h,
4860        ..*opts
4861    };
4862    // Same 1/4-res mask fast-path decision as `render_into`/`render_rows`, so
4863    // a region render stays byte-identical to the matching crop of the full
4864    // render at every subsample tier (#691).
4865    let (mask_plane, mask_shift) = resolve_sub4_mask(
4866        page,
4867        bg_subsample,
4868        opts,
4869        mask.as_deref(),
4870        fg_palette.as_ref(),
4871    );
4872    let ctx_mask = mask_plane.get();
4873    for_each_bg_band(
4874        page,
4875        &region_opts,
4876        &bg,
4877        ctx_mask,
4878        mask_shift,
4879        fg_palette.as_ref(),
4880        blit_map.as_deref().map(Vec::as_slice),
4881        fg44.as_deref(),
4882        &gamma_lut,
4883        (region.x, region.y),
4884        (out_w, out_h),
4885        |ctx, oy0| composite_into(ctx, band_rows_mut(&mut pm.data, out_w, oy0, ctx.out_h)),
4886    )?;
4887
4888    // Shared Lanczos-3 post-pass: scaling is judged against the full render
4889    // size (full_w/full_h) but the result is scaled to the region (out_w/out_h).
4890    let pm = apply_lanczos_postpass(
4891        pm,
4892        page,
4893        opts,
4894        (full_w, full_h),
4895        (out_w, out_h),
4896        |native_opts| render_region(page, region, native_opts),
4897    )?;
4898
4899    Ok(rotate_pixmap(
4900        pm,
4901        combine_rotations(page.rotation(), opts.rotation),
4902    ))
4903}
4904
4905/// `true` when a cooperative cancel flag is present and set.
4906///
4907/// `Relaxed` is enough: the flag carries no data, it only asks in-flight work
4908/// to stop at its next checkpoint.
4909#[inline]
4910pub(crate) fn is_cancelled(cancel: Option<&core::sync::atomic::AtomicBool>) -> bool {
4911    cancel.is_some_and(|flag| flag.load(core::sync::atomic::Ordering::Relaxed))
4912}
4913
4914/// Region variant of [`render_progressive`] (#691 slice 3): composite the
4915/// `region` sub-rectangle of progressive frame `chunk_n` (BG44 chunks
4916/// `0..=chunk_n`, full foreground).
4917///
4918/// **Byte-identical** to the matching crop of
4919/// `render_progressive(page, opts, chunk_n)` for every input: the layer
4920/// decode is the same `decode_layers(.., chunk_n + 1)` call, the composite
4921/// uses the same full-resolution mask (shift 0 — `render_progressive` never
4922/// takes the 1/4-res mask fast path), and `composite_into` computes each
4923/// pixel from its absolute position in the full render, so a sub-rectangle
4924/// reproduces the frame's bytes exactly (see
4925/// `progressive_tiles_match_progressive_frames` in `djvu_tile`).
4926///
4927/// `cancel` is a cooperative stop flag checked on entry and between the
4928/// layer decode and the composite; `Ok(None)` means the render was abandoned
4929/// at a checkpoint. Partial-quality frames are decoded from scratch on every
4930/// call (the `PageLayers` caches only memoize the full-chunk decode), and
4931/// their pixels are never inserted into the composited-tile cache.
4932///
4933/// # Errors
4934///
4935/// Same as [`render_progressive`], plus [`RenderError::UnsupportedOption`]
4936/// for Lanczos-3 resampling (its whole-pixmap re-render recursion is
4937/// incompatible with region output; the tile API rejects it earlier anyway).
4938pub(crate) fn render_region_progressive(
4939    page: &DjVuPage,
4940    region: RenderRect,
4941    opts: &RenderOptions,
4942    chunk_n: usize,
4943    cancel: Option<&core::sync::atomic::AtomicBool>,
4944) -> Result<Option<Pixmap>, RenderError> {
4945    check_output_pixels(
4946        "render_region_progressive",
4947        page,
4948        None,
4949        region.width,
4950        region.height,
4951    )?;
4952    if opts.resampling == Resampling::Lanczos3 {
4953        return Err(RenderError::UnsupportedOption(
4954            "Lanczos-3 resampling is not supported for progressive region renders",
4955        ));
4956    }
4957    let n_bg44 = page.bg44_chunks().len();
4958    let max_chunk = n_bg44.saturating_sub(1);
4959    if n_bg44 > 0 && chunk_n > max_chunk {
4960        return Err(RenderError::ChunkOutOfRange {
4961            chunk_n,
4962            max: max_chunk,
4963        });
4964    }
4965    if is_cancelled(cancel) {
4966        return Ok(None);
4967    }
4968
4969    let full_w = opts.width.max(1);
4970    let full_h = opts.height.max(1);
4971    let gamma_lut = build_gamma_lut(page.gamma());
4972    let bg_subsample = best_iw44_subsample(opts.decode_scale(page));
4973    let DecodedLayers {
4974        bg,
4975        fg_palette,
4976        mask,
4977        blit_map,
4978        fg44,
4979    } = decode_layers(page, opts, bg_subsample, chunk_n + 1)?;
4980
4981    if is_cancelled(cancel) {
4982        return Ok(None);
4983    }
4984
4985    let out_w = region.width;
4986    let out_h = region.height;
4987    let mut pm = Pixmap::white(out_w, out_h);
4988    let region_opts = RenderOptions {
4989        width: full_w,
4990        height: full_h,
4991        ..*opts
4992    };
4993    for_each_bg_band(
4994        page,
4995        &region_opts,
4996        &bg,
4997        mask.as_deref(),
4998        0,
4999        fg_palette.as_ref(),
5000        blit_map.as_deref().map(Vec::as_slice),
5001        fg44.as_deref(),
5002        &gamma_lut,
5003        (region.x, region.y),
5004        (out_w, out_h),
5005        |ctx, oy0| composite_into(ctx, band_rows_mut(&mut pm.data, out_w, oy0, ctx.out_h)),
5006    )?;
5007
5008    Ok(Some(rotate_pixmap(
5009        pm,
5010        combine_rotations(page.rotation(), opts.rotation),
5011    )))
5012}
5013
5014/// Render a sub-rectangle of a page, assembling the output from a per-page
5015/// cache of composited [`TILE_SIZE`]×`TILE_SIZE` output tiles.
5016///
5017/// # Why this exists (C4_TILE_CACHE)
5018///
5019/// [`render_region`] recomposites its entire requested rectangle from scratch
5020/// on every call — the per-pixel work in `composite_into` is not memoized
5021/// anywhere. VIEWER_BENCH (`benches/viewer.rs`) scripted an interactive
5022/// pan/zoom session (open → full render → zoom → 12-step overlapping pan →
5023/// zoom → pan) and measured that a `full_recomposite` pan step costs
5024/// proportionally to its *entire* viewport every time, while an
5025/// `incremental_strip` step (composite only the newly-exposed edge) costs
5026/// proportionally to just that edge — see `PERF_EXPERIMENTS.md` round 36 for
5027/// the numbers. A tile cache captures that gap for real: it composites each
5028/// `TILE_SIZE`-aligned tile once and reuses it for every subsequent request
5029/// that touches it, regardless of how the requested rectangle is framed.
5030///
5031/// # Correctness
5032///
5033/// **Byte-identical** to [`render_region`] for every input — this is a cache
5034/// in front of the same compositor, not a different one. `composite_into`
5035/// computes each output pixel from its *absolute* position
5036/// (`offset_x + ox`, `offset_y + oy`); tile boundaries are aligned to that
5037/// same absolute grid, so assembling a request from whole or partial tiles
5038/// reproduces exactly the bytes a direct `render_region` call would have
5039/// produced (see `render_region_tiled_matches_render_region` and
5040/// `render_region_tiled_overlapping_regions_share_cache` in the test module).
5041///
5042/// # Eligibility
5043///
5044/// The cache only activates for the mode it was built for; anything else
5045/// falls back to a plain [`render_region`] call with no tile bookkeeping:
5046///
5047/// - `opts.resampling == Resampling::Lanczos3` — its native-scale re-render
5048///   recursion isn't compatible with per-tile assembly.
5049/// - the combined page + user rotation isn't the identity — tiles are cached
5050///   pre-rotation, so a rotated request would need a different assembly.
5051/// - `opts.permissive` — kept off the fast path defensively; this targets the
5052///   interactive strict-decode hot path, not error recovery.
5053///
5054/// This is an **opt-in** entry point: call it where you want tile caching
5055/// (e.g. a pan/zoom viewer). [`render_region`] itself is untouched and pays no
5056/// overhead for callers (thumbnails, export, one-shot renders) that don't
5057/// want a per-page tile cache.
5058///
5059/// # Errors
5060///
5061/// Same as [`render_region`].
5062#[cfg(feature = "std")]
5063pub fn render_region_tiled(
5064    page: &DjVuPage,
5065    region: RenderRect,
5066    opts: &RenderOptions,
5067) -> Result<Pixmap, RenderError> {
5068    let pm = render_region_tiled_cancellable(page, region, opts, None)?;
5069    // Without a cancel flag the render can never be abandoned.
5070    Ok(pm.expect("uncancellable render completed"))
5071}
5072
5073/// [`render_region_tiled`] with a cooperative cancel flag (#691 slice 3).
5074///
5075/// The flag is checked on entry and again before each internal
5076/// [`TILE_SIZE`]-tile is fetched or composited; `Ok(None)` means the render
5077/// was abandoned at a checkpoint. Cancellation never corrupts the tile
5078/// cache: a tile is inserted only after its composite completed, so an
5079/// abandoned call leaves either fully-composited tiles or nothing.
5080#[cfg(feature = "std")]
5081pub(crate) fn render_region_tiled_cancellable(
5082    page: &DjVuPage,
5083    region: RenderRect,
5084    opts: &RenderOptions,
5085    cancel: Option<&core::sync::atomic::AtomicBool>,
5086) -> Result<Option<Pixmap>, RenderError> {
5087    check_output_pixels(
5088        "render_region_tiled",
5089        page,
5090        None,
5091        region.width,
5092        region.height,
5093    )?;
5094
5095    let full_w = opts.width.max(1);
5096    let full_h = opts.height.max(1);
5097
5098    let eligible = opts.resampling == Resampling::Bilinear
5099        && !opts.permissive
5100        && combine_rotations(page.rotation(), opts.rotation) == crate::info::Rotation::None;
5101
5102    if is_cancelled(cancel) {
5103        return Ok(None);
5104    }
5105    if !eligible {
5106        return render_region(page, region, opts).map(Some);
5107    }
5108
5109    let gamma_lut = build_gamma_lut(page.gamma());
5110    let bg_subsample = best_iw44_subsample(opts.decode_scale(page));
5111    let DecodedLayers {
5112        bg,
5113        fg_palette,
5114        mask,
5115        blit_map,
5116        fg44,
5117    } = decode_layers(page, opts, bg_subsample, usize::MAX)?;
5118
5119    let region_opts = RenderOptions {
5120        width: full_w,
5121        height: full_h,
5122        ..*opts
5123    };
5124    // Same 1/4-res mask fast-path decision as `render_into`/`render_rows`
5125    // (see render_region); the choice is a pure function of the tile-key
5126    // fields plus per-page constants, so cached tiles stay coherent.
5127    let (mask_plane, mask_shift) = resolve_sub4_mask(
5128        page,
5129        bg_subsample,
5130        opts,
5131        mask.as_deref(),
5132        fg_palette.as_ref(),
5133    );
5134    let ctx_mask = mask_plane.get();
5135    // Template context for the whole full_w×full_h render; each tile below
5136    // copies it (cheap: `Copy`) and only overwrites offset/out fields.
5137    let ctx_template = CompositeContext::from_layers(
5138        page,
5139        &region_opts,
5140        bg.whole().map(PlaneView::whole),
5141        ctx_mask,
5142        mask_shift,
5143        fg_palette.as_ref(),
5144        blit_map.as_deref().map(Vec::as_slice),
5145        fg44.as_deref(),
5146        &gamma_lut,
5147        (0, 0),
5148        (full_w, full_h),
5149    );
5150    // #811: a banded background is fetched one tile row at a time, on the
5151    // first cache miss in that row, and dropped with the row.
5152    let banded = match &bg {
5153        Background::Banded { image, .. } => Some(image),
5154        _ => None,
5155    };
5156
5157    let out_w = region.width;
5158    let out_h = region.height;
5159    let mut pm = Pixmap::white(out_w, out_h);
5160    let out_stride = out_w as usize * 4;
5161
5162    let region_x1 = region.x.saturating_add(region.width).min(full_w);
5163    let region_y1 = region.y.saturating_add(region.height).min(full_h);
5164    if region_x1 <= region.x || region_y1 <= region.y {
5165        // Region lies entirely outside the full render — nothing to copy;
5166        // return the white-filled pixmap (matches render_region's behaviour,
5167        // whose compositor loop would likewise touch no valid pixels).
5168        return Ok(Some(pm));
5169    }
5170    let tx0 = region.x / TILE_SIZE;
5171    let ty0 = region.y / TILE_SIZE;
5172    let tx1 = (region_x1 - 1) / TILE_SIZE;
5173    let ty1 = (region_y1 - 1) / TILE_SIZE;
5174
5175    let layers = page.render_layers();
5176    for ty in ty0..=ty1 {
5177        let tile_y0 = ty * TILE_SIZE;
5178        let tile_h = TILE_SIZE.min(full_h - tile_y0);
5179        // The background band for this tile row: `(pixmap, first plane row)`.
5180        let mut row_band: Option<(Pixmap, u32)> = None;
5181        for tx in tx0..=tx1 {
5182            if is_cancelled(cancel) {
5183                return Ok(None);
5184            }
5185            let tile_x0 = tx * TILE_SIZE;
5186            let tile_w = TILE_SIZE.min(full_w - tile_x0);
5187            let key: TileKey = (full_w, full_h, tile_x0, tile_y0, opts.bold, opts.mask_aa);
5188
5189            let tile = match layers.get_tile(key) {
5190                Some(t) => t,
5191                None => {
5192                    if let Some(image) = banded
5193                        && row_band.is_none()
5194                    {
5195                        let (lo, hi) = bg_rows_needed(
5196                            (page.width() as u32, page.height() as u32),
5197                            (full_w, full_h),
5198                            (image.width, image.height),
5199                            tile_y0..tile_y0 + tile_h,
5200                        );
5201                        row_band = Some((image.rgb_rows(lo..hi)?, lo));
5202                    }
5203                    let mut tile_ctx = match (banded, &row_band) {
5204                        (Some(image), Some((band, lo))) => {
5205                            ctx_template.with_bg(Some(PlaneView::band(band, image.height, *lo)))
5206                        }
5207                        _ => ctx_template,
5208                    };
5209                    tile_ctx.offset_x = tile_x0;
5210                    tile_ctx.offset_y = tile_y0;
5211                    tile_ctx.out_w = tile_w;
5212                    tile_ctx.out_h = tile_h;
5213                    let mut data = vec![0u8; tile_w as usize * tile_h as usize * 4];
5214                    composite_into(&tile_ctx, &mut data)?;
5215                    let entry = std::sync::Arc::new(TileEntry {
5216                        w: tile_w,
5217                        h: tile_h,
5218                        data,
5219                    });
5220                    layers.insert_tile(key, entry.clone());
5221                    entry
5222                }
5223            };
5224
5225            // Copy the overlap between `region` and this tile into `pm`.
5226            let ox0 = tile_x0.max(region.x);
5227            let oy0 = tile_y0.max(region.y);
5228            let ox1 = (tile_x0 + tile.w).min(region_x1);
5229            let oy1 = (tile_y0 + tile.h).min(region_y1);
5230            if ox0 >= ox1 || oy0 >= oy1 {
5231                continue;
5232            }
5233            let copy_w = (ox1 - ox0) as usize;
5234            let tile_stride = tile.w as usize * 4;
5235            for y in oy0..oy1 {
5236                let tile_row = (y - tile_y0) as usize;
5237                let tile_col = (ox0 - tile_x0) as usize;
5238                let src_start = tile_row * tile_stride + tile_col * 4;
5239                let dst_row = (y - region.y) as usize;
5240                let dst_col = (ox0 - region.x) as usize;
5241                let dst_start = dst_row * out_stride + dst_col * 4;
5242                pm.data[dst_start..dst_start + copy_w * 4]
5243                    .copy_from_slice(&tile.data[src_start..src_start + copy_w * 4]);
5244            }
5245        }
5246    }
5247
5248    Ok(Some(pm))
5249}
5250
5251/// Render a `DjVuPage` to an 8-bit grayscale image.
5252///
5253/// Equivalent to calling [`render_pixmap`] and converting the result with
5254/// [`Pixmap::to_gray8`]. Returns a [`GrayPixmap`] where `data.len() ==
5255/// width * height`.
5256///
5257/// For bilevel (JB2-only) pages this produces only `0` and `255` values.
5258/// For colour pages, luminance is computed with ITU-R BT.601 weights.
5259pub fn render_gray8(page: &DjVuPage, opts: &RenderOptions) -> Result<GrayPixmap, RenderError> {
5260    Ok(render_pixmap(page, opts)?.to_gray8())
5261}
5262
5263/// Render all pages of a document in parallel using rayon.
5264///
5265/// Each page is rendered independently with its own [`RenderOptions`] computed
5266/// from the given `dpi`.  Results are returned in page order.
5267///
5268/// Requires the `parallel` feature flag.
5269#[cfg(feature = "parallel")]
5270pub fn render_pages_parallel(
5271    doc: &crate::djvu_document::DjVuDocument,
5272    dpi: u32,
5273) -> Vec<Result<Pixmap, RenderError>> {
5274    use rayon::prelude::*;
5275
5276    let count = doc.page_count();
5277    (0..count)
5278        .into_par_iter()
5279        .map(|i| {
5280            let page = doc.page(i)?;
5281            let native_dpi = page.dpi() as f32;
5282            let scale = dpi as f32 / native_dpi;
5283            let w = ((page.width() as f32 * scale).round() as u32).max(1);
5284            let h = ((page.height() as f32 * scale).round() as u32).max(1);
5285            let opts = RenderOptions {
5286                width: w,
5287                height: h,
5288                ..Default::default()
5289            };
5290            render_pixmap(page, &opts)
5291        })
5292        .collect()
5293}
5294
5295/// Coarse render: decode only the first BG44 chunk for a fast blurry preview.
5296///
5297/// Returns `Ok(None)` when the page has no BG44 chunks.
5298pub fn render_coarse(page: &DjVuPage, opts: &RenderOptions) -> Result<Option<Pixmap>, RenderError> {
5299    let w = opts.width;
5300    let h = opts.height;
5301
5302    check_output_pixels("render_coarse", page, None, w, h)?;
5303
5304    let bg_subsample = best_iw44_subsample(opts.decode_scale(page));
5305    let bg = decode_background_chunks(page, 1, bg_subsample)?;
5306    if !bg.is_some() {
5307        return Ok(None);
5308    }
5309
5310    let gamma_lut = build_gamma_lut(page.gamma());
5311    let mut pm = Pixmap::white(w, h);
5312
5313    for_each_bg_band(
5314        page,
5315        opts,
5316        &bg,
5317        None,
5318        0,
5319        None,
5320        None,
5321        None,
5322        &gamma_lut,
5323        (0, 0),
5324        (w, h),
5325        |ctx, oy0| composite_into(ctx, band_rows_mut(&mut pm.data, w, oy0, ctx.out_h)),
5326    )?;
5327
5328    Ok(Some(rotate_pixmap(
5329        pm,
5330        combine_rotations(page.rotation(), opts.rotation),
5331    )))
5332}
5333
5334/// Progressive render: decode BG44 chunks 1..=chunk_n and all other layers.
5335///
5336/// `chunk_n = 0` behaves like [`render_coarse`] (first chunk only).
5337/// Each additional chunk adds detail. The result after all chunks is
5338/// equivalent to [`render_pixmap`].
5339///
5340/// # Errors
5341///
5342/// Returns [`RenderError::ChunkOutOfRange`] if `chunk_n` exceeds the number
5343/// of available BG44 chunks.
5344pub fn render_progressive(
5345    page: &DjVuPage,
5346    opts: &RenderOptions,
5347    chunk_n: usize,
5348) -> Result<Pixmap, RenderError> {
5349    let w = opts.width;
5350    let h = opts.height;
5351
5352    check_output_pixels("render_progressive", page, None, w, h)?;
5353
5354    let n_bg44 = page.bg44_chunks().len();
5355    let max_chunk = n_bg44.saturating_sub(1);
5356
5357    if n_bg44 > 0 && chunk_n > max_chunk {
5358        return Err(RenderError::ChunkOutOfRange {
5359            chunk_n,
5360            max: max_chunk,
5361        });
5362    }
5363
5364    let gamma_lut = build_gamma_lut(page.gamma());
5365
5366    // Decode background up to chunk_n + 1 chunks; full foreground + bold dilation
5367    // via the shared decode_layers path so no logic can drift between this and the
5368    // full render.
5369    let bg_subsample = best_iw44_subsample(opts.decode_scale(page));
5370    let DecodedLayers {
5371        bg,
5372        fg_palette,
5373        mask,
5374        blit_map,
5375        fg44,
5376    } = decode_layers(page, opts, bg_subsample, chunk_n + 1)?;
5377
5378    let mut pm = Pixmap::white(w, h);
5379    for_each_bg_band(
5380        page,
5381        opts,
5382        &bg,
5383        mask.as_deref(),
5384        0,
5385        fg_palette.as_ref(),
5386        blit_map.as_deref().map(Vec::as_slice),
5387        fg44.as_deref(),
5388        &gamma_lut,
5389        (0, 0),
5390        (w, h),
5391        |ctx, oy0| composite_into(ctx, band_rows_mut(&mut pm.data, w, oy0, ctx.out_h)),
5392    )?;
5393
5394    // Shared Lanczos-3 post-pass; the native re-render decodes the same
5395    // `chunk_n` refinement level.
5396    let pm = apply_lanczos_postpass(pm, page, opts, (w, h), (w, h), |native_opts| {
5397        render_progressive(page, native_opts, chunk_n)
5398    })?;
5399
5400    Ok(rotate_pixmap(
5401        pm,
5402        combine_rotations(page.rotation(), opts.rotation),
5403    ))
5404}
5405
5406/// Number of progressive refinement frames a page yields: one per BG44 chunk,
5407/// or a single frame for bilevel/JB2-only pages with no BG44 data.
5408///
5409/// This is the seam that hides the `max(1, bg44_chunks().len())` convention from
5410/// callers, so progressive consumers never reach into [`DjVuPage::bg44_chunks`]
5411/// to size their own loops.
5412pub fn progressive_steps(page: &DjVuPage) -> usize {
5413    page.bg44_chunks().len().max(1)
5414}
5415
5416/// Render progressive frame `step` (`0..`[`progressive_steps`]).
5417///
5418/// Encapsulates the "no BG44 chunks ⇒ a single full [`render_pixmap`], otherwise
5419/// [`render_progressive`]`(step)`" decision that every progressive caller (the
5420/// `Page::render_scaled_progressive` collector and the async
5421/// `render_progressive_stream`) previously open-coded. `step` is interpreted as
5422/// the BG44 chunk index on multi-chunk pages.
5423pub fn render_progressive_step(
5424    page: &DjVuPage,
5425    opts: &RenderOptions,
5426    step: usize,
5427) -> Result<Pixmap, RenderError> {
5428    if page.bg44_chunks().is_empty() {
5429        render_pixmap(page, opts)
5430    } else {
5431        render_progressive(page, opts, step)
5432    }
5433}
5434
5435/// Stateful **streaming** progressive decoder (B5).
5436///
5437/// Where [`render_progressive_all`] needs every BG44 chunk up front and
5438/// [`render_progressive`] re-decodes chunks `1..=k` from scratch for frame `k`
5439/// (O(N²) over all frames), this holds the decode state across calls: the
5440/// foreground (mask / FG44 / palette) is decoded **once** and the background
5441/// accumulates in a single [`Iw44Image`]. Feed one BG44 refinement chunk at a
5442/// time — e.g. as it arrives over a network — with [`Self::push_bg44_chunk`] and
5443/// get the refined frame back, for O(N) total decode.
5444///
5445/// It serves the same case as `render_progressive_all`'s incremental fast path
5446/// (strict decode, `Bilinear` resampling, non-zero output size); the frames it
5447/// returns are byte-identical to that path. `Lanczos3` and `permissive` are not
5448/// supported here (Lanczos re-renders at native resolution per frame, leaving no
5449/// shared incremental state) — use [`render_progressive_all`] for those.
5450pub struct ProgressiveDecoder<'a> {
5451    page: &'a DjVuPage,
5452    opts: RenderOptions,
5453    w: u32,
5454    h: u32,
5455    gamma_lut: [u8; 256],
5456    bg_subsample: u32,
5457    fg_palette: Option<FgbzPalette>,
5458    mask: Option<Arc<crate::bitmap::Bitmap>>,
5459    blit_map: Option<Arc<Vec<i32>>>,
5460    fg44: Option<Arc<Pixmap>>,
5461    rotation: crate::info::Rotation,
5462    /// Shared so a very large page can hand bands of it to the compositor
5463    /// (#811); nobody else holds it between frames.
5464    img: Arc<Iw44Image>,
5465    chunks_fed: usize,
5466}
5467
5468impl<'a> ProgressiveDecoder<'a> {
5469    /// Build a streaming decoder for `page` at `opts`. Decodes the foreground
5470    /// once (including any `bold` dilation) so only the background refines per
5471    /// chunk.
5472    ///
5473    /// Errors: [`RenderError::InvalidDimensions`] if `opts.width`/`height` is 0;
5474    /// [`RenderError::Unsupported`] if `opts.resampling` is not `Bilinear` or
5475    /// `opts.permissive` is set (see the type docs); or a decode error from the
5476    /// foreground.
5477    pub fn new(page: &'a DjVuPage, opts: &RenderOptions) -> Result<Self, RenderError> {
5478        check_output_pixels(
5479            "render_progressive_decoder",
5480            page,
5481            None,
5482            opts.width,
5483            opts.height,
5484        )?;
5485        if opts.resampling != Resampling::Bilinear || opts.permissive {
5486            return Err(RenderError::UnsupportedOption(
5487                "ProgressiveDecoder supports only strict Bilinear rendering; \
5488                 use render_progressive_all for Lanczos3 / permissive",
5489            ));
5490        }
5491
5492        let gamma_lut = build_gamma_lut(page.gamma());
5493        let bg_subsample = best_iw44_subsample(opts.decode_scale(page));
5494        let ForegroundLayers {
5495            fg_palette,
5496            mask,
5497            blit_map,
5498            fg44,
5499        } = decode_foreground_strict(page)?;
5500        let mask = if opts.bold > 0 {
5501            mask.map(|m| Arc::new(Arc::unwrap_or_clone(m).dilate_n(opts.bold as u32)))
5502        } else {
5503            mask
5504        };
5505        let rotation = combine_rotations(page.rotation(), opts.rotation);
5506
5507        Ok(Self {
5508            page,
5509            opts: opts.clone(),
5510            w: opts.width,
5511            h: opts.height,
5512            gamma_lut,
5513            bg_subsample,
5514            fg_palette,
5515            mask,
5516            blit_map,
5517            fg44,
5518            rotation,
5519            img: Arc::new(Iw44Image::new()),
5520            chunks_fed: 0,
5521        })
5522    }
5523
5524    /// Feed the next BG44 refinement chunk and return the refined frame. Each
5525    /// call accumulates into the shared decoder, so the returned frame reflects
5526    /// every chunk fed so far. Byte-identical to the corresponding frame of
5527    /// [`render_progressive_all`].
5528    pub fn push_bg44_chunk(&mut self, chunk: &[u8]) -> Result<Pixmap, RenderError> {
5529        #[cfg(test)]
5530        count_bg44_chunk_decode();
5531        // Never shared between frames, so this is the in-place path.
5532        Arc::make_mut(&mut self.img)
5533            .decode_chunk(chunk)
5534            .map_err(RenderError::Iw44)?;
5535        self.chunks_fed += 1;
5536        let bg = Background::from_shared_iw44(&self.img, self.bg_subsample)?;
5537
5538        let mut pm = Pixmap::white(self.w, self.h);
5539        for_each_bg_band(
5540            self.page,
5541            &self.opts,
5542            &bg,
5543            self.mask.as_deref(),
5544            0,
5545            self.fg_palette.as_ref(),
5546            self.blit_map.as_deref().map(Vec::as_slice),
5547            self.fg44.as_deref(),
5548            &self.gamma_lut,
5549            (0, 0),
5550            (self.w, self.h),
5551            |ctx, oy0| composite_into(ctx, band_rows_mut(&mut pm.data, self.w, oy0, ctx.out_h)),
5552        )?;
5553        Ok(rotate_pixmap(pm, self.rotation))
5554    }
5555
5556    /// Number of chunks fed so far (= number of frames produced).
5557    pub fn frames_produced(&self) -> usize {
5558        self.chunks_fed
5559    }
5560}
5561
5562/// Eagerly render every progressive frame into a `Vec`, coarsest first.
5563///
5564/// The convenience form of [`render_progressive_step`] over the full
5565/// [`progressive_steps`] range; the last frame equals [`render_pixmap`].
5566/// Streaming consumers that want one frame at a time should drive a
5567/// [`ProgressiveDecoder`] (strict Bilinear) or [`render_progressive_step`].
5568pub fn render_progressive_all(
5569    page: &DjVuPage,
5570    opts: &RenderOptions,
5571) -> Result<Vec<Pixmap>, RenderError> {
5572    let steps = progressive_steps(page);
5573    let bg44_chunks = page.bg44_chunks();
5574
5575    // Incremental fast path (B5): the per-frame `render_progressive_step` decodes
5576    // BG44 chunks 1..=k from scratch for every frame k — O(N²) over all frames.
5577    // The foreground (mask/FG44/palette) is identical across frames and already
5578    // memoised; only the background refines. So decode the foreground once, feed
5579    // BG44 chunks into a single accumulating `Iw44Image` one per frame, and
5580    // snapshot each frame — O(N) total decode.
5581    //
5582    // Restricted to the case the fast path can serve byte-identically:
5583    //   * strict mode (permissive uses a different, error-tolerant FG decode),
5584    //   * Bilinear (Lanczos re-renders at native per frame via the post-pass —
5585    //     no shared incremental state to exploit),
5586    //   * a real multi-chunk BG44 page (otherwise there is nothing to amortise).
5587    // Everything else falls back to the simple per-frame loop below.
5588    let can_stream = steps > 1
5589        && bg44_chunks.len() == steps
5590        && !opts.permissive
5591        && opts.resampling == Resampling::Bilinear
5592        && opts.width != 0
5593        && opts.height != 0;
5594
5595    if can_stream {
5596        // Drive the streaming `ProgressiveDecoder`: it decodes the foreground once
5597        // and accumulates the background across chunks (O(N)), exactly the batch
5598        // incremental fast path this used to inline. Collecting every frame here
5599        // is byte-identical to feeding the chunks one at a time.
5600        let mut dec = ProgressiveDecoder::new(page, opts)?;
5601        let mut frames = Vec::with_capacity(steps);
5602        for chunk in bg44_chunks.iter().take(steps) {
5603            frames.push(dec.push_bg44_chunk(chunk)?);
5604        }
5605        return Ok(frames);
5606    }
5607
5608    let mut frames = Vec::with_capacity(steps);
5609    for step in 0..steps {
5610        frames.push(render_progressive_step(page, opts, step)?);
5611    }
5612    Ok(frames)
5613}
5614
5615// ── Tests ────────────────────────────────────────────────────────────────────
5616
5617#[cfg(test)]
5618mod tests {
5619    use super::*;
5620    use crate::djvu_document::DjVuDocument;
5621
5622    /// #815: a refused output pixmap surfaces as the render-output limit, not
5623    /// as a blank page.
5624    #[test]
5625    fn pixmap_too_large_maps_to_render_output_limit() {
5626        let e = RenderError::from(crate::pixmap::PixmapError::TooLarge {
5627            width: 10000,
5628            height: 10000,
5629            pixels: 100_000_000,
5630            max: Pixmap::MAX_PIXELS,
5631        });
5632        match e {
5633            RenderError::ResourceLimit(x) => {
5634                assert_eq!(
5635                    x.axis,
5636                    crate::resource_limits::ResourceLimitAxis::RenderOutputPixels
5637                );
5638                assert_eq!((x.found, x.limit), (100_000_000, Pixmap::MAX_PIXELS as u64));
5639                assert_eq!((x.width, x.height), (Some(10000), Some(10000)));
5640            }
5641            other => panic!("expected ResourceLimit, got {other:?}"),
5642        }
5643    }
5644
5645    #[test]
5646    fn pixmap_overflow_maps_to_invalid_dimensions() {
5647        let e = RenderError::from(crate::pixmap::PixmapError::Overflow {
5648            width: u32::MAX,
5649            height: u32::MAX,
5650        });
5651        assert!(matches!(
5652            e,
5653            RenderError::InvalidDimensions {
5654                width: u32::MAX,
5655                height: u32::MAX
5656            }
5657        ));
5658    }
5659
5660    fn assets_path() -> std::path::PathBuf {
5661        std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
5662            .join("references/djvujs/library/assets")
5663    }
5664
5665    /// Helper that returns an owned document so tests can borrow pages from it.
5666    fn load_doc(filename: &str) -> DjVuDocument {
5667        let data = std::fs::read(assets_path().join(filename))
5668            .unwrap_or_else(|_| panic!("{filename} must exist"));
5669        DjVuDocument::parse(&data).unwrap_or_else(|e| panic!("parse failed: {e}"))
5670    }
5671
5672    // ── Banded background (#811) ─────────────────────────────────────────────
5673
5674    /// Composite `page` at `opts` over `out` output rows starting at `offset`,
5675    /// through `for_each_bg_band` with the given background, into a flat
5676    /// buffer (`rows == false`) or through the row sink (`rows == true`).
5677    fn composite_with_bg(
5678        page: &DjVuPage,
5679        opts: &RenderOptions,
5680        bg: &Background,
5681        offset: (u32, u32),
5682        out: (u32, u32),
5683        rows: bool,
5684    ) -> Vec<u8> {
5685        let gamma_lut = build_gamma_lut(page.gamma());
5686        let DecodedLayers {
5687            bg: _,
5688            fg_palette,
5689            mask,
5690            blit_map,
5691            fg44,
5692        } = decode_layers(page, opts, 1, usize::MAX).unwrap();
5693        let stride = out.0 as usize * 4;
5694        let mut buf = vec![0u8; stride * out.1 as usize];
5695        for_each_bg_band(
5696            page,
5697            opts,
5698            bg,
5699            mask.as_deref(),
5700            0,
5701            fg_palette.as_ref(),
5702            blit_map.as_deref().map(Vec::as_slice),
5703            fg44.as_deref(),
5704            &gamma_lut,
5705            offset,
5706            out,
5707            |ctx, oy0| {
5708                if rows {
5709                    composite_rows(ctx, |y, row| {
5710                        let at = (y + oy0 as usize) * stride;
5711                        buf[at..at + stride].copy_from_slice(row);
5712                    })
5713                } else {
5714                    composite_into(ctx, band_rows_mut(&mut buf, out.0, oy0, ctx.out_h))
5715                }
5716            },
5717        )
5718        .unwrap();
5719        buf
5720    }
5721
5722    /// A background composited from bands of the wavelet image is
5723    /// byte-identical to one composited from the whole RGB pixmap: at 1:1, on
5724    /// an upscale, on a downscale, with a region offset, through the flat
5725    /// buffer and through the row sink, with bands far smaller than the
5726    /// production budget so every seam is exercised.
5727    #[test]
5728    fn banded_background_composites_like_the_whole_one() {
5729        // chicken: a small page, composited whole at every size. colorbook:
5730        // BG44 + JB2 mask, plane at page/3. history: plane at page/3 with a
5731        // ragged edge. carte: a wide page with a page/3 plane. The large pages
5732        // are composited as regions — the mapping is what matters, not the
5733        // area. (All four have colour backgrounds; banding is colour-only.)
5734        let subjects = [
5735            ("chicken.djvu", true),
5736            ("colorbook.djvu", false),
5737            ("history.djvu", false),
5738            ("carte.djvu", false),
5739        ];
5740        for (file, small) in subjects {
5741            let started = std::time::Instant::now();
5742            let doc = load_doc(file);
5743            let page = doc.page(0).unwrap();
5744            let img = page.decoded_bg44().expect("fixture has a BG44 background");
5745            assert!(
5746                img.rgb_band_rows().is_none(),
5747                "{file} is small: the production path must hold it whole"
5748            );
5749            let whole = Background::Whole(Arc::new(img.to_rgb_subsample(1).unwrap()));
5750            let (pw, ph) = (page.width() as u32, page.height() as u32);
5751            let sizes = [(pw, ph), (pw * 7 / 5, ph * 7 / 5), (pw * 5 / 7, ph * 5 / 7)];
5752            let band_sizes: &[u32] = if small { &[9, 37] } else { &[37, 300] };
5753            for (w, h) in sizes {
5754                let opts = RenderOptions {
5755                    width: w,
5756                    height: h,
5757                    ..Default::default()
5758                };
5759                // The whole output, or two regions: one off the top-left
5760                // corner and one at the bottom-right edge, with a ragged
5761                // height so the last band is a partial one.
5762                let cases: Vec<((u32, u32), (u32, u32))> = if small {
5763                    vec![((0, 0), (w, h)), ((13, 29), (w - 40, h - 61))]
5764                } else {
5765                    let (rw, rh) = (200, 333);
5766                    vec![((13, 29), (rw, rh)), ((w - rw, h - rh), (rw, rh))]
5767                };
5768                for (offset, out) in cases {
5769                    let expect = composite_with_bg(page, &opts, &whole, offset, out, false);
5770                    for &band_rows in band_sizes {
5771                        let banded = Background::Banded {
5772                            image: img.clone(),
5773                            band_rows,
5774                        };
5775                        for rows in [false, true] {
5776                            let got = composite_with_bg(page, &opts, &banded, offset, out, rows);
5777                            assert!(
5778                                got == expect,
5779                                "{file} at {w}x{h}, offset {offset:?}, out {out:?}, \
5780                                 band_rows {band_rows}, rows={rows}: banded composite differs"
5781                            );
5782                        }
5783                    }
5784                }
5785            }
5786            println!("{file}: checked in {:?}", started.elapsed());
5787        }
5788    }
5789
5790    /// `bg_rows_needed` returns the rows the samplers read, and they lie
5791    /// inside the plane; bands from `bg_band_out_rows` respect the budget.
5792    #[test]
5793    fn bg_band_planning_stays_inside_the_plane_and_the_budget() {
5794        let page = (2260u32, 3669u32);
5795        let plane = (754u32, 1223u32);
5796        for full in [(2260, 3669), (3164, 5137), (1614, 2621), (753, 1223)] {
5797            let (lo, hi) = bg_rows_needed(page, full, plane, 0..full.1);
5798            assert_eq!(lo, 0);
5799            assert!(
5800                hi <= plane.1,
5801                "full render at {full:?} reads {hi} > {} rows",
5802                plane.1
5803            );
5804            if full == page {
5805                assert_eq!(hi, plane.1, "a 1:1 render reads every plane row");
5806            }
5807            let mut oy = 0;
5808            while oy < full.1 {
5809                let rows = bg_band_out_rows(page, full, plane, oy, full.1 - oy, 64);
5810                let (lo, hi) = bg_rows_needed(page, full, plane, oy..oy + rows);
5811                assert!(
5812                    lo < hi && hi <= plane.1,
5813                    "{full:?} band at {oy}: {lo}..{hi}"
5814                );
5815                assert!(
5816                    hi - lo <= 64,
5817                    "{full:?} band at {oy}: {lo}..{hi} exceeds 64 rows"
5818                );
5819                oy += rows;
5820            }
5821        }
5822        assert_eq!(bg_rows_needed(page, page, plane, 5..5), (0, 0));
5823    }
5824
5825    // ── Compositor hot-path unit tests ───────────────────────────────────────
5826    //
5827    // The three `composite_rows_*_one` functions are the compositor's hot
5828    // paths. They were previously exercised only through full-page
5829    // `render_pixmap` / `render_into` against decoded fixtures, so a compositor
5830    // bug could not be isolated from a decode bug. These tests drive each path
5831    // directly off a synthetic `CompositeContext` (built from layers we control,
5832    // not decoded from a file), making the compositor an independently-tested
5833    // surface. Solid / block-uniform colours keep the assertions exact and free
5834    // of sampling-rounding brittleness.
5835
5836    fn identity_lut() -> [u8; 256] {
5837        core::array::from_fn(|i| i as u8)
5838    }
5839
5840    /// Build a `CompositeContext` from synthetic layers, mirroring the q24 /
5841    /// gamma wiring that [`CompositeContext::from_layers`] performs, but without
5842    /// needing a `DjVuPage`. Foreground palette / blit map are unused here.
5843    #[allow(clippy::too_many_arguments)]
5844    fn synth_ctx<'a>(
5845        opts: &'a RenderOptions,
5846        page_w: u32,
5847        page_h: u32,
5848        bg: Option<&'a Pixmap>,
5849        mask: Option<&'a crate::bitmap::Bitmap>,
5850        gamma_lut: &'a [u8; 256],
5851        out_w: u32,
5852        out_h: u32,
5853    ) -> CompositeContext<'a> {
5854        let (fg_x_q24, fg_y_q24) = fg_q24(None, page_w, page_h);
5855        let bg = bg.map(PlaneView::whole);
5856        let (bg_x_q24, bg_y_q24) = bg_q24(bg.map(|b| (b.width(), b.height())), page_w, page_h);
5857        CompositeContext {
5858            opts,
5859            page_w,
5860            page_h,
5861            bg,
5862            bg_x_q24,
5863            bg_y_q24,
5864            mask,
5865            mask_shift: 0,
5866            fg_palette: None,
5867            blit_map: None,
5868            fg44: None,
5869            fg_x_q24,
5870            fg_y_q24,
5871            gamma_lut,
5872            // Compute from the lut (mirroring CompositeContext::from_layers)
5873            // rather than hard-coding, so a future test passing a non-identity
5874            // lut is not silently routed down the identity fast path.
5875            gamma_is_identity: gamma_lut.iter().enumerate().all(|(i, &v)| v == i as u8),
5876            offset_x: 0,
5877            offset_y: 0,
5878            out_w,
5879            out_h,
5880        }
5881    }
5882
5883    #[test]
5884    fn composite_bilevel_one_maps_mask_to_black_and_white() {
5885        // Foreground bits → opaque black; background → opaque white.
5886        let opts = RenderOptions::default();
5887        let mut mask = crate::bitmap::Bitmap::new(8, 1);
5888        mask.set_black(0, 0);
5889        mask.set_black(3, 0);
5890        let lut = identity_lut();
5891        let ctx = synth_ctx(&opts, 8, 1, None, Some(&mask), &lut, 8, 1);
5892
5893        let mut row = vec![0u8; 8 * 4];
5894        composite_rows_bilevel_one(&ctx, 0, FRAC, FRAC, &mut row);
5895
5896        for x in 0..8usize {
5897            let p = &row[x * 4..x * 4 + 4];
5898            if x == 0 || x == 3 {
5899                assert_eq!(p, [0, 0, 0, 255], "foreground pixel at x={x}");
5900            } else {
5901                assert_eq!(p, [255, 255, 255, 255], "background pixel at x={x}");
5902            }
5903        }
5904    }
5905
5906    #[test]
5907    fn composite_bilinear_one_copies_background_1to1_unsubsampled() {
5908        // page_w == bg_w ⇒ bg_x_q24 == 1<<24, so this drives the A2 tight
5909        // mask-LUT-expand fast path (the common corpus case where the
5910        // background is at page resolution). Identity gamma + all-background
5911        // mask ⇒ the bg pixels must be reproduced exactly.
5912        let opts = RenderOptions::default();
5913        let bg = Pixmap::try_new(4, 2, 10, 20, 30, 255).expect("fits the pixmap limit");
5914        let mask = crate::bitmap::Bitmap::new(4, 2); // all background
5915        let lut = identity_lut();
5916        let ctx = synth_ctx(&opts, 4, 2, Some(&bg), Some(&mask), &lut, 4, 2);
5917
5918        let mut row = vec![0u8; 4 * 4];
5919        composite_rows_bilinear_one(&ctx, 0, FRAC, FRAC, &mut row, None, &mut Vec::new());
5920
5921        for x in 0..4usize {
5922            let p = &row[x * 4..x * 4 + 4];
5923            assert_eq!(&p[..3], &[10, 20, 30], "background colour at x={x}");
5924            assert_eq!(p[3], 255, "opaque at x={x}");
5925        }
5926    }
5927
5928    #[test]
5929    fn composite_bilinear_one_upsamples_subsampled_background() {
5930        // page_w (8) > bg_w (4) ⇒ bg_x_q24 != 1<<24, so the A2 tight path is
5931        // skipped and the real bilinear sampler (`bilinear_from_rows`) runs to
5932        // upscale the subsampled background. A solid bg interpolates to itself,
5933        // so every output pixel must equal the bg colour exactly — proving the
5934        // sampler addresses the bg correctly without corrupting it.
5935        let opts = RenderOptions::default();
5936        let bg = Pixmap::try_new(4, 2, 70, 90, 110, 255).expect("fits the pixmap limit"); // half page resolution
5937        let mask = crate::bitmap::Bitmap::new(8, 2); // page-res, all background
5938        let lut = identity_lut();
5939        let ctx = synth_ctx(&opts, 8, 2, Some(&bg), Some(&mask), &lut, 8, 2);
5940        // Sanity: this configuration must NOT take the 1:1 tight path.
5941        assert_ne!(
5942            ctx.bg_x_q24,
5943            1 << 24,
5944            "test must exercise the subsampled path"
5945        );
5946
5947        let mut row = vec![0u8; 8 * 4];
5948        composite_rows_bilinear_one(&ctx, 0, FRAC, FRAC, &mut row, None, &mut Vec::new());
5949
5950        for x in 0..8usize {
5951            let p = &row[x * 4..x * 4 + 4];
5952            assert_eq!(&p[..3], &[70, 90, 110], "upsampled bg colour at x={x}");
5953            assert_eq!(p[3], 255, "opaque at x={x}");
5954        }
5955    }
5956
5957    #[test]
5958    fn composite_area_avg_one_averages_uniform_background_on_downscale() {
5959        // 2× downscale of a solid background: every 2×2 source block averages to
5960        // the same colour, so the output cells equal that colour exactly.
5961        let opts = RenderOptions::default();
5962        let bg = Pixmap::try_new(4, 2, 40, 80, 120, 255).expect("fits the pixmap limit");
5963        let mask = crate::bitmap::Bitmap::new(4, 2); // all background
5964        let lut = identity_lut();
5965        let ctx = synth_ctx(&opts, 4, 2, Some(&bg), Some(&mask), &lut, 2, 1);
5966
5967        let fx_step = 2 * FRAC;
5968        let fy_step = 2 * FRAC;
5969        let bg_fx_step = ((fx_step as u64 * ctx.bg_x_q24) >> 24) as u32;
5970        let bg_fy_step = ((fy_step as u64 * ctx.bg_y_q24) >> 24) as u32;
5971        let xs = precompute_area_avg_x(&ctx, fx_step, bg_fx_step);
5972
5973        let mut row = vec![0u8; 2 * 4];
5974        composite_rows_area_avg_one(
5975            &ctx,
5976            0,
5977            fx_step,
5978            fy_step,
5979            bg_fx_step,
5980            bg_fy_step,
5981            &mut row,
5982            Some(&xs),
5983        );
5984
5985        for x in 0..2usize {
5986            let p = &row[x * 4..x * 4 + 4];
5987            assert_eq!(&p[..3], &[40, 80, 120], "averaged background at x={x}");
5988            assert_eq!(p[3], 255, "opaque at x={x}");
5989        }
5990    }
5991
5992    // ── TDD: failing tests written first ─────────────────────────────────────
5993
5994    /// Issue #199 regression: page-space FRACBITS coords must be scaled into
5995    /// FG44-space using the `fg_x_q24` / `fg_y_q24` ratios. With page_w=2260
5996    /// and fg_w=189 a Q24 ratio of `(189 << 24) / 2260` maps the rightmost
5997    /// column to `fg_w - 1` instead of clamping every column past x=189.
5998    #[test]
5999    fn fg_q24_maps_endpoints_into_fg_space() {
6000        let fg = Pixmap::white(189, 306);
6001        let (qx, qy) = fg_q24(Some(&fg), 2260, 3669);
6002        assert!(qx > 0 && qy > 0);
6003        let frac = 1u64 << FRACBITS;
6004        let last_x = 2259u64 * frac;
6005        let fg_fx = (last_x * qx) >> 24;
6006        let fg_px = fg_fx >> FRACBITS;
6007        assert_eq!(fg_px, (fg.width as u64) - 1);
6008        let last_y = 3668u64 * frac;
6009        let fg_fy = (last_y * qy) >> 24;
6010        let fg_py = fg_fy >> FRACBITS;
6011        assert_eq!(fg_py, (fg.height as u64) - 1);
6012    }
6013
6014    #[test]
6015    fn fg_q24_returns_zero_when_fg_is_none() {
6016        assert_eq!(fg_q24(None, 100, 100), (0, 0));
6017    }
6018
6019    /// Issue #199 second-half regression: BG plane is often stored at a
6020    /// non-power-of-2 fraction of the page (1/3 is common for 400dpi colour
6021    /// scans). Without `bg_x_q24` / `bg_y_q24` page→bg-space scaling the BG
6022    /// sampler clamped most of the page to the rightmost BG column.
6023    #[test]
6024    fn bg_q24_maps_non_pow2_subsample() {
6025        // Page 2260×3669 with BG plane 754×1223 (DjVu's padded 1/3-page layout).
6026        let (qx, qy) = bg_q24(Some((754, 1223)), 2260, 3669);
6027        assert_eq!(qx, (1u64 << 24) / 3);
6028        assert_eq!(qy, (1u64 << 24) / 3);
6029
6030        let last_x = 2259u32 * FRAC;
6031        let bg_px = (map_plane_center_frac(last_x, qx) as u64) >> FRACBITS;
6032        assert!(bg_px < 754);
6033        let last_y = 3668u32 * FRAC;
6034        let bg_py = (map_plane_center_frac(last_y, qy) as u64) >> FRACBITS;
6035        assert!(bg_py < 1223);
6036    }
6037
6038    #[test]
6039    fn bg_q24_returns_zero_when_bg_is_none() {
6040        assert_eq!(bg_q24(None, 100, 100), (0, 0));
6041    }
6042
6043    #[test]
6044    fn plane_q24_some_branch_with_zero_dimension_plane() {
6045        // Lines 508-512: plane_q24 Some(p) arm, reached via fg_q24 when fg has
6046        // zero width (so fg_q24's inner guard p.width > 0 fails, falling through
6047        // to plane_q24). With page_w > 0 && page_h > 0, plane_q24 takes its
6048        // Some arm and returns (0/page_w, 0/page_h) = (0, 0).
6049        let zero_width_fg = Pixmap::try_new(0, 10, 0, 0, 0, 0).expect("fits the pixmap limit");
6050        let (qx, qy) = fg_q24(Some(&zero_width_fg), 100, 100);
6051        assert_eq!(qx, 0); // (0 << 24) / 100 = 0
6052        assert_eq!(qy, (10u64 << 24) / 100); // height-based ratio
6053    }
6054
6055    #[test]
6056    fn fg_q24_uses_integer_horizontal_cell_pitch() {
6057        let fg = Pixmap::white(189, 306);
6058        let (qx, qy) = fg_q24(Some(&fg), 2260, 3669);
6059        assert_eq!(qx, (1u64 << 24) / 12);
6060        assert_eq!(qy, ((306u64) << 24) / 3669);
6061    }
6062
6063    #[test]
6064    fn bg_q24_uses_integer_cell_pitch_for_padded_edges() {
6065        let (qx, qy) = bg_q24(Some((754, 1223)), 2260, 3669);
6066        assert_eq!(qx, (1u64 << 24) / 3);
6067        assert_eq!(qy, (1u64 << 24) / 3);
6068    }
6069
6070    #[test]
6071    fn map_plane_center_frac_aligns_pixel_centers() {
6072        // Destination page is twice the source plane size.  Page pixel x=1 has
6073        // centre 1.5; mapped to source centre space that is 1.5 * 0.5 - 0.5 = 0.25.
6074        let q24 = (1u64 << 24) / 2;
6075        assert_eq!(map_plane_center_frac(0, q24), 0);
6076        assert_eq!(map_plane_center_frac(FRAC, q24), FRAC / 4);
6077    }
6078
6079    #[test]
6080    fn sample_bilinear_rounds_to_nearest() {
6081        let mut pm = Pixmap::try_new(2, 2, 0, 0, 0, 255).expect("fits the pixmap limit");
6082        pm.set_rgb(1, 1, 255, 255, 255);
6083
6084        // At the exact centre, bilinear interpolation is 63.75, which should
6085        // round to 64 instead of truncating to 63.
6086        assert_eq!(sample_bilinear(&pm, FRAC / 2, FRAC / 2), (64, 64, 64));
6087    }
6088
6089    #[test]
6090    fn sample_nearest_rounds_to_nearest_pixel() {
6091        let mut pm = Pixmap::try_new(2, 1, 10, 20, 30, 255).expect("fits the pixmap limit");
6092        pm.set_rgb(1, 0, 200, 210, 220);
6093
6094        assert_eq!(sample_nearest(&pm, FRAC / 2 - 1, 0), (10, 20, 30));
6095        assert_eq!(sample_nearest(&pm, FRAC / 2, 0), (200, 210, 220));
6096    }
6097
6098    #[test]
6099    fn mask_box_coverage_values() {
6100        use crate::bitmap::Bitmap;
6101        // 4×1 mask: bits [1,0,1,1] → 3 out of 4 → coverage = (3*255+2)/4 = 191
6102        let mut bm = Bitmap::new(4, 1);
6103        bm.set(0, 0, true);
6104        bm.set(2, 0, true);
6105        bm.set(3, 0, true);
6106        let step = 4 * FRAC;
6107        assert_eq!(mask_box_coverage(&bm, 0, 0, step, FRAC), 191);
6108        // all foreground → 255
6109        let mut bm_full = Bitmap::new(2, 2);
6110        bm_full.set(0, 0, true);
6111        bm_full.set(1, 0, true);
6112        bm_full.set(0, 1, true);
6113        bm_full.set(1, 1, true);
6114        assert_eq!(mask_box_coverage(&bm_full, 0, 0, 2 * FRAC, 2 * FRAC), 255);
6115        // all background → 0
6116        let bm_empty = Bitmap::new(2, 2);
6117        assert_eq!(mask_box_coverage(&bm_empty, 0, 0, 2 * FRAC, 2 * FRAC), 0);
6118    }
6119
6120    // ── D_AA_ZOOM: mask_aa (bilinear coverage AA at upscale) ─────────────────
6121
6122    #[test]
6123    fn mask_bilinear_coverage_values() {
6124        use crate::bitmap::Bitmap;
6125        // 4×1 mask: bit 0 set, rest clear.
6126        let mut bm = Bitmap::new(4, 1);
6127        bm.set(0, 0, true);
6128        // Exactly on a pixel centre (tx=ty=0): pure sample of bit 0 → 255.
6129        assert_eq!(mask_bilinear_coverage(&bm, 0, 0), 255);
6130        // Halfway between bit 0 (255) and bit 1 (0): (255+0)/2 rounded → 128.
6131        assert_eq!(mask_bilinear_coverage(&bm, 8, 0), 128);
6132        // Halfway between bit 1 and bit 2, both clear → 0.
6133        assert_eq!(mask_bilinear_coverage(&bm, 24, 0), 0);
6134        // All-foreground mask → 255 everywhere, no interpolation artifacts.
6135        let mut bm_full = Bitmap::new(2, 2);
6136        bm_full.set(0, 0, true);
6137        bm_full.set(1, 0, true);
6138        bm_full.set(0, 1, true);
6139        bm_full.set(1, 1, true);
6140        assert_eq!(mask_bilinear_coverage(&bm_full, 4, 4), 255);
6141        // All-background mask → 0 everywhere.
6142        let bm_empty = Bitmap::new(2, 2);
6143        assert_eq!(mask_bilinear_coverage(&bm_empty, 4, 4), 0);
6144    }
6145
6146    /// `composite_rows_bilevel_one` at 2× upscale with `mask_aa: false` (the
6147    /// default) must reproduce the exact nearest-bit pattern — hard requirement
6148    /// that the opt-in flag changes nothing unless explicitly enabled.
6149    #[test]
6150    fn composite_bilevel_one_mask_aa_disabled_matches_nearest_at_upscale() {
6151        let opts = RenderOptions::default();
6152        assert!(!opts.mask_aa);
6153        let mut mask = crate::bitmap::Bitmap::new(4, 1);
6154        mask.set(0, 0, true); // only x=0 is foreground
6155        let lut = identity_lut();
6156        let ctx = synth_ctx(&opts, 4, 1, None, Some(&mask), &lut, 8, 2);
6157
6158        let fx_step = FRAC / 2; // 2× upscale
6159        let fy_step = FRAC / 2;
6160        let mut row = vec![0u8; 8 * 4];
6161        composite_rows_bilevel_one(&ctx, 0, fx_step, fy_step, &mut row);
6162
6163        // Nearest-neighbour duplication of source pixels [0,0,1,1,2,2,3,3];
6164        // only source pixel 0 is foreground (black), rest background (white).
6165        let expected_black = [true, true, false, false, false, false, false, false];
6166        for (x, &is_black) in expected_black.iter().enumerate() {
6167            let p = &row[x * 4..x * 4 + 4];
6168            if is_black {
6169                assert_eq!(p, [0, 0, 0, 255], "expected black at x={x}");
6170            } else {
6171                assert_eq!(p, [255, 255, 255, 255], "expected white at x={x}");
6172            }
6173        }
6174    }
6175
6176    /// With `mask_aa: true` at the same 2× upscale, the pixel straddling the
6177    /// mask edge (x=1, halfway between the set bit 0 and clear bit 1) must come
6178    /// out as an intermediate gray — proof the bilinear coverage path actually
6179    /// smooths the edge instead of just reproducing nearest-neighbour.
6180    #[test]
6181    fn composite_bilevel_one_mask_aa_enabled_smooths_edge_at_upscale() {
6182        let opts = RenderOptions {
6183            mask_aa: true,
6184            ..Default::default()
6185        };
6186        let mut mask = crate::bitmap::Bitmap::new(4, 1);
6187        mask.set(0, 0, true);
6188        let lut = identity_lut();
6189        let ctx = synth_ctx(&opts, 4, 1, None, Some(&mask), &lut, 8, 2);
6190
6191        let fx_step = FRAC / 2;
6192        let fy_step = FRAC / 2;
6193        let mut row = vec![0u8; 8 * 4];
6194        composite_rows_bilevel_one(&ctx, 0, fx_step, fy_step, &mut row);
6195
6196        // x=0 lands exactly on the set bit → still pure black.
6197        assert_eq!(&row[0..4], [0, 0, 0, 255], "x=0 exact sample stays black");
6198        // x=1 straddles bit 0 (fg) / bit 1 (bg) at tx=8/16 → coverage 128 → gray 127.
6199        assert_eq!(&row[4..8], [127, 127, 127, 255], "x=1 is a blended gray");
6200        // x=2 onward land entirely within the background region → white.
6201        for x in 2..8usize {
6202            assert_eq!(
6203                &row[x * 4..x * 4 + 4],
6204                [255, 255, 255, 255],
6205                "x={x} stays white"
6206            );
6207        }
6208    }
6209
6210    /// `mask_aa` must be a no-op on the exact 1:1 fast path (native scale) —
6211    /// the flag only ever matters past the early return for genuine upscale.
6212    #[test]
6213    fn composite_bilevel_one_mask_aa_is_noop_at_native_scale() {
6214        let mut mask = crate::bitmap::Bitmap::new(4, 1);
6215        mask.set(0, 0, true);
6216        let lut = identity_lut();
6217
6218        let opts_off = RenderOptions::default();
6219        let ctx_off = synth_ctx(&opts_off, 4, 1, None, Some(&mask), &lut, 4, 1);
6220        let mut row_off = vec![0u8; 4 * 4];
6221        composite_rows_bilevel_one(&ctx_off, 0, FRAC, FRAC, &mut row_off);
6222
6223        let opts_on = RenderOptions {
6224            mask_aa: true,
6225            ..Default::default()
6226        };
6227        let ctx_on = synth_ctx(&opts_on, 4, 1, None, Some(&mask), &lut, 4, 1);
6228        let mut row_on = vec![0u8; 4 * 4];
6229        composite_rows_bilevel_one(&ctx_on, 0, FRAC, FRAC, &mut row_on);
6230
6231        assert_eq!(row_off, row_on, "mask_aa must not affect native 1:1 scale");
6232    }
6233
6234    /// `mask_aa` must be a no-op on downscale — the bilinear-upscale branch is
6235    /// only reachable when `!downscale`, so a `mask_aa: true` downscale render
6236    /// must still take the existing `mask_box_coverage` path unchanged.
6237    #[test]
6238    fn composite_bilevel_one_mask_aa_is_noop_at_downscale() {
6239        let mut mask = crate::bitmap::Bitmap::new(4, 1);
6240        mask.set(0, 0, true);
6241        mask.set(2, 0, true);
6242        mask.set(3, 0, true);
6243        let lut = identity_lut();
6244
6245        let fx_step = 4 * FRAC; // 4× downscale
6246        let fy_step = FRAC;
6247
6248        let opts_off = RenderOptions::default();
6249        let ctx_off = synth_ctx(&opts_off, 4, 1, None, Some(&mask), &lut, 1, 1);
6250        let mut row_off = vec![0u8; 4];
6251        composite_rows_bilevel_one(&ctx_off, 0, fx_step, fy_step, &mut row_off);
6252
6253        let opts_on = RenderOptions {
6254            mask_aa: true,
6255            ..Default::default()
6256        };
6257        let ctx_on = synth_ctx(&opts_on, 4, 1, None, Some(&mask), &lut, 1, 1);
6258        let mut row_on = vec![0u8; 4];
6259        composite_rows_bilevel_one(&ctx_on, 0, fx_step, fy_step, &mut row_on);
6260
6261        assert_eq!(row_off, row_on, "mask_aa must not affect downscale");
6262    }
6263
6264    /// `composite_rows_bilinear_one` (colour path) at 2× upscale with
6265    /// `mask_aa: false` must reproduce the exact binary nearest-bit coverage —
6266    /// same hard byte-identical requirement as the bilevel path, for the
6267    /// colour+mask compositor.
6268    #[test]
6269    fn composite_bilinear_one_mask_aa_disabled_matches_nearest_at_upscale() {
6270        let opts = RenderOptions::default();
6271        let bg = Pixmap::try_new(8, 1, 200, 150, 100, 255).expect("fits the pixmap limit");
6272        let mut mask = crate::bitmap::Bitmap::new(8, 1);
6273        mask.set(0, 0, true); // only x=0 is foreground
6274        let lut = identity_lut();
6275        let ctx = synth_ctx(&opts, 8, 1, Some(&bg), Some(&mask), &lut, 4, 1);
6276
6277        let fx_step = FRAC / 2; // 2× upscale
6278        let fy_step = FRAC / 2;
6279        let mut row = vec![0u8; 4 * 4];
6280        composite_rows_bilinear_one(&ctx, 0, fx_step, fy_step, &mut row, None, &mut Vec::new());
6281
6282        // Nearest px indices for ox=0..4 are [0,0,1,1]; only px 0 is foreground,
6283        // rendered black (no FG44 layer ⇒ (0,0,0)); px 1 is background colour.
6284        assert_eq!(&row[0..4], [0, 0, 0, 255], "ox=0 nearest foreground");
6285        assert_eq!(&row[4..8], [0, 0, 0, 255], "ox=1 nearest foreground");
6286        assert_eq!(&row[8..12], [200, 150, 100, 255], "ox=2 background");
6287        assert_eq!(&row[12..16], [200, 150, 100, 255], "ox=3 background");
6288    }
6289
6290    /// With `mask_aa: true` the pixel straddling the mask edge blends the
6291    /// (black) foreground colour with the background colour proportionally to
6292    /// the interpolated coverage, instead of snapping to one or the other.
6293    #[test]
6294    fn composite_bilinear_one_mask_aa_enabled_blends_fg_bg_at_upscale() {
6295        let opts = RenderOptions {
6296            mask_aa: true,
6297            ..Default::default()
6298        };
6299        let bg = Pixmap::try_new(8, 1, 200, 150, 100, 255).expect("fits the pixmap limit");
6300        let mut mask = crate::bitmap::Bitmap::new(8, 1);
6301        mask.set(0, 0, true);
6302        let lut = identity_lut();
6303        let ctx = synth_ctx(&opts, 8, 1, Some(&bg), Some(&mask), &lut, 4, 1);
6304
6305        let fx_step = FRAC / 2;
6306        let fy_step = FRAC / 2;
6307        let mut row = vec![0u8; 4 * 4];
6308        composite_rows_bilinear_one(&ctx, 0, fx_step, fy_step, &mut row, None, &mut Vec::new());
6309
6310        // ox=0 lands exactly on the set bit → still pure (black) foreground.
6311        assert_eq!(
6312            &row[0..4],
6313            [0, 0, 0, 255],
6314            "ox=0 exact sample stays foreground"
6315        );
6316        // ox=1 straddles the edge at coverage 128 → blend(0, 200/150/100, 128).
6317        assert_eq!(
6318            &row[4..8],
6319            [100, 75, 50, 255],
6320            "ox=1 is a fg/bg blend, not a hard snap"
6321        );
6322        // ox=2, ox=3 are entirely background.
6323        assert_eq!(&row[8..12], [200, 150, 100, 255], "ox=2 background");
6324        assert_eq!(&row[12..16], [200, 150, 100, 255], "ox=3 background");
6325    }
6326
6327    /// Subtle no-op case: an exact page-level 1:1 render (`fx_step == fy_step
6328    /// == FRAC`) whose *background* plane is internally subsampled still falls
6329    /// through to the general B-series loop (the "extra-tight" 1:1 fast path
6330    /// requires `bg_x_q24 == 1<<24`, which fails here) — but `mask_aa` must
6331    /// still be a no-op there because there is no genuine axis upscale.
6332    #[test]
6333    fn composite_bilinear_one_mask_aa_is_noop_when_bg_subsampled_at_native_scale() {
6334        let bg = Pixmap::try_new(4, 1, 200, 150, 100, 255).expect("fits the pixmap limit"); // subsampled: page_w=8, bg_w=4
6335        let mut mask = crate::bitmap::Bitmap::new(8, 1);
6336        mask.set(0, 0, true);
6337        let lut = identity_lut();
6338
6339        let opts_off = RenderOptions::default();
6340        let ctx_off = synth_ctx(&opts_off, 8, 1, Some(&bg), Some(&mask), &lut, 8, 1);
6341        assert_ne!(
6342            ctx_off.bg_x_q24,
6343            1 << 24,
6344            "test must exercise subsampled bg"
6345        );
6346        let mut row_off = vec![0u8; 8 * 4];
6347        composite_rows_bilinear_one(&ctx_off, 0, FRAC, FRAC, &mut row_off, None, &mut Vec::new());
6348
6349        let opts_on = RenderOptions {
6350            mask_aa: true,
6351            ..Default::default()
6352        };
6353        let ctx_on = synth_ctx(&opts_on, 8, 1, Some(&bg), Some(&mask), &lut, 8, 1);
6354        let mut row_on = vec![0u8; 8 * 4];
6355        composite_rows_bilinear_one(&ctx_on, 0, FRAC, FRAC, &mut row_on, None, &mut Vec::new());
6356
6357        assert_eq!(
6358            row_off, row_on,
6359            "mask_aa must not affect native 1:1 scale even with a subsampled bg plane"
6360        );
6361    }
6362
6363    /// The precomputed `BilinearX` column table must be byte-identical to the
6364    /// in-loop Q48 fallback on every row — 2× upscale, non-zero `offset_x`,
6365    /// subsampled non-uniform bg so any x0/x1/tx mismatch shows up in bytes.
6366    #[test]
6367    fn composite_bilinear_one_column_table_matches_fallback() {
6368        let opts = RenderOptions::default();
6369        let mut bg = Pixmap::try_new(3, 2, 0, 0, 0, 255).expect("fits the pixmap limit"); // subsampled: page_w=8, bg_w=3
6370        for (i, px) in bg.data.as_chunks_mut::<4>().0.iter_mut().enumerate() {
6371            px[0] = (i * 40) as u8;
6372            px[1] = (i * 25 + 7) as u8;
6373            px[2] = (255 - i * 30) as u8;
6374        }
6375        let mut mask = crate::bitmap::Bitmap::new(8, 2);
6376        mask.set(2, 0, true); // one fg bit so the partial/fg branches run too
6377        let lut = identity_lut();
6378
6379        let mut ctx = synth_ctx(&opts, 8, 2, Some(&bg), Some(&mask), &lut, 16, 4);
6380        ctx.offset_x = 3;
6381
6382        let fx_step = FRAC / 2; // 2× upscale
6383        let fy_step = FRAC / 2;
6384        let table = precompute_bilinear_x(&ctx, fx_step).expect("bg present");
6385
6386        for oy in 0..4 {
6387            let mut row_table = vec![0u8; 16 * 4];
6388            let mut row_fallback = vec![0u8; 16 * 4];
6389            composite_rows_bilinear_one(
6390                &ctx,
6391                oy,
6392                fx_step,
6393                fy_step,
6394                &mut row_table,
6395                Some(&table),
6396                &mut Vec::new(),
6397            );
6398            composite_rows_bilinear_one(
6399                &ctx,
6400                oy,
6401                fx_step,
6402                fy_step,
6403                &mut row_fallback,
6404                None,
6405                &mut Vec::new(),
6406            );
6407            assert_eq!(
6408                row_table, row_fallback,
6409                "table and fallback sampling must agree at oy={oy}"
6410            );
6411        }
6412    }
6413
6414    /// Integration-level: `render_pixmap` at native scale on a real bilevel
6415    /// document must be byte-identical whether `mask_aa` is on or off — the
6416    /// no-op-at-scale-≤1 guarantee holding end-to-end, not just at the
6417    /// synthetic-context unit level.
6418    #[test]
6419    fn render_pixmap_mask_aa_is_noop_at_native_scale_real_doc() {
6420        let doc = load_doc("boy_jb2.djvu");
6421        let page = doc.page(0).unwrap();
6422        let (w, h) = (page.width() as u32, page.height() as u32);
6423
6424        let opts_off = RenderOptions {
6425            width: w,
6426            height: h,
6427            ..Default::default()
6428        };
6429        let opts_on = RenderOptions {
6430            width: w,
6431            height: h,
6432            mask_aa: true,
6433            ..Default::default()
6434        };
6435        let pm_off = render_pixmap(page, &opts_off).expect("render should succeed");
6436        let pm_on = render_pixmap(page, &opts_on).expect("render should succeed");
6437        assert_eq!(
6438            pm_off.data, pm_on.data,
6439            "mask_aa must be a no-op at native scale"
6440        );
6441    }
6442
6443    /// Integration-level: `render_pixmap` at downscale on a real bilevel
6444    /// document must also be byte-identical between `mask_aa` on/off.
6445    #[test]
6446    fn render_pixmap_mask_aa_is_noop_at_downscale_real_doc() {
6447        let doc = load_doc("boy_jb2.djvu");
6448        let page = doc.page(0).unwrap();
6449        let (w, h) = ((page.width() as u32) / 2, (page.height() as u32) / 2);
6450
6451        let opts_off = RenderOptions {
6452            width: w,
6453            height: h,
6454            ..Default::default()
6455        };
6456        let opts_on = RenderOptions {
6457            width: w,
6458            height: h,
6459            mask_aa: true,
6460            ..Default::default()
6461        };
6462        let pm_off = render_pixmap(page, &opts_off).expect("render should succeed");
6463        let pm_on = render_pixmap(page, &opts_on).expect("render should succeed");
6464        assert_eq!(
6465            pm_off.data, pm_on.data,
6466            "mask_aa must be a no-op at downscale"
6467        );
6468    }
6469
6470    /// Integration-level: at genuine 2× and 4× upscale on a real bilevel
6471    /// document, `mask_aa: true` must actually change the output (introduce
6472    /// intermediate gray values along glyph edges) — proving the flag is wired
6473    /// end-to-end, not just correct in isolated unit tests.
6474    #[test]
6475    fn render_pixmap_mask_aa_smooths_edges_at_zoom_real_doc() {
6476        let doc = load_doc("boy_jb2.djvu");
6477        let page = doc.page(0).unwrap();
6478        let (pw, ph) = (page.width() as u32, page.height() as u32);
6479
6480        for &zoom in &[2u32, 4u32] {
6481            let opts_off = RenderOptions {
6482                width: pw * zoom,
6483                height: ph * zoom,
6484                ..Default::default()
6485            };
6486            let opts_on = RenderOptions {
6487                width: pw * zoom,
6488                height: ph * zoom,
6489                mask_aa: true,
6490                ..Default::default()
6491            };
6492            let pm_off = render_pixmap(page, &opts_off).expect("nearest render should succeed");
6493            let pm_on = render_pixmap(page, &opts_on).expect("AA render should succeed");
6494            assert_eq!(pm_off.width, pm_on.width);
6495            assert_eq!(pm_off.height, pm_on.height);
6496
6497            assert_ne!(
6498                pm_off.data, pm_on.data,
6499                "mask_aa=true must change output at {zoom}× zoom"
6500            );
6501            let has_intermediate_gray = pm_on
6502                .data
6503                .as_chunks::<4>()
6504                .0
6505                .iter()
6506                .any(|px| px[0] == px[1] && px[1] == px[2] && px[0] != 0 && px[0] != 255);
6507            assert!(
6508                has_intermediate_gray,
6509                "mask_aa=true should introduce intermediate gray values at {zoom}× zoom"
6510            );
6511        }
6512    }
6513
6514    /// RenderOptions default values.
6515    #[test]
6516    fn render_options_default() {
6517        let opts = RenderOptions::default();
6518        assert_eq!(opts.width, 0);
6519        assert_eq!(opts.height, 0);
6520        assert_eq!(opts.bold, 0);
6521        assert!(!opts.aa);
6522        assert_eq!(opts.resampling, Resampling::Bilinear);
6523        assert!(!opts.mask_aa, "mask_aa must default to false (opt-in)");
6524    }
6525
6526    /// RenderOptions can be constructed with explicit fields.
6527    #[test]
6528    fn render_options_construction() {
6529        let opts = RenderOptions {
6530            width: 400,
6531            height: 300,
6532            bold: 1,
6533            aa: true,
6534            rotation: UserRotation::Cw90,
6535            ..Default::default()
6536        };
6537        assert_eq!(opts.width, 400);
6538        assert_eq!(opts.height, 300);
6539        assert_eq!(opts.bold, 1);
6540        assert!(opts.aa);
6541        assert_eq!(opts.rotation, UserRotation::Cw90);
6542    }
6543
6544    /// The incremental `render_progressive_all` fast path (B5) must be
6545    /// byte-identical to the per-frame `render_progressive_step` loop it
6546    /// replaces, on a real multi-BG44-chunk page.
6547    #[test]
6548    fn render_progressive_all_matches_per_frame() {
6549        let doc = load_doc("chicken.djvu");
6550        let page = doc.page(0).unwrap();
6551        // chicken.djvu has 3 BG44 chunks → 3 progressive frames, exercising the
6552        // incremental streaming path (steps > 1, Bilinear, strict).
6553        assert!(
6554            page.bg44_chunks().len() >= 2,
6555            "need a multi-chunk BG44 page"
6556        );
6557
6558        let opts = RenderOptions {
6559            width: page.width() as u32,
6560            height: page.height() as u32,
6561            resampling: Resampling::Bilinear,
6562            ..Default::default()
6563        };
6564
6565        let all = render_progressive_all(page, &opts).expect("progressive_all");
6566        let steps = progressive_steps(page);
6567        assert_eq!(all.len(), steps);
6568        for (step, frame) in all.iter().enumerate() {
6569            let per_frame = render_progressive_step(page, &opts, step).expect("progressive_step");
6570            assert_eq!(
6571                (frame.width, frame.height),
6572                (per_frame.width, per_frame.height),
6573                "frame {step} dimensions differ"
6574            );
6575            assert!(
6576                frame.data == per_frame.data,
6577                "frame {step} pixels differ between incremental and per-frame paths"
6578            );
6579        }
6580    }
6581
6582    #[test]
6583    fn progressive_decoder_streams_frames_matching_batch() {
6584        // The streaming ProgressiveDecoder, fed one BG44 chunk at a time, must
6585        // reproduce render_progressive_all's frames byte-for-byte.
6586        let doc = load_doc("chicken.djvu");
6587        let page = doc.page(0).unwrap();
6588        let chunks = page.bg44_chunks();
6589        assert!(chunks.len() >= 2, "need a multi-chunk BG44 page");
6590
6591        let opts = RenderOptions {
6592            width: page.width() as u32,
6593            height: page.height() as u32,
6594            resampling: Resampling::Bilinear,
6595            ..Default::default()
6596        };
6597
6598        let batch = render_progressive_all(page, &opts).expect("progressive_all");
6599
6600        let mut dec = ProgressiveDecoder::new(page, &opts).expect("decoder");
6601        let steps = progressive_steps(page);
6602        for (i, chunk) in chunks.iter().take(steps).enumerate() {
6603            let frame = dec.push_bg44_chunk(chunk).expect("push");
6604            assert_eq!(dec.frames_produced(), i + 1);
6605            assert_eq!(
6606                (frame.width, frame.height),
6607                (batch[i].width, batch[i].height),
6608                "streamed frame {i} dimensions differ"
6609            );
6610            assert!(
6611                frame.data == batch[i].data,
6612                "streamed frame {i} pixels differ from batch progressive_all"
6613            );
6614        }
6615    }
6616
6617    /// The streaming `ProgressiveDecoder`, fed one BG44 chunk at a time, must
6618    /// reproduce `render_progressive_step`'s frames byte-for-byte — the
6619    /// byte-identical requirement checked directly against the per-frame API
6620    /// (not only via the `render_progressive_all` batch path already covered
6621    /// above), on both the small 3-chunk `chicken.djvu` and the larger
6622    /// 4-chunk `colorbook.djvu` fixtures.
6623    fn assert_progressive_decoder_matches_step(filename: &str) {
6624        let doc = load_doc(filename);
6625        let page = doc.page(0).unwrap();
6626        let chunks = page.bg44_chunks();
6627        assert!(
6628            chunks.len() >= 2,
6629            "{filename}: need a multi-chunk BG44 page"
6630        );
6631
6632        let opts = RenderOptions {
6633            width: page.width() as u32,
6634            height: page.height() as u32,
6635            resampling: Resampling::Bilinear,
6636            ..Default::default()
6637        };
6638
6639        let mut dec = ProgressiveDecoder::new(page, &opts).expect("decoder");
6640        let steps = progressive_steps(page);
6641        for (i, chunk) in chunks.iter().take(steps).enumerate() {
6642            let streamed = dec.push_bg44_chunk(chunk).expect("push");
6643            let stepped = render_progressive_step(page, &opts, i).expect("progressive_step");
6644            assert_eq!(
6645                (streamed.width, streamed.height),
6646                (stepped.width, stepped.height),
6647                "{filename} frame {i} dimensions differ"
6648            );
6649            assert!(
6650                streamed.data == stepped.data,
6651                "{filename}: streamed frame {i} pixels differ from render_progressive_step"
6652            );
6653        }
6654    }
6655
6656    #[test]
6657    fn progressive_decoder_matches_render_progressive_step_chicken() {
6658        assert_progressive_decoder_matches_step("chicken.djvu");
6659    }
6660
6661    #[test]
6662    fn progressive_decoder_matches_render_progressive_step_colorbook() {
6663        assert_progressive_decoder_matches_step("colorbook.djvu");
6664    }
6665
6666    /// Structural proof of the B5 claim (primary evidence per the design doc,
6667    /// since wall-clock is noisy on a shared machine): a naive session that
6668    /// calls `render_progressive_step(0..N)` re-decodes BG44 chunks
6669    /// `1+2+...+N` times — O(N²) — while a `ProgressiveDecoder` session over
6670    /// the same N frames decodes each chunk exactly once — O(N). Counted via
6671    /// the `#[cfg(test)]`-only `BG44_CHUNK_DECODES` counter at the two real
6672    /// `Iw44Image::decode_chunk` call sites, not wall-clock timing.
6673    ///
6674    /// Under the `parallel` feature, `decode_layers` runs the naive session's
6675    /// background decode through `rayon::join` (see `#440` there). Calling
6676    /// `rayon::join` from a plain thread that isn't already a rayon worker —
6677    /// such as this test's own thread — makes rayon bridge onto a worker
6678    /// thread from its shared *global* pool to execute the join. That breaks
6679    /// the `BG44_CHUNK_DECODES` thread-local's implicit assumption that every
6680    /// counted decode call lands on the thread that set/reads it: the
6681    /// increments happen on a global-pool worker thread this test's thread_local
6682    /// handle never sees, so the naive count silently reads back as 0
6683    /// (verified: found failing under `--features cli,mmap,parallel`, and in
6684    /// isolation under `--features parallel` alone — `mmap` is not implicated).
6685    /// Route the whole measurement through a dedicated, single-worker rayon
6686    /// pool instead: with exactly one worker, any `rayon::join` bridged into
6687    /// it always resolves on that same worker, so setting and reading the
6688    /// counter from *inside* the pool keeps everything on one thread
6689    /// regardless of the `parallel` feature — and because the pool is freshly
6690    /// built here (not rayon's shared global pool), this stays isolated from
6691    /// any other test's concurrent decode calls, preserving the isolation the
6692    /// original thread-local was there for.
6693    #[test]
6694    fn progressive_decoder_chunk_decodes_are_on_not_on_squared() {
6695        let body = || {
6696            for filename in ["chicken.djvu", "colorbook.djvu"] {
6697                let doc = load_doc(filename);
6698                let page = doc.page(0).unwrap();
6699                let chunks = page.bg44_chunks();
6700                let n = chunks.len();
6701                assert!(n >= 3, "{filename}: need >=3 BG44 chunks");
6702
6703                let opts = RenderOptions {
6704                    width: page.width() as u32,
6705                    height: page.height() as u32,
6706                    resampling: Resampling::Bilinear,
6707                    ..Default::default()
6708                };
6709
6710                // Naive per-frame session: render_progressive_step(0..N), each call
6711                // re-decoding the chunk prefix from scratch (the pre-B5 behaviour).
6712                BG44_CHUNK_DECODES.with(|c| c.set(0));
6713                for step in 0..n {
6714                    render_progressive_step(page, &opts, step).expect("progressive_step");
6715                }
6716                let naive = BG44_CHUNK_DECODES.with(|c| c.get());
6717                let expected_naive: usize = (1..=n).sum(); // 1+2+...+N
6718                assert_eq!(
6719                    naive, expected_naive,
6720                    "{filename}: naive per-frame session should decode chunks \
6721                     1+2+...+N = {expected_naive} times, got {naive}"
6722                );
6723
6724                // Stateful streaming session: one decode per chunk, total N.
6725                BG44_CHUNK_DECODES.with(|c| c.set(0));
6726                let mut dec = ProgressiveDecoder::new(page, &opts).expect("decoder");
6727                for chunk in chunks.iter() {
6728                    dec.push_bg44_chunk(chunk).expect("push");
6729                }
6730                let streamed = BG44_CHUNK_DECODES.with(|c| c.get());
6731                assert_eq!(
6732                    streamed, n,
6733                    "{filename}: stateful session should decode each chunk exactly \
6734                     once (O(N) = {n}), got {streamed}"
6735                );
6736
6737                assert!(
6738                    naive > streamed,
6739                    "{filename}: naive session ({naive} decodes) should strictly \
6740                     exceed the streamed session ({streamed} decodes)"
6741                );
6742            }
6743        };
6744
6745        #[cfg(feature = "parallel")]
6746        {
6747            let pool = rayon::ThreadPoolBuilder::new()
6748                .num_threads(1)
6749                .build()
6750                .expect("build single-threaded pool for deterministic thread-local counting");
6751            pool.install(body);
6752        }
6753        #[cfg(not(feature = "parallel"))]
6754        body();
6755    }
6756
6757    #[test]
6758    fn progressive_decoder_rejects_lanczos_and_zero_dims() {
6759        let doc = load_doc("chicken.djvu");
6760        let page = doc.page(0).unwrap();
6761        let mut opts = RenderOptions {
6762            width: page.width() as u32,
6763            height: page.height() as u32,
6764            resampling: Resampling::Lanczos3,
6765            ..Default::default()
6766        };
6767        assert!(matches!(
6768            ProgressiveDecoder::new(page, &opts),
6769            Err(RenderError::UnsupportedOption(_))
6770        ));
6771        opts.resampling = Resampling::Bilinear;
6772        opts.width = 0;
6773        assert!(matches!(
6774            ProgressiveDecoder::new(page, &opts),
6775            Err(RenderError::InvalidDimensions { .. })
6776        ));
6777    }
6778
6779    /// Same byte-identity guarantee for the incremental progressive path with
6780    /// `bold > 0`: the fast path dilates the mask once and reuses it across
6781    /// frames, which must match the per-frame path that dilates each frame.
6782    #[test]
6783    fn render_progressive_all_matches_per_frame_bold() {
6784        let doc = load_doc("chicken.djvu");
6785        let page = doc.page(0).unwrap();
6786        assert!(
6787            page.bg44_chunks().len() >= 2,
6788            "need a multi-chunk BG44 page"
6789        );
6790
6791        let opts = RenderOptions {
6792            width: page.width() as u32,
6793            height: page.height() as u32,
6794            resampling: Resampling::Bilinear,
6795            bold: 2,
6796            ..Default::default()
6797        };
6798
6799        let all = render_progressive_all(page, &opts).expect("progressive_all");
6800        let steps = progressive_steps(page);
6801        assert_eq!(all.len(), steps);
6802        for (step, frame) in all.iter().enumerate() {
6803            let per_frame = render_progressive_step(page, &opts, step).expect("progressive_step");
6804            assert_eq!(
6805                (frame.width, frame.height),
6806                (per_frame.width, per_frame.height),
6807                "frame {step} dimensions differ (bold)"
6808            );
6809            assert!(
6810                frame.data == per_frame.data,
6811                "frame {step} pixels differ between incremental and per-frame paths (bold)"
6812            );
6813        }
6814    }
6815
6816    /// Evicting the render cache must not change output: a re-render after
6817    /// `evict_render_caches` is byte-identical to the first (the cache rebuilds
6818    /// lazily and correctly).
6819    #[test]
6820    fn evict_render_cache_preserves_output() {
6821        let mut doc = load_doc("chicken.djvu");
6822        let (w, h) = {
6823            let p = doc.page(0).unwrap();
6824            (p.width() as u32, p.height() as u32)
6825        };
6826        let opts = RenderOptions {
6827            width: w,
6828            height: h,
6829            ..Default::default()
6830        };
6831        let first = {
6832            let p = doc.page(0).unwrap();
6833            render_pixmap(p, &opts).unwrap()
6834        };
6835        doc.evict_render_caches();
6836        let second = {
6837            let p = doc.page(0).unwrap();
6838            render_pixmap(p, &opts).unwrap()
6839        };
6840        assert_eq!(
6841            first.data, second.data,
6842            "output changed after cache eviction"
6843        );
6844    }
6845
6846    /// `enforce_cache_budget` evicts least-recently-used unprotected pages down
6847    /// to the budget, keeps protected pages, and re-renders byte-identically.
6848    #[test]
6849    fn enforce_cache_budget_lru_and_correctness() {
6850        let doc = load_doc("colorbook.djvu");
6851        if doc.page_count() < 3 {
6852            return; // needs a multi-page fixture
6853        }
6854        // Render pages 0,1,2 in order → LRU order is 0 < 1 < 2 by access tick.
6855        let mut first0 = None;
6856        for i in 0..3 {
6857            let (w, h) = {
6858                let p = doc.page(i).unwrap();
6859                (p.width() as u32, p.height() as u32)
6860            };
6861            let opts = RenderOptions {
6862                width: w,
6863                height: h,
6864                ..Default::default()
6865            };
6866            let p = doc.page(i).unwrap();
6867            let pm = render_pixmap(p, &opts).unwrap();
6868            if i == 0 {
6869                first0 = Some(pm);
6870            }
6871        }
6872        // LRU ticks strictly increase with render order.
6873        let t0 = doc.page(0).unwrap().render_cache_access_tick();
6874        let t1 = doc.page(1).unwrap().render_cache_access_tick();
6875        let t2 = doc.page(2).unwrap().render_cache_access_tick();
6876        assert!(t0 < t1 && t1 < t2, "LRU ticks not ordered: {t0} {t1} {t2}");
6877
6878        assert!(doc.render_cache_bytes() > 0);
6879        // Budget 1 byte, protect page 2 → evict the two LRU unprotected pages.
6880        let freed = doc.enforce_cache_budget(1, &[2]);
6881        assert!(freed > 0, "expected some bytes freed");
6882        assert_eq!(
6883            doc.page(0).unwrap().render_cache_bytes(),
6884            0,
6885            "page 0 not evicted"
6886        );
6887        assert_eq!(
6888            doc.page(1).unwrap().render_cache_bytes(),
6889            0,
6890            "page 1 not evicted"
6891        );
6892        assert!(
6893            doc.page(2).unwrap().render_cache_bytes() > 0,
6894            "protected page 2 was evicted"
6895        );
6896
6897        // Re-rendering an evicted page reproduces the original output exactly.
6898        let (w, h) = {
6899            let p = doc.page(0).unwrap();
6900            (p.width() as u32, p.height() as u32)
6901        };
6902        let opts = RenderOptions {
6903            width: w,
6904            height: h,
6905            ..Default::default()
6906        };
6907        let second0 = render_pixmap(doc.page(0).unwrap(), &opts).unwrap();
6908        assert_eq!(first0.unwrap().data, second0.data);
6909    }
6910
6911    /// C5_COMPRESS: `downgrade_render_cache` must (a) shrink the cache, (b)
6912    /// keep a previously-cached `bg_rgb_s2` warm — a subsequent sub=2 render
6913    /// must not force a fresh BG44 decode — and (c) still reproduce the exact
6914    /// same full-resolution output on a later cold sub=1 render (the
6915    /// full-res path re-decodes from scratch, byte-identically).
6916    #[test]
6917    fn downgrade_render_cache_keeps_downscaled_tier_warm() {
6918        let doc = load_doc("colorbook.djvu");
6919        let (w, h) = {
6920            let p = doc.page(0).unwrap();
6921            (p.width() as u32, p.height() as u32)
6922        };
6923        let opts_s1 = RenderOptions {
6924            width: w,
6925            height: h,
6926            ..Default::default()
6927        };
6928        // sub=2 request: half-resolution output.
6929        let opts_s2 = RenderOptions {
6930            width: w / 2,
6931            height: h / 2,
6932            ..Default::default()
6933        };
6934
6935        let first_s1 = render_pixmap(doc.page(0).unwrap(), &opts_s1).unwrap();
6936        let first_s2 = render_pixmap(doc.page(0).unwrap(), &opts_s2).unwrap();
6937        let bytes_before = doc.page(0).unwrap().render_cache_bytes();
6938        assert!(bytes_before > 0);
6939
6940        doc.downgrade_render_caches();
6941        let bytes_after = doc.page(0).unwrap().render_cache_bytes();
6942        assert!(
6943            bytes_after > 0 && bytes_after < bytes_before,
6944            "downgrade should shrink but not zero the cache: before={bytes_before} after={bytes_after}"
6945        );
6946
6947        // sub=2 render after downgrade: warm (bg_rgb_s2 preserved), and output
6948        // is unchanged.
6949        let second_s2 = render_pixmap(doc.page(0).unwrap(), &opts_s2).unwrap();
6950        assert_eq!(first_s2.data, second_s2.data, "sub=2 output changed");
6951
6952        // sub=1 (full-res) render after downgrade: cold-decodes but still
6953        // reproduces the original output exactly.
6954        let second_s1 = render_pixmap(doc.page(0).unwrap(), &opts_s1).unwrap();
6955        assert_eq!(first_s1.data, second_s1.data, "sub=1 output changed");
6956    }
6957
6958    /// IW44_CHECKPOINT (#608): a full render after a sub>=4 render (which
6959    /// cached the first-chunk partial decode) resumes from that checkpoint —
6960    /// and must be byte-identical to a cold full render on a fresh document.
6961    #[test]
6962    fn full_decode_resumed_from_partial_is_byte_identical() {
6963        let doc_a = load_doc("colorbook.djvu");
6964        let (w, h) = {
6965            let p = doc_a.page(0).unwrap();
6966            (p.width() as u32, p.height() as u32)
6967        };
6968        // Warm the partial tier via a sub=4 render, then full render.
6969        let opts_s4 = RenderOptions {
6970            width: w / 4,
6971            height: h / 4,
6972            ..Default::default()
6973        };
6974        let opts_s1 = RenderOptions {
6975            width: w,
6976            height: h,
6977            ..Default::default()
6978        };
6979        let _ = render_pixmap(doc_a.page(0).unwrap(), &opts_s4).unwrap();
6980        assert!(
6981            doc_a
6982                .page(0)
6983                .unwrap()
6984                .render_layers()
6985                .bg44_partial
6986                .is_computed(),
6987            "sub=4 render must populate the partial tier"
6988        );
6989        let resumed = render_pixmap(doc_a.page(0).unwrap(), &opts_s1).unwrap();
6990
6991        // Cold full render on a fresh document (no partial tier).
6992        let doc_b = load_doc("colorbook.djvu");
6993        let cold = render_pixmap(doc_b.page(0).unwrap(), &opts_s1).unwrap();
6994
6995        assert_eq!(
6996            resumed.data, cold.data,
6997            "resumed full decode must be byte-identical"
6998        );
6999    }
7000
7001    /// #576: back-and-forth pan hit-rate through the tile cache. LRU keeps
7002    /// the tiles a reversing pan is about to revisit; the printed numbers are
7003    /// the experiment's measurement (run with --nocapture), the assert is the
7004    /// regression floor.
7005    #[test]
7006    fn tile_cache_back_and_forth_pan_hit_rate() {
7007        let doc = load_doc("colorbook.djvu");
7008        let page = doc.page(0).unwrap();
7009        let (w, h) = (page.width() as u32, page.height() as u32);
7010        // 2x zoom full-render space, viewport ~1/3 page, 25% pan steps,
7011        // left-to-right then back — the classic reading pattern.
7012        let opts = RenderOptions {
7013            width: w * 2,
7014            height: h * 2,
7015            ..Default::default()
7016        };
7017        // Realistic laptop viewport: 1440×960 ≈ 24 tiles (fits the 8 MiB /
7018        // ~32-tile budget with headroom — a viewport larger than the budget
7019        // thrashes any policy).
7020        let vw = 1440u32.min(w * 2);
7021        let vh = 960u32.min(h * 2);
7022        let step = vw / 4;
7023        let max_x = (w * 2).saturating_sub(vw);
7024        let mut xs: Vec<u32> = (0..=(max_x / step)).map(|i| i * step).collect();
7025        let back: Vec<u32> = xs.iter().rev().skip(1).copied().collect();
7026        xs.extend(back);
7027        for &x in &xs {
7028            let _ = render_region_tiled(
7029                page,
7030                RenderRect {
7031                    x,
7032                    y: 0,
7033                    width: vw,
7034                    height: vh,
7035                },
7036                &opts,
7037            )
7038            .unwrap();
7039        }
7040        let (hits, misses, evictions) = page.render_layers().tile_cache_stats();
7041        let rate = hits as f64 / (hits + misses).max(1) as f64;
7042        println!(
7043            "tile cache back-and-forth pan: hits={hits} misses={misses} evictions={evictions} hit-rate={:.1}%",
7044            rate * 100.0
7045        );
7046        assert!(
7047            rate > 0.30,
7048            "back-and-forth pan hit rate too low: {:.1}%",
7049            rate * 100.0
7050        );
7051    }
7052
7053    /// Round 89 follow-up: a *cold* thumbnail-style render (bg_subsample >= 4,
7054    /// no bold, no FGbz, first render of the page — nothing warm yet) must
7055    /// not run the full-resolution JB2 mask decode at all, and must still
7056    /// produce output pixel-identical to the same render done the old way
7057    /// (mask_sub4 built by downsampling an already-decoded full-resolution
7058    /// mask). Before this change, `decode_layers`'s #607 fast path required
7059    /// `mask_sub4` to already be warm, so the very first (cold) sub>=4
7060    /// render — exactly `Document::thumbnails()`'s access pattern — always
7061    /// paid for a full-resolution `extract_mask` canvas just to immediately
7062    /// downsample and discard it.
7063    #[cfg(feature = "std")]
7064    #[test]
7065    fn cold_thumbnail_sweep_skips_full_mask_decode() {
7066        let body = || {
7067            let (w, h) = {
7068                let doc = load_doc("colorbook.djvu");
7069                let page = doc.page(0).unwrap();
7070                (page.width() as u32, page.height() as u32)
7071            };
7072            let opts_s4 = RenderOptions {
7073                width: w / 4,
7074                height: h / 4,
7075                ..Default::default()
7076            };
7077            let opts_s1 = RenderOptions {
7078                width: w,
7079                height: h,
7080                ..Default::default()
7081            };
7082
7083            // Reference: force the full-resolution mask to decode and cache
7084            // first (a plain sub=1 render), then take the sub4 render — this
7085            // exercises `mask_sub4`'s "downsample an already-cached full mask"
7086            // branch, matching pre-fix behaviour exactly.
7087            let doc_warm = load_doc("colorbook.djvu");
7088            let page_warm = doc_warm.page(0).unwrap();
7089            let _ = render_pixmap(page_warm, &opts_s1).unwrap();
7090            let reference = render_pixmap(page_warm, &opts_s4).unwrap();
7091
7092            // Cold: a fresh document, straight to a sub4 render — nothing
7093            // warm, must decode straight to 1/4 resolution via
7094            // `extract_mask_sub4` and must not touch the full-res decoder.
7095            let doc_cold = load_doc("colorbook.djvu");
7096            let page_cold = doc_cold.page(0).unwrap();
7097            JB2_MASK_DECODES.with(|c| c.set(0));
7098            let cold_s4 = render_pixmap(page_cold, &opts_s4).unwrap();
7099            assert_eq!(
7100                JB2_MASK_DECODES.with(|c| c.get()),
7101                0,
7102                "cold sub>=4 render must not run the full-resolution JB2 decode"
7103            );
7104
7105            assert_eq!(
7106                cold_s4.data, reference.data,
7107                "cold sub4 thumbnail-style render must match the warm-mask-sub4 render"
7108            );
7109        };
7110
7111        #[cfg(feature = "parallel")]
7112        {
7113            let pool = rayon::ThreadPoolBuilder::new()
7114                .num_threads(1)
7115                .build()
7116                .expect("build single-threaded pool for deterministic thread-local counting");
7117            pool.install(body);
7118        }
7119        #[cfg(not(feature = "parallel"))]
7120        body();
7121    }
7122
7123    /// #607: `downgrade` retains the 1/4-res mask, and an eligible sub>=4
7124    /// re-render consumes it without re-running the JB2 decode — while
7125    /// producing pixel-identical output. A full-resolution re-render stays
7126    /// cold and also reproduces the original bytes.
7127    ///
7128    /// Under `parallel`, `decode_layers` runs the cold full-res path through
7129    /// `rayon::join` (#440). The same thread-local counting hazard as
7130    /// [`progressive_decoder_chunk_decodes_are_on_not_on_squared`] applies:
7131    /// increments land on a global-pool worker, so the test thread reads 0.
7132    /// Route the measurement through a dedicated single-worker pool (#721).
7133    #[test]
7134    fn downgrade_retains_sub4_mask_and_skips_jb2_decode() {
7135        let body = || {
7136            let doc = load_doc("colorbook.djvu");
7137            let (w, h) = {
7138                let p = doc.page(0).unwrap();
7139                (p.width() as u32, p.height() as u32)
7140            };
7141            let opts_s4 = RenderOptions {
7142                width: w / 4,
7143                height: h / 4,
7144                ..Default::default()
7145            };
7146            let opts_s1 = RenderOptions {
7147                width: w,
7148                height: h,
7149                ..Default::default()
7150            };
7151
7152            // Warm the sub4 tier (this decodes the full mask once and builds
7153            // mask_sub4), then downgrade.
7154            let first_s4 = render_pixmap(doc.page(0).unwrap(), &opts_s4).unwrap();
7155            let first_s1 = render_pixmap(doc.page(0).unwrap(), &opts_s1).unwrap();
7156            doc.downgrade_render_caches();
7157            assert!(
7158                doc.page(0)
7159                    .unwrap()
7160                    .render_layers()
7161                    .mask_sub4_cached()
7162                    .is_some(),
7163                "downgrade must retain mask_sub4"
7164            );
7165
7166            // Structural proof: the warm sub4 re-render must not invoke the JB2
7167            // decoder at all.
7168            JB2_MASK_DECODES.with(|c| c.set(0));
7169            let second_s4 = render_pixmap(doc.page(0).unwrap(), &opts_s4).unwrap();
7170            assert_eq!(
7171                JB2_MASK_DECODES.with(|c| c.get()),
7172                0,
7173                "warm sub4 re-render after downgrade must not re-run the JB2 decode"
7174            );
7175            assert_eq!(first_s4.data, second_s4.data, "sub=4 output changed");
7176
7177            // Full-resolution re-render: cold (decodes the mask again), output
7178            // unchanged.
7179            JB2_MASK_DECODES.with(|c| c.set(0));
7180            let second_s1 = render_pixmap(doc.page(0).unwrap(), &opts_s1).unwrap();
7181            assert!(
7182                JB2_MASK_DECODES.with(|c| c.get()) > 0,
7183                "full-res re-render after downgrade must cold-decode the mask"
7184            );
7185            assert_eq!(first_s1.data, second_s1.data, "sub=1 output changed");
7186        };
7187
7188        #[cfg(feature = "parallel")]
7189        {
7190            let pool = rayon::ThreadPoolBuilder::new()
7191                .num_threads(1)
7192                .build()
7193                .expect("build single-threaded pool for deterministic thread-local counting");
7194            pool.install(body);
7195        }
7196        #[cfg(not(feature = "parallel"))]
7197        body();
7198    }
7199
7200    /// #607 eligibility guard: bold dilation needs the full-resolution mask,
7201    /// so a bold sub>=4 render after downgrade must decode it (and match the
7202    /// pre-downgrade bold render exactly).
7203    ///
7204    /// Same `parallel` + thread-local counting wrap as
7205    /// [`downgrade_retains_sub4_mask_and_skips_jb2_decode`] (#721).
7206    #[test]
7207    fn downgraded_sub4_with_bold_still_full_decodes() {
7208        let body = || {
7209            let doc = load_doc("colorbook.djvu");
7210            let (w, h) = {
7211                let p = doc.page(0).unwrap();
7212                (p.width() as u32, p.height() as u32)
7213            };
7214            let opts_bold = RenderOptions {
7215                width: w / 4,
7216                height: h / 4,
7217                bold: 1,
7218                ..Default::default()
7219            };
7220            let first = render_pixmap(doc.page(0).unwrap(), &opts_bold).unwrap();
7221            // Also warm the plain sub4 tier so mask_sub4 survives the downgrade.
7222            let opts_s4 = RenderOptions {
7223                width: w / 4,
7224                height: h / 4,
7225                ..Default::default()
7226            };
7227            let _ = render_pixmap(doc.page(0).unwrap(), &opts_s4).unwrap();
7228            doc.downgrade_render_caches();
7229
7230            JB2_MASK_DECODES.with(|c| c.set(0));
7231            let second = render_pixmap(doc.page(0).unwrap(), &opts_bold).unwrap();
7232            assert!(
7233                JB2_MASK_DECODES.with(|c| c.get()) > 0,
7234                "bold render must not take the retained-sub4 shortcut"
7235            );
7236            assert_eq!(first.data, second.data, "bold sub=4 output changed");
7237        };
7238
7239        #[cfg(feature = "parallel")]
7240        {
7241            let pool = rayon::ThreadPoolBuilder::new()
7242                .num_threads(1)
7243                .build()
7244                .expect("build single-threaded pool for deterministic thread-local counting");
7245            pool.install(body);
7246        }
7247        #[cfg(not(feature = "parallel"))]
7248        body();
7249    }
7250
7251    /// `enforce_cache_budget_with(downgrade_before_drop: true)` honours the same
7252    /// byte ceiling as `enforce_cache_budget`, and a downgraded (not fully
7253    /// dropped) page still reproduces byte-identical output.
7254    #[test]
7255    fn enforce_cache_budget_with_downgrade_matches_budget_and_output() {
7256        let doc = load_doc("colorbook.djvu");
7257        if doc.page_count() < 3 {
7258            return;
7259        }
7260        let mut expected = Vec::new();
7261        for i in 0..3 {
7262            let (w, h) = {
7263                let p = doc.page(i).unwrap();
7264                (p.width() as u32, p.height() as u32)
7265            };
7266            let opts = RenderOptions {
7267                width: w,
7268                height: h,
7269                ..Default::default()
7270            };
7271            let p = doc.page(i).unwrap();
7272            expected.push(render_pixmap(p, &opts).unwrap());
7273        }
7274
7275        let total_before = doc.render_cache_bytes();
7276        assert!(total_before > 0);
7277        let budget = total_before / 2;
7278        let opts = crate::djvu_document::CacheBudgetOptions {
7279            downgrade_before_drop: true,
7280        };
7281        let _freed = doc.enforce_cache_budget_with(budget, &[], opts);
7282        assert!(
7283            doc.render_cache_bytes() <= budget,
7284            "cache not held under budget: {} > {}",
7285            doc.render_cache_bytes(),
7286            budget
7287        );
7288
7289        // Re-render every page (whichever were downgraded or dropped) and
7290        // check the output is unchanged either way.
7291        for (i, expected_pm) in expected.iter().enumerate().take(3) {
7292            let (w, h) = {
7293                let p = doc.page(i).unwrap();
7294                (p.width() as u32, p.height() as u32)
7295            };
7296            let opts = RenderOptions {
7297                width: w,
7298                height: h,
7299                ..Default::default()
7300            };
7301            let pm = render_pixmap(doc.page(i).unwrap(), &opts).unwrap();
7302            assert_eq!(expected_pm.data, pm.data, "page {i} output changed");
7303        }
7304    }
7305
7306    /// `fit_to_width` scales correctly, preserving aspect ratio.
7307    #[test]
7308    fn fit_to_width_preserves_aspect() {
7309        let doc = load_doc("chicken.djvu");
7310        let page = doc.page(0).unwrap();
7311        let pw = page.width() as u32;
7312        let ph = page.height() as u32;
7313
7314        let opts = RenderOptions::fit_to_width(page, 800);
7315        assert_eq!(opts.width, 800);
7316        let expected_h = ((ph as f64 * 800.0) / pw as f64).round() as u32;
7317        assert_eq!(opts.height, expected_h);
7318        // The pipeline's decode scale is derived from width; it matches the
7319        // width/page-width ratio the deprecated `scale` field used to carry.
7320        assert!((opts.decode_scale(page) - 800.0 / pw as f32).abs() < 0.01);
7321    }
7322
7323    /// `fit_to_height` scales correctly, preserving aspect ratio.
7324    #[test]
7325    fn fit_to_height_preserves_aspect() {
7326        let doc = load_doc("chicken.djvu");
7327        let page = doc.page(0).unwrap();
7328        let pw = page.width() as u32;
7329        let ph = page.height() as u32;
7330
7331        let opts = RenderOptions::fit_to_height(page, 600);
7332        assert_eq!(opts.height, 600);
7333        let expected_w = ((pw as f64 * 600.0) / ph as f64).round() as u32;
7334        assert_eq!(opts.width, expected_w);
7335        // fit_to_height preserves aspect, so width/page-width equals
7336        // height/page-height — the decode scale matches either ratio.
7337        assert!((opts.decode_scale(page) - 600.0 / ph as f32).abs() < 0.01);
7338    }
7339
7340    /// `fit_to_box` chooses the smaller scale factor.
7341    #[test]
7342    fn fit_to_box_constrains_both() {
7343        let doc = load_doc("chicken.djvu");
7344        let page = doc.page(0).unwrap();
7345
7346        // Very wide box — height should be the constraint
7347        let opts = RenderOptions::fit_to_box(page, 10000, 100);
7348        assert!(opts.width <= 10000);
7349        assert!(opts.height <= 100);
7350        assert!(opts.width > 0 && opts.height > 0);
7351
7352        // Very tall box — width should be the constraint
7353        let opts = RenderOptions::fit_to_box(page, 100, 10000);
7354        assert!(opts.width <= 100);
7355        assert!(opts.height <= 10000);
7356        assert!(opts.width > 0 && opts.height > 0);
7357    }
7358
7359    /// `fit_to_box` with a square box picks the tighter dimension.
7360    #[test]
7361    fn fit_to_box_square() {
7362        let doc = load_doc("chicken.djvu");
7363        let page = doc.page(0).unwrap();
7364
7365        let opts = RenderOptions::fit_to_box(page, 500, 500);
7366        assert!(opts.width <= 500);
7367        assert!(opts.height <= 500);
7368        // At least one dimension should be close to 500
7369        assert!(opts.width >= 490 || opts.height >= 490);
7370    }
7371
7372    /// Rotated page: fit_to_width uses display dimensions (swapped w/h).
7373    #[test]
7374    fn fit_to_width_rotation_aware() {
7375        // boy_jb2_rotate90 has a 90° rotation in the INFO chunk
7376        let doc = load_doc("boy_jb2_rotate90.djvu");
7377        let page = doc.page(0).unwrap();
7378        let pw = page.width() as u32;
7379        let ph = page.height() as u32;
7380        // Display dimensions are swapped for 90° rotation
7381        let (dw, dh) = (ph, pw);
7382
7383        let opts = RenderOptions::fit_to_width(page, 400);
7384        assert_eq!(opts.width, 400);
7385        let expected_h = ((dh as f64 * 400.0) / dw as f64).round() as u32;
7386        assert_eq!(opts.height, expected_h);
7387    }
7388
7389    /// `render_into` with a zero-width dimension returns InvalidDimensions.
7390    #[test]
7391    fn render_into_invalid_dimensions() {
7392        let doc = load_doc("chicken.djvu");
7393        let page = doc.page(0).unwrap();
7394
7395        let opts = RenderOptions {
7396            width: 0,
7397            height: 100,
7398            ..Default::default()
7399        };
7400        let mut buf = vec![0u8; 400];
7401        let err = render_into(page, &opts, &mut buf).unwrap_err();
7402        assert!(
7403            matches!(err, RenderError::InvalidDimensions { .. }),
7404            "expected InvalidDimensions, got {err:?}"
7405        );
7406    }
7407
7408    /// `render_into` with a too-small buffer returns BufTooSmall.
7409    #[test]
7410    fn render_into_buf_too_small() {
7411        let doc = load_doc("chicken.djvu");
7412        let page = doc.page(0).unwrap();
7413
7414        let opts = RenderOptions {
7415            width: 10,
7416            height: 10,
7417            ..Default::default()
7418        };
7419        let mut buf = vec![0u8; 10]; // too small (needs 400)
7420        let err = render_into(page, &opts, &mut buf).unwrap_err();
7421        assert!(
7422            matches!(err, RenderError::BufTooSmall { need: 400, got: 10 }),
7423            "expected BufTooSmall, got {err:?}"
7424        );
7425    }
7426
7427    /// `render_into` fills a pre-allocated buffer without allocating new one.
7428    ///
7429    /// We verify by: calling with exactly the right size buf, no panic,
7430    /// and the buffer is mutated (not all-zero after the call).
7431    #[test]
7432    fn render_into_fills_buffer_no_alloc() {
7433        let doc = load_doc("chicken.djvu");
7434        let page = doc.page(0).unwrap();
7435
7436        let w = 50u32;
7437        let h = 40u32;
7438        let opts = RenderOptions {
7439            width: w,
7440            height: h,
7441            ..Default::default()
7442        };
7443        let mut buf = vec![0u8; (w * h * 4) as usize];
7444        render_into(page, &opts, &mut buf).expect("render_into should succeed");
7445
7446        // The page is a color image — pixels should not all be zero
7447        assert!(
7448            buf.iter().any(|&b| b != 0),
7449            "rendered buffer should contain non-zero pixels"
7450        );
7451    }
7452
7453    /// `render_into` can be called twice with the same buffer (zero-allocation reuse).
7454    #[test]
7455    fn render_into_reuse_buffer() {
7456        let doc = load_doc("chicken.djvu");
7457        let page = doc.page(0).unwrap();
7458
7459        let w = 30u32;
7460        let h = 20u32;
7461        let opts = RenderOptions {
7462            width: w,
7463            height: h,
7464            ..Default::default()
7465        };
7466        let mut buf = vec![0u8; (w * h * 4) as usize];
7467
7468        // First render
7469        render_into(page, &opts, &mut buf).expect("first render_into should succeed");
7470        let first = buf.clone();
7471
7472        // Second render — same result
7473        render_into(page, &opts, &mut buf).expect("second render_into should succeed");
7474        assert_eq!(
7475            first, buf,
7476            "repeated render_into should produce identical output"
7477        );
7478    }
7479
7480    /// gamma=2.2 (most DjVu files) produces an identity LUT — no correction needed
7481    /// for a standard display gamma=2.2.
7482    #[test]
7483    fn gamma_lut_standard_is_identity() {
7484        let lut = build_gamma_lut(2.2);
7485        for (i, &val) in lut.iter().enumerate() {
7486            assert_eq!(
7487                val, i as u8,
7488                "gamma=2.2 LUT at {i}: expected {i}, got {val}"
7489            );
7490        }
7491    }
7492
7493    /// A linear-light source (gamma=1.0) is corrected: midtones become brighter
7494    /// (exponent=1/2.2<1 raises sub-unity values toward 1.0) to compensate
7495    /// for the display gamma-2.2 encoding needed for correct appearance.
7496    #[test]
7497    fn gamma_lut_linear_source_brightens() {
7498        let lut_linear = build_gamma_lut(1.0); // linear source → needs brightening
7499        let mid = 128u8;
7500        let corrected = lut_linear[mid as usize];
7501        assert!(
7502            corrected > mid,
7503            "linear-source LUT at mid ({corrected}) should be brighter than {mid}"
7504        );
7505    }
7506
7507    /// Gamma LUT for gamma=0.0 (invalid) falls back to identity.
7508    #[test]
7509    fn gamma_lut_zero_is_identity() {
7510        let lut = build_gamma_lut(0.0);
7511        for (i, &val) in lut.iter().enumerate() {
7512            assert_eq!(val, i as u8, "zero gamma should produce identity LUT");
7513        }
7514    }
7515
7516    /// render_coarse returns a valid pixmap (non-empty, correct dimensions) for
7517    /// a color page.
7518    #[test]
7519    fn render_coarse_returns_pixmap() {
7520        let doc = load_doc("chicken.djvu");
7521        let page = doc.page(0).unwrap();
7522
7523        let opts = RenderOptions {
7524            width: 60,
7525            height: 80,
7526            ..Default::default()
7527        };
7528
7529        let result = render_coarse(page, &opts).expect("render_coarse should succeed");
7530        // chicken.djvu may or may not have BG44 chunks
7531        if let Some(pm) = result {
7532            assert_eq!(pm.width, 60);
7533            assert_eq!(pm.height, 80);
7534            assert_eq!(pm.data.len(), 60 * 80 * 4);
7535        }
7536        // Ok(None) is also valid if no BG44
7537    }
7538
7539    /// render_progressive returns valid pixmap after each chunk.
7540    #[test]
7541    fn render_progressive_each_chunk() {
7542        // Use a page that has multiple BG44 chunks (boy.djvu is a good candidate)
7543        let doc = load_doc("boy.djvu");
7544        let page = doc.page(0).unwrap();
7545
7546        let opts = RenderOptions {
7547            width: 80,
7548            height: 100,
7549            ..Default::default()
7550        };
7551
7552        let n_bg44 = page.bg44_chunks().len();
7553
7554        for chunk_n in 0..n_bg44 {
7555            let pm = render_progressive(page, &opts, chunk_n)
7556                .unwrap_or_else(|e| panic!("render_progressive chunk {chunk_n} failed: {e}"));
7557            assert_eq!(pm.width, 80);
7558            assert_eq!(pm.height, 100);
7559            assert_eq!(pm.data.len(), 80 * 100 * 4);
7560            // Each frame must have some non-zero pixels
7561            assert!(
7562                pm.data.iter().any(|&b| b != 0),
7563                "chunk {chunk_n}: rendered frame should not be all-zero"
7564            );
7565        }
7566    }
7567
7568    /// Regression test for BUG-ZPSHORT (found while validating B5): on
7569    /// `watchmaker.djvu` page 0, BG44 chunk 2 of 4 is a legitimate two-byte
7570    /// `[serial, slices]` header with a **zero-length** ZP payload (the encoder
7571    /// had nothing left to encode for that refinement round). The strict
7572    /// progressive path (`render_progressive_step` / `ProgressiveDecoder`)
7573    /// used to hard-fail on it with `Iw44(ZpTooShort)`, while the permissive
7574    /// full-page cache (`PageLayers::bg44`) silently swallowed the error and
7575    /// dropped that chunk *and every chunk after it* — so the full render
7576    /// "succeeded" but silently used only 2 of 4 refinement chunks. Both are
7577    /// now fixed by treating a short/empty payload as valid trailing `0xFF`
7578    /// padding at the `djvu-iw44` layer (see `Iw44Image::decode_chunk`),
7579    /// matching the padding convention `ZpDecoder::read_byte` already uses at
7580    /// a stream's true end. Every step and the full render must now succeed.
7581    #[test]
7582    fn render_progressive_step_handles_zero_length_bg44_chunk() {
7583        let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
7584            .join("tests/corpus/watchmaker.djvu");
7585        let data = std::fs::read(&path).expect("watchmaker.djvu must exist");
7586        let doc = DjVuDocument::parse(&data).expect("parse failed");
7587        let page = doc.page(0).unwrap();
7588
7589        let chunks = page.bg44_chunks();
7590        assert_eq!(
7591            chunks.len(),
7592            4,
7593            "expected 4 BG44 chunks on watchmaker page 0"
7594        );
7595        assert_eq!(
7596            chunks[2].len(),
7597            2,
7598            "chunk 2 should be the zero-payload [serial, slices] header this regression covers"
7599        );
7600
7601        let opts = RenderOptions {
7602            width: page.width() as u32,
7603            height: page.height() as u32,
7604            resampling: Resampling::Bilinear,
7605            ..Default::default()
7606        };
7607
7608        for step in 0..progressive_steps(page) {
7609            render_progressive_step(page, &opts, step)
7610                .unwrap_or_else(|e| panic!("step {step} should succeed, got {e}"));
7611        }
7612        render_progressive_all(page, &opts).expect("progressive_all should succeed");
7613        render_pixmap(page, &opts).expect("render_pixmap should succeed");
7614
7615        let mut dec = ProgressiveDecoder::new(page, &opts).expect("decoder");
7616        for chunk in &chunks {
7617            dec.push_bg44_chunk(chunk)
7618                .expect("ProgressiveDecoder should also handle the zero-length chunk");
7619        }
7620    }
7621
7622    /// `render_progressive_all` yields `progressive_steps` frames and its last
7623    /// frame is byte-identical to `render_pixmap` (the sealed protocol contract).
7624    #[test]
7625    fn render_progressive_all_seals_chunk_loop() {
7626        let doc = load_doc("boy.djvu");
7627        let page = doc.page(0).unwrap();
7628        let opts = RenderOptions {
7629            width: 80,
7630            height: 100,
7631            ..Default::default()
7632        };
7633
7634        // The seam hides `max(1, bg44_chunks().len())` from callers.
7635        let steps = progressive_steps(page);
7636        assert_eq!(steps, page.bg44_chunks().len().max(1));
7637
7638        let frames = render_progressive_all(page, &opts).expect("progressive_all must succeed");
7639        assert_eq!(frames.len(), steps, "one frame per progressive step");
7640
7641        let full = render_pixmap(page, &opts).expect("render_pixmap must succeed");
7642        assert_eq!(
7643            frames.last().unwrap().data,
7644            full.data,
7645            "final progressive frame must equal the full render"
7646        );
7647        // `render_progressive_step(0)` is also a valid (coarse) frame.
7648        let first = render_progressive_step(page, &opts, 0).expect("step 0 must succeed");
7649        assert_eq!(first.data, frames[0].data);
7650    }
7651
7652    /// #691 slice 3 regression: a progressive frame must not depend on
7653    /// whether the retained 1/4-res mask cache (#607) is warm. The fast
7654    /// path in `decode_layers` used to hand the progressive path a maskless
7655    /// layer set, so a prior full render at the same downscale silently
7656    /// dropped the text layer from every later progressive frame.
7657    #[cfg(feature = "std")]
7658    #[test]
7659    fn render_progressive_ignores_mask_sub4_warmth() {
7660        // colorbook.djvu: multi-chunk BG44, JB2 mask, no FGbz palette — at a
7661        // strong downscale it is exactly the page shape the #607 fast path
7662        // triggers on.
7663        let opts = RenderOptions {
7664            width: 61,
7665            height: 83,
7666            ..Default::default()
7667        };
7668        let cold = {
7669            let doc = load_doc("colorbook.djvu");
7670            let page = doc.page(0).unwrap();
7671            render_progressive_step(page, &opts, 1).unwrap()
7672        };
7673        let doc = load_doc("colorbook.djvu");
7674        let page = doc.page(0).unwrap();
7675        // Warm the sub4 mask cache the way any interactive session would:
7676        // with a plain full render at the same output size.
7677        let _ = render_pixmap(page, &opts).unwrap();
7678        assert!(
7679            page.render_layers().mask_sub4_cached().is_some(),
7680            "precondition: the full render must have retained the sub4 mask"
7681        );
7682        let warm = render_progressive_step(page, &opts, 1).unwrap();
7683        assert_eq!(
7684            cold.data, warm.data,
7685            "progressive frame changed with cache warmth"
7686        );
7687    }
7688
7689    /// render_progressive with chunk_n out of range returns ChunkOutOfRange.
7690    #[test]
7691    fn render_progressive_chunk_out_of_range() {
7692        let doc = load_doc("boy.djvu");
7693        let page = doc.page(0).unwrap();
7694
7695        let opts = RenderOptions {
7696            width: 40,
7697            height: 50,
7698            ..Default::default()
7699        };
7700
7701        let n_bg44 = page.bg44_chunks().len();
7702        if n_bg44 == 0 {
7703            // No BG44 chunks — skip this test
7704            return;
7705        }
7706
7707        let err = render_progressive(page, &opts, n_bg44 + 10).unwrap_err();
7708        assert!(
7709            matches!(err, RenderError::ChunkOutOfRange { .. }),
7710            "expected ChunkOutOfRange, got {err:?}"
7711        );
7712    }
7713
7714    /// render_pixmap with gamma gives different result than without (identity gamma).
7715    ///
7716    /// We compare rendering chicken.djvu twice: once with its natural gamma,
7717    /// once with gamma forced to 1.0 (identity). The pixel values should differ.
7718    #[test]
7719    fn render_pixmap_gamma_differs_from_identity() {
7720        let doc = load_doc("chicken.djvu");
7721        let page = doc.page(0).unwrap();
7722
7723        let w = 40u32;
7724        let h = 53u32; // ~native aspect for 181x240
7725
7726        let opts = RenderOptions {
7727            width: w,
7728            height: h,
7729            ..Default::default()
7730        };
7731
7732        // Render with native gamma (2.2 from INFO chunk)
7733        let pm_gamma = render_pixmap(page, &opts).expect("render with gamma should succeed");
7734
7735        // Render with identity gamma LUT manually applied to output
7736        let lut_identity = build_gamma_lut(1.0);
7737        let pm_identity = render_pixmap(page, &opts).expect("render for identity should succeed");
7738        // Apply identity correction (no-op) — pixels should be the same
7739        for i in 0..pm_identity.data.len().saturating_sub(3) {
7740            if i % 4 != 3 {
7741                // non-alpha channel
7742                let _ = lut_identity[pm_identity.data[i] as usize];
7743            }
7744        }
7745
7746        // Since chicken.djvu gamma = 2.2, the gamma-corrected render
7747        // should have generally brighter mid-tones than a raw (no-correction) render.
7748        // We test this by checking that the gamma render is not bit-for-bit identical
7749        // to a hypothetical no-correction render. Since we always apply gamma in
7750        // render_pixmap, we test the gamma LUT effect directly (covered by
7751        // `gamma_correction_changes_pixels`).
7752        //
7753        // Instead, verify that pm_gamma has valid dimensions and non-trivial content.
7754        assert_eq!(pm_gamma.width, w);
7755        assert_eq!(pm_gamma.height, h);
7756        assert!(
7757            pm_gamma.data.iter().any(|&b| b != 255),
7758            "should have non-white pixels"
7759        );
7760    }
7761
7762    /// render_pixmap for a bilevel (JB2-only) page produces black pixels.
7763    #[test]
7764    fn render_bilevel_page_has_black_pixels() {
7765        let doc = load_doc("boy_jb2.djvu");
7766        let page = doc.page(0).unwrap();
7767
7768        let opts = RenderOptions {
7769            width: 60,
7770            height: 80,
7771            ..Default::default()
7772        };
7773
7774        let pm = render_pixmap(page, &opts).expect("render bilevel should succeed");
7775        assert_eq!(pm.width, 60);
7776        assert_eq!(pm.height, 80);
7777        // A bilevel page should have some black pixels
7778        assert!(
7779            pm.data
7780                .as_chunks::<4>()
7781                .0
7782                .iter()
7783                .any(|px| px[0] == 0 && px[1] == 0 && px[2] == 0),
7784            "bilevel page should contain black pixels"
7785        );
7786    }
7787
7788    /// `render_pixmap` with aa=true returns a valid pixmap.
7789    #[test]
7790    fn render_with_aa() {
7791        let doc = load_doc("chicken.djvu");
7792        let page = doc.page(0).unwrap();
7793
7794        let opts = RenderOptions {
7795            width: 40,
7796            height: 54,
7797            aa: true,
7798            ..Default::default()
7799        };
7800        // With aa=true the output is downscaled 2×, so we get 20×27
7801        let pm = render_pixmap(page, &opts).expect("render with AA should succeed");
7802        // AA downscales the output
7803        assert_eq!(pm.width, 20);
7804        assert_eq!(pm.height, 27);
7805    }
7806
7807    // -- Rotation tests -------------------------------------------------------
7808
7809    #[test]
7810    fn rotate_pixmap_none_is_identity() {
7811        let mut pm = Pixmap::white(3, 2);
7812        pm.set_rgb(0, 0, 255, 0, 0);
7813        let rotated = rotate_pixmap(pm.clone(), crate::info::Rotation::None);
7814        assert_eq!(rotated.width, 3);
7815        assert_eq!(rotated.height, 2);
7816        assert_eq!(rotated.get_rgb(0, 0), (255, 0, 0));
7817    }
7818
7819    #[test]
7820    fn rotate_pixmap_cw90_swaps_dims() {
7821        let mut pm = Pixmap::white(4, 2);
7822        pm.set_rgb(0, 0, 255, 0, 0); // top-left red
7823        let rotated = rotate_pixmap(pm, crate::info::Rotation::Cw90);
7824        assert_eq!(rotated.width, 2);
7825        assert_eq!(rotated.height, 4);
7826        // Top-left (0,0) of original goes to (height-1-0, 0) = (1, 0) in rotated
7827        assert_eq!(rotated.get_rgb(1, 0), (255, 0, 0));
7828    }
7829
7830    #[test]
7831    fn rotate_pixmap_180_preserves_dims() {
7832        let mut pm = Pixmap::white(3, 2);
7833        pm.set_rgb(0, 0, 255, 0, 0); // top-left red
7834        let rotated = rotate_pixmap(pm, crate::info::Rotation::Rot180);
7835        assert_eq!(rotated.width, 3);
7836        assert_eq!(rotated.height, 2);
7837        assert_eq!(rotated.get_rgb(2, 1), (255, 0, 0));
7838    }
7839
7840    #[test]
7841    fn rotate_pixmap_ccw90_swaps_dims() {
7842        let mut pm = Pixmap::white(4, 2);
7843        pm.set_rgb(0, 0, 255, 0, 0); // top-left red
7844        let rotated = rotate_pixmap(pm, crate::info::Rotation::Ccw90);
7845        assert_eq!(rotated.width, 2);
7846        assert_eq!(rotated.height, 4);
7847        // Top-left (0,0) -> (0, width-1-0) = (0, 3) in rotated
7848        assert_eq!(rotated.get_rgb(0, 3), (255, 0, 0));
7849    }
7850
7851    #[test]
7852    fn render_pixmap_rotation_90_swaps_dimensions() {
7853        let doc = load_doc("boy_jb2_rotate90.djvu");
7854        let page = doc.page(0).expect("page 0");
7855        let orig_w = page.width();
7856        let orig_h = page.height();
7857        let opts = RenderOptions {
7858            width: orig_w as u32,
7859            height: orig_h as u32,
7860            ..Default::default()
7861        };
7862        let pm = render_pixmap(page, &opts).expect("render should succeed");
7863        // 90° rotation swaps width and height
7864        assert_eq!(
7865            pm.width, orig_h as u32,
7866            "rotated width should be original height"
7867        );
7868        assert_eq!(
7869            pm.height, orig_w as u32,
7870            "rotated height should be original width"
7871        );
7872    }
7873
7874    #[test]
7875    fn render_pixmap_rotation_180_preserves_dimensions() {
7876        let doc = load_doc("boy_jb2_rotate180.djvu");
7877        let page = doc.page(0).expect("page 0");
7878        let orig_w = page.width();
7879        let orig_h = page.height();
7880        let opts = RenderOptions {
7881            width: orig_w as u32,
7882            height: orig_h as u32,
7883            ..Default::default()
7884        };
7885        let pm = render_pixmap(page, &opts).expect("render should succeed");
7886        assert_eq!(pm.width, orig_w as u32);
7887        assert_eq!(pm.height, orig_h as u32);
7888    }
7889
7890    #[test]
7891    fn render_pixmap_rotation_270_swaps_dimensions() {
7892        let doc = load_doc("boy_jb2_rotate270.djvu");
7893        let page = doc.page(0).expect("page 0");
7894        let orig_w = page.width();
7895        let orig_h = page.height();
7896        let opts = RenderOptions {
7897            width: orig_w as u32,
7898            height: orig_h as u32,
7899            ..Default::default()
7900        };
7901        let pm = render_pixmap(page, &opts).expect("render should succeed");
7902        assert_eq!(
7903            pm.width, orig_h as u32,
7904            "rotated width should be original height"
7905        );
7906        assert_eq!(
7907            pm.height, orig_w as u32,
7908            "rotated height should be original width"
7909        );
7910    }
7911
7912    // -- User rotation tests ---------------------------------------------------
7913
7914    /// combine_rotations adds steps modulo 4.
7915    #[test]
7916    fn combine_rotations_identity() {
7917        use crate::info::Rotation;
7918        assert_eq!(
7919            combine_rotations(Rotation::None, UserRotation::None),
7920            Rotation::None
7921        );
7922    }
7923
7924    #[test]
7925    fn combine_rotations_info_only() {
7926        use crate::info::Rotation;
7927        assert_eq!(
7928            combine_rotations(Rotation::Cw90, UserRotation::None),
7929            Rotation::Cw90
7930        );
7931    }
7932
7933    #[test]
7934    fn combine_rotations_user_only() {
7935        use crate::info::Rotation;
7936        assert_eq!(
7937            combine_rotations(Rotation::None, UserRotation::Ccw90),
7938            Rotation::Ccw90
7939        );
7940    }
7941
7942    #[test]
7943    fn combine_rotations_sum() {
7944        use crate::info::Rotation;
7945        // 90 CW (INFO) + 90 CW (user) = 180
7946        assert_eq!(
7947            combine_rotations(Rotation::Cw90, UserRotation::Cw90),
7948            Rotation::Rot180
7949        );
7950        // 90 CW + 270 CW = 360 = None
7951        assert_eq!(
7952            combine_rotations(Rotation::Cw90, UserRotation::Ccw90),
7953            Rotation::None
7954        );
7955        // 180 + 180 = 360 = None
7956        assert_eq!(
7957            combine_rotations(Rotation::Rot180, UserRotation::Rot180),
7958            Rotation::None
7959        );
7960    }
7961
7962    /// User rotation Cw90 on a non-rotated page swaps output dimensions.
7963    #[test]
7964    fn user_rotation_cw90_swaps_dimensions() {
7965        let doc = load_doc("chicken.djvu");
7966        let page = doc.page(0).unwrap();
7967        let pw = page.width() as u32;
7968        let ph = page.height() as u32;
7969
7970        let opts = RenderOptions {
7971            width: pw,
7972            height: ph,
7973            rotation: UserRotation::Cw90,
7974            ..Default::default()
7975        };
7976        let pm = render_pixmap(page, &opts).expect("render");
7977        assert_eq!(pm.width, ph, "user Cw90 should swap: width becomes height");
7978        assert_eq!(pm.height, pw, "user Cw90 should swap: height becomes width");
7979    }
7980
7981    /// User rotation 180° preserves dimensions.
7982    #[test]
7983    fn user_rotation_180_preserves_dimensions() {
7984        let doc = load_doc("chicken.djvu");
7985        let page = doc.page(0).unwrap();
7986        let pw = page.width() as u32;
7987        let ph = page.height() as u32;
7988
7989        let opts = RenderOptions {
7990            width: pw,
7991            height: ph,
7992            rotation: UserRotation::Rot180,
7993            ..Default::default()
7994        };
7995        let pm = render_pixmap(page, &opts).expect("render");
7996        assert_eq!(pm.width, pw);
7997        assert_eq!(pm.height, ph);
7998    }
7999
8000    /// UserRotation default is None.
8001    #[test]
8002    fn user_rotation_default_is_none() {
8003        assert_eq!(UserRotation::default(), UserRotation::None);
8004        let opts = RenderOptions::default();
8005        assert_eq!(opts.rotation, UserRotation::None);
8006    }
8007
8008    // -- FGbz multi-color palette tests ---------------------------------------
8009
8010    #[test]
8011    fn fgbz_palette_page_renders_multiple_colors() {
8012        // irish.djvu is a single-page file with an FGbz palette.
8013        let doc = load_doc("irish.djvu");
8014        let page = doc.page(0).expect("page 0");
8015        let w = page.width() as u32;
8016        let h = page.height() as u32;
8017        let opts = RenderOptions {
8018            width: w,
8019            height: h,
8020            ..Default::default()
8021        };
8022        let pm = render_pixmap(page, &opts).expect("render should succeed");
8023
8024        // Collect distinct non-white, non-black foreground colors
8025        let mut fg_colors = std::collections::HashSet::new();
8026        for y in 0..h {
8027            for x in 0..w {
8028                let (r, g, b) = pm.get_rgb(x, y);
8029                // Skip white and near-white (background)
8030                if r > 240 && g > 240 && b > 240 {
8031                    continue;
8032                }
8033                fg_colors.insert((r, g, b));
8034            }
8035        }
8036
8037        // A multi-color palette page should produce more than 1 distinct
8038        // foreground color (if it only had 1, it'd be the old bug).
8039        assert!(
8040            fg_colors.len() > 1,
8041            "multi-color palette page should have >1 distinct foreground colors, got {}",
8042            fg_colors.len()
8043        );
8044    }
8045
8046    #[test]
8047    fn lookup_palette_color_uses_blit_map() {
8048        let pal = FgbzPalette {
8049            colors: vec![
8050                PaletteColor { r: 255, g: 0, b: 0 }, // index 0: red
8051                PaletteColor { r: 0, g: 0, b: 255 }, // index 1: blue
8052            ],
8053            indices: vec![1, 0], // blit 0 → color 1 (blue), blit 1 → color 0 (red)
8054        };
8055        let bm = crate::bitmap::Bitmap::new(2, 1);
8056        let blit_map = vec![0i32, 1i32]; // pixel (0,0) → blit 0, pixel (1,0) → blit 1
8057
8058        let c0 = lookup_palette_color(&pal, Some(&blit_map), Some(&bm), 0, 0);
8059        assert_eq!(
8060            (c0.r, c0.g, c0.b),
8061            (0, 0, 255),
8062            "blit 0 → indices[0]=1 → blue"
8063        );
8064
8065        let c1 = lookup_palette_color(&pal, Some(&blit_map), Some(&bm), 1, 0);
8066        assert_eq!(
8067            (c1.r, c1.g, c1.b),
8068            (255, 0, 0),
8069            "blit 1 → indices[1]=0 → red"
8070        );
8071    }
8072
8073    #[test]
8074    fn lookup_palette_color_fallback_without_blit_map() {
8075        let pal = FgbzPalette {
8076            colors: vec![PaletteColor { r: 0, g: 128, b: 0 }],
8077            indices: vec![],
8078        };
8079        let c = lookup_palette_color(&pal, None, None, 0, 0);
8080        assert_eq!(
8081            (c.r, c.g, c.b),
8082            (0, 128, 0),
8083            "should fall back to first color"
8084        );
8085    }
8086
8087    // ── BGjp / FGjp tests ─────────────────────────────────────────────────────
8088
8089    /// Load the synthetic bgjp_test.djvu fixture from the assets directory.
8090    fn load_bgjp_doc() -> DjVuDocument {
8091        load_doc("bgjp_test.djvu")
8092    }
8093
8094    /// BGjp fixture loads without error and reports correct dimensions.
8095    #[test]
8096    fn bgjp_fixture_loads() {
8097        let doc = load_bgjp_doc();
8098        let page = doc.page(0).unwrap();
8099        assert_eq!(page.width(), 4);
8100        assert_eq!(page.height(), 4);
8101    }
8102
8103    /// BGjp chunk is present in the fixture.
8104    #[test]
8105    fn bgjp_chunk_present() {
8106        let doc = load_bgjp_doc();
8107        let page = doc.page(0).unwrap();
8108        assert!(
8109            page.find_chunk(b"BGjp").is_some(),
8110            "fixture must have a BGjp chunk"
8111        );
8112        assert!(
8113            page.bg44_chunks().is_empty(),
8114            "fixture must NOT have BG44 chunks"
8115        );
8116    }
8117
8118    /// `decode_bgjp` returns a non-None Pixmap for the BGjp fixture.
8119    #[test]
8120    fn decode_bgjp_returns_pixmap() {
8121        let doc = load_bgjp_doc();
8122        let page = doc.page(0).unwrap();
8123        let pm = decode_bgjp(page).expect("decode_bgjp must not error");
8124        assert!(pm.is_some(), "decode_bgjp must return Some(Pixmap)");
8125        let pm = pm.unwrap();
8126        assert_eq!(pm.width, 4);
8127        assert_eq!(pm.height, 4);
8128        assert_eq!(pm.data.len(), 4 * 4 * 4); // RGBA
8129    }
8130
8131    /// `decode_bgjp` returns None for a page with no BGjp chunk.
8132    #[test]
8133    fn decode_bgjp_returns_none_without_chunk() {
8134        let doc = load_doc("chicken.djvu");
8135        let page = doc.page(0).unwrap();
8136        let pm = decode_bgjp(page).expect("should not error");
8137        assert!(pm.is_none());
8138    }
8139
8140    /// `decode_jpeg_to_pixmap` produces RGBA output with alpha=255.
8141    #[test]
8142    fn decode_jpeg_to_pixmap_alpha_is_255() {
8143        let doc = load_bgjp_doc();
8144        let page = doc.page(0).unwrap();
8145        let data = page.find_chunk(b"BGjp").unwrap();
8146        let pm = decode_jpeg_to_pixmap(data).expect("decode must succeed");
8147        for chunk in pm.data.as_chunks::<4>().0 {
8148            assert_eq!(chunk[3], 255, "alpha must be 255 for every pixel");
8149        }
8150    }
8151
8152    /// render_pixmap falls back to BGjp when no BG44 chunks are present.
8153    #[test]
8154    fn render_pixmap_uses_bgjp_background() {
8155        let doc = load_bgjp_doc();
8156        let page = doc.page(0).unwrap();
8157        let opts = RenderOptions {
8158            width: 4,
8159            height: 4,
8160            ..Default::default()
8161        };
8162        let pm = render_pixmap(page, &opts).expect("render must succeed");
8163        assert_eq!(pm.width, 4);
8164        assert_eq!(pm.height, 4);
8165    }
8166
8167    /// render_coarse also falls back to BGjp (no BG44 chunks).
8168    #[test]
8169    fn render_coarse_uses_bgjp_background() {
8170        let doc = load_bgjp_doc();
8171        let page = doc.page(0).unwrap();
8172        let opts = RenderOptions {
8173            width: 4,
8174            height: 4,
8175            ..Default::default()
8176        };
8177        let pm = render_coarse(page, &opts).expect("render_coarse must succeed");
8178        assert!(pm.is_some(), "must return Some when BGjp present");
8179        let pm = pm.unwrap();
8180        assert_eq!(pm.width, 4);
8181        assert_eq!(pm.height, 4);
8182    }
8183
8184    // ── Lanczos-3 tests ───────────────────────────────────────────────────────
8185    // (The raw resampler `scale_lanczos3` and its `lanczos3_kernel` now live in
8186    // the `pixmap` module and are unit-tested there; these exercise the render
8187    // path's use of Lanczos-3.)
8188
8189    /// `Resampling::Lanczos3` produces the correct output dimensions.
8190    #[test]
8191    fn render_pixmap_lanczos3_correct_dimensions() {
8192        let doc = load_doc("chicken.djvu");
8193        let page = doc.page(0).unwrap();
8194        let pw = page.width() as u32;
8195        let ph = page.height() as u32;
8196        let tw = pw / 2;
8197        let th = ph / 2;
8198
8199        let opts = RenderOptions {
8200            width: tw,
8201            height: th,
8202            resampling: Resampling::Lanczos3,
8203            ..Default::default()
8204        };
8205        let pm = render_pixmap(page, &opts).expect("Lanczos3 render must succeed");
8206        assert_eq!(pm.width, tw);
8207        assert_eq!(pm.height, th);
8208    }
8209
8210    /// Lanczos-3 and bilinear renders differ (different algorithms produce different output).
8211    #[test]
8212    fn lanczos3_differs_from_bilinear_at_half_scale() {
8213        let doc = load_doc("chicken.djvu");
8214        let page = doc.page(0).unwrap();
8215        let pw = page.width() as u32;
8216        let ph = page.height() as u32;
8217        let tw = pw / 2;
8218        let th = ph / 2;
8219
8220        let bilinear = render_pixmap(
8221            page,
8222            &RenderOptions {
8223                width: tw,
8224                height: th,
8225                resampling: Resampling::Bilinear,
8226                ..Default::default()
8227            },
8228        )
8229        .unwrap();
8230
8231        let lanczos = render_pixmap(
8232            page,
8233            &RenderOptions {
8234                width: tw,
8235                height: th,
8236                resampling: Resampling::Lanczos3,
8237                ..Default::default()
8238            },
8239        )
8240        .unwrap();
8241
8242        // Dimensions must be the same.
8243        assert_eq!(bilinear.width, lanczos.width);
8244        assert_eq!(bilinear.height, lanczos.height);
8245
8246        // But pixel values should differ (algorithms are not identical).
8247        let differ = bilinear
8248            .data
8249            .iter()
8250            .zip(lanczos.data.iter())
8251            .any(|(a, b)| a != b);
8252        assert!(
8253            differ,
8254            "Lanczos3 and bilinear must produce different pixel values"
8255        );
8256    }
8257
8258    /// `Resampling::Bilinear` default is maintained for backward compat.
8259    #[test]
8260    fn resampling_default_is_bilinear() {
8261        let opts = RenderOptions::default();
8262        assert_eq!(opts.resampling, Resampling::Bilinear);
8263    }
8264
8265    // ── render_region tests ───────────────────────────────────────────────────
8266
8267    /// `render_region` allocates only the region-sized buffer (≤ 512 KB for 256×256).
8268    #[test]
8269    fn render_region_allocates_proportionally() {
8270        let doc = load_doc("chicken.djvu");
8271        let page = doc.page(0).unwrap();
8272        let opts = RenderOptions::fit_to_width(page, 1000);
8273        let region = RenderRect {
8274            x: 0,
8275            y: 0,
8276            width: 256,
8277            height: 256,
8278        };
8279        let pm = render_region(page, region, &opts).expect("render_region should succeed");
8280        assert_eq!(pm.width, 256);
8281        assert_eq!(pm.height, 256);
8282        assert_eq!(pm.data.len(), 256 * 256 * 4);
8283        assert!(
8284            pm.data.len() <= 512 * 1024,
8285            "region allocation {} exceeds 512 KB",
8286            pm.data.len()
8287        );
8288    }
8289
8290    /// `render_region` pixels match the same pixels from `render_pixmap`.
8291    #[test]
8292    fn render_region_matches_full_render() {
8293        let doc = load_doc("chicken.djvu");
8294        let page = doc.page(0).unwrap();
8295        let opts = RenderOptions {
8296            width: 100,
8297            height: 80,
8298            ..Default::default()
8299        };
8300        let full = render_pixmap(page, &opts).expect("full render should succeed");
8301        let region = RenderRect {
8302            x: 10,
8303            y: 10,
8304            width: 30,
8305            height: 20,
8306        };
8307        let part = render_region(page, region, &opts).expect("region render should succeed");
8308
8309        assert_eq!(part.width, 30);
8310        assert_eq!(part.height, 20);
8311
8312        for ry in 0..20u32 {
8313            for rx in 0..30u32 {
8314                let full_base = ((10 + ry) as usize * 100 + (10 + rx) as usize) * 4;
8315                let part_base = (ry as usize * 30 + rx as usize) * 4;
8316                assert_eq!(
8317                    &full.data[full_base..full_base + 4],
8318                    &part.data[part_base..part_base + 4],
8319                    "pixel mismatch at region ({rx},{ry}) / full ({},{} )",
8320                    10 + rx,
8321                    10 + ry
8322                );
8323            }
8324        }
8325    }
8326
8327    // ── C4_TILE_CACHE: render_region_tiled ───────────────────────────────────
8328
8329    /// `render_region_tiled` is byte-identical to `render_region` across a
8330    /// scripted "pan": many overlapping regions, some spanning multiple
8331    /// `TILE_SIZE`-aligned tiles, some landing on partial edge tiles at the
8332    /// full render's right/bottom border (full render size deliberately not a
8333    /// multiple of `TILE_SIZE`).
8334    #[test]
8335    fn render_region_tiled_matches_render_region() {
8336        let doc = load_doc("colorbook.djvu");
8337        let page = doc.page(0).unwrap();
8338        // Not a multiple of TILE_SIZE (256), so the sequence below touches
8339        // partial edge tiles too.
8340        let opts = RenderOptions {
8341            width: 900,
8342            height: 700,
8343            ..Default::default()
8344        };
8345
8346        // A little pan sequence: overlapping viewports sweeping across and
8347        // down the page, each straddling tile boundaries differently.
8348        let regions = [
8349            RenderRect {
8350                x: 0,
8351                y: 0,
8352                width: 300,
8353                height: 220,
8354            },
8355            RenderRect {
8356                x: 60,
8357                y: 0,
8358                width: 300,
8359                height: 220,
8360            },
8361            RenderRect {
8362                x: 200,
8363                y: 40,
8364                width: 300,
8365                height: 220,
8366            },
8367            RenderRect {
8368                x: 400,
8369                y: 40,
8370                width: 300,
8371                height: 220,
8372            },
8373            RenderRect {
8374                x: 600,
8375                y: 480,
8376                width: 300,
8377                height: 220,
8378            }, // right/bottom edge tiles
8379            RenderRect {
8380                x: 250,
8381                y: 250,
8382                width: 400,
8383                height: 300,
8384            }, // spans 2x2 tiles
8385            RenderRect {
8386                x: 1,
8387                y: 1,
8388                width: 5,
8389                height: 5,
8390            }, // sub-tile sliver
8391        ];
8392
8393        for region in regions {
8394            let direct = render_region(page, region, &opts).expect("render_region");
8395            let tiled = render_region_tiled(page, region, &opts).expect("render_region_tiled");
8396            assert_eq!(tiled.width, direct.width);
8397            assert_eq!(tiled.height, direct.height);
8398            assert_eq!(
8399                tiled.data, direct.data,
8400                "render_region_tiled diverged from render_region for {region:?}"
8401            );
8402        }
8403    }
8404
8405    /// A second call for a region already fully covered by previously-cached
8406    /// tiles still reproduces the same bytes (exercises the cache-hit path,
8407    /// not just cold tile composition).
8408    #[test]
8409    fn render_region_tiled_repeated_region_matches() {
8410        let doc = load_doc("chicken.djvu");
8411        let page = doc.page(0).unwrap();
8412        let opts = RenderOptions {
8413            width: 640,
8414            height: 480,
8415            ..Default::default()
8416        };
8417        let region = RenderRect {
8418            x: 100,
8419            y: 100,
8420            width: 200,
8421            height: 150,
8422        };
8423
8424        let first = render_region_tiled(page, region, &opts).unwrap();
8425        // Bytes should now be resident in the page's tile cache.
8426        assert!(page.render_cache_bytes() > 0);
8427        let second = render_region_tiled(page, region, &opts).unwrap();
8428        assert_eq!(first.data, second.data);
8429
8430        // A neighbouring, overlapping region should also match a direct render.
8431        let overlapping = RenderRect {
8432            x: 150,
8433            y: 120,
8434            width: 200,
8435            height: 150,
8436        };
8437        let direct = render_region(page, overlapping, &opts).unwrap();
8438        let tiled = render_region_tiled(page, overlapping, &opts).unwrap();
8439        assert_eq!(direct.data, tiled.data);
8440    }
8441
8442    /// Ineligible modes (rotation, Lanczos-3, permissive) fall back to
8443    /// `render_region` and still produce its exact output.
8444    #[test]
8445    fn render_region_tiled_falls_back_for_ineligible_modes() {
8446        let doc = load_doc("chicken.djvu");
8447        let page = doc.page(0).unwrap();
8448        let region = RenderRect {
8449            x: 5,
8450            y: 5,
8451            width: 40,
8452            height: 30,
8453        };
8454
8455        let rotated_opts = RenderOptions {
8456            width: 200,
8457            height: 150,
8458            rotation: UserRotation::Cw90,
8459            ..Default::default()
8460        };
8461        assert_eq!(
8462            render_region(page, region, &rotated_opts).unwrap().data,
8463            render_region_tiled(page, region, &rotated_opts)
8464                .unwrap()
8465                .data
8466        );
8467
8468        let lanczos_opts = RenderOptions {
8469            width: 100,
8470            height: 75,
8471            resampling: Resampling::Lanczos3,
8472            ..Default::default()
8473        };
8474        assert_eq!(
8475            render_region(page, region, &lanczos_opts).unwrap().data,
8476            render_region_tiled(page, region, &lanczos_opts)
8477                .unwrap()
8478                .data
8479        );
8480
8481        let permissive_opts = RenderOptions {
8482            width: 200,
8483            height: 150,
8484            permissive: true,
8485            ..Default::default()
8486        };
8487        assert_eq!(
8488            render_region(page, region, &permissive_opts).unwrap().data,
8489            render_region_tiled(page, region, &permissive_opts)
8490                .unwrap()
8491                .data
8492        );
8493    }
8494
8495    /// The tile cache's byte accounting is bounded (FIFO eviction) and feeds
8496    /// into `render_cache_bytes` / `evict_render_cache` like the other layer
8497    /// caches (C5 integration).
8498    #[test]
8499    fn render_region_tiled_cache_is_budget_bounded_and_evictable() {
8500        let mut doc = load_doc("colorbook.djvu");
8501        let (native_w, native_h) = {
8502            let p = doc.page(0).unwrap();
8503            (p.width() as u32, p.height() as u32)
8504        };
8505        // A render large enough to have many more than
8506        // TILE_CACHE_MAX_BYTES / (TILE_SIZE*TILE_SIZE*4) tiles available.
8507        let opts = RenderOptions {
8508            width: native_w.max(4000),
8509            height: native_h.max(4000),
8510            ..Default::default()
8511        };
8512        let full_w = opts.width;
8513
8514        {
8515            let page = doc.page(0).unwrap();
8516            // Touch many disjoint tiles by requesting a small region in each.
8517            let tiles_per_side = (full_w / TILE_SIZE).clamp(1, 12);
8518            for ty in 0..tiles_per_side {
8519                for tx in 0..tiles_per_side {
8520                    let region = RenderRect {
8521                        x: tx * TILE_SIZE,
8522                        y: ty * TILE_SIZE,
8523                        width: 8,
8524                        height: 8,
8525                    };
8526                    let _ = render_region_tiled(page, region, &opts).unwrap();
8527                }
8528            }
8529            let bytes = page.render_layers().tile_cache_bytes();
8530            assert!(bytes > 0, "expected some tile bytes cached");
8531            assert!(
8532                bytes <= TILE_CACHE_MAX_BYTES,
8533                "tile cache exceeded its byte budget: {bytes} > {TILE_CACHE_MAX_BYTES}"
8534            );
8535        }
8536
8537        // Evicting the whole page's render cache drops the tiles too.
8538        doc.evict_render_caches();
8539        assert_eq!(doc.page(0).unwrap().render_cache_bytes(), 0);
8540    }
8541
8542    /// `render_region_tiled` with zero-size dimensions errors like
8543    /// `render_region`.
8544    #[test]
8545    fn render_region_tiled_rejects_zero_dimensions() {
8546        let doc = load_doc("chicken.djvu");
8547        let page = doc.page(0).unwrap();
8548        let opts = RenderOptions {
8549            width: 100,
8550            height: 80,
8551            ..Default::default()
8552        };
8553        let region = RenderRect {
8554            x: 0,
8555            y: 0,
8556            width: 0,
8557            height: 10,
8558        };
8559        assert!(render_region_tiled(page, region, &opts).is_err());
8560    }
8561
8562    /// `render_region` with a byte-aligned x offset on a bilevel page matches the
8563    /// full render — exercises the generalized P2 BILEVEL_RGBA fast path (#433),
8564    /// which fires when `offset_x % 8 == 0`.
8565    #[test]
8566    fn render_region_bilevel_byte_aligned_offset_matches_full() {
8567        let doc = load_doc("boy_jb2.djvu");
8568        let page = doc.page(0).unwrap();
8569        let full_w = page.width() as u32;
8570        let full_h = page.height() as u32;
8571        let opts = RenderOptions {
8572            width: full_w,
8573            height: full_h,
8574            ..Default::default()
8575        };
8576        let full = render_pixmap(page, &opts).expect("full render should succeed");
8577        // x=16 is byte-aligned (16 % 8 == 0) → generalized P2 path; x=17 below isn't.
8578        for &x in &[16u32, 17u32] {
8579            let region = RenderRect {
8580                x,
8581                y: 8,
8582                width: 64,
8583                height: 32,
8584            };
8585            let part = render_region(page, region, &opts).expect("region render should succeed");
8586            for ry in 0..region.height {
8587                for rx in 0..region.width {
8588                    let fb = (((region.y + ry) * full_w + (x + rx)) * 4) as usize;
8589                    let pb = ((ry * region.width + rx) * 4) as usize;
8590                    assert_eq!(
8591                        &full.data[fb..fb + 4],
8592                        &part.data[pb..pb + 4],
8593                        "mismatch at x={x} region ({rx},{ry})"
8594                    );
8595                }
8596            }
8597        }
8598    }
8599
8600    /// `render_region` with invalid dimensions returns an error.
8601    #[test]
8602    fn render_region_invalid_dimensions() {
8603        let doc = load_doc("chicken.djvu");
8604        let page = doc.page(0).unwrap();
8605        let opts = RenderOptions {
8606            width: 100,
8607            height: 100,
8608            ..Default::default()
8609        };
8610        let region = RenderRect {
8611            x: 0,
8612            y: 0,
8613            width: 0,
8614            height: 50,
8615        };
8616        let err = render_region(page, region, &opts).unwrap_err();
8617        assert!(
8618            matches!(err, RenderError::InvalidDimensions { .. }),
8619            "expected InvalidDimensions, got {err:?}"
8620        );
8621    }
8622
8623    /// `render_pixmap` still works correctly (regression guard).
8624    #[test]
8625    fn render_pixmap_still_works_after_refactor() {
8626        let doc = load_doc("chicken.djvu");
8627        let page = doc.page(0).unwrap();
8628        let opts = RenderOptions {
8629            width: 80,
8630            height: 60,
8631            ..Default::default()
8632        };
8633        let pm = render_pixmap(page, &opts).expect("render_pixmap should succeed");
8634        assert_eq!(pm.width, 80);
8635        assert_eq!(pm.height, 60);
8636        assert_eq!(pm.data.len(), 80 * 60 * 4);
8637    }
8638
8639    /// `best_iw44_subsample` returns expected power-of-2 values.
8640    #[test]
8641    fn best_iw44_subsample_values() {
8642        assert_eq!(best_iw44_subsample(1.0), 1, "scale=1.0 → subsample=1");
8643        assert_eq!(best_iw44_subsample(0.5), 2, "scale=0.5 → subsample=2");
8644        assert_eq!(
8645            best_iw44_subsample(0.375),
8646            4,
8647            "scale=0.375 → subsample=4 (1.5/0.375=4.0, allows 1.5× upscale)"
8648        );
8649        assert_eq!(best_iw44_subsample(0.25), 4, "scale=0.25 → subsample=4");
8650        assert_eq!(
8651            best_iw44_subsample(0.1),
8652            8,
8653            "scale=0.1 → subsample=8 (capped)"
8654        );
8655        assert_eq!(
8656            best_iw44_subsample(0.0),
8657            1,
8658            "scale=0.0 → subsample=1 (edge case)"
8659        );
8660        assert_eq!(
8661            best_iw44_subsample(-1.0),
8662            1,
8663            "scale<0 → subsample=1 (edge case)"
8664        );
8665        assert_eq!(
8666            best_iw44_subsample(2.0),
8667            1,
8668            "scale>1.0 → subsample=1 (no upscaling needed)"
8669        );
8670    }
8671
8672    /// The IW44 decode subsample is derived from the output `width`, not from
8673    /// the deprecated `scale` field — the regression guard for the PDF
8674    /// over-decode (#377).
8675    #[test]
8676    #[allow(deprecated)] // deliberately writes the legacy `scale` field to prove it is ignored
8677    fn decode_subsample_derives_from_width_not_scale_field() {
8678        let doc = load_doc("chicken.djvu");
8679        let page = doc.page(0).unwrap();
8680        let (dw, _) = display_dimensions(page);
8681
8682        // Quarter-width output. The PDF exporter built exactly this — a small
8683        // `width` with `scale` left at the 1.0 default — and the old pipeline
8684        // read `scale` and decoded at full wavelet resolution (subsample 1).
8685        // The decode scale is now derived from `width`, so it is 0.25 →
8686        // subsample 4, and the over-decode is gone.
8687        let opts = RenderOptions {
8688            width: dw / 4,
8689            ..Default::default()
8690        };
8691        assert!((opts.decode_scale(page) - 0.25).abs() < 0.01);
8692        assert_eq!(best_iw44_subsample(opts.decode_scale(page)), 4);
8693
8694        // Writing the legacy `scale` field by hand — to any value — must not
8695        // change the width-derived subsample.
8696        for misleading in [1.0_f32, 0.0, 0.5, 4.0] {
8697            let mut o = RenderOptions {
8698                width: dw / 4,
8699                ..Default::default()
8700            };
8701            o.scale = misleading;
8702            assert_eq!(
8703                best_iw44_subsample(o.decode_scale(page)),
8704                4,
8705                "scale={misleading} must not change the width-derived subsample",
8706            );
8707        }
8708    }
8709
8710    /// INFO-rotated page: the decode scale follows the *native* page width, not
8711    /// the rotation-swapped display width. The compositor scales the native
8712    /// raster into the output buffer before `rotate_pixmap` runs, so a downscaled
8713    /// rotated page must not pick its IW44 subsample from the swapped dimension —
8714    /// doing so over-subsampled a downscaled portrait background (#377 follow-up).
8715    #[test]
8716    fn decode_scale_uses_native_width_for_rotated_page() {
8717        // boy_jb2_rotate90 carries a 90° rotation in its INFO chunk.
8718        let doc = load_doc("boy_jb2_rotate90.djvu");
8719        let page = doc.page(0).unwrap();
8720        let pw = page.width() as u32;
8721        let (dw, _) = display_dimensions(page);
8722        assert_ne!(dw, pw, "fixture must be a non-square INFO-rotated page");
8723
8724        // The raster exporters size the output from the native page width
8725        // (page.width() * s); at s = 0.5 the half-size render must decode at 0.5.
8726        let opts = RenderOptions {
8727            width: pw / 2,
8728            height: (page.height() as u32) / 2,
8729            ..Default::default()
8730        };
8731        let native = opts.width as f32 / pw as f32; // correct: width / native width
8732        let display = opts.width as f32 / dw as f32; // the old bug: width / display width
8733        assert!(
8734            (opts.decode_scale(page) - native).abs() < 1e-4,
8735            "decode_scale {} should equal the native-width ratio {native}",
8736            opts.decode_scale(page),
8737        );
8738        assert!(
8739            (opts.decode_scale(page) - display).abs() > 1e-2,
8740            "decode_scale must not follow the rotation-swapped display width ({display})",
8741        );
8742    }
8743
8744    /// Rendering with bg_subsample=2 (scale=0.5) produces the correct output dimensions.
8745    #[test]
8746    fn render_pixmap_subsampled_bg_correct_dimensions() {
8747        let doc = load_doc("boy.djvu");
8748        let page = doc.page(0).unwrap();
8749        // width = half the page → decode_scale ≈ 0.5 → bg_subsample=2 internally
8750        let opts = RenderOptions {
8751            width: (page.width() as f32 * 0.5) as u32,
8752            height: (page.height() as f32 * 0.5) as u32,
8753            ..Default::default()
8754        };
8755        let pm = render_pixmap(page, &opts).expect("subsampled render should succeed");
8756        assert_eq!(pm.width, opts.width);
8757        assert_eq!(pm.height, opts.height);
8758        assert_eq!(
8759            pm.data.len() as u64,
8760            opts.width as u64 * opts.height as u64 * 4
8761        );
8762    }
8763
8764    /// Second render of the same page produces identical pixels — confirms the
8765    /// BG44 cache is used and does not corrupt output.
8766    #[test]
8767    fn decoded_bg44_cache_produces_identical_pixels_on_second_render() {
8768        let doc = load_doc("boy.djvu");
8769        let page = doc.page(0).unwrap();
8770        let opts = RenderOptions {
8771            width: page.width() as u32,
8772            height: page.height() as u32,
8773            ..Default::default()
8774        };
8775        let pm1 = render_pixmap(page, &opts).expect("first render should succeed");
8776        let pm2 = render_pixmap(page, &opts).expect("second render should succeed");
8777        assert_eq!(
8778            pm1.data, pm2.data,
8779            "cached render must produce identical pixels"
8780        );
8781    }
8782
8783    /// After the first render the `decoded_bg44` cache is populated — the
8784    /// image dimensions match the page's raw BG44 size.
8785    #[test]
8786    fn decoded_bg44_is_populated_after_render() {
8787        let doc = load_doc("boy.djvu");
8788        let page = doc.page(0).unwrap();
8789        let opts = RenderOptions {
8790            width: page.width() as u32,
8791            height: page.height() as u32,
8792            ..Default::default()
8793        };
8794        // Trigger cache population.
8795        render_pixmap(page, &opts).expect("render should succeed");
8796        // Cache must now hold an image whose size matches the page's native size.
8797        let cached = page
8798            .decoded_bg44()
8799            .expect("cache should be populated after render");
8800        assert_eq!(
8801            cached.width,
8802            page.width() as u32,
8803            "cached bg44 width must equal page width"
8804        );
8805        assert_eq!(
8806            cached.height,
8807            page.height() as u32,
8808            "cached bg44 height must equal page height"
8809        );
8810    }
8811
8812    /// `downsample_mask_4x` is render-tier logic now testable on a hand-built
8813    /// bitmap — no DjVu bytes to parse. Each 4×4 source block collapses to one
8814    /// output bit that is set iff any source bit in the block is set.
8815    #[test]
8816    fn downsample_mask_4x_max_pools_each_block() {
8817        // 8×8 mask: set a single pixel in the top-left block and one in the
8818        // bottom-right block; the other two blocks stay clear.
8819        let mut src = crate::bitmap::Bitmap::new(8, 8);
8820        src.set(1, 2, true); // top-left 4×4 block
8821        src.set(6, 5, true); // bottom-right 4×4 block
8822        let out = downsample_mask_4x(&src);
8823        assert_eq!((out.width, out.height), (2, 2));
8824        assert!(out.get(0, 0), "top-left block had a set bit");
8825        assert!(!out.get(1, 0), "top-right block was empty");
8826        assert!(!out.get(0, 1), "bottom-left block was empty");
8827        assert!(out.get(1, 1), "bottom-right block had a set bit");
8828    }
8829
8830    /// A non-multiple-of-4 mask rounds up: a 5×5 source yields a 2×2 result and
8831    /// the ragged edge block still max-pools its single column/row.
8832    #[test]
8833    fn downsample_mask_4x_rounds_up_ragged_edges() {
8834        let mut src = crate::bitmap::Bitmap::new(5, 5);
8835        src.set(4, 4, true); // lone pixel in the ragged bottom-right block
8836        let out = downsample_mask_4x(&src);
8837        assert_eq!((out.width, out.height), (2, 2));
8838        assert!(out.get(1, 1), "ragged corner block must capture its bit");
8839        assert!(!out.get(0, 0));
8840    }
8841
8842    /// `render_region` applies page rotation the same way as `render_pixmap`.
8843    ///
8844    /// For a 90° CW rotation a non-square region of width×height is returned as
8845    /// height×width — proving rotation was applied (not silently skipped).
8846    #[test]
8847    fn render_region_applies_rotation() {
8848        let doc = load_doc("chicken.djvu");
8849        let page = doc.page(0).unwrap();
8850        // Request an explicit 90° CW user rotation.
8851        let opts = RenderOptions {
8852            width: 80,
8853            height: 60,
8854            rotation: UserRotation::Cw90,
8855            ..Default::default()
8856        };
8857        // Non-square region so swapped dimensions are detectable.
8858        let region = RenderRect {
8859            x: 0,
8860            y: 0,
8861            width: 40,
8862            height: 20,
8863        };
8864        let part = render_region(page, region, &opts).expect("region render should succeed");
8865        // After CW90 rotation a 40×20 region becomes 20×40.
8866        assert_eq!(
8867            part.width, 20,
8868            "expected width=20 (was region.height) after CW90 rotation"
8869        );
8870        assert_eq!(
8871            part.height, 40,
8872            "expected height=40 (was region.width) after CW90 rotation"
8873        );
8874    }
8875
8876    /// #691: `render_region` matches the same crop of `render_pixmap` even
8877    /// when the downscale activates the 1/4-resolution mask fast path
8878    /// (`bg_subsample >= 4`, no bold, no FGbz) — the region path must take
8879    /// the same `resolve_sub4_mask` decision as the full-page path.
8880    #[test]
8881    fn render_region_matches_full_render_crop_at_sub4() {
8882        let doc = load_doc("boy_jb2.djvu");
8883        let page = doc.page(0).unwrap();
8884        let opts = RenderOptions {
8885            width: 40,
8886            height: 52,
8887            ..Default::default()
8888        };
8889        let full = render_pixmap(page, &opts).unwrap();
8890        let region = RenderRect {
8891            x: 8,
8892            y: 8,
8893            width: 24,
8894            height: 24,
8895        };
8896        let reg = render_region(page, region, &opts).unwrap();
8897        let mut crop = Vec::new();
8898        for y in 0..24usize {
8899            let s = ((8 + y) * 40 + 8) * 4;
8900            crop.extend_from_slice(&full.data[s..s + 24 * 4]);
8901        }
8902        assert_eq!(
8903            reg.data, crop,
8904            "render_region must be byte-identical to the matching crop of render_pixmap"
8905        );
8906    }
8907
8908    // ── Issue #225: render_rows byte-identical to render_into (direct-write path) ──
8909
8910    // ── Issue #225 Phase 2: public render_streaming API ──────────────────────
8911
8912    /// `render_streaming` must produce byte-for-byte identical output to
8913    /// `render_pixmap` when no post-processing options are set (no aa, no
8914    /// Lanczos scaling, no rotation).
8915    #[test]
8916    fn render_streaming_byte_identical_to_render_pixmap_color() {
8917        let doc = load_doc("chicken.djvu");
8918        let page = doc.page(0).unwrap();
8919        let w = 60u32;
8920        let h = 80u32;
8921        let opts = RenderOptions {
8922            width: w,
8923            height: h,
8924            ..Default::default()
8925        };
8926
8927        let pm = render_pixmap(page, &opts).expect("render_pixmap should succeed");
8928
8929        let row_stride = w as usize * 4;
8930        let mut streamed = vec![0u8; w as usize * h as usize * 4];
8931        render_streaming(page, &opts, |y, row| {
8932            assert_eq!(row.len(), row_stride);
8933            let start = y * row_stride;
8934            streamed[start..start + row_stride].copy_from_slice(row);
8935        })
8936        .expect("render_streaming should succeed");
8937
8938        assert_eq!(
8939            pm.data, streamed,
8940            "render_streaming must be byte-identical to render_pixmap when no post-processing options are set"
8941        );
8942    }
8943
8944    /// `render_streaming` must produce byte-for-byte identical output to
8945    /// `render_pixmap` for a bilevel page.
8946    #[test]
8947    fn render_streaming_byte_identical_to_render_pixmap_bilevel() {
8948        let doc = load_doc("boy_jb2.djvu");
8949        let page = doc.page(0).unwrap();
8950        let w = 50u32;
8951        let h = 70u32;
8952        let opts = RenderOptions {
8953            width: w,
8954            height: h,
8955            ..Default::default()
8956        };
8957
8958        let pm = render_pixmap(page, &opts).expect("render_pixmap should succeed");
8959
8960        let row_stride = w as usize * 4;
8961        let mut streamed = vec![0u8; w as usize * h as usize * 4];
8962        render_streaming(page, &opts, |y, row| {
8963            let start = y * row_stride;
8964            streamed[start..start + row_stride].copy_from_slice(row);
8965        })
8966        .expect("render_streaming should succeed");
8967
8968        assert_eq!(pm.data, streamed);
8969    }
8970
8971    /// `render_streaming` rejects anti-aliasing with `UnsupportedOption`.
8972    #[test]
8973    fn render_streaming_rejects_aa() {
8974        let doc = load_doc("chicken.djvu");
8975        let page = doc.page(0).unwrap();
8976        let opts = RenderOptions {
8977            width: 60,
8978            height: 80,
8979            aa: true,
8980            ..Default::default()
8981        };
8982        let err = render_streaming(page, &opts, |_, _| {}).unwrap_err();
8983        assert!(
8984            matches!(err, RenderError::UnsupportedOption(_)),
8985            "expected UnsupportedOption, got {err:?}"
8986        );
8987    }
8988
8989    /// `render_streaming` rejects Lanczos-3 when scaling actually happens.
8990    #[test]
8991    fn render_streaming_rejects_lanczos_with_scaling() {
8992        let doc = load_doc("chicken.djvu");
8993        let page = doc.page(0).unwrap();
8994        // Force scaling by picking dimensions ≠ the page's native size.
8995        let opts = RenderOptions {
8996            width: page.width() as u32 / 2,
8997            height: page.height() as u32 / 2,
8998            resampling: Resampling::Lanczos3,
8999            ..Default::default()
9000        };
9001        let err = render_streaming(page, &opts, |_, _| {}).unwrap_err();
9002        assert!(matches!(err, RenderError::UnsupportedOption(_)));
9003    }
9004
9005    /// `render_streaming` allows Lanczos-3 when output matches native page
9006    /// dimensions (Lanczos is a no-op in that case).
9007    #[test]
9008    fn render_streaming_allows_lanczos_at_native_size() {
9009        let doc = load_doc("chicken.djvu");
9010        let page = doc.page(0).unwrap();
9011        let opts = RenderOptions {
9012            width: page.width() as u32,
9013            height: page.height() as u32,
9014            resampling: Resampling::Lanczos3,
9015            ..Default::default()
9016        };
9017        let mut row_count = 0usize;
9018        render_streaming(page, &opts, |_, _| row_count += 1).expect("should succeed at native");
9019        assert_eq!(row_count, page.height() as usize);
9020    }
9021
9022    /// `render_streaming` rejects user rotation.
9023    #[test]
9024    fn render_streaming_rejects_user_rotation() {
9025        let doc = load_doc("chicken.djvu");
9026        let page = doc.page(0).unwrap();
9027        let opts = RenderOptions {
9028            width: 60,
9029            height: 80,
9030            rotation: UserRotation::Cw90,
9031            ..Default::default()
9032        };
9033        let err = render_streaming(page, &opts, |_, _| {}).unwrap_err();
9034        assert!(matches!(err, RenderError::UnsupportedOption(_)));
9035    }
9036
9037    /// `render_streaming` rejects zero dimensions with `InvalidDimensions`.
9038    #[test]
9039    fn render_streaming_rejects_zero_dimensions() {
9040        let doc = load_doc("chicken.djvu");
9041        let page = doc.page(0).unwrap();
9042        let opts = RenderOptions {
9043            width: 0,
9044            height: 80,
9045            ..Default::default()
9046        };
9047        let err = render_streaming(page, &opts, |_, _| {}).unwrap_err();
9048        assert!(matches!(err, RenderError::InvalidDimensions { .. }));
9049    }
9050
9051    // ── Permissive render path ────────────────────────────────────────────────
9052
9053    /// Permissive render on a standard IW44+JB2 page completes without error.
9054    #[test]
9055    fn permissive_render_iw44_jb2_page() {
9056        let doc = load_doc("czech.djvu");
9057        let page = doc.page(0).unwrap();
9058        let opts = RenderOptions {
9059            width: 40,
9060            height: 40,
9061            permissive: true,
9062            ..Default::default()
9063        };
9064        let pm = render_pixmap(page, &opts).expect("permissive render should not error");
9065        assert_eq!(pm.width, 40);
9066        assert_eq!(pm.height, 40);
9067    }
9068
9069    /// Oversized render dimensions must be rejected, not OOM / panic on an empty
9070    /// overflow pixmap (security finding).
9071    #[test]
9072    fn oversized_render_dimensions_are_rejected() {
9073        let doc = load_doc("czech.djvu");
9074        let page = doc.page(0).unwrap();
9075        let opts = RenderOptions {
9076            width: 60_000,
9077            height: 60_000, // 3.6 G px > MAX_RENDER_PIXELS
9078            permissive: true,
9079            ..Default::default()
9080        };
9081        assert!(matches!(
9082            render_pixmap(page, &opts),
9083            Err(RenderError::ResourceLimit(_))
9084        ));
9085    }
9086
9087    #[test]
9088    fn render_into_rejects_oversized_output_with_typed_limit_error() {
9089        let doc = load_doc("czech.djvu");
9090        let page = doc.page(0).unwrap();
9091        let opts = RenderOptions {
9092            width: 60_000,
9093            height: 60_000,
9094            permissive: true,
9095            ..Default::default()
9096        };
9097        let mut buf = vec![0u8; 16];
9098        assert!(matches!(
9099            render_into(page, &opts, &mut buf),
9100            Err(RenderError::ResourceLimit(_))
9101        ));
9102    }
9103
9104    #[test]
9105    fn configurable_render_limit_overrides_inherited_ceiling() {
9106        let doc = load_doc("czech.djvu");
9107        let page = doc.page(0).unwrap();
9108        let opts = RenderOptions {
9109            width: 500,
9110            height: 500,
9111            permissive: true,
9112            ..Default::default()
9113        };
9114        let limits = crate::resource_limits::ResourceLimits {
9115            max_render_pixels: Some(100_000),
9116            ..Default::default()
9117        };
9118        assert!(matches!(
9119            render_pixmap_with_limits(page, &opts, Some(limits)),
9120            Err(RenderError::ResourceLimit(exceeded)) if exceeded.operation == "render_pixmap"
9121                && exceeded.axis == crate::resource_limits::ResourceLimitAxis::RenderOutputPixels
9122        ));
9123    }
9124
9125    /// Permissive render on a BGjp page (no BG44) falls through to decode_bgjp.
9126    #[test]
9127    fn permissive_render_bgjp_page() {
9128        let doc = load_doc("bgjp_test.djvu");
9129        let page = doc.page(0).unwrap();
9130        let opts = RenderOptions {
9131            width: 4,
9132            height: 4,
9133            permissive: true,
9134            ..Default::default()
9135        };
9136        let pm = render_pixmap(page, &opts).expect("permissive render of BGjp page should succeed");
9137        assert_eq!(pm.width, 4);
9138        assert_eq!(pm.height, 4);
9139    }
9140
9141    /// Permissive render on a page with FGbz palette exercises the indexed mask path.
9142    #[test]
9143    fn permissive_render_fgbz_page() {
9144        let doc = load_doc("navm_fgbz.djvu");
9145        let page = doc.page(0).unwrap();
9146        let opts = RenderOptions {
9147            width: 40,
9148            height: 40,
9149            permissive: true,
9150            ..Default::default()
9151        };
9152        let pm = render_pixmap(page, &opts).expect("permissive render of FGbz page should succeed");
9153        assert!(pm.width > 0 && pm.height > 0);
9154    }
9155
9156    /// render_gray8 produces a grayscale pixmap.
9157    #[test]
9158    fn render_gray8_produces_grayscale_output() {
9159        let doc = load_doc("boy_jb2.djvu");
9160        let page = doc.page(0).unwrap();
9161        let opts = RenderOptions {
9162            width: 20,
9163            height: 20,
9164            ..Default::default()
9165        };
9166        let gpm = render_gray8(page, &opts).expect("render_gray8 should succeed");
9167        assert_eq!(gpm.width, 20);
9168        assert_eq!(gpm.height, 20);
9169        assert_eq!(gpm.data.len(), 20 * 20);
9170    }
9171
9172    /// render_coarse rejects zero width.
9173    #[test]
9174    fn render_coarse_rejects_zero_dimensions() {
9175        let doc = load_doc("chicken.djvu");
9176        let page = doc.page(0).unwrap();
9177        let opts = RenderOptions {
9178            width: 0,
9179            height: 50,
9180            ..Default::default()
9181        };
9182        let err = render_coarse(page, &opts).unwrap_err();
9183        assert!(matches!(err, RenderError::InvalidDimensions { .. }));
9184    }
9185
9186    /// render_coarse on a JB2-only page returns None.
9187    #[test]
9188    fn render_coarse_jb2_only_page_returns_none() {
9189        let doc = load_doc("boy_jb2.djvu");
9190        let page = doc.page(0).unwrap();
9191        let opts = RenderOptions {
9192            width: 40,
9193            height: 40,
9194            ..Default::default()
9195        };
9196        let result = render_coarse(page, &opts).expect("should not error");
9197        assert!(
9198            result.is_none(),
9199            "JB2-only page has no BG44 so render_coarse yields None"
9200        );
9201    }
9202
9203    /// render_progressive rejects zero dimensions.
9204    #[test]
9205    fn render_progressive_rejects_zero_dimensions() {
9206        let doc = load_doc("chicken.djvu");
9207        let page = doc.page(0).unwrap();
9208        let opts = RenderOptions {
9209            width: 0,
9210            height: 50,
9211            ..Default::default()
9212        };
9213        let err = render_progressive(page, &opts, 0).unwrap_err();
9214        assert!(matches!(err, RenderError::InvalidDimensions { .. }));
9215    }
9216
9217    /// render_progressive on a JB2-only page (no BG44) completes successfully.
9218    #[test]
9219    fn render_progressive_jb2_only_page_succeeds() {
9220        let doc = load_doc("boy_jb2.djvu");
9221        let page = doc.page(0).unwrap();
9222        let opts = RenderOptions {
9223            width: 40,
9224            height: 40,
9225            ..Default::default()
9226        };
9227        let result = render_progressive(page, &opts, 0)
9228            .expect("render_progressive should succeed even without BG44");
9229        assert_eq!(result.width, 40);
9230        assert_eq!(result.height, 40);
9231    }
9232
9233    /// Lanczos3 at native resolution skips the re-render (need_scale=false path).
9234    #[test]
9235    fn lanczos3_at_native_resolution_skips_rerender() {
9236        let doc = load_doc("bgjp_test.djvu");
9237        let page = doc.page(0).unwrap();
9238        // bgjp_test.djvu is 4×4 — render at native size with Lanczos3
9239        let opts = RenderOptions {
9240            width: page.width() as u32,
9241            height: page.height() as u32,
9242            resampling: Resampling::Lanczos3,
9243            ..Default::default()
9244        };
9245        let pm = render_pixmap(page, &opts).expect("lanczos at native size must succeed");
9246        assert_eq!(pm.width, page.width() as u32);
9247        assert_eq!(pm.height, page.height() as u32);
9248    }
9249
9250    /// render_pixmap rejects zero width.
9251    #[test]
9252    fn render_pixmap_rejects_zero_width() {
9253        let doc = load_doc("chicken.djvu");
9254        let page = doc.page(0).unwrap();
9255        let opts = RenderOptions {
9256            width: 0,
9257            height: 50,
9258            ..Default::default()
9259        };
9260        let err = render_pixmap(page, &opts).unwrap_err();
9261        assert!(matches!(err, RenderError::InvalidDimensions { .. }));
9262    }
9263
9264    /// Build a minimal single-page DJVU document with the given width and height.
9265    fn make_doc_with_dims(w: u16, h: u16) -> Vec<u8> {
9266        use crate::iff::{Chunk, DjvuFile, emit};
9267        let mut info = vec![0u8; 10];
9268        info[0] = (w >> 8) as u8;
9269        info[1] = w as u8;
9270        info[2] = (h >> 8) as u8;
9271        info[3] = h as u8;
9272        let file = DjvuFile {
9273            root: Chunk::Form {
9274                secondary_id: *b"DJVU",
9275                length: 0,
9276                children: vec![Chunk::Leaf {
9277                    id: *b"INFO",
9278                    data: info,
9279                }],
9280            },
9281        };
9282        emit(&file)
9283    }
9284
9285    // ── RenderOptions edge-case helpers ──────────────────────────────────────
9286
9287    #[test]
9288    fn fit_to_width_zero_page_width_uses_requested_width() {
9289        // Line 213: dw == 0 → height = width (same as requested width).
9290        let bytes = make_doc_with_dims(0, 100);
9291        let doc = DjVuDocument::parse(&bytes).unwrap();
9292        let page = doc.page(0).unwrap();
9293        let opts = RenderOptions::fit_to_width(page, 40);
9294        assert_eq!(opts.width, 40);
9295        assert_eq!(opts.height, 40); // because dw==0 → height = width
9296    }
9297
9298    #[test]
9299    fn fit_to_height_zero_page_height_uses_requested_height() {
9300        // Line 232: dh == 0 → width = height (same as requested height).
9301        let bytes = make_doc_with_dims(100, 0);
9302        let doc = DjVuDocument::parse(&bytes).unwrap();
9303        let page = doc.page(0).unwrap();
9304        let opts = RenderOptions::fit_to_height(page, 60);
9305        assert_eq!(opts.height, 60);
9306        assert_eq!(opts.width, 60); // because dh==0 → width = height
9307    }
9308
9309    #[test]
9310    fn fit_to_box_zero_page_dims_falls_back_to_box_max() {
9311        // Lines 255-259: dw==0 || dh==0 → return max_width × max_height with scale=1.
9312        let bytes = make_doc_with_dims(0, 0);
9313        let doc = DjVuDocument::parse(&bytes).unwrap();
9314        let page = doc.page(0).unwrap();
9315        let opts = RenderOptions::fit_to_box(page, 80, 60);
9316        assert_eq!(opts.width, 80);
9317        assert_eq!(opts.height, 60);
9318    }
9319
9320    #[test]
9321    fn can_stream_true_at_native_resolution_with_lanczos3() {
9322        // Line 290: the "|| native dims" branch — evaluated when Bilinear is false.
9323        let doc = load_doc("chicken.djvu");
9324        let page = doc.page(0).unwrap();
9325        let opts = RenderOptions {
9326            width: page.width() as u32,
9327            height: page.height() as u32,
9328            resampling: Resampling::Lanczos3,
9329            ..Default::default()
9330        };
9331        assert!(opts.can_stream(page));
9332    }
9333
9334    /// Rendering a JB2-only (no BG44) page at very small scale triggers
9335    /// subsample >= 4 and exercises bg44_partial's empty-chunks path (line 918).
9336    #[test]
9337    fn render_bilevel_at_tiny_scale_exercises_bg44_partial_empty() {
9338        let doc = load_doc("boy_jb2.djvu");
9339        let page = doc.page(0).unwrap();
9340        // Use a tiny width so decode_scale << 0.25 and best_iw44_subsample >= 4.
9341        let opts = RenderOptions {
9342            width: 10,
9343            height: 10,
9344            ..Default::default()
9345        };
9346        let pm = render_pixmap(page, &opts).expect("tiny bilevel render should succeed");
9347        assert!(pm.width > 0 && pm.height > 0);
9348    }
9349
9350    /// Bold dilation (opts.bold > 0) thickens the mask.
9351    #[test]
9352    fn render_with_bold_dilation_produces_output() {
9353        let doc = load_doc("boy_jb2.djvu");
9354        let page = doc.page(0).unwrap();
9355        let opts = RenderOptions {
9356            width: 40,
9357            height: 40,
9358            bold: 1,
9359            ..Default::default()
9360        };
9361        let pm = render_pixmap(page, &opts).expect("bold render should succeed");
9362        assert_eq!(pm.width, 40);
9363        assert_eq!(pm.height, 40);
9364    }
9365}