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}