Skip to main content

oximedia_codec/webp/
riff.rs

1//! WebP RIFF container parser and writer.
2//!
3//! WebP uses a RIFF-based container format. The file structure is:
4//! - `"RIFF"` (4 bytes) + file_size (4 bytes LE u32) + `"WEBP"` (4 bytes)
5//! - Then one or more chunks, each with:
6//!   - FourCC (4 bytes ASCII)
7//!   - Chunk size (4 bytes LE u32)
8//!   - Chunk data (size bytes, padded to even boundary)
9
10use crate::error::{CodecError, CodecResult};
11
12// ── Constants ──────────────────────────────────────────────────────────────────
13
14/// RIFF header magic bytes.
15const RIFF_MAGIC: &[u8; 4] = b"RIFF";
16/// WebP form type.
17const WEBP_MAGIC: &[u8; 4] = b"WEBP";
18/// RIFF header size (RIFF tag + file size + WEBP tag).
19const RIFF_HEADER_SIZE: usize = 12;
20/// Chunk header size (FourCC + chunk size).
21const CHUNK_HEADER_SIZE: usize = 8;
22/// VP8 sync code bytes.
23const VP8_SYNC_CODE: [u8; 3] = [0x9D, 0x01, 0x2A];
24/// VP8L signature byte.
25const VP8L_SIGNATURE: u8 = 0x2F;
26/// VP8X chunk data size (flags byte + 3 reserved + 3-byte width-1 + 3-byte height-1 = 10).
27const VP8X_CHUNK_DATA_SIZE: usize = 10;
28
29// ── FourCC constants ───────────────────────────────────────────────────────────
30
31const FOURCC_VP8: [u8; 4] = *b"VP8 ";
32const FOURCC_VP8L: [u8; 4] = *b"VP8L";
33const FOURCC_VP8X: [u8; 4] = *b"VP8X";
34const FOURCC_ALPH: [u8; 4] = *b"ALPH";
35const FOURCC_ANIM: [u8; 4] = *b"ANIM";
36const FOURCC_ANMF: [u8; 4] = *b"ANMF";
37const FOURCC_ICCP: [u8; 4] = *b"ICCP";
38const FOURCC_EXIF: [u8; 4] = *b"EXIF";
39const FOURCC_XMP: [u8; 4] = *b"XMP ";
40
41// ── VP8X feature flag bits ─────────────────────────────────────────────────────
42
43const VP8X_FLAG_ANIMATION: u8 = 1 << 1;
44const VP8X_FLAG_XMP: u8 = 1 << 2;
45const VP8X_FLAG_EXIF: u8 = 1 << 3;
46const VP8X_FLAG_ALPHA: u8 = 1 << 4;
47const VP8X_FLAG_ICC: u8 = 1 << 5;
48
49// ── Chunk Type ─────────────────────────────────────────────────────────────────
50
51/// WebP chunk types as defined by the RIFF-based WebP specification.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum ChunkType {
54    /// `"VP8 "` — Lossy bitstream (VP8 format).
55    Vp8,
56    /// `"VP8L"` — Lossless bitstream (VP8L format).
57    Vp8L,
58    /// `"VP8X"` — Extended format header (feature flags + canvas size).
59    Vp8X,
60    /// `"ALPH"` — Alpha channel data.
61    Alph,
62    /// `"ANIM"` — Animation parameters (background color, loop count).
63    Anim,
64    /// `"ANMF"` — Animation frame.
65    Anmf,
66    /// `"ICCP"` — ICC color profile.
67    Iccp,
68    /// `"EXIF"` — EXIF metadata.
69    Exif,
70    /// `"XMP "` — XMP metadata.
71    Xmp,
72    /// Any other FourCC not recognized by this parser.
73    Unknown([u8; 4]),
74}
75
76impl ChunkType {
77    /// Create a `ChunkType` from a 4-byte FourCC.
78    fn from_fourcc(fourcc: [u8; 4]) -> Self {
79        match fourcc {
80            FOURCC_VP8 => ChunkType::Vp8,
81            FOURCC_VP8L => ChunkType::Vp8L,
82            FOURCC_VP8X => ChunkType::Vp8X,
83            FOURCC_ALPH => ChunkType::Alph,
84            FOURCC_ANIM => ChunkType::Anim,
85            FOURCC_ANMF => ChunkType::Anmf,
86            FOURCC_ICCP => ChunkType::Iccp,
87            FOURCC_EXIF => ChunkType::Exif,
88            FOURCC_XMP => ChunkType::Xmp,
89            other => ChunkType::Unknown(other),
90        }
91    }
92
93    /// Convert this chunk type back to its 4-byte FourCC.
94    fn to_fourcc(self) -> [u8; 4] {
95        match self {
96            ChunkType::Vp8 => FOURCC_VP8,
97            ChunkType::Vp8L => FOURCC_VP8L,
98            ChunkType::Vp8X => FOURCC_VP8X,
99            ChunkType::Alph => FOURCC_ALPH,
100            ChunkType::Anim => FOURCC_ANIM,
101            ChunkType::Anmf => FOURCC_ANMF,
102            ChunkType::Iccp => FOURCC_ICCP,
103            ChunkType::Exif => FOURCC_EXIF,
104            ChunkType::Xmp => FOURCC_XMP,
105            ChunkType::Unknown(cc) => cc,
106        }
107    }
108}
109
110impl std::fmt::Display for ChunkType {
111    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
112        let fourcc = self.to_fourcc();
113        let s = String::from_utf8_lossy(&fourcc);
114        write!(f, "{s}")
115    }
116}
117
118// ── RiffChunk ──────────────────────────────────────────────────────────────────
119
120/// A parsed RIFF chunk with its type tag and raw payload data.
121#[derive(Debug, Clone)]
122pub struct RiffChunk {
123    /// The chunk type identified by its FourCC tag.
124    pub chunk_type: ChunkType,
125    /// The raw payload bytes of the chunk (excluding padding).
126    pub data: Vec<u8>,
127}
128
129// ── WebPEncoding ───────────────────────────────────────────────────────────────
130
131/// WebP container encoding type, determined by the first chunk.
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133pub enum WebPEncoding {
134    /// Simple lossy (first chunk is `VP8 `).
135    Lossy,
136    /// Simple lossless (first chunk is `VP8L`).
137    Lossless,
138    /// Extended format (first chunk is `VP8X`).
139    Extended,
140}
141
142// ── VP8X Features ──────────────────────────────────────────────────────────────
143
144/// Feature flags and canvas size parsed from a VP8X chunk.
145#[derive(Debug, Clone, Copy, Default)]
146pub struct Vp8xFeatures {
147    /// Whether animation chunks may follow.
148    pub has_animation: bool,
149    /// Whether the file contains XMP metadata.
150    pub has_xmp: bool,
151    /// Whether the file contains EXIF metadata.
152    pub has_exif: bool,
153    /// Whether the file contains alpha channel data.
154    pub has_alpha: bool,
155    /// Whether the file contains an ICC color profile.
156    pub has_icc: bool,
157    /// Canvas width in pixels (1-based; stored as width-1 in VP8X).
158    pub canvas_width: u32,
159    /// Canvas height in pixels (1-based; stored as height-1 in VP8X).
160    pub canvas_height: u32,
161}
162
163impl Vp8xFeatures {
164    /// Parse VP8X features from the raw chunk data (expected 10 bytes).
165    fn parse(data: &[u8]) -> CodecResult<Self> {
166        if data.len() < VP8X_CHUNK_DATA_SIZE {
167            return Err(CodecError::InvalidBitstream(format!(
168                "VP8X chunk too small: expected at least {VP8X_CHUNK_DATA_SIZE} bytes, got {}",
169                data.len()
170            )));
171        }
172
173        let flags = data[0];
174
175        // Canvas width-1 is stored as a 24-bit LE value at bytes 4..7
176        let canvas_width =
177            u32::from(data[4]) | (u32::from(data[5]) << 8) | (u32::from(data[6]) << 16);
178        let canvas_width = canvas_width + 1;
179
180        // Canvas height-1 is stored as a 24-bit LE value at bytes 7..10
181        let canvas_height =
182            u32::from(data[7]) | (u32::from(data[8]) << 8) | (u32::from(data[9]) << 16);
183        let canvas_height = canvas_height + 1;
184
185        Ok(Self {
186            has_animation: (flags & VP8X_FLAG_ANIMATION) != 0,
187            has_xmp: (flags & VP8X_FLAG_XMP) != 0,
188            has_exif: (flags & VP8X_FLAG_EXIF) != 0,
189            has_alpha: (flags & VP8X_FLAG_ALPHA) != 0,
190            has_icc: (flags & VP8X_FLAG_ICC) != 0,
191            canvas_width,
192            canvas_height,
193        })
194    }
195
196    /// Encode VP8X features into a 10-byte chunk payload.
197    fn encode(&self) -> [u8; VP8X_CHUNK_DATA_SIZE] {
198        let mut buf = [0u8; VP8X_CHUNK_DATA_SIZE];
199
200        let mut flags: u8 = 0;
201        if self.has_animation {
202            flags |= VP8X_FLAG_ANIMATION;
203        }
204        if self.has_xmp {
205            flags |= VP8X_FLAG_XMP;
206        }
207        if self.has_exif {
208            flags |= VP8X_FLAG_EXIF;
209        }
210        if self.has_alpha {
211            flags |= VP8X_FLAG_ALPHA;
212        }
213        if self.has_icc {
214            flags |= VP8X_FLAG_ICC;
215        }
216        buf[0] = flags;
217        // bytes 1..4 are reserved (zero)
218
219        let w = self.canvas_width.saturating_sub(1);
220        buf[4] = (w & 0xFF) as u8;
221        buf[5] = ((w >> 8) & 0xFF) as u8;
222        buf[6] = ((w >> 16) & 0xFF) as u8;
223
224        let h = self.canvas_height.saturating_sub(1);
225        buf[7] = (h & 0xFF) as u8;
226        buf[8] = ((h >> 8) & 0xFF) as u8;
227        buf[9] = ((h >> 16) & 0xFF) as u8;
228
229        buf
230    }
231}
232
233// ── WebPContainer ──────────────────────────────────────────────────────────────
234
235/// A fully parsed WebP RIFF container.
236#[derive(Debug, Clone)]
237pub struct WebPContainer {
238    /// The encoding type (lossy, lossless, or extended).
239    pub encoding: WebPEncoding,
240    /// VP8X feature flags, present only for extended format.
241    pub features: Option<Vp8xFeatures>,
242    /// All chunks in the container, in order.
243    pub chunks: Vec<RiffChunk>,
244}
245
246impl WebPContainer {
247    /// Parse a WebP file from a byte slice.
248    ///
249    /// Validates the RIFF header and iterates through all chunks.
250    pub fn parse(data: &[u8]) -> CodecResult<Self> {
251        if data.len() < RIFF_HEADER_SIZE {
252            return Err(CodecError::InvalidBitstream(
253                "Data too small for RIFF header".into(),
254            ));
255        }
256
257        // Validate RIFF magic
258        if &data[0..4] != RIFF_MAGIC {
259            return Err(CodecError::InvalidBitstream(
260                "Missing RIFF magic bytes".into(),
261            ));
262        }
263
264        // Read declared file size (bytes after the initial 8 bytes)
265        let file_size = read_u32_le(&data[4..8]);
266        let declared_total = file_size as usize + 8; // +8 for RIFF tag + size field
267
268        // Validate WEBP form type
269        if &data[8..12] != WEBP_MAGIC {
270            return Err(CodecError::InvalidBitstream(
271                "Missing WEBP form type".into(),
272            ));
273        }
274
275        // The actual data we can parse (don't read past the buffer)
276        let payload_end = declared_total.min(data.len());
277
278        // Parse chunks
279        let mut offset = RIFF_HEADER_SIZE;
280        let mut chunks = Vec::new();
281
282        while offset + CHUNK_HEADER_SIZE <= payload_end {
283            let mut fourcc = [0u8; 4];
284            fourcc.copy_from_slice(&data[offset..offset + 4]);
285            let chunk_size = read_u32_le(&data[offset + 4..offset + 8]) as usize;
286            offset += CHUNK_HEADER_SIZE;
287
288            // Guard against truncated data
289            if offset + chunk_size > payload_end {
290                return Err(CodecError::InvalidBitstream(format!(
291                    "Chunk '{}' at offset {} declares size {} but only {} bytes remain",
292                    String::from_utf8_lossy(&fourcc),
293                    offset - CHUNK_HEADER_SIZE,
294                    chunk_size,
295                    payload_end.saturating_sub(offset),
296                )));
297            }
298
299            let chunk_data = data[offset..offset + chunk_size].to_vec();
300            chunks.push(RiffChunk {
301                chunk_type: ChunkType::from_fourcc(fourcc),
302                data: chunk_data,
303            });
304
305            // Advance past chunk data, with even-byte padding
306            offset += chunk_size;
307            if chunk_size % 2 != 0 {
308                offset += 1;
309            }
310        }
311
312        if chunks.is_empty() {
313            return Err(CodecError::InvalidBitstream(
314                "No chunks found in WebP container".into(),
315            ));
316        }
317
318        // Determine encoding type from the first chunk
319        let encoding = match chunks[0].chunk_type {
320            ChunkType::Vp8 => WebPEncoding::Lossy,
321            ChunkType::Vp8L => WebPEncoding::Lossless,
322            ChunkType::Vp8X => WebPEncoding::Extended,
323            other => {
324                return Err(CodecError::InvalidBitstream(format!(
325                    "Unexpected first chunk type: {other}"
326                )));
327            }
328        };
329
330        // Parse VP8X features if present
331        let features = if encoding == WebPEncoding::Extended {
332            Some(Vp8xFeatures::parse(&chunks[0].data)?)
333        } else {
334            None
335        };
336
337        Ok(Self {
338            encoding,
339            features,
340            chunks,
341        })
342    }
343
344    /// Find the VP8 or VP8L bitstream chunk.
345    ///
346    /// For simple files, this is the first (and only) chunk.
347    /// For extended files, this searches for the first VP8/VP8L chunk.
348    pub fn bitstream_chunk(&self) -> Option<&RiffChunk> {
349        self.chunks
350            .iter()
351            .find(|c| c.chunk_type == ChunkType::Vp8 || c.chunk_type == ChunkType::Vp8L)
352    }
353
354    /// Find the alpha chunk (`ALPH`), if present.
355    pub fn alpha_chunk(&self) -> Option<&RiffChunk> {
356        self.chunks.iter().find(|c| c.chunk_type == ChunkType::Alph)
357    }
358
359    /// Find the ICC profile chunk, if present.
360    pub fn icc_chunk(&self) -> Option<&RiffChunk> {
361        self.chunks.iter().find(|c| c.chunk_type == ChunkType::Iccp)
362    }
363
364    /// Find the EXIF metadata chunk, if present.
365    pub fn exif_chunk(&self) -> Option<&RiffChunk> {
366        self.chunks.iter().find(|c| c.chunk_type == ChunkType::Exif)
367    }
368
369    /// Find the XMP metadata chunk, if present.
370    pub fn xmp_chunk(&self) -> Option<&RiffChunk> {
371        self.chunks.iter().find(|c| c.chunk_type == ChunkType::Xmp)
372    }
373
374    /// Find the animation parameters chunk, if present.
375    pub fn anim_chunk(&self) -> Option<&RiffChunk> {
376        self.chunks.iter().find(|c| c.chunk_type == ChunkType::Anim)
377    }
378
379    /// Collect all animation frame chunks.
380    pub fn animation_frames(&self) -> Vec<&RiffChunk> {
381        self.chunks
382            .iter()
383            .filter(|c| c.chunk_type == ChunkType::Anmf)
384            .collect()
385    }
386
387    /// Get canvas dimensions.
388    ///
389    /// For VP8X extended format, uses the canvas size from the header.
390    /// For simple lossy, parses the VP8 frame header.
391    /// For simple lossless, parses the VP8L signature header.
392    pub fn dimensions(&self) -> CodecResult<(u32, u32)> {
393        // VP8X canvas size takes priority
394        if let Some(ref features) = self.features {
395            return Ok((features.canvas_width, features.canvas_height));
396        }
397
398        // Otherwise parse from the bitstream chunk
399        let bs = self
400            .bitstream_chunk()
401            .ok_or_else(|| CodecError::InvalidBitstream("No bitstream chunk found".into()))?;
402
403        match bs.chunk_type {
404            ChunkType::Vp8 => parse_vp8_dimensions(&bs.data),
405            ChunkType::Vp8L => parse_vp8l_dimensions(&bs.data),
406            _ => Err(CodecError::InvalidBitstream(
407                "Bitstream chunk is neither VP8 nor VP8L".into(),
408            )),
409        }
410    }
411}
412
413// ── VP8 / VP8L dimension parsing ───────────────────────────────────────────────
414
415/// Parse width and height from a VP8 lossy bitstream header.
416///
417/// VP8 frame header layout:
418/// - Bytes 0-2: frame tag (3 bytes)
419/// - Bytes 3-5: sync code 0x9D 0x01 0x2A
420/// - Bytes 6-7: width (LE u16, lower 14 bits = width, upper 2 = horizontal scale)
421/// - Bytes 8-9: height (LE u16, lower 14 bits = height, upper 2 = vertical scale)
422fn parse_vp8_dimensions(data: &[u8]) -> CodecResult<(u32, u32)> {
423    if data.len() < 10 {
424        return Err(CodecError::InvalidBitstream(
425            "VP8 bitstream too small for frame header".into(),
426        ));
427    }
428
429    // Validate sync code at bytes 3..6
430    if data[3] != VP8_SYNC_CODE[0] || data[4] != VP8_SYNC_CODE[1] || data[5] != VP8_SYNC_CODE[2] {
431        return Err(CodecError::InvalidBitstream(
432            "VP8 sync code not found (expected 0x9D 0x01 0x2A)".into(),
433        ));
434    }
435
436    let raw_width = u16::from_le_bytes([data[6], data[7]]);
437    let raw_height = u16::from_le_bytes([data[8], data[9]]);
438
439    // Lower 14 bits are the actual dimension
440    let width = u32::from(raw_width & 0x3FFF);
441    let height = u32::from(raw_height & 0x3FFF);
442
443    if width == 0 || height == 0 {
444        return Err(CodecError::InvalidBitstream(
445            "VP8 dimensions cannot be zero".into(),
446        ));
447    }
448
449    Ok((width, height))
450}
451
452/// Parse width and height from a VP8L lossless bitstream header.
453///
454/// VP8L header layout:
455/// - Byte 0: signature 0x2F
456/// - Bytes 1-4: 32 bits containing:
457///   - bits 0..13  (14 bits): width - 1
458///   - bits 14..27 (14 bits): height - 1
459///   - bit 28:     alpha_is_used
460///   - bits 29..31 (3 bits): version (must be 0)
461fn parse_vp8l_dimensions(data: &[u8]) -> CodecResult<(u32, u32)> {
462    if data.len() < 5 {
463        return Err(CodecError::InvalidBitstream(
464            "VP8L bitstream too small for header".into(),
465        ));
466    }
467
468    if data[0] != VP8L_SIGNATURE {
469        return Err(CodecError::InvalidBitstream(format!(
470            "VP8L signature mismatch: expected 0x{VP8L_SIGNATURE:02X}, got 0x{:02X}",
471            data[0]
472        )));
473    }
474
475    let bits = u32::from_le_bytes([data[1], data[2], data[3], data[4]]);
476
477    let width = (bits & 0x3FFF) + 1; // 14 bits
478    let height = ((bits >> 14) & 0x3FFF) + 1; // 14 bits
479
480    if width == 0 || height == 0 {
481        return Err(CodecError::InvalidBitstream(
482            "VP8L dimensions cannot be zero".into(),
483        ));
484    }
485
486    Ok((width, height))
487}
488
489// ── WebPWriter ─────────────────────────────────────────────────────────────────
490
491/// Writer for constructing WebP RIFF containers from encoded bitstream data.
492pub struct WebPWriter;
493
494impl WebPWriter {
495    /// Write a simple lossy WebP file (RIFF header + VP8 chunk).
496    pub fn write_lossy(vp8_data: &[u8]) -> Vec<u8> {
497        Self::write_single_chunk(&FOURCC_VP8, vp8_data)
498    }
499
500    /// Write a simple lossless WebP file (RIFF header + VP8L chunk).
501    pub fn write_lossless(vp8l_data: &[u8]) -> Vec<u8> {
502        Self::write_single_chunk(&FOURCC_VP8L, vp8l_data)
503    }
504
505    /// Write an extended WebP file with optional alpha.
506    ///
507    /// Produces: RIFF header, VP8X chunk, optional ALPH chunk, VP8 chunk.
508    pub fn write_extended(
509        vp8_data: &[u8],
510        alpha_data: Option<&[u8]>,
511        width: u32,
512        height: u32,
513    ) -> Vec<u8> {
514        let features = Vp8xFeatures {
515            has_alpha: alpha_data.is_some(),
516            canvas_width: width,
517            canvas_height: height,
518            ..Vp8xFeatures::default()
519        };
520
521        let vp8x_payload = features.encode();
522
523        // Calculate total file size
524        let mut body_size: usize = 4; // "WEBP" form type
525        body_size += chunk_wire_size(&vp8x_payload);
526        if let Some(alpha) = alpha_data {
527            body_size += chunk_wire_size(alpha);
528        }
529        body_size += chunk_wire_size(vp8_data);
530
531        let mut buf = Vec::with_capacity(8 + body_size);
532
533        // RIFF header
534        buf.extend_from_slice(RIFF_MAGIC);
535        buf.extend_from_slice(&(body_size as u32).to_le_bytes());
536        buf.extend_from_slice(WEBP_MAGIC);
537
538        // VP8X chunk
539        write_chunk(&mut buf, &FOURCC_VP8X, &vp8x_payload);
540
541        // Optional ALPH chunk
542        if let Some(alpha) = alpha_data {
543            write_chunk(&mut buf, &FOURCC_ALPH, alpha);
544        }
545
546        // VP8 bitstream chunk
547        write_chunk(&mut buf, &FOURCC_VP8, vp8_data);
548
549        buf
550    }
551
552    /// Write an extended WebP file from a list of chunks.
553    ///
554    /// The caller is responsible for providing a valid VP8X chunk as the first entry.
555    pub fn write_chunks(chunks: &[RiffChunk]) -> Vec<u8> {
556        let mut body_size: usize = 4; // "WEBP"
557        for chunk in chunks {
558            body_size += chunk_wire_size(&chunk.data);
559        }
560
561        let mut buf = Vec::with_capacity(8 + body_size);
562        buf.extend_from_slice(RIFF_MAGIC);
563        buf.extend_from_slice(&(body_size as u32).to_le_bytes());
564        buf.extend_from_slice(WEBP_MAGIC);
565
566        for chunk in chunks {
567            let fourcc = chunk.chunk_type.to_fourcc();
568            write_chunk(&mut buf, &fourcc, &chunk.data);
569        }
570
571        buf
572    }
573
574    /// Internal: write a single-chunk WebP file.
575    fn write_single_chunk(fourcc: &[u8; 4], payload: &[u8]) -> Vec<u8> {
576        let body_size = 4 + chunk_wire_size(payload); // "WEBP" + chunk
577        let mut buf = Vec::with_capacity(8 + body_size);
578
579        buf.extend_from_slice(RIFF_MAGIC);
580        buf.extend_from_slice(&(body_size as u32).to_le_bytes());
581        buf.extend_from_slice(WEBP_MAGIC);
582
583        write_chunk(&mut buf, fourcc, payload);
584        buf
585    }
586}
587
588// ── Helpers ────────────────────────────────────────────────────────────────────
589
590/// Read a little-endian u32 from a 4-byte slice.
591fn read_u32_le(data: &[u8]) -> u32 {
592    let mut buf = [0u8; 4];
593    buf.copy_from_slice(&data[..4]);
594    u32::from_le_bytes(buf)
595}
596
597/// Calculate the wire size of a single chunk (header + payload + padding).
598fn chunk_wire_size(payload: &[u8]) -> usize {
599    let padded = if payload.len() % 2 != 0 {
600        payload.len() + 1
601    } else {
602        payload.len()
603    };
604    CHUNK_HEADER_SIZE + padded
605}
606
607/// Write a chunk (FourCC + LE size + data + optional pad byte) to `buf`.
608fn write_chunk(buf: &mut Vec<u8>, fourcc: &[u8; 4], data: &[u8]) {
609    buf.extend_from_slice(fourcc);
610    buf.extend_from_slice(&(data.len() as u32).to_le_bytes());
611    buf.extend_from_slice(data);
612    if data.len() % 2 != 0 {
613        buf.push(0); // pad to even boundary
614    }
615}
616
617// ── Tests ──────────────────────────────────────────────────────────────────────
618
619#[cfg(test)]
620mod tests {
621    use super::*;
622
623    // ── Helpers ────────────────────────────────────────────────────────
624
625    /// Build a minimal valid VP8 bitstream header with the given dimensions.
626    fn make_vp8_header(width: u16, height: u16) -> Vec<u8> {
627        let mut data = vec![0u8; 10];
628        // Frame tag (3 bytes, keyframe)
629        data[0] = 0x00;
630        data[1] = 0x00;
631        data[2] = 0x00;
632        // Sync code
633        data[3] = 0x9D;
634        data[4] = 0x01;
635        data[5] = 0x2A;
636        // Width (LE, lower 14 bits)
637        let w_bytes = width.to_le_bytes();
638        data[6] = w_bytes[0];
639        data[7] = w_bytes[1];
640        // Height (LE, lower 14 bits)
641        let h_bytes = height.to_le_bytes();
642        data[8] = h_bytes[0];
643        data[9] = h_bytes[1];
644        data
645    }
646
647    /// Build a minimal valid VP8L bitstream header with the given dimensions.
648    fn make_vp8l_header(width: u32, height: u32) -> Vec<u8> {
649        let mut data = vec![0u8; 5];
650        data[0] = VP8L_SIGNATURE;
651        let w_minus_1 = (width - 1) & 0x3FFF;
652        let h_minus_1 = (height - 1) & 0x3FFF;
653        let bits: u32 = w_minus_1 | (h_minus_1 << 14);
654        let b = bits.to_le_bytes();
655        data[1] = b[0];
656        data[2] = b[1];
657        data[3] = b[2];
658        data[4] = b[3];
659        data
660    }
661
662    /// Build a simple lossy WebP file from raw VP8 data.
663    fn make_simple_lossy(width: u16, height: u16) -> Vec<u8> {
664        let vp8 = make_vp8_header(width, height);
665        WebPWriter::write_lossy(&vp8)
666    }
667
668    /// Build a simple lossless WebP file from raw VP8L data.
669    fn make_simple_lossless(width: u32, height: u32) -> Vec<u8> {
670        let vp8l = make_vp8l_header(width, height);
671        WebPWriter::write_lossless(&vp8l)
672    }
673
674    // ── ChunkType ──────────────────────────────────────────────────────
675
676    #[test]
677    fn test_chunk_type_roundtrip() {
678        let types = [
679            ChunkType::Vp8,
680            ChunkType::Vp8L,
681            ChunkType::Vp8X,
682            ChunkType::Alph,
683            ChunkType::Anim,
684            ChunkType::Anmf,
685            ChunkType::Iccp,
686            ChunkType::Exif,
687            ChunkType::Xmp,
688            ChunkType::Unknown(*b"TEST"),
689        ];
690
691        for ct in &types {
692            let fourcc = ct.to_fourcc();
693            let recovered = ChunkType::from_fourcc(fourcc);
694            assert_eq!(*ct, recovered);
695        }
696    }
697
698    #[test]
699    fn test_chunk_type_display() {
700        assert_eq!(ChunkType::Vp8.to_string(), "VP8 ");
701        assert_eq!(ChunkType::Vp8L.to_string(), "VP8L");
702        assert_eq!(ChunkType::Xmp.to_string(), "XMP ");
703        assert_eq!(ChunkType::Unknown(*b"TSET").to_string(), "TSET");
704    }
705
706    // ── VP8X Features ──────────────────────────────────────────────────
707
708    #[test]
709    fn test_vp8x_features_parse_all_flags() {
710        let mut data = [0u8; 10];
711        data[0] =
712            VP8X_FLAG_ANIMATION | VP8X_FLAG_XMP | VP8X_FLAG_EXIF | VP8X_FLAG_ALPHA | VP8X_FLAG_ICC;
713        // canvas width-1 = 1919 (0x077F) => width 1920
714        data[4] = 0x7F;
715        data[5] = 0x07;
716        data[6] = 0x00;
717        // canvas height-1 = 1079 (0x0437) => height 1080
718        data[7] = 0x37;
719        data[8] = 0x04;
720        data[9] = 0x00;
721
722        let feat = Vp8xFeatures::parse(&data).expect("should parse");
723        assert!(feat.has_animation);
724        assert!(feat.has_xmp);
725        assert!(feat.has_exif);
726        assert!(feat.has_alpha);
727        assert!(feat.has_icc);
728        assert_eq!(feat.canvas_width, 1920);
729        assert_eq!(feat.canvas_height, 1080);
730    }
731
732    #[test]
733    fn test_vp8x_features_parse_no_flags() {
734        let data = [0u8; 10];
735        let feat = Vp8xFeatures::parse(&data).expect("should parse");
736        assert!(!feat.has_animation);
737        assert!(!feat.has_xmp);
738        assert!(!feat.has_exif);
739        assert!(!feat.has_alpha);
740        assert!(!feat.has_icc);
741        assert_eq!(feat.canvas_width, 1);
742        assert_eq!(feat.canvas_height, 1);
743    }
744
745    #[test]
746    fn test_vp8x_features_roundtrip() {
747        let original = Vp8xFeatures {
748            has_animation: true,
749            has_xmp: false,
750            has_exif: true,
751            has_alpha: true,
752            has_icc: false,
753            canvas_width: 3840,
754            canvas_height: 2160,
755        };
756
757        let encoded = original.encode();
758        let decoded = Vp8xFeatures::parse(&encoded).expect("should parse");
759
760        assert_eq!(original.has_animation, decoded.has_animation);
761        assert_eq!(original.has_xmp, decoded.has_xmp);
762        assert_eq!(original.has_exif, decoded.has_exif);
763        assert_eq!(original.has_alpha, decoded.has_alpha);
764        assert_eq!(original.has_icc, decoded.has_icc);
765        assert_eq!(original.canvas_width, decoded.canvas_width);
766        assert_eq!(original.canvas_height, decoded.canvas_height);
767    }
768
769    #[test]
770    fn test_vp8x_features_parse_too_small() {
771        let data = [0u8; 5];
772        let result = Vp8xFeatures::parse(&data);
773        assert!(result.is_err());
774    }
775
776    #[test]
777    fn test_vp8x_max_canvas_size() {
778        // 24-bit max = 16_777_215, stored as width-1 => width = 16_777_216
779        let feat = Vp8xFeatures {
780            canvas_width: 16_777_216,
781            canvas_height: 16_777_216,
782            ..Vp8xFeatures::default()
783        };
784        let encoded = feat.encode();
785        let decoded = Vp8xFeatures::parse(&encoded).expect("should parse");
786        assert_eq!(decoded.canvas_width, 16_777_216);
787        assert_eq!(decoded.canvas_height, 16_777_216);
788    }
789
790    // ── VP8 Dimensions ─────────────────────────────────────────────────
791
792    #[test]
793    fn test_vp8_dimensions_basic() {
794        let data = make_vp8_header(640, 480);
795        let (w, h) = parse_vp8_dimensions(&data).expect("should parse");
796        assert_eq!(w, 640);
797        assert_eq!(h, 480);
798    }
799
800    #[test]
801    fn test_vp8_dimensions_with_scale_bits() {
802        // Set scale bits in upper 2 bits of the 16-bit values
803        let mut data = make_vp8_header(320, 240);
804        // Add horizontal scale = 1 (bits 14-15)
805        data[7] |= 0x40; // set bit 14
806                         // Add vertical scale = 2 (bits 14-15)
807        data[9] |= 0x80; // set bit 15
808
809        let (w, h) = parse_vp8_dimensions(&data).expect("should parse");
810        // Width/height should ignore scale bits (only lower 14)
811        assert_eq!(w, 320);
812        assert_eq!(h, 240);
813    }
814
815    #[test]
816    fn test_vp8_dimensions_too_small() {
817        let data = [0u8; 5];
818        assert!(parse_vp8_dimensions(&data).is_err());
819    }
820
821    #[test]
822    fn test_vp8_dimensions_bad_sync() {
823        let mut data = make_vp8_header(100, 100);
824        data[3] = 0x00; // corrupt sync code
825        assert!(parse_vp8_dimensions(&data).is_err());
826    }
827
828    #[test]
829    fn test_vp8_dimensions_zero_width() {
830        let mut data = make_vp8_header(0, 100);
831        // Width 0 in lower 14 bits
832        data[6] = 0;
833        data[7] = 0;
834        assert!(parse_vp8_dimensions(&data).is_err());
835    }
836
837    // ── VP8L Dimensions ────────────────────────────────────────────────
838
839    #[test]
840    fn test_vp8l_dimensions_basic() {
841        let data = make_vp8l_header(800, 600);
842        let (w, h) = parse_vp8l_dimensions(&data).expect("should parse");
843        assert_eq!(w, 800);
844        assert_eq!(h, 600);
845    }
846
847    #[test]
848    fn test_vp8l_dimensions_one_pixel() {
849        let data = make_vp8l_header(1, 1);
850        let (w, h) = parse_vp8l_dimensions(&data).expect("should parse");
851        assert_eq!(w, 1);
852        assert_eq!(h, 1);
853    }
854
855    #[test]
856    fn test_vp8l_dimensions_max_14bit() {
857        // Max 14-bit value: 16383 (0x3FFF) + 1 = 16384
858        let data = make_vp8l_header(16384, 16384);
859        let (w, h) = parse_vp8l_dimensions(&data).expect("should parse");
860        assert_eq!(w, 16384);
861        assert_eq!(h, 16384);
862    }
863
864    #[test]
865    fn test_vp8l_dimensions_too_small() {
866        let data = [VP8L_SIGNATURE, 0, 0];
867        assert!(parse_vp8l_dimensions(&data).is_err());
868    }
869
870    #[test]
871    fn test_vp8l_dimensions_bad_signature() {
872        let mut data = make_vp8l_header(100, 100);
873        data[0] = 0xFF;
874        assert!(parse_vp8l_dimensions(&data).is_err());
875    }
876
877    // ── WebPContainer::parse ───────────────────────────────────────────
878
879    #[test]
880    fn test_parse_simple_lossy() {
881        let webp = make_simple_lossy(320, 240);
882        let container = WebPContainer::parse(&webp).expect("should parse");
883
884        assert_eq!(container.encoding, WebPEncoding::Lossy);
885        assert!(container.features.is_none());
886        assert_eq!(container.chunks.len(), 1);
887        assert_eq!(container.chunks[0].chunk_type, ChunkType::Vp8);
888
889        let (w, h) = container.dimensions().expect("should get dimensions");
890        assert_eq!(w, 320);
891        assert_eq!(h, 240);
892    }
893
894    #[test]
895    fn test_parse_simple_lossless() {
896        let webp = make_simple_lossless(1024, 768);
897        let container = WebPContainer::parse(&webp).expect("should parse");
898
899        assert_eq!(container.encoding, WebPEncoding::Lossless);
900        assert!(container.features.is_none());
901        assert_eq!(container.chunks.len(), 1);
902        assert_eq!(container.chunks[0].chunk_type, ChunkType::Vp8L);
903
904        let (w, h) = container.dimensions().expect("should get dimensions");
905        assert_eq!(w, 1024);
906        assert_eq!(h, 768);
907    }
908
909    #[test]
910    fn test_parse_extended_with_alpha() {
911        let vp8 = make_vp8_header(640, 480);
912        let alpha = vec![0xAA; 100];
913        let webp = WebPWriter::write_extended(&vp8, Some(&alpha), 640, 480);
914        let container = WebPContainer::parse(&webp).expect("should parse");
915
916        assert_eq!(container.encoding, WebPEncoding::Extended);
917        let features = container.features.expect("should have features");
918        assert!(features.has_alpha);
919        assert!(!features.has_animation);
920        assert_eq!(features.canvas_width, 640);
921        assert_eq!(features.canvas_height, 480);
922
923        assert_eq!(container.chunks.len(), 3); // VP8X, ALPH, VP8
924        assert!(container.alpha_chunk().is_some());
925        assert_eq!(container.alpha_chunk().map(|c| c.data.len()), Some(100));
926
927        let bs = container.bitstream_chunk().expect("should have bitstream");
928        assert_eq!(bs.chunk_type, ChunkType::Vp8);
929    }
930
931    #[test]
932    fn test_parse_extended_no_alpha() {
933        let vp8 = make_vp8_header(1920, 1080);
934        let webp = WebPWriter::write_extended(&vp8, None, 1920, 1080);
935        let container = WebPContainer::parse(&webp).expect("should parse");
936
937        assert_eq!(container.encoding, WebPEncoding::Extended);
938        let features = container.features.expect("should have features");
939        assert!(!features.has_alpha);
940        assert_eq!(features.canvas_width, 1920);
941        assert_eq!(features.canvas_height, 1080);
942
943        assert_eq!(container.chunks.len(), 2); // VP8X, VP8
944        assert!(container.alpha_chunk().is_none());
945    }
946
947    #[test]
948    fn test_parse_too_small() {
949        let data = [0u8; 8];
950        assert!(WebPContainer::parse(&data).is_err());
951    }
952
953    #[test]
954    fn test_parse_bad_riff_magic() {
955        let mut webp = make_simple_lossy(10, 10);
956        webp[0] = b'X';
957        assert!(WebPContainer::parse(&webp).is_err());
958    }
959
960    #[test]
961    fn test_parse_bad_webp_magic() {
962        let mut webp = make_simple_lossy(10, 10);
963        webp[8] = b'X';
964        assert!(WebPContainer::parse(&webp).is_err());
965    }
966
967    #[test]
968    fn test_parse_empty_payload() {
969        // Valid RIFF/WEBP header but no chunks
970        let mut data = Vec::new();
971        data.extend_from_slice(RIFF_MAGIC);
972        data.extend_from_slice(&4u32.to_le_bytes()); // file size = 4 (just "WEBP")
973        data.extend_from_slice(WEBP_MAGIC);
974        assert!(WebPContainer::parse(&data).is_err());
975    }
976
977    // ── WebPWriter ─────────────────────────────────────────────────────
978
979    #[test]
980    fn test_write_lossy_roundtrip() {
981        let vp8 = make_vp8_header(256, 256);
982        let webp = WebPWriter::write_lossy(&vp8);
983        let container = WebPContainer::parse(&webp).expect("should parse");
984
985        assert_eq!(container.encoding, WebPEncoding::Lossy);
986        let bs = container.bitstream_chunk().expect("bitstream");
987        assert_eq!(bs.data, vp8);
988    }
989
990    #[test]
991    fn test_write_lossless_roundtrip() {
992        let vp8l = make_vp8l_header(512, 512);
993        let webp = WebPWriter::write_lossless(&vp8l);
994        let container = WebPContainer::parse(&webp).expect("should parse");
995
996        assert_eq!(container.encoding, WebPEncoding::Lossless);
997        let bs = container.bitstream_chunk().expect("bitstream");
998        assert_eq!(bs.data, vp8l);
999    }
1000
1001    #[test]
1002    fn test_write_extended_roundtrip() {
1003        let vp8 = make_vp8_header(1280, 720);
1004        let alpha = vec![0xFF; 50];
1005        let webp = WebPWriter::write_extended(&vp8, Some(&alpha), 1280, 720);
1006        let container = WebPContainer::parse(&webp).expect("should parse");
1007
1008        assert_eq!(container.encoding, WebPEncoding::Extended);
1009        let feat = container.features.expect("features");
1010        assert!(feat.has_alpha);
1011        assert_eq!(feat.canvas_width, 1280);
1012        assert_eq!(feat.canvas_height, 720);
1013
1014        let bs = container.bitstream_chunk().expect("bitstream");
1015        assert_eq!(bs.data, vp8);
1016
1017        let alph = container.alpha_chunk().expect("alpha");
1018        assert_eq!(alph.data, alpha);
1019    }
1020
1021    #[test]
1022    fn test_write_odd_sized_payload_padding() {
1023        // Odd-length payload should be padded to even boundary
1024        let vp8 = vec![
1025            0x9D, 0x01, 0x2A, 0x9D, 0x01, 0x2A, 0x01, 0x00, 0x01, 0x00, 0xAB,
1026        ];
1027        // 11 bytes = odd, should be padded
1028        let webp = WebPWriter::write_lossy(&vp8);
1029
1030        // Total: 12 (header) + 8 (chunk header) + 11 (data) + 1 (pad) = 32
1031        assert_eq!(webp.len(), 32);
1032
1033        // Verify we can parse it back
1034        // (The VP8 header in this data has correct sync code at the right offset)
1035        let container = WebPContainer::parse(&webp).expect("should parse padded");
1036        let bs = container.bitstream_chunk().expect("bitstream");
1037        assert_eq!(bs.data, vp8);
1038    }
1039
1040    #[test]
1041    fn test_write_chunks_custom() {
1042        let chunks = vec![
1043            RiffChunk {
1044                chunk_type: ChunkType::Vp8X,
1045                data: Vp8xFeatures {
1046                    has_icc: true,
1047                    canvas_width: 100,
1048                    canvas_height: 100,
1049                    ..Vp8xFeatures::default()
1050                }
1051                .encode()
1052                .to_vec(),
1053            },
1054            RiffChunk {
1055                chunk_type: ChunkType::Iccp,
1056                data: vec![0x01, 0x02, 0x03],
1057            },
1058            RiffChunk {
1059                chunk_type: ChunkType::Vp8,
1060                data: make_vp8_header(100, 100),
1061            },
1062        ];
1063
1064        let webp = WebPWriter::write_chunks(&chunks);
1065        let container = WebPContainer::parse(&webp).expect("should parse");
1066
1067        assert_eq!(container.encoding, WebPEncoding::Extended);
1068        assert_eq!(container.chunks.len(), 3);
1069        let feat = container.features.expect("features");
1070        assert!(feat.has_icc);
1071        assert_eq!(feat.canvas_width, 100);
1072        assert_eq!(feat.canvas_height, 100);
1073
1074        let icc = container.icc_chunk().expect("icc");
1075        assert_eq!(icc.data, vec![0x01, 0x02, 0x03]);
1076    }
1077
1078    // ── Accessor methods ───────────────────────────────────────────────
1079
1080    #[test]
1081    fn test_accessor_methods_none() {
1082        let webp = make_simple_lossy(10, 10);
1083        let container = WebPContainer::parse(&webp).expect("should parse");
1084        assert!(container.alpha_chunk().is_none());
1085        assert!(container.icc_chunk().is_none());
1086        assert!(container.exif_chunk().is_none());
1087        assert!(container.xmp_chunk().is_none());
1088        assert!(container.anim_chunk().is_none());
1089        assert!(container.animation_frames().is_empty());
1090    }
1091
1092    #[test]
1093    fn test_dimensions_from_vp8x() {
1094        let vp8 = make_vp8_header(100, 100);
1095        // VP8X says 640x480, VP8 header says 100x100 — VP8X takes priority
1096        let webp = WebPWriter::write_extended(&vp8, None, 640, 480);
1097        let container = WebPContainer::parse(&webp).expect("should parse");
1098        let (w, h) = container.dimensions().expect("dimensions");
1099        assert_eq!(w, 640);
1100        assert_eq!(h, 480);
1101    }
1102
1103    #[test]
1104    fn test_dimensions_from_vp8_bitstream() {
1105        let webp = make_simple_lossy(1920, 1080);
1106        let container = WebPContainer::parse(&webp).expect("should parse");
1107        let (w, h) = container.dimensions().expect("dimensions");
1108        assert_eq!(w, 1920);
1109        assert_eq!(h, 1080);
1110    }
1111
1112    #[test]
1113    fn test_dimensions_from_vp8l_bitstream() {
1114        let webp = make_simple_lossless(4096, 2048);
1115        let container = WebPContainer::parse(&webp).expect("should parse");
1116        let (w, h) = container.dimensions().expect("dimensions");
1117        assert_eq!(w, 4096);
1118        assert_eq!(h, 2048);
1119    }
1120
1121    // ── Edge cases ─────────────────────────────────────────────────────
1122
1123    #[test]
1124    fn test_unknown_chunk_type_preserved() {
1125        let chunks = vec![RiffChunk {
1126            chunk_type: ChunkType::Vp8,
1127            data: make_vp8_header(10, 10),
1128        }];
1129        let mut webp = WebPWriter::write_chunks(&chunks);
1130
1131        // Manually append an unknown chunk "ZZZZ" with 4 bytes of data
1132        // But we need to fix the RIFF file size first
1133        let old_file_size = read_u32_le(&webp[4..8]);
1134        let extra_chunk_size: u32 = 8 + 4; // header + data
1135        let new_file_size = old_file_size + extra_chunk_size;
1136        webp[4..8].copy_from_slice(&new_file_size.to_le_bytes());
1137        webp.extend_from_slice(b"ZZZZ");
1138        webp.extend_from_slice(&4u32.to_le_bytes());
1139        webp.extend_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF]);
1140
1141        let container = WebPContainer::parse(&webp).expect("should parse");
1142        assert_eq!(container.chunks.len(), 2);
1143        assert_eq!(container.chunks[1].chunk_type, ChunkType::Unknown(*b"ZZZZ"));
1144        assert_eq!(container.chunks[1].data, vec![0xDE, 0xAD, 0xBE, 0xEF]);
1145    }
1146
1147    #[test]
1148    fn test_truncated_chunk_error() {
1149        let mut webp = make_simple_lossy(10, 10);
1150        // Corrupt: set chunk size to be larger than actual data
1151        let chunk_size_offset = RIFF_HEADER_SIZE + 4;
1152        webp[chunk_size_offset..chunk_size_offset + 4].copy_from_slice(&9999u32.to_le_bytes());
1153        assert!(WebPContainer::parse(&webp).is_err());
1154    }
1155
1156    #[test]
1157    fn test_multiple_chunks_with_metadata() {
1158        let vp8x_data = Vp8xFeatures {
1159            has_exif: true,
1160            has_xmp: true,
1161            canvas_width: 200,
1162            canvas_height: 150,
1163            ..Vp8xFeatures::default()
1164        }
1165        .encode();
1166
1167        let chunks = vec![
1168            RiffChunk {
1169                chunk_type: ChunkType::Vp8X,
1170                data: vp8x_data.to_vec(),
1171            },
1172            RiffChunk {
1173                chunk_type: ChunkType::Exif,
1174                data: vec![0x45, 0x78, 0x69, 0x66], // "Exif"
1175            },
1176            RiffChunk {
1177                chunk_type: ChunkType::Xmp,
1178                data: b"<x:xmpmeta>test</x:xmpmeta>".to_vec(),
1179            },
1180            RiffChunk {
1181                chunk_type: ChunkType::Vp8,
1182                data: make_vp8_header(200, 150),
1183            },
1184        ];
1185
1186        let webp = WebPWriter::write_chunks(&chunks);
1187        let container = WebPContainer::parse(&webp).expect("should parse");
1188
1189        assert_eq!(container.encoding, WebPEncoding::Extended);
1190        assert_eq!(container.chunks.len(), 4);
1191
1192        let feat = container.features.expect("features");
1193        assert!(feat.has_exif);
1194        assert!(feat.has_xmp);
1195
1196        let exif = container.exif_chunk().expect("exif");
1197        assert_eq!(exif.data, vec![0x45, 0x78, 0x69, 0x66]);
1198
1199        let xmp = container.xmp_chunk().expect("xmp");
1200        assert_eq!(xmp.data, b"<x:xmpmeta>test</x:xmpmeta>");
1201
1202        let (w, h) = container.dimensions().expect("dimensions");
1203        assert_eq!(w, 200);
1204        assert_eq!(h, 150);
1205    }
1206
1207    #[test]
1208    fn test_even_payload_no_padding() {
1209        // Even-length payload should NOT have padding byte
1210        let vp8 = make_vp8_header(10, 10); // 10 bytes = even
1211        let webp = WebPWriter::write_lossy(&vp8);
1212        // Total: 12 (header) + 8 (chunk header) + 10 (data) = 30
1213        assert_eq!(webp.len(), 30);
1214    }
1215
1216    #[test]
1217    fn test_file_size_field_accuracy() {
1218        let vp8 = make_vp8_header(10, 10);
1219        let webp = WebPWriter::write_lossy(&vp8);
1220        let declared = read_u32_le(&webp[4..8]) as usize;
1221        // declared = total - 8 (RIFF + size field)
1222        assert_eq!(declared + 8, webp.len());
1223    }
1224
1225    #[test]
1226    fn test_extended_file_size_field_accuracy() {
1227        let vp8 = make_vp8_header(100, 100);
1228        let alpha = vec![0x42; 7]; // odd length
1229        let webp = WebPWriter::write_extended(&vp8, Some(&alpha), 100, 100);
1230        let declared = read_u32_le(&webp[4..8]) as usize;
1231        assert_eq!(declared + 8, webp.len());
1232    }
1233}