Skip to main content

gamut_webp/
decoder.rs

1//! The public WebP decoder: parses the RIFF container and routes to the VP8/VP8L bitstream decoder.
2//!
3//! Container parsing and format routing are implemented (via [`gamut_riff`]). The lossless **VP8L**
4//! and lossy **VP8** bitstreams are decoded natively; an extended **VP8X** file is parsed and its
5//! inner bitstream decoded (its `ALPH` alpha chunk is applied in a later milestone).
6
7use gamut_color::Bt601Range;
8use gamut_core::{DecodeImage, Dimensions, Error, ImageBuf, Result, Rgb8, Rgba8};
9use gamut_riff::{RiffReader, WebpChunkId};
10
11use crate::alpha;
12use crate::vp8l::decoder::{argb_to_rgb8, argb_to_rgba8, decode as decode_vp8l};
13
14/// Decodes a WebP file to interleaved 8-bit RGB.
15///
16/// gamut ships its own decoder because every WebP decoder in the Rust ecosystem ultimately wraps
17/// libwebp; a `#![forbid(unsafe_code)]` decoder removes that crate's memory-unsafety exposure.
18#[derive(Debug, Clone, Default)]
19pub struct WebpDecoder {
20    /// Reserved for future decode options (e.g. ignoring alpha); keeps the type extensible.
21    _private: (),
22}
23
24impl WebpDecoder {
25    /// Creates a decoder with the default configuration.
26    #[must_use]
27    pub fn new() -> Self {
28        Self::default()
29    }
30
31    /// Decodes the WebP file in `data` to interleaved 8-bit RGB, appending the pixels to `out` and
32    /// returning the image [`Dimensions`]. Backs the [`DecodeImage<Rgb8>`] impl.
33    fn decode_rgb8_into(&self, data: &[u8], out: &mut Vec<u8>) -> Result<Dimensions> {
34        // Reconstruction chunks must precede metadata, so the first VP8/VP8L/VP8X chunk wins; any
35        // leading metadata/unknown chunks are skipped (RFC 9649 §2.7).
36        for chunk in RiffReader::new(data)? {
37            let chunk = chunk?;
38            match WebpChunkId::from(chunk.fourcc) {
39                WebpChunkId::Vp8l => {
40                    let (dims, argb) = decode_vp8l(chunk.payload)?;
41                    argb_to_rgb8(&argb, out);
42                    return Ok(dims);
43                }
44                WebpChunkId::Vp8 => {
45                    let recon = crate::vp8::frame::decode_frame(chunk.payload)?;
46                    let yuv = recon.to_yuv420();
47                    let dims = Dimensions {
48                        width: yuv.width(),
49                        height: yuv.height(),
50                    };
51                    // WebP/VP8 is limited-range BT.601; decode with the matching inverse.
52                    out.extend_from_slice(&yuv.to_rgb8(Bt601Range::Limited));
53                    return Ok(dims);
54                }
55                WebpChunkId::Vp8x => {
56                    // Validate the extended-format header, then fall through to the inner VP8/VP8L
57                    // bitstream chunk that follows. Alpha (the `ALPH` chunk gated by the VP8X alpha
58                    // flag) is decoded in a later milestone.
59                    gamut_riff::Vp8xHeader::from_payload(chunk.payload)?;
60                    continue;
61                }
62                _ => continue,
63            }
64        }
65        Err(Error::InvalidInput(
66            "WebP: no VP8/VP8L/VP8X bitstream chunk",
67        ))
68    }
69
70    /// Decodes the WebP file in `data` to interleaved 8-bit RGBA, appending the pixels to `out` and
71    /// returning the image [`Dimensions`]. A simple (alpha-less) file decodes to opaque RGBA; an
72    /// extended file's `ALPH` chunk supplies the alpha; a `VP8L` bitstream carries its own. Backs the
73    /// [`DecodeImage<Rgba8>`] impl.
74    fn decode_rgba8_into(&self, data: &[u8], out: &mut Vec<u8>) -> Result<Dimensions> {
75        let mut alph: Option<&[u8]> = None;
76        for chunk in RiffReader::new(data)? {
77            let chunk = chunk?;
78            match WebpChunkId::from(chunk.fourcc) {
79                WebpChunkId::Vp8x => {
80                    gamut_riff::Vp8xHeader::from_payload(chunk.payload)?;
81                }
82                WebpChunkId::Alpha => alph = Some(chunk.payload),
83                WebpChunkId::Vp8l => {
84                    let (dims, argb) = decode_vp8l(chunk.payload)?;
85                    argb_to_rgba8(&argb, out);
86                    return Ok(dims);
87                }
88                WebpChunkId::Vp8 => {
89                    let yuv = crate::vp8::frame::decode_frame(chunk.payload)?.to_yuv420();
90                    let dims = Dimensions {
91                        width: yuv.width(),
92                        height: yuv.height(),
93                    };
94                    let (w, h) = (dims.width as usize, dims.height as usize);
95                    let alpha = match alph {
96                        Some(payload) => alpha::read_alph(payload, w, h)?,
97                        None => vec![0xffu8; w * h],
98                    };
99                    let rgb = yuv.to_rgb8(Bt601Range::Limited);
100                    out.reserve(w * h * 4);
101                    for (px, &a) in rgb.chunks_exact(3).zip(alpha.iter()) {
102                        out.extend_from_slice(&[px[0], px[1], px[2], a]);
103                    }
104                    return Ok(dims);
105                }
106                _ => continue,
107            }
108        }
109        Err(Error::InvalidInput("WebP: no VP8/VP8L bitstream chunk"))
110    }
111}
112
113impl DecodeImage<Rgb8> for WebpDecoder {
114    fn decode_image(&self, data: &[u8]) -> Result<ImageBuf<Rgb8>> {
115        let mut px = Vec::new();
116        let dims = self.decode_rgb8_into(data, &mut px)?;
117        ImageBuf::new(px, dims)
118    }
119}
120
121impl DecodeImage<Rgba8> for WebpDecoder {
122    fn decode_image(&self, data: &[u8]) -> Result<ImageBuf<Rgba8>> {
123        let mut px = Vec::new();
124        let dims = self.decode_rgba8_into(data, &mut px)?;
125        ImageBuf::new(px, dims)
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132    use crate::vp8l::bit_io::BitWriter;
133    use crate::vp8l::header::Vp8lHeader;
134    use crate::vp8l::prefix::write_simple_prefix_code;
135    use gamut_riff::{FourCc, RiffWriter, write_simple_lossless, write_simple_lossy};
136
137    /// Builds a simple-lossless WebP file holding a solid-color `width`×`height` VP8L image.
138    fn solid_lossless_webp(width: u32, height: u32, r: u8, g: u8, b: u8) -> Vec<u8> {
139        let mut w = BitWriter::new();
140        Vp8lHeader::from_dimensions(Dimensions { width, height }, false)
141            .unwrap()
142            .write(&mut w);
143        w.write_bits(0, 1); // no transforms
144        w.write_bits(0, 1); // no color cache
145        w.write_bits(0, 1); // single meta prefix code
146        write_simple_prefix_code(&mut w, &[u16::from(g)]);
147        write_simple_prefix_code(&mut w, &[u16::from(r)]);
148        write_simple_prefix_code(&mut w, &[u16::from(b)]);
149        write_simple_prefix_code(&mut w, &[0xff]); // alpha (opaque)
150        write_simple_prefix_code(&mut w, &[0]); // distance (unused)
151        write_simple_lossless(&w.finish())
152    }
153
154    #[test]
155    fn decodes_lossless_container_to_rgb8() {
156        let file = solid_lossless_webp(2, 2, 0x12, 0x34, 0x56);
157        let got: ImageBuf<Rgb8> = WebpDecoder::new().decode_image(&file).unwrap();
158        assert_eq!(
159            got.dimensions(),
160            Dimensions {
161                width: 2,
162                height: 2
163            }
164        );
165        assert_eq!(got.as_samples(), [0x12, 0x34, 0x56].repeat(4).as_slice());
166    }
167
168    #[test]
169    fn routes_lossy_container_to_vp8() {
170        // A `VP8 ` chunk reaches the VP8 decoder, which rejects this malformed (non-key-frame, 3-byte)
171        // payload rather than panicking.
172        let file = write_simple_lossy(&[0x9d, 0x01, 0x2a]);
173        let got: Result<ImageBuf<Rgb8>> = WebpDecoder::new().decode_image(&file);
174        assert!(got.is_err());
175    }
176
177    #[test]
178    fn decodes_extended_container_with_inner_bitstream() {
179        use gamut_riff::{Vp8xHeader, write_extended};
180        // A VP8X feature header followed by a VP8L bitstream decodes to the inner image (the alpha
181        // flag's `ALPH` chunk is handled in a later milestone).
182        let inner = solid_lossless_webp(2, 2, 0x11, 0x22, 0x33);
183        let vp8l = RiffReader::new(&inner)
184            .unwrap()
185            .next()
186            .unwrap()
187            .unwrap()
188            .payload
189            .to_vec();
190        let header = Vp8xHeader {
191            canvas_width: 2,
192            canvas_height: 2,
193            ..Default::default()
194        };
195        let file = write_extended(&header, &[(FourCc::VP8L, &vp8l)]);
196        let got: ImageBuf<Rgb8> = WebpDecoder::new()
197            .decode_image(&file)
198            .expect("decode VP8X file");
199        assert_eq!(
200            got.dimensions(),
201            Dimensions {
202                width: 2,
203                height: 2
204            }
205        );
206        assert_eq!(got.as_samples(), [0x11, 0x22, 0x33].repeat(4).as_slice());
207    }
208
209    #[test]
210    fn rejects_extended_container_without_bitstream() {
211        // A VP8X header with no following bitstream chunk has nothing to decode.
212        let header = gamut_riff::Vp8xHeader {
213            canvas_width: 4,
214            canvas_height: 4,
215            ..Default::default()
216        };
217        let file = gamut_riff::write_extended(&header, &[]);
218        let got: Result<ImageBuf<Rgb8>> = WebpDecoder::new().decode_image(&file);
219        assert!(matches!(got, Err(Error::InvalidInput(_))));
220    }
221
222    #[test]
223    fn skips_leading_metadata_then_decodes_bitstream() {
224        // A leading metadata chunk must be skipped; the VP8L chunk that follows is decoded.
225        let vp8l = {
226            let full = solid_lossless_webp(1, 1, 9, 8, 7);
227            // Extract just the VP8L chunk payload from the simple-lossless file.
228            RiffReader::new(&full)
229                .unwrap()
230                .next()
231                .unwrap()
232                .unwrap()
233                .payload
234                .to_vec()
235        };
236        let mut w = RiffWriter::new();
237        w.write_chunk(FourCc::ICCP, &[1, 2, 3, 4]);
238        w.write_chunk(FourCc::VP8L, &vp8l);
239        let file = w.finish();
240        let got: ImageBuf<Rgb8> = WebpDecoder::new().decode_image(&file).unwrap();
241        assert_eq!(
242            got.dimensions(),
243            Dimensions {
244                width: 1,
245                height: 1
246            }
247        );
248        assert_eq!(got.as_samples(), [9, 8, 7].as_slice());
249    }
250
251    #[test]
252    fn errors_when_no_bitstream_chunk() {
253        let mut w = RiffWriter::new();
254        w.write_chunk(FourCc::EXIF, &[0xee; 6]);
255        let file = w.finish();
256        let err: Result<ImageBuf<Rgb8>> = WebpDecoder::new().decode_image(&file);
257        assert!(matches!(err, Err(Error::InvalidInput(_))));
258    }
259
260    #[test]
261    fn rejects_non_riff_data() {
262        let err: Result<ImageBuf<Rgb8>> = WebpDecoder::new().decode_image(b"not a webp");
263        assert!(matches!(err, Err(Error::InvalidInput(_))));
264    }
265}