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 at render
24//! time. The LUT maps linear 8-bit values to gamma-corrected 8-bit values.
25//! If gamma = 2.2 the LUT is the standard sRGB-approximate power curve.
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::{vec, vec::Vec};
40
41use crate::djvu_document::DjVuPage;
42use crate::iw44_new::Iw44Image;
43use crate::jb2_new;
44use crate::pixmap::{GrayPixmap, Pixmap};
45
46// ── Errors ───────────────────────────────────────────────────────────────────
47
48/// Errors that can occur during DjVuPage rendering.
49#[derive(Debug, thiserror::Error)]
50pub enum RenderError {
51    /// IW44 wavelet decode error.
52    #[error("IW44 decode error: {0}")]
53    Iw44(#[from] crate::error::Iw44Error),
54
55    /// JB2 bilevel decode error.
56    #[error("JB2 decode error: {0}")]
57    Jb2(#[from] crate::error::Jb2Error),
58
59    /// The output buffer provided to `render_into` is too small.
60    #[error("buffer too small: need {need} bytes, got {got}")]
61    BufTooSmall { need: usize, got: usize },
62
63    /// The requested render dimensions are invalid (zero width or height).
64    #[error("invalid render dimensions: {width}x{height}")]
65    InvalidDimensions { width: u32, height: u32 },
66
67    /// `chunk_n` is out of range for progressive rendering.
68    #[error("chunk index {chunk_n} out of range (max {max})")]
69    ChunkOutOfRange { chunk_n: usize, max: usize },
70
71    /// BZZ decompression error (for FGbz palette).
72    #[error("BZZ error: {0}")]
73    Bzz(#[from] crate::error::BzzError),
74
75    /// JPEG decode error (for BGjp/FGjp chunks).
76    #[cfg(feature = "std")]
77    #[error("JPEG decode error: {0}")]
78    Jpeg(String),
79
80    /// Document-level error (e.g. page index out of range).
81    #[error("document error: {0}")]
82    Doc(#[from] crate::djvu_document::DocError),
83}
84
85// ── RenderOptions ─────────────────────────────────────────────────────────────
86
87/// User-requested rotation, applied on top of the INFO chunk rotation.
88///
89/// The final rotation is the sum of the INFO rotation and the user rotation.
90/// For example, if the INFO chunk specifies 90° CW and the user requests 90° CW,
91/// the output will be rotated 180°.
92#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
93pub enum UserRotation {
94    /// No additional rotation (only INFO chunk rotation applies).
95    #[default]
96    None,
97    /// 90° clockwise.
98    Cw90,
99    /// 180°.
100    Rot180,
101    /// 90° counter-clockwise (= 270° clockwise).
102    Ccw90,
103}
104
105/// Resampling algorithm used when scaling a rendered page to the target size.
106///
107/// Applied after full-resolution decode and compositing.
108#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
109pub enum Resampling {
110    /// Bilinear interpolation (default — fast, acceptable quality).
111    #[default]
112    Bilinear,
113    /// Lanczos-3 separable resampling.
114    ///
115    /// Higher quality than bilinear for downscaling (less aliasing, sharper
116    /// text). Slower: two-pass separable filter with a 6-tap kernel.
117    /// The rendered pixmap is produced at full page resolution and then
118    /// downscaled, so memory usage is higher than `Bilinear`.
119    Lanczos3,
120}
121
122/// Rendering parameters passed to `render_into` and related functions.
123///
124/// # Example
125///
126/// ```
127/// use djvu_rs::djvu_render::{RenderOptions, UserRotation};
128///
129/// let opts = RenderOptions {
130///     width: 800,
131///     height: 600,
132///     scale: 1.0,
133///     bold: 0,
134///     aa: true,
135///     rotation: UserRotation::None,
136///     permissive: false,
137///     resampling: djvu_rs::djvu_render::Resampling::Bilinear,
138/// };
139/// ```
140#[derive(Debug, Clone, PartialEq)]
141pub struct RenderOptions {
142    /// Output width in pixels.
143    pub width: u32,
144    /// Output height in pixels.
145    pub height: u32,
146    /// Scale factor (informational; actual size is given by `width`/`height`).
147    pub scale: f32,
148    /// Bold level: number of dilation passes on the JB2 mask (0 = no dilation).
149    pub bold: u8,
150    /// Whether to apply anti-aliasing downscale pass.
151    pub aa: bool,
152    /// User-requested rotation, combined with the INFO chunk rotation.
153    pub rotation: UserRotation,
154    /// When `true`, tolerate corrupted chunks instead of returning an error.
155    ///
156    /// - BG44: decodes chunks until the first decode error; uses whatever
157    ///   was decoded so far (may be empty / blurry).
158    /// - JB2 mask: if decoding fails, renders the background without a mask
159    ///   rather than returning `Err`.
160    ///
161    /// Returns `Ok(pixmap)` even when chunks are skipped. Useful for document
162    /// viewers where a partial render is better than a blank page.
163    ///
164    /// Default: `false` (strict — any decode error propagates as `Err`).
165    pub permissive: bool,
166    /// Resampling algorithm applied when scaling to `width`×`height`.
167    ///
168    /// Default: [`Resampling::Bilinear`] (preserves backward compatibility).
169    pub resampling: Resampling,
170}
171
172impl Default for RenderOptions {
173    fn default() -> Self {
174        RenderOptions {
175            width: 0,
176            height: 0,
177            scale: 1.0,
178            bold: 0,
179            aa: false,
180            rotation: UserRotation::None,
181            permissive: false,
182            resampling: Resampling::Bilinear,
183        }
184    }
185}
186
187impl RenderOptions {
188    /// Create render options that scale the page to fit the given width,
189    /// preserving aspect ratio. Respects page rotation from the INFO chunk.
190    pub fn fit_to_width(page: &crate::djvu_document::DjVuPage, width: u32) -> Self {
191        let (dw, dh) = display_dimensions(page);
192        let height = if dw == 0 {
193            width
194        } else {
195            ((dh as f64 * width as f64) / dw as f64).round() as u32
196        }
197        .max(1);
198        let scale = width as f32 / dw.max(1) as f32;
199        RenderOptions {
200            width,
201            height,
202            scale,
203            ..Default::default()
204        }
205    }
206
207    /// Create render options that scale the page to fit the given height,
208    /// preserving aspect ratio. Respects page rotation from the INFO chunk.
209    pub fn fit_to_height(page: &crate::djvu_document::DjVuPage, height: u32) -> Self {
210        let (dw, dh) = display_dimensions(page);
211        let width = if dh == 0 {
212            height
213        } else {
214            ((dw as f64 * height as f64) / dh as f64).round() as u32
215        }
216        .max(1);
217        let scale = height as f32 / dh.max(1) as f32;
218        RenderOptions {
219            width,
220            height,
221            scale,
222            ..Default::default()
223        }
224    }
225
226    /// Create render options that scale the page to fit within a bounding box,
227    /// preserving aspect ratio. Respects page rotation from the INFO chunk.
228    pub fn fit_to_box(
229        page: &crate::djvu_document::DjVuPage,
230        max_width: u32,
231        max_height: u32,
232    ) -> Self {
233        let (dw, dh) = display_dimensions(page);
234        if dw == 0 || dh == 0 {
235            return RenderOptions {
236                width: max_width.max(1),
237                height: max_height.max(1),
238                scale: 1.0,
239                ..Default::default()
240            };
241        }
242        let scale_w = max_width as f64 / dw as f64;
243        let scale_h = max_height as f64 / dh as f64;
244        let scale = if scale_w < scale_h { scale_w } else { scale_h };
245        let width = (dw as f64 * scale).round() as u32;
246        let height = (dh as f64 * scale).round() as u32;
247        RenderOptions {
248            width: width.max(1),
249            height: height.max(1),
250            scale: scale as f32,
251            ..Default::default()
252        }
253    }
254}
255
256/// Return `(display_width, display_height)` — dimensions after rotation.
257fn display_dimensions(page: &crate::djvu_document::DjVuPage) -> (u32, u32) {
258    let w = page.width() as u32;
259    let h = page.height() as u32;
260    match page.rotation() {
261        crate::info::Rotation::Cw90 | crate::info::Rotation::Ccw90 => (h, w),
262        _ => (w, h),
263    }
264}
265
266// ── Gamma LUT ─────────────────────────────────────────────────────────────────
267
268/// Precompute a gamma-correction look-up table for values 0..255.
269///
270/// The LUT converts linear 8-bit values to display-corrected values using the
271/// gamma exponent from the INFO chunk (e.g. 2.2).
272///
273/// `lut[i] = round(255 * (i/255)^(1/gamma))`
274///
275/// When `gamma <= 0.0` or not finite, falls back to identity (no correction).
276fn build_gamma_lut(gamma: f32) -> [u8; 256] {
277    let mut lut = [0u8; 256];
278    if gamma <= 0.0 || !gamma.is_finite() || (gamma - 1.0).abs() < 1e-4 {
279        // Identity
280        for (i, v) in lut.iter_mut().enumerate() {
281            *v = i as u8;
282        }
283        return lut;
284    }
285    let inv_gamma = 1.0 / gamma;
286    for (i, v) in lut.iter_mut().enumerate() {
287        let linear = i as f32 / 255.0;
288        let corrected = linear.powf(inv_gamma);
289        *v = (corrected * 255.0 + 0.5) as u8;
290    }
291    lut
292}
293
294// ── Bilinear scaling (FRACBITS = 4) ──────────────────────────────────────────
295
296/// Fixed-point fractional bits for bilinear scaling (1 << 4 = 16 subpixels).
297const FRACBITS: u32 = 4;
298const FRAC: u32 = 1 << FRACBITS;
299const FRAC_MASK: u32 = FRAC - 1;
300
301/// Sample a pixmap at fractional coordinates using bilinear interpolation.
302///
303/// Coordinates are in fixed-point: `fx = x * FRAC`, etc.
304/// Returns (r, g, b).
305#[inline]
306fn sample_bilinear(pm: &Pixmap, fx: u32, fy: u32) -> (u8, u8, u8) {
307    let x0 = (fx >> FRACBITS).min(pm.width.saturating_sub(1));
308    let y0 = (fy >> FRACBITS).min(pm.height.saturating_sub(1));
309    let x1 = (x0 + 1).min(pm.width.saturating_sub(1));
310    let y1 = (y0 + 1).min(pm.height.saturating_sub(1));
311
312    let tx = fx & FRAC_MASK; // 0..15
313    let ty = fy & FRAC_MASK;
314
315    let (r00, g00, b00) = pm.get_rgb(x0, y0);
316    let (r10, g10, b10) = pm.get_rgb(x1, y0);
317    let (r01, g01, b01) = pm.get_rgb(x0, y1);
318    let (r11, g11, b11) = pm.get_rgb(x1, y1);
319
320    let lerp = |a: u8, b: u8, c: u8, d: u8| -> u8 {
321        let top = a as u32 * (FRAC - tx) + b as u32 * tx;
322        let bot = c as u32 * (FRAC - tx) + d as u32 * tx;
323        let v = (top * (FRAC - ty) + bot * ty) >> (2 * FRACBITS);
324        v.min(255) as u8
325    };
326
327    (
328        lerp(r00, r10, r01, r11),
329        lerp(g00, g10, g01, g11),
330        lerp(b00, b10, b01, b11),
331    )
332}
333
334/// Area-average (box filter) sample: average all source pixels covered by the
335/// output pixel's footprint.  Used when downscaling (scale < 1.0) for better
336/// anti-aliasing and fewer moire patterns than bilinear.
337///
338/// `fx`, `fy` are the top-left corner of the output pixel in fixed-point.
339/// `fx_step`, `fy_step` are the output pixel size in source coordinates.
340#[inline]
341fn sample_area_avg(pm: &Pixmap, fx: u32, fy: u32, fx_step: u32, fy_step: u32) -> (u8, u8, u8) {
342    let x0 = (fx >> FRACBITS).min(pm.width.saturating_sub(1));
343    let y0 = (fy >> FRACBITS).min(pm.height.saturating_sub(1));
344    let x1 = ((fx + fx_step) >> FRACBITS).min(pm.width.saturating_sub(1));
345    let y1 = ((fy + fy_step) >> FRACBITS).min(pm.height.saturating_sub(1));
346
347    // Fast path: box is 1×1 pixel → just read it
348    if x0 == x1 && y0 == y1 {
349        return pm.get_rgb(x0, y0);
350    }
351
352    let mut r_sum = 0u32;
353    let mut g_sum = 0u32;
354    let mut b_sum = 0u32;
355
356    let pw = pm.width as usize;
357    let cols = (x1 - x0 + 1) as usize;
358    let rows = (y1 - y0 + 1) as usize;
359
360    // Read directly from the RGBA data buffer for speed
361    for sy in y0..=y1 {
362        let row_off = (sy as usize * pw + x0 as usize) * 4;
363        for c in 0..cols {
364            let off = row_off + c * 4;
365            if let Some(px) = pm.data.get(off..off + 3) {
366                r_sum += px[0] as u32;
367                g_sum += px[1] as u32;
368                b_sum += px[2] as u32;
369            }
370        }
371    }
372
373    let count = (rows * cols) as u32;
374    if count == 0 {
375        return (255, 255, 255);
376    }
377
378    (
379        ((r_sum + count / 2) / count) as u8,
380        ((g_sum + count / 2) / count) as u8,
381        ((b_sum + count / 2) / count) as u8,
382    )
383}
384
385// ── Lanczos-3 resampling ─────────────────────────────────────────────────────
386
387/// Lanczos-3 kernel: `sinc(x) * sinc(x/3)` for `|x| < 3`, 0 otherwise.
388///
389/// Uses the normalised sinc: `sinc(x) = sin(π x) / (π x)`, `sinc(0) = 1`.
390#[inline]
391fn lanczos3_kernel(x: f32) -> f32 {
392    let ax = x.abs();
393    if ax >= 3.0 {
394        return 0.0;
395    }
396    if ax < 1e-6 {
397        return 1.0;
398    }
399    let pi_x = core::f32::consts::PI * ax;
400    let sinc_x = pi_x.sin() / pi_x;
401    let pi_x3 = pi_x / 3.0;
402    let sinc_x3 = pi_x3.sin() / pi_x3;
403    sinc_x * sinc_x3
404}
405
406/// Scale `src` to `dst_w × dst_h` using separable Lanczos-3 resampling.
407///
408/// Two-pass implementation:
409/// 1. Horizontal pass: `src_w × src_h` → `dst_w × src_h` intermediate.
410/// 2. Vertical pass: `dst_w × src_h` → `dst_w × dst_h` output.
411///
412/// Only RGBA pixmaps are handled (alpha is passed through unchanged at 255).
413pub fn scale_lanczos3(src: &Pixmap, dst_w: u32, dst_h: u32) -> Pixmap {
414    let src_w = src.width;
415    let src_h = src.height;
416
417    // Short-circuit: nothing to scale.
418    if src_w == dst_w && src_h == dst_h {
419        return src.clone();
420    }
421    if dst_w == 0 || dst_h == 0 {
422        return Pixmap::white(dst_w.max(1), dst_h.max(1));
423    }
424
425    // ── Horizontal pass ───────────────────────────────────────────────────────
426    // Map each output column `ox` (0..dst_w) to a source position, then sum
427    // the Lanczos-3 kernel over the contributing source columns.
428    let h_scale = src_w as f32 / dst_w as f32;
429    let h_support = (3.0_f32 * h_scale.max(1.0)).ceil() as i32; // kernel half-width in src pixels
430
431    let mut mid = Pixmap::new(dst_w, src_h, 255, 255, 255, 255);
432    for oy in 0..src_h {
433        for ox in 0..dst_w {
434            // Centre of the output pixel in source coordinates.
435            let cx = (ox as f32 + 0.5) * h_scale - 0.5;
436            let x0 = (cx.floor() as i32 - h_support + 1).max(0);
437            let x1 = (cx.floor() as i32 + h_support).min(src_w as i32 - 1);
438
439            let mut r = 0.0_f32;
440            let mut g = 0.0_f32;
441            let mut b = 0.0_f32;
442            let mut w_sum = 0.0_f32;
443
444            for sx in x0..=x1 {
445                let w = lanczos3_kernel((sx as f32 - cx) / h_scale.max(1.0));
446                let (pr, pg, pb) = src.get_rgb(sx as u32, oy);
447                r += pr as f32 * w;
448                g += pg as f32 * w;
449                b += pb as f32 * w;
450                w_sum += w;
451            }
452
453            let norm = if w_sum.abs() > 1e-6 { 1.0 / w_sum } else { 1.0 };
454            mid.set_rgb(
455                ox,
456                oy,
457                (r * norm).round().clamp(0.0, 255.0) as u8,
458                (g * norm).round().clamp(0.0, 255.0) as u8,
459                (b * norm).round().clamp(0.0, 255.0) as u8,
460            );
461        }
462    }
463
464    // ── Vertical pass ─────────────────────────────────────────────────────────
465    let v_scale = src_h as f32 / dst_h as f32;
466    let v_support = (3.0_f32 * v_scale.max(1.0)).ceil() as i32;
467
468    let mut out = Pixmap::new(dst_w, dst_h, 255, 255, 255, 255);
469    for oy in 0..dst_h {
470        let cy = (oy as f32 + 0.5) * v_scale - 0.5;
471        let y0 = (cy.floor() as i32 - v_support + 1).max(0);
472        let y1 = (cy.floor() as i32 + v_support).min(src_h as i32 - 1);
473
474        for ox in 0..dst_w {
475            let mut r = 0.0_f32;
476            let mut g = 0.0_f32;
477            let mut b = 0.0_f32;
478            let mut w_sum = 0.0_f32;
479
480            for sy in y0..=y1 {
481                let w = lanczos3_kernel((sy as f32 - cy) / v_scale.max(1.0));
482                let (pr, pg, pb) = mid.get_rgb(ox, sy as u32);
483                r += pr as f32 * w;
484                g += pg as f32 * w;
485                b += pb as f32 * w;
486                w_sum += w;
487            }
488
489            let norm = if w_sum.abs() > 1e-6 { 1.0 / w_sum } else { 1.0 };
490            out.set_rgb(
491                ox,
492                oy,
493                (r * norm).round().clamp(0.0, 255.0) as u8,
494                (g * norm).round().clamp(0.0, 255.0) as u8,
495                (b * norm).round().clamp(0.0, 255.0) as u8,
496            );
497        }
498    }
499
500    out
501}
502
503/// Check whether any pixel in the mask box is set (foreground).
504/// Used for area-averaging downscale to determine if a box has foreground.
505#[inline]
506fn mask_box_any(
507    mask: &crate::bitmap::Bitmap,
508    fx: u32,
509    fy: u32,
510    fx_step: u32,
511    fy_step: u32,
512) -> bool {
513    let x0 = (fx >> FRACBITS).min(mask.width.saturating_sub(1));
514    let y0 = (fy >> FRACBITS).min(mask.height.saturating_sub(1));
515    let x1 = ((fx + fx_step) >> FRACBITS).min(mask.width.saturating_sub(1));
516    let y1 = ((fy + fy_step) >> FRACBITS).min(mask.height.saturating_sub(1));
517
518    for sy in y0..=y1 {
519        for sx in x0..=x1 {
520            if mask.get(sx, sy) {
521                return true;
522            }
523        }
524    }
525    false
526}
527
528/// Find the center foreground pixel in a mask box for palette color lookup.
529#[inline]
530fn mask_box_center_fg(
531    mask: &crate::bitmap::Bitmap,
532    fx: u32,
533    fy: u32,
534    fx_step: u32,
535    fy_step: u32,
536) -> (u32, u32) {
537    // Use the center of the box
538    let cx = (fx + fx_step / 2) >> FRACBITS;
539    let cy = (fy + fy_step / 2) >> FRACBITS;
540    (
541        cx.min(mask.width.saturating_sub(1)),
542        cy.min(mask.height.saturating_sub(1)),
543    )
544}
545
546// ── Anti-aliasing downscale ──────────────────────────────────────────────────
547
548/// Apply a 2×2 box-filter downscale pass for anti-aliasing.
549///
550/// If either dimension of `pm` is 1, the output dimension stays at 1.
551fn aa_downscale(pm: &Pixmap) -> Pixmap {
552    let out_w = (pm.width / 2).max(1);
553    let out_h = (pm.height / 2).max(1);
554    let mut out = Pixmap::white(out_w, out_h);
555    for y in 0..out_h {
556        for x in 0..out_w {
557            let sx = (x * 2).min(pm.width.saturating_sub(1));
558            let sy = (y * 2).min(pm.height.saturating_sub(1));
559            let sx1 = (sx + 1).min(pm.width.saturating_sub(1));
560            let sy1 = (sy + 1).min(pm.height.saturating_sub(1));
561
562            let (r00, g00, b00) = pm.get_rgb(sx, sy);
563            let (r10, g10, b10) = pm.get_rgb(sx1, sy);
564            let (r01, g01, b01) = pm.get_rgb(sx, sy1);
565            let (r11, g11, b11) = pm.get_rgb(sx1, sy1);
566
567            let avg = |a: u8, b: u8, c: u8, d: u8| -> u8 {
568                ((a as u32 + b as u32 + c as u32 + d as u32 + 2) / 4) as u8
569            };
570            out.set_rgb(
571                x,
572                y,
573                avg(r00, r10, r01, r11),
574                avg(g00, g10, g01, g11),
575                avg(b00, b10, b01, b11),
576            );
577        }
578    }
579    out
580}
581
582// ── Page rotation ───────────────────────────────────────────────────────────
583
584/// Convert a rotation to a number of 90° CW steps (0..3).
585fn rotation_to_steps(r: crate::info::Rotation) -> u8 {
586    use crate::info::Rotation;
587    match r {
588        Rotation::None => 0,
589        Rotation::Cw90 => 1,
590        Rotation::Rot180 => 2,
591        Rotation::Ccw90 => 3,
592    }
593}
594
595/// Convert a user rotation to a number of 90° CW steps (0..3).
596fn user_rotation_to_steps(r: UserRotation) -> u8 {
597    match r {
598        UserRotation::None => 0,
599        UserRotation::Cw90 => 1,
600        UserRotation::Rot180 => 2,
601        UserRotation::Ccw90 => 3,
602    }
603}
604
605/// Combine INFO chunk rotation with user rotation and return the combined
606/// `info::Rotation` value.
607fn combine_rotations(info: crate::info::Rotation, user: UserRotation) -> crate::info::Rotation {
608    use crate::info::Rotation;
609    let steps = (rotation_to_steps(info) + user_rotation_to_steps(user)) % 4;
610    match steps {
611        0 => Rotation::None,
612        1 => Rotation::Cw90,
613        2 => Rotation::Rot180,
614        3 => Rotation::Ccw90,
615        _ => unreachable!(),
616    }
617}
618
619/// Apply page rotation to the rendered pixmap.
620///
621/// For 90°/270° rotations, width and height are swapped.
622fn rotate_pixmap(src: Pixmap, rotation: crate::info::Rotation) -> Pixmap {
623    use crate::info::Rotation;
624    match rotation {
625        Rotation::None => src,
626        Rotation::Cw90 => {
627            let w = src.height;
628            let h = src.width;
629            let mut out = Pixmap::white(w, h);
630            for y in 0..src.height {
631                for x in 0..src.width {
632                    let (r, g, b) = src.get_rgb(x, y);
633                    out.set_rgb(src.height - 1 - y, x, r, g, b);
634                }
635            }
636            out
637        }
638        Rotation::Rot180 => {
639            let mut out = Pixmap::white(src.width, src.height);
640            for y in 0..src.height {
641                for x in 0..src.width {
642                    let (r, g, b) = src.get_rgb(x, y);
643                    out.set_rgb(src.width - 1 - x, src.height - 1 - y, r, g, b);
644                }
645            }
646            out
647        }
648        Rotation::Ccw90 => {
649            let w = src.height;
650            let h = src.width;
651            let mut out = Pixmap::white(w, h);
652            for y in 0..src.height {
653                for x in 0..src.width {
654                    let (r, g, b) = src.get_rgb(x, y);
655                    out.set_rgb(y, src.width - 1 - x, r, g, b);
656                }
657            }
658            out
659        }
660    }
661}
662
663// ── FGbz palette parsing ──────────────────────────────────────────────────────
664
665/// An RGB color from the FGbz palette.
666#[derive(Debug, Clone, Copy, Default)]
667struct PaletteColor {
668    r: u8,
669    g: u8,
670    b: u8,
671}
672
673/// Parsed FGbz data: palette colors and optional per-blit color indices.
674struct FgbzPalette {
675    colors: Vec<PaletteColor>,
676    /// Per-blit color index: `indices[blit_idx]` → index into `colors`.
677    /// Empty when the FGbz chunk has no index table (version bit 7 unset).
678    indices: Vec<i16>,
679}
680
681/// Parse the FGbz chunk into palette colors and per-blit index table.
682///
683/// FGbz format:
684/// - byte 0: version (bit 7 = has index table, bits 6-0 must be 0)
685/// - byte 1-2: big-endian u16 palette size (number of colors)
686/// - next `palette_size * 3` bytes: BGR triples (raw if version=0, BZZ if version has bit 0 set)
687/// - if bit 7 set: 3-byte big-endian count + BZZ-compressed i16be index table
688fn parse_fgbz(data: &[u8]) -> Result<FgbzPalette, RenderError> {
689    if data.len() < 3 {
690        return Ok(FgbzPalette {
691            colors: vec![],
692            indices: vec![],
693        });
694    }
695
696    let version = data[0];
697    let has_indices = (version & 0x80) != 0;
698
699    let n_colors =
700        u16::from_be_bytes([*data.get(1).unwrap_or(&0), *data.get(2).unwrap_or(&0)]) as usize;
701
702    if n_colors == 0 {
703        return Ok(FgbzPalette {
704            colors: vec![],
705            indices: vec![],
706        });
707    }
708
709    // Colors: raw BGR triples starting at byte 3
710    let color_bytes = n_colors * 3;
711    let color_data = data.get(3..).unwrap_or(&[]);
712
713    let mut colors = Vec::with_capacity(n_colors);
714    for i in 0..n_colors {
715        let base = i * 3;
716        if base + 2 < color_data.len().min(color_bytes) {
717            colors.push(PaletteColor {
718                r: color_data[base + 2],
719                g: color_data[base + 1],
720                b: color_data[base],
721            });
722        } else {
723            colors.push(PaletteColor { r: 0, g: 0, b: 0 });
724        }
725    }
726
727    // Per-blit index table
728    let mut indices = Vec::new();
729    if has_indices {
730        let idx_start = 3 + color_bytes;
731        if idx_start + 3 <= data.len() {
732            let num_indices = ((data[idx_start] as u32) << 16)
733                | ((data[idx_start + 1] as u32) << 8)
734                | (data[idx_start + 2] as u32);
735
736            let bzz_data = data.get(idx_start + 3..).unwrap_or(&[]);
737            let decoded = crate::bzz_new::bzz_decode(bzz_data)?;
738
739            let n = num_indices as usize;
740            indices.reserve(n);
741            for i in 0..n {
742                if i * 2 + 1 < decoded.len() {
743                    indices.push(i16::from_be_bytes([decoded[i * 2], decoded[i * 2 + 1]]));
744                }
745            }
746        }
747    }
748
749    Ok(FgbzPalette { colors, indices })
750}
751
752// ── Core compositor ───────────────────────────────────────────────────────────
753
754/// Return the largest power-of-2 IW44 subsample factor for the given render
755/// scale such that the decoded resolution is still ≥ the target resolution
756/// (no upscaling required in the compositor).
757///
758/// Examples:
759/// - scale=1.0  → 1 (full resolution)
760/// - scale=0.5  → 2 (1/scale=2.0 → 2)
761/// - scale=0.375→ 2 (1/scale=2.67 → 2)
762/// - scale=0.25 → 4 (1/scale=4.0 → 4)
763/// - scale=0.1  → 8 (1/scale=10 → capped at 8)
764fn best_iw44_subsample(scale: f32) -> u32 {
765    if scale <= 0.0 || !scale.is_finite() || scale >= 1.0 {
766        return 1;
767    }
768    // Largest power of 2 s.t. s <= floor(1.0 / scale)
769    let max_sub = (1.0_f32 / scale) as u32; // truncating = floor for positive
770    let mut s = 1u32;
771    while s * 2 <= max_sub {
772        s *= 2;
773    }
774    s.min(8)
775}
776
777/// Decode background from BG44 chunks up to `max_chunks`.
778///
779/// `subsample` controls IW44 decode resolution: 1 = full, 2 = half, 4 = quarter.
780/// Use `best_iw44_subsample(opts.scale)` to pick an appropriate value.
781///
782/// When `max_chunks == usize::MAX`, the decoded wavelet image is fetched from
783/// [`DjVuPage::decoded_bg44`]'s cache, avoiding repeated ZP arithmetic decode.
784///
785/// Returns `None` if there are no BG44 chunks.
786/// `max_chunks = usize::MAX` means decode all chunks.
787fn decode_background_chunks(
788    page: &DjVuPage,
789    max_chunks: usize,
790    subsample: u32,
791) -> Result<Option<Pixmap>, RenderError> {
792    // Fast path: use the cached fully-decoded Iw44Image when all chunks are wanted.
793    if max_chunks == usize::MAX {
794        let bg44_chunks = page.bg44_chunks();
795        if !bg44_chunks.is_empty() {
796            // BG44 chunks exist — cache must have a decoded image; None means decode
797            // failed (strict mode propagates the error).
798            let img = page
799                .decoded_bg44()
800                .ok_or(RenderError::Iw44(crate::Iw44Error::Invalid))?;
801            return Ok(Some(img.to_rgb_subsample(subsample)?));
802        }
803        // No BG44 chunks — fall through to the JPEG fallback below.
804    } else {
805        let bg44_chunks = page.bg44_chunks();
806        if !bg44_chunks.is_empty() {
807            let mut img = Iw44Image::new();
808            for chunk_data in bg44_chunks.iter().take(max_chunks) {
809                img.decode_chunk(chunk_data)?;
810            }
811            return Ok(Some(img.to_rgb_subsample(subsample)?));
812        }
813    }
814
815    // Fall back to JPEG-encoded background if present.
816    #[cfg(feature = "std")]
817    if let Some(pm) = decode_bgjp(page)? {
818        return Ok(Some(pm));
819    }
820
821    Ok(None)
822}
823
824/// Permissive variant: decode BG44 chunks until the first error, then stop.
825///
826/// Returns whatever was decoded so far (may be blurry / incomplete).
827/// Returns `None` only when there are no BG44 chunks at all or even the
828/// first chunk fails to produce a valid image.
829fn decode_background_chunks_permissive(
830    page: &DjVuPage,
831    max_chunks: usize,
832    subsample: u32,
833) -> Option<Pixmap> {
834    let bg44_chunks = page.bg44_chunks();
835    if !bg44_chunks.is_empty() {
836        let mut img = Iw44Image::new();
837        for chunk_data in bg44_chunks.iter().take(max_chunks) {
838            if img.decode_chunk(chunk_data).is_err() {
839                break; // stop on first error, use what we have
840            }
841        }
842        return img.to_rgb_subsample(subsample).ok();
843    }
844
845    // Fall back to JPEG-encoded background if present.
846    #[cfg(feature = "std")]
847    {
848        decode_bgjp(page).ok().flatten()
849    }
850    #[cfg(not(feature = "std"))]
851    None
852}
853
854/// Decode the JB2 mask (Sjbz chunk) without blit tracking.
855fn decode_mask(page: &DjVuPage) -> Result<Option<crate::bitmap::Bitmap>, RenderError> {
856    let sjbz = match page.find_chunk(b"Sjbz") {
857        Some(data) => data,
858        None => return Ok(None),
859    };
860
861    let dict = match page.find_chunk(b"Djbz") {
862        Some(djbz) => Some(jb2_new::decode_dict(djbz, None)?),
863        None => None,
864    };
865
866    let bm = jb2_new::decode(sjbz, dict.as_ref())?;
867    Ok(Some(bm))
868}
869
870/// Decode the JB2 mask with per-pixel blit index tracking.
871fn decode_mask_indexed(
872    page: &DjVuPage,
873) -> Result<Option<(crate::bitmap::Bitmap, Vec<i32>)>, RenderError> {
874    let sjbz = match page.find_chunk(b"Sjbz") {
875        Some(data) => data,
876        None => return Ok(None),
877    };
878
879    let dict = match page.find_chunk(b"Djbz") {
880        Some(djbz) => Some(jb2_new::decode_dict(djbz, None)?),
881        None => None,
882    };
883
884    let (bm, blit_map) = jb2_new::decode_indexed(sjbz, dict.as_ref())?;
885    Ok(Some((bm, blit_map)))
886}
887
888/// Decode the FGbz foreground palette with per-blit color indices.
889fn decode_fg_palette_full(page: &DjVuPage) -> Result<Option<FgbzPalette>, RenderError> {
890    let fgbz = match page.find_chunk(b"FGbz") {
891        Some(data) => data,
892        None => return Ok(None),
893    };
894
895    let pal = parse_fgbz(fgbz)?;
896    if pal.colors.is_empty() {
897        return Ok(None);
898    }
899    Ok(Some(pal))
900}
901
902/// Decode the FG44 foreground layer.
903///
904/// Falls back to FGjp (JPEG-encoded foreground) when no FG44 chunks are present.
905fn decode_fg44(page: &DjVuPage) -> Result<Option<Pixmap>, RenderError> {
906    let fg44_chunks = page.fg44_chunks();
907    if !fg44_chunks.is_empty() {
908        let mut img = Iw44Image::new();
909        for chunk_data in &fg44_chunks {
910            img.decode_chunk(chunk_data)?;
911        }
912        return Ok(Some(img.to_rgb()?));
913    }
914
915    // Fall back to JPEG-encoded foreground if present.
916    #[cfg(feature = "std")]
917    if let Some(pm) = decode_fgjp(page)? {
918        return Ok(Some(pm));
919    }
920
921    Ok(None)
922}
923
924/// Decode a BGjp (JPEG-encoded background) chunk into an RGB [`Pixmap`].
925///
926/// Returns `None` when the page has no `BGjp` chunk.
927/// Only available with the `std` feature (requires `zune-jpeg`).
928#[cfg(feature = "std")]
929fn decode_bgjp(page: &DjVuPage) -> Result<Option<Pixmap>, RenderError> {
930    let data = match page.find_chunk(b"BGjp") {
931        Some(d) => d,
932        None => return Ok(None),
933    };
934    Ok(Some(decode_jpeg_to_pixmap(data)?))
935}
936
937/// Decode an FGjp (JPEG-encoded foreground) chunk into an RGB [`Pixmap`].
938///
939/// Returns `None` when the page has no `FGjp` chunk.
940/// Only available with the `std` feature (requires `zune-jpeg`).
941#[cfg(feature = "std")]
942fn decode_fgjp(page: &DjVuPage) -> Result<Option<Pixmap>, RenderError> {
943    let data = match page.find_chunk(b"FGjp") {
944        Some(d) => d,
945        None => return Ok(None),
946    };
947    Ok(Some(decode_jpeg_to_pixmap(data)?))
948}
949
950/// Decode raw JPEG bytes into an RGBA [`Pixmap`].
951///
952/// Uses `zune-jpeg` for decoding. The JPEG is decoded to RGB and then
953/// converted to RGBA (alpha = 255).
954#[cfg(feature = "std")]
955fn decode_jpeg_to_pixmap(data: &[u8]) -> Result<Pixmap, RenderError> {
956    use zune_jpeg::JpegDecoder;
957    use zune_jpeg::zune_core::bytestream::ZCursor;
958
959    let cursor = ZCursor::new(data);
960    let mut decoder = JpegDecoder::new(cursor);
961    decoder
962        .decode_headers()
963        .map_err(|e| RenderError::Jpeg(format!("{e:?}")))?;
964    let info = decoder
965        .info()
966        .ok_or_else(|| RenderError::Jpeg("missing image info after decode_headers".to_owned()))?;
967    let w = info.width as usize;
968    let h = info.height as usize;
969    let rgb = decoder
970        .decode()
971        .map_err(|e| RenderError::Jpeg(format!("{e:?}")))?;
972
973    // zune-jpeg returns RGB bytes; convert to RGBA
974    let mut rgba = vec![0u8; w * h * 4];
975    for (i, pixel) in rgba.chunks_exact_mut(4).enumerate() {
976        let src = i * 3;
977        pixel[0] = *rgb.get(src).unwrap_or(&0);
978        pixel[1] = *rgb.get(src + 1).unwrap_or(&0);
979        pixel[2] = *rgb.get(src + 2).unwrap_or(&0);
980        pixel[3] = 255;
981    }
982    Ok(Pixmap {
983        width: w as u32,
984        height: h as u32,
985        data: rgba,
986    })
987}
988
989/// A sub-rectangle within the full rendered output.
990///
991/// Used by [`render_region`] to select which portion of the page to render.
992/// `x` and `y` are pixel offsets within the output at `opts.width × opts.height`
993/// resolution.
994#[derive(Debug, Clone, Copy, PartialEq, Eq)]
995pub struct RenderRect {
996    /// X offset in output pixels.
997    pub x: u32,
998    /// Y offset in output pixels.
999    pub y: u32,
1000    /// Width of the output region in pixels.
1001    pub width: u32,
1002    /// Height of the output region in pixels.
1003    pub height: u32,
1004}
1005
1006/// All decoded layers and options passed to the compositor.
1007struct CompositeContext<'a> {
1008    opts: &'a RenderOptions,
1009    page_w: u32,
1010    page_h: u32,
1011    bg: Option<&'a Pixmap>,
1012    /// Subsample factor used when decoding `bg` (1 = full resolution, 2 = half, …).
1013    /// Page-space coordinates are divided by this before indexing into `bg`.
1014    bg_subsample: u32,
1015    mask: Option<&'a crate::bitmap::Bitmap>,
1016    fg_palette: Option<&'a FgbzPalette>,
1017    /// Per-pixel blit index map (same dimensions as mask). `-1` = no blit.
1018    blit_map: Option<&'a [i32]>,
1019    fg44: Option<&'a Pixmap>,
1020    gamma_lut: &'a [u8; 256],
1021    /// X offset within the full render (for region renders; 0 for full page).
1022    offset_x: u32,
1023    /// Y offset within the full render (for region renders; 0 for full page).
1024    offset_y: u32,
1025    /// Output width (may be smaller than opts.width for region renders).
1026    out_w: u32,
1027    /// Output height (may be smaller than opts.height for region renders).
1028    out_h: u32,
1029}
1030
1031/// Look up the palette color for a foreground pixel at (px, py).
1032///
1033/// Uses the blit map to find the per-glyph blit index, then maps it through
1034/// the FGbz index table to get the final color. Falls back to palette[0] when
1035/// no index table is present, and to black when lookup fails.
1036#[inline]
1037fn lookup_palette_color(
1038    pal: &FgbzPalette,
1039    blit_map: Option<&[i32]>,
1040    mask: Option<&crate::bitmap::Bitmap>,
1041    px: u32,
1042    py: u32,
1043) -> PaletteColor {
1044    if let Some(bm) = blit_map
1045        && let Some(m) = mask
1046    {
1047        let mi = py as usize * m.width as usize + px as usize;
1048        if mi < bm.len() {
1049            let blit_idx = bm[mi];
1050            if blit_idx >= 0 {
1051                if !pal.indices.is_empty() {
1052                    // Two-level indirection: blit_idx → color_idx → color
1053                    let bi = blit_idx as usize;
1054                    if bi < pal.indices.len() {
1055                        let ci = pal.indices[bi] as usize;
1056                        if ci < pal.colors.len() {
1057                            return pal.colors[ci];
1058                        }
1059                    }
1060                } else {
1061                    // No index table: use blit_idx directly as color index
1062                    let ci = blit_idx as usize;
1063                    if ci < pal.colors.len() {
1064                        return pal.colors[ci];
1065                    }
1066                }
1067            }
1068        }
1069    }
1070    // Fallback: first palette color or black
1071    pal.colors.first().copied().unwrap_or_default()
1072}
1073
1074/// Bilinear composite loop — used when upscaling or at 1:1 (step ≤ 1 pixel).
1075/// Single-pixel mask check per output pixel.
1076#[allow(clippy::too_many_arguments)]
1077fn composite_loop_bilinear(
1078    ctx: &CompositeContext<'_>,
1079    buf: &mut [u8],
1080    w: u32,
1081    h: u32,
1082    page_w: u32,
1083    page_h: u32,
1084    fx_step: u32,
1085    fy_step: u32,
1086) {
1087    for oy in 0..h {
1088        let fy = (oy + ctx.offset_y) * fy_step;
1089        let py = (fy >> FRACBITS).min(page_h.saturating_sub(1));
1090        let row_base = oy as usize * w as usize;
1091
1092        for ox in 0..w {
1093            let fx = (ox + ctx.offset_x) * fx_step;
1094            let px = (fx >> FRACBITS).min(page_w.saturating_sub(1));
1095
1096            let is_fg = ctx
1097                .mask
1098                .is_some_and(|m| px < m.width && py < m.height && m.get(px, py));
1099
1100            let (r, g, b) = if is_fg {
1101                if let Some(pal) = ctx.fg_palette {
1102                    let color = lookup_palette_color(pal, ctx.blit_map, ctx.mask, px, py);
1103                    (color.r, color.g, color.b)
1104                } else if let Some(fg) = ctx.fg44 {
1105                    sample_bilinear(fg, fx, fy)
1106                } else {
1107                    (0, 0, 0)
1108                }
1109            } else if let Some(bg) = ctx.bg {
1110                let s = ctx.bg_subsample;
1111                sample_bilinear(bg, fx / s, fy / s)
1112            } else {
1113                (255, 255, 255)
1114            };
1115
1116            let r = ctx.gamma_lut[r as usize];
1117            let g = ctx.gamma_lut[g as usize];
1118            let b = ctx.gamma_lut[b as usize];
1119
1120            let base = (row_base + ox as usize) * 4;
1121            if let Some(pixel) = buf.get_mut(base..base + 4) {
1122                pixel[0] = r;
1123                pixel[1] = g;
1124                pixel[2] = b;
1125                pixel[3] = 255;
1126            }
1127        }
1128    }
1129}
1130
1131/// Area-averaging composite loop — used when downscaling (step > 1 pixel).
1132/// Uses box filter for background sampling and checks a box of mask pixels.
1133#[allow(clippy::too_many_arguments)]
1134fn composite_loop_area_avg(
1135    ctx: &CompositeContext<'_>,
1136    buf: &mut [u8],
1137    w: u32,
1138    h: u32,
1139    _page_w: u32,
1140    _page_h: u32,
1141    fx_step: u32,
1142    fy_step: u32,
1143) {
1144    for oy in 0..h {
1145        let fy = (oy + ctx.offset_y) * fy_step;
1146        let row_base = oy as usize * w as usize;
1147
1148        for ox in 0..w {
1149            let fx = (ox + ctx.offset_x) * fx_step;
1150
1151            let is_fg = ctx
1152                .mask
1153                .is_some_and(|m| mask_box_any(m, fx, fy, fx_step, fy_step));
1154
1155            let (r, g, b) = if is_fg {
1156                if let Some(pal) = ctx.fg_palette {
1157                    let (cx, cy) = mask_box_center_fg(ctx.mask.unwrap(), fx, fy, fx_step, fy_step);
1158                    let color = lookup_palette_color(pal, ctx.blit_map, ctx.mask, cx, cy);
1159                    (color.r, color.g, color.b)
1160                } else if let Some(fg) = ctx.fg44 {
1161                    sample_area_avg(fg, fx, fy, fx_step, fy_step)
1162                } else {
1163                    (0, 0, 0)
1164                }
1165            } else if let Some(bg) = ctx.bg {
1166                let s = ctx.bg_subsample;
1167                sample_area_avg(bg, fx / s, fy / s, fx_step / s, fy_step / s)
1168            } else {
1169                (255, 255, 255)
1170            };
1171
1172            let r = ctx.gamma_lut[r as usize];
1173            let g = ctx.gamma_lut[g as usize];
1174            let b = ctx.gamma_lut[b as usize];
1175
1176            let base = (row_base + ox as usize) * 4;
1177            if let Some(pixel) = buf.get_mut(base..base + 4) {
1178                pixel[0] = r;
1179                pixel[1] = g;
1180                pixel[2] = b;
1181                pixel[3] = 255;
1182            }
1183        }
1184    }
1185}
1186
1187/// Composite one page into `buf` (RGBA, pre-allocated) using the given context.
1188///
1189/// This is a zero-allocation render path when `buf` is already the right size.
1190/// For region renders, `ctx.out_w`/`ctx.out_h` give the output dimensions and
1191/// `ctx.offset_x`/`ctx.offset_y` give the starting offset within the full render.
1192fn composite_into(ctx: &CompositeContext<'_>, buf: &mut [u8]) -> Result<(), RenderError> {
1193    let w = ctx.out_w;
1194    let h = ctx.out_h;
1195    let full_w = ctx.opts.width;
1196    let full_h = ctx.opts.height;
1197    let page_w = ctx.page_w;
1198    let page_h = ctx.page_h;
1199
1200    // Fixed-point step: how many source pixels per full-render output pixel
1201    let fx_step = ((page_w as u64 * FRAC as u64) / full_w.max(1) as u64) as u32;
1202    let fy_step = ((page_h as u64 * FRAC as u64) / full_h.max(1) as u64) as u32;
1203
1204    // Downscaling when output is smaller than source (step > 1 pixel)
1205    if fx_step > FRAC || fy_step > FRAC {
1206        composite_loop_area_avg(ctx, buf, w, h, page_w, page_h, fx_step, fy_step);
1207    } else {
1208        composite_loop_bilinear(ctx, buf, w, h, page_w, page_h, fx_step, fy_step);
1209    }
1210
1211    Ok(())
1212}
1213
1214// ── Public API ────────────────────────────────────────────────────────────────
1215
1216/// Render a `DjVuPage` into a pre-allocated RGBA buffer.
1217///
1218/// This is the zero-allocation render path when `buf` is reused across calls
1219/// with the same dimensions. The buffer must be at least `width * height * 4`
1220/// bytes.
1221///
1222/// # Errors
1223///
1224/// - [`RenderError::BufTooSmall`] if `buf.len() < width * height * 4`
1225/// - [`RenderError::InvalidDimensions`] if `width == 0 || height == 0`
1226/// - Propagates IW44 / JB2 decode errors.
1227pub fn render_into(
1228    page: &DjVuPage,
1229    opts: &RenderOptions,
1230    buf: &mut [u8],
1231) -> Result<(), RenderError> {
1232    let w = opts.width;
1233    let h = opts.height;
1234
1235    if w == 0 || h == 0 {
1236        return Err(RenderError::InvalidDimensions {
1237            width: w,
1238            height: h,
1239        });
1240    }
1241
1242    let need = (w as usize)
1243        .checked_mul(h as usize)
1244        .and_then(|n| n.checked_mul(4))
1245        .unwrap_or(usize::MAX);
1246
1247    if buf.len() < need {
1248        return Err(RenderError::BufTooSmall {
1249            need,
1250            got: buf.len(),
1251        });
1252    }
1253
1254    let gamma_lut = build_gamma_lut(page.gamma());
1255
1256    // Decode all layers
1257    let bg_subsample = best_iw44_subsample(opts.scale);
1258    let bg = decode_background_chunks(page, usize::MAX, bg_subsample)?;
1259    let fg_palette = decode_fg_palette_full(page)?;
1260
1261    // Use indexed mask when we have a palette (for per-glyph colors)
1262    let (mask, blit_map) = if fg_palette.is_some() {
1263        match decode_mask_indexed(page)? {
1264            Some((bm, bm_map)) => (Some(bm), Some(bm_map)),
1265            None => (None, None),
1266        }
1267    } else {
1268        (decode_mask(page)?, None)
1269    };
1270
1271    let mask = if opts.bold > 0 {
1272        mask.map(|m| {
1273            let mut dilated = m;
1274            for _ in 0..opts.bold {
1275                dilated = dilated.dilate();
1276            }
1277            dilated
1278        })
1279    } else {
1280        mask
1281    };
1282    let fg44 = decode_fg44(page)?;
1283
1284    let ctx = CompositeContext {
1285        opts,
1286        page_w: page.width() as u32,
1287        page_h: page.height() as u32,
1288        bg: bg.as_ref(),
1289        bg_subsample,
1290        mask: mask.as_ref(),
1291        fg_palette: fg_palette.as_ref(),
1292        blit_map: blit_map.as_deref(),
1293        fg44: fg44.as_ref(),
1294        gamma_lut: &gamma_lut,
1295        offset_x: 0,
1296        offset_y: 0,
1297        out_w: w,
1298        out_h: h,
1299    };
1300    composite_into(&ctx, buf)?;
1301
1302    Ok(())
1303}
1304
1305/// Render a `DjVuPage` to a new [`Pixmap`] using the given options.
1306pub fn render_pixmap(page: &DjVuPage, opts: &RenderOptions) -> Result<Pixmap, RenderError> {
1307    let w = opts.width;
1308    let h = opts.height;
1309
1310    if w == 0 || h == 0 {
1311        return Err(RenderError::InvalidDimensions {
1312            width: w,
1313            height: h,
1314        });
1315    }
1316
1317    let gamma_lut = build_gamma_lut(page.gamma());
1318
1319    // Decode all layers, respecting permissive mode.
1320    let bg;
1321    let fg_palette;
1322    let mask;
1323    let blit_map;
1324    let fg44;
1325
1326    let bg_subsample = best_iw44_subsample(opts.scale);
1327
1328    if opts.permissive {
1329        bg = decode_background_chunks_permissive(page, usize::MAX, bg_subsample);
1330        fg_palette = decode_fg_palette_full(page).ok().flatten();
1331        let indexed = if fg_palette.is_some() {
1332            decode_mask_indexed(page).ok().flatten()
1333        } else {
1334            None
1335        };
1336        if let Some((bm, bm_map)) = indexed {
1337            mask = Some(bm);
1338            blit_map = Some(bm_map);
1339        } else {
1340            mask = decode_mask(page).ok().flatten();
1341            blit_map = None;
1342        }
1343        fg44 = decode_fg44(page).ok().flatten();
1344    } else {
1345        bg = decode_background_chunks(page, usize::MAX, bg_subsample)?;
1346        fg_palette = decode_fg_palette_full(page)?;
1347        let indexed_result = if fg_palette.is_some() {
1348            decode_mask_indexed(page)?
1349        } else {
1350            None
1351        };
1352        if let Some((bm, bm_map)) = indexed_result {
1353            mask = Some(bm);
1354            blit_map = Some(bm_map);
1355        } else {
1356            mask = if fg_palette.is_none() {
1357                decode_mask(page)?
1358            } else {
1359                None
1360            };
1361            blit_map = None;
1362        }
1363        fg44 = decode_fg44(page)?;
1364    }
1365
1366    let mask = if opts.bold > 0 {
1367        mask.map(|m| {
1368            let mut dilated = m;
1369            for _ in 0..opts.bold {
1370                dilated = dilated.dilate();
1371            }
1372            dilated
1373        })
1374    } else {
1375        mask
1376    };
1377
1378    let mut pm = Pixmap::white(w, h);
1379
1380    {
1381        let ctx = CompositeContext {
1382            opts,
1383            page_w: page.width() as u32,
1384            page_h: page.height() as u32,
1385            bg: bg.as_ref(),
1386            bg_subsample,
1387            mask: mask.as_ref(),
1388            fg_palette: fg_palette.as_ref(),
1389            blit_map: blit_map.as_deref(),
1390            fg44: fg44.as_ref(),
1391            gamma_lut: &gamma_lut,
1392            offset_x: 0,
1393            offset_y: 0,
1394            out_w: w,
1395            out_h: h,
1396        };
1397        composite_into(&ctx, &mut pm.data)?;
1398    }
1399
1400    if opts.aa {
1401        pm = aa_downscale(&pm);
1402    }
1403
1404    // Apply Lanczos-3 post-processing when requested.
1405    // The composited pixmap is already at `w × h`; if the page dimensions
1406    // differ from the output (i.e. actual scaling happened) reprocess it
1407    // with the higher-quality Lanczos filter.
1408    if opts.resampling == Resampling::Lanczos3 {
1409        let need_scale = page.width() as u32 != w || page.height() as u32 != h;
1410        if need_scale {
1411            // Re-render at native resolution, then downscale with Lanczos.
1412            let native_opts = RenderOptions {
1413                width: page.width() as u32,
1414                height: page.height() as u32,
1415                scale: 1.0,
1416                bold: opts.bold,
1417                aa: false,
1418                rotation: UserRotation::None, // rotation applied after scaling
1419                permissive: opts.permissive,
1420                resampling: Resampling::Bilinear, // avoid infinite recursion
1421            };
1422            // Render at full resolution (may fail gracefully).
1423            if let Ok(native_pm) = render_pixmap(page, &native_opts) {
1424                pm = scale_lanczos3(&native_pm, w, h);
1425            }
1426            // If native render failed, pm already holds the bilinear result.
1427        }
1428    }
1429
1430    Ok(rotate_pixmap(
1431        pm,
1432        combine_rotations(page.rotation(), opts.rotation),
1433    ))
1434}
1435
1436/// Render a sub-rectangle of a page into a new [`Pixmap`].
1437///
1438/// Unlike [`render_pixmap`], which always allocates `opts.width × opts.height`
1439/// pixels, `render_region` only allocates `region.width × region.height` pixels.
1440/// This makes it efficient for thumbnails, viewport clips, and tile rendering.
1441///
1442/// `opts.width` and `opts.height` still define the **full-page** render dimensions
1443/// used for scale calculation. `region` selects which sub-rectangle of that
1444/// full render to output. The returned `Pixmap` has dimensions
1445/// `region.width × region.height`.
1446///
1447/// # Errors
1448///
1449/// - [`RenderError::InvalidDimensions`] if `region.width == 0 || region.height == 0`
1450/// - Propagates IW44 / JB2 decode errors.
1451pub fn render_region(
1452    page: &DjVuPage,
1453    region: RenderRect,
1454    opts: &RenderOptions,
1455) -> Result<Pixmap, RenderError> {
1456    if region.width == 0 || region.height == 0 {
1457        return Err(RenderError::InvalidDimensions {
1458            width: region.width,
1459            height: region.height,
1460        });
1461    }
1462
1463    let full_w = opts.width.max(1);
1464    let full_h = opts.height.max(1);
1465    let gamma_lut = build_gamma_lut(page.gamma());
1466
1467    let bg;
1468    let fg_palette;
1469    let mask;
1470    let blit_map;
1471    let fg44;
1472
1473    let bg_subsample = best_iw44_subsample(opts.scale);
1474
1475    if opts.permissive {
1476        bg = decode_background_chunks_permissive(page, usize::MAX, bg_subsample);
1477        fg_palette = decode_fg_palette_full(page).ok().flatten();
1478        let indexed = if fg_palette.is_some() {
1479            decode_mask_indexed(page).ok().flatten()
1480        } else {
1481            None
1482        };
1483        if let Some((bm, bm_map)) = indexed {
1484            mask = Some(bm);
1485            blit_map = Some(bm_map);
1486        } else {
1487            mask = decode_mask(page).ok().flatten();
1488            blit_map = None;
1489        }
1490        fg44 = decode_fg44(page).ok().flatten();
1491    } else {
1492        bg = decode_background_chunks(page, usize::MAX, bg_subsample)?;
1493        fg_palette = decode_fg_palette_full(page)?;
1494        let indexed_result = if fg_palette.is_some() {
1495            decode_mask_indexed(page)?
1496        } else {
1497            None
1498        };
1499        if let Some((bm, bm_map)) = indexed_result {
1500            mask = Some(bm);
1501            blit_map = Some(bm_map);
1502        } else {
1503            mask = if fg_palette.is_none() {
1504                decode_mask(page)?
1505            } else {
1506                None
1507            };
1508            blit_map = None;
1509        }
1510        fg44 = decode_fg44(page)?;
1511    }
1512
1513    let mask = if opts.bold > 0 {
1514        mask.map(|m| {
1515            let mut dilated = m;
1516            for _ in 0..opts.bold {
1517                dilated = dilated.dilate();
1518            }
1519            dilated
1520        })
1521    } else {
1522        mask
1523    };
1524
1525    let out_w = region.width;
1526    let out_h = region.height;
1527    let mut pm = Pixmap::white(out_w, out_h);
1528
1529    let region_opts = RenderOptions {
1530        width: full_w,
1531        height: full_h,
1532        ..*opts
1533    };
1534    let ctx = CompositeContext {
1535        opts: &region_opts,
1536        page_w: page.width() as u32,
1537        page_h: page.height() as u32,
1538        bg: bg.as_ref(),
1539        bg_subsample,
1540        mask: mask.as_ref(),
1541        fg_palette: fg_palette.as_ref(),
1542        blit_map: blit_map.as_deref(),
1543        fg44: fg44.as_ref(),
1544        gamma_lut: &gamma_lut,
1545        offset_x: region.x,
1546        offset_y: region.y,
1547        out_w,
1548        out_h,
1549    };
1550    composite_into(&ctx, &mut pm.data)?;
1551
1552    // Apply Lanczos-3 post-processing when requested (same logic as render_pixmap).
1553    if opts.resampling == Resampling::Lanczos3 {
1554        let need_scale = page.width() as u32 != full_w || page.height() as u32 != full_h;
1555        if need_scale {
1556            let native_opts = RenderOptions {
1557                width: page.width() as u32,
1558                height: page.height() as u32,
1559                scale: 1.0,
1560                bold: opts.bold,
1561                aa: false,
1562                rotation: UserRotation::None,
1563                permissive: opts.permissive,
1564                resampling: Resampling::Bilinear,
1565            };
1566            if let Ok(native_pm) = render_region(page, region, &native_opts) {
1567                pm = scale_lanczos3(&native_pm, out_w, out_h);
1568            }
1569        }
1570    }
1571
1572    Ok(rotate_pixmap(
1573        pm,
1574        combine_rotations(page.rotation(), opts.rotation),
1575    ))
1576}
1577
1578/// Render a `DjVuPage` to an 8-bit grayscale image.
1579///
1580/// Equivalent to calling [`render_pixmap`] and converting the result with
1581/// [`Pixmap::to_gray8`]. Returns a [`GrayPixmap`] where `data.len() ==
1582/// width * height`.
1583///
1584/// For bilevel (JB2-only) pages this produces only `0` and `255` values.
1585/// For colour pages, luminance is computed with ITU-R BT.601 weights.
1586pub fn render_gray8(page: &DjVuPage, opts: &RenderOptions) -> Result<GrayPixmap, RenderError> {
1587    Ok(render_pixmap(page, opts)?.to_gray8())
1588}
1589
1590/// Render all pages of a document in parallel using rayon.
1591///
1592/// Each page is rendered independently with its own [`RenderOptions`] computed
1593/// from the given `dpi`.  Results are returned in page order.
1594///
1595/// Requires the `parallel` feature flag.
1596#[cfg(feature = "parallel")]
1597pub fn render_pages_parallel(
1598    doc: &crate::djvu_document::DjVuDocument,
1599    dpi: u32,
1600) -> Vec<Result<Pixmap, RenderError>> {
1601    use rayon::prelude::*;
1602
1603    let count = doc.page_count();
1604    (0..count)
1605        .into_par_iter()
1606        .map(|i| {
1607            let page = doc.page(i)?;
1608            let native_dpi = page.dpi() as f32;
1609            let scale = dpi as f32 / native_dpi;
1610            let w = ((page.width() as f32 * scale).round() as u32).max(1);
1611            let h = ((page.height() as f32 * scale).round() as u32).max(1);
1612            let opts = RenderOptions {
1613                width: w,
1614                height: h,
1615                scale,
1616                bold: 0,
1617                aa: false,
1618                rotation: UserRotation::None,
1619                permissive: false,
1620                resampling: Resampling::Bilinear,
1621            };
1622            render_pixmap(page, &opts)
1623        })
1624        .collect()
1625}
1626
1627/// Coarse render: decode only the first BG44 chunk for a fast blurry preview.
1628///
1629/// Returns `Ok(None)` when the page has no BG44 chunks.
1630pub fn render_coarse(page: &DjVuPage, opts: &RenderOptions) -> Result<Option<Pixmap>, RenderError> {
1631    let w = opts.width;
1632    let h = opts.height;
1633
1634    if w == 0 || h == 0 {
1635        return Err(RenderError::InvalidDimensions {
1636            width: w,
1637            height: h,
1638        });
1639    }
1640
1641    let bg_subsample = best_iw44_subsample(opts.scale);
1642    let bg = decode_background_chunks(page, 1, bg_subsample)?;
1643    let bg = match bg {
1644        Some(b) => b,
1645        None => return Ok(None),
1646    };
1647
1648    let gamma_lut = build_gamma_lut(page.gamma());
1649    let mut pm = Pixmap::white(w, h);
1650
1651    {
1652        let ctx = CompositeContext {
1653            opts,
1654            page_w: page.width() as u32,
1655            page_h: page.height() as u32,
1656            bg: Some(&bg),
1657            bg_subsample,
1658            mask: None,
1659            fg_palette: None,
1660            blit_map: None,
1661            fg44: None,
1662            gamma_lut: &gamma_lut,
1663            offset_x: 0,
1664            offset_y: 0,
1665            out_w: w,
1666            out_h: h,
1667        };
1668        composite_into(&ctx, &mut pm.data)?;
1669    }
1670
1671    Ok(Some(rotate_pixmap(
1672        pm,
1673        combine_rotations(page.rotation(), opts.rotation),
1674    )))
1675}
1676
1677/// Progressive render: decode BG44 chunks 1..=chunk_n and all other layers.
1678///
1679/// `chunk_n = 0` behaves like [`render_coarse`] (first chunk only).
1680/// Each additional chunk adds detail. The result after all chunks is
1681/// equivalent to [`render_pixmap`].
1682///
1683/// # Errors
1684///
1685/// Returns [`RenderError::ChunkOutOfRange`] if `chunk_n` exceeds the number
1686/// of available BG44 chunks.
1687pub fn render_progressive(
1688    page: &DjVuPage,
1689    opts: &RenderOptions,
1690    chunk_n: usize,
1691) -> Result<Pixmap, RenderError> {
1692    let w = opts.width;
1693    let h = opts.height;
1694
1695    if w == 0 || h == 0 {
1696        return Err(RenderError::InvalidDimensions {
1697            width: w,
1698            height: h,
1699        });
1700    }
1701
1702    let n_bg44 = page.bg44_chunks().len();
1703    let max_chunk = n_bg44.saturating_sub(1);
1704
1705    if n_bg44 > 0 && chunk_n > max_chunk {
1706        return Err(RenderError::ChunkOutOfRange {
1707            chunk_n,
1708            max: max_chunk,
1709        });
1710    }
1711
1712    let gamma_lut = build_gamma_lut(page.gamma());
1713
1714    // Decode background up to chunk_n + 1 chunks
1715    let bg_subsample = best_iw44_subsample(opts.scale);
1716    let bg = decode_background_chunks(page, chunk_n + 1, bg_subsample)?;
1717    let fg_palette = decode_fg_palette_full(page)?;
1718
1719    let (mask, blit_map) = if fg_palette.is_some() {
1720        match decode_mask_indexed(page)? {
1721            Some((bm, bm_map)) => (Some(bm), Some(bm_map)),
1722            None => (None, None),
1723        }
1724    } else {
1725        (decode_mask(page)?, None)
1726    };
1727
1728    let mask = if opts.bold > 0 {
1729        mask.map(|m| {
1730            let mut dilated = m;
1731            for _ in 0..opts.bold {
1732                dilated = dilated.dilate();
1733            }
1734            dilated
1735        })
1736    } else {
1737        mask
1738    };
1739    let fg44 = decode_fg44(page)?;
1740
1741    let mut pm = Pixmap::white(w, h);
1742    {
1743        let ctx = CompositeContext {
1744            opts,
1745            page_w: page.width() as u32,
1746            page_h: page.height() as u32,
1747            bg: bg.as_ref(),
1748            bg_subsample,
1749            mask: mask.as_ref(),
1750            fg_palette: fg_palette.as_ref(),
1751            blit_map: blit_map.as_deref(),
1752            fg44: fg44.as_ref(),
1753            gamma_lut: &gamma_lut,
1754            offset_x: 0,
1755            offset_y: 0,
1756            out_w: w,
1757            out_h: h,
1758        };
1759        composite_into(&ctx, &mut pm.data)?;
1760    }
1761
1762    // Apply Lanczos-3 post-processing when requested (same logic as render_pixmap).
1763    if opts.resampling == Resampling::Lanczos3 {
1764        let need_scale = page.width() as u32 != w || page.height() as u32 != h;
1765        if need_scale {
1766            let native_opts = RenderOptions {
1767                width: page.width() as u32,
1768                height: page.height() as u32,
1769                scale: 1.0,
1770                bold: opts.bold,
1771                aa: false,
1772                rotation: UserRotation::None,
1773                permissive: opts.permissive,
1774                resampling: Resampling::Bilinear,
1775            };
1776            if let Ok(native_pm) = render_progressive(page, &native_opts, chunk_n) {
1777                pm = scale_lanczos3(&native_pm, w, h);
1778            }
1779        }
1780    }
1781
1782    Ok(rotate_pixmap(
1783        pm,
1784        combine_rotations(page.rotation(), opts.rotation),
1785    ))
1786}
1787
1788// ── Tests ────────────────────────────────────────────────────────────────────
1789
1790#[cfg(test)]
1791mod tests {
1792    use super::*;
1793    use crate::djvu_document::DjVuDocument;
1794
1795    fn assets_path() -> std::path::PathBuf {
1796        std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1797            .join("references/djvujs/library/assets")
1798    }
1799
1800    fn load_page(filename: &str) -> DjVuPage {
1801        let data = std::fs::read(assets_path().join(filename))
1802            .unwrap_or_else(|_| panic!("{filename} must exist"));
1803        let doc = DjVuDocument::parse(&data).unwrap_or_else(|e| panic!("parse failed: {e}"));
1804        // Return owned page — DjVuDocument owns the pages, access them by value
1805        // by re-parsing with index 0
1806        let _ = doc.page(0).expect("page 0 must exist");
1807        // For tests, we re-parse and use doc directly
1808        let data2 = std::fs::read(assets_path().join(filename)).unwrap();
1809        let doc2 = DjVuDocument::parse(&data2).unwrap();
1810        // We need an owned DjVuPage. Since DjVuDocument stores them,
1811        // and page() returns &DjVuPage, we use a helper that owns the doc.
1812        // For test simplicity, we rely on the static lifetime via owned doc.
1813        // Build a wrapper struct to hold the doc and return a usable page.
1814        drop(doc2);
1815        panic!("use load_doc_page instead")
1816    }
1817
1818    /// Helper that returns an owned document so tests can borrow pages from it.
1819    fn load_doc(filename: &str) -> DjVuDocument {
1820        let data = std::fs::read(assets_path().join(filename))
1821            .unwrap_or_else(|_| panic!("{filename} must exist"));
1822        DjVuDocument::parse(&data).unwrap_or_else(|e| panic!("parse failed: {e}"))
1823    }
1824
1825    // ── TDD: failing tests written first ─────────────────────────────────────
1826
1827    /// RenderOptions default values.
1828    #[test]
1829    fn render_options_default() {
1830        let opts = RenderOptions::default();
1831        assert_eq!(opts.width, 0);
1832        assert_eq!(opts.height, 0);
1833        assert_eq!(opts.bold, 0);
1834        assert!(!opts.aa);
1835        assert!((opts.scale - 1.0).abs() < 1e-6);
1836        assert_eq!(opts.resampling, Resampling::Bilinear);
1837    }
1838
1839    /// RenderOptions can be constructed with explicit fields.
1840    #[test]
1841    fn render_options_construction() {
1842        let opts = RenderOptions {
1843            width: 400,
1844            height: 300,
1845            scale: 0.5,
1846            bold: 1,
1847            aa: true,
1848            rotation: UserRotation::Cw90,
1849            permissive: false,
1850            resampling: Resampling::Bilinear,
1851        };
1852        assert_eq!(opts.width, 400);
1853        assert_eq!(opts.height, 300);
1854        assert_eq!(opts.bold, 1);
1855        assert!(opts.aa);
1856        assert!((opts.scale - 0.5).abs() < 1e-6);
1857        assert_eq!(opts.rotation, UserRotation::Cw90);
1858    }
1859
1860    /// `fit_to_width` scales correctly, preserving aspect ratio.
1861    #[test]
1862    fn fit_to_width_preserves_aspect() {
1863        let doc = load_doc("chicken.djvu");
1864        let page = doc.page(0).unwrap();
1865        let pw = page.width() as u32;
1866        let ph = page.height() as u32;
1867
1868        let opts = RenderOptions::fit_to_width(page, 800);
1869        assert_eq!(opts.width, 800);
1870        let expected_h = ((ph as f64 * 800.0) / pw as f64).round() as u32;
1871        assert_eq!(opts.height, expected_h);
1872        assert!((opts.scale - 800.0 / pw as f32).abs() < 0.01);
1873    }
1874
1875    /// `fit_to_height` scales correctly, preserving aspect ratio.
1876    #[test]
1877    fn fit_to_height_preserves_aspect() {
1878        let doc = load_doc("chicken.djvu");
1879        let page = doc.page(0).unwrap();
1880        let pw = page.width() as u32;
1881        let ph = page.height() as u32;
1882
1883        let opts = RenderOptions::fit_to_height(page, 600);
1884        assert_eq!(opts.height, 600);
1885        let expected_w = ((pw as f64 * 600.0) / ph as f64).round() as u32;
1886        assert_eq!(opts.width, expected_w);
1887        assert!((opts.scale - 600.0 / ph as f32).abs() < 0.01);
1888    }
1889
1890    /// `fit_to_box` chooses the smaller scale factor.
1891    #[test]
1892    fn fit_to_box_constrains_both() {
1893        let doc = load_doc("chicken.djvu");
1894        let page = doc.page(0).unwrap();
1895
1896        // Very wide box — height should be the constraint
1897        let opts = RenderOptions::fit_to_box(page, 10000, 100);
1898        assert!(opts.width <= 10000);
1899        assert!(opts.height <= 100);
1900        assert!(opts.width > 0 && opts.height > 0);
1901
1902        // Very tall box — width should be the constraint
1903        let opts = RenderOptions::fit_to_box(page, 100, 10000);
1904        assert!(opts.width <= 100);
1905        assert!(opts.height <= 10000);
1906        assert!(opts.width > 0 && opts.height > 0);
1907    }
1908
1909    /// `fit_to_box` with a square box picks the tighter dimension.
1910    #[test]
1911    fn fit_to_box_square() {
1912        let doc = load_doc("chicken.djvu");
1913        let page = doc.page(0).unwrap();
1914
1915        let opts = RenderOptions::fit_to_box(page, 500, 500);
1916        assert!(opts.width <= 500);
1917        assert!(opts.height <= 500);
1918        // At least one dimension should be close to 500
1919        assert!(opts.width >= 490 || opts.height >= 490);
1920    }
1921
1922    /// Rotated page: fit_to_width uses display dimensions (swapped w/h).
1923    #[test]
1924    fn fit_to_width_rotation_aware() {
1925        // boy_jb2_rotate90 has a 90° rotation in the INFO chunk
1926        let doc = load_doc("boy_jb2_rotate90.djvu");
1927        let page = doc.page(0).unwrap();
1928        let pw = page.width() as u32;
1929        let ph = page.height() as u32;
1930        // Display dimensions are swapped for 90° rotation
1931        let (dw, dh) = (ph, pw);
1932
1933        let opts = RenderOptions::fit_to_width(page, 400);
1934        assert_eq!(opts.width, 400);
1935        let expected_h = ((dh as f64 * 400.0) / dw as f64).round() as u32;
1936        assert_eq!(opts.height, expected_h);
1937    }
1938
1939    /// `render_into` with a zero-width dimension returns InvalidDimensions.
1940    #[test]
1941    fn render_into_invalid_dimensions() {
1942        let doc = load_doc("chicken.djvu");
1943        let page = doc.page(0).unwrap();
1944
1945        let opts = RenderOptions {
1946            width: 0,
1947            height: 100,
1948            ..Default::default()
1949        };
1950        let mut buf = vec![0u8; 400];
1951        let err = render_into(page, &opts, &mut buf).unwrap_err();
1952        assert!(
1953            matches!(err, RenderError::InvalidDimensions { .. }),
1954            "expected InvalidDimensions, got {err:?}"
1955        );
1956    }
1957
1958    /// `render_into` with a too-small buffer returns BufTooSmall.
1959    #[test]
1960    fn render_into_buf_too_small() {
1961        let doc = load_doc("chicken.djvu");
1962        let page = doc.page(0).unwrap();
1963
1964        let opts = RenderOptions {
1965            width: 10,
1966            height: 10,
1967            ..Default::default()
1968        };
1969        let mut buf = vec![0u8; 10]; // too small (needs 400)
1970        let err = render_into(page, &opts, &mut buf).unwrap_err();
1971        assert!(
1972            matches!(err, RenderError::BufTooSmall { need: 400, got: 10 }),
1973            "expected BufTooSmall, got {err:?}"
1974        );
1975    }
1976
1977    /// `render_into` fills a pre-allocated buffer without allocating new one.
1978    ///
1979    /// We verify by: calling with exactly the right size buf, no panic,
1980    /// and the buffer is mutated (not all-zero after the call).
1981    #[test]
1982    fn render_into_fills_buffer_no_alloc() {
1983        let doc = load_doc("chicken.djvu");
1984        let page = doc.page(0).unwrap();
1985
1986        let w = 50u32;
1987        let h = 40u32;
1988        let opts = RenderOptions {
1989            width: w,
1990            height: h,
1991            ..Default::default()
1992        };
1993        let mut buf = vec![0u8; (w * h * 4) as usize];
1994        render_into(page, &opts, &mut buf).expect("render_into should succeed");
1995
1996        // The page is a color image — pixels should not all be zero
1997        assert!(
1998            buf.iter().any(|&b| b != 0),
1999            "rendered buffer should contain non-zero pixels"
2000        );
2001    }
2002
2003    /// `render_into` can be called twice with the same buffer (zero-allocation reuse).
2004    #[test]
2005    fn render_into_reuse_buffer() {
2006        let doc = load_doc("chicken.djvu");
2007        let page = doc.page(0).unwrap();
2008
2009        let w = 30u32;
2010        let h = 20u32;
2011        let opts = RenderOptions {
2012            width: w,
2013            height: h,
2014            ..Default::default()
2015        };
2016        let mut buf = vec![0u8; (w * h * 4) as usize];
2017
2018        // First render
2019        render_into(page, &opts, &mut buf).expect("first render_into should succeed");
2020        let first = buf.clone();
2021
2022        // Second render — same result
2023        render_into(page, &opts, &mut buf).expect("second render_into should succeed");
2024        assert_eq!(
2025            first, buf,
2026            "repeated render_into should produce identical output"
2027        );
2028    }
2029
2030    /// Gamma correction changes pixel values vs identity (no-gamma).
2031    #[test]
2032    fn gamma_correction_changes_pixels() {
2033        // Build gamma LUT for gamma=2.2 and identity
2034        let lut_gamma = build_gamma_lut(2.2);
2035        let lut_identity = build_gamma_lut(1.0);
2036
2037        // For midtone value, gamma-corrected should differ from identity
2038        let mid = 128u8;
2039        let corrected = lut_gamma[mid as usize];
2040        let identity = lut_identity[mid as usize];
2041
2042        // Identity LUT should map 128 → 128
2043        assert_eq!(identity, mid, "identity LUT must be identity");
2044
2045        // Gamma-corrected midtone should be brighter (gamma decode = lighter)
2046        assert!(
2047            corrected > mid,
2048            "gamma-corrected midtone ({corrected}) should be > identity ({mid})"
2049        );
2050    }
2051
2052    /// Gamma LUT for identity (gamma=1.0) is the identity function.
2053    #[test]
2054    fn gamma_lut_identity() {
2055        let lut = build_gamma_lut(1.0);
2056        for (i, &val) in lut.iter().enumerate() {
2057            assert_eq!(val, i as u8, "identity LUT at {i}: expected {i}, got {val}");
2058        }
2059    }
2060
2061    /// Gamma LUT for gamma=0.0 (invalid) falls back to identity.
2062    #[test]
2063    fn gamma_lut_zero_is_identity() {
2064        let lut = build_gamma_lut(0.0);
2065        for (i, &val) in lut.iter().enumerate() {
2066            assert_eq!(val, i as u8, "zero gamma should produce identity LUT");
2067        }
2068    }
2069
2070    /// render_coarse returns a valid pixmap (non-empty, correct dimensions) for
2071    /// a color page.
2072    #[test]
2073    fn render_coarse_returns_pixmap() {
2074        let doc = load_doc("chicken.djvu");
2075        let page = doc.page(0).unwrap();
2076
2077        let opts = RenderOptions {
2078            width: 60,
2079            height: 80,
2080            ..Default::default()
2081        };
2082
2083        let result = render_coarse(page, &opts).expect("render_coarse should succeed");
2084        // chicken.djvu may or may not have BG44 chunks
2085        if let Some(pm) = result {
2086            assert_eq!(pm.width, 60);
2087            assert_eq!(pm.height, 80);
2088            assert_eq!(pm.data.len(), 60 * 80 * 4);
2089        }
2090        // Ok(None) is also valid if no BG44
2091    }
2092
2093    /// render_progressive returns valid pixmap after each chunk.
2094    #[test]
2095    fn render_progressive_each_chunk() {
2096        // Use a page that has multiple BG44 chunks (boy.djvu is a good candidate)
2097        let doc = load_doc("boy.djvu");
2098        let page = doc.page(0).unwrap();
2099
2100        let opts = RenderOptions {
2101            width: 80,
2102            height: 100,
2103            ..Default::default()
2104        };
2105
2106        let n_bg44 = page.bg44_chunks().len();
2107
2108        for chunk_n in 0..n_bg44 {
2109            let pm = render_progressive(page, &opts, chunk_n)
2110                .unwrap_or_else(|e| panic!("render_progressive chunk {chunk_n} failed: {e}"));
2111            assert_eq!(pm.width, 80);
2112            assert_eq!(pm.height, 100);
2113            assert_eq!(pm.data.len(), 80 * 100 * 4);
2114            // Each frame must have some non-zero pixels
2115            assert!(
2116                pm.data.iter().any(|&b| b != 0),
2117                "chunk {chunk_n}: rendered frame should not be all-zero"
2118            );
2119        }
2120    }
2121
2122    /// render_progressive with chunk_n out of range returns ChunkOutOfRange.
2123    #[test]
2124    fn render_progressive_chunk_out_of_range() {
2125        let doc = load_doc("boy.djvu");
2126        let page = doc.page(0).unwrap();
2127
2128        let opts = RenderOptions {
2129            width: 40,
2130            height: 50,
2131            ..Default::default()
2132        };
2133
2134        let n_bg44 = page.bg44_chunks().len();
2135        if n_bg44 == 0 {
2136            // No BG44 chunks — skip this test
2137            return;
2138        }
2139
2140        let err = render_progressive(page, &opts, n_bg44 + 10).unwrap_err();
2141        assert!(
2142            matches!(err, RenderError::ChunkOutOfRange { .. }),
2143            "expected ChunkOutOfRange, got {err:?}"
2144        );
2145    }
2146
2147    /// render_pixmap with gamma gives different result than without (identity gamma).
2148    ///
2149    /// We compare rendering chicken.djvu twice: once with its natural gamma,
2150    /// once with gamma forced to 1.0 (identity). The pixel values should differ.
2151    #[test]
2152    fn render_pixmap_gamma_differs_from_identity() {
2153        let doc = load_doc("chicken.djvu");
2154        let page = doc.page(0).unwrap();
2155
2156        let w = 40u32;
2157        let h = 53u32; // ~native aspect for 181x240
2158
2159        let opts = RenderOptions {
2160            width: w,
2161            height: h,
2162            ..Default::default()
2163        };
2164
2165        // Render with native gamma (2.2 from INFO chunk)
2166        let pm_gamma = render_pixmap(page, &opts).expect("render with gamma should succeed");
2167
2168        // Render with identity gamma LUT manually applied to output
2169        let lut_identity = build_gamma_lut(1.0);
2170        let pm_identity = render_pixmap(page, &opts).expect("render for identity should succeed");
2171        // Apply identity correction (no-op) — pixels should be the same
2172        for i in 0..pm_identity.data.len().saturating_sub(3) {
2173            if i % 4 != 3 {
2174                // non-alpha channel
2175                let _ = lut_identity[pm_identity.data[i] as usize];
2176            }
2177        }
2178
2179        // Since chicken.djvu gamma = 2.2, the gamma-corrected render
2180        // should have generally brighter mid-tones than a raw (no-correction) render.
2181        // We test this by checking that the gamma render is not bit-for-bit identical
2182        // to a hypothetical no-correction render. Since we always apply gamma in
2183        // render_pixmap, we test the gamma LUT effect directly (covered by
2184        // `gamma_correction_changes_pixels`).
2185        //
2186        // Instead, verify that pm_gamma has valid dimensions and non-trivial content.
2187        assert_eq!(pm_gamma.width, w);
2188        assert_eq!(pm_gamma.height, h);
2189        assert!(
2190            pm_gamma.data.iter().any(|&b| b != 255),
2191            "should have non-white pixels"
2192        );
2193    }
2194
2195    /// render_pixmap for a bilevel (JB2-only) page produces black pixels.
2196    #[test]
2197    fn render_bilevel_page_has_black_pixels() {
2198        let doc = load_doc("boy_jb2.djvu");
2199        let page = doc.page(0).unwrap();
2200
2201        let opts = RenderOptions {
2202            width: 60,
2203            height: 80,
2204            ..Default::default()
2205        };
2206
2207        let pm = render_pixmap(page, &opts).expect("render bilevel should succeed");
2208        assert_eq!(pm.width, 60);
2209        assert_eq!(pm.height, 80);
2210        // A bilevel page should have some black pixels
2211        assert!(
2212            pm.data
2213                .chunks_exact(4)
2214                .any(|px| px[0] == 0 && px[1] == 0 && px[2] == 0),
2215            "bilevel page should contain black pixels"
2216        );
2217    }
2218
2219    /// `render_pixmap` with aa=true returns a valid pixmap.
2220    #[test]
2221    fn render_with_aa() {
2222        let doc = load_doc("chicken.djvu");
2223        let page = doc.page(0).unwrap();
2224
2225        let opts = RenderOptions {
2226            width: 40,
2227            height: 54,
2228            aa: true,
2229            ..Default::default()
2230        };
2231        // With aa=true the output is downscaled 2×, so we get 20×27
2232        let pm = render_pixmap(page, &opts).expect("render with AA should succeed");
2233        // AA downscales the output
2234        assert_eq!(pm.width, 20);
2235        assert_eq!(pm.height, 27);
2236    }
2237
2238    // Remove the unused helper that panics
2239    #[allow(dead_code)]
2240    fn _unused_load_page(_: &str) -> ! {
2241        let _ = load_page; // suppress dead code warning
2242        panic!("use load_doc instead")
2243    }
2244
2245    // -- Rotation tests -------------------------------------------------------
2246
2247    #[test]
2248    fn rotate_pixmap_none_is_identity() {
2249        let mut pm = Pixmap::white(3, 2);
2250        pm.set_rgb(0, 0, 255, 0, 0);
2251        let rotated = rotate_pixmap(pm.clone(), crate::info::Rotation::None);
2252        assert_eq!(rotated.width, 3);
2253        assert_eq!(rotated.height, 2);
2254        assert_eq!(rotated.get_rgb(0, 0), (255, 0, 0));
2255    }
2256
2257    #[test]
2258    fn rotate_pixmap_cw90_swaps_dims() {
2259        let mut pm = Pixmap::white(4, 2);
2260        pm.set_rgb(0, 0, 255, 0, 0); // top-left red
2261        let rotated = rotate_pixmap(pm, crate::info::Rotation::Cw90);
2262        assert_eq!(rotated.width, 2);
2263        assert_eq!(rotated.height, 4);
2264        // Top-left (0,0) of original goes to (height-1-0, 0) = (1, 0) in rotated
2265        assert_eq!(rotated.get_rgb(1, 0), (255, 0, 0));
2266    }
2267
2268    #[test]
2269    fn rotate_pixmap_180_preserves_dims() {
2270        let mut pm = Pixmap::white(3, 2);
2271        pm.set_rgb(0, 0, 255, 0, 0); // top-left red
2272        let rotated = rotate_pixmap(pm, crate::info::Rotation::Rot180);
2273        assert_eq!(rotated.width, 3);
2274        assert_eq!(rotated.height, 2);
2275        assert_eq!(rotated.get_rgb(2, 1), (255, 0, 0));
2276    }
2277
2278    #[test]
2279    fn rotate_pixmap_ccw90_swaps_dims() {
2280        let mut pm = Pixmap::white(4, 2);
2281        pm.set_rgb(0, 0, 255, 0, 0); // top-left red
2282        let rotated = rotate_pixmap(pm, crate::info::Rotation::Ccw90);
2283        assert_eq!(rotated.width, 2);
2284        assert_eq!(rotated.height, 4);
2285        // Top-left (0,0) -> (0, width-1-0) = (0, 3) in rotated
2286        assert_eq!(rotated.get_rgb(0, 3), (255, 0, 0));
2287    }
2288
2289    #[test]
2290    fn render_pixmap_rotation_90_swaps_dimensions() {
2291        let doc = load_doc("boy_jb2_rotate90.djvu");
2292        let page = doc.page(0).expect("page 0");
2293        let orig_w = page.width();
2294        let orig_h = page.height();
2295        let opts = RenderOptions {
2296            width: orig_w as u32,
2297            height: orig_h as u32,
2298            ..Default::default()
2299        };
2300        let pm = render_pixmap(page, &opts).expect("render should succeed");
2301        // 90° rotation swaps width and height
2302        assert_eq!(
2303            pm.width, orig_h as u32,
2304            "rotated width should be original height"
2305        );
2306        assert_eq!(
2307            pm.height, orig_w as u32,
2308            "rotated height should be original width"
2309        );
2310    }
2311
2312    #[test]
2313    fn render_pixmap_rotation_180_preserves_dimensions() {
2314        let doc = load_doc("boy_jb2_rotate180.djvu");
2315        let page = doc.page(0).expect("page 0");
2316        let orig_w = page.width();
2317        let orig_h = page.height();
2318        let opts = RenderOptions {
2319            width: orig_w as u32,
2320            height: orig_h as u32,
2321            ..Default::default()
2322        };
2323        let pm = render_pixmap(page, &opts).expect("render should succeed");
2324        assert_eq!(pm.width, orig_w as u32);
2325        assert_eq!(pm.height, orig_h as u32);
2326    }
2327
2328    #[test]
2329    fn render_pixmap_rotation_270_swaps_dimensions() {
2330        let doc = load_doc("boy_jb2_rotate270.djvu");
2331        let page = doc.page(0).expect("page 0");
2332        let orig_w = page.width();
2333        let orig_h = page.height();
2334        let opts = RenderOptions {
2335            width: orig_w as u32,
2336            height: orig_h as u32,
2337            ..Default::default()
2338        };
2339        let pm = render_pixmap(page, &opts).expect("render should succeed");
2340        assert_eq!(
2341            pm.width, orig_h as u32,
2342            "rotated width should be original height"
2343        );
2344        assert_eq!(
2345            pm.height, orig_w as u32,
2346            "rotated height should be original width"
2347        );
2348    }
2349
2350    // -- User rotation tests ---------------------------------------------------
2351
2352    /// combine_rotations adds steps modulo 4.
2353    #[test]
2354    fn combine_rotations_identity() {
2355        use crate::info::Rotation;
2356        assert_eq!(
2357            combine_rotations(Rotation::None, UserRotation::None),
2358            Rotation::None
2359        );
2360    }
2361
2362    #[test]
2363    fn combine_rotations_info_only() {
2364        use crate::info::Rotation;
2365        assert_eq!(
2366            combine_rotations(Rotation::Cw90, UserRotation::None),
2367            Rotation::Cw90
2368        );
2369    }
2370
2371    #[test]
2372    fn combine_rotations_user_only() {
2373        use crate::info::Rotation;
2374        assert_eq!(
2375            combine_rotations(Rotation::None, UserRotation::Ccw90),
2376            Rotation::Ccw90
2377        );
2378    }
2379
2380    #[test]
2381    fn combine_rotations_sum() {
2382        use crate::info::Rotation;
2383        // 90 CW (INFO) + 90 CW (user) = 180
2384        assert_eq!(
2385            combine_rotations(Rotation::Cw90, UserRotation::Cw90),
2386            Rotation::Rot180
2387        );
2388        // 90 CW + 270 CW = 360 = None
2389        assert_eq!(
2390            combine_rotations(Rotation::Cw90, UserRotation::Ccw90),
2391            Rotation::None
2392        );
2393        // 180 + 180 = 360 = None
2394        assert_eq!(
2395            combine_rotations(Rotation::Rot180, UserRotation::Rot180),
2396            Rotation::None
2397        );
2398    }
2399
2400    /// User rotation Cw90 on a non-rotated page swaps output dimensions.
2401    #[test]
2402    fn user_rotation_cw90_swaps_dimensions() {
2403        let doc = load_doc("chicken.djvu");
2404        let page = doc.page(0).unwrap();
2405        let pw = page.width() as u32;
2406        let ph = page.height() as u32;
2407
2408        let opts = RenderOptions {
2409            width: pw,
2410            height: ph,
2411            rotation: UserRotation::Cw90,
2412            ..Default::default()
2413        };
2414        let pm = render_pixmap(page, &opts).expect("render");
2415        assert_eq!(pm.width, ph, "user Cw90 should swap: width becomes height");
2416        assert_eq!(pm.height, pw, "user Cw90 should swap: height becomes width");
2417    }
2418
2419    /// User rotation 180° preserves dimensions.
2420    #[test]
2421    fn user_rotation_180_preserves_dimensions() {
2422        let doc = load_doc("chicken.djvu");
2423        let page = doc.page(0).unwrap();
2424        let pw = page.width() as u32;
2425        let ph = page.height() as u32;
2426
2427        let opts = RenderOptions {
2428            width: pw,
2429            height: ph,
2430            rotation: UserRotation::Rot180,
2431            ..Default::default()
2432        };
2433        let pm = render_pixmap(page, &opts).expect("render");
2434        assert_eq!(pm.width, pw);
2435        assert_eq!(pm.height, ph);
2436    }
2437
2438    /// UserRotation default is None.
2439    #[test]
2440    fn user_rotation_default_is_none() {
2441        assert_eq!(UserRotation::default(), UserRotation::None);
2442        let opts = RenderOptions::default();
2443        assert_eq!(opts.rotation, UserRotation::None);
2444    }
2445
2446    // -- FGbz multi-color palette tests ---------------------------------------
2447
2448    #[test]
2449    fn fgbz_palette_page_renders_multiple_colors() {
2450        // irish.djvu is a single-page file with an FGbz palette.
2451        let doc = load_doc("irish.djvu");
2452        let page = doc.page(0).expect("page 0");
2453        let w = page.width() as u32;
2454        let h = page.height() as u32;
2455        let opts = RenderOptions {
2456            width: w,
2457            height: h,
2458            ..Default::default()
2459        };
2460        let pm = render_pixmap(page, &opts).expect("render should succeed");
2461
2462        // Collect distinct non-white, non-black foreground colors
2463        let mut fg_colors = std::collections::HashSet::new();
2464        for y in 0..h {
2465            for x in 0..w {
2466                let (r, g, b) = pm.get_rgb(x, y);
2467                // Skip white and near-white (background)
2468                if r > 240 && g > 240 && b > 240 {
2469                    continue;
2470                }
2471                fg_colors.insert((r, g, b));
2472            }
2473        }
2474
2475        // A multi-color palette page should produce more than 1 distinct
2476        // foreground color (if it only had 1, it'd be the old bug).
2477        assert!(
2478            fg_colors.len() > 1,
2479            "multi-color palette page should have >1 distinct foreground colors, got {}",
2480            fg_colors.len()
2481        );
2482    }
2483
2484    #[test]
2485    fn lookup_palette_color_uses_blit_map() {
2486        let pal = FgbzPalette {
2487            colors: vec![
2488                PaletteColor { r: 255, g: 0, b: 0 }, // index 0: red
2489                PaletteColor { r: 0, g: 0, b: 255 }, // index 1: blue
2490            ],
2491            indices: vec![1, 0], // blit 0 → color 1 (blue), blit 1 → color 0 (red)
2492        };
2493        let bm = crate::bitmap::Bitmap::new(2, 1);
2494        let blit_map = vec![0i32, 1i32]; // pixel (0,0) → blit 0, pixel (1,0) → blit 1
2495
2496        let c0 = lookup_palette_color(&pal, Some(&blit_map), Some(&bm), 0, 0);
2497        assert_eq!(
2498            (c0.r, c0.g, c0.b),
2499            (0, 0, 255),
2500            "blit 0 → indices[0]=1 → blue"
2501        );
2502
2503        let c1 = lookup_palette_color(&pal, Some(&blit_map), Some(&bm), 1, 0);
2504        assert_eq!(
2505            (c1.r, c1.g, c1.b),
2506            (255, 0, 0),
2507            "blit 1 → indices[1]=0 → red"
2508        );
2509    }
2510
2511    #[test]
2512    fn lookup_palette_color_fallback_without_blit_map() {
2513        let pal = FgbzPalette {
2514            colors: vec![PaletteColor { r: 0, g: 128, b: 0 }],
2515            indices: vec![],
2516        };
2517        let c = lookup_palette_color(&pal, None, None, 0, 0);
2518        assert_eq!(
2519            (c.r, c.g, c.b),
2520            (0, 128, 0),
2521            "should fall back to first color"
2522        );
2523    }
2524
2525    // ── BGjp / FGjp tests ─────────────────────────────────────────────────────
2526
2527    /// Load the synthetic bgjp_test.djvu fixture from the assets directory.
2528    fn load_bgjp_doc() -> DjVuDocument {
2529        load_doc("bgjp_test.djvu")
2530    }
2531
2532    /// BGjp fixture loads without error and reports correct dimensions.
2533    #[test]
2534    fn bgjp_fixture_loads() {
2535        let doc = load_bgjp_doc();
2536        let page = doc.page(0).unwrap();
2537        assert_eq!(page.width(), 4);
2538        assert_eq!(page.height(), 4);
2539    }
2540
2541    /// BGjp chunk is present in the fixture.
2542    #[test]
2543    fn bgjp_chunk_present() {
2544        let doc = load_bgjp_doc();
2545        let page = doc.page(0).unwrap();
2546        assert!(
2547            page.find_chunk(b"BGjp").is_some(),
2548            "fixture must have a BGjp chunk"
2549        );
2550        assert!(
2551            page.bg44_chunks().is_empty(),
2552            "fixture must NOT have BG44 chunks"
2553        );
2554    }
2555
2556    /// `decode_bgjp` returns a non-None Pixmap for the BGjp fixture.
2557    #[test]
2558    fn decode_bgjp_returns_pixmap() {
2559        let doc = load_bgjp_doc();
2560        let page = doc.page(0).unwrap();
2561        let pm = decode_bgjp(page).expect("decode_bgjp must not error");
2562        assert!(pm.is_some(), "decode_bgjp must return Some(Pixmap)");
2563        let pm = pm.unwrap();
2564        assert_eq!(pm.width, 4);
2565        assert_eq!(pm.height, 4);
2566        assert_eq!(pm.data.len(), 4 * 4 * 4); // RGBA
2567    }
2568
2569    /// `decode_bgjp` returns None for a page with no BGjp chunk.
2570    #[test]
2571    fn decode_bgjp_returns_none_without_chunk() {
2572        let doc = load_doc("chicken.djvu");
2573        let page = doc.page(0).unwrap();
2574        let pm = decode_bgjp(page).expect("should not error");
2575        assert!(pm.is_none());
2576    }
2577
2578    /// `decode_jpeg_to_pixmap` produces RGBA output with alpha=255.
2579    #[test]
2580    fn decode_jpeg_to_pixmap_alpha_is_255() {
2581        let doc = load_bgjp_doc();
2582        let page = doc.page(0).unwrap();
2583        let data = page.find_chunk(b"BGjp").unwrap();
2584        let pm = decode_jpeg_to_pixmap(data).expect("decode must succeed");
2585        for chunk in pm.data.chunks_exact(4) {
2586            assert_eq!(chunk[3], 255, "alpha must be 255 for every pixel");
2587        }
2588    }
2589
2590    /// render_pixmap falls back to BGjp when no BG44 chunks are present.
2591    #[test]
2592    fn render_pixmap_uses_bgjp_background() {
2593        let doc = load_bgjp_doc();
2594        let page = doc.page(0).unwrap();
2595        let opts = RenderOptions {
2596            width: 4,
2597            height: 4,
2598            scale: 1.0,
2599            bold: 0,
2600            aa: false,
2601            rotation: UserRotation::None,
2602            permissive: false,
2603            resampling: Resampling::Bilinear,
2604        };
2605        let pm = render_pixmap(page, &opts).expect("render must succeed");
2606        assert_eq!(pm.width, 4);
2607        assert_eq!(pm.height, 4);
2608    }
2609
2610    /// render_coarse also falls back to BGjp (no BG44 chunks).
2611    #[test]
2612    fn render_coarse_uses_bgjp_background() {
2613        let doc = load_bgjp_doc();
2614        let page = doc.page(0).unwrap();
2615        let opts = RenderOptions {
2616            width: 4,
2617            height: 4,
2618            scale: 1.0,
2619            bold: 0,
2620            aa: false,
2621            rotation: UserRotation::None,
2622            permissive: false,
2623            resampling: Resampling::Bilinear,
2624        };
2625        let pm = render_coarse(page, &opts).expect("render_coarse must succeed");
2626        assert!(pm.is_some(), "must return Some when BGjp present");
2627        let pm = pm.unwrap();
2628        assert_eq!(pm.width, 4);
2629        assert_eq!(pm.height, 4);
2630    }
2631
2632    // ── Lanczos-3 tests ───────────────────────────────────────────────────────
2633
2634    /// `lanczos3_kernel(0)` == 1.0 (unity at origin).
2635    #[test]
2636    fn lanczos3_kernel_unity_at_zero() {
2637        assert!((lanczos3_kernel(0.0) - 1.0).abs() < 1e-5);
2638    }
2639
2640    /// `lanczos3_kernel` is zero outside |x| ≥ 3.
2641    #[test]
2642    fn lanczos3_kernel_zero_outside_support() {
2643        assert_eq!(lanczos3_kernel(3.0), 0.0);
2644        assert_eq!(lanczos3_kernel(-3.5), 0.0);
2645        assert_eq!(lanczos3_kernel(10.0), 0.0);
2646    }
2647
2648    /// `scale_lanczos3` preserves dimensions.
2649    #[test]
2650    fn scale_lanczos3_correct_dimensions() {
2651        let src = Pixmap::white(100, 80);
2652        let dst = scale_lanczos3(&src, 50, 40);
2653        assert_eq!(dst.width, 50);
2654        assert_eq!(dst.height, 40);
2655    }
2656
2657    /// `scale_lanczos3` returns a clone when source and target match.
2658    #[test]
2659    fn scale_lanczos3_noop_when_same_size() {
2660        let src = Pixmap::new(4, 4, 200, 100, 50, 255);
2661        let dst = scale_lanczos3(&src, 4, 4);
2662        assert_eq!(dst.width, 4);
2663        assert_eq!(dst.height, 4);
2664        assert_eq!(dst.data, src.data);
2665    }
2666
2667    /// Scaling a solid-color pixmap with Lanczos-3 preserves the color.
2668    #[test]
2669    fn scale_lanczos3_preserves_solid_color() {
2670        // Solid red 20×20 → 10×10
2671        let src = Pixmap::new(20, 20, 200, 0, 0, 255);
2672        let dst = scale_lanczos3(&src, 10, 10);
2673        assert_eq!(dst.width, 10);
2674        assert_eq!(dst.height, 10);
2675        // All output pixels should be close to red (200, 0, 0).
2676        for chunk in dst.data.chunks_exact(4) {
2677            let (r, g, b) = (chunk[0], chunk[1], chunk[2]);
2678            assert!(
2679                (r as i32 - 200).abs() <= 5 && g <= 5 && b <= 5,
2680                "expected near-red (200,0,0), got ({r},{g},{b})"
2681            );
2682        }
2683    }
2684
2685    /// `Resampling::Lanczos3` produces the correct output dimensions.
2686    #[test]
2687    fn render_pixmap_lanczos3_correct_dimensions() {
2688        let doc = load_doc("chicken.djvu");
2689        let page = doc.page(0).unwrap();
2690        let pw = page.width() as u32;
2691        let ph = page.height() as u32;
2692        let tw = pw / 2;
2693        let th = ph / 2;
2694
2695        let opts = RenderOptions {
2696            width: tw,
2697            height: th,
2698            scale: 0.5,
2699            resampling: Resampling::Lanczos3,
2700            ..Default::default()
2701        };
2702        let pm = render_pixmap(page, &opts).expect("Lanczos3 render must succeed");
2703        assert_eq!(pm.width, tw);
2704        assert_eq!(pm.height, th);
2705    }
2706
2707    /// Lanczos-3 and bilinear renders differ (different algorithms produce different output).
2708    #[test]
2709    fn lanczos3_differs_from_bilinear_at_half_scale() {
2710        let doc = load_doc("chicken.djvu");
2711        let page = doc.page(0).unwrap();
2712        let pw = page.width() as u32;
2713        let ph = page.height() as u32;
2714        let tw = pw / 2;
2715        let th = ph / 2;
2716
2717        let bilinear = render_pixmap(
2718            page,
2719            &RenderOptions {
2720                width: tw,
2721                height: th,
2722                scale: 0.5,
2723                resampling: Resampling::Bilinear,
2724                ..Default::default()
2725            },
2726        )
2727        .unwrap();
2728
2729        let lanczos = render_pixmap(
2730            page,
2731            &RenderOptions {
2732                width: tw,
2733                height: th,
2734                scale: 0.5,
2735                resampling: Resampling::Lanczos3,
2736                ..Default::default()
2737            },
2738        )
2739        .unwrap();
2740
2741        // Dimensions must be the same.
2742        assert_eq!(bilinear.width, lanczos.width);
2743        assert_eq!(bilinear.height, lanczos.height);
2744
2745        // But pixel values should differ (algorithms are not identical).
2746        let differ = bilinear
2747            .data
2748            .iter()
2749            .zip(lanczos.data.iter())
2750            .any(|(a, b)| a != b);
2751        assert!(
2752            differ,
2753            "Lanczos3 and bilinear must produce different pixel values"
2754        );
2755    }
2756
2757    /// `Resampling::Bilinear` default is maintained for backward compat.
2758    #[test]
2759    fn resampling_default_is_bilinear() {
2760        let opts = RenderOptions::default();
2761        assert_eq!(opts.resampling, Resampling::Bilinear);
2762    }
2763
2764    // ── render_region tests ───────────────────────────────────────────────────
2765
2766    /// `render_region` allocates only the region-sized buffer (≤ 512 KB for 256×256).
2767    #[test]
2768    fn render_region_allocates_proportionally() {
2769        let doc = load_doc("chicken.djvu");
2770        let page = doc.page(0).unwrap();
2771        let opts = RenderOptions::fit_to_width(page, 1000);
2772        let region = RenderRect {
2773            x: 0,
2774            y: 0,
2775            width: 256,
2776            height: 256,
2777        };
2778        let pm = render_region(page, region, &opts).expect("render_region should succeed");
2779        assert_eq!(pm.width, 256);
2780        assert_eq!(pm.height, 256);
2781        assert_eq!(pm.data.len(), 256 * 256 * 4);
2782        assert!(
2783            pm.data.len() <= 512 * 1024,
2784            "region allocation {} exceeds 512 KB",
2785            pm.data.len()
2786        );
2787    }
2788
2789    /// `render_region` pixels match the same pixels from `render_pixmap`.
2790    #[test]
2791    fn render_region_matches_full_render() {
2792        let doc = load_doc("chicken.djvu");
2793        let page = doc.page(0).unwrap();
2794        let opts = RenderOptions {
2795            width: 100,
2796            height: 80,
2797            ..Default::default()
2798        };
2799        let full = render_pixmap(page, &opts).expect("full render should succeed");
2800        let region = RenderRect {
2801            x: 10,
2802            y: 10,
2803            width: 30,
2804            height: 20,
2805        };
2806        let part = render_region(page, region, &opts).expect("region render should succeed");
2807
2808        assert_eq!(part.width, 30);
2809        assert_eq!(part.height, 20);
2810
2811        for ry in 0..20u32 {
2812            for rx in 0..30u32 {
2813                let full_base = ((10 + ry) as usize * 100 + (10 + rx) as usize) * 4;
2814                let part_base = (ry as usize * 30 + rx as usize) * 4;
2815                assert_eq!(
2816                    &full.data[full_base..full_base + 4],
2817                    &part.data[part_base..part_base + 4],
2818                    "pixel mismatch at region ({rx},{ry}) / full ({},{} )",
2819                    10 + rx,
2820                    10 + ry
2821                );
2822            }
2823        }
2824    }
2825
2826    /// `render_region` with invalid dimensions returns an error.
2827    #[test]
2828    fn render_region_invalid_dimensions() {
2829        let doc = load_doc("chicken.djvu");
2830        let page = doc.page(0).unwrap();
2831        let opts = RenderOptions {
2832            width: 100,
2833            height: 100,
2834            ..Default::default()
2835        };
2836        let region = RenderRect {
2837            x: 0,
2838            y: 0,
2839            width: 0,
2840            height: 50,
2841        };
2842        let err = render_region(page, region, &opts).unwrap_err();
2843        assert!(
2844            matches!(err, RenderError::InvalidDimensions { .. }),
2845            "expected InvalidDimensions, got {err:?}"
2846        );
2847    }
2848
2849    /// `render_pixmap` still works correctly (regression guard).
2850    #[test]
2851    fn render_pixmap_still_works_after_refactor() {
2852        let doc = load_doc("chicken.djvu");
2853        let page = doc.page(0).unwrap();
2854        let opts = RenderOptions {
2855            width: 80,
2856            height: 60,
2857            ..Default::default()
2858        };
2859        let pm = render_pixmap(page, &opts).expect("render_pixmap should succeed");
2860        assert_eq!(pm.width, 80);
2861        assert_eq!(pm.height, 60);
2862        assert_eq!(pm.data.len(), 80 * 60 * 4);
2863    }
2864
2865    /// `best_iw44_subsample` returns expected power-of-2 values.
2866    #[test]
2867    fn best_iw44_subsample_values() {
2868        assert_eq!(best_iw44_subsample(1.0), 1, "scale=1.0 → subsample=1");
2869        assert_eq!(best_iw44_subsample(0.5), 2, "scale=0.5 → subsample=2");
2870        assert_eq!(
2871            best_iw44_subsample(0.375),
2872            2,
2873            "scale=0.375 → subsample=2 (1/0.375=2.67)"
2874        );
2875        assert_eq!(best_iw44_subsample(0.25), 4, "scale=0.25 → subsample=4");
2876        assert_eq!(
2877            best_iw44_subsample(0.1),
2878            8,
2879            "scale=0.1 → subsample=8 (capped)"
2880        );
2881        assert_eq!(
2882            best_iw44_subsample(0.0),
2883            1,
2884            "scale=0.0 → subsample=1 (edge case)"
2885        );
2886        assert_eq!(
2887            best_iw44_subsample(-1.0),
2888            1,
2889            "scale<0 → subsample=1 (edge case)"
2890        );
2891        assert_eq!(
2892            best_iw44_subsample(2.0),
2893            1,
2894            "scale>1.0 → subsample=1 (no upscaling needed)"
2895        );
2896    }
2897
2898    /// Rendering with bg_subsample=2 (scale=0.5) produces the correct output dimensions.
2899    #[test]
2900    fn render_pixmap_subsampled_bg_correct_dimensions() {
2901        let doc = load_doc("boy.djvu");
2902        let page = doc.page(0).unwrap();
2903        // scale=0.5 triggers bg_subsample=2 internally
2904        let opts = RenderOptions {
2905            width: (page.width() as f32 * 0.5) as u32,
2906            height: (page.height() as f32 * 0.5) as u32,
2907            scale: 0.5,
2908            ..Default::default()
2909        };
2910        let pm = render_pixmap(page, &opts).expect("subsampled render should succeed");
2911        assert_eq!(pm.width, opts.width);
2912        assert_eq!(pm.height, opts.height);
2913        assert_eq!(
2914            pm.data.len() as u64,
2915            opts.width as u64 * opts.height as u64 * 4
2916        );
2917    }
2918
2919    /// Second render of the same page produces identical pixels — confirms the
2920    /// BG44 cache is used and does not corrupt output.
2921    #[test]
2922    fn decoded_bg44_cache_produces_identical_pixels_on_second_render() {
2923        let doc = load_doc("boy.djvu");
2924        let page = doc.page(0).unwrap();
2925        let opts = RenderOptions {
2926            width: page.width() as u32,
2927            height: page.height() as u32,
2928            ..Default::default()
2929        };
2930        let pm1 = render_pixmap(page, &opts).expect("first render should succeed");
2931        let pm2 = render_pixmap(page, &opts).expect("second render should succeed");
2932        assert_eq!(
2933            pm1.data, pm2.data,
2934            "cached render must produce identical pixels"
2935        );
2936    }
2937
2938    /// After the first render the `decoded_bg44` cache is populated — the
2939    /// image dimensions match the page's raw BG44 size.
2940    #[test]
2941    fn decoded_bg44_is_populated_after_render() {
2942        let doc = load_doc("boy.djvu");
2943        let page = doc.page(0).unwrap();
2944        let opts = RenderOptions {
2945            width: page.width() as u32,
2946            height: page.height() as u32,
2947            ..Default::default()
2948        };
2949        // Trigger cache population.
2950        render_pixmap(page, &opts).expect("render should succeed");
2951        // Cache must now hold an image whose size matches the page's native size.
2952        let cached = page
2953            .decoded_bg44()
2954            .expect("cache should be populated after render");
2955        assert_eq!(
2956            cached.width,
2957            page.width() as u32,
2958            "cached bg44 width must equal page width"
2959        );
2960        assert_eq!(
2961            cached.height,
2962            page.height() as u32,
2963            "cached bg44 height must equal page height"
2964        );
2965    }
2966
2967    /// `render_region` applies page rotation the same way as `render_pixmap`.
2968    ///
2969    /// For a 90° CW rotation a non-square region of width×height is returned as
2970    /// height×width — proving rotation was applied (not silently skipped).
2971    #[test]
2972    fn render_region_applies_rotation() {
2973        let doc = load_doc("chicken.djvu");
2974        let page = doc.page(0).unwrap();
2975        // Request an explicit 90° CW user rotation.
2976        let opts = RenderOptions {
2977            width: 80,
2978            height: 60,
2979            rotation: UserRotation::Cw90,
2980            ..Default::default()
2981        };
2982        // Non-square region so swapped dimensions are detectable.
2983        let region = RenderRect {
2984            x: 0,
2985            y: 0,
2986            width: 40,
2987            height: 20,
2988        };
2989        let part = render_region(page, region, &opts).expect("region render should succeed");
2990        // After CW90 rotation a 40×20 region becomes 20×40.
2991        assert_eq!(
2992            part.width, 20,
2993            "expected width=20 (was region.height) after CW90 rotation"
2994        );
2995        assert_eq!(
2996            part.height, 40,
2997            "expected height=40 (was region.width) after CW90 rotation"
2998        );
2999    }
3000}