Skip to main content

otf_pixels_codec_avif/
props.rs

1//! Item properties: `iprp` holding `ipco` (the property store) and `ipma`
2//! (the item-to-property associations).
3//!
4//! HEIF keeps an item's descriptive data out of line. `ipco` is an array of
5//! property boxes shared by every item in the file, and `ipma` maps each item
6//! to the 1-based indices of the properties that describe it. So an image
7//! item's dimensions, its AV1 configuration and its colour information are
8//! three separate boxes elsewhere in the file, joined only by index.
9//!
10//! # Essential properties
11//!
12//! Each association carries an `essential` bit. A property marked essential
13//! that the decoder does not understand makes the item undecodable — the
14//! specification is explicit that it must not be ignored, because essential
15//! properties change how the pixels are to be interpreted. Honouring that bit
16//! is why [`Properties::essential_unknown`] exists: silently skipping an
17//! unknown essential property would produce a confidently wrong image.
18
19use crate::boxes::{FourCc, Reader};
20use otf_pixels_core::{Orientation, PixelsError, Result};
21use std::collections::HashMap;
22
23/// How the chroma planes are sampled relative to luma.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum Subsampling {
26    /// 4:0:0 — no chroma planes at all.
27    Monochrome,
28    /// 4:2:0 — chroma at half resolution on both axes.
29    Yuv420,
30    /// 4:2:2 — chroma at half resolution horizontally.
31    Yuv422,
32    /// 4:4:4 — chroma at full resolution.
33    Yuv444,
34}
35
36impl Subsampling {
37    /// How many luma samples share one chroma sample horizontally.
38    #[must_use]
39    pub const fn x_shift(self) -> u32 {
40        match self {
41            Self::Yuv420 | Self::Yuv422 => 1,
42            Self::Monochrome | Self::Yuv444 => 0,
43        }
44    }
45
46    /// How many luma samples share one chroma sample vertically.
47    #[must_use]
48    pub const fn y_shift(self) -> u32 {
49        match self {
50            Self::Yuv420 => 1,
51            Self::Monochrome | Self::Yuv422 | Self::Yuv444 => 0,
52        }
53    }
54}
55
56/// The `av1C` AV1 codec configuration.
57///
58/// This is the bridge between the two specifications AVIF is made of: it
59/// declares the AV1 profile and sample format, and carries the sequence header
60/// OBU that the bitstream decoder needs before it can read a frame.
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct Av1Config {
63    /// AV1 sequence profile, 0–2.
64    pub seq_profile: u8,
65    /// AV1 level index.
66    pub seq_level_idx0: u8,
67    /// AV1 tier.
68    pub seq_tier0: u8,
69    /// Bits per sample: 8, 10 or 12.
70    pub bit_depth: u8,
71    /// How chroma is sampled.
72    pub subsampling: Subsampling,
73    /// Chroma sample position, 0–3.
74    pub chroma_sample_position: u8,
75    /// The OBUs carried in the configuration record itself.
76    ///
77    /// Normally the sequence header, so a decoder can be configured before it
78    /// reaches the frame data in `mdat`.
79    pub config_obus: Vec<u8>,
80}
81
82/// The `ispe` image spatial extents — an item's dimensions in pixels.
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub struct Extents {
85    /// Width in pixels.
86    pub width: u32,
87    /// Height in pixels.
88    pub height: u32,
89}
90
91/// The `pixi` pixel information — bit depth per channel.
92#[derive(Debug, Clone, PartialEq, Eq)]
93pub struct PixelInfo {
94    /// Bits per channel, one entry per channel.
95    pub bits_per_channel: Vec<u8>,
96}
97
98/// The `colr` colour information.
99#[derive(Debug, Clone, PartialEq, Eq)]
100pub enum Colour {
101    /// CICP code points, as `nclx`.
102    Nclx {
103        /// CICP colour primaries.
104        primaries: u16,
105        /// CICP transfer characteristics.
106        transfer: u16,
107        /// CICP matrix coefficients, which choose the YUV↔RGB matrix.
108        matrix: u16,
109        /// Whether samples use the full range rather than the studio range.
110        full_range: bool,
111    },
112    /// An embedded ICC profile, as `rICC` or `prof`.
113    ///
114    /// Carried through but not applied: SPEC §Pixel formats puts ICC
115    /// transforms in v2, and v1 is sRGB-assumed.
116    Icc(Vec<u8>),
117}
118
119/// One entry of the `ipco` property store.
120#[derive(Debug, Clone, PartialEq, Eq)]
121pub enum Property {
122    /// `ispe` — the item's dimensions.
123    Extents(Extents),
124    /// `av1C` — the AV1 codec configuration.
125    Av1Config(Av1Config),
126    /// `pixi` — bit depth per channel.
127    PixelInfo(PixelInfo),
128    /// `colr` — colour information.
129    Colour(Colour),
130    /// `irot` — rotation, in counter-clockwise multiples of 90 degrees (0–3).
131    Rotation(u8),
132    /// `imir` — mirroring. `true` exchanges left and right (mode 1);
133    /// `false` exchanges top and bottom (mode 0).
134    Mirror(bool),
135    /// `auxC` — the auxiliary type URN, which is how an alpha plane announces
136    /// itself.
137    AuxiliaryType(String),
138    /// `pasp` — the pixel aspect ratio, as horizontal and vertical spacing.
139    PixelAspect(u32, u32),
140    /// A property this decoder does not interpret.
141    ///
142    /// Retained rather than dropped so that [`Properties::essential_unknown`]
143    /// can tell whether ignoring it is safe.
144    Unknown(FourCc),
145}
146
147impl Property {
148    /// The four-character type this property was parsed from.
149    #[must_use]
150    pub const fn kind(&self) -> FourCc {
151        match self {
152            Self::Extents(_) => FourCc::new(b"ispe"),
153            Self::Av1Config(_) => FourCc::new(b"av1C"),
154            Self::PixelInfo(_) => FourCc::new(b"pixi"),
155            Self::Colour(_) => FourCc::new(b"colr"),
156            Self::Rotation(_) => FourCc::new(b"irot"),
157            Self::Mirror(_) => FourCc::new(b"imir"),
158            Self::AuxiliaryType(_) => FourCc::new(b"auxC"),
159            Self::PixelAspect(_, _) => FourCc::new(b"pasp"),
160            Self::Unknown(kind) => *kind,
161        }
162    }
163}
164
165/// One item's association with a property.
166#[derive(Debug, Clone, Copy, PartialEq, Eq)]
167pub struct Association {
168    /// 1-based index into the `ipco` store. Zero means "no property".
169    pub index: u16,
170    /// Whether an unrecognised property here makes the item undecodable.
171    pub essential: bool,
172}
173
174/// The parsed contents of `iprp`.
175#[derive(Debug, Clone, Default)]
176pub struct Properties {
177    /// The `ipco` store, in file order. Association indices are 1-based.
178    entries: Vec<Property>,
179    /// The `ipma` map from item ID to that item's associations.
180    associations: HashMap<u32, Vec<Association>>,
181}
182
183impl Properties {
184    /// Parse an `iprp` box payload.
185    ///
186    /// # Errors
187    ///
188    /// Returns [`PixelsError::Malformed`] if the box or any child is
189    /// structurally invalid.
190    pub fn parse(mut iprp: Reader<'_>) -> Result<Self> {
191        let mut entries = Vec::new();
192        let mut associations = HashMap::new();
193
194        while let Some(header) = iprp.next_box() {
195            let header = header?;
196            let payload = iprp.payload(&header);
197            match &header.kind.0 {
198                b"ipco" => entries = parse_ipco(payload)?,
199                // A file may carry more than one `ipma`; the associations
200                // accumulate rather than the later box replacing the earlier.
201                b"ipma" => parse_ipma(payload, &mut associations)?,
202                _ => {}
203            }
204        }
205
206        Ok(Self {
207            entries,
208            associations,
209        })
210    }
211
212    /// Every property associated with `item_id`, in association order.
213    #[must_use]
214    pub fn for_item(&self, item_id: u32) -> Vec<&Property> {
215        let Some(associations) = self.associations.get(&item_id) else {
216            return Vec::new();
217        };
218        associations
219            .iter()
220            .filter_map(|association| {
221                // Indices are 1-based, and zero means "no property".
222                let index = usize::from(association.index).checked_sub(1)?;
223                self.entries.get(index)
224            })
225            .collect()
226    }
227
228    /// The type of the first essential property of `item_id` that this
229    /// decoder does not interpret, if there is one.
230    ///
231    /// An item with such a property must be reported [`PixelsError::Unsupported`]
232    /// rather than decoded, because the property was declared to change the
233    /// meaning of the pixels.
234    #[must_use]
235    pub fn essential_unknown(&self, item_id: u32) -> Option<FourCc> {
236        let associations = self.associations.get(&item_id)?;
237        associations.iter().find_map(|association| {
238            if !association.essential {
239                return None;
240            }
241            let index = usize::from(association.index).checked_sub(1)?;
242            match self.entries.get(index) {
243                Some(Property::Unknown(kind)) => Some(*kind),
244                _ => None,
245            }
246        })
247    }
248
249    /// The item's dimensions, from its `ispe`.
250    #[must_use]
251    pub fn extents(&self, item_id: u32) -> Option<Extents> {
252        self.for_item(item_id)
253            .into_iter()
254            .find_map(|property| match property {
255                Property::Extents(extents) => Some(*extents),
256                _ => None,
257            })
258    }
259
260    /// The item's AV1 configuration, from its `av1C`.
261    #[must_use]
262    pub fn av1_config(&self, item_id: u32) -> Option<&Av1Config> {
263        self.for_item(item_id)
264            .into_iter()
265            .find_map(|property| match property {
266                Property::Av1Config(config) => Some(config),
267                _ => None,
268            })
269    }
270
271    /// The item's colour information, from its `colr`. An item may carry
272    /// both an `nclx` and an ICC `colr` (libavif writes both when given a
273    /// profile); the `nclx` is preferred, since it is what governs the YUV
274    /// to RGB conversion.
275    #[must_use]
276    pub fn colour(&self, item_id: u32) -> Option<&Colour> {
277        let colours = || {
278            self.for_item(item_id)
279                .into_iter()
280                .filter_map(|property| match property {
281                    Property::Colour(colour) => Some(colour),
282                    _ => None,
283                })
284        };
285        colours()
286            .find(|colour| matches!(colour, Colour::Nclx { .. }))
287            .or_else(|| colours().next())
288    }
289
290    /// The item's ICC profile, from an ICC `colr`.
291    #[must_use]
292    pub fn icc_profile(&self, item_id: u32) -> Option<&[u8]> {
293        self.for_item(item_id)
294            .into_iter()
295            .find_map(|property| match property {
296                Property::Colour(Colour::Icc(profile)) => Some(profile.as_slice()),
297                _ => None,
298            })
299    }
300
301    /// The item's auxiliary type URN, from its `auxC`.
302    #[must_use]
303    pub fn auxiliary_type(&self, item_id: u32) -> Option<&str> {
304        self.for_item(item_id)
305            .into_iter()
306            .find_map(|property| match property {
307                Property::AuxiliaryType(urn) => Some(urn.as_str()),
308                _ => None,
309            })
310    }
311
312    /// The item's `irot` and `imir` properties, composed in association
313    /// order, which is the order HEIF applies transformative properties in.
314    #[must_use]
315    pub fn orientation(&self, item_id: u32) -> Orientation {
316        // Track the result as "rotate `turns` clockwise, then mirror left to
317        // right if `mirrored`".
318        let (mut turns, mut mirrored) = (0_u8, false);
319        for property in self.for_item(item_id) {
320            match property {
321                Property::Rotation(anticlockwise) => {
322                    // A rotation after a mirror is the opposite rotation
323                    // before it, so the mirror stays last.
324                    let clockwise = (4 - anticlockwise % 4) % 4;
325                    turns = if mirrored {
326                        (turns + 4 - clockwise) % 4
327                    } else {
328                        (turns + clockwise) % 4
329                    };
330                }
331                Property::Mirror(left_right) => {
332                    // Exchanging top and bottom is a half turn followed by
333                    // exchanging left and right. A half turn commutes with a
334                    // mirror, so it folds into `turns` either way.
335                    if !left_right {
336                        turns = (turns + 2) % 4;
337                    }
338                    mirrored = !mirrored;
339                }
340                _ => {}
341            }
342        }
343        Orientation::from_parts(turns, mirrored)
344    }
345}
346
347/// Parse the `ipco` property store.
348fn parse_ipco(mut ipco: Reader<'_>) -> Result<Vec<Property>> {
349    let mut entries = Vec::new();
350    while let Some(header) = ipco.next_box() {
351        let header = header?;
352        let payload = ipco.payload(&header);
353        entries.push(parse_property(header.kind, payload)?);
354    }
355    Ok(entries)
356}
357
358/// Parse one property box.
359fn parse_property(kind: FourCc, mut payload: Reader<'_>) -> Result<Property> {
360    match &kind.0 {
361        b"ispe" => {
362            let (_version, _flags) = payload.full_box()?;
363            let width = payload.u32()?;
364            let height = payload.u32()?;
365            Ok(Property::Extents(Extents { width, height }))
366        }
367        b"av1C" => parse_av1c(payload).map(Property::Av1Config),
368        b"pixi" => {
369            let (_version, _flags) = payload.full_box()?;
370            let count = payload.u8()?;
371            let mut bits_per_channel = Vec::with_capacity(usize::from(count));
372            for _ in 0..count {
373                bits_per_channel.push(payload.u8()?);
374            }
375            Ok(Property::PixelInfo(PixelInfo { bits_per_channel }))
376        }
377        b"colr" => parse_colr(payload).map(Property::Colour),
378        b"irot" => {
379            // Only the low two bits are the angle; the rest is reserved.
380            let turns = payload.u8()? & 0x03;
381            Ok(Property::Rotation(turns))
382        }
383        b"imir" => {
384            // Low bit, per ISO/IEC 23008-12:2022: mode 0 exchanges top and
385            // bottom, mode 1 exchanges left and right. The 2017 text called
386            // the field `axis` and worded it ambiguously; libavif, which
387            // writes most AVIFs, has always meant what the 2022 text says.
388            let mode = payload.u8()? & 0x01;
389            Ok(Property::Mirror(mode == 1))
390        }
391        b"auxC" => {
392            let (_version, _flags) = payload.full_box()?;
393            let urn = payload.cstring()?;
394            Ok(Property::AuxiliaryType(urn.to_owned()))
395        }
396        b"pasp" => {
397            let horizontal = payload.u32()?;
398            let vertical = payload.u32()?;
399            Ok(Property::PixelAspect(horizontal, vertical))
400        }
401        _ => Ok(Property::Unknown(kind)),
402    }
403}
404
405/// Parse an `av1C` AV1 codec configuration record.
406fn parse_av1c(mut payload: Reader<'_>) -> Result<Av1Config> {
407    let first = payload.u8()?;
408    // Top bit is a marker that must be 1, low seven bits the record version,
409    // which is 1. Both are fixed by the specification, so a mismatch means
410    // this is not an AV1 configuration record.
411    if first >> 7 != 1 {
412        return Err(PixelsError::malformed(
413            "avif",
414            "the av1C marker bit is not set",
415        ));
416    }
417    let version = first & 0x7f;
418    if version != 1 {
419        return Err(PixelsError::malformed(
420            "avif",
421            format!("av1C record version {version} is not 1"),
422        ));
423    }
424
425    let second = payload.u8()?;
426    let seq_profile = second >> 5;
427    let seq_level_idx0 = second & 0x1f;
428
429    let third = payload.u8()?;
430    let seq_tier0 = (third >> 7) & 1;
431    let high_bitdepth = (third >> 6) & 1;
432    let twelve_bit = (third >> 5) & 1;
433    let monochrome = (third >> 4) & 1;
434    let subsampling_x = (third >> 3) & 1;
435    let subsampling_y = (third >> 2) & 1;
436    let chroma_sample_position = third & 0x03;
437
438    // Byte four is reserved bits plus the initial presentation delay, which
439    // only matters for sequences.
440    let _fourth = payload.u8()?;
441
442    // Profile 2 is the only one that can carry twelve-bit samples.
443    let bit_depth = if seq_profile == 2 && high_bitdepth == 1 {
444        if twelve_bit == 1 { 12 } else { 10 }
445    } else if high_bitdepth == 1 {
446        10
447    } else {
448        8
449    };
450
451    let subsampling = match (monochrome, subsampling_x, subsampling_y) {
452        (1, _, _) => Subsampling::Monochrome,
453        (_, 1, 1) => Subsampling::Yuv420,
454        (_, 1, 0) => Subsampling::Yuv422,
455        (_, 0, 0) => Subsampling::Yuv444,
456        // 4:4:0 is not a format AV1 defines.
457        (_, x, y) => {
458            return Err(PixelsError::malformed(
459                "avif",
460                format!(
461                    "av1C declares chroma subsampling ({x}, {y}), which AV1 has no such format for"
462                ),
463            ));
464        }
465    };
466
467    Ok(Av1Config {
468        seq_profile,
469        seq_level_idx0,
470        seq_tier0,
471        bit_depth,
472        subsampling,
473        chroma_sample_position,
474        config_obus: payload.rest().to_vec(),
475    })
476}
477
478/// Parse a `colr` colour information box.
479fn parse_colr(mut payload: Reader<'_>) -> Result<Colour> {
480    let kind = payload.fourcc()?;
481    match &kind.0 {
482        b"nclx" => {
483            let primaries = payload.u16()?;
484            let transfer = payload.u16()?;
485            let matrix = payload.u16()?;
486            // Full-range is the top bit of the next byte; the rest is reserved.
487            let full_range = payload.u8()? >> 7 == 1;
488            Ok(Colour::Nclx {
489                primaries,
490                transfer,
491                matrix,
492                full_range,
493            })
494        }
495        b"rICC" | b"prof" => Ok(Colour::Icc(payload.rest().to_vec())),
496        other => Err(PixelsError::malformed(
497            "avif",
498            format!(
499                "colr declares colour type '{}', which is not one this format defines",
500                FourCc(*other)
501            ),
502        )),
503    }
504}
505
506/// Parse an `ipma` association box into `out`.
507fn parse_ipma(mut payload: Reader<'_>, out: &mut HashMap<u32, Vec<Association>>) -> Result<()> {
508    let (version, flags) = payload.full_box()?;
509    let entry_count = payload.u32()?;
510    // Each entry is at least three bytes, so a count larger than the box could
511    // hold is malformed. Checking up front stops a huge count from driving a
512    // huge allocation before the truncation is noticed.
513    let smallest_entry = if version < 1 { 3 } else { 5 };
514    if u64::from(entry_count) * smallest_entry > payload.remaining() as u64 {
515        return Err(PixelsError::malformed(
516            "avif",
517            format!(
518                "ipma declares {entry_count} entries, more than its {} remaining bytes can hold",
519                payload.remaining()
520            ),
521        ));
522    }
523
524    // Flag bit 0 selects 15-bit property indices over 7-bit ones.
525    let wide_indices = flags & 1 == 1;
526
527    for _ in 0..entry_count {
528        let item_id = if version < 1 {
529            u32::from(payload.u16()?)
530        } else {
531            payload.u32()?
532        };
533        let association_count = payload.u8()?;
534        let mut associations = Vec::with_capacity(usize::from(association_count));
535        for _ in 0..association_count {
536            let (essential, index) = if wide_indices {
537                let word = payload.u16()?;
538                (word >> 15 == 1, word & 0x7fff)
539            } else {
540                let byte = payload.u8()?;
541                (byte >> 7 == 1, u16::from(byte & 0x7f))
542            };
543            associations.push(Association { index, essential });
544        }
545        out.entry(item_id).or_default().extend(associations);
546    }
547    Ok(())
548}
549
550#[cfg(test)]
551#[allow(
552    clippy::unwrap_used,
553    clippy::indexing_slicing,
554    clippy::panic,
555    reason = "tests operate on known-good values and assert shapes directly"
556)]
557mod tests {
558    use super::*;
559    use otf_pixels_core::ErrorCode;
560
561    fn boxed(kind: &[u8; 4], payload: &[u8]) -> Vec<u8> {
562        let mut out = Vec::new();
563        let total = u32::try_from(8 + payload.len()).unwrap();
564        out.extend_from_slice(&total.to_be_bytes());
565        out.extend_from_slice(kind);
566        out.extend_from_slice(payload);
567        out
568    }
569
570    fn ispe(width: u32, height: u32) -> Vec<u8> {
571        let mut payload = vec![0, 0, 0, 0];
572        payload.extend_from_slice(&width.to_be_bytes());
573        payload.extend_from_slice(&height.to_be_bytes());
574        boxed(b"ispe", &payload)
575    }
576
577    /// An `av1C` for 8-bit 4:2:0, profile 0, with one trailing config byte.
578    fn av1c_420_8bit() -> Vec<u8> {
579        // marker+version, profile/level, tier/depth/mono/subsampling, delay.
580        boxed(b"av1C", &[0x81, 0x00, 0x0C, 0x00, 0xAA])
581    }
582
583    fn parse_one(kind: &[u8; 4], payload: &[u8]) -> Result<Property> {
584        let file = boxed(kind, payload);
585        let mut reader = Reader::new(&file);
586        let header = reader.next_box().unwrap().unwrap();
587        parse_property(header.kind, reader.payload(&header))
588    }
589
590    #[test]
591    fn av1c_decodes_profile_depth_and_subsampling() {
592        let file = av1c_420_8bit();
593        let mut reader = Reader::new(&file);
594        let header = reader.next_box().unwrap().unwrap();
595        let Property::Av1Config(config) =
596            parse_property(header.kind, reader.payload(&header)).unwrap()
597        else {
598            panic!("expected an av1C");
599        };
600        assert_eq!(config.seq_profile, 0);
601        assert_eq!(config.bit_depth, 8);
602        assert_eq!(config.subsampling, Subsampling::Yuv420);
603        // Everything after the four-byte record is the config OBU run.
604        assert_eq!(config.config_obus, vec![0xAA]);
605    }
606
607    #[test]
608    fn av1c_twelve_bit_requires_profile_two() {
609        // high_bitdepth=1, twelve_bit=1, but profile 0: reads as 10-bit,
610        // because only profile 2 can carry twelve-bit samples.
611        let file = boxed(b"av1C", &[0x81, 0x00, 0x6C, 0x00]);
612        let mut reader = Reader::new(&file);
613        let header = reader.next_box().unwrap().unwrap();
614        let Property::Av1Config(config) =
615            parse_property(header.kind, reader.payload(&header)).unwrap()
616        else {
617            panic!("expected an av1C");
618        };
619        assert_eq!(config.bit_depth, 10);
620
621        // The same bits under profile 2 do mean twelve.
622        let file = boxed(b"av1C", &[0x81, 0x40, 0x6C, 0x00]);
623        let mut reader = Reader::new(&file);
624        let header = reader.next_box().unwrap().unwrap();
625        let Property::Av1Config(config) =
626            parse_property(header.kind, reader.payload(&header)).unwrap()
627        else {
628            panic!("expected an av1C");
629        };
630        assert_eq!(config.seq_profile, 2);
631        assert_eq!(config.bit_depth, 12);
632    }
633
634    #[test]
635    fn av1c_rejects_a_bad_marker_or_version() {
636        let error = parse_one(b"av1C", &[0x01, 0x00, 0x0C, 0x00]).unwrap_err();
637        assert_eq!(error.code(), ErrorCode::Malformed);
638        assert!(error.to_string().contains("marker"), "{error}");
639
640        let error = parse_one(b"av1C", &[0x82, 0x00, 0x0C, 0x00]).unwrap_err();
641        assert_eq!(error.code(), ErrorCode::Malformed);
642        assert!(error.to_string().contains("version 2"), "{error}");
643    }
644
645    #[test]
646    fn av1c_rejects_a_subsampling_av1_does_not_define() {
647        // 4:4:0 — subsampling_x = 0, subsampling_y = 1.
648        let error = parse_one(b"av1C", &[0x81, 0x00, 0x04, 0x00]).unwrap_err();
649        assert_eq!(error.code(), ErrorCode::Malformed);
650        assert!(error.to_string().contains("subsampling"), "{error}");
651    }
652
653    #[test]
654    fn monochrome_wins_over_the_subsampling_bits() {
655        // monochrome = 1 with both subsampling bits set.
656        let file = boxed(b"av1C", &[0x81, 0x00, 0x1C, 0x00]);
657        let mut reader = Reader::new(&file);
658        let header = reader.next_box().unwrap().unwrap();
659        let Property::Av1Config(config) =
660            parse_property(header.kind, reader.payload(&header)).unwrap()
661        else {
662            panic!("expected an av1C");
663        };
664        assert_eq!(config.subsampling, Subsampling::Monochrome);
665        assert_eq!(config.subsampling.x_shift(), 0);
666        assert_eq!(config.subsampling.y_shift(), 0);
667    }
668
669    #[test]
670    fn colr_reads_nclx_code_points_and_the_range_flag() {
671        let mut payload = Vec::from(*b"nclx");
672        payload.extend_from_slice(&1_u16.to_be_bytes());
673        payload.extend_from_slice(&13_u16.to_be_bytes());
674        payload.extend_from_slice(&6_u16.to_be_bytes());
675        payload.push(0x80);
676
677        let Property::Colour(Colour::Nclx {
678            primaries,
679            transfer,
680            matrix,
681            full_range,
682        }) = parse_one(b"colr", &payload).unwrap()
683        else {
684            panic!("expected an nclx colr");
685        };
686        assert_eq!((primaries, transfer, matrix), (1, 13, 6));
687        assert!(full_range);
688    }
689
690    #[test]
691    fn colr_carries_an_icc_profile_through() {
692        let mut payload = Vec::from(*b"prof");
693        payload.extend_from_slice(&[1, 2, 3, 4]);
694        let Property::Colour(Colour::Icc(profile)) = parse_one(b"colr", &payload).unwrap() else {
695            panic!("expected an ICC colr");
696        };
697        assert_eq!(profile, vec![1, 2, 3, 4]);
698    }
699
700    /// A store holding `properties`, all associated with item 1 in order.
701    fn item_with(properties: Vec<Property>) -> Properties {
702        let associations = (1..=properties.len())
703            .map(|index| Association {
704                index: u16::try_from(index).unwrap(),
705                essential: true,
706            })
707            .collect();
708        Properties {
709            entries: properties,
710            associations: HashMap::from([(1, associations)]),
711        }
712    }
713
714    #[test]
715    fn irot_and_imir_compose_in_association_order() {
716        use Property::{Mirror, Rotation};
717        let cases = [
718            (vec![], Orientation::Normal),
719            // irot counts anticlockwise: one turn is EXIF 8.
720            (vec![Rotation(1)], Orientation::Rotate270),
721            (vec![Rotation(2)], Orientation::Rotate180),
722            (vec![Rotation(3)], Orientation::Rotate90),
723            // libavif: mode 0 is top-to-bottom, mode 1 left-to-right.
724            (vec![Mirror(false)], Orientation::FlipVertical),
725            (vec![Mirror(true)], Orientation::FlipHorizontal),
726            // MIAF order, rotation then mirror. A quarter turn clockwise then
727            // exchanging left and right is the transverse; anticlockwise,
728            // the transpose.
729            (vec![Rotation(3), Mirror(true)], Orientation::Transpose),
730            (vec![Rotation(1), Mirror(true)], Orientation::Transverse),
731            (vec![Rotation(1), Mirror(false)], Orientation::Transpose),
732            // Out of MIAF order: the mirror first. Mirroring then turning
733            // anticlockwise equals turning clockwise then mirroring.
734            (vec![Mirror(true), Rotation(1)], Orientation::Transpose),
735        ];
736        for (properties, expected) in cases {
737            assert_eq!(
738                item_with(properties.clone()).orientation(1),
739                expected,
740                "{properties:?}"
741            );
742        }
743        assert_eq!(Properties::default().orientation(7), Orientation::Normal);
744    }
745
746    #[test]
747    fn orientation_properties_are_masked_to_their_defined_bits() {
748        // Reserved high bits set; only the low two are the angle.
749        assert_eq!(parse_one(b"irot", &[0xFE]).unwrap(), Property::Rotation(2));
750        assert_eq!(
751            parse_one(b"imir", &[0xFE]).unwrap(),
752            Property::Mirror(false)
753        );
754        assert_eq!(parse_one(b"imir", &[0xFF]).unwrap(), Property::Mirror(true));
755    }
756
757    #[test]
758    fn pixi_reads_one_depth_per_channel() {
759        let Property::PixelInfo(info) = parse_one(b"pixi", &[0, 0, 0, 0, 3, 8, 8, 8]).unwrap()
760        else {
761            panic!("expected a pixi");
762        };
763        assert_eq!(info.bits_per_channel, vec![8, 8, 8]);
764    }
765
766    #[test]
767    fn an_uninterpreted_property_is_retained_by_type() {
768        assert_eq!(
769            parse_one(b"clap", &[0; 32]).unwrap(),
770            Property::Unknown(FourCc::new(b"clap"))
771        );
772    }
773
774    /// Build an `iprp` with one `ispe` and one `av1C`, associated to item 1.
775    fn sample_iprp() -> Vec<u8> {
776        let mut ipco = ispe(64, 48);
777        ipco.extend_from_slice(&av1c_420_8bit());
778
779        // version 0, flags 0, one entry, item 1, two associations: property 1
780        // non-essential, property 2 essential.
781        let ipma_payload = vec![0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 2, 0x01, 0x82];
782
783        let mut iprp = boxed(b"ipco", &ipco);
784        iprp.extend_from_slice(&boxed(b"ipma", &ipma_payload));
785        boxed(b"iprp", &iprp)
786    }
787
788    #[test]
789    fn properties_resolve_through_the_association_map() {
790        let file = sample_iprp();
791        let mut reader = Reader::new(&file);
792        let header = reader.next_box().unwrap().unwrap();
793        let properties = Properties::parse(reader.payload(&header)).unwrap();
794
795        assert_eq!(
796            properties.extents(1),
797            Some(Extents {
798                width: 64,
799                height: 48
800            })
801        );
802        assert_eq!(properties.av1_config(1).unwrap().bit_depth, 8);
803        // An item with no associations resolves to nothing rather than failing.
804        assert!(properties.extents(2).is_none());
805        assert!(properties.for_item(2).is_empty());
806    }
807
808    #[test]
809    fn an_out_of_range_association_index_is_skipped_not_indexed() {
810        let mut ipco = ispe(8, 8);
811        ipco.extend_from_slice(&av1c_420_8bit());
812        // Associates property 99, which does not exist, and index 0, which
813        // means "no property".
814        let ipma_payload = vec![0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 2, 99, 0];
815        let mut iprp = boxed(b"ipco", &ipco);
816        iprp.extend_from_slice(&boxed(b"ipma", &ipma_payload));
817        let file = boxed(b"iprp", &iprp);
818
819        let mut reader = Reader::new(&file);
820        let header = reader.next_box().unwrap().unwrap();
821        let properties = Properties::parse(reader.payload(&header)).unwrap();
822        assert!(properties.for_item(1).is_empty());
823    }
824
825    #[test]
826    fn an_essential_property_we_do_not_understand_is_reported() {
827        let mut ipco = ispe(8, 8);
828        // A property type this decoder does not interpret.
829        ipco.extend_from_slice(&boxed(b"zzzz", &[0; 4]));
830        // Item 1 associates property 2 as essential.
831        let ipma_payload = vec![0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 1, 0x82];
832        let mut iprp = boxed(b"ipco", &ipco);
833        iprp.extend_from_slice(&boxed(b"ipma", &ipma_payload));
834        let file = boxed(b"iprp", &iprp);
835
836        let mut reader = Reader::new(&file);
837        let header = reader.next_box().unwrap().unwrap();
838        let properties = Properties::parse(reader.payload(&header)).unwrap();
839        assert_eq!(
840            properties.essential_unknown(1),
841            Some(FourCc::new(b"zzzz")),
842            "an essential unknown property must not be silently ignored"
843        );
844
845        // The same property marked non-essential is ignorable.
846        let ipma_payload = vec![0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 1, 0x02];
847        let mut iprp = boxed(b"ipco", &ipco);
848        iprp.extend_from_slice(&boxed(b"ipma", &ipma_payload));
849        let file = boxed(b"iprp", &iprp);
850        let mut reader = Reader::new(&file);
851        let header = reader.next_box().unwrap().unwrap();
852        let properties = Properties::parse(reader.payload(&header)).unwrap();
853        assert_eq!(properties.essential_unknown(1), None);
854    }
855
856    #[test]
857    fn ipma_supports_wide_indices_and_wide_item_ids() {
858        let mut ipco = ispe(16, 16);
859        ipco.extend_from_slice(&av1c_420_8bit());
860        // version 1 (32-bit item IDs), flags 1 (15-bit property indices).
861        let mut ipma_payload = vec![1, 0, 0, 1];
862        ipma_payload.extend_from_slice(&1_u32.to_be_bytes()); // entry count
863        ipma_payload.extend_from_slice(&70_000_u32.to_be_bytes()); // item ID
864        ipma_payload.push(1); // association count
865        ipma_payload.extend_from_slice(&0x0001_u16.to_be_bytes()); // property 1
866
867        let mut iprp = boxed(b"ipco", &ipco);
868        iprp.extend_from_slice(&boxed(b"ipma", &ipma_payload));
869        let file = boxed(b"iprp", &iprp);
870
871        let mut reader = Reader::new(&file);
872        let header = reader.next_box().unwrap().unwrap();
873        let properties = Properties::parse(reader.payload(&header)).unwrap();
874        assert_eq!(
875            properties.extents(70_000),
876            Some(Extents {
877                width: 16,
878                height: 16
879            })
880        );
881    }
882
883    /// A huge entry count must be rejected against the box's actual size
884    /// rather than driving an allocation proportional to the claim.
885    #[test]
886    fn ipma_rejects_more_entries_than_the_box_can_hold() {
887        let ipma_payload = vec![0, 0, 0, 0, 0xFF, 0xFF, 0xFF, 0xFF, 0, 1, 0];
888        let mut iprp = boxed(b"ipco", &ispe(8, 8));
889        iprp.extend_from_slice(&boxed(b"ipma", &ipma_payload));
890        let file = boxed(b"iprp", &iprp);
891
892        let mut reader = Reader::new(&file);
893        let header = reader.next_box().unwrap().unwrap();
894        let error = Properties::parse(reader.payload(&header)).unwrap_err();
895        assert_eq!(error.code(), ErrorCode::Malformed);
896        assert!(error.to_string().contains("more than its"), "{error}");
897    }
898
899    #[test]
900    fn auxc_reads_the_alpha_urn() {
901        let mut payload = vec![0, 0, 0, 0];
902        payload.extend_from_slice(b"urn:mpeg:mpegB:cicp:systems:auxiliary:alpha\0");
903        assert_eq!(
904            parse_one(b"auxC", &payload).unwrap(),
905            Property::AuxiliaryType("urn:mpeg:mpegB:cicp:systems:auxiliary:alpha".to_owned())
906        );
907    }
908}