Skip to main content

djvu_jb2/
lib.rs

1//! JB2 bilevel image decoder — clean-room implementation (phase 2b).
2//!
3//! Decodes JB2-encoded bitonal images from DjVu Sjbz and Djbz chunks.
4//! The JB2 format uses a ZP adaptive arithmetic coder with 262 context variables
5//! and a symbol dictionary for run-length compression of recurring glyphs.
6//!
7//! # Key public types
8//!
9//! - `Jb2Dict` — shared symbol dictionary decoded from a Djbz chunk
10//! - `decode` — decode a Sjbz image stream to a `Bitmap`
11//! - `decode_dict` — decode a Djbz dictionary stream to a `Jb2Dict`
12//!
13//! # Record types
14//!
15//! | Code | Meaning |
16//! |------|---------|
17//! | 0    | start-of-image |
18//! | 1    | new-symbol, add to dict AND blit to page |
19//! | 2    | new-symbol, add to dict only |
20//! | 3    | new-symbol (direct), blit only (not added to dict) |
21//! | 4    | matched-refine, add to dict AND blit |
22//! | 5    | matched-refine, add to dict only |
23//! | 6    | matched-refine, blit only |
24//! | 7    | matched-copy (no refinement), blit only |
25//! | 8    | non-symbol (halftone block), blit only |
26//! | 9    | required-dict-or-reset |
27//! | 10   | comment |
28//! | 11   | end-of-data |
29
30#![cfg_attr(not(feature = "std"), no_std)]
31#![deny(unsafe_code)]
32
33#[cfg(not(feature = "std"))]
34extern crate alloc;
35
36#[cfg(not(feature = "std"))]
37use alloc::{vec, vec::Vec};
38#[cfg(feature = "std")]
39use std::{vec, vec::Vec};
40
41use djvu_bitmap::Bitmap;
42use djvu_zp::ZpDecoder;
43
44/// JB2 bitonal image decoding errors.
45#[derive(Debug, thiserror::Error, PartialEq, Eq)]
46pub enum Jb2Error {
47    /// Input ended before the JB2 stream was complete.
48    #[error("JB2 stream is truncated")]
49    Truncated,
50
51    /// A flag bit in the image/dict header was set when it must be zero.
52    #[error("JB2: bad flag bit in header")]
53    BadHeaderFlag,
54
55    /// The inherited dictionary length exceeds the shared dictionary size.
56    #[error("JB2: inherited dict length exceeds shared dict size")]
57    InheritedDictTooLarge,
58
59    /// The stream references a shared dictionary but none was provided.
60    #[error("JB2: stream requires shared dict but none provided")]
61    MissingSharedDict,
62
63    /// Image dimensions exceed the safety limit (~64M pixels).
64    #[error("JB2: image dimensions too large")]
65    ImageTooLarge,
66
67    /// A record references a dictionary symbol but the dictionary is empty.
68    #[error("JB2: dict reference with empty dict")]
69    EmptyDictReference,
70
71    /// A decoded symbol index is out of range for the current dictionary.
72    #[error("JB2: decoded symbol index out of dictionary range")]
73    InvalidSymbolIndex,
74
75    /// An unrecognized record type was encountered in the image stream.
76    #[error("JB2: unknown record type")]
77    UnknownRecordType,
78
79    /// An unexpected record type was encountered in a dictionary stream.
80    #[error("JB2: unexpected record type in dict stream")]
81    UnexpectedDictRecordType,
82
83    /// The ZP arithmetic coder could not be initialized (insufficient input).
84    #[error("JB2: insufficient data to initialize ZP coder")]
85    ZpInitFailed,
86
87    /// Stream contains more records than the safety limit allows.
88    #[error("JB2: record count exceeds safety limit")]
89    TooManyRecords,
90}
91
92// ────────────────────────────────────────────────────────────────────────────
93// NumContext: binary-tree arena for variable-length integer decoding
94// ────────────────────────────────────────────────────────────────────────────
95
96/// Binary-tree context store used to decode variable-length integers with ZP.
97///
98/// Each node in the tree holds one adaptive ZP context byte. Nodes are
99/// allocated lazily as the decoder traverses the tree.
100struct NumContext {
101    ctx: Vec<u8>,
102    left: Vec<u32>,
103    right: Vec<u32>,
104}
105
106impl NumContext {
107    fn new() -> Self {
108        // Index 0 = unused sentinel; index 1 = root.
109        NumContext {
110            ctx: vec![0, 0],
111            left: vec![0, 0],
112            right: vec![0, 0],
113        }
114    }
115
116    fn root(&self) -> usize {
117        1
118    }
119
120    fn get_left(&mut self, node: usize) -> usize {
121        if self.left[node] == 0 {
122            let idx = self.ctx.len() as u32;
123            self.ctx.push(0);
124            self.left.push(0);
125            self.right.push(0);
126            self.left[node] = idx;
127        }
128        self.left[node] as usize
129    }
130
131    fn get_right(&mut self, node: usize) -> usize {
132        if self.right[node] == 0 {
133            let idx = self.ctx.len() as u32;
134            self.ctx.push(0);
135            self.left.push(0);
136            self.right.push(0);
137            self.right[node] = idx;
138        }
139        self.right[node] as usize
140    }
141}
142
143/// Decode a variable-length integer in the range `[low, high]` using ZP
144/// with a binary-tree context store.
145fn decode_num(zp: &mut ZpDecoder<'_>, ctx: &mut NumContext, low: i32, high: i32) -> i32 {
146    let mut low = low;
147    let mut high = high;
148    let mut negative = false;
149    let mut cutoff: i32 = 0;
150    let mut phase: u32 = 1;
151    let mut range: u32 = 0xffff_ffff;
152    let mut node = ctx.root();
153
154    while range != 1 {
155        let decision = if low >= cutoff {
156            true
157        } else if high >= cutoff {
158            zp.decode_bit(&mut ctx.ctx[node])
159        } else {
160            false
161        };
162
163        node = if decision {
164            ctx.get_right(node)
165        } else {
166            ctx.get_left(node)
167        };
168
169        match phase {
170            1 => {
171                negative = !decision;
172                if negative {
173                    let temp = -low - 1;
174                    low = -high - 1;
175                    high = temp;
176                }
177                phase = 2;
178                cutoff = 1;
179            }
180            2 => {
181                if !decision {
182                    phase = 3;
183                    range = ((cutoff + 1) / 2) as u32;
184                    if range == 1 {
185                        // range is already 1; set cutoff to 0 to terminate the loop.
186                        cutoff = 0;
187                    } else {
188                        cutoff -= (range / 2) as i32;
189                    }
190                } else {
191                    cutoff = cutoff * 2 + 1;
192                }
193            }
194            3 => {
195                range /= 2;
196                if range == 0 {
197                    range = 1;
198                }
199                if range != 1 {
200                    if !decision {
201                        cutoff -= (range / 2) as i32;
202                    } else {
203                        cutoff += (range / 2) as i32;
204                    }
205                } else if !decision {
206                    cutoff -= 1;
207                }
208            }
209            _ => {
210                // Unreachable: phase cycles through 1, 2, 3 only.
211                // Use a saturating decrement to keep range moving toward 1.
212                range = range.saturating_sub(1);
213            }
214        }
215    }
216
217    if negative { -cutoff - 1 } else { cutoff }
218}
219
220// ────────────────────────────────────────────────────────────────────────────
221// Jbm: internal bit-packed working bitmap (row 0 = bottom of page)
222// ────────────────────────────────────────────────────────────────────────────
223
224/// Internal working bitmap used during JB2 decoding.
225///
226/// Pixels are stored bit-packed: 1 bit per pixel, MSB-first within each byte,
227/// rows padded to byte boundary (`row_stride_bytes`). Matches `Bitmap`'s
228/// convention, which makes blit into `Bitmap` a shift-align copy rather than
229/// a byte→bit pack.
230/// Row 0 is the **bottom** of the image (DjVu convention).
231#[derive(Clone)]
232struct Jbm {
233    width: i32,
234    height: i32,
235    data: Vec<u8>,
236}
237
238impl Jbm {
239    #[inline(always)]
240    fn row_stride_bytes(width: i32) -> usize {
241        (width.max(0) as usize).div_ceil(8)
242    }
243
244    #[inline(always)]
245    fn stride(&self) -> usize {
246        Self::row_stride_bytes(self.width)
247    }
248
249    #[inline(always)]
250    fn storage_bytes(width: i32, height: i32) -> usize {
251        Self::row_stride_bytes(width).saturating_mul(height.max(0) as usize)
252    }
253
254    fn new(width: i32, height: i32) -> Self {
255        let len = Self::storage_bytes(width, height);
256        Jbm {
257            width,
258            height,
259            data: vec![0u8; len],
260        }
261    }
262
263    /// Return the pixel value at (row, col); out-of-bounds → 0.
264    #[inline(always)]
265    fn get(&self, row: i32, col: i32) -> u8 {
266        if row < 0 || row >= self.height || col < 0 || col >= self.width {
267            return 0;
268        }
269        let stride = self.stride();
270        let byte = self.data[row as usize * stride + (col as usize / 8)];
271        (byte >> (7 - (col as usize & 7))) & 1
272    }
273
274    /// Set pixel at (row, col) to black (1). Caller must ensure in-bounds.
275    #[inline(always)]
276    fn set_black(&mut self, row: usize, col: usize) {
277        let stride = self.stride();
278        self.data[row * stride + (col / 8)] |= 0x80u8 >> (col & 7);
279    }
280
281    /// Construct a `Jbm` using a reusable scratch buffer.
282    ///
283    /// The buffer is grown to at least `storage_bytes(width, height)` bytes
284    /// (never shrunk), and the used portion is zeroed.  The old buffer
285    /// contents are taken via `std::mem::take` so `pool` is left empty on
286    /// return; the caller regains the buffer by calling
287    /// [`Jbm::crop_and_recycle`] or [`Jbm::recycle_into`].
288    fn new_from_pool(width: i32, height: i32, pool: &mut Vec<u8>) -> Self {
289        let bytes = Self::storage_bytes(width, height);
290        if pool.len() < bytes {
291            pool.resize(bytes, 0u8);
292        }
293        // Zero the portion we will use (including any bytes reused from a previous symbol).
294        pool[..bytes].fill(0u8);
295        let mut data = core::mem::take(pool);
296        data.truncate(bytes);
297        Jbm {
298            width,
299            height,
300            data,
301        }
302    }
303
304    /// Crop to content and return the original backing buffer to the pool.
305    ///
306    /// This is the pool-aware alternative to `crop_to_content()`: it performs
307    /// the same crop but moves the (now-unused) full-size backing buffer back
308    /// into `pool` so it can be reused for the next symbol decode.
309    ///
310    /// Fast path: if all four border edges already have content (i.e. the bitmap
311    /// is already tight), skip the O(w×h) full scan and copy entirely — just
312    /// return `self` directly.  This handles the common case where the JB2
313    /// encoder already provided tight bounding box dimensions.
314    fn crop_and_recycle(self, pool: &mut Vec<u8>) -> Jbm {
315        if self.width > 0 && self.height > 0 {
316            let w = self.width as usize;
317            let h = self.height as usize;
318            let stride = self.stride();
319            let last_col = w - 1;
320            let data = &self.data;
321            // Any bit set in the row's stride bytes. Padding bits (if any) are
322            // guaranteed zero, so OR-ing the whole row is safe.
323            let top_has = data[..stride].iter().any(|&b| b != 0);
324            let bot_has = data[(h - 1) * stride..h * stride].iter().any(|&b| b != 0);
325            let left_has = (0..h).any(|r| (data[r * stride] & 0x80) != 0);
326            let right_has =
327                (0..h).any(|r| (data[r * stride + last_col / 8] & (0x80u8 >> (last_col & 7))) != 0);
328            if top_has && bot_has && left_has && right_has {
329                // Already tight — return self directly without copying.
330                // Pre-allocate the pool with the same capacity so the next
331                // new_from_pool call can reuse it without a realloc.
332                *pool = Vec::with_capacity(self.data.len());
333                return self;
334            }
335        }
336        let cropped = self.crop_to_content();
337        // Move our data buffer back to the pool (it may be larger than `cropped.data`)
338        *pool = self.data;
339        cropped
340    }
341
342    /// Move the backing buffer back into `pool` without cropping.
343    ///
344    /// Used for symbols that are blitted but not stored in the dict.
345    fn recycle_into(self, pool: &mut Vec<u8>) {
346        *pool = self.data;
347    }
348
349    /// Return a new Jbm with surrounding empty rows/columns removed.
350    fn crop_to_content(&self) -> Jbm {
351        if self.width <= 0 || self.height <= 0 {
352            return Jbm::new(0, 0);
353        }
354        let stride = self.stride();
355        let mut min_row = self.height;
356        let mut max_row: i32 = -1;
357        let mut min_col = self.width;
358        let mut max_col: i32 = -1;
359
360        for row in 0..self.height {
361            let row_bytes = &self.data[row as usize * stride..(row as usize + 1) * stride];
362            // Find first/last nonzero byte in the row, then refine to column index.
363            let mut byte_min: Option<usize> = None;
364            let mut byte_max: Option<usize> = None;
365            for (i, &b) in row_bytes.iter().enumerate() {
366                if b != 0 {
367                    if byte_min.is_none() {
368                        byte_min = Some(i);
369                    }
370                    byte_max = Some(i);
371                }
372            }
373            if let (Some(bmin), Some(bmax)) = (byte_min, byte_max) {
374                let col_lo = bmin * 8 + row_bytes[bmin].leading_zeros() as usize;
375                // leading zeros in a reversed sense: for MSB-first, the first set
376                // bit position within the byte is `leading_zeros`.
377                let col_hi = bmax * 8 + (7 - row_bytes[bmax].trailing_zeros() as usize);
378                let col_hi = col_hi.min(self.width as usize - 1) as i32;
379                let col_lo = col_lo as i32;
380                min_row = min_row.min(row);
381                max_row = max_row.max(row);
382                min_col = min_col.min(col_lo);
383                max_col = max_col.max(col_hi);
384            }
385        }
386
387        if max_row < 0 {
388            return Jbm::new(0, 0);
389        }
390
391        let nw = max_col - min_col + 1;
392        let nh = max_row - min_row + 1;
393        let mut out = Jbm::new(nw, nh);
394
395        for row in min_row..=max_row {
396            for col in min_col..=max_col {
397                let src_byte = self.data[row as usize * stride + (col as usize / 8)];
398                if (src_byte >> (7 - (col as usize & 7))) & 1 != 0 {
399                    let out_row = (row - min_row) as usize;
400                    let out_col = (col - min_col) as usize;
401                    out.set_black(out_row, out_col);
402                }
403            }
404        }
405        out
406    }
407}
408
409// ────────────────────────────────────────────────────────────────────────────
410// Direct bitmap decode: 10-bit context
411// ────────────────────────────────────────────────────────────────────────────
412
413/// Decode a bitmap using the direct (10-pixel context) method.
414///
415/// Decodes top-to-bottom using an incremental rolling window that avoids
416/// recomputing all 10 context bits from scratch each pixel.
417const MAX_SYMBOL_PIXELS: usize = 16 * 1024 * 1024; // 16 MP per symbol — allows large connected components while bounding DoS input
418const MAX_EXHAUSTED_SYMBOL_PIXELS: usize = 4096; // synthetic post-EOF ZP fill — prevents fuzz-time DoS
419const MAX_EXHAUSTED_TOTAL_SYMBOL_PIXELS: usize = 4 * 1024 * 1024; // cumulative post-EOF decode work
420// 256 MP cumulative decoded-symbol work. Dense JB2 pages can contain many
421// direct or refinement records whose individual symbols are valid and whose
422// blit work is bounded separately below; 64 MP was too low for the
423// `pathogenic_bacteria_1896.djvu` corpus (#258).
424pub(crate) const MAX_TOTAL_SYMBOL_PIXELS: usize = 256 * 1024 * 1024;
425const MAX_TOTAL_BLIT_PIXELS: usize = 256 * 1024 * 1024; // 256 MP total blit work — prevents type-7 DoS
426const MAX_RECORDS: usize = 65_536; // 64 K records per stream — prevents DoS via record-loop spin on exhausted ZP input
427const MAX_COMMENT_BYTES: usize = 4096; // 4 KiB per comment record — prevents DoS via huge comment length
428
429/// Check that decoding a `w × h` symbol won't exceed per-symbol or stream-total pixel budgets.
430#[inline(always)]
431fn check_pixel_budget(w: i32, h: i32, total: &mut usize) -> Result<(), Jb2Error> {
432    let pixels = (w.max(0) as usize).saturating_mul(h.max(0) as usize);
433    if pixels > MAX_SYMBOL_PIXELS {
434        return Err(Jb2Error::ImageTooLarge);
435    }
436    *total = total.saturating_add(pixels);
437    if *total > MAX_TOTAL_SYMBOL_PIXELS {
438        return Err(Jb2Error::ImageTooLarge);
439    }
440    Ok(())
441}
442
443#[inline(always)]
444fn check_symbol_decode_budget(
445    zp: &ZpDecoder<'_>,
446    w: i32,
447    h: i32,
448    total: &mut usize,
449) -> Result<(), Jb2Error> {
450    let pixels = (w.max(0) as usize).saturating_mul(h.max(0) as usize);
451    check_pixel_budget(w, h, total)?;
452    if zp.is_exhausted()
453        && (pixels > MAX_EXHAUSTED_SYMBOL_PIXELS || *total > MAX_EXHAUSTED_TOTAL_SYMBOL_PIXELS)
454    {
455        return Err(Jb2Error::Truncated);
456    }
457    Ok(())
458}
459
460/// Check that blitting a symbol won't exceed the total blit-work budget.
461///
462/// Prevents DoS via repeated blitting of a large dict symbol (type 7 / matched copy)
463/// which has no decode cost but O(w×h) blit cost per record.
464#[inline(always)]
465fn check_blit_budget(sym: &Jbm, total: &mut usize) -> Result<(), Jb2Error> {
466    let pixels = (sym.width.max(0) as usize).saturating_mul(sym.height.max(0) as usize);
467    *total = total.saturating_add(pixels);
468    if *total > MAX_TOTAL_BLIT_PIXELS {
469        return Err(Jb2Error::ImageTooLarge);
470    }
471    Ok(())
472}
473/// Decode one row of a direct-mode JB2 bitmap with inline ZP arithmetic.
474///
475/// Extracts the five hot ZP fields to true stack-locals so LLVM keeps them
476/// in registers throughout the row without spilling through the struct pointer.
477#[inline(never)]
478fn decode_direct_row(
479    zp: &mut ZpDecoder<'_>,
480    ctx: &mut [u8; 1024],
481    row_slice: &mut [u8],
482    rp1: &[u8],
483    rp2: &[u8],
484) {
485    use djvu_zp::tables::{LPS_NEXT, MPS_NEXT, PROB, THRESHOLD};
486
487    let mut a: u32 = zp.a;
488    let mut c: u32 = zp.c;
489    let mut fence: u32 = zp.fence;
490    let mut bit_buf = zp.bit_buf;
491    let mut bit_count = zp.bit_count;
492    let data = zp.data;
493    let mut pos = zp.pos;
494
495    macro_rules! read_byte {
496        () => {{
497            let b = if pos < data.len() { data[pos] } else { 0xff };
498            pos = pos.wrapping_add(1);
499            b as u32
500        }};
501    }
502    macro_rules! refill {
503        () => {
504            while bit_count <= 24 {
505                bit_buf = (bit_buf << 8) | read_byte!();
506                bit_count += 8;
507            }
508        };
509    }
510    macro_rules! renorm {
511        () => {{
512            let shift = (a as u16).leading_ones();
513            bit_count -= shift as i32;
514            a = (a << shift) & 0xffff;
515            let mask = (1u32 << (shift & 31)).wrapping_sub(1);
516            c = ((c << shift) | (bit_buf >> (bit_count as u32 & 31)) & mask) & 0xffff;
517            if bit_count < 16 {
518                refill!();
519            }
520            fence = c.min(0x7fff);
521        }};
522    }
523
524    let pix = |row: &[u8], col: usize| -> u32 { row.get(col).copied().unwrap_or(0) as u32 };
525    let w = row_slice.len();
526    let mut r2 = pix(rp2, 0) << 1 | pix(rp2, 1);
527    let mut r1 = pix(rp1, 0) << 2 | pix(rp1, 1) << 1 | pix(rp1, 2);
528    let mut r0: u32 = 0;
529
530    let (rp2_off, rp1_off) = if w >= 3 && rp2.len() >= w && rp1.len() >= w {
531        (&rp2[2..w], &rp1[3..w])
532    } else {
533        (&rp2[..0], &rp1[..0])
534    };
535    let mid_end = rp2_off.len().min(rp1_off.len());
536
537    macro_rules! decode_step {
538        ($out:expr, $n2:expr, $n1:expr) => {{
539            let idx = (((r2 << 7) | (r1 << 2) | r0) & 1023) as usize;
540            let state = ctx[idx] as usize;
541            let mps_bit = state & 1;
542            let z = a + PROB[state] as u32;
543            let bit = if z <= fence {
544                a = z;
545                mps_bit != 0
546            } else {
547                let boundary = 0x6000u32 + ((a + z) >> 2);
548                let z_clamped = z.min(boundary);
549                if z_clamped > c {
550                    let complement = 0x10000u32 - z_clamped;
551                    a = (a + complement) & 0xffff;
552                    c = (c + complement) & 0xffff;
553                    ctx[idx] = LPS_NEXT[state];
554                    renorm!();
555                    (1 - mps_bit) != 0
556                } else {
557                    if a >= THRESHOLD[state] as u32 {
558                        ctx[idx] = MPS_NEXT[state];
559                    }
560                    bit_count -= 1;
561                    a = (z_clamped << 1) & 0xffff;
562                    c = ((c << 1) | (bit_buf >> (bit_count as u32 & 31)) & 1) & 0xffff;
563                    if bit_count < 16 {
564                        refill!();
565                    }
566                    fence = c.min(0x7fff);
567                    mps_bit != 0
568                }
569            };
570            *$out = bit as u8;
571            r2 = ((r2 << 1) & 0b111) | ($n2 as u32);
572            r1 = ((r1 << 1) & 0b11111) | ($n1 as u32);
573            r0 = ((r0 << 1) & 0b11) | bit as u32;
574        }};
575    }
576
577    let (fast_slice, slow_slice) = row_slice.split_at_mut(mid_end);
578    for (out, (n2, n1)) in fast_slice.iter_mut().zip(rp2_off.iter().zip(rp1_off)) {
579        decode_step!(out, *n2, *n1);
580    }
581    for (i, out) in slow_slice.iter_mut().enumerate() {
582        let col = i + mid_end;
583        decode_step!(out, pix(rp2, col + 2), pix(rp1, col + 3));
584    }
585
586    zp.a = a;
587    zp.c = c;
588    zp.fence = fence;
589    zp.bit_buf = bit_buf;
590    zp.bit_count = bit_count;
591    zp.pos = pos;
592}
593
594/// Decode one row of a refinement-mode JB2 bitmap with inline ZP arithmetic.
595///
596/// Same local-variable register-allocation trick as `decode_direct_row`,
597/// but uses the 11-bit refinement context (ctx: [u8; 2048]).
598/// Rolling-window initial values (`init_c_r1`, `init_m_r1`, `init_m_r0`) are
599/// pre-computed by the caller at the start of each outer (row) iteration.
600#[allow(clippy::too_many_arguments)]
601#[inline(never)]
602fn decode_ref_row(
603    zp: &mut ZpDecoder<'_>,
604    ctx: &mut [u8; 2048],
605    ctx_p: &mut [u16; 2048],
606    cbm_row_mut: &mut [u8],
607    cbm_r1: &[u8],
608    mbm_r2: &[u8],
609    mbm_r1: &[u8],
610    mbm_r0: &[u8],
611    col_shift: i32,
612    init_c_r1: u32,
613    init_m_r1: u32,
614    init_m_r0: u32,
615) {
616    use djvu_zp::tables::{LPS_NEXT, MPS_NEXT, PROB, THRESHOLD};
617
618    let mut a: u32 = zp.a;
619    let mut c: u32 = zp.c;
620    let mut fence: u32 = zp.fence;
621    let mut bit_buf = zp.bit_buf;
622    let mut bit_count = zp.bit_count;
623    let data = zp.data;
624    let mut pos = zp.pos;
625
626    macro_rules! read_byte {
627        () => {{
628            let b = if pos < data.len() { data[pos] } else { 0xff };
629            pos = pos.wrapping_add(1);
630            b as u32
631        }};
632    }
633    macro_rules! refill {
634        () => {
635            while bit_count <= 24 {
636                bit_buf = (bit_buf << 8) | read_byte!();
637                bit_count += 8;
638            }
639        };
640    }
641    macro_rules! renorm {
642        () => {{
643            let shift = (a as u16).leading_ones();
644            bit_count -= shift as i32;
645            a = (a << shift) & 0xffff;
646            let mask = (1u32 << (shift & 31)).wrapping_sub(1);
647            c = ((c << shift) | (bit_buf >> (bit_count as u32 & 31)) & mask) & 0xffff;
648            if bit_count < 16 {
649                refill!();
650            }
651            fence = c.min(0x7fff);
652        }};
653    }
654
655    let pix_row = |row_slice: &[u8], col: i32| -> u32 {
656        if col < 0 {
657            return 0;
658        }
659        row_slice.get(col as usize).copied().unwrap_or(0) as u32
660    };
661
662    // c_r0 = previous decoded pixel in this row (starts 0; advances with `bit`).
663    let mut c_r0: u32 = 0;
664    let mut c_r1 = init_c_r1;
665    let mut m_r1 = init_m_r1;
666    let mut m_r0 = init_m_r0;
667
668    for col in 0..cbm_row_mut.len() as i32 {
669        let m_r2 = pix_row(mbm_r2, col + col_shift);
670        // idx ≤ 2047: c_r1<8, c_r0<2, m_r2<2, m_r1<8, m_r0<8
671        let idx = ((c_r1 << 8) | (c_r0 << 7) | (m_r2 << 6) | (m_r1 << 3) | m_r0) & 2047;
672
673        let state = ctx[idx as usize] as usize;
674        let prob = ctx_p[idx as usize] as u32; // parallel load: precomputed PROB[state]
675        let mps_bit = state & 1;
676        let z = a + prob;
677
678        let bit = if z <= fence {
679            a = z;
680            mps_bit != 0
681        } else {
682            let boundary = 0x6000u32 + ((a + z) >> 2);
683            let z_clamped = z.min(boundary);
684            if z_clamped > c {
685                let complement = 0x10000u32 - z_clamped;
686                a = (a + complement) & 0xffff;
687                c = (c + complement) & 0xffff;
688                let next = LPS_NEXT[state];
689                ctx[idx as usize] = next;
690                ctx_p[idx as usize] = PROB[next as usize];
691                renorm!();
692                (1 - mps_bit) != 0
693            } else {
694                if a >= THRESHOLD[state] as u32 {
695                    let next = MPS_NEXT[state];
696                    ctx[idx as usize] = next;
697                    ctx_p[idx as usize] = PROB[next as usize];
698                }
699                bit_count -= 1;
700                a = (z_clamped << 1) & 0xffff;
701                c = ((c << 1) | (bit_buf >> (bit_count as u32 & 31)) & 1) & 0xffff;
702                if bit_count < 16 {
703                    refill!();
704                }
705                fence = c.min(0x7fff);
706                mps_bit != 0
707            }
708        };
709
710        if bit {
711            cbm_row_mut[col as usize] = 1;
712        }
713
714        c_r1 = ((c_r1 << 1) & 0b111) | pix_row(cbm_r1, col + 2);
715        c_r0 = bit as u32;
716        m_r1 = ((m_r1 << 1) & 0b111) | pix_row(mbm_r1, col + col_shift + 2);
717        m_r0 = ((m_r0 << 1) & 0b111) | pix_row(mbm_r0, col + col_shift + 2);
718    }
719
720    zp.a = a;
721    zp.c = c;
722    zp.fence = fence;
723    zp.bit_buf = bit_buf;
724    zp.bit_count = bit_count;
725    zp.pos = pos;
726}
727
728/// Pack one decoded row (1 byte per pixel, 0 or 1) into packed Jbm storage
729/// (1 bit per pixel, MSB-first within byte).
730#[inline]
731fn pack_row_into(src: &[u8], width: usize, dst: &mut [u8]) {
732    let full_bytes = width / 8;
733    let rem = width % 8;
734    for i in 0..full_bytes {
735        let s: &[u8; 8] = src[i * 8..(i + 1) * 8].try_into().unwrap();
736        dst[i] = pack_byte(s);
737    }
738    if rem > 0 {
739        let base = full_bytes * 8;
740        let mut byte_val = 0u8;
741        for j in 0..rem {
742            if src[base + j] != 0 {
743                byte_val |= 0x80u8 >> j;
744            }
745        }
746        dst[full_bytes] = byte_val;
747    }
748}
749
750/// Unpack one Jbm row (packed, MSB-first) into a 1-byte-per-pixel scratch
751/// buffer. Caller ensures `dst.len() >= width`.
752#[inline]
753fn unpack_row_into(src: &[u8], width: usize, dst: &mut [u8]) {
754    let full_bytes = width / 8;
755    let rem = width % 8;
756    for i in 0..full_bytes {
757        let b = src[i];
758        let out = &mut dst[i * 8..(i + 1) * 8];
759        out[0] = (b >> 7) & 1;
760        out[1] = (b >> 6) & 1;
761        out[2] = (b >> 5) & 1;
762        out[3] = (b >> 4) & 1;
763        out[4] = (b >> 3) & 1;
764        out[5] = (b >> 2) & 1;
765        out[6] = (b >> 1) & 1;
766        out[7] = b & 1;
767    }
768    if rem > 0 {
769        let b = src[full_bytes];
770        let base = full_bytes * 8;
771        for j in 0..rem {
772            dst[base + j] = (b >> (7 - j)) & 1;
773        }
774    }
775}
776
777fn decode_bitmap_direct(
778    zp: &mut ZpDecoder<'_>,
779    ctx: &mut [u8; 1024],
780    width: i32,
781    height: i32,
782    pool: &mut Vec<u8>,
783) -> Result<Jbm, Jb2Error> {
784    let pixels = (width.max(0) as usize).saturating_mul(height.max(0) as usize);
785    if pixels > MAX_SYMBOL_PIXELS {
786        return Err(Jb2Error::ImageTooLarge);
787    }
788    if width <= 0 || height <= 0 {
789        return Ok(Jbm::new_from_pool(width, height, pool));
790    }
791    let mut bm = Jbm::new_from_pool(width, height, pool);
792    let w = width as usize;
793    let h = height as usize;
794    let stride = bm.stride();
795    debug_assert_eq!(bm.data.len(), stride * h);
796
797    // Scratch rows: 1 byte per pixel. Rotated each iteration so the decoder
798    // can read the two previously-decoded rows without unpacking from storage.
799    let mut s_curr = vec![0u8; w];
800    let mut s_prev1 = vec![0u8; w];
801    let mut s_prev2 = vec![0u8; w];
802
803    for row in (0..h).rev() {
804        s_curr.iter_mut().for_each(|b| *b = 0);
805        decode_direct_row(zp, ctx, &mut s_curr, &s_prev1, &s_prev2);
806        pack_row_into(&s_curr, w, &mut bm.data[row * stride..(row + 1) * stride]);
807        // Rotate: prev2 ← prev1, prev1 ← curr, curr ← (old prev2, re-used).
808        core::mem::swap(&mut s_prev2, &mut s_prev1);
809        core::mem::swap(&mut s_prev1, &mut s_curr);
810    }
811    Ok(bm)
812}
813
814// ────────────────────────────────────────────────────────────────────────────
815// Refinement bitmap decode: 11-bit context
816// ────────────────────────────────────────────────────────────────────────────
817
818/// Decode a bitmap using the refinement (11-pixel context) method.
819///
820/// The new (child) bitmap `cbm` is decoded relative to a reference (matched)
821/// bitmap `mbm`. Center alignment is used per the DjVu spec.
822fn decode_bitmap_ref(
823    zp: &mut ZpDecoder<'_>,
824    ctx: &mut [u8; 2048],
825    ctx_p: &mut [u16; 2048],
826    width: i32,
827    height: i32,
828    mbm: &Jbm,
829    pool: &mut Vec<u8>,
830) -> Result<Jbm, Jb2Error> {
831    let pixels = (width.max(0) as usize).saturating_mul(height.max(0) as usize);
832    if pixels > MAX_SYMBOL_PIXELS {
833        return Err(Jb2Error::ImageTooLarge);
834    }
835    if width <= 0 || height <= 0 {
836        return Ok(Jbm::new_from_pool(width, height, pool));
837    }
838    let mut cbm = Jbm::new_from_pool(width, height, pool);
839
840    // Center alignment: anchor the reference bitmap at the center of the child.
841    let crow = (height - 1) >> 1;
842    let ccol = (width - 1) >> 1;
843    let mrow = (mbm.height - 1) >> 1;
844    let mcol = (mbm.width - 1) >> 1;
845    let row_shift = mrow - crow;
846    let col_shift = mcol - ccol;
847
848    // Access a pre-sliced row at a possibly-negative column index; returns 0 for OOB.
849    let pix_row = |row_slice: &[u8], col: i32| -> u32 {
850        if col < 0 {
851            return 0;
852        }
853        row_slice.get(col as usize).copied().unwrap_or(0) as u32
854    };
855
856    let cw = width as usize;
857    let cstride = cbm.stride();
858    let mw = mbm.width.max(0) as usize;
859    let mstride = mbm.stride();
860
861    // Rolling scratch (1 byte/pixel) for the three mbm reference rows.
862    // Each slot holds the unpacked content of rows `mr+1`, `mr`, `mr-1` relative
863    // to the current iteration's `mr = row + row_shift`. Empty slice when OOB.
864    let mut s_mbm_r2 = vec![0u8; mw];
865    let mut s_mbm_r1 = vec![0u8; mw];
866    let mut s_mbm_r0 = vec![0u8; mw];
867    let mut have_r2;
868    let mut have_r1;
869    let mut have_r0;
870
871    // Scratch for cbm: current row being decoded, and previously-decoded row.
872    let mut s_cbm_curr = vec![0u8; cw];
873    let mut s_cbm_prev1 = vec![0u8; cw];
874
875    let unpack_mbm_row = |r: i32, buf: &mut [u8]| -> bool {
876        if r < 0 || r >= mbm.height || mw == 0 {
877            return false;
878        }
879        let off = r as usize * mstride;
880        unpack_row_into(&mbm.data[off..off + mstride], mw, buf);
881        true
882    };
883
884    // Prime the rolling mbm scratch before the first iteration (row = height-1):
885    // mbm_r2 = mbm[mr+1], mbm_r1 = mbm[mr], mbm_r0 = mbm[mr-1], with mr = (height-1) + row_shift.
886    let first_mr = (height - 1) + row_shift;
887    have_r2 = unpack_mbm_row(first_mr + 1, &mut s_mbm_r2);
888    have_r1 = unpack_mbm_row(first_mr, &mut s_mbm_r1);
889    have_r0 = unpack_mbm_row(first_mr - 1, &mut s_mbm_r0);
890
891    for row in (0..height).rev() {
892        let mr = row + row_shift;
893
894        // Empty slice when the row is OOB (matches previous behaviour).
895        let mbm_r2: &[u8] = if have_r2 { &s_mbm_r2 } else { &[] };
896        let mbm_r1: &[u8] = if have_r1 { &s_mbm_r1 } else { &[] };
897        let mbm_r0: &[u8] = if have_r0 { &s_mbm_r0 } else { &[] };
898
899        let cbm_r1: &[u8] = if row + 1 < height { &s_cbm_prev1 } else { &[] };
900        s_cbm_curr.iter_mut().for_each(|b| *b = 0);
901
902        let init_c_r1 = pix_row(cbm_r1, 0) << 1 | pix_row(cbm_r1, 1);
903        let init_m_r1 = pix_row(mbm_r1, col_shift - 1) << 2
904            | pix_row(mbm_r1, col_shift) << 1
905            | pix_row(mbm_r1, col_shift + 1);
906        let init_m_r0 = pix_row(mbm_r0, col_shift - 1) << 2
907            | pix_row(mbm_r0, col_shift) << 1
908            | pix_row(mbm_r0, col_shift + 1);
909
910        decode_ref_row(
911            zp,
912            ctx,
913            ctx_p,
914            &mut s_cbm_curr,
915            cbm_r1,
916            mbm_r2,
917            mbm_r1,
918            mbm_r0,
919            col_shift,
920            init_c_r1,
921            init_m_r1,
922            init_m_r0,
923        );
924
925        // Pack current cbm row into storage.
926        pack_row_into(
927            &s_cbm_curr,
928            cw,
929            &mut cbm.data[row as usize * cstride..(row as usize + 1) * cstride],
930        );
931
932        // Rotate cbm scratch: prev1 ← curr, curr ← (old prev1, reused next iteration).
933        core::mem::swap(&mut s_cbm_prev1, &mut s_cbm_curr);
934
935        // Rotate mbm scratch: r2 ← r1, r1 ← r0, r0 ← freshly unpacked mr-2.
936        //   After this, new mr = mr-1, so:
937        //     new r2 = mbm[new mr + 1]   = mbm[mr]       = old r1
938        //     new r1 = mbm[new mr]       = mbm[mr-1]     = old r0
939        //     new r0 = mbm[new mr - 1]   = mbm[mr-2]     = needs unpack
940        core::mem::swap(&mut s_mbm_r2, &mut s_mbm_r1);
941        have_r2 = have_r1;
942        core::mem::swap(&mut s_mbm_r1, &mut s_mbm_r0);
943        have_r1 = have_r0;
944        have_r0 = unpack_mbm_row(mr - 2, &mut s_mbm_r0);
945    }
946    Ok(cbm)
947}
948
949// ────────────────────────────────────────────────────────────────────────────
950// Baseline: rolling median-of-3 for vertical symbol positioning
951// ────────────────────────────────────────────────────────────────────────────
952
953struct Baseline {
954    arr: [i32; 3],
955    index: i32,
956}
957
958impl Baseline {
959    fn new() -> Self {
960        Baseline {
961            arr: [0, 0, 0],
962            index: -1,
963        }
964    }
965
966    fn fill(&mut self, val: i32) {
967        self.arr = [val, val, val];
968    }
969
970    fn add(&mut self, val: i32) {
971        self.index += 1;
972        if self.index == 3 {
973            self.index = 0;
974        }
975        self.arr[self.index as usize] = val;
976    }
977
978    fn get_val(&self) -> i32 {
979        let (a, b, c) = (self.arr[0], self.arr[1], self.arr[2]);
980        if (a >= b && a <= c) || (a <= b && a >= c) {
981            a
982        } else if (b >= a && b <= c) || (b <= a && b >= c) {
983            b
984        } else {
985            c
986        }
987    }
988}
989
990// ────────────────────────────────────────────────────────────────────────────
991// Blit a symbol onto the page (OR compositing, bottom-left origin)
992// ────────────────────────────────────────────────────────────────────────────
993
994#[allow(clippy::too_many_arguments)]
995fn blit_indexed(
996    page: &mut [u8],
997    blit_map: &mut [i32],
998    page_w: i32,
999    page_h: i32,
1000    symbol: &Jbm,
1001    x: i32,
1002    y: i32,
1003    blit_idx: i32,
1004) {
1005    // Guard: negative/zero dimensions would wrap `width as usize` to a huge value
1006    // in the fast-path loop, causing an effectively infinite iteration count.
1007    if symbol.width <= 0 || symbol.height <= 0 {
1008        return;
1009    }
1010    if x >= 0 && y >= 0 && x + symbol.width <= page_w && y + symbol.height <= page_h {
1011        let pw = page_w as usize;
1012        let sw = symbol.width as usize;
1013        let sym_stride = symbol.stride();
1014        let full_bytes = sw / 8;
1015        let rem = sw & 7;
1016        for row in 0..symbol.height as usize {
1017            let src_row_off = row * sym_stride;
1018            let dst_off = (y as usize + row) * pw + x as usize;
1019            for byte_i in 0..full_bytes {
1020                let b = symbol.data[src_row_off + byte_i];
1021                if b == 0 {
1022                    continue;
1023                }
1024                let base_col = byte_i * 8;
1025                for j in 0..8 {
1026                    if (b >> (7 - j)) & 1 != 0 {
1027                        page[dst_off + base_col + j] = 1;
1028                        blit_map[dst_off + base_col + j] = blit_idx;
1029                    }
1030                }
1031            }
1032            if rem > 0 {
1033                let b = symbol.data[src_row_off + full_bytes];
1034                if b != 0 {
1035                    let base_col = full_bytes * 8;
1036                    for j in 0..rem {
1037                        if (b >> (7 - j)) & 1 != 0 {
1038                            page[dst_off + base_col + j] = 1;
1039                            blit_map[dst_off + base_col + j] = blit_idx;
1040                        }
1041                    }
1042                }
1043            }
1044        }
1045    } else {
1046        for row in 0..symbol.height {
1047            let py = y + row;
1048            if py < 0 || py >= page_h {
1049                continue;
1050            }
1051            for col in 0..symbol.width {
1052                if symbol.get(row, col) != 0 {
1053                    let px = x + col;
1054                    if px >= 0 && px < page_w {
1055                        let idx = (py * page_w + px) as usize;
1056                        page[idx] = 1;
1057                        blit_map[idx] = blit_idx;
1058                    }
1059                }
1060            }
1061        }
1062    }
1063}
1064
1065// ────────────────────────────────────────────────────────────────────────────
1066// Blit symbol directly into a packed Bitmap (no intermediate byte-per-pixel buffer)
1067// ────────────────────────────────────────────────────────────────────────────
1068
1069/// Blit a symbol into a packed Bitmap with JB2→bitmap coordinate flip.
1070///
1071/// JB2 uses y=0 at the bottom; `Bitmap` uses y=0 at the top.
1072/// Both source (`Jbm`) and destination (`Bitmap`) are 1-bit-per-pixel,
1073/// MSB-first within byte, byte-aligned rows. Fast path is a shift-align
1074/// byte OR; no bit packing needed.
1075fn blit_to_bitmap(bm: &mut Bitmap, sym: &Jbm, x: i32, y: i32) {
1076    if sym.width <= 0 || sym.height <= 0 {
1077        return;
1078    }
1079    let bw = bm.width as i32;
1080    let bh = bm.height as i32;
1081    let bm_stride = bm.row_stride();
1082    let sw = sym.width;
1083    let sh = sym.height;
1084    let sym_stride = sym.stride();
1085
1086    // Fast path: symbol completely within bitmap bounds.
1087    if x >= 0
1088        && y >= 0
1089        && x.checked_add(sw).is_some_and(|v| v <= bw)
1090        && y.checked_add(sh).is_some_and(|v| v <= bh)
1091    {
1092        let x_off = x as usize;
1093        let byte_off = x_off / 8;
1094        let bit_off = x_off & 7;
1095        let sw_u = sw as usize;
1096        let full = sw_u / 8;
1097        let rem = sw_u & 7;
1098        let bm_y_base = (bm.height as usize) - 1 - y as usize;
1099
1100        if bit_off == 0 {
1101            for sym_row in 0..sh as usize {
1102                let bm_y = bm_y_base - sym_row;
1103                let src = &sym.data[sym_row * sym_stride..sym_row * sym_stride + sym_stride];
1104                let dst = &mut bm.data[bm_y * bm_stride..];
1105                for i in 0..full {
1106                    dst[byte_off + i] |= src[i];
1107                }
1108                if rem > 0 {
1109                    // Last byte of packed source: its high `rem` bits are valid
1110                    // pixels; low `8 - rem` bits are padding (guaranteed 0 by
1111                    // construction), so OR-ing the whole byte is correct.
1112                    dst[byte_off + full] |= src[full];
1113                }
1114            }
1115        } else {
1116            let rshift = bit_off as u32;
1117            let lshift = 8 - bit_off as u32;
1118            for sym_row in 0..sh as usize {
1119                let bm_y = bm_y_base - sym_row;
1120                let src = &sym.data[sym_row * sym_stride..sym_row * sym_stride + sym_stride];
1121                let row_off = bm_y * bm_stride;
1122                for (i, &s) in src.iter().enumerate().take(full) {
1123                    bm.data[row_off + byte_off + i] |= s >> rshift;
1124                    bm.data[row_off + byte_off + i + 1] |= s << lshift;
1125                }
1126                if rem > 0 {
1127                    let s = src[full];
1128                    bm.data[row_off + byte_off + full] |= s >> rshift;
1129                    let overflow = row_off + byte_off + full + 1;
1130                    if overflow < bm.data.len() {
1131                        bm.data[overflow] |= s << lshift;
1132                    }
1133                }
1134            }
1135        }
1136    } else {
1137        // Slow path: clipped blit, per-pixel bounds checks.
1138        for sym_row in 0..sh {
1139            let bm_y = bh - 1 - y - sym_row;
1140            if bm_y < 0 || bm_y >= bh {
1141                continue;
1142            }
1143            let bm_y = bm_y as usize;
1144            let row_off = bm_y * bm_stride;
1145            let src_row_off = sym_row as usize * sym_stride;
1146            for col in 0..sw {
1147                let b = sym.data[src_row_off + (col as usize / 8)];
1148                if (b >> (7 - (col as usize & 7))) & 1 != 0 {
1149                    let px = x + col;
1150                    if px >= 0 && px < bw {
1151                        let px = px as usize;
1152                        bm.data[row_off + px / 8] |= 0x80u8 >> (px & 7);
1153                    }
1154                }
1155            }
1156        }
1157    }
1158}
1159
1160// ────────────────────────────────────────────────────────────────────────────
1161// Convert internal page buffer (row 0 = bottom) to Bitmap (row 0 = top)
1162// ────────────────────────────────────────────────────────────────────────────
1163
1164/// Pack a single byte: each of the 8 input bytes (0 or 1) into one output byte.
1165/// Bit 7 = src[0], bit 6 = src[1], …, bit 0 = src[7].
1166#[inline(always)]
1167fn pack_byte(s: &[u8; 8]) -> u8 {
1168    ((s[0] != 0) as u8) << 7
1169        | ((s[1] != 0) as u8) << 6
1170        | ((s[2] != 0) as u8) << 5
1171        | ((s[3] != 0) as u8) << 4
1172        | ((s[4] != 0) as u8) << 3
1173        | ((s[5] != 0) as u8) << 2
1174        | ((s[6] != 0) as u8) << 1
1175        | ((s[7] != 0) as u8)
1176}
1177
1178fn page_to_bitmap(page: &[u8], width: i32, height: i32) -> Bitmap {
1179    let w = width as usize;
1180    let h = height as usize;
1181    let mut bm = Bitmap::new(width as u32, height as u32);
1182    let stride = bm.row_stride();
1183    let full_bytes = w / 8;
1184    let remaining = w % 8;
1185
1186    for row in 0..h {
1187        let src_row = &page[row * w..(row + 1) * w];
1188        let dst_y = h - 1 - row; // flip: JB2 row 0=bottom → PBM row 0=top
1189        let dst_off = dst_y * stride;
1190
1191        // Process 8 source bytes → 1 packed byte.
1192        // The fixed-size array slice tells LLVM the chunk is exactly 8 bytes,
1193        // allowing it to vectorize the comparison+shift tree.
1194        for byte_idx in 0..full_bytes {
1195            let s: &[u8; 8] = src_row[byte_idx * 8..(byte_idx + 1) * 8]
1196                .try_into()
1197                .unwrap();
1198            bm.data[dst_off + byte_idx] = pack_byte(s);
1199        }
1200
1201        // Partial last byte (< 8 pixels).
1202        if remaining > 0 {
1203            let base = full_bytes * 8;
1204            let mut byte_val = 0u8;
1205            for bit_pos in 0..remaining {
1206                if src_row[base + bit_pos] != 0 {
1207                    byte_val |= 0x80u8 >> bit_pos;
1208                }
1209            }
1210            bm.data[dst_off + full_bytes] = byte_val;
1211        }
1212    }
1213    bm
1214}
1215
1216/// Flip blit_map vertically to match bitmap coordinate system (bottom→top).
1217fn flip_blit_map(blit_map: &mut [i32], width: usize, height: usize) {
1218    for row in 0..height / 2 {
1219        let mirror = height - 1 - row;
1220        let a = row * width;
1221        let b = mirror * width;
1222        for col in 0..width {
1223            blit_map.swap(a + col, b + col);
1224        }
1225    }
1226}
1227
1228// ────────────────────────────────────────────────────────────────────────────
1229// Symbol coordinate decoding
1230// ────────────────────────────────────────────────────────────────────────────
1231
1232/// ZP coder contexts used exclusively for symbol coordinate decoding.
1233struct CoordContexts {
1234    offset_type: u8,
1235    hoff: NumContext,
1236    voff: NumContext,
1237    shoff: NumContext,
1238    svoff: NumContext,
1239}
1240
1241impl CoordContexts {
1242    fn new() -> Self {
1243        Self {
1244            offset_type: 0,
1245            hoff: NumContext::new(),
1246            voff: NumContext::new(),
1247            shoff: NumContext::new(),
1248            svoff: NumContext::new(),
1249        }
1250    }
1251}
1252
1253/// Running layout state for symbol positioning within a JB2 image.
1254struct LayoutState {
1255    first_left: i32,
1256    first_bottom: i32,
1257    last_right: i32,
1258    baseline: Baseline,
1259}
1260
1261impl LayoutState {
1262    fn new(image_height: i32) -> Self {
1263        Self {
1264            first_left: -1,
1265            first_bottom: image_height - 1,
1266            last_right: 0,
1267            baseline: Baseline::new(),
1268        }
1269    }
1270}
1271
1272fn decode_symbol_coords(
1273    zp: &mut ZpDecoder<'_>,
1274    coord_ctx: &mut CoordContexts,
1275    layout: &mut LayoutState,
1276    sym_width: i32,
1277    sym_height: i32,
1278) -> (i32, i32) {
1279    let new_line = zp.decode_bit(&mut coord_ctx.offset_type);
1280
1281    let (x, y) = if new_line {
1282        let hoff = decode_num(zp, &mut coord_ctx.hoff, -262143, 262142);
1283        let voff = decode_num(zp, &mut coord_ctx.voff, -262143, 262142);
1284        let nx = layout.first_left + hoff;
1285        let ny = layout.first_bottom + voff - sym_height + 1;
1286        layout.first_left = nx;
1287        layout.first_bottom = ny;
1288        layout.baseline.fill(ny);
1289        (nx, ny)
1290    } else {
1291        let hoff = decode_num(zp, &mut coord_ctx.shoff, -262143, 262142);
1292        let voff = decode_num(zp, &mut coord_ctx.svoff, -262143, 262142);
1293        (layout.last_right + hoff, layout.baseline.get_val() + voff)
1294    };
1295
1296    layout.baseline.add(y);
1297    layout.last_right = x + sym_width - 1;
1298    (x, y)
1299}
1300
1301// ────────────────────────────────────────────────────────────────────────────
1302// Working symbol table: zero-copy view of shared dict + local symbols
1303// ────────────────────────────────────────────────────────────────────────────
1304
1305/// Two-part symbol table used during JB2 image/dict decode.
1306///
1307/// The `shared` slice refers directly to the cached shared dictionary's symbols
1308/// (no clone), while `local` holds symbols defined by the stream being decoded.
1309/// This avoids deep-copying the (potentially large) shared dictionary on every
1310/// `decode_mask()` call.
1311struct JbmDict<'a> {
1312    shared: &'a [Jbm],
1313    local: Vec<Jbm>,
1314}
1315
1316impl<'a> JbmDict<'a> {
1317    fn new(shared: &'a [Jbm]) -> Self {
1318        JbmDict {
1319            shared,
1320            local: Vec::new(),
1321        }
1322    }
1323    fn len(&self) -> usize {
1324        self.shared.len() + self.local.len()
1325    }
1326    fn is_empty(&self) -> bool {
1327        self.shared.is_empty() && self.local.is_empty()
1328    }
1329    fn push(&mut self, sym: Jbm) {
1330        self.local.push(sym);
1331    }
1332    fn into_symbols(self) -> Vec<Jbm> {
1333        // Used by decode_dictionary to return the complete symbol list.
1334        let mut out = self.shared.to_vec();
1335        out.extend(self.local);
1336        out
1337    }
1338}
1339
1340impl core::ops::Index<usize> for JbmDict<'_> {
1341    type Output = Jbm;
1342    #[inline(always)]
1343    fn index(&self, index: usize) -> &Jbm {
1344        let n = self.shared.len();
1345        if index < n {
1346            &self.shared[index]
1347        } else {
1348            &self.local[index - n]
1349        }
1350    }
1351}
1352
1353// ────────────────────────────────────────────────────────────────────────────
1354// Public API
1355// ────────────────────────────────────────────────────────────────────────────
1356
1357/// A shared JB2 symbol dictionary decoded from a Djbz chunk.
1358///
1359/// Pass this to [`decode`] when the Sjbz stream references an external dict
1360/// via a "required-dict-or-reset" (type 9) record.
1361pub struct Jb2Dict {
1362    symbols: Vec<Jbm>,
1363}
1364
1365/// Decode a JB2 image stream (Sjbz chunk data) into a [`Bitmap`].
1366///
1367/// `shared_dict` must be provided when the Sjbz stream begins with a
1368/// "required-dict-or-reset" record that references an external dictionary.
1369///
1370/// # Errors
1371///
1372/// Returns [`Jb2Error`] on malformed input, missing dictionary, or oversized image.
1373pub fn decode(data: &[u8], shared_dict: Option<&Jb2Dict>) -> Result<Bitmap, Jb2Error> {
1374    decode_image(data, shared_dict)
1375}
1376
1377/// Decode a JB2 image stream with per-pixel blit index tracking.
1378///
1379/// Returns the bitmap and a blit map (`Vec<i32>`) of the same pixel dimensions.
1380/// `blit_map[y * width + x]` holds the blit record index for each foreground
1381/// pixel, or `-1` for background. This is used by the FGbz palette to assign
1382/// per-glyph colors.
1383pub fn decode_indexed(
1384    data: &[u8],
1385    shared_dict: Option<&Jb2Dict>,
1386) -> Result<(Bitmap, Vec<i32>), Jb2Error> {
1387    decode_image_indexed(data, shared_dict)
1388}
1389
1390/// Decode a JB2 dictionary stream (Djbz chunk data) into a [`Jb2Dict`].
1391///
1392/// The returned dict can then be passed to [`decode`] for Sjbz streams that
1393/// reference it via an INCL or "required-dict-or-reset" record.
1394///
1395/// # Errors
1396///
1397/// Returns [`Jb2Error`] on malformed input.
1398pub fn decode_dict(data: &[u8], inherited: Option<&Jb2Dict>) -> Result<Jb2Dict, Jb2Error> {
1399    decode_dictionary(data, inherited)
1400}
1401
1402// ────────────────────────────────────────────────────────────────────────────
1403// Core image decode
1404// ────────────────────────────────────────────────────────────────────────────
1405
1406fn decode_image(data: &[u8], shared_dict: Option<&Jb2Dict>) -> Result<Bitmap, Jb2Error> {
1407    let mut pool = Vec::new();
1408    decode_image_with_pool(data, shared_dict, &mut pool)
1409}
1410
1411/// Decode a JB2 image stream, reusing `pool` as a scratch buffer for symbol bitmaps.
1412///
1413/// `pool` is resized up (never shrunk) across symbol decodes, eliminating
1414/// per-symbol heap allocations. Pass `&mut Vec::new()` to use a fresh pool,
1415/// or reuse a pool across multiple decode calls for additional savings.
1416fn decode_image_with_pool(
1417    data: &[u8],
1418    shared_dict: Option<&Jb2Dict>,
1419    pool: &mut Vec<u8>,
1420) -> Result<Bitmap, Jb2Error> {
1421    let mut zp = ZpDecoder::new(data).map_err(|_| Jb2Error::ZpInitFailed)?;
1422
1423    // Contexts for variable-length integer decoding
1424    let mut record_type_ctx = NumContext::new();
1425    let mut image_size_ctx = NumContext::new();
1426    let mut symbol_width_ctx = NumContext::new();
1427    let mut symbol_height_ctx = NumContext::new();
1428    let mut inherit_dict_size_ctx = NumContext::new();
1429    let mut coord_ctx = CoordContexts::new();
1430    let mut symbol_index_ctx = NumContext::new();
1431    let mut symbol_width_diff_ctx = NumContext::new();
1432    let mut symbol_height_diff_ctx = NumContext::new();
1433    let mut horiz_abs_loc_ctx = NumContext::new();
1434    let mut vert_abs_loc_ctx = NumContext::new();
1435    let mut comment_length_ctx = NumContext::new();
1436    let mut comment_octet_ctx = NumContext::new();
1437
1438    let mut direct_bitmap_ctx = [0u8; 1024];
1439    let mut refinement_bitmap_ctx = [0u8; 2048];
1440    let mut refinement_bitmap_ctx_p = [0x8000u16; 2048];
1441    let mut total_sym_pixels = 0usize;
1442    let mut total_blit_pixels = 0usize;
1443
1444    // Preamble: optional "required-dict-or-reset" (type 9) followed by
1445    // "start-of-image" (type 0).
1446    let mut rtype = decode_num(&mut zp, &mut record_type_ctx, 0, 11);
1447    let mut initial_dict_length: usize = 0;
1448    if rtype == 9 {
1449        initial_dict_length = decode_num(&mut zp, &mut inherit_dict_size_ctx, 0, 262142) as usize;
1450        rtype = decode_num(&mut zp, &mut record_type_ctx, 0, 11);
1451    }
1452    // `rtype` is now the start-of-image record (0); ignore its value.
1453    let _ = rtype;
1454
1455    // Image dimensions
1456    let image_width = {
1457        let w = decode_num(&mut zp, &mut image_size_ctx, 0, 262142);
1458        if w == 0 { 200 } else { w }
1459    };
1460    let image_height = {
1461        let h = decode_num(&mut zp, &mut image_size_ctx, 0, 262142);
1462        if h == 0 { 200 } else { h }
1463    };
1464
1465    // Reserved flag bit — must be 0
1466    let mut flag_ctx: u8 = 0;
1467    if zp.decode_bit(&mut flag_ctx) {
1468        return Err(Jb2Error::BadHeaderFlag);
1469    }
1470
1471    // Populate initial dictionary from shared dict — zero-copy: borrow the
1472    // cached dict's symbol slice directly rather than deep-cloning it.
1473    let initial_symbols: &[Jbm] = if initial_dict_length > 0 {
1474        match shared_dict {
1475            Some(sd) => {
1476                if initial_dict_length > sd.symbols.len() {
1477                    return Err(Jb2Error::InheritedDictTooLarge);
1478                }
1479                &sd.symbols[..initial_dict_length]
1480            }
1481            None => return Err(Jb2Error::MissingSharedDict),
1482        }
1483    } else {
1484        &[]
1485    };
1486    let mut dict = JbmDict::new(initial_symbols);
1487
1488    // Safety cap: ~64M pixels (same guard, but now the backing store is 8× smaller).
1489    const MAX_PIXELS: usize = 64 * 1024 * 1024;
1490    let page_size = (image_width as usize).saturating_mul(image_height as usize);
1491    if page_size > MAX_PIXELS {
1492        return Err(Jb2Error::ImageTooLarge);
1493    }
1494    // Use a packed 1-bit-per-pixel bitmap as the page buffer instead of a
1495    // byte-per-pixel Vec. This is 8× smaller (~1.8 MB vs ~14.5 MB for a 600 dpi
1496    // page), fitting in L2 cache and dramatically reducing cache pressure during blits.
1497    let mut page_bm = Bitmap::new(image_width as u32, image_height as u32);
1498
1499    let mut layout = LayoutState::new(image_height);
1500
1501    // Main decode loop — capped to prevent infinite spin when ZP input is exhausted
1502    let mut record_count = 0usize;
1503    loop {
1504        if record_count >= MAX_RECORDS {
1505            return Err(Jb2Error::TooManyRecords);
1506        }
1507        record_count += 1;
1508        let rtype = decode_num(&mut zp, &mut record_type_ctx, 0, 11);
1509
1510        match rtype {
1511            // 1 — new symbol, direct decode → add to dict AND blit
1512            1 => {
1513                let w = decode_num(&mut zp, &mut symbol_width_ctx, 0, 262142);
1514                let h = decode_num(&mut zp, &mut symbol_height_ctx, 0, 262142);
1515                check_symbol_decode_budget(&zp, w, h, &mut total_sym_pixels)?;
1516                let bm = decode_bitmap_direct(&mut zp, &mut direct_bitmap_ctx, w, h, pool)?;
1517                let (x, y) =
1518                    decode_symbol_coords(&mut zp, &mut coord_ctx, &mut layout, bm.width, bm.height);
1519                check_blit_budget(&bm, &mut total_blit_pixels)?;
1520                blit_to_bitmap(&mut page_bm, &bm, x, y);
1521                dict.push(bm.crop_and_recycle(pool));
1522            }
1523
1524            // 2 — new symbol, direct decode → add to dict only
1525            2 => {
1526                let w = decode_num(&mut zp, &mut symbol_width_ctx, 0, 262142);
1527                let h = decode_num(&mut zp, &mut symbol_height_ctx, 0, 262142);
1528                check_symbol_decode_budget(&zp, w, h, &mut total_sym_pixels)?;
1529                let bm = decode_bitmap_direct(&mut zp, &mut direct_bitmap_ctx, w, h, pool)?;
1530                dict.push(bm.crop_and_recycle(pool));
1531            }
1532
1533            // 3 — new symbol, direct decode → blit only (not stored in dict)
1534            3 => {
1535                let w = decode_num(&mut zp, &mut symbol_width_ctx, 0, 262142);
1536                let h = decode_num(&mut zp, &mut symbol_height_ctx, 0, 262142);
1537                check_symbol_decode_budget(&zp, w, h, &mut total_sym_pixels)?;
1538                let bm = decode_bitmap_direct(&mut zp, &mut direct_bitmap_ctx, w, h, pool)?;
1539                let (x, y) =
1540                    decode_symbol_coords(&mut zp, &mut coord_ctx, &mut layout, bm.width, bm.height);
1541                check_blit_budget(&bm, &mut total_blit_pixels)?;
1542                blit_to_bitmap(&mut page_bm, &bm, x, y);
1543                bm.recycle_into(pool);
1544            }
1545
1546            // 4 — matched refinement → add to dict AND blit
1547            4 => {
1548                if dict.is_empty() {
1549                    return Err(Jb2Error::EmptyDictReference);
1550                }
1551                let index =
1552                    decode_num(&mut zp, &mut symbol_index_ctx, 0, dict.len() as i32 - 1) as usize;
1553                if index >= dict.len() {
1554                    return Err(Jb2Error::InvalidSymbolIndex);
1555                }
1556                let wdiff = decode_num(&mut zp, &mut symbol_width_diff_ctx, -262143, 262142);
1557                let hdiff = decode_num(&mut zp, &mut symbol_height_diff_ctx, -262143, 262142);
1558                let cbm_w = dict[index].width + wdiff;
1559                let cbm_h = dict[index].height + hdiff;
1560                check_symbol_decode_budget(&zp, cbm_w, cbm_h, &mut total_sym_pixels)?;
1561                let cbm = decode_bitmap_ref(
1562                    &mut zp,
1563                    &mut refinement_bitmap_ctx,
1564                    &mut refinement_bitmap_ctx_p,
1565                    cbm_w,
1566                    cbm_h,
1567                    &dict[index],
1568                    pool,
1569                )?;
1570                let (x, y) = decode_symbol_coords(
1571                    &mut zp,
1572                    &mut coord_ctx,
1573                    &mut layout,
1574                    cbm.width,
1575                    cbm.height,
1576                );
1577                check_blit_budget(&cbm, &mut total_blit_pixels)?;
1578                blit_to_bitmap(&mut page_bm, &cbm, x, y);
1579                dict.push(cbm.crop_and_recycle(pool));
1580            }
1581
1582            // 5 — matched refinement → add to dict only
1583            5 => {
1584                if dict.is_empty() {
1585                    return Err(Jb2Error::EmptyDictReference);
1586                }
1587                let index =
1588                    decode_num(&mut zp, &mut symbol_index_ctx, 0, dict.len() as i32 - 1) as usize;
1589                if index >= dict.len() {
1590                    return Err(Jb2Error::InvalidSymbolIndex);
1591                }
1592                let wdiff = decode_num(&mut zp, &mut symbol_width_diff_ctx, -262143, 262142);
1593                let hdiff = decode_num(&mut zp, &mut symbol_height_diff_ctx, -262143, 262142);
1594                let cbm_w = dict[index].width + wdiff;
1595                let cbm_h = dict[index].height + hdiff;
1596                check_symbol_decode_budget(&zp, cbm_w, cbm_h, &mut total_sym_pixels)?;
1597                let cbm = decode_bitmap_ref(
1598                    &mut zp,
1599                    &mut refinement_bitmap_ctx,
1600                    &mut refinement_bitmap_ctx_p,
1601                    cbm_w,
1602                    cbm_h,
1603                    &dict[index],
1604                    pool,
1605                )?;
1606                dict.push(cbm.crop_and_recycle(pool));
1607            }
1608
1609            // 6 — matched refinement → blit only
1610            6 => {
1611                if dict.is_empty() {
1612                    return Err(Jb2Error::EmptyDictReference);
1613                }
1614                let index =
1615                    decode_num(&mut zp, &mut symbol_index_ctx, 0, dict.len() as i32 - 1) as usize;
1616                if index >= dict.len() {
1617                    return Err(Jb2Error::InvalidSymbolIndex);
1618                }
1619                let wdiff = decode_num(&mut zp, &mut symbol_width_diff_ctx, -262143, 262142);
1620                let hdiff = decode_num(&mut zp, &mut symbol_height_diff_ctx, -262143, 262142);
1621                let cbm_w = dict[index].width + wdiff;
1622                let cbm_h = dict[index].height + hdiff;
1623                check_symbol_decode_budget(&zp, cbm_w, cbm_h, &mut total_sym_pixels)?;
1624                let cbm = decode_bitmap_ref(
1625                    &mut zp,
1626                    &mut refinement_bitmap_ctx,
1627                    &mut refinement_bitmap_ctx_p,
1628                    cbm_w,
1629                    cbm_h,
1630                    &dict[index],
1631                    pool,
1632                )?;
1633                let (x, y) = decode_symbol_coords(
1634                    &mut zp,
1635                    &mut coord_ctx,
1636                    &mut layout,
1637                    cbm.width,
1638                    cbm.height,
1639                );
1640                check_blit_budget(&cbm, &mut total_blit_pixels)?;
1641                blit_to_bitmap(&mut page_bm, &cbm, x, y);
1642                cbm.recycle_into(pool);
1643            }
1644
1645            // 7 — matched copy, no refinement → blit only
1646            7 => {
1647                if dict.is_empty() {
1648                    return Err(Jb2Error::EmptyDictReference);
1649                }
1650                let index =
1651                    decode_num(&mut zp, &mut symbol_index_ctx, 0, dict.len() as i32 - 1) as usize;
1652                if index >= dict.len() {
1653                    return Err(Jb2Error::InvalidSymbolIndex);
1654                }
1655                let bm_w = dict[index].width;
1656                let bm_h = dict[index].height;
1657                let (x, y) = decode_symbol_coords(&mut zp, &mut coord_ctx, &mut layout, bm_w, bm_h);
1658                let sym = &dict[index];
1659                check_blit_budget(sym, &mut total_blit_pixels)?;
1660                blit_to_bitmap(&mut page_bm, sym, x, y);
1661            }
1662
1663            // 8 — non-symbol (halftone), absolute coordinates
1664            8 => {
1665                let w = decode_num(&mut zp, &mut symbol_width_ctx, 0, 262142);
1666                let h = decode_num(&mut zp, &mut symbol_height_ctx, 0, 262142);
1667                check_symbol_decode_budget(&zp, w, h, &mut total_sym_pixels)?;
1668                let bm = decode_bitmap_direct(&mut zp, &mut direct_bitmap_ctx, w, h, pool)?;
1669                let left = decode_num(&mut zp, &mut horiz_abs_loc_ctx, 1, image_width);
1670                let top = decode_num(&mut zp, &mut vert_abs_loc_ctx, 1, image_height);
1671                let x = left - 1;
1672                let y = top - h;
1673                check_blit_budget(&bm, &mut total_blit_pixels)?;
1674                blit_to_bitmap(&mut page_bm, &bm, x, y);
1675                bm.recycle_into(pool);
1676            }
1677
1678            // 9 — required-dict-or-reset (already consumed in preamble; ignore here)
1679            9 => {}
1680
1681            // 10 — comment: skip bytes
1682            10 => {
1683                let length = decode_num(&mut zp, &mut comment_length_ctx, 0, 262142) as usize;
1684                let length = length.min(MAX_COMMENT_BYTES);
1685                for _ in 0..length {
1686                    decode_num(&mut zp, &mut comment_octet_ctx, 0, 255);
1687                }
1688            }
1689
1690            // 11 — end-of-data
1691            11 => break,
1692
1693            _ => return Err(Jb2Error::UnknownRecordType),
1694        }
1695    }
1696
1697    Ok(page_bm)
1698}
1699
1700/// Same as `decode_image` but tracks per-pixel blit indices.
1701fn decode_image_indexed(
1702    data: &[u8],
1703    shared_dict: Option<&Jb2Dict>,
1704) -> Result<(Bitmap, Vec<i32>), Jb2Error> {
1705    let mut pool = Vec::new();
1706    decode_image_indexed_with_pool(data, shared_dict, &mut pool)
1707}
1708
1709fn decode_image_indexed_with_pool(
1710    data: &[u8],
1711    shared_dict: Option<&Jb2Dict>,
1712    pool: &mut Vec<u8>,
1713) -> Result<(Bitmap, Vec<i32>), Jb2Error> {
1714    let mut zp = ZpDecoder::new(data).map_err(|_| Jb2Error::ZpInitFailed)?;
1715
1716    let mut record_type_ctx = NumContext::new();
1717    let mut image_size_ctx = NumContext::new();
1718    let mut symbol_width_ctx = NumContext::new();
1719    let mut symbol_height_ctx = NumContext::new();
1720    let mut inherit_dict_size_ctx = NumContext::new();
1721    let mut coord_ctx = CoordContexts::new();
1722    let mut symbol_index_ctx = NumContext::new();
1723    let mut symbol_width_diff_ctx = NumContext::new();
1724    let mut symbol_height_diff_ctx = NumContext::new();
1725    let mut horiz_abs_loc_ctx = NumContext::new();
1726    let mut vert_abs_loc_ctx = NumContext::new();
1727    let mut comment_length_ctx = NumContext::new();
1728    let mut comment_octet_ctx = NumContext::new();
1729
1730    let mut direct_bitmap_ctx = [0u8; 1024];
1731    let mut refinement_bitmap_ctx = [0u8; 2048];
1732    let mut refinement_bitmap_ctx_p = [0x8000u16; 2048];
1733    let mut total_sym_pixels = 0usize;
1734    let mut total_blit_pixels = 0usize;
1735
1736    let mut rtype = decode_num(&mut zp, &mut record_type_ctx, 0, 11);
1737    let mut initial_dict_length: usize = 0;
1738    if rtype == 9 {
1739        initial_dict_length = decode_num(&mut zp, &mut inherit_dict_size_ctx, 0, 262142) as usize;
1740        rtype = decode_num(&mut zp, &mut record_type_ctx, 0, 11);
1741    }
1742    let _ = rtype;
1743
1744    let image_width = {
1745        let w = decode_num(&mut zp, &mut image_size_ctx, 0, 262142);
1746        if w == 0 { 200 } else { w }
1747    };
1748    let image_height = {
1749        let h = decode_num(&mut zp, &mut image_size_ctx, 0, 262142);
1750        if h == 0 { 200 } else { h }
1751    };
1752
1753    let mut flag_ctx: u8 = 0;
1754    if zp.decode_bit(&mut flag_ctx) {
1755        return Err(Jb2Error::BadHeaderFlag);
1756    }
1757
1758    let initial_symbols_idx: &[Jbm] = if initial_dict_length > 0 {
1759        match shared_dict {
1760            Some(sd) => {
1761                if initial_dict_length > sd.symbols.len() {
1762                    return Err(Jb2Error::InheritedDictTooLarge);
1763                }
1764                &sd.symbols[..initial_dict_length]
1765            }
1766            None => return Err(Jb2Error::MissingSharedDict),
1767        }
1768    } else {
1769        &[]
1770    };
1771    let mut dict = JbmDict::new(initial_symbols_idx);
1772
1773    const MAX_PIXELS: usize = 64 * 1024 * 1024;
1774    let page_size = (image_width as usize).saturating_mul(image_height as usize);
1775    if page_size > MAX_PIXELS {
1776        return Err(Jb2Error::ImageTooLarge);
1777    }
1778    let mut page = vec![0u8; page_size];
1779    let mut blit_map = vec![-1i32; page_size];
1780
1781    let mut layout = LayoutState::new(image_height);
1782    let mut blit_count: i32 = 0;
1783
1784    let mut record_count = 0usize;
1785    loop {
1786        if record_count >= MAX_RECORDS {
1787            return Err(Jb2Error::TooManyRecords);
1788        }
1789        record_count += 1;
1790        let rtype = decode_num(&mut zp, &mut record_type_ctx, 0, 11);
1791
1792        match rtype {
1793            1 => {
1794                let w = decode_num(&mut zp, &mut symbol_width_ctx, 0, 262142);
1795                let h = decode_num(&mut zp, &mut symbol_height_ctx, 0, 262142);
1796                check_symbol_decode_budget(&zp, w, h, &mut total_sym_pixels)?;
1797                let bm = decode_bitmap_direct(&mut zp, &mut direct_bitmap_ctx, w, h, pool)?;
1798                let (x, y) =
1799                    decode_symbol_coords(&mut zp, &mut coord_ctx, &mut layout, bm.width, bm.height);
1800                check_blit_budget(&bm, &mut total_blit_pixels)?;
1801                blit_indexed(
1802                    &mut page,
1803                    &mut blit_map,
1804                    image_width,
1805                    image_height,
1806                    &bm,
1807                    x,
1808                    y,
1809                    blit_count,
1810                );
1811                blit_count += 1;
1812                dict.push(bm.crop_and_recycle(pool));
1813            }
1814            2 => {
1815                let w = decode_num(&mut zp, &mut symbol_width_ctx, 0, 262142);
1816                let h = decode_num(&mut zp, &mut symbol_height_ctx, 0, 262142);
1817                check_symbol_decode_budget(&zp, w, h, &mut total_sym_pixels)?;
1818                let bm = decode_bitmap_direct(&mut zp, &mut direct_bitmap_ctx, w, h, pool)?;
1819                dict.push(bm.crop_and_recycle(pool));
1820            }
1821            3 => {
1822                let w = decode_num(&mut zp, &mut symbol_width_ctx, 0, 262142);
1823                let h = decode_num(&mut zp, &mut symbol_height_ctx, 0, 262142);
1824                check_symbol_decode_budget(&zp, w, h, &mut total_sym_pixels)?;
1825                let bm = decode_bitmap_direct(&mut zp, &mut direct_bitmap_ctx, w, h, pool)?;
1826                let (x, y) =
1827                    decode_symbol_coords(&mut zp, &mut coord_ctx, &mut layout, bm.width, bm.height);
1828                check_blit_budget(&bm, &mut total_blit_pixels)?;
1829                blit_indexed(
1830                    &mut page,
1831                    &mut blit_map,
1832                    image_width,
1833                    image_height,
1834                    &bm,
1835                    x,
1836                    y,
1837                    blit_count,
1838                );
1839                blit_count += 1;
1840                bm.recycle_into(pool);
1841            }
1842            4 => {
1843                if dict.is_empty() {
1844                    return Err(Jb2Error::EmptyDictReference);
1845                }
1846                let index =
1847                    decode_num(&mut zp, &mut symbol_index_ctx, 0, dict.len() as i32 - 1) as usize;
1848                if index >= dict.len() {
1849                    return Err(Jb2Error::InvalidSymbolIndex);
1850                }
1851                let wdiff = decode_num(&mut zp, &mut symbol_width_diff_ctx, -262143, 262142);
1852                let hdiff = decode_num(&mut zp, &mut symbol_height_diff_ctx, -262143, 262142);
1853                let cbm_w = dict[index].width + wdiff;
1854                let cbm_h = dict[index].height + hdiff;
1855                check_symbol_decode_budget(&zp, cbm_w, cbm_h, &mut total_sym_pixels)?;
1856                let cbm = decode_bitmap_ref(
1857                    &mut zp,
1858                    &mut refinement_bitmap_ctx,
1859                    &mut refinement_bitmap_ctx_p,
1860                    cbm_w,
1861                    cbm_h,
1862                    &dict[index],
1863                    pool,
1864                )?;
1865                let (x, y) = decode_symbol_coords(
1866                    &mut zp,
1867                    &mut coord_ctx,
1868                    &mut layout,
1869                    cbm.width,
1870                    cbm.height,
1871                );
1872                check_blit_budget(&cbm, &mut total_blit_pixels)?;
1873                blit_indexed(
1874                    &mut page,
1875                    &mut blit_map,
1876                    image_width,
1877                    image_height,
1878                    &cbm,
1879                    x,
1880                    y,
1881                    blit_count,
1882                );
1883                blit_count += 1;
1884                dict.push(cbm.crop_and_recycle(pool));
1885            }
1886            5 => {
1887                if dict.is_empty() {
1888                    return Err(Jb2Error::EmptyDictReference);
1889                }
1890                let index =
1891                    decode_num(&mut zp, &mut symbol_index_ctx, 0, dict.len() as i32 - 1) as usize;
1892                if index >= dict.len() {
1893                    return Err(Jb2Error::InvalidSymbolIndex);
1894                }
1895                let wdiff = decode_num(&mut zp, &mut symbol_width_diff_ctx, -262143, 262142);
1896                let hdiff = decode_num(&mut zp, &mut symbol_height_diff_ctx, -262143, 262142);
1897                let cbm_w = dict[index].width + wdiff;
1898                let cbm_h = dict[index].height + hdiff;
1899                check_symbol_decode_budget(&zp, cbm_w, cbm_h, &mut total_sym_pixels)?;
1900                let cbm = decode_bitmap_ref(
1901                    &mut zp,
1902                    &mut refinement_bitmap_ctx,
1903                    &mut refinement_bitmap_ctx_p,
1904                    cbm_w,
1905                    cbm_h,
1906                    &dict[index],
1907                    pool,
1908                )?;
1909                dict.push(cbm.crop_and_recycle(pool));
1910            }
1911            6 => {
1912                if dict.is_empty() {
1913                    return Err(Jb2Error::EmptyDictReference);
1914                }
1915                let index =
1916                    decode_num(&mut zp, &mut symbol_index_ctx, 0, dict.len() as i32 - 1) as usize;
1917                if index >= dict.len() {
1918                    return Err(Jb2Error::InvalidSymbolIndex);
1919                }
1920                let wdiff = decode_num(&mut zp, &mut symbol_width_diff_ctx, -262143, 262142);
1921                let hdiff = decode_num(&mut zp, &mut symbol_height_diff_ctx, -262143, 262142);
1922                let cbm_w = dict[index].width + wdiff;
1923                let cbm_h = dict[index].height + hdiff;
1924                check_symbol_decode_budget(&zp, cbm_w, cbm_h, &mut total_sym_pixels)?;
1925                let cbm = decode_bitmap_ref(
1926                    &mut zp,
1927                    &mut refinement_bitmap_ctx,
1928                    &mut refinement_bitmap_ctx_p,
1929                    cbm_w,
1930                    cbm_h,
1931                    &dict[index],
1932                    pool,
1933                )?;
1934                let (x, y) = decode_symbol_coords(
1935                    &mut zp,
1936                    &mut coord_ctx,
1937                    &mut layout,
1938                    cbm.width,
1939                    cbm.height,
1940                );
1941                check_blit_budget(&cbm, &mut total_blit_pixels)?;
1942                blit_indexed(
1943                    &mut page,
1944                    &mut blit_map,
1945                    image_width,
1946                    image_height,
1947                    &cbm,
1948                    x,
1949                    y,
1950                    blit_count,
1951                );
1952                blit_count += 1;
1953                cbm.recycle_into(pool);
1954            }
1955            7 => {
1956                if dict.is_empty() {
1957                    return Err(Jb2Error::EmptyDictReference);
1958                }
1959                let index =
1960                    decode_num(&mut zp, &mut symbol_index_ctx, 0, dict.len() as i32 - 1) as usize;
1961                if index >= dict.len() {
1962                    return Err(Jb2Error::InvalidSymbolIndex);
1963                }
1964                let (x, y) = decode_symbol_coords(
1965                    &mut zp,
1966                    &mut coord_ctx,
1967                    &mut layout,
1968                    dict[index].width,
1969                    dict[index].height,
1970                );
1971                check_blit_budget(&dict[index], &mut total_blit_pixels)?;
1972                blit_indexed(
1973                    &mut page,
1974                    &mut blit_map,
1975                    image_width,
1976                    image_height,
1977                    &dict[index],
1978                    x,
1979                    y,
1980                    blit_count,
1981                );
1982                blit_count += 1;
1983            }
1984            8 => {
1985                let w = decode_num(&mut zp, &mut symbol_width_ctx, 0, 262142);
1986                let h = decode_num(&mut zp, &mut symbol_height_ctx, 0, 262142);
1987                check_symbol_decode_budget(&zp, w, h, &mut total_sym_pixels)?;
1988                let bm = decode_bitmap_direct(&mut zp, &mut direct_bitmap_ctx, w, h, pool)?;
1989                let left = decode_num(&mut zp, &mut horiz_abs_loc_ctx, 1, image_width);
1990                let top = decode_num(&mut zp, &mut vert_abs_loc_ctx, 1, image_height);
1991                check_blit_budget(&bm, &mut total_blit_pixels)?;
1992                blit_indexed(
1993                    &mut page,
1994                    &mut blit_map,
1995                    image_width,
1996                    image_height,
1997                    &bm,
1998                    left - 1,
1999                    top - h,
2000                    blit_count,
2001                );
2002                blit_count += 1;
2003                bm.recycle_into(pool);
2004            }
2005            9 => {}
2006            10 => {
2007                let length = decode_num(&mut zp, &mut comment_length_ctx, 0, 262142) as usize;
2008                let length = length.min(MAX_COMMENT_BYTES);
2009                for _ in 0..length {
2010                    decode_num(&mut zp, &mut comment_octet_ctx, 0, 255);
2011                }
2012            }
2013            11 => break,
2014            _ => return Err(Jb2Error::UnknownRecordType),
2015        }
2016    }
2017
2018    let bm = page_to_bitmap(&page, image_width, image_height);
2019    flip_blit_map(&mut blit_map, image_width as usize, image_height as usize);
2020    Ok((bm, blit_map))
2021}
2022
2023// ────────────────────────────────────────────────────────────────────────────
2024// Core dictionary decode
2025// ────────────────────────────────────────────────────────────────────────────
2026
2027fn decode_dictionary(data: &[u8], inherited: Option<&Jb2Dict>) -> Result<Jb2Dict, Jb2Error> {
2028    let mut pool: Vec<u8> = Vec::new();
2029    decode_dictionary_with_pool(data, inherited, &mut pool)
2030}
2031
2032fn decode_dictionary_with_pool(
2033    data: &[u8],
2034    inherited: Option<&Jb2Dict>,
2035    pool: &mut Vec<u8>,
2036) -> Result<Jb2Dict, Jb2Error> {
2037    let mut zp = ZpDecoder::new(data).map_err(|_| Jb2Error::ZpInitFailed)?;
2038
2039    let mut record_type_ctx = NumContext::new();
2040    let mut image_size_ctx = NumContext::new();
2041    let mut symbol_width_ctx = NumContext::new();
2042    let mut symbol_height_ctx = NumContext::new();
2043    let mut inherit_dict_size_ctx = NumContext::new();
2044    let mut symbol_index_ctx = NumContext::new();
2045    let mut symbol_width_diff_ctx = NumContext::new();
2046    let mut symbol_height_diff_ctx = NumContext::new();
2047    let mut comment_length_ctx = NumContext::new();
2048    let mut comment_octet_ctx = NumContext::new();
2049
2050    let mut direct_bitmap_ctx = [0u8; 1024];
2051    let mut refinement_bitmap_ctx = [0u8; 2048];
2052    let mut refinement_bitmap_ctx_p = [0x8000u16; 2048];
2053    let mut total_sym_pixels = 0usize;
2054
2055    // Preamble
2056    let mut rtype = decode_num(&mut zp, &mut record_type_ctx, 0, 11);
2057    let mut initial_dict_length: usize = 0;
2058    if rtype == 9 {
2059        initial_dict_length = decode_num(&mut zp, &mut inherit_dict_size_ctx, 0, 262142) as usize;
2060        rtype = decode_num(&mut zp, &mut record_type_ctx, 0, 11);
2061    }
2062    let _ = rtype;
2063
2064    // Dimensions (present but unused in dict streams)
2065    let _dict_width = decode_num(&mut zp, &mut image_size_ctx, 0, 262142);
2066    let _dict_height = decode_num(&mut zp, &mut image_size_ctx, 0, 262142);
2067
2068    // Reserved flag bit
2069    let mut flag_ctx: u8 = 0;
2070    if zp.decode_bit(&mut flag_ctx) {
2071        return Err(Jb2Error::BadHeaderFlag);
2072    }
2073
2074    let initial_inh: &[Jbm] = if initial_dict_length > 0 {
2075        match inherited {
2076            Some(inh) => {
2077                if initial_dict_length > inh.symbols.len() {
2078                    return Err(Jb2Error::InheritedDictTooLarge);
2079                }
2080                &inh.symbols[..initial_dict_length]
2081            }
2082            None => return Err(Jb2Error::MissingSharedDict),
2083        }
2084    } else {
2085        &[]
2086    };
2087    let mut dict = JbmDict::new(initial_inh);
2088
2089    // Dict streams only accept types 2, 5, 9, 10, 11
2090    let mut record_count = 0usize;
2091    loop {
2092        if record_count >= MAX_RECORDS {
2093            return Err(Jb2Error::TooManyRecords);
2094        }
2095        record_count += 1;
2096        let rtype = decode_num(&mut zp, &mut record_type_ctx, 0, 11);
2097
2098        match rtype {
2099            // 2 — new symbol, direct decode → add to dict
2100            2 => {
2101                let w = decode_num(&mut zp, &mut symbol_width_ctx, 0, 262142);
2102                let h = decode_num(&mut zp, &mut symbol_height_ctx, 0, 262142);
2103                check_symbol_decode_budget(&zp, w, h, &mut total_sym_pixels)?;
2104                let bm = decode_bitmap_direct(&mut zp, &mut direct_bitmap_ctx, w, h, pool)?;
2105                dict.push(bm.crop_and_recycle(pool));
2106            }
2107
2108            // 5 — matched refinement → add to dict
2109            5 => {
2110                if dict.is_empty() {
2111                    return Err(Jb2Error::EmptyDictReference);
2112                }
2113                let index =
2114                    decode_num(&mut zp, &mut symbol_index_ctx, 0, dict.len() as i32 - 1) as usize;
2115                if index >= dict.len() {
2116                    return Err(Jb2Error::InvalidSymbolIndex);
2117                }
2118                let wdiff = decode_num(&mut zp, &mut symbol_width_diff_ctx, -262143, 262142);
2119                let hdiff = decode_num(&mut zp, &mut symbol_height_diff_ctx, -262143, 262142);
2120                let cbm_w = dict[index].width + wdiff;
2121                let cbm_h = dict[index].height + hdiff;
2122                check_symbol_decode_budget(&zp, cbm_w, cbm_h, &mut total_sym_pixels)?;
2123                let cbm = decode_bitmap_ref(
2124                    &mut zp,
2125                    &mut refinement_bitmap_ctx,
2126                    &mut refinement_bitmap_ctx_p,
2127                    cbm_w,
2128                    cbm_h,
2129                    &dict[index],
2130                    pool,
2131                )?;
2132                dict.push(cbm.crop_and_recycle(pool));
2133            }
2134
2135            // 9 — required-dict-or-reset (ignored in dict streams)
2136            9 => {}
2137
2138            // 10 — comment: skip bytes
2139            10 => {
2140                let length = decode_num(&mut zp, &mut comment_length_ctx, 0, 262142) as usize;
2141                let length = length.min(MAX_COMMENT_BYTES);
2142                for _ in 0..length {
2143                    decode_num(&mut zp, &mut comment_octet_ctx, 0, 255);
2144                }
2145            }
2146
2147            // 11 — end-of-data
2148            11 => break,
2149
2150            _ => return Err(Jb2Error::UnexpectedDictRecordType),
2151        }
2152    }
2153
2154    Ok(Jb2Dict {
2155        symbols: dict.into_symbols(),
2156    })
2157}
2158
2159// ────────────────────────────────────────────────────────────────────────────
2160// Tests
2161// ────────────────────────────────────────────────────────────────────────────
2162
2163#[cfg(test)]
2164mod tests {
2165    use super::*;
2166
2167    fn assets_path() -> std::path::PathBuf {
2168        std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
2169            .join("../../references/djvujs/library/assets")
2170    }
2171
2172    fn golden_path() -> std::path::PathBuf {
2173        std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../tests/golden/jb2")
2174    }
2175
2176    // ── IFF helpers ──────────────────────────────────────────────────────────
2177
2178    fn extract_sjbz(djvu_data: &[u8]) -> Vec<u8> {
2179        let file = djvu_iff::parse(djvu_data).unwrap();
2180        let sjbz = file.root.find_first(b"Sjbz").unwrap();
2181        sjbz.data().to_vec()
2182    }
2183
2184    fn extract_first_page_sjbz(djvu_data: &[u8]) -> Vec<u8> {
2185        let file = djvu_iff::parse(djvu_data).unwrap();
2186        let page_form = file
2187            .root
2188            .children()
2189            .iter()
2190            .find(|c| {
2191                matches!(c, djvu_iff::Chunk::Form { secondary_id, .. }
2192                    if secondary_id == b"DJVU")
2193            })
2194            .expect("no DJVU form");
2195        page_form.find_first(b"Sjbz").unwrap().data().to_vec()
2196    }
2197
2198    fn find_page_form_data(djvu_data: &[u8], page: usize) -> Vec<u8> {
2199        let file = djvu_iff::parse(djvu_data).unwrap();
2200        let mut idx = 0;
2201        for chunk in file.root.children() {
2202            if matches!(chunk, djvu_iff::Chunk::Form { secondary_id, .. }
2203                if secondary_id == b"DJVU")
2204            {
2205                if idx == page {
2206                    return chunk.find_first(b"Sjbz").unwrap().data().to_vec();
2207                }
2208                idx += 1;
2209            }
2210        }
2211        panic!("page {} not found", page);
2212    }
2213
2214    fn find_djvi_djbz_data(djvu_data: &[u8]) -> Vec<u8> {
2215        let file = djvu_iff::parse(djvu_data).unwrap();
2216        for chunk in file.root.children() {
2217            if let djvu_iff::Chunk::Form { secondary_id, .. } = chunk
2218                && secondary_id == b"DJVI"
2219                && let Some(djbz) = chunk.find_first(b"Djbz")
2220            {
2221                return djbz.data().to_vec();
2222            }
2223        }
2224        panic!("DJVI with Djbz not found");
2225    }
2226
2227    // ── Failing tests written first (TDD Red phase) ──────────────────────────
2228
2229    /// The new decoder must produce the same pixel-exact output as the legacy
2230    /// decoder for boy_jb2.djvu.
2231    #[test]
2232    fn jb2_new_decode_boy_jb2_mask() {
2233        let djvu = std::fs::read(assets_path().join("boy_jb2.djvu")).unwrap();
2234        let sjbz = extract_sjbz(&djvu);
2235        let bitmap = decode(&sjbz, None).unwrap();
2236        let actual_pbm = bitmap.to_pbm();
2237        let expected_pbm = std::fs::read(golden_path().join("boy_jb2_mask.pbm")).unwrap();
2238        assert_eq!(
2239            actual_pbm.len(),
2240            expected_pbm.len(),
2241            "PBM size mismatch: got {} expected {}",
2242            actual_pbm.len(),
2243            expected_pbm.len()
2244        );
2245        assert_eq!(actual_pbm, expected_pbm, "boy_jb2_mask pixel mismatch");
2246    }
2247
2248    #[test]
2249    fn jb2_new_decode_carte_p1_mask() {
2250        let djvu = std::fs::read(assets_path().join("carte.djvu")).unwrap();
2251        let sjbz = extract_first_page_sjbz(&djvu);
2252        let bitmap = decode(&sjbz, None).unwrap();
2253        let actual_pbm = bitmap.to_pbm();
2254        let expected_pbm = std::fs::read(golden_path().join("carte_p1_mask.pbm")).unwrap();
2255        assert_eq!(
2256            actual_pbm.len(),
2257            expected_pbm.len(),
2258            "carte_p1_mask size mismatch"
2259        );
2260        assert_eq!(actual_pbm, expected_pbm, "carte_p1_mask pixel mismatch");
2261    }
2262
2263    #[test]
2264    fn jb2_new_decode_djvu3spec_p1_mask() {
2265        let djvu = std::fs::read(assets_path().join("DjVu3Spec_bundled.djvu")).unwrap();
2266        let file = djvu_iff::parse(&djvu).unwrap();
2267
2268        // Inline Djbz in page 1
2269        let mut idx = 0usize;
2270        let mut page_form_opt: Option<&djvu_iff::Chunk> = None;
2271        for chunk in file.root.children() {
2272            if matches!(chunk, djvu_iff::Chunk::Form { secondary_id, .. }
2273                if secondary_id == b"DJVU")
2274            {
2275                if idx == 0 {
2276                    page_form_opt = Some(chunk);
2277                    break;
2278                }
2279                idx += 1;
2280            }
2281        }
2282        let page_form = page_form_opt.expect("page 0 not found");
2283        let djbz_data = page_form.find_first(b"Djbz").unwrap().data().to_vec();
2284        let sjbz_data = page_form.find_first(b"Sjbz").unwrap().data().to_vec();
2285
2286        let shared_dict = decode_dict(&djbz_data, None).unwrap();
2287        let bitmap = decode(&sjbz_data, Some(&shared_dict)).unwrap();
2288        let actual_pbm = bitmap.to_pbm();
2289        let expected_pbm = std::fs::read(golden_path().join("djvu3spec_p1_mask.pbm")).unwrap();
2290        assert_eq!(
2291            actual_pbm.len(),
2292            expected_pbm.len(),
2293            "djvu3spec_p1_mask size mismatch"
2294        );
2295        assert_eq!(actual_pbm, expected_pbm, "djvu3spec_p1_mask pixel mismatch");
2296    }
2297
2298    #[test]
2299    fn jb2_new_decode_djvu3spec_p2_mask() {
2300        let djvu = std::fs::read(assets_path().join("DjVu3Spec_bundled.djvu")).unwrap();
2301        let djbz_data = find_djvi_djbz_data(&djvu);
2302        let sjbz_data = find_page_form_data(&djvu, 1);
2303
2304        let shared_dict = decode_dict(&djbz_data, None).unwrap();
2305        let bitmap = decode(&sjbz_data, Some(&shared_dict)).unwrap();
2306        let actual_pbm = bitmap.to_pbm();
2307        let expected_pbm = std::fs::read(golden_path().join("djvu3spec_p2_mask.pbm")).unwrap();
2308        assert_eq!(
2309            actual_pbm.len(),
2310            expected_pbm.len(),
2311            "djvu3spec_p2_mask size mismatch"
2312        );
2313        assert_eq!(actual_pbm, expected_pbm, "djvu3spec_p2_mask pixel mismatch");
2314    }
2315
2316    #[test]
2317    fn jb2_new_decode_navm_fgbz_p1_mask() {
2318        let djvu = std::fs::read(assets_path().join("navm_fgbz.djvu")).unwrap();
2319        let djbz_data = find_djvi_djbz_data(&djvu);
2320        let sjbz_data = find_page_form_data(&djvu, 0);
2321
2322        let shared_dict = decode_dict(&djbz_data, None).unwrap();
2323        let bitmap = decode(&sjbz_data, Some(&shared_dict)).unwrap();
2324        let actual_pbm = bitmap.to_pbm();
2325        let expected_pbm = std::fs::read(golden_path().join("navm_fgbz_p1_mask.pbm")).unwrap();
2326        assert_eq!(
2327            actual_pbm.len(),
2328            expected_pbm.len(),
2329            "navm_fgbz_p1_mask size mismatch"
2330        );
2331        assert_eq!(actual_pbm, expected_pbm, "navm_fgbz_p1_mask pixel mismatch");
2332    }
2333
2334    // ── Robustness tests ─────────────────────────────────────────────────────
2335
2336    #[test]
2337    fn jb2_new_empty_input_does_not_panic() {
2338        let _ = decode(&[], None);
2339    }
2340
2341    #[test]
2342    fn jb2_new_single_byte_does_not_panic() {
2343        let _ = decode(&[0x00], None);
2344    }
2345
2346    #[test]
2347    fn jb2_new_all_zeros_does_not_panic() {
2348        let _ = decode(&[0u8; 64], None);
2349    }
2350
2351    #[test]
2352    fn jb2_new_dict_empty_input_does_not_panic() {
2353        let _ = decode_dict(&[], None);
2354    }
2355
2356    #[test]
2357    fn jb2_new_dict_truncated_does_not_panic() {
2358        let _ = decode_dict(&[0u8; 8], None);
2359    }
2360
2361    // ── Error variant tests ──────────────────────────────────────────────────
2362
2363    #[test]
2364    fn jb2_error_variants_have_meaningful_messages() {
2365        assert!(Jb2Error::BadHeaderFlag.to_string().contains("flag"));
2366        assert!(Jb2Error::InheritedDictTooLarge.to_string().contains("dict"));
2367        assert!(Jb2Error::MissingSharedDict.to_string().contains("dict"));
2368        assert!(Jb2Error::ImageTooLarge.to_string().contains("large"));
2369        assert!(Jb2Error::EmptyDictReference.to_string().contains("dict"));
2370        assert!(Jb2Error::InvalidSymbolIndex.to_string().contains("symbol"));
2371        assert!(Jb2Error::UnknownRecordType.to_string().contains("record"));
2372        assert!(
2373            Jb2Error::UnexpectedDictRecordType
2374                .to_string()
2375                .contains("record")
2376        );
2377        assert!(Jb2Error::ZpInitFailed.to_string().contains("ZP"));
2378        assert!(Jb2Error::Truncated.to_string().contains("truncated"));
2379    }
2380
2381    /// Verify `ImageTooLarge` fires via saturating multiply.
2382    #[test]
2383    fn jb2_image_size_overflow_guard() {
2384        let w: usize = 65536;
2385        let h: usize = 65537;
2386        let safe_size = w.saturating_mul(h);
2387        assert!(
2388            safe_size > 64 * 1024 * 1024,
2389            "saturating_mul must exceed MAX_PIXELS"
2390        );
2391    }
2392
2393    // ── Error path tests ───────────────────��────────────────────────────────
2394
2395    #[test]
2396    fn test_decode_empty_data() {
2397        let result = decode(&[], None);
2398        assert!(result.is_err());
2399    }
2400
2401    #[test]
2402    fn test_decode_dict_empty() {
2403        let result = decode_dict(&[], None);
2404        assert!(result.is_err());
2405    }
2406
2407    #[test]
2408    fn test_decode_indexed_empty() {
2409        let result = decode_indexed(&[], None);
2410        assert!(result.is_err());
2411    }
2412
2413    /// Regression test: negative symbol dimensions caused `width as usize` to
2414    /// wrap to a huge value in the blit fast path, producing a near-infinite
2415    /// inner loop and effectively hanging the decoder.
2416    #[test]
2417    fn blit_negative_width_does_not_hang() {
2418        let start = std::time::Instant::now();
2419        let _ = decode(&[0x7e, 0x00, 0x0c], None);
2420        assert!(start.elapsed().as_secs() < 2, "took {:?}", start.elapsed());
2421    }
2422
2423    // ── Pool reuse tests ──────────────────────────────────────────────────────
2424
2425    /// Decoding a real JB2 stream with an explicit scratch pool must produce
2426    /// pixel-identical output to the poolless `decode` path, and the pool must
2427    /// grow to at least 1 byte (proving it was used for at least one symbol).
2428    #[test]
2429    fn jb2_pool_decode_matches_regular_decode_carte() {
2430        let djvu = std::fs::read(assets_path().join("carte.djvu")).unwrap();
2431        let sjbz = extract_first_page_sjbz(&djvu);
2432
2433        let regular = decode(&sjbz, None).expect("regular decode");
2434
2435        let mut pool = Vec::new();
2436        let pooled = decode_image_with_pool(&sjbz, None, &mut pool).expect("pool decode");
2437
2438        assert_eq!(regular.width, pooled.width, "width must match");
2439        assert_eq!(regular.height, pooled.height, "height must match");
2440        assert_eq!(regular.data, pooled.data, "pixel data must be identical");
2441        assert!(
2442            pool.capacity() > 0,
2443            "pool must have been used (capacity > 0 after decode)"
2444        );
2445    }
2446}
2447
2448#[cfg(test)]
2449mod regression_fuzz2 {
2450    use super::*;
2451
2452    /// Regression test: a fuzzer-discovered 11-byte input triggered two DoS
2453    /// paths simultaneously: the ZP-exhausted record loop spinning up to
2454    /// MAX_RECORDS times, and a near-4MP symbol decode.
2455    #[test]
2456    fn huge_symbol_from_small_input_does_not_hang() {
2457        let data = &[
2458            0x7f, 0x00, 0x0a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
2459        ];
2460        let start = std::time::Instant::now();
2461        let _ = decode(data, None);
2462        // In release the full decode is <100 ms; in debug the unoptimised loop
2463        // is ~10× slower, so we allow 8 s (still well under the 10 s fuzz
2464        // CI timeout that motivated this fix).
2465        let limit_secs = if cfg!(debug_assertions) { 8 } else { 2 };
2466        assert!(
2467            start.elapsed().as_secs() < limit_secs,
2468            "took {:?}",
2469            start.elapsed()
2470        );
2471    }
2472
2473    /// Regression test for the 2026-05-03 `fuzz_jb2` timeout on main. The
2474    /// 6-byte stream exhausts ZP input, then asks for a large refinement symbol.
2475    #[test]
2476    fn exhausted_refinement_symbol_from_small_input_does_not_hang() {
2477        let data = &[0x2a, 0xce, 0x7d, 0x24, 0x01, 0x00];
2478        let start = std::time::Instant::now();
2479        assert!(matches!(decode(data, None), Err(Jb2Error::Truncated)));
2480        let limit_secs = if cfg!(debug_assertions) { 2 } else { 1 };
2481        assert!(
2482            start.elapsed().as_secs() < limit_secs,
2483            "took {:?}",
2484            start.elapsed()
2485        );
2486    }
2487
2488    /// Regression test for the follow-up `fuzz_jb2` timeout where the
2489    /// post-EOF stream repeatedly emits small-but-expensive refinement symbols.
2490    #[test]
2491    fn exhausted_repeated_refinement_symbols_do_not_hang() {
2492        let data = &[0x2a, 0xce, 0xf1, 0xce, 0xf1, 0x88, 0x52, 0x82, 0xf7, 0xf7];
2493        let start = std::time::Instant::now();
2494        assert!(matches!(decode(data, None), Err(Jb2Error::Truncated)));
2495        let limit_secs = if cfg!(debug_assertions) { 2 } else { 1 };
2496        assert!(
2497            start.elapsed().as_secs() < limit_secs,
2498            "took {:?}",
2499            start.elapsed()
2500        );
2501    }
2502}