Skip to main content

oxideav_webp/
alph.rs

1//! Typed parser for the `ALPH` chunk **info byte** per RFC 9649
2//! §2.7.1.2 (Figure 10).
3//!
4//! The §2.3 walker in [`crate::container`] surfaces an `ALPH` chunk as
5//! an opaque payload whose first byte packs four 2-bit fields:
6//!
7//! ```text
8//!  0 1 2 3 4 5 6 7
9//! +-+-+-+-+-+-+-+-+
10//! |Rsv| P | F | C |
11//! +-+-+-+-+-+-+-+-+
12//! ```
13//!
14//! * `Rsv` — Reserved, 2 bits. MUST be 0; readers MUST ignore.
15//! * `P`   — Preprocessing, 2 bits. 0 = none, 1 = level reduction.
16//!   Other values are informational (decoders are not required to act
17//!   on this hint).
18//! * `F`   — Filtering method, 2 bits. 0 = none, 1 = horizontal,
19//!   2 = vertical, 3 = gradient.
20//! * `C`   — Compression method, 2 bits. 0 = uncompressed raw,
21//!   1 = WebP lossless format. Other values are not defined by RFC
22//!   9649 §2.7.1.2.
23//!
24//! This module decodes the info byte into a typed [`AlphHeader`]; it
25//! also decodes the full Alpha Bitstream that follows
26//! ([`decode_alpha`]) into a width × height plane of 8-bit alpha
27//! values, covering both compression methods and all four §2.7.1.2
28//! filtering methods.
29//!
30//! ## Alpha bitstream decode ([`decode_alpha`])
31//!
32//! Per RFC 9649 §2.7.1.2, the alpha bitstream is either:
33//!
34//! * **Compression method 0** — raw, uncompressed 8-bit alpha values
35//!   in scan order, of length `width * height`.
36//! * **Compression method 1** — a §3 WebP-lossless *image-stream* of
37//!   implicit dimensions `width x height` (no 5-byte image header).
38//!   Once decoded into ARGB, "the transparency information must be
39//!   extracted from the green channel of the ARGB quadruplet."
40//!
41//! After de-compression, the §2.7.1.2 inverse filter
42//! (none / horizontal / vertical / gradient) is applied over the
43//! reconstructed plane: each output alpha is
44//! `(predictor + decompressed) % 256`, with the per-method predictor
45//! and the documented left-most / top-most edge cases.
46//!
47//! ## Bit layout anchor
48//!
49//! The RFC's ASCII-art `|Rsv|P|F|C|` reads MSB-first within the byte,
50//! giving:
51//!
52//! | bit (LSB=0) | field |
53//! |-------------|-------|
54//! | 7..6        | Rsv   |
55//! | 5..4        | P     |
56//! | 3..2        | F     |
57//! | 1..0        | C     |
58//!
59//! Cross-checked against `docs/image/webp/fixtures/lossy-with-alpha-128x128/trace.txt`
60//! which reports `header_byte=0x01 method=1 filter=0 pre_processing=0` for a
61//! reference-encoder-produced fixture — only the C nibble's LSB is set, matching
62//! `compression = 1` (lossless) with everything else 0.
63
64use core::fmt;
65
66/// Compression method (`C`) per RFC 9649 §2.7.1.2.
67///
68/// The spec enumerates `0` (no compression) and `1` (WebP lossless
69/// format). Higher values are not defined; we preserve them in
70/// [`Self::Reserved`] so callers can refuse on encounter without the
71/// parser itself imposing that policy.
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub enum AlphCompression {
74    /// 0: No compression — the alpha bitstream is raw 8-bit values in
75    /// scan order, of length `width * height`.
76    None,
77    /// 1: Lossless — the alpha bitstream is a §3 VP8L image-stream
78    /// with implicit dimensions `width x height` (no header).
79    Lossless,
80    /// 2 or 3 — undefined by §2.7.1.2.
81    Reserved(u8),
82}
83
84impl AlphCompression {
85    fn from_bits(c: u8) -> Self {
86        match c & 0b11 {
87            0 => Self::None,
88            1 => Self::Lossless,
89            other => Self::Reserved(other),
90        }
91    }
92}
93
94/// Filtering method (`F`) per RFC 9649 §2.7.1.2.
95///
96/// The four values are exhaustive within the 2-bit field; the spec
97/// defines a prediction rule for each (None / A / B / clip(A+B-C)).
98#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub enum AlphFiltering {
100    /// 0: predictor = 0 for every pixel (no filter).
101    None,
102    /// 1: predictor = A (the pixel to the left).
103    Horizontal,
104    /// 2: predictor = B (the pixel above).
105    Vertical,
106    /// 3: predictor = clip(A + B - C) — the gradient predictor.
107    Gradient,
108}
109
110impl AlphFiltering {
111    fn from_bits(f: u8) -> Self {
112        match f & 0b11 {
113            0 => Self::None,
114            1 => Self::Horizontal,
115            2 => Self::Vertical,
116            3 => Self::Gradient,
117            _ => unreachable!("masked to 2 bits"),
118        }
119    }
120}
121
122/// Preprocessing hint (`P`) per RFC 9649 §2.7.1.2.
123///
124/// Only `0` and `1` are named in the spec; the other two 2-bit values
125/// are reserved. §2.7.1.2: "Decoders are not required to use this
126/// information in any specified way." — i.e. this is purely
127/// informational metadata, not a refusal trigger.
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129pub enum AlphPreprocessing {
130    /// 0: No preprocessing was applied.
131    None,
132    /// 1: Level reduction was applied prior to compression.
133    LevelReduction,
134    /// 2 or 3 — undefined by §2.7.1.2.
135    Reserved(u8),
136}
137
138impl AlphPreprocessing {
139    fn from_bits(p: u8) -> Self {
140        match p & 0b11 {
141            0 => Self::None,
142            1 => Self::LevelReduction,
143            other => Self::Reserved(other),
144        }
145    }
146}
147
148/// Errors raised by the §2.7.1.2 ALPH info-byte parser and the
149/// [`decode_alpha`] bitstream decoder.
150#[derive(Debug, Clone, PartialEq, Eq)]
151pub enum AlphError {
152    /// The ALPH payload is empty — at minimum one info byte is
153    /// required per §2.7.1.2 Figure 10, even if the alpha bitstream
154    /// itself is zero-length (which §2.7.1.2 does not forbid).
155    EmptyPayload,
156    /// `width * height` overflowed `usize` (or `u32`), so the plane
157    /// cannot be addressed on this platform.
158    DimensionsOverflow {
159        /// The implicit width passed by the caller.
160        width: u32,
161        /// The implicit height passed by the caller.
162        height: u32,
163    },
164    /// Compression method 0 (raw) but the alpha bitstream length does
165    /// not equal `width * height` (§2.7.1.2: "a byte sequence of
166    /// length = width * height").
167    RawLengthMismatch {
168        /// The expected `width * height` byte count.
169        expected: usize,
170        /// The actual number of bytes available in the bitstream.
171        actual: usize,
172    },
173    /// Compression method `C` was `2` or `3` — undefined by §2.7.1.2.
174    UnsupportedCompression(u8),
175    /// The compression-method-1 §3 VP8L image-stream failed to decode.
176    Vp8l(crate::vp8l_decode::DecodeError),
177}
178
179impl fmt::Display for AlphError {
180    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
181        match self {
182            Self::EmptyPayload => {
183                f.write_str("ALPH payload missing the §2.7.1.2 info byte (payload length 0)")
184            }
185            Self::DimensionsOverflow { width, height } => write!(
186                f,
187                "ALPH alpha-plane dimensions {width}x{height} overflow the addressable range"
188            ),
189            Self::RawLengthMismatch { expected, actual } => write!(
190                f,
191                "ALPH raw (method 0) bitstream length {actual} != width*height {expected}"
192            ),
193            Self::UnsupportedCompression(c) => write!(
194                f,
195                "ALPH compression method {c} is undefined by §2.7.1.2 (only 0 and 1 exist)"
196            ),
197            Self::Vp8l(e) => write!(f, "ALPH method-1 VP8L image-stream decode: {e}"),
198        }
199    }
200}
201
202impl std::error::Error for AlphError {}
203
204impl From<crate::vp8l_decode::DecodeError> for AlphError {
205    fn from(e: crate::vp8l_decode::DecodeError) -> Self {
206        Self::Vp8l(e)
207    }
208}
209
210/// Decoded §2.7.1.2 `ALPH` info byte plus the offset at which the
211/// alpha bitstream begins inside the chunk payload.
212///
213/// Constructed via [`AlphHeader::parse`]. The actual alpha bitstream
214/// (raw or VP8L-compressed) is **not** decoded — this layer's job is
215/// to surface the 2-bit `Rsv` / `P` / `F` / `C` decomposition. The
216/// payload after byte 0 — `payload[1..]` — is the §2.7.1.2 "Alpha
217/// bitstream" of `Chunk Size - 1` bytes; callers that need it should
218/// slice the chunk payload at [`Self::bitstream_offset`].
219#[derive(Debug, Clone, Copy, PartialEq, Eq)]
220pub struct AlphHeader {
221    /// `C` field — compression method (§2.7.1.2).
222    pub compression: AlphCompression,
223    /// `F` field — filtering method (§2.7.1.2).
224    pub filtering: AlphFiltering,
225    /// `P` field — preprocessing hint (§2.7.1.2).
226    pub preprocessing: AlphPreprocessing,
227    /// `Rsv` field — raw 2-bit value from bits 7..6 of the info byte.
228    /// §2.7.1.2 says "MUST be 0. Readers MUST ignore this field." —
229    /// we surface the raw value for observability without rejecting.
230    pub reserved: u8,
231    /// Raw info byte, preserved for round-trip and trace assertions.
232    pub info_byte: u8,
233}
234
235impl AlphHeader {
236    /// Parse the `ALPH` chunk payload's §2.7.1.2 info byte.
237    ///
238    /// `payload` is the whole §2.3 chunk payload (i.e. the slice
239    /// returned by [`crate::container::WebpChunk::payload`] for a
240    /// chunk whose FourCC is [`crate::container::fourcc::ALPH`]). Only
241    /// the first byte is consumed by this layer; the remainder is the
242    /// alpha bitstream callers must hand off to a later VP8L or raw
243    /// decode pass.
244    pub fn parse(payload: &[u8]) -> Result<Self, AlphError> {
245        let info = *payload.first().ok_or(AlphError::EmptyPayload)?;
246
247        // §2.7.1.2 Figure 10: byte 0 packs Rsv|P|F|C, MSB-first.
248        let reserved = (info >> 6) & 0b11;
249        let p_bits = (info >> 4) & 0b11;
250        let f_bits = (info >> 2) & 0b11;
251        let c_bits = info & 0b11;
252
253        Ok(Self {
254            compression: AlphCompression::from_bits(c_bits),
255            filtering: AlphFiltering::from_bits(f_bits),
256            preprocessing: AlphPreprocessing::from_bits(p_bits),
257            reserved,
258            info_byte: info,
259        })
260    }
261
262    /// Offset (within the ALPH chunk payload) at which the alpha
263    /// bitstream begins. Always 1 per §2.7.1.2 — the info byte is
264    /// followed immediately by the bitstream.
265    pub const fn bitstream_offset(&self) -> usize {
266        1
267    }
268}
269
270/// `clip(v)` per §2.7.1.2: 0 if `v < 0`, 255 if `v > 255`, else `v`.
271#[inline]
272fn clip(v: i32) -> u8 {
273    v.clamp(0, 255) as u8
274}
275
276/// Decode a complete `ALPH` chunk payload to a `width * height` plane of
277/// 8-bit alpha values, in scan order.
278///
279/// `payload` is the **whole** §2.3 ALPH chunk payload (the §2.7.1.2 info
280/// byte followed by the alpha bitstream). `width` / `height` are the
281/// implicit alpha-plane dimensions — for a still image these are the
282/// `VP8X` canvas dimensions (or the `VP8 ` keyframe dimensions); for an
283/// animation frame they are the `ANMF` frame dimensions.
284///
285/// The decode follows §2.7.1.2 in two stages:
286///
287/// 1. **De-compression** (`C` field): method 0 copies the raw bytes;
288///    method 1 decodes the headerless §3 VP8L image-stream and lifts the
289///    alpha values out of the **green** channel of each ARGB pixel.
290/// 2. **Inverse filtering** (`F` field): the per-pixel predictor
291///    (none / A / B / clip(A+B-C)) is added to the de-compressed value
292///    modulo 256, with the §2.7.1.2 left-most / top-most edge cases.
293///
294/// Returns the reconstructed alpha plane (`width * height` bytes). The
295/// §2.7.1.2 preprocessing (`P`) hint is informational and is **not**
296/// applied here (the spec: "Decoders are not required to use this
297/// information in any specified way.").
298pub fn decode_alpha(payload: &[u8], width: u32, height: u32) -> Result<Vec<u8>, AlphError> {
299    let header = AlphHeader::parse(payload)?;
300
301    let count = (width as usize)
302        .checked_mul(height as usize)
303        .ok_or(AlphError::DimensionsOverflow { width, height })?;
304
305    // The alpha bitstream proper is everything after the info byte.
306    let bitstream = &payload[header.bitstream_offset()..];
307
308    // Stage 1 — de-compression into the raw (still-filtered) plane.
309    let filtered: Vec<u8> = match header.compression {
310        AlphCompression::None => {
311            if bitstream.len() != count {
312                return Err(AlphError::RawLengthMismatch {
313                    expected: count,
314                    actual: bitstream.len(),
315                });
316            }
317            bitstream.to_vec()
318        }
319        AlphCompression::Lossless => {
320            // §2.7.1.2: a headerless §3 image-stream of implicit
321            // dimensions width x height; the alpha values live in the
322            // GREEN channel of the decoded ARGB quadruplets.
323            let image =
324                crate::vp8l_transform::decode_lossless_headerless(bitstream, width, height)?;
325            image
326                .pixels()
327                .iter()
328                .map(|argb| (argb >> 8) as u8)
329                .collect()
330        }
331        AlphCompression::Reserved(c) => return Err(AlphError::UnsupportedCompression(c)),
332    };
333
334    // A zero-area plane has nothing to filter.
335    if count == 0 {
336        return Ok(filtered);
337    }
338
339    // Stage 2 — inverse filter into the final plane.
340    let w = width as usize;
341    let h = height as usize;
342
343    Ok(inverse_filter(filtered, w, h, header.filtering))
344}
345
346/// §2.7.1.2 Stage-2 inverse filter: reconstruct the alpha plane from the
347/// de-compressed residual `filtered` (length `w * h`, scan order) under
348/// the given §2.7.1.2 filtering method.
349///
350/// `alpha = (predictor + residual) % 256`, where the predictor reads the
351/// already-reconstructed `out` plane for the A = left / B = above /
352/// C = above-left neighbours (RFC 9649 §2.7.1.2 Figure 11). The
353/// per-method §2.7.1.2 edge cases — `(0,0)` always predicts 0; the first
354/// column and first row each fall back to the single in-bounds neighbour
355/// — are evaluated **once outside the interior loop** (one specialised
356/// border pass + one branch-free interior loop per method) rather than
357/// re-tested on every pixel. This is the same border-rule hoist the
358/// lossless §3.5.2 inverse predictor received; it does not change a
359/// single emitted byte (the per-pixel arithmetic is bit-for-bit the prior
360/// `match (x, y)` / `match filtering` form, just with the constant
361/// dispatch lifted out of the hot loop).
362fn inverse_filter(filtered: Vec<u8>, w: usize, h: usize, filtering: AlphFiltering) -> Vec<u8> {
363    // §2.7.1.2 method 0 (None): predictor = 0 for every pixel, so the
364    // reconstruction is the identity `out = filtered`. No border special
365    // case is needed — `(0,0)` already predicts 0 under None.
366    if filtering == AlphFiltering::None {
367        return filtered;
368    }
369
370    let mut out = vec![0u8; w * h];
371
372    // (0,0) always predicts 0, for every filter method.
373    out[0] = filtered[0];
374
375    match filtering {
376        AlphFiltering::None => unreachable!("handled above"),
377        AlphFiltering::Horizontal => {
378            // First row (x>0, y=0): predictor = A = left = out[x-1].
379            for x in 1..w {
380                out[x] = ((out[x - 1] as i32 + filtered[x] as i32) & 0xff) as u8;
381            }
382            for y in 1..h {
383                let row = y * w;
384                let above = row - w;
385                // Left-most (0, y>0): predicted by (0, y-1) = out[above].
386                out[row] = ((out[above] as i32 + filtered[row] as i32) & 0xff) as u8;
387                // Interior (x>0, y>0): predictor = A = left = out[row+x-1].
388                for x in 1..w {
389                    let i = row + x;
390                    out[i] = ((out[i - 1] as i32 + filtered[i] as i32) & 0xff) as u8;
391                }
392            }
393        }
394        AlphFiltering::Vertical => {
395            // First row (x>0, y=0): predictor = (x-1, 0) = out[x-1].
396            for x in 1..w {
397                out[x] = ((out[x - 1] as i32 + filtered[x] as i32) & 0xff) as u8;
398            }
399            // Interior + left-most (any x, y>0): predictor = B = above.
400            for y in 1..h {
401                let row = y * w;
402                let above = row - w;
403                for x in 0..w {
404                    let i = row + x;
405                    out[i] = ((out[above + x] as i32 + filtered[i] as i32) & 0xff) as u8;
406                }
407            }
408        }
409        AlphFiltering::Gradient => {
410            // First row (x>0, y=0): predictor = (x-1, 0) = out[x-1].
411            for x in 1..w {
412                out[x] = ((out[x - 1] as i32 + filtered[x] as i32) & 0xff) as u8;
413            }
414            for y in 1..h {
415                let row = y * w;
416                let above = row - w;
417                // Left-most (0, y>0): predicted by (0, y-1) = out[above].
418                out[row] = ((out[above] as i32 + filtered[row] as i32) & 0xff) as u8;
419                // Interior (x>0, y>0): predictor = clip(A + B − C).
420                for x in 1..w {
421                    let i = row + x;
422                    let a = out[i - 1] as i32;
423                    let b = out[above + x] as i32;
424                    let c = out[above + x - 1] as i32;
425                    let pred = clip(a + b - c) as i32;
426                    out[i] = ((pred + filtered[i] as i32) & 0xff) as u8;
427                }
428            }
429        }
430    }
431
432    out
433}
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438
439    /// Compose an ALPH info byte from its four 2-bit fields, MSB-first.
440    fn info(rsv: u8, p: u8, f: u8, c: u8) -> u8 {
441        ((rsv & 0b11) << 6) | ((p & 0b11) << 4) | ((f & 0b11) << 2) | (c & 0b11)
442    }
443
444    #[test]
445    fn empty_payload_is_rejected_with_named_error() {
446        // §2.7.1.2 Figure 10 mandates one info byte at minimum.
447        assert_eq!(AlphHeader::parse(&[]), Err(AlphError::EmptyPayload));
448    }
449
450    #[test]
451    fn all_zero_info_decodes_to_none_none_none_zero() {
452        // info = 0x00 → C=0, F=0, P=0, Rsv=0. The simplest legal ALPH.
453        let h = AlphHeader::parse(&[0x00]).unwrap();
454        assert_eq!(h.compression, AlphCompression::None);
455        assert_eq!(h.filtering, AlphFiltering::None);
456        assert_eq!(h.preprocessing, AlphPreprocessing::None);
457        assert_eq!(h.reserved, 0);
458        assert_eq!(h.info_byte, 0);
459        assert_eq!(h.bitstream_offset(), 1);
460    }
461
462    #[test]
463    fn compression_field_decodes_all_four_values() {
464        // C nibble at bits 1..0.
465        assert_eq!(
466            AlphHeader::parse(&[info(0, 0, 0, 0)]).unwrap().compression,
467            AlphCompression::None
468        );
469        assert_eq!(
470            AlphHeader::parse(&[info(0, 0, 0, 1)]).unwrap().compression,
471            AlphCompression::Lossless
472        );
473        assert_eq!(
474            AlphHeader::parse(&[info(0, 0, 0, 2)]).unwrap().compression,
475            AlphCompression::Reserved(2)
476        );
477        assert_eq!(
478            AlphHeader::parse(&[info(0, 0, 0, 3)]).unwrap().compression,
479            AlphCompression::Reserved(3)
480        );
481    }
482
483    #[test]
484    fn filtering_field_decodes_all_four_methods() {
485        // F nibble at bits 3..2. All four are named in §2.7.1.2.
486        assert_eq!(
487            AlphHeader::parse(&[info(0, 0, 0, 0)]).unwrap().filtering,
488            AlphFiltering::None
489        );
490        assert_eq!(
491            AlphHeader::parse(&[info(0, 0, 1, 0)]).unwrap().filtering,
492            AlphFiltering::Horizontal
493        );
494        assert_eq!(
495            AlphHeader::parse(&[info(0, 0, 2, 0)]).unwrap().filtering,
496            AlphFiltering::Vertical
497        );
498        assert_eq!(
499            AlphHeader::parse(&[info(0, 0, 3, 0)]).unwrap().filtering,
500            AlphFiltering::Gradient
501        );
502    }
503
504    #[test]
505    fn preprocessing_field_decodes_both_named_values_plus_reserved() {
506        // P nibble at bits 5..4. §2.7.1.2 names 0 + 1.
507        assert_eq!(
508            AlphHeader::parse(&[info(0, 0, 0, 0)])
509                .unwrap()
510                .preprocessing,
511            AlphPreprocessing::None
512        );
513        assert_eq!(
514            AlphHeader::parse(&[info(0, 1, 0, 0)])
515                .unwrap()
516                .preprocessing,
517            AlphPreprocessing::LevelReduction
518        );
519        assert_eq!(
520            AlphHeader::parse(&[info(0, 2, 0, 0)])
521                .unwrap()
522                .preprocessing,
523            AlphPreprocessing::Reserved(2)
524        );
525        assert_eq!(
526            AlphHeader::parse(&[info(0, 3, 0, 0)])
527                .unwrap()
528                .preprocessing,
529            AlphPreprocessing::Reserved(3)
530        );
531    }
532
533    #[test]
534    fn reserved_field_surfaces_raw_two_bit_value_without_rejection() {
535        // §2.7.1.2: "MUST be 0. Readers MUST ignore this field." So a
536        // non-zero Rsv must parse, with the raw value carried through.
537        for rsv in 0u8..=3 {
538            let h = AlphHeader::parse(&[info(rsv, 0, 0, 0)]).unwrap();
539            assert_eq!(h.reserved, rsv, "Rsv={rsv}");
540            // Named fields stay clean.
541            assert_eq!(h.compression, AlphCompression::None);
542            assert_eq!(h.filtering, AlphFiltering::None);
543            assert_eq!(h.preprocessing, AlphPreprocessing::None);
544        }
545    }
546
547    #[test]
548    fn fields_decode_independently_across_a_full_combination() {
549        // Hand-pick a byte where every nibble is non-zero & distinct:
550        // Rsv=2, P=3, F=1, C=2  →  10 11 01 10  =  0xB6
551        let h = AlphHeader::parse(&[0xB6]).unwrap();
552        assert_eq!(h.reserved, 0b10);
553        assert_eq!(h.preprocessing, AlphPreprocessing::Reserved(0b11));
554        assert_eq!(h.filtering, AlphFiltering::Horizontal);
555        assert_eq!(h.compression, AlphCompression::Reserved(0b10));
556        assert_eq!(h.info_byte, 0xB6);
557    }
558
559    #[test]
560    fn fixture_lossy_with_alpha_info_byte_decodes_to_lossless_no_filter_no_pre() {
561        // docs/image/webp/fixtures/lossy-with-alpha-128x128/trace.txt
562        //   ALPH method=1 filter=0 pre_processing=0 header_byte=0x01
563        let h = AlphHeader::parse(&[0x01]).unwrap();
564        assert_eq!(h.compression, AlphCompression::Lossless);
565        assert_eq!(h.filtering, AlphFiltering::None);
566        assert_eq!(h.preprocessing, AlphPreprocessing::None);
567        assert_eq!(h.reserved, 0);
568        assert_eq!(h.info_byte, 0x01);
569    }
570
571    #[test]
572    fn bitstream_offset_is_always_one_past_the_info_byte() {
573        // §2.7.1.2 "Alpha bitstream: _Chunk Size_ bytes - 1" — i.e.
574        // payload[1..] for any payload that survives parse().
575        let h = AlphHeader::parse(&[0x01, 0xAA, 0xBB]).unwrap();
576        assert_eq!(h.bitstream_offset(), 1);
577    }
578
579    #[test]
580    fn trailing_bytes_are_not_consumed_by_the_info_byte_parse() {
581        // Extra bytes (the actual bitstream) must NOT change the
582        // decoded info-byte fields; the parser only reads byte 0.
583        let baseline = AlphHeader::parse(&[0x01]).unwrap();
584        let with_tail = AlphHeader::parse(&[0x01, 0xFF, 0x00, 0x55, 0xAA]).unwrap();
585        assert_eq!(baseline, with_tail);
586    }
587
588    // ---- decode_alpha: §2.7.1.2 bitstream decode ----
589
590    /// Build an ALPH payload with filter method `f` and compression
591    /// method 0 (raw): the info byte followed by the residual stream.
592    fn raw_alph(f: u8, residual: &[u8]) -> Vec<u8> {
593        let mut v = vec![info(0, 0, f, 0)];
594        v.extend_from_slice(residual);
595        v
596    }
597
598    #[test]
599    fn decode_raw_uncompressed_no_filter_is_identity() {
600        // §2.7.1.2 method 0 + filter 0: alpha = (0 + X) % 256 = X.
601        let residual = [10u8, 5, 250, 3, 100, 200];
602        let payload = raw_alph(0, &residual);
603        let plane = decode_alpha(&payload, 3, 2).unwrap();
604        assert_eq!(plane, residual.to_vec());
605    }
606
607    #[test]
608    fn decode_raw_length_mismatch_is_rejected() {
609        // method 0 requires exactly width*height residual bytes.
610        let payload = raw_alph(0, &[1, 2, 3]); // 3 bytes for a 2x2 (=4) plane.
611        assert_eq!(
612            decode_alpha(&payload, 2, 2),
613            Err(AlphError::RawLengthMismatch {
614                expected: 4,
615                actual: 3
616            })
617        );
618    }
619
620    #[test]
621    fn decode_unsupported_compression_method_is_rejected() {
622        // C = 2 → Reserved(2) → UnsupportedCompression.
623        let payload = vec![info(0, 0, 0, 2), 0, 0, 0, 0];
624        assert_eq!(
625            decode_alpha(&payload, 2, 2),
626            Err(AlphError::UnsupportedCompression(2))
627        );
628    }
629
630    #[test]
631    fn decode_horizontal_filter_inverse() {
632        // §2.7.1.2 method 1 (horizontal): predictor = A (left); the
633        // left-most pixel (0, y>0) uses (0, y-1); (0,0) uses 0.
634        //   X     = [10,  5, 250,   3, 100, 200]   (3x2)
635        //   out   = [10, 15,   9,  13, 113,  57]
636        let residual = [10u8, 5, 250, 3, 100, 200];
637        let payload = raw_alph(1, &residual);
638        let plane = decode_alpha(&payload, 3, 2).unwrap();
639        assert_eq!(plane, vec![10, 15, 9, 13, 113, 57]);
640    }
641
642    #[test]
643    fn decode_vertical_filter_inverse() {
644        // §2.7.1.2 method 2 (vertical): predictor = B (above); the
645        // top-most pixel (x>0, 0) uses (x-1, 0); (0,0) uses 0.
646        //   X   = [10,  5, 250,  3, 100, 200]   (3x2)
647        //   out = [10, 15,   9, 13, 115, 209]
648        let residual = [10u8, 5, 250, 3, 100, 200];
649        let payload = raw_alph(2, &residual);
650        let plane = decode_alpha(&payload, 3, 2).unwrap();
651        assert_eq!(plane, vec![10, 15, 9, 13, 115, 209]);
652    }
653
654    #[test]
655    fn decode_gradient_filter_inverse() {
656        // §2.7.1.2 method 3 (gradient): predictor = clip(A+B-C) for
657        // interior pixels; left-most uses above, top-most uses left,
658        // (0,0) uses 0.
659        //   X   = [10,  5,  7,   3, 100,  50,  20,   8,   9]   (3x3)
660        //   out = [10, 15, 22,  13, 118, 175,  33, 146, 212]
661        let residual = [10u8, 5, 7, 3, 100, 50, 20, 8, 9];
662        let payload = raw_alph(3, &residual);
663        let plane = decode_alpha(&payload, 3, 3).unwrap();
664        assert_eq!(plane, vec![10, 15, 22, 13, 118, 175, 33, 146, 212]);
665    }
666
667    #[test]
668    fn decode_modulo_256_wraps_into_0_255() {
669        // §2.7.1.2: "modulo-256 arithmetic to wrap the [256..511] range
670        // into the [0..255] one." Horizontal, single row.
671        //   X   = [200, 200]   →   out = [200, (200+200)%256 = 144]
672        let payload = raw_alph(1, &[200, 200]);
673        let plane = decode_alpha(&payload, 2, 1).unwrap();
674        assert_eq!(plane, vec![200, 144]);
675    }
676
677    #[test]
678    fn decode_gradient_clip_clamps_predictor() {
679        // Force clip() to clamp high: A=255, B=255, C=0 → A+B-C=510 →
680        // clip=255. Build a 2x2 whose reconstruction reaches that.
681        //   X   = [255, 0, 0, 5]   (2x2)
682        //   (0,0)=255; (1,0) top-most pred=255 → 255; (0,1) left-most
683        //   pred=out(0,0)=255 → 255; (1,1) interior A=255 B=255 C=255 →
684        //   clip(255)=255 → (255+5)%256 = 4.
685        let payload = raw_alph(3, &[255, 0, 0, 5]);
686        let plane = decode_alpha(&payload, 2, 2).unwrap();
687        assert_eq!(plane, vec![255, 255, 255, 4]);
688    }
689
690    #[test]
691    fn decode_zero_area_plane_is_empty() {
692        // A 0xN or Nx0 plane decodes to an empty raw plane (length 0).
693        let payload = raw_alph(0, &[]);
694        assert_eq!(decode_alpha(&payload, 0, 4).unwrap(), Vec::<u8>::new());
695        assert_eq!(decode_alpha(&payload, 4, 0).unwrap(), Vec::<u8>::new());
696    }
697
698    #[test]
699    fn decode_empty_payload_is_rejected() {
700        assert_eq!(decode_alpha(&[], 1, 1), Err(AlphError::EmptyPayload));
701    }
702
703    /// Straight per-pixel transcription of the §2.7.1.2 inverse filter as
704    /// the round-291 `match (x, y)` / `match filtering` form read, kept
705    /// here as the byte-identity oracle for the round-293 border-rule
706    /// hoist. If the hoisted [`inverse_filter`] ever diverges from this
707    /// reference on any plane / method, the test below fails.
708    fn inverse_filter_reference(filtered: &[u8], w: usize, h: usize, f: AlphFiltering) -> Vec<u8> {
709        let mut out = vec![0u8; w * h];
710        let idx = |x: usize, y: usize| y * w + x;
711        for y in 0..h {
712            for x in 0..w {
713                let xv = filtered[idx(x, y)] as i32;
714                let predictor: i32 = match (x, y) {
715                    (0, 0) => 0,
716                    _ => match f {
717                        AlphFiltering::None => 0,
718                        AlphFiltering::Horizontal => {
719                            if x == 0 {
720                                out[idx(0, y - 1)] as i32
721                            } else {
722                                out[idx(x - 1, y)] as i32
723                            }
724                        }
725                        AlphFiltering::Vertical => {
726                            if y == 0 {
727                                out[idx(x - 1, 0)] as i32
728                            } else {
729                                out[idx(x, y - 1)] as i32
730                            }
731                        }
732                        AlphFiltering::Gradient => {
733                            if x == 0 {
734                                out[idx(0, y - 1)] as i32
735                            } else if y == 0 {
736                                out[idx(x - 1, 0)] as i32
737                            } else {
738                                let a = out[idx(x - 1, y)] as i32;
739                                let b = out[idx(x, y - 1)] as i32;
740                                let c = out[idx(x - 1, y - 1)] as i32;
741                                clip(a + b - c) as i32
742                            }
743                        }
744                    },
745                };
746                out[idx(x, y)] = ((predictor + xv) & 0xff) as u8;
747            }
748        }
749        out
750    }
751
752    #[test]
753    fn hoisted_inverse_filter_matches_per_pixel_reference_across_methods_and_dims() {
754        // Deterministic LCG residual so the predictor sees a spread of
755        // neighbour values across every dimension/method combination; the
756        // hoisted Stage-2 loop must equal the per-pixel reference exactly.
757        let mut state: u32 = 0x1234_5678;
758        let mut next = || {
759            state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
760            (state >> 24) as u8
761        };
762        for &(w, h) in &[
763            (1usize, 1usize),
764            (1, 7),
765            (7, 1),
766            (2, 2),
767            (3, 5),
768            (5, 3),
769            (16, 16),
770            (13, 17),
771            (128, 128),
772        ] {
773            let residual: Vec<u8> = (0..w * h).map(|_| next()).collect();
774            for f in [
775                AlphFiltering::None,
776                AlphFiltering::Horizontal,
777                AlphFiltering::Vertical,
778                AlphFiltering::Gradient,
779            ] {
780                let got = inverse_filter(residual.clone(), w, h, f);
781                let want = inverse_filter_reference(&residual, w, h, f);
782                assert_eq!(got, want, "mismatch at {w}x{h} method {f:?}");
783            }
784        }
785    }
786}