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