Skip to main content

oxideav_webp/
build.rs

1//! RIFF/WEBP container *builder* helpers per RFC 9649 §2.3–§2.7.
2//!
3//! Where [`crate::container`] **walks** an existing `RIFF/WEBP` byte
4//! stream into a typed [`crate::container::WebpContainer`], this module
5//! is its inverse: it **assembles** a well-formed `RIFF/WEBP` byte
6//! stream from a single bitstream payload.
7//!
8//! The round-5 surface is deliberately minimal — just enough container
9//! plumbing for an external encoder (e.g. the workspace's `cli-convert`
10//! tool) to bolt a `VP8 ` / `VP8L` / `VP8X`-extended file header around
11//! payload bytes it computed elsewhere. The actual `VP8 ` / `VP8L`
12//! bitstream encoders are *not* in this crate yet; the builders here
13//! treat the codec payload as opaque bytes.
14//!
15//! ## What lives in this module
16//!
17//! * [`build_chunk`] — the §2.3 generic chunk writer: `FourCC` + 4-byte
18//!   little-endian `Size` + payload bytes + (if `Size` is odd) a single
19//!   `0x00` pad byte. The §2.4 file-header writer and the §2.7.1 `VP8X`
20//!   payload writer are both expressed in terms of this primitive.
21//!
22//! * [`build_vp8x_chunk`] — the §2.7.1 Figure 7 typed writer:
23//!   `flags(1) + Reserved(3) + (canvas_width-1)(3 LE) + (canvas_height-1)(3 LE)`.
24//!   Returns the **payload** only (10 bytes); wrap with [`build_chunk`]
25//!   passing [`crate::container::fourcc::VP8X`] for the on-disk chunk.
26//!
27//! * [`build_webp_file`] — the §2.4 file writer for a *simple* layout
28//!   (lossy / lossless / extended-with-VP8X-only). Given a single
29//!   codec payload + image kind + canvas dimensions it produces:
30//!
31//!     ```text
32//!     RIFF | <File Size LE u32> | WEBP | <chunk> ...
33//!     ```
34//!
35//!   For [`ImageKind::Lossy`] / [`ImageKind::Lossless`] the body is the
36//!   single `VP8 ` / `VP8L` chunk per §2.5 / §2.6. For
37//!   [`ImageKind::ExtendedLossy`] / [`ImageKind::ExtendedLossless`]
38//!   the body is a §2.7.1 `VP8X` chunk followed by the `VP8 ` / `VP8L`
39//!   chunk per §2.7's chunk-ordering rule.
40//!
41//! ## What is intentionally *not* here
42//!
43//! * No `ANIM` / `ANMF` / `ALPH` writers — the round-5 still-image fast
44//!   path plus the §2.7 metadata-wrapper writer ([`build_webp_file_with_metadata`])
45//!   cover ICCP / EXIF / XMP. Animation chunks live in [`crate::anim_encode`]
46//!   because they need additional global parameters (`ANIM` background +
47//!   loop count, per-frame `ANMF` placement).
48//! * No payload validation. `build_webp_file` does not parse the bytes
49//!   the caller hands it as `payload`. A nonsense payload still
50//!   produces a structurally-valid RIFF — the responsibility for the
51//!   payload's correctness sits with whoever computed it.
52//! * No registry dependency. Every public function in this module
53//!   compiles cleanly under `--no-default-features` (no `oxideav-core`
54//!   in the dependency tree) because the builders are plain
55//!   byte-pushing functions over `std::vec::Vec<u8>`.
56//!
57//! ## §2.4 `File Size` rule
58//!
59//! RFC 9649 §2.4: "The file size in the header is the total size of
60//! the chunks that follow plus 4 bytes for the `WEBP` FourCC." So if
61//! the body (concatenated chunks, each already including its own §2.3
62//! pad byte) is `N` bytes long, the `File Size` field is `N + 4`. The
63//! `RIFF` FourCC and the `File Size` field itself are *not* counted.
64//!
65//! ## Round-trip guarantee
66//!
67//! Every byte stream produced by [`build_webp_file`] / [`build_chunk`]
68//! parses successfully through [`crate::container::parse`] and
69//! [`crate::parse_vp8x_header`]. The
70//! [`crate::container::WebpChunk::payload`] of the resulting chunks
71//! equals the input payload byte-for-byte (the §2.3 pad byte is added
72//! to the chunk *stream* on disk but is not counted in `Size` and is
73//! therefore not included in the `payload` slice). This is enforced
74//! by the round-trip tests at the bottom of this module.
75
76use crate::container::{fourcc, FourCc};
77
78/// Maximum 1-based canvas dimension representable in the §2.7.1 24-bit
79/// `Minus One` field — `2^24` (because the on-disk value is `dim - 1`
80/// and the field is 24 bits wide, the largest representable dim is
81/// `0x00FF_FFFF + 1 = 0x0100_0000`).
82pub const MAX_VP8X_CANVAS_DIM: u32 = 0x0100_0000;
83
84/// Maximum payload bytes a single §2.3 chunk can carry. The `Size`
85/// field is a `uint32`; a chunk whose payload is exactly this size
86/// fills the field; an odd value of this size would also require a
87/// `0x00` pad byte. We additionally subtract 1 to leave room for that
88/// pad byte without overflowing `u32` when callers compute total chunk
89/// sizes downstream. (Practical WebP files are nowhere near this.)
90pub const MAX_CHUNK_PAYLOAD: u32 = u32::MAX - 1;
91
92/// Which §2.4 / §2.5 / §2.6 / §2.7 file layout [`build_webp_file`]
93/// should emit around the caller's payload.
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95pub enum ImageKind {
96    /// §2.5 simple-lossy layout: just a `VP8 ` chunk after the §2.4
97    /// file header. No `VP8X`. The `width`/`height` arguments to
98    /// [`build_webp_file`] are *ignored* — the canvas dimensions in a
99    /// simple-lossy WebP are derived by the decoder from the `VP8 `
100    /// bitstream itself per §2.5's "VP8 frame header contains the VP8
101    /// frame width and height" note.
102    Lossy,
103    /// §2.6 simple-lossless layout: just a `VP8L` chunk after the
104    /// §2.4 file header. No `VP8X`. Same note re. canvas dimensions:
105    /// the `width`/`height` arguments are *ignored* because the `VP8L`
106    /// bitstream carries its own image-width / image-height fields
107    /// per §2.6.
108    Lossless,
109    /// §2.7 extended layout: `VP8X` + `VP8 `. Use this when the file
110    /// needs a `VP8X`-declared canvas (e.g. ahead of future ALPH /
111    /// metadata / animation extensions, or to declare a canvas size
112    /// that disagrees with the `VP8 ` bitstream's intrinsic
113    /// dimensions). Round-5 emits **no** feature flags in the VP8X
114    /// byte; downstream rounds can add an enum variant with feature
115    /// flags once `ALPH` / animation writers exist.
116    ExtendedLossy,
117    /// §2.7 extended layout: `VP8X` + `VP8L`. Symmetric with
118    /// [`ImageKind::ExtendedLossy`] but the bitstream chunk is `VP8L`.
119    ExtendedLossless,
120}
121
122impl ImageKind {
123    /// FourCC of the single bitstream chunk this kind wraps the
124    /// payload in.
125    pub fn bitstream_fourcc(self) -> FourCc {
126        match self {
127            Self::Lossy | Self::ExtendedLossy => fourcc::VP8,
128            Self::Lossless | Self::ExtendedLossless => fourcc::VP8L,
129        }
130    }
131
132    /// True if this kind emits a `VP8X` chunk ahead of the bitstream
133    /// per §2.7.
134    pub fn is_extended(self) -> bool {
135        matches!(self, Self::ExtendedLossy | Self::ExtendedLossless)
136    }
137}
138
139/// Errors raised by the §2.3–§2.7 builder helpers. Builders refuse
140/// inputs that would produce a file the corresponding parser would
141/// reject — the symmetry is deliberate so round-trips can't be
142/// constructed-but-unparseable.
143#[derive(Debug, Clone, PartialEq, Eq)]
144pub enum BuildError {
145    /// A §2.7.1 canvas dimension is zero. The on-disk field is
146    /// `dim - 1`, so a 0 dimension would underflow.
147    CanvasDimZero {
148        /// Which dimension was zero — `"width"` or `"height"`.
149        which: &'static str,
150    },
151    /// A §2.7.1 canvas dimension exceeds the 24-bit `Minus One` field's
152    /// maximum representable value (`2^24`).
153    CanvasDimTooLarge {
154        /// Which dimension overflowed.
155        which: &'static str,
156        /// The offending 1-based dimension.
157        got: u32,
158    },
159    /// The product `canvas_width * canvas_height` exceeds the §2.7.1
160    /// `2^32 - 1` cap.
161    CanvasTooLarge {
162        /// 1-based canvas width.
163        canvas_width: u32,
164        /// 1-based canvas height.
165        canvas_height: u32,
166    },
167    /// The caller-supplied payload is larger than a §2.3 chunk's
168    /// `Size` field (a `uint32`) can address.
169    PayloadTooLargeForChunk {
170        /// Payload length the caller passed in.
171        got: usize,
172    },
173}
174
175impl core::fmt::Display for BuildError {
176    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
177        match self {
178            Self::CanvasDimZero { which } => {
179                write!(f, "§2.7.1 VP8X canvas {which} must be ≥ 1 (got 0)")
180            }
181            Self::CanvasDimTooLarge { which, got } => write!(
182                f,
183                "§2.7.1 VP8X canvas {which} {got} exceeds the 24-bit Minus-One field's 2^24 cap",
184            ),
185            Self::CanvasTooLarge {
186                canvas_width,
187                canvas_height,
188            } => write!(
189                f,
190                "§2.7.1 VP8X canvas {canvas_width}x{canvas_height} \
191                 exceeds the 2^32 - 1 product cap",
192            ),
193            Self::PayloadTooLargeForChunk { got } => write!(
194                f,
195                "§2.3 chunk payload of {got} bytes exceeds the uint32 Size field's range",
196            ),
197        }
198    }
199}
200
201impl std::error::Error for BuildError {}
202
203/// Emit a §2.3 RIFF chunk: 4-byte FourCC + 4-byte little-endian
204/// `Size` + payload + (if `Size` is odd) one `0x00` pad byte.
205///
206/// The pad byte is **not** counted in `Size` per §2.3. Callers don't
207/// need to think about it — this writer adds it when (and only when)
208/// the payload length is odd.
209///
210/// The returned `Vec<u8>` is exactly `8 + payload.len() + (payload.len() & 1)`
211/// bytes long.
212pub fn build_chunk(fourcc: FourCc, payload: &[u8]) -> Result<Vec<u8>, BuildError> {
213    if payload.len() as u64 > MAX_CHUNK_PAYLOAD as u64 {
214        return Err(BuildError::PayloadTooLargeForChunk { got: payload.len() });
215    }
216    let size = payload.len() as u32;
217    let needs_pad = (size & 1) == 1;
218    let total = 8 + payload.len() + if needs_pad { 1 } else { 0 };
219    let mut out = Vec::with_capacity(total);
220    out.extend_from_slice(&fourcc);
221    out.extend_from_slice(&size.to_le_bytes());
222    out.extend_from_slice(payload);
223    if needs_pad {
224        out.push(0);
225    }
226    Ok(out)
227}
228
229/// Emit the 10-byte §2.7.1 Figure 7 `VP8X` chunk **payload**
230/// (i.e. *not* including the 8-byte chunk header that
231/// [`build_chunk`] would prepend).
232///
233/// Layout (matches [`crate::vp8x::Vp8xHeader::parse`]'s inverse):
234///
235/// | offset | width | field                                |
236/// |--------|-------|--------------------------------------|
237/// | 0      | 1 B   | flags byte `Rsv\|I\|L\|E\|X\|A\|R`     |
238/// | 1..4   | 3 B   | reserved 24-bit field (zero-filled)  |
239/// | 4..7   | 3 B   | `(canvas_width - 1)`  uint24 LE      |
240/// | 7..10  | 3 B   | `(canvas_height - 1)` uint24 LE      |
241///
242/// Where the `flags` byte is built from the named feature flags using
243/// the same bit-position table as the parser:
244///
245/// | feature flag      | bit (LSB=0) |
246/// |-------------------|-------------|
247/// | `has_iccp` (`I`)  | 5           |
248/// | `has_alpha` (`L`) | 4           |
249/// | `has_exif` (`E`)  | 3           |
250/// | `has_xmp` (`X`)   | 2           |
251/// | `has_animation` (`A`) | 1       |
252///
253/// The two `Rsv` bits (7..6), the trailing `R` bit (0), and the 24-bit
254/// reserved field at bytes 1..4 are zero-filled — §2.7.1 requires
255/// reserved positions to be 0 on write even though readers MUST
256/// ignore non-zero values.
257///
258/// Returns the 10-byte payload; callers wrap it via
259/// `build_chunk(fourcc::VP8X, &payload)` to obtain the on-disk chunk.
260pub fn build_vp8x_chunk(
261    canvas_width: u32,
262    canvas_height: u32,
263    flags: Vp8xFlags,
264) -> Result<Vec<u8>, BuildError> {
265    if canvas_width == 0 {
266        return Err(BuildError::CanvasDimZero { which: "width" });
267    }
268    if canvas_height == 0 {
269        return Err(BuildError::CanvasDimZero { which: "height" });
270    }
271    if canvas_width > MAX_VP8X_CANVAS_DIM {
272        return Err(BuildError::CanvasDimTooLarge {
273            which: "width",
274            got: canvas_width,
275        });
276    }
277    if canvas_height > MAX_VP8X_CANVAS_DIM {
278        return Err(BuildError::CanvasDimTooLarge {
279            which: "height",
280            got: canvas_height,
281        });
282    }
283    if (canvas_width as u64) * (canvas_height as u64) > u64::from(u32::MAX) {
284        return Err(BuildError::CanvasTooLarge {
285            canvas_width,
286            canvas_height,
287        });
288    }
289
290    let cwm1 = canvas_width - 1;
291    let chm1 = canvas_height - 1;
292
293    // §2.7.1 byte 0 bit positions (LSB=0): I=5, L=4, E=3, X=2, A=1.
294    let mut flag_byte: u8 = 0;
295    if flags.has_iccp {
296        flag_byte |= 1 << 5;
297    }
298    if flags.has_alpha {
299        flag_byte |= 1 << 4;
300    }
301    if flags.has_exif {
302        flag_byte |= 1 << 3;
303    }
304    if flags.has_xmp {
305        flag_byte |= 1 << 2;
306    }
307    if flags.has_animation {
308        flag_byte |= 1 << 1;
309    }
310
311    let mut payload = Vec::with_capacity(10);
312    payload.push(flag_byte);
313    // §2.7.1 24-bit Reserved field — MUST be 0 on write.
314    payload.extend_from_slice(&[0u8, 0u8, 0u8]);
315    // §2.7.1 Canvas Width Minus One — 24-bit little-endian.
316    payload.push((cwm1 & 0xFF) as u8);
317    payload.push(((cwm1 >> 8) & 0xFF) as u8);
318    payload.push(((cwm1 >> 16) & 0xFF) as u8);
319    // §2.7.1 Canvas Height Minus One — 24-bit little-endian.
320    payload.push((chm1 & 0xFF) as u8);
321    payload.push(((chm1 >> 8) & 0xFF) as u8);
322    payload.push(((chm1 >> 16) & 0xFF) as u8);
323    Ok(payload)
324}
325
326/// Feature flags for the §2.7.1 `VP8X` flag octet.
327///
328/// `Default` is all-zero — i.e. a `VP8X` chunk that declares no
329/// optional features. That matches the round-5 fast path: the
330/// builder emits a `VP8X` only to express a canvas size that the
331/// `VP8 ` / `VP8L` bitstream wouldn't otherwise carry.
332///
333/// Once `ALPH` / `ANIM` / `ICCP` / `EXIF` / `XMP ` writers land in
334/// later rounds, those writers will set the corresponding flag here
335/// so the §2.7.1 declaration matches the chunks the builder actually
336/// emitted.
337#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
338pub struct Vp8xFlags {
339    /// §2.7.1 `I` bit — an `ICCP` chunk follows.
340    pub has_iccp: bool,
341    /// §2.7.1 `L` bit — any frame contains alpha.
342    pub has_alpha: bool,
343    /// §2.7.1 `E` bit — an `EXIF` chunk follows.
344    pub has_exif: bool,
345    /// §2.7.1 `X` bit — an `XMP ` chunk follows.
346    pub has_xmp: bool,
347    /// §2.7.1 `A` bit — this is an animated image.
348    pub has_animation: bool,
349}
350
351/// Build a `RIFF/WEBP` file around a single bitstream payload per
352/// RFC 9649 §2.4 + §2.5 / §2.6 / §2.7.
353///
354/// Arguments:
355///
356/// * `payload` — the opaque `VP8 ` or `VP8L` bitstream bytes. The
357///   builder copies these into the output without inspecting them.
358/// * `image_kind` — which file layout to emit; see [`ImageKind`]. The
359///   selection determines the bitstream FourCC (`VP8 ` for
360///   `Lossy` / `ExtendedLossy`, `VP8L` for the lossless pair) and
361///   whether a §2.7.1 `VP8X` chunk is emitted ahead of the bitstream.
362/// * `canvas_width`, `canvas_height` — 1-based pixel dimensions, used
363///   only for [`ImageKind::ExtendedLossy`] / [`ImageKind::ExtendedLossless`].
364///   For [`ImageKind::Lossy`] / [`ImageKind::Lossless`] the canvas
365///   dimensions are encoded in the bitstream's own frame header per
366///   §2.5 / §2.6 and the arguments here are *ignored* (the builder
367///   does not validate them against the bitstream).
368///
369/// Returns the complete on-disk byte stream, including the 12-byte
370/// §2.4 file header and any §2.3 pad bytes the chunks needed.
371///
372/// ```text
373/// [ 'RIFF' | <File Size LE u32> | 'WEBP' | <VP8X chunk?> | <VP8 / VP8L chunk> ]
374/// ```
375///
376/// §2.4 `File Size` = `4` (the 'WEBP' FourCC) `+ body.len()`, where
377/// `body` is the concatenation of every chunk after 'WEBP' (each
378/// chunk already includes its own §2.3 pad byte where required). The
379/// `RIFF` FourCC and the `File Size` field itself are not counted.
380pub fn build_webp_file(
381    payload: &[u8],
382    image_kind: ImageKind,
383    canvas_width: u32,
384    canvas_height: u32,
385) -> Result<Vec<u8>, BuildError> {
386    if payload.len() as u64 > MAX_CHUNK_PAYLOAD as u64 {
387        return Err(BuildError::PayloadTooLargeForChunk { got: payload.len() });
388    }
389
390    let bitstream_chunk = build_chunk(image_kind.bitstream_fourcc(), payload)?;
391
392    let body = if image_kind.is_extended() {
393        let vp8x_payload = build_vp8x_chunk(canvas_width, canvas_height, Vp8xFlags::default())?;
394        let vp8x_chunk = build_chunk(fourcc::VP8X, &vp8x_payload)?;
395        let mut b = Vec::with_capacity(vp8x_chunk.len() + bitstream_chunk.len());
396        b.extend_from_slice(&vp8x_chunk);
397        b.extend_from_slice(&bitstream_chunk);
398        b
399    } else {
400        bitstream_chunk
401    };
402
403    // §2.4: File Size = 4 ('WEBP' FourCC) + body length.
404    let file_size = (body.len() as u64) + 4;
405    // The §2.4 File Size field is uint32 with a documented maximum of
406    // 2^32 - 10, so a body up to ~4 GiB - 14 fits. We bound by u32::MAX
407    // here for the cast; in practice MAX_CHUNK_PAYLOAD already caps
408    // each chunk far below that.
409    if file_size > u64::from(u32::MAX) {
410        return Err(BuildError::PayloadTooLargeForChunk { got: payload.len() });
411    }
412    let file_size = file_size as u32;
413
414    let mut out = Vec::with_capacity(12 + body.len());
415    out.extend_from_slice(&fourcc::RIFF);
416    out.extend_from_slice(&file_size.to_le_bytes());
417    out.extend_from_slice(&fourcc::WEBP);
418    out.extend_from_slice(&body);
419    Ok(out)
420}
421
422/// Borrowed §2.7 file-level metadata payloads for the metadata-aware
423/// container writer [`build_webp_file_with_metadata`].
424///
425/// Each field is the raw payload bytes of the corresponding §2.7.1.4 /
426/// §2.7.1.5 chunk, or `None` to omit the chunk entirely. The writer
427/// derives the §2.7.1 `VP8X` flag bits from which fields are `Some`
428/// (`I` for `iccp`, `E` for `exif`, `X` for `xmp`) so the declared
429/// feature set always matches the chunks actually emitted.
430///
431/// The `Default` impl is all-`None` — equivalent to a non-metadata
432/// extended-layout file. A caller with no metadata to embed and no
433/// alpha to declare should use [`build_webp_file`] directly instead.
434#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
435pub struct FileMetadata<'a> {
436    /// §2.7.1.4 `ICCP` ICC color-profile payload to embed, or `None` to
437    /// omit the chunk. The writer sets the §2.7.1 `I` flag iff
438    /// `Some(_)`.
439    pub iccp: Option<&'a [u8]>,
440    /// §2.7.1.5 `EXIF` Exif payload to embed, or `None` to omit the
441    /// chunk. The writer sets the §2.7.1 `E` flag iff `Some(_)`.
442    pub exif: Option<&'a [u8]>,
443    /// §2.7.1.5 `XMP ` XMP payload to embed, or `None` to omit the
444    /// chunk. The writer sets the §2.7.1 `X` flag iff `Some(_)`.
445    pub xmp: Option<&'a [u8]>,
446}
447
448impl FileMetadata<'_> {
449    /// `true` when every metadata field is `None`.
450    pub fn is_empty(&self) -> bool {
451        self.iccp.is_none() && self.exif.is_none() && self.xmp.is_none()
452    }
453}
454
455/// Build a `RIFF/WEBP` file in the §2.7 *extended* layout, wrapping a
456/// single bitstream payload with a §2.7.1 `VP8X` chunk + optional
457/// §2.7.1.4 `ICCP` / §2.7.1.5 `EXIF` / §2.7.1.5 `XMP ` metadata chunks.
458///
459/// The on-disk chunk order is the §2.7 canonical order:
460///
461/// ```text
462/// RIFF | <File Size LE u32> | WEBP | VP8X | ICCP? | <VP8 | VP8L> | EXIF? | XMP ?
463/// ```
464///
465/// (Each `?` chunk is emitted only when the corresponding
466/// [`FileMetadata`] field is `Some`.)
467///
468/// Arguments:
469///
470/// * `payload` — the opaque `VP8 ` or `VP8L` bitstream bytes.
471/// * `image_kind` — selects the bitstream chunk's FourCC; the *simple*
472///   variants ([`ImageKind::Lossy`] / [`ImageKind::Lossless`]) and the
473///   *extended* variants ([`ImageKind::ExtendedLossy`] /
474///   [`ImageKind::ExtendedLossless`]) both produce the same extended
475///   on-disk layout here — this writer *always* emits a `VP8X`
476///   ahead of the bitstream because metadata chunks require an
477///   `Extended File Format` per §2.7.
478/// * `canvas_width`, `canvas_height` — 1-based pixel dimensions written
479///   into the §2.7.1 canvas-minus-one fields (subject to the §2.7.1
480///   range / product caps enforced by [`build_vp8x_chunk`]).
481/// * `has_alpha` — value of the §2.7.1 `L` ("Alpha") flag. `true`
482///   when any frame contains transparency; carried verbatim into the
483///   `VP8X` flag byte. (Whether the bitstream payload itself carries
484///   alpha is the caller's responsibility — this writer treats the
485///   payload as opaque bytes.)
486/// * `metadata` — see [`FileMetadata`]. Setting `iccp` / `exif` / `xmp`
487///   to `Some(..)` both (a) sets the corresponding §2.7.1 flag bit
488///   (`I` / `E` / `X`) and (b) emits the chunk in §2.7 canonical
489///   position.
490///
491/// ## Round-trip guarantee
492///
493/// Every byte stream produced by this function parses successfully
494/// through [`crate::container::parse`], and its §2.7.1.4 `ICCP` /
495/// §2.7.1.5 `EXIF` / §2.7.1.5 `XMP ` payloads round-trip byte-for-byte
496/// through [`crate::extract_metadata`]. The §2.7.1 `VP8X` flag octet
497/// also round-trips through [`crate::vp8x::Vp8xHeader::parse`] —
498/// `has_iccp` / `has_exif` / `has_xmp` reflect exactly which
499/// [`FileMetadata`] fields were `Some(..)`, and `has_alpha` reflects
500/// the `has_alpha` argument.
501///
502/// ## §2.3 odd-payload pad bytes
503///
504/// Each chunk emitted here goes through [`build_chunk`], so any
505/// metadata payload of odd length receives the §2.3 `0x00` pad byte
506/// automatically. The pad byte is *not* counted in the chunk's `Size`
507/// field and *is* counted in the §2.4 file `File Size` total, mirroring
508/// what [`crate::container::parse`] expects on the read side.
509pub fn build_webp_file_with_metadata(
510    payload: &[u8],
511    image_kind: ImageKind,
512    canvas_width: u32,
513    canvas_height: u32,
514    has_alpha: bool,
515    metadata: FileMetadata<'_>,
516) -> Result<Vec<u8>, BuildError> {
517    if payload.len() as u64 > MAX_CHUNK_PAYLOAD as u64 {
518        return Err(BuildError::PayloadTooLargeForChunk { got: payload.len() });
519    }
520
521    // §2.7.1 VP8X flag byte — derive from which metadata fields are
522    // Some plus the explicit has_alpha argument.
523    let flags = Vp8xFlags {
524        has_iccp: metadata.iccp.is_some(),
525        has_alpha,
526        has_exif: metadata.exif.is_some(),
527        has_xmp: metadata.xmp.is_some(),
528        has_animation: false,
529    };
530    let vp8x_payload = build_vp8x_chunk(canvas_width, canvas_height, flags)?;
531    let vp8x_chunk = build_chunk(fourcc::VP8X, &vp8x_payload)?;
532    let bitstream_chunk = build_chunk(image_kind.bitstream_fourcc(), payload)?;
533
534    // §2.7 canonical order: VP8X, ICCP (before image data), image data,
535    // EXIF, XMP.
536    let mut body = Vec::with_capacity(
537        vp8x_chunk.len()
538            + metadata.iccp.map_or(0, |b| 8 + b.len() + (b.len() & 1))
539            + bitstream_chunk.len()
540            + metadata.exif.map_or(0, |b| 8 + b.len() + (b.len() & 1))
541            + metadata.xmp.map_or(0, |b| 8 + b.len() + (b.len() & 1)),
542    );
543    body.extend_from_slice(&vp8x_chunk);
544    if let Some(iccp) = metadata.iccp {
545        let c = build_chunk(fourcc::ICCP, iccp)?;
546        body.extend_from_slice(&c);
547    }
548    body.extend_from_slice(&bitstream_chunk);
549    if let Some(exif) = metadata.exif {
550        let c = build_chunk(fourcc::EXIF, exif)?;
551        body.extend_from_slice(&c);
552    }
553    if let Some(xmp) = metadata.xmp {
554        let c = build_chunk(fourcc::XMP, xmp)?;
555        body.extend_from_slice(&c);
556    }
557
558    // §2.4: File Size = 4 ('WEBP' FourCC) + body length.
559    let file_size = (body.len() as u64) + 4;
560    if file_size > u64::from(u32::MAX) {
561        return Err(BuildError::PayloadTooLargeForChunk { got: payload.len() });
562    }
563    let file_size = file_size as u32;
564
565    let mut out = Vec::with_capacity(12 + body.len());
566    out.extend_from_slice(&fourcc::RIFF);
567    out.extend_from_slice(&file_size.to_le_bytes());
568    out.extend_from_slice(&fourcc::WEBP);
569    out.extend_from_slice(&body);
570    Ok(out)
571}
572
573#[cfg(test)]
574mod tests {
575    use super::*;
576    use crate::container::{parse, ContainerError};
577    use crate::vp8x::Vp8xHeader;
578
579    /// §2.3 sanity: a chunk with even-length payload is exactly
580    /// `8 + payload.len()` bytes — no pad byte.
581    #[test]
582    fn build_chunk_even_payload_has_no_pad_byte() {
583        let bytes = build_chunk(fourcc::VP8, &[0u8; 8]).unwrap();
584        assert_eq!(bytes.len(), 8 + 8);
585        assert_eq!(&bytes[0..4], b"VP8 ");
586        assert_eq!(&bytes[4..8], &8u32.to_le_bytes());
587    }
588
589    /// §2.3 sanity: odd-length payload gets a trailing `0x00` byte
590    /// that is *not* counted in `Size`.
591    #[test]
592    fn build_chunk_odd_payload_appends_one_zero_pad_byte() {
593        let bytes = build_chunk(fourcc::ICCP, &[0xAA, 0xBB, 0xCC]).unwrap();
594        // 8-byte header + 3 payload bytes + 1 pad byte.
595        assert_eq!(bytes.len(), 8 + 3 + 1);
596        assert_eq!(&bytes[4..8], &3u32.to_le_bytes());
597        assert_eq!(bytes[bytes.len() - 1], 0u8);
598        // The pad byte sits past the declared Size.
599        assert_eq!(&bytes[8..8 + 3], &[0xAA, 0xBB, 0xCC]);
600    }
601
602    /// §2.7.1 Figure 7 layout: byte 0 is flags, bytes 1..4 reserved
603    /// (zero), bytes 4..7 width-minus-one LE, bytes 7..10
604    /// height-minus-one LE.
605    #[test]
606    fn build_vp8x_payload_layout_matches_figure_7_byte_for_byte() {
607        let payload = build_vp8x_chunk(
608            128,
609            64,
610            Vp8xFlags {
611                has_alpha: true,
612                ..Default::default()
613            },
614        )
615        .unwrap();
616        assert_eq!(payload.len(), 10);
617        // L bit (4) set, all others clear.
618        assert_eq!(payload[0], 0b0001_0000);
619        // Reserved 24-bit field zero.
620        assert_eq!(&payload[1..4], &[0u8, 0u8, 0u8]);
621        // 128 → minus-one = 127 = 0x7F.
622        assert_eq!(&payload[4..7], &[0x7F, 0x00, 0x00]);
623        // 64 → minus-one = 63 = 0x3F.
624        assert_eq!(&payload[7..10], &[0x3F, 0x00, 0x00]);
625    }
626
627    /// All five named feature flags map to the bit positions the
628    /// parser already locked down in [`crate::vp8x`].
629    #[test]
630    fn build_vp8x_flag_bits_match_parser_table() {
631        let cases: &[(Vp8xFlags, u8)] = &[
632            (
633                Vp8xFlags {
634                    has_iccp: true,
635                    ..Default::default()
636                },
637                0b0010_0000,
638            ),
639            (
640                Vp8xFlags {
641                    has_alpha: true,
642                    ..Default::default()
643                },
644                0b0001_0000,
645            ),
646            (
647                Vp8xFlags {
648                    has_exif: true,
649                    ..Default::default()
650                },
651                0b0000_1000,
652            ),
653            (
654                Vp8xFlags {
655                    has_xmp: true,
656                    ..Default::default()
657                },
658                0b0000_0100,
659            ),
660            (
661                Vp8xFlags {
662                    has_animation: true,
663                    ..Default::default()
664                },
665                0b0000_0010,
666            ),
667        ];
668        for (flags, expected) in cases {
669            let payload = build_vp8x_chunk(1, 1, *flags).unwrap();
670            assert_eq!(payload[0], *expected, "flags={flags:?}");
671        }
672
673        // All five together — every bit position above OR'd.
674        let all = build_vp8x_chunk(
675            1,
676            1,
677            Vp8xFlags {
678                has_iccp: true,
679                has_alpha: true,
680                has_exif: true,
681                has_xmp: true,
682                has_animation: true,
683            },
684        )
685        .unwrap();
686        assert_eq!(all[0], 0b0011_1110);
687    }
688
689    /// 24-bit Minus-One width / height: exercise all three octets so
690    /// we catch any LE / BE confusion in the encoder.
691    #[test]
692    fn build_vp8x_canvas_dims_are_24bit_little_endian() {
693        let payload = build_vp8x_chunk(0x00ABCD, 0x000124, Vp8xFlags::default()).unwrap();
694        // 0x00ABCD - 1 = 0x00ABCC
695        assert_eq!(&payload[4..7], &[0xCC, 0xAB, 0x00]);
696        // 0x000124 - 1 = 0x000123
697        assert_eq!(&payload[7..10], &[0x23, 0x01, 0x00]);
698    }
699
700    /// §2.7.1 0-dimension is impossible to encode (the field is
701    /// `dim - 1` and would underflow). The builder refuses up front.
702    #[test]
703    fn build_vp8x_rejects_zero_canvas_dim() {
704        assert_eq!(
705            build_vp8x_chunk(0, 1, Vp8xFlags::default()).unwrap_err(),
706            BuildError::CanvasDimZero { which: "width" }
707        );
708        assert_eq!(
709            build_vp8x_chunk(1, 0, Vp8xFlags::default()).unwrap_err(),
710            BuildError::CanvasDimZero { which: "height" }
711        );
712    }
713
714    /// §2.7.1 dim above 2^24 doesn't fit in the 24-bit Minus-One field.
715    #[test]
716    fn build_vp8x_rejects_canvas_dim_above_2_pow_24() {
717        let too_big = MAX_VP8X_CANVAS_DIM + 1;
718        assert_eq!(
719            build_vp8x_chunk(too_big, 1, Vp8xFlags::default()).unwrap_err(),
720            BuildError::CanvasDimTooLarge {
721                which: "width",
722                got: too_big
723            }
724        );
725        assert_eq!(
726            build_vp8x_chunk(1, too_big, Vp8xFlags::default()).unwrap_err(),
727            BuildError::CanvasDimTooLarge {
728                which: "height",
729                got: too_big
730            }
731        );
732
733        // Exactly 2^24 still fits.
734        let ok = build_vp8x_chunk(MAX_VP8X_CANVAS_DIM, 1, Vp8xFlags::default()).unwrap();
735        assert_eq!(&ok[4..7], &[0xFF, 0xFF, 0xFF]); // 2^24 - 1 = 0x00FF_FFFF
736    }
737
738    /// §2.7.1 product cap: w*h > 2^32 - 1 is rejected, mirroring the
739    /// parser's `CanvasTooLarge`.
740    #[test]
741    fn build_vp8x_rejects_canvas_above_product_cap() {
742        let err = build_vp8x_chunk(65_536, 65_536, Vp8xFlags::default()).unwrap_err();
743        assert_eq!(
744            err,
745            BuildError::CanvasTooLarge {
746                canvas_width: 65_536,
747                canvas_height: 65_536,
748            }
749        );
750    }
751
752    /// §2.4 file: simple-lossy round-trip through the parser. The
753    /// chunk list, FourCCs, and payload bytes survive intact.
754    #[test]
755    fn build_webp_file_simple_lossy_round_trips_through_parser() {
756        let payload = b"\xDE\xAD\xBE\xEF\x01\x02\x03"; // 7 bytes, odd → pad byte needed
757        let bytes = build_webp_file(payload, ImageKind::Lossy, 0, 0).unwrap();
758        // 12 (file header) + 8 (chunk header) + 7 (payload) + 1 (pad)
759        assert_eq!(bytes.len(), 12 + 8 + 7 + 1);
760        assert_eq!(&bytes[0..4], b"RIFF");
761        assert_eq!(&bytes[8..12], b"WEBP");
762        let c = parse(&bytes).expect("simple-lossy file built by builder parses");
763        assert_eq!(c.chunks.len(), 1);
764        assert_eq!(c.chunks[0].fourcc, fourcc::VP8);
765        assert_eq!(c.chunks[0].size, 7);
766        assert_eq!(c.chunks[0].payload(&bytes), payload);
767        assert!(!c.is_extended());
768        // §2.4: File Size = 4 ('WEBP') + body (16 bytes incl. pad) = 20.
769        assert_eq!(c.riff_file_size, 20);
770    }
771
772    /// §2.4 file: simple-lossless layout produces a single `VP8L`
773    /// chunk; even-payload exercises the no-pad path.
774    #[test]
775    fn build_webp_file_simple_lossless_uses_vp8l_chunk() {
776        let payload = vec![0x2F, 0x00, 0x00, 0x00]; // 4 bytes, even
777        let bytes = build_webp_file(&payload, ImageKind::Lossless, 0, 0).unwrap();
778        // 12 + 8 + 4 + 0 (no pad).
779        assert_eq!(bytes.len(), 12 + 8 + 4);
780        let c = parse(&bytes).unwrap();
781        assert_eq!(c.chunks.len(), 1);
782        assert_eq!(c.chunks[0].fourcc, fourcc::VP8L);
783        assert_eq!(c.chunks[0].payload(&bytes), payload.as_slice());
784    }
785
786    /// §2.7 extended-lossy: emits VP8X first, then VP8.
787    #[test]
788    fn build_webp_file_extended_lossy_emits_vp8x_then_vp8() {
789        let payload = vec![0u8; 6]; // even, no pad
790        let bytes = build_webp_file(&payload, ImageKind::ExtendedLossy, 320, 240).unwrap();
791        let c = parse(&bytes).unwrap();
792        assert_eq!(c.chunks.len(), 2);
793        assert_eq!(c.chunks[0].fourcc, fourcc::VP8X);
794        assert_eq!(c.chunks[1].fourcc, fourcc::VP8);
795        assert!(c.is_extended());
796
797        // VP8X payload decodes to the canvas dims we passed in.
798        let vp8x = Vp8xHeader::parse(c.chunks[0].payload(&bytes)).unwrap();
799        assert_eq!(vp8x.canvas_width, 320);
800        assert_eq!(vp8x.canvas_height, 240);
801        // Default flags — none set.
802        assert!(!vp8x.has_iccp);
803        assert!(!vp8x.has_alpha);
804        assert!(!vp8x.has_exif);
805        assert!(!vp8x.has_xmp);
806        assert!(!vp8x.has_animation);
807        assert!(!vp8x.has_unknown);
808    }
809
810    /// §2.7 extended-lossless: emits VP8X first, then VP8L. Also
811    /// exercises a 1x1 canvas at the §2.7.1 lower bound.
812    #[test]
813    fn build_webp_file_extended_lossless_emits_vp8x_then_vp8l() {
814        let payload = vec![0u8; 5]; // odd → pad byte on VP8L
815        let bytes = build_webp_file(&payload, ImageKind::ExtendedLossless, 1, 1).unwrap();
816        let c = parse(&bytes).unwrap();
817        assert_eq!(c.chunks.len(), 2);
818        assert_eq!(c.chunks[0].fourcc, fourcc::VP8X);
819        assert_eq!(c.chunks[1].fourcc, fourcc::VP8L);
820        let vp8x = Vp8xHeader::parse(c.chunks[0].payload(&bytes)).unwrap();
821        assert_eq!(vp8x.canvas_width, 1);
822        assert_eq!(vp8x.canvas_height, 1);
823        assert_eq!(c.chunks[1].payload(&bytes), &[0u8; 5]);
824    }
825
826    /// §2.4 File Size accounting matches the field the parser sees —
827    /// counts `WEBP` (4) plus every byte of the chunk body (including
828    /// pad bytes), and nothing else.
829    #[test]
830    fn build_webp_file_file_size_field_matches_parsed_value() {
831        // Simple lossy with 7-byte payload (odd → +1 pad). Body =
832        // 8 header + 7 payload + 1 pad = 16. File Size = 20.
833        let bytes = build_webp_file(&[0u8; 7], ImageKind::Lossy, 0, 0).unwrap();
834        let declared = u32::from_le_bytes(bytes[4..8].try_into().unwrap());
835        assert_eq!(declared, 20);
836
837        // Extended lossy with 8-byte payload (even, no pad). Body =
838        // (8 + 10 VP8X) + (8 + 8 VP8) = 18 + 16 = 34. File Size = 38.
839        let bytes = build_webp_file(&[0u8; 8], ImageKind::ExtendedLossy, 100, 100).unwrap();
840        let declared = u32::from_le_bytes(bytes[4..8].try_into().unwrap());
841        assert_eq!(declared, 38);
842        // Sanity: declared payload-end matches the byte slice length.
843        assert_eq!((declared as usize) + 8, bytes.len());
844    }
845
846    /// Canvas-validation errors on the extended layouts bubble out of
847    /// `build_webp_file` (rather than panicking or producing a file
848    /// the parser would reject).
849    #[test]
850    fn build_webp_file_extended_propagates_canvas_validation_errors() {
851        // 0-width canvas.
852        assert_eq!(
853            build_webp_file(&[0u8; 4], ImageKind::ExtendedLossy, 0, 1).unwrap_err(),
854            BuildError::CanvasDimZero { which: "width" }
855        );
856        // Product cap exceeded.
857        assert_eq!(
858            build_webp_file(&[0u8; 4], ImageKind::ExtendedLossless, 65_536, 65_536).unwrap_err(),
859            BuildError::CanvasTooLarge {
860                canvas_width: 65_536,
861                canvas_height: 65_536,
862            }
863        );
864    }
865
866    /// Empty payloads are *allowed* — they produce a well-formed RIFF
867    /// with a zero-size bitstream chunk. The parser accepts it; what
868    /// the decoder does next is the decoder's problem.
869    #[test]
870    fn build_webp_file_empty_payload_is_a_well_formed_empty_chunk() {
871        let bytes = build_webp_file(&[], ImageKind::Lossy, 0, 0).unwrap();
872        let c = parse(&bytes).unwrap();
873        assert_eq!(c.chunks.len(), 1);
874        assert_eq!(c.chunks[0].size, 0);
875        assert!(c.chunks[0].payload(&bytes).is_empty());
876    }
877
878    /// Round-trip with a longer (multi-block) payload to catch any
879    /// off-by-one issues in the Vec growth / cursor arithmetic.
880    #[test]
881    fn build_webp_file_round_trip_preserves_64kib_payload_byte_for_byte() {
882        let mut payload = Vec::with_capacity(65_535);
883        for i in 0..65_535 {
884            payload.push((i & 0xFF) as u8);
885        }
886        let bytes = build_webp_file(&payload, ImageKind::Lossless, 0, 0).unwrap();
887        let c = parse(&bytes).expect("64 KiB payload parses");
888        assert_eq!(c.chunks.len(), 1);
889        assert_eq!(c.chunks[0].size as usize, payload.len());
890        assert_eq!(c.chunks[0].payload(&bytes), payload.as_slice());
891        // Odd payload → §2.3 pad byte; the parser still walks cleanly.
892    }
893
894    // ─────────────────────── build_webp_file_with_metadata ───────────────────────
895
896    /// §2.7 canonical chunk order when no metadata is present: VP8X
897    /// then the bitstream chunk, nothing else.
898    #[test]
899    fn build_with_metadata_emits_vp8x_then_payload_when_no_metadata() {
900        let payload = vec![0u8; 6];
901        let bytes = build_webp_file_with_metadata(
902            &payload,
903            ImageKind::ExtendedLossy,
904            64,
905            32,
906            false,
907            FileMetadata::default(),
908        )
909        .unwrap();
910        let c = parse(&bytes).unwrap();
911        assert_eq!(c.chunks.len(), 2);
912        assert_eq!(c.chunks[0].fourcc, fourcc::VP8X);
913        assert_eq!(c.chunks[1].fourcc, fourcc::VP8);
914        let vp8x = Vp8xHeader::parse(c.chunks[0].payload(&bytes)).unwrap();
915        assert!(!vp8x.has_iccp);
916        assert!(!vp8x.has_alpha);
917        assert!(!vp8x.has_exif);
918        assert!(!vp8x.has_xmp);
919        assert!(!vp8x.has_animation);
920    }
921
922    /// §2.7.1.4 ICCP-only round trip: chunk lands before the bitstream,
923    /// §2.7.1 `I` flag set, payload survives.
924    #[test]
925    fn build_with_metadata_iccp_only_round_trips() {
926        let payload = vec![0u8; 4];
927        let iccp = b"icc-profile-bytes".to_vec();
928        let bytes = build_webp_file_with_metadata(
929            &payload,
930            ImageKind::ExtendedLossless,
931            16,
932            16,
933            false,
934            FileMetadata {
935                iccp: Some(&iccp),
936                ..Default::default()
937            },
938        )
939        .unwrap();
940        let c = parse(&bytes).unwrap();
941        // Order: VP8X, ICCP, VP8L.
942        assert_eq!(c.chunks.len(), 3);
943        assert_eq!(c.chunks[0].fourcc, fourcc::VP8X);
944        assert_eq!(c.chunks[1].fourcc, fourcc::ICCP);
945        assert_eq!(c.chunks[2].fourcc, fourcc::VP8L);
946        let vp8x = Vp8xHeader::parse(c.chunks[0].payload(&bytes)).unwrap();
947        assert!(vp8x.has_iccp);
948        assert!(!vp8x.has_exif);
949        assert!(!vp8x.has_xmp);
950        // Extracted ICCP payload matches.
951        let m = crate::extract_metadata(&bytes).unwrap();
952        assert_eq!(m.icc.as_deref(), Some(&iccp[..]));
953        assert_eq!(m.exif, None);
954        assert_eq!(m.xmp, None);
955    }
956
957    /// §2.7.1.5 EXIF-only round trip: §2.7 says EXIF lands *after* the
958    /// bitstream.
959    #[test]
960    fn build_with_metadata_exif_only_round_trips() {
961        let payload = vec![0u8; 4];
962        let exif = b"Exif\x00\x00MM\x00*".to_vec();
963        let bytes = build_webp_file_with_metadata(
964            &payload,
965            ImageKind::ExtendedLossless,
966            8,
967            8,
968            false,
969            FileMetadata {
970                exif: Some(&exif),
971                ..Default::default()
972            },
973        )
974        .unwrap();
975        let c = parse(&bytes).unwrap();
976        assert_eq!(c.chunks.len(), 3);
977        assert_eq!(c.chunks[0].fourcc, fourcc::VP8X);
978        assert_eq!(c.chunks[1].fourcc, fourcc::VP8L);
979        assert_eq!(c.chunks[2].fourcc, fourcc::EXIF);
980        let vp8x = Vp8xHeader::parse(c.chunks[0].payload(&bytes)).unwrap();
981        assert!(!vp8x.has_iccp);
982        assert!(vp8x.has_exif);
983        assert!(!vp8x.has_xmp);
984        let m = crate::extract_metadata(&bytes).unwrap();
985        assert_eq!(m.icc, None);
986        assert_eq!(m.exif.as_deref(), Some(&exif[..]));
987        assert_eq!(m.xmp, None);
988    }
989
990    /// §2.7.1.5 XMP-only round trip.
991    #[test]
992    fn build_with_metadata_xmp_only_round_trips() {
993        let payload = vec![0u8; 4];
994        let xmp = b"<?xpacket begin?>".to_vec();
995        let bytes = build_webp_file_with_metadata(
996            &payload,
997            ImageKind::ExtendedLossless,
998            8,
999            8,
1000            false,
1001            FileMetadata {
1002                xmp: Some(&xmp),
1003                ..Default::default()
1004            },
1005        )
1006        .unwrap();
1007        let c = parse(&bytes).unwrap();
1008        assert_eq!(c.chunks.len(), 3);
1009        assert_eq!(c.chunks[0].fourcc, fourcc::VP8X);
1010        assert_eq!(c.chunks[1].fourcc, fourcc::VP8L);
1011        assert_eq!(c.chunks[2].fourcc, fourcc::XMP);
1012        let vp8x = Vp8xHeader::parse(c.chunks[0].payload(&bytes)).unwrap();
1013        assert!(vp8x.has_xmp);
1014        let m = crate::extract_metadata(&bytes).unwrap();
1015        assert_eq!(m.xmp.as_deref(), Some(&xmp[..]));
1016    }
1017
1018    /// ICCP + EXIF together: ICCP before the bitstream, EXIF after,
1019    /// both flag bits set.
1020    #[test]
1021    fn build_with_metadata_iccp_plus_exif_round_trips() {
1022        let payload = vec![0u8; 4];
1023        let iccp = b"icc".to_vec();
1024        let exif = b"Exif\x00\x00MM\x00*more".to_vec();
1025        let bytes = build_webp_file_with_metadata(
1026            &payload,
1027            ImageKind::ExtendedLossless,
1028            8,
1029            8,
1030            false,
1031            FileMetadata {
1032                iccp: Some(&iccp),
1033                exif: Some(&exif),
1034                ..Default::default()
1035            },
1036        )
1037        .unwrap();
1038        let c = parse(&bytes).unwrap();
1039        assert_eq!(c.chunks.len(), 4);
1040        assert_eq!(c.chunks[0].fourcc, fourcc::VP8X);
1041        assert_eq!(c.chunks[1].fourcc, fourcc::ICCP);
1042        assert_eq!(c.chunks[2].fourcc, fourcc::VP8L);
1043        assert_eq!(c.chunks[3].fourcc, fourcc::EXIF);
1044        let vp8x = Vp8xHeader::parse(c.chunks[0].payload(&bytes)).unwrap();
1045        assert!(vp8x.has_iccp);
1046        assert!(vp8x.has_exif);
1047        assert!(!vp8x.has_xmp);
1048        let m = crate::extract_metadata(&bytes).unwrap();
1049        assert_eq!(m.icc.as_deref(), Some(&iccp[..]));
1050        assert_eq!(m.exif.as_deref(), Some(&exif[..]));
1051    }
1052
1053    /// ICCP + XMP together: ICCP before, XMP after, no EXIF.
1054    #[test]
1055    fn build_with_metadata_iccp_plus_xmp_round_trips() {
1056        let payload = vec![0u8; 4];
1057        let iccp = b"icc-bytes-here".to_vec();
1058        let xmp = b"<xmp/>".to_vec();
1059        let bytes = build_webp_file_with_metadata(
1060            &payload,
1061            ImageKind::ExtendedLossless,
1062            8,
1063            8,
1064            false,
1065            FileMetadata {
1066                iccp: Some(&iccp),
1067                xmp: Some(&xmp),
1068                ..Default::default()
1069            },
1070        )
1071        .unwrap();
1072        let c = parse(&bytes).unwrap();
1073        assert_eq!(c.chunks.len(), 4);
1074        assert_eq!(c.chunks[0].fourcc, fourcc::VP8X);
1075        assert_eq!(c.chunks[1].fourcc, fourcc::ICCP);
1076        assert_eq!(c.chunks[2].fourcc, fourcc::VP8L);
1077        assert_eq!(c.chunks[3].fourcc, fourcc::XMP);
1078        let vp8x = Vp8xHeader::parse(c.chunks[0].payload(&bytes)).unwrap();
1079        assert!(vp8x.has_iccp);
1080        assert!(!vp8x.has_exif);
1081        assert!(vp8x.has_xmp);
1082        let m = crate::extract_metadata(&bytes).unwrap();
1083        assert_eq!(m.icc.as_deref(), Some(&iccp[..]));
1084        assert_eq!(m.xmp.as_deref(), Some(&xmp[..]));
1085    }
1086
1087    /// EXIF + XMP together: both land after the bitstream, in §2.7 order
1088    /// (EXIF before XMP).
1089    #[test]
1090    fn build_with_metadata_exif_plus_xmp_round_trips() {
1091        let payload = vec![0u8; 4];
1092        let exif = b"E".to_vec();
1093        let xmp = b"X".to_vec();
1094        let bytes = build_webp_file_with_metadata(
1095            &payload,
1096            ImageKind::ExtendedLossless,
1097            8,
1098            8,
1099            false,
1100            FileMetadata {
1101                exif: Some(&exif),
1102                xmp: Some(&xmp),
1103                ..Default::default()
1104            },
1105        )
1106        .unwrap();
1107        let c = parse(&bytes).unwrap();
1108        assert_eq!(c.chunks.len(), 4);
1109        assert_eq!(c.chunks[0].fourcc, fourcc::VP8X);
1110        assert_eq!(c.chunks[1].fourcc, fourcc::VP8L);
1111        assert_eq!(c.chunks[2].fourcc, fourcc::EXIF);
1112        assert_eq!(c.chunks[3].fourcc, fourcc::XMP);
1113        let m = crate::extract_metadata(&bytes).unwrap();
1114        assert_eq!(m.exif.as_deref(), Some(&exif[..]));
1115        assert_eq!(m.xmp.as_deref(), Some(&xmp[..]));
1116    }
1117
1118    /// All three metadata kinds together: VP8X | ICCP | bitstream |
1119    /// EXIF | XMP, every flag bit set.
1120    #[test]
1121    fn build_with_metadata_all_three_round_trip_in_canonical_order() {
1122        let payload = vec![0u8; 4];
1123        let iccp = b"ICC-profile-blob".to_vec();
1124        let exif = b"Exif\x00\x00II*\x00".to_vec();
1125        let xmp = b"<x:xmpmeta/>".to_vec();
1126        let bytes = build_webp_file_with_metadata(
1127            &payload,
1128            ImageKind::ExtendedLossless,
1129            16,
1130            16,
1131            true, // also flips the L bit
1132            FileMetadata {
1133                iccp: Some(&iccp),
1134                exif: Some(&exif),
1135                xmp: Some(&xmp),
1136            },
1137        )
1138        .unwrap();
1139        let c = parse(&bytes).unwrap();
1140        assert_eq!(c.chunks.len(), 5);
1141        assert_eq!(c.chunks[0].fourcc, fourcc::VP8X);
1142        assert_eq!(c.chunks[1].fourcc, fourcc::ICCP);
1143        assert_eq!(c.chunks[2].fourcc, fourcc::VP8L);
1144        assert_eq!(c.chunks[3].fourcc, fourcc::EXIF);
1145        assert_eq!(c.chunks[4].fourcc, fourcc::XMP);
1146        let vp8x = Vp8xHeader::parse(c.chunks[0].payload(&bytes)).unwrap();
1147        assert!(vp8x.has_iccp);
1148        assert!(vp8x.has_alpha);
1149        assert!(vp8x.has_exif);
1150        assert!(vp8x.has_xmp);
1151        assert!(!vp8x.has_animation);
1152        // §2.7.1 canvas dims survive.
1153        assert_eq!(vp8x.canvas_width, 16);
1154        assert_eq!(vp8x.canvas_height, 16);
1155        let m = crate::extract_metadata(&bytes).unwrap();
1156        assert_eq!(m.icc.as_deref(), Some(&iccp[..]));
1157        assert_eq!(m.exif.as_deref(), Some(&exif[..]));
1158        assert_eq!(m.xmp.as_deref(), Some(&xmp[..]));
1159    }
1160
1161    /// §2.3 odd-length metadata payloads trigger a single `0x00` pad
1162    /// byte per chunk (not counted in `Size`), and the parser still
1163    /// walks cleanly.
1164    #[test]
1165    fn build_with_metadata_odd_payloads_get_pad_bytes() {
1166        // Three odd-length metadata payloads + an odd-length bitstream.
1167        let payload = vec![0xABu8, 0xCD, 0xEF, 0x01, 0x02]; // 5 bytes (odd)
1168        let iccp = b"AAA".to_vec(); // 3 bytes (odd)
1169        let exif = b"BBBBB".to_vec(); // 5 bytes (odd)
1170        let xmp = b"CCCCCCC".to_vec(); // 7 bytes (odd)
1171        let bytes = build_webp_file_with_metadata(
1172            &payload,
1173            ImageKind::ExtendedLossless,
1174            8,
1175            8,
1176            false,
1177            FileMetadata {
1178                iccp: Some(&iccp),
1179                exif: Some(&exif),
1180                xmp: Some(&xmp),
1181            },
1182        )
1183        .unwrap();
1184        // Each odd chunk contributes 8 (header) + len + 1 (pad).
1185        // VP8X is 10 bytes (even → no pad). Total body:
1186        // (8+10) + (8+3+1) + (8+5+1) + (8+5+1) + (8+7+1) = 18+12+14+14+16 = 74.
1187        // File Size = 4 ('WEBP') + 74 = 78.
1188        let declared = u32::from_le_bytes(bytes[4..8].try_into().unwrap());
1189        assert_eq!(declared, 78);
1190        // Parser-level Size fields exclude the pad byte each.
1191        let c = parse(&bytes).unwrap();
1192        assert_eq!(c.chunks[1].size, 3, "ICCP §Size excludes pad byte");
1193        assert_eq!(c.chunks[2].size, 5, "VP8L §Size excludes pad byte");
1194        assert_eq!(c.chunks[3].size, 5, "EXIF §Size excludes pad byte");
1195        assert_eq!(c.chunks[4].size, 7, "XMP §Size excludes pad byte");
1196        // Payload survives intact through the parser (the pad byte sits
1197        // outside `payload()`).
1198        assert_eq!(c.chunks[1].payload(&bytes), &iccp[..]);
1199        assert_eq!(c.chunks[2].payload(&bytes), &payload[..]);
1200        assert_eq!(c.chunks[3].payload(&bytes), &exif[..]);
1201        assert_eq!(c.chunks[4].payload(&bytes), &xmp[..]);
1202    }
1203
1204    /// §2.7.1 flag-bit derivation: each `Some(..)` field independently
1205    /// flips the corresponding bit, every other combination clears it.
1206    /// Exhaustively covers the 2^3 metadata-presence states (× the
1207    /// `has_alpha` × 2 axis for the L bit).
1208    #[test]
1209    fn build_with_metadata_flag_bits_match_field_presence() {
1210        let payload = vec![0u8; 2];
1211        for has_icc in [false, true] {
1212            for has_exif_p in [false, true] {
1213                for has_xmp_p in [false, true] {
1214                    for has_alpha in [false, true] {
1215                        let icc_blob: &[u8] = &[0xAA];
1216                        let exif_blob: &[u8] = &[0xBB];
1217                        let xmp_blob: &[u8] = &[0xCC];
1218                        let metadata = FileMetadata {
1219                            iccp: has_icc.then_some(icc_blob),
1220                            exif: has_exif_p.then_some(exif_blob),
1221                            xmp: has_xmp_p.then_some(xmp_blob),
1222                        };
1223                        let bytes = build_webp_file_with_metadata(
1224                            &payload,
1225                            ImageKind::ExtendedLossless,
1226                            4,
1227                            4,
1228                            has_alpha,
1229                            metadata,
1230                        )
1231                        .unwrap();
1232                        let c = parse(&bytes).unwrap();
1233                        let vp8x = Vp8xHeader::parse(c.chunks[0].payload(&bytes)).unwrap();
1234                        assert_eq!(
1235                            vp8x.has_iccp, has_icc,
1236                            "I flag (has_icc={has_icc}, has_exif={has_exif_p}, has_xmp={has_xmp_p}, has_alpha={has_alpha})"
1237                        );
1238                        assert_eq!(
1239                            vp8x.has_alpha, has_alpha,
1240                            "L flag (has_icc={has_icc}, has_exif={has_exif_p}, has_xmp={has_xmp_p}, has_alpha={has_alpha})"
1241                        );
1242                        assert_eq!(
1243                            vp8x.has_exif, has_exif_p,
1244                            "E flag (has_icc={has_icc}, has_exif={has_exif_p}, has_xmp={has_xmp_p}, has_alpha={has_alpha})"
1245                        );
1246                        assert_eq!(
1247                            vp8x.has_xmp, has_xmp_p,
1248                            "X flag (has_icc={has_icc}, has_exif={has_exif_p}, has_xmp={has_xmp_p}, has_alpha={has_alpha})"
1249                        );
1250                        // Animation always off — this writer never emits ANIM/ANMF.
1251                        assert!(!vp8x.has_animation);
1252                    }
1253                }
1254            }
1255        }
1256    }
1257
1258    /// Canvas-validation failures propagate out of the metadata writer
1259    /// without producing a half-baked file.
1260    #[test]
1261    fn build_with_metadata_propagates_canvas_validation_errors() {
1262        assert_eq!(
1263            build_webp_file_with_metadata(
1264                &[0u8; 4],
1265                ImageKind::ExtendedLossless,
1266                0,
1267                1,
1268                false,
1269                FileMetadata::default(),
1270            )
1271            .unwrap_err(),
1272            BuildError::CanvasDimZero { which: "width" }
1273        );
1274        assert_eq!(
1275            build_webp_file_with_metadata(
1276                &[0u8; 4],
1277                ImageKind::ExtendedLossless,
1278                65_536,
1279                65_536,
1280                false,
1281                FileMetadata::default(),
1282            )
1283            .unwrap_err(),
1284            BuildError::CanvasTooLarge {
1285                canvas_width: 65_536,
1286                canvas_height: 65_536,
1287            }
1288        );
1289    }
1290
1291    /// FileMetadata::is_empty mirrors "every field is None" — and the
1292    /// writer with an empty metadata struct on a VP8L payload still
1293    /// emits a parseable file (just VP8X + bitstream, no metadata
1294    /// chunks).
1295    #[test]
1296    fn build_with_metadata_empty_metadata_omits_optional_chunks() {
1297        assert!(FileMetadata::default().is_empty());
1298        let payload = vec![0u8; 8];
1299        let bytes = build_webp_file_with_metadata(
1300            &payload,
1301            ImageKind::ExtendedLossless,
1302            4,
1303            4,
1304            false,
1305            FileMetadata::default(),
1306        )
1307        .unwrap();
1308        let c = parse(&bytes).unwrap();
1309        assert_eq!(c.chunks.len(), 2);
1310        assert_eq!(c.chunks[0].fourcc, fourcc::VP8X);
1311        assert_eq!(c.chunks[1].fourcc, fourcc::VP8L);
1312        let m = crate::extract_metadata(&bytes).unwrap();
1313        assert!(m.icc.is_none());
1314        assert!(m.exif.is_none());
1315        assert!(m.xmp.is_none());
1316    }
1317
1318    /// Negative: a hand-crafted file with a chunk Size that runs past
1319    /// the buffer is rejected by the parser. (Sanity check that we're
1320    /// actually exercising the same parser the builders need to round-
1321    /// trip through.)
1322    #[test]
1323    fn parser_still_rejects_corrupt_size_field_after_builder_round_trip() {
1324        let mut bytes = build_webp_file(&[0u8; 8], ImageKind::Lossy, 0, 0).unwrap();
1325        // Corrupt the VP8 chunk's Size to a huge value.
1326        let chunk_size_off = 12 + 4;
1327        bytes[chunk_size_off..chunk_size_off + 4].copy_from_slice(&100_000u32.to_le_bytes());
1328        match parse(&bytes) {
1329            Err(ContainerError::ChunkPayloadOverflowsRiff { .. }) => {}
1330            other => panic!("expected ChunkPayloadOverflowsRiff, got {other:?}"),
1331        }
1332    }
1333}