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}