diff --git a/Cargo.lock b/Cargo.lock index fcdd8e15..6958d2c0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -739,6 +739,7 @@ dependencies = [ "gamut-color", "gamut-core", "gamut-isobmff", + "gamut-metadata", "libheif-oracle", ] @@ -797,6 +798,7 @@ dependencies = [ "gamut-color", "gamut-core", "gamut-dsp", + "gamut-metadata", "libjpeg-oracle", ] @@ -807,6 +809,7 @@ dependencies = [ "gamut-codec-abi", "gamut-core", "gamut-jxl-sys", + "gamut-metadata", "jxl", ] diff --git a/crates/gamut-heic/Cargo.toml b/crates/gamut-heic/Cargo.toml index 922963a3..e03fed56 100644 --- a/crates/gamut-heic/Cargo.toml +++ b/crates/gamut-heic/Cargo.toml @@ -20,6 +20,16 @@ gamut-codec-abi.workspace = true gamut-core.workspace = true gamut-color.workspace = true gamut-isobmff.workspace = true +# Typed metadata (issue #420): the unified `Metadata` model over the Exif / XMP items and `colr` +# ICC profile this crate locates. Optional and off by default, and a *normal* dependency (never +# dev-only) so release ordering follows it (`mise run check-release-deps`). +gamut-metadata = { workspace = true, optional = true } + +[features] +default = [] +# Typed metadata wiring: `HeifImage::blocks` / `HeifImage::metadata` over the `gamut-metadata` +# facade (decode-only, like the crate). +metadata = ["dep:gamut-metadata"] [dev-dependencies] # libheif (+ libde265 decode, kvazaar encode) as the differential-conformance oracle: FFI to a diff --git a/crates/gamut-heic/STATUS.md b/crates/gamut-heic/STATUS.md index c6f0a828..cc7e4535 100644 --- a/crates/gamut-heic/STATUS.md +++ b/crates/gamut-heic/STATUS.md @@ -151,7 +151,7 @@ references (`dinf`/`dref`, `iloc` `construction_method` 2); mirroring the finali | Meta-level accounting: `meta`/`iprp` children not consumed by the model surfaced as `UnknownBox` (e.g. `dinf`/`dref`, `uuid`) | 14496-12 | ✅ | S1 | | C2PA manifest store located in a top-level `uuid` `ContentProvenanceBox`: opaque bytes + exact byte range, purposes `manifest`/`original`/`update` (`c2pa`, `c2pa_manifest_stores`) | C2PA 2.4 §A.5.1, §A.5.3, §8.4.2.3 (`references/c2pa` pending, #431) | ✅ | S7 | | Store bounding is `LBox`-only and content-dependent (`LBox` validity alone cannot separate a store bound from a plausible interior length). Two routes close it: assert the `jumb` `TBox` — traceable to §A.3.9/§15.12.3.2 but only as a JPEG XL aside, so it is a maintainer call because it narrows what is reported — or confirm the store by §11.1.4.2's JUMBF type UUID, which needs 19566-5's Description Box layout. A `c2pa-rs` oracle fixture would settle either empirically | C2PA 2.4 §A.3.9, §11.1.4.2, §A.5.3; ISO/IEC 19566-5 (not vendored) | ☐ | #239 oracle | -| C2PA store surfaced through the `gamut-metadata` facade as a `MetadataBlock` | C2PA 2.4 §A.5 | ☐ | later | +| C2PA store surfaced through the `gamut-metadata` facade as a `MetadataBlock` (the store lives in a top-level `uuid` box outside the item model `HeifImage::blocks` reads; a caller appends `MetadataBlock::C2pa(HeifContainer::c2pa().bytes)` itself) | C2PA 2.4 §A.5 | ☐ | later | | C2PA validation: JUMBF interior parse, `c2pa.hash.bmff.v3` hard binding, signature/trust verification | C2PA 2.4 §18.6, §A.5.6 | ☐ | user / #239 | | `ftyp` brands + `is_hevc_still` (`heic`/`heix`/`heim`/`heis`, or `mif1`+`hvcC` primary) | 23008-12; `references/heif` §7 | ✅ | S1 | | Sequence brands `msf1`/`hevc`/`hevx` (image sequences) | `references/heif` §7 | OOS | OOS | @@ -175,7 +175,8 @@ references (`dinf`/`dref`, `iloc` `construction_method` 2); mirroring the finali | Derived-image sources (`dimg`), `grid` payload + tile-count validation, `iovl` payload | 23008-12 §6.6.2; `references/heif` §4 | ✅ | S1 | | `iden` identity derived item recognised (kind); source via `dimg` | 23008-12 §6.6.2.1 | ✅ | S1 | | Entity groups + `altr` alternatives lens | 14496-12; MIAF | ✅ | S1 | -| Decoded Exif/XMP bytes → `gamut-exif`/`gamut-xmp` (payload exposed opaque here) | 23008-12 §A | ☐ | later | +| Exif `ExifDataBlock` lens: `HeifItem::exif_tiff_stream` applies the 4-byte `exif_tiff_header_offset` and yields the TIFF stream (`II`/`MM`); `HeifItem::icc_profile` yields the `rICC`/`prof` bytes regardless of `nclx` order | 23008-12 §A.2.1; `references/heif` §9 | ✅ | #420 | +| Decoded Exif/XMP/ICC bytes → the `gamut-metadata` facade: `HeifImage::blocks` (`MetadataBlock`s) and `HeifImage::metadata` (`Metadata`), behind the opt-in `metadata` feature (a normal, optional dependency). Pinned by typed extraction from an authored fixture at offsets 0 and 6. **Oracle cell not covered:** `tooling/exiv2-oracle` is block-level and in-memory (no HEIF reader), so "exiv2 reads the items out of the HEIC" is untested; the item bytes are pinned byte-exact against libheif (`tests/conformance.rs`) and the leaf crates pin the payloads against exiv2 (#510) | 23008-12 §A; issue #420 | ✅ | #420 | | Protected / `uri ` items; external data references | 23008-12 | OOS | OOS | ## C. HEVC configuration & NAL layer (14496-15 · H.265) diff --git a/crates/gamut-heic/src/image.rs b/crates/gamut-heic/src/image.rs index ff55de6a..115b7791 100644 --- a/crates/gamut-heic/src/image.rs +++ b/crates/gamut-heic/src/image.rs @@ -286,6 +286,62 @@ impl HeifImage { } } +#[cfg(feature = "metadata")] +impl HeifImage { + /// The primary item's located metadata payloads as + /// [`MetadataBlock`](gamut_metadata::MetadataBlock)s, ready for + /// [`Metadata::from_blocks`](gamut_metadata::Metadata::from_blocks) or a + /// [`MetadataExtractor`](gamut_metadata::MetadataExtractor) with a chosen + /// [`ConflictPolicy`](gamut_metadata::ConflictPolicy): the Exif item's TIFF stream + /// ([`HeifItem::exif_tiff_stream`]), the XMP `mime` item's packet ([`xmp`](Self::xmp)) and the + /// primary item's `colr` ICC profile ([`HeifItem::icc_profile`]), each present only when the + /// file carries it. + /// + /// HEIF has no IPTC-IIM item type, so no `IptcIim` block is produced. A C2PA manifest store + /// lives in a top-level `uuid` box outside the item model, so it is not produced here either: + /// [`HeifContainer::c2pa`](crate::HeifContainer::c2pa) locates it, and a caller wanting it in + /// the same model appends a [`MetadataBlock::C2pa`](gamut_metadata::MetadataBlock::C2pa). + /// + /// # Errors + /// + /// Returns [`Error::InvalidInput`] if the Exif item's payload is malformed — shorter than its + /// 4-byte `exif_tiff_header_offset`, or with the offset past the payload's end. + pub fn blocks(&self) -> Result>> { + use gamut_metadata::MetadataBlock; + let mut blocks = Vec::new(); + if let Some(exif) = self.exif() { + blocks.push(MetadataBlock::Exif(exif.exif_tiff_stream()?)); + } + if let Some(xmp) = self.xmp() { + blocks.push(MetadataBlock::Xmp(&xmp.as_isobmff_item().payload)); + } + if let Some(icc) = self.primary_item().icc_profile() { + blocks.push(MetadataBlock::Icc(icc)); + } + Ok(blocks) + } + + /// Parses the primary item's located metadata into the unified + /// [`Metadata`](gamut_metadata::Metadata) model — + /// [`Metadata::from_blocks`](gamut_metadata::Metadata::from_blocks) over + /// [`blocks`](Self::blocks). + /// + /// # Errors + /// + /// Returns [`Error::InvalidInput`] as [`blocks`](Self::blocks) does, or when a located payload + /// does not parse — the facade's [`MetadataError`](gamut_metadata::MetadataError) message, + /// naming the carrier, is carried as [`Error::detail`]. + pub fn metadata(&self) -> Result { + gamut_metadata::Metadata::from_blocks(&self.blocks()?).map_err(|e| { + Error::invalid_input( + env!("CARGO_PKG_NAME"), + "HEIF: embedded metadata does not parse", + ) + .with_detail(e.to_string()) + }) + } +} + /// A single HEIF item, viewed by role. A zero-cost borrow of the underlying [`gamut_isobmff::Item`]; /// [`as_isobmff_item`](Self::as_isobmff_item) exposes it. Per-item accessors read the item's type /// and properties; cross-item relationships live on [`HeifImage`]. @@ -301,6 +357,55 @@ impl<'a> HeifItem<'a> { self.inner } + /// For an `Exif` item, the TIFF stream (starting `II`/`MM`) behind the payload's 4-byte + /// big-endian `exif_tiff_header_offset` — the `ExifDataBlock` of ISO/IEC 23008-12 §A.2.1, + /// whose offset counts bytes from the end of the field to the TIFF header (`references/heif` + /// §9). This is the form `gamut-exif` parses; the raw payload, offset included, stays + /// available through [`as_isobmff_item`](Self::as_isobmff_item). + /// + /// # Errors + /// + /// Returns [`Error::InvalidInput`] if the item is not an `Exif` item, if the payload is shorter + /// than the offset field, or if the offset points past the payload's end. + pub fn exif_tiff_stream(&self) -> Result<&'a [u8]> { + if !matches!(self.kind(), ItemKind::Exif) { + return Err(Error::invalid_input( + env!("CARGO_PKG_NAME"), + "HEIF: item is not an Exif item", + )); + } + let [o0, o1, o2, o3, rest @ ..] = self.inner.payload.as_slice() else { + return Err(Error::invalid_input( + env!("CARGO_PKG_NAME"), + "HEIF: Exif item payload is shorter than its tiff-header offset field", + )); + }; + usize::try_from(u32::from_be_bytes([*o0, *o1, *o2, *o3])) + .ok() + .and_then(|offset| rest.get(offset..)) + .ok_or_else(|| { + Error::invalid_input( + env!("CARGO_PKG_NAME"), + "HEIF: Exif item tiff-header offset out of range", + ) + }) + } + + /// The ICC profile carried by the item's first `colr` property of ICC type (`rICC` or `prof`), + /// if any — the bytes `gamut-icc` parses. An item may carry both an `nclx` and an ICC `colr` + /// (MIAF allows the pair); [`colour`](Self::colour) returns whichever comes first, this lens + /// the profile regardless of order. + #[must_use] + pub fn icc_profile(&self) -> Option<&'a [u8]> { + self.inner.properties.iter().find_map(|p| match &p.kind { + PropertyKind::Colour( + ColourInformation::RestrictedIcc(profile) + | ColourInformation::UnrestrictedIcc(profile), + ) => Some(profile.as_slice()), + _ => None, + }) + } + /// The item's id. #[must_use] pub fn id(&self) -> u32 { @@ -698,3 +803,121 @@ fn is_alpha_urn(aux_type: &str) -> bool { fn is_depth_urn(aux_type: &str) -> bool { DEPTH_AUX_URNS.contains(&aux_type) } + +/// Unit tests for the two metadata lenses on [`HeifItem`]: the `exif_tiff_header_offset` +/// arithmetic of `exif_tiff_stream` and the property search of `icc_profile`. They read +/// `HeifImage::new`, which is `pub(crate)`, so they live here. +#[cfg(test)] +mod tests { + use gamut_core::ErrorKind; + use gamut_isobmff::{IsoBmffImage, NclxColr, Property}; + + use super::*; + + /// A one-item file whose primary is `item`. + fn image_of(item: Item) -> HeifImage { + HeifImage::new(IsoBmffImage { + major_brand: *b"heic", + minor_version: 0, + compatible_brands: vec![*b"heic", *b"mif1"], + primary_item_id: item.id, + items: vec![item], + groups: vec![], + }) + .unwrap() + } + + /// A bare item of the given type and payload. + fn item(item_type: [u8; 4], payload: Vec, properties: Vec) -> Item { + Item { + id: 1, + item_type, + name: String::new(), + content_type: None, + content_encoding: None, + hidden: false, + references: vec![], + properties, + payload, + } + } + + fn colr(info: ColourInformation) -> Property { + Property { + essential: false, + kind: PropertyKind::Colour(info), + } + } + + #[test] + fn exif_tiff_stream_skips_the_offset_field_and_the_offset() { + // Offset 0 (the usual case) and a non-zero offset skipping filler bytes. + for (payload, expected) in [ + (b"\0\0\0\0II*\0".to_vec(), &b"II*\0"[..]), + (b"\0\0\0\x02\xEE\xEEMM\0*".to_vec(), &b"MM\0*"[..]), + // An offset landing exactly at the end is an empty stream, not an error. + (b"\0\0\0\x01\xEE".to_vec(), &b""[..]), + ] { + let image = image_of(item(*b"Exif", payload.clone(), vec![])); + assert_eq!( + image.primary_item().exif_tiff_stream().unwrap(), + expected, + "{payload:?}" + ); + } + } + + #[test] + fn exif_tiff_stream_refuses_the_named_faults() { + let cases: [(Item, &str); 3] = [ + ( + item(*b"mime", b"\0\0\0\0".to_vec(), vec![]), + "HEIF: item is not an Exif item", + ), + ( + item(*b"Exif", b"\0\0\0".to_vec(), vec![]), + "HEIF: Exif item payload is shorter than its tiff-header offset field", + ), + ( + item(*b"Exif", b"\0\0\0\x05II*\0".to_vec(), vec![]), + "HEIF: Exif item tiff-header offset out of range", + ), + ]; + for (item, message) in cases { + let image = image_of(item); + let err = image.primary_item().exif_tiff_stream().unwrap_err(); + assert_eq!(err.kind(), ErrorKind::InvalidInput, "{message}"); + assert_eq!(err.static_message(), Some(message)); + } + } + + #[test] + fn icc_profile_finds_the_icc_colr_behind_an_nclx_one() { + let nclx = ColourInformation::Nclx(NclxColr { + colour_primaries: 1, + transfer_characteristics: 13, + matrix_coefficients: 6, + full_range: true, + }); + let hvc1 = |props: Vec| item(*b"hvc1", vec![0xAA], props); + + // nclx first, then `prof`: `colour()` reports the nclx, the lens the profile. + let image = image_of(hvc1(vec![ + colr(nclx.clone()), + colr(ColourInformation::UnrestrictedIcc(vec![1, 2, 3])), + ])); + let primary = image.primary_item(); + assert!(matches!(primary.colour(), Some(ColourInformation::Nclx(_)))); + assert_eq!(primary.icc_profile(), Some(&[1u8, 2, 3][..])); + + // `rICC` counts too. + let image = image_of(hvc1(vec![colr(ColourInformation::RestrictedIcc(vec![9]))])); + assert_eq!(image.primary_item().icc_profile(), Some(&[9u8][..])); + + // nclx alone, or no colr at all: no profile. + let image = image_of(hvc1(vec![colr(nclx)])); + assert_eq!(image.primary_item().icc_profile(), None); + let image = image_of(hvc1(vec![])); + assert_eq!(image.primary_item().icc_profile(), None); + } +} diff --git a/crates/gamut-heic/src/lib.rs b/crates/gamut-heic/src/lib.rs index 540b16d1..29fec200 100644 --- a/crates/gamut-heic/src/lib.rs +++ b/crates/gamut-heic/src/lib.rs @@ -77,11 +77,22 @@ //! [`HevcDecoder`] seam — over gamut-authored fixtures generated at test time //! (`tests/conformance.rs`, the dev-only `tooling/libheif-oracle`; see `references/heif` "Oracle"). //! +//! # Metadata +//! +//! [`HeifImage::exif`] / [`HeifImage::xmp`] locate the Exif and XMP items describing the primary +//! image, [`HeifItem::exif_tiff_stream`] applies the Exif item's `exif_tiff_header_offset` to +//! yield the TIFF stream `gamut-exif` parses, and [`HeifItem::icc_profile`] yields the `colr` ICC +//! bytes `gamut-icc` parses. With the optional **`metadata`** Cargo feature (off by default) the +//! same payloads are wired to the `gamut-metadata` facade's typed models: [`HeifImage::blocks`] +//! hands them over as `MetadataBlock`s and [`HeifImage::metadata`] parses them into a unified +//! `Metadata`. A C2PA manifest store lives outside the item model, in a top-level `uuid` box; +//! [`HeifContainer::c2pa`] locates it. The dependency direction stays +//! `gamut-heic → gamut-metadata`. +//! //! # Deferred to later slices //! -//! Wiring the decoded Exif/XMP bytes through `gamut-exif`/`gamut-xmp`. Image *sequences* -//! (`msf1`/`hevc`/`hevx` tracks) are permanently out of scope (gamut is image-first). See this -//! crate's `STATUS.md`. +//! Image *sequences* (`msf1`/`hevc`/`hevx` tracks) are permanently out of scope (gamut is +//! image-first). See this crate's `STATUS.md`. //! //! # Example //! @@ -160,6 +171,10 @@ pub use backend::{ pub use c2pa::{C2PA_UUID, C2paBoxPurpose, C2paManifestStore}; pub use container::{HeifContainer, Segment, SegmentKind, UnknownBox, UnknownBoxLocation}; pub use decode::{DecodedFrame, HevcDecoder}; +// The facade types named in the `metadata`-feature signatures, so a caller can spell +// `HeifImage::metadata` / `HeifImage::blocks` without a direct dependency. +#[cfg(feature = "metadata")] +pub use gamut_metadata::{Metadata, MetadataBlock}; pub use hvcc::{ChromaFormat, HevcConfig, NalArray}; pub use image::{ CleanAperture, ContentLightLevel, HeifImage, HeifItem, ItemKind, PixelAspectRatio, diff --git a/crates/gamut-heic/tests/metadata_facade.rs b/crates/gamut-heic/tests/metadata_facade.rs new file mode 100644 index 00000000..de9a2cf4 --- /dev/null +++ b/crates/gamut-heic/tests/metadata_facade.rs @@ -0,0 +1,143 @@ +//! The `metadata` feature over a HEIF fixture: `HeifImage::blocks` / `metadata` hand the Exif +//! item (offset applied), the XMP `mime` item and the `colr` ICC profile to the facade, and the +//! typed model equals the one the payloads were built from. Decode-only (the crate has no +//! encoder), so the fixture is authored through `gamut_isobmff::write`. +#![cfg(feature = "metadata")] + +mod common; + +use common::{clean_file, hvc1_item, iref, item}; +use gamut_core::ErrorKind; +use gamut_heic::{HeifContainer, Metadata, MetadataBlock}; +use gamut_isobmff::{ColourInformation, Item, Property, PropertyKind}; +use gamut_metadata::exif::{ByteOrder, Exif, ExifTag, Value}; +use gamut_metadata::icc::{ColorSpace, DeviceClass, IccProfile, ProfileHeader}; +use gamut_metadata::xmp::{WellKnownNs, XmpMeta}; + +/// The three carrier payloads as the leaf crates serialize them: an `Exif\0\0`-prefixed EXIF +/// blob, an XMP packet and an ICC profile. +fn payloads() -> (Vec, Vec, Vec) { + let mut exif = Exif::new(ByteOrder::LittleEndian); + exif.set_tag(ExifTag::Make, Value::Ascii("gamut".to_owned())); + let mut xmp = XmpMeta::new(); + xmp.set_text(WellKnownNs::Xmp.uri(), "CreatorTool", "gamut"); + let icc = IccProfile { + header: ProfileHeader::new(DeviceClass::Display, ColorSpace::Rgb), + tags: Vec::new(), + }; + let encoded = Metadata::from_carriers(Some(exif), Some(xmp), Some(icc)) + .encode() + .unwrap(); + ( + encoded.exif.unwrap(), + encoded.xmp.unwrap(), + encoded.icc.unwrap(), + ) +} + +/// A HEIF whose primary (1) carries a `prof` ICC `colr`, described by an Exif item (2) with the +/// given `ExifDataBlock` payload and an XMP `mime` item (3). +fn fixture(exif_payload: Vec, xmp: Vec, icc: Vec) -> Vec { + let mut primary = hvc1_item(1, vec![1, 2, 3, 4]); + primary.properties.push(Property { + essential: false, + kind: PropertyKind::Colour(ColourInformation::UnrestrictedIcc(icc)), + }); + let exif = Item { + references: vec![iref(b"cdsc", &[1])], + ..item(2, *b"Exif", exif_payload) + }; + let xmp = Item { + content_type: Some("application/rdf+xml".to_string()), + references: vec![iref(b"cdsc", &[1])], + ..item(3, *b"mime", xmp) + }; + clean_file(1, vec![primary, exif, xmp]) +} + +/// `offset` as a big-endian `exif_tiff_header_offset` followed by `rest`. +fn exif_data_block(offset: u32, rest: &[u8]) -> Vec { + let mut out = offset.to_be_bytes().to_vec(); + out.extend_from_slice(rest); + out +} + +#[test] +fn typed_metadata_is_extracted_from_the_items_and_the_colr() { + let (exif, xmp, icc) = payloads(); + // The model the payloads came from, as the facade itself extracts it (the ICC header's + // `size` is stamped by serialization, so the hand-built model is not the comparison point). + let expected = Metadata::from_blocks(&[ + MetadataBlock::Exif(&exif), + MetadataBlock::Xmp(&xmp), + MetadataBlock::Icc(&icc), + ]) + .unwrap(); + + // Offset 0 over the bare TIFF stream (the usual authoring), and offset 6 keeping the + // `Exif\0\0` signature in front of the TIFF header — both locate the same stream. + let tiff = exif.strip_prefix(b"Exif\0\0").unwrap(); + for exif_payload in [exif_data_block(0, tiff), exif_data_block(6, &exif)] { + let data = fixture(exif_payload, xmp.clone(), icc.clone()); + let container = HeifContainer::parse(&data).unwrap(); + let image = container.image(); + + let blocks = image.blocks().unwrap(); + assert_eq!( + blocks, + vec![ + MetadataBlock::Exif(tiff), + MetadataBlock::Xmp(&xmp), + MetadataBlock::Icc(&icc), + ] + ); + assert_eq!(image.metadata().unwrap(), expected); + } +} + +#[test] +fn a_file_without_metadata_yields_an_empty_model() { + let data = clean_file(1, vec![hvc1_item(1, vec![1, 2, 3, 4])]); + let container = HeifContainer::parse(&data).unwrap(); + assert!(container.image().blocks().unwrap().is_empty()); + assert_eq!(container.image().metadata().unwrap(), Metadata::default()); +} + +#[test] +fn a_malformed_exif_item_is_invalid_input_from_both_accessors() { + let (_, xmp, icc) = payloads(); + // Three bytes: shorter than the offset field itself. + let data = fixture(vec![0, 0, 0], xmp, icc); + let container = HeifContainer::parse(&data).unwrap(); + for err in [ + container.image().blocks().unwrap_err(), + container.image().metadata().unwrap_err(), + ] { + assert_eq!(err.kind(), ErrorKind::InvalidInput); + assert_eq!( + err.static_message(), + Some("HEIF: Exif item payload is shorter than its tiff-header offset field") + ); + } +} + +#[test] +fn an_unparsable_payload_is_invalid_input_with_the_facade_detail() { + let (exif, xmp, _) = payloads(); + let tiff = exif.strip_prefix(b"Exif\0\0").unwrap(); + // A `prof` colr whose bytes are not an ICC profile: located fine, refused by the facade. + let data = fixture( + exif_data_block(0, tiff), + xmp, + b"not an icc profile".to_vec(), + ); + let container = HeifContainer::parse(&data).unwrap(); + assert_eq!(container.image().blocks().unwrap().len(), 3); + let err = container.image().metadata().unwrap_err(); + assert_eq!(err.kind(), ErrorKind::InvalidInput); + assert_eq!( + err.static_message(), + Some("HEIF: embedded metadata does not parse") + ); + assert!(err.detail().is_some_and(|d| d.starts_with("ICC:")), "{err}"); +} diff --git a/crates/gamut-jpeg/Cargo.toml b/crates/gamut-jpeg/Cargo.toml index 525f447b..8394ccad 100644 --- a/crates/gamut-jpeg/Cargo.toml +++ b/crates/gamut-jpeg/Cargo.toml @@ -24,6 +24,17 @@ gamut-codec-abi.workspace = true gamut-color.workspace = true # The §A.3.3 forward DCT kernel and the §A.3.4 round-to-nearest quantizer divide. gamut-dsp.workspace = true +# Typed metadata (issue #420): the unified `Metadata` model over the raw APP1/APP2 payloads this +# crate locates and writes. Optional and off by default — a plain JPEG consumer pays for none of +# the four metadata crates — and a *normal* dependency (never dev-only), so release ordering +# follows it (`mise run check-release-deps`). +gamut-metadata = { workspace = true, optional = true } + +[features] +default = [] +# Typed metadata wiring: `JpegMetadata::blocks` / `JpegMetadata::metadata` and +# `JpegEncoder::with_metadata` / `with_encoded_metadata` over the `gamut-metadata` facade. +metadata = ["dep:gamut-metadata"] [dev-dependencies] # Differential cross-check oracle: a vendored, statically-linked libjpeg-turbo 3.2.0 (built from the diff --git a/crates/gamut-jpeg/STATUS.md b/crates/gamut-jpeg/STATUS.md index 1afa4ad0..0f7ae36f 100644 --- a/crates/gamut-jpeg/STATUS.md +++ b/crates/gamut-jpeg/STATUS.md @@ -48,8 +48,15 @@ progressive-stream walker (scan script, per-scan DHTs, restart cadence, EOBn-run - Colour-space handling: JFIF (APP0) and Adobe (APP14) transform flags; CMYK / YCCK **decode**. - APP-segment metadata (P7): APP1 EXIF + XMP and multi-segment APP2 `ICC_PROFILE`, **read** (`metadata()`) and **write** (`with_exif`/`with_xmp`/`with_icc_profile`), raw-bytes payloads that - feed `gamut-metadata`'s `MetadataBlock` directly (proven by a dev-only interop test; the runtime - dependency edge stays jpeg ← core, color, dsp). + feed `gamut-metadata`'s `MetadataBlock` directly (the default dependency edge stays jpeg ← core, + color, dsp). +- Typed metadata (P14, issue #420) behind the opt-in **`metadata`** feature (a normal, optional + dependency on `gamut-metadata`): `JpegMetadata::blocks` (EXIF = the TIFF stream, `Exif\0\0` + stripped; XMP = the `xpacket`; ICC = the reassembled profile) and `JpegMetadata::metadata` + (`Metadata::from_blocks`); `JpegEncoder::with_metadata(&Metadata)` (default `MetadataEmbedder`) + and `with_encoded_metadata(&EncodedMetadata)` (caller-chosen policies), routing each carrier to + the raw setter above. IPTC-IIM and C2PA blocks are typed `Unsupported` on the encoder (no APP13 / + APP11 carriage — see below), and a C2PA store is never copied forward (facade policy). - Pluggable codestream backends (P8, issue #277): the `backend` module's `JpegStreamDecoder` / `JpegStreamEncoder` traits, `JpegDecoder::push_backend` / `JpegEncoder::push_backend`, and the `gamut-codec-abi` adapters in both directions. @@ -228,3 +235,4 @@ progressive-stream walker (scan script, per-scan DHTs, restart cadence, EOBn-run | P11 | T.81 §A.3.4, §B.2.4.1; issue #332 | **Caller-supplied quantization tables:** the public `QuantTables` pair — natural order, every entry `1..=255` **by construction** (`new` rejects zero, so the encoder never divides by zero and never emits a DQT its own decoder refuses) — used verbatim via `JpegEncoder::with_quant_tables`, with `annex_k()`/`scaled()` recovering the frozen IJG mapping over arbitrary bases. Quality becomes inert while set; the frozen quality contract still governs the default path; backends are vetoed (a `JpegEncodeRequest` cannot carry tables). Alternate built-in base tables deferred (citation obligation) | ✅ done | | P12 | T.81 §F.1.2, Annex K.5/K.6; issue #333 | **RD-optimized coefficient selection:** the `rd` module's per-block AC trellis — dynamic program over (position, {v, v−1}) nodes with the exact run/size + ZRL + EOB bit cost of the typical AC tables as rate proxy, step-normalized distortion (the quantization table as the perceptual weighting), tuned dimensionless λ (pinned; measured 8.7% battery-wide saving at ≤ 0.35 dB) — plus `TrellisAdaptive` per-block λ modulation `√(energy/Σstep²)` clamped `[¼, 4]`. Opt-in `with_rd_optimization`; DC plain (deferred: cross-block DP through the DC predictor/restarts); default byte-identical; backends vetoed; progressive carries identical coefficients by the shared `quantize_block_rd` seam + configuration-only rate model | ✅ done | | P13 | Adobe TN #5116; ISO/IEC 18181-1 (XYB, via the vendored CD + pinned libjxl 0.12.0 constants); issue #334 | **XYB colour mode:** `with_color_mode(JpegColorMode::Xyb)` — sRGB → linear (EOTF LUT) → opsin XYB → scaled-XYB u8 planes (X, Y, B−Y) coded 4:4:4 under SOI → APP14 `transform=0` → EXIF/XMP → APP2 `XYB_ICC_PROFILE` → DQT, SOF ids `R`,`G`,`B`, X/Y on the luminance table and B−Y on the chrominance table (placeholder pairing, ledgered above). The 768-byte profile (input class, D50 XYZ PCS, `desc`/`cprt`/`wtpt`/`chad`/`A2B0` mAB with a 2×2×2 CLUT + cube-root parametric M curves + the frozen 0.5·XYZ(D50)·OpsinInverse matrix/`B2A0` no-op) is vendored static and umbrella-pinned; the decoder needs zero changes (RGB passthrough via APP14/ids). Differential: libjpeg-turbo decodes + test-side XYB inversion ≥ 40 dB on the battery; lcms2 reproduces sRGB from real samples (worst 25 codes at the documented near-black X amplification, mean 2.46) | ✅ done | +| P14 | issue #420; `gamut-metadata` | **Typed metadata wiring** behind the opt-in `metadata` feature: `JpegMetadata::blocks` / `metadata`, `JpegEncoder::with_metadata` / `with_encoded_metadata`; IPTC-IIM / C2PA typed `Unsupported`; pinned by the typed extract → embed → extract equality through the stream. **Oracle cell not covered:** `tooling/exiv2-oracle` is block-level and in-memory (no JPEG reader), so "exiv2 reads the embedded APP1/APP2 payloads out of the JPEG" is untested; the located bytes are pinned byte-exact against libjpeg-turbo (ICC, `tests/oracle.rs`) and the leaf crates pin the payloads against exiv2 (#510) | ✅ done | diff --git a/crates/gamut-jpeg/src/lib.rs b/crates/gamut-jpeg/src/lib.rs index 98d2f79a..11179de0 100644 --- a/crates/gamut-jpeg/src/lib.rs +++ b/crates/gamut-jpeg/src/lib.rs @@ -50,6 +50,13 @@ //! embed them. The payloads are raw bytes in exactly the form the `gamut-metadata` facade's //! `MetadataBlock` borrows. //! +//! With the optional **`metadata`** Cargo feature the same payloads are wired to the facade's typed +//! models: [`JpegMetadata::blocks`] hands them over as `MetadataBlock`s and +//! [`JpegMetadata::metadata`] parses them into a unified `Metadata`, while +//! [`JpegEncoder::with_metadata`] / [`JpegEncoder::with_encoded_metadata`] embed one, routing each +//! carrier to the raw setter above. The feature is off by default so a plain JPEG consumer never +//! builds the metadata crates; the dependency direction stays `gamut-jpeg → gamut-metadata`. +//! //! # Pluggable backends //! //! Both directions are pluggable through the [`backend`] module: [`JpegStreamDecoder`] and @@ -102,6 +109,8 @@ mod decoder; mod encoder; mod huffman; mod marker; +#[cfg(feature = "metadata")] +mod metadata; mod progressive; mod quant; mod rd; @@ -116,5 +125,9 @@ pub use backend::{ }; pub use decoder::{JpegDecoder, JpegInfo, JpegMetadata, JpegProcess, info, metadata}; pub use encoder::{ChromaSubsampling, JpegColorMode, JpegEncoder, RdOptimization, XYB_ICC_PROFILE}; +// The facade types named in the `metadata`-feature signatures, so a caller can spell +// `JpegMetadata::metadata` / `JpegEncoder::with_metadata` without a direct dependency. +#[cfg(feature = "metadata")] +pub use gamut_metadata::{EncodedMetadata, Metadata, MetadataBlock}; pub use marker::DensityUnit; pub use quant::{CHROMINANCE, LUMINANCE, QuantTables}; diff --git a/crates/gamut-jpeg/src/metadata.rs b/crates/gamut-jpeg/src/metadata.rs new file mode 100644 index 00000000..ad35ce3a --- /dev/null +++ b/crates/gamut-jpeg/src/metadata.rs @@ -0,0 +1,309 @@ +//! The `metadata` feature: typed [`gamut_metadata`] wiring over the raw APP-segment surface. +//! +//! [`crate::metadata`] locates the APP1 EXIF / APP1 XMP / APP2 `ICC_PROFILE` payloads as bytes; +//! this module hands them to the facade as [`MetadataBlock`]s and turns a facade [`Metadata`] back +//! into the encoder's raw setters. The dependency direction is `gamut-jpeg → gamut-metadata` +//! (the facade never learns about JPEG segments), and the module is compiled only with the +//! `metadata` Cargo feature so a plain JPEG consumer never pays for the metadata crates. +//! +//! The block boundaries are the ones the facade documents: the EXIF block is the **TIFF stream** +//! (`II`/`MM` first; the `Exif\0\0` APP1 signature is already stripped by [`crate::metadata`] and +//! re-added by [`JpegEncoder::with_exif`]), the XMP block is the `xpacket` with the namespace URI +//! stripped, and the ICC block is the profile **reassembled** from its APP2 chunks. + +use gamut_core::{Error, Result}; +use gamut_metadata::{EncodedMetadata, Metadata, MetadataBlock, MetadataEmbedder}; + +use crate::{JpegEncoder, JpegMetadata}; + +impl JpegMetadata { + /// The located payloads as [`MetadataBlock`]s, ready for [`Metadata::from_blocks`] or a + /// [`MetadataExtractor`](gamut_metadata::MetadataExtractor) with a chosen + /// [`ConflictPolicy`](gamut_metadata::ConflictPolicy): the EXIF TIFF stream, the XMP packet + /// and the reassembled ICC profile, each present only when the stream carried it. + /// + /// JPEG's legacy IPTC-IIM carrier (APP13) and the C2PA APP11 carriage are not located by this + /// crate (see `STATUS.md`), so no [`MetadataBlock::IptcIim`] / [`MetadataBlock::C2pa`] is ever + /// produced here. + #[must_use] + pub fn blocks(&self) -> Vec> { + let mut blocks = Vec::new(); + if let Some(exif) = &self.exif { + blocks.push(MetadataBlock::Exif(exif)); + } + if let Some(xmp) = &self.xmp { + blocks.push(MetadataBlock::Xmp(xmp)); + } + if let Some(icc) = &self.icc { + blocks.push(MetadataBlock::Icc(icc)); + } + blocks + } + + /// Parses the located payloads into the unified [`Metadata`] model — + /// [`Metadata::from_blocks`] over [`blocks`](Self::blocks). + /// + /// # Errors + /// + /// Returns the facade's [`MetadataError`](gamut_metadata::MetadataError) naming the carrier + /// whose parse failed. + /// + /// # Example + /// + /// ``` + /// use gamut_core::{Dimensions, EncodeImage, Gray8, ImageRef}; + /// use gamut_jpeg::JpegEncoder; + /// use gamut_metadata::exif::{ByteOrder, Exif, ExifTag, Value}; + /// use gamut_metadata::Metadata; + /// + /// # fn main() -> Result<(), Box> { + /// let mut exif = Exif::new(ByteOrder::LittleEndian); + /// exif.set_tag(ExifTag::PhotographicSensitivity, Value::Short(vec![400])); + /// let typed = Metadata::from_carriers(Some(exif), None, None); + /// + /// let pixels = vec![0u8; 64]; + /// let image = ImageRef::::new(&pixels, Dimensions::new(8, 8)?)?; + /// let jpeg = JpegEncoder::new().with_metadata(&typed)?.encode_to_vec(image)?; + /// + /// let read = gamut_jpeg::metadata(&jpeg)?.metadata()?; + /// assert_eq!(read, typed); + /// # Ok(()) + /// # } + /// ``` + pub fn metadata(&self) -> gamut_metadata::Result { + Metadata::from_blocks(&self.blocks()) + } +} + +impl JpegEncoder { + /// Embeds a unified [`Metadata`] model: serializes it with the default + /// [`MetadataEmbedder`] and routes each carrier to the matching raw setter + /// ([`with_exif`](Self::with_exif), [`with_xmp`](Self::with_xmp), + /// [`with_icc_profile`](Self::with_icc_profile)). Returns the updated encoder for chaining. + /// + /// The default embedder emits no legacy IPTC-IIM block (IPTC lives inside the XMP packet) and + /// **drops** a C2PA manifest store — a store is signed over the file it came from and can never + /// be copied into a re-encoded one; see [`gamut_metadata::C2paPolicy`]. A caller that must be + /// told about either configures the embedder itself and calls + /// [`with_encoded_metadata`](Self::with_encoded_metadata). Carriers absent from the model leave + /// any earlier raw setting untouched. + /// + /// # Errors + /// + /// Returns [`Error::InvalidInput`] when the model does not serialize (the facade's message is + /// carried as [`Error::detail`]), and whatever + /// [`with_encoded_metadata`](Self::with_encoded_metadata) returns. + pub fn with_metadata(self, meta: &Metadata) -> Result { + let encoded = MetadataEmbedder::new().embed(meta).map_err(|e| { + Error::invalid_input(env!("CARGO_PKG_NAME"), "JPEG: metadata does not serialize") + .with_detail(e.to_string()) + })?; + self.with_encoded_metadata(&encoded) + } + + /// Embeds already-serialized facade blocks — the output of a [`MetadataEmbedder`] configured by + /// the caller — routing each present carrier to the matching raw setter. Returns the updated + /// encoder for chaining. + /// + /// Only the carriers JPEG can write are accepted: EXIF (APP1), XMP (APP1) and ICC (APP2). The + /// size caps of those segments are still checked at encode time, exactly as for the raw + /// setters. Fields that are `None` leave any earlier raw setting untouched. + /// + /// # Errors + /// + /// Returns [`Error::Unsupported`] when the blocks carry a legacy IPTC-IIM stream (JPEG's APP13 + /// carriage is not implemented — see `STATUS.md`) or a C2PA manifest store (APP11 carriage is + /// not implemented, and a store must not be copied forward in any case). Nothing is applied + /// when an error is returned. + pub fn with_encoded_metadata(mut self, encoded: &EncodedMetadata) -> Result { + if encoded.iptc_iim.is_some() { + return Err(Error::unsupported( + env!("CARGO_PKG_NAME"), + "JPEG: IPTC-IIM (APP13) embedding is not supported", + )); + } + if encoded.c2pa.is_some() { + return Err(Error::unsupported( + env!("CARGO_PKG_NAME"), + "JPEG: C2PA manifest store (APP11) embedding is not supported", + )); + } + if let Some(exif) = &encoded.exif { + self = self.with_exif(exif); + } + if let Some(xmp) = &encoded.xmp { + self = self.with_xmp(xmp); + } + if let Some(icc) = &encoded.icc { + self = self.with_icc_profile(icc); + } + Ok(self) + } +} + +#[cfg(test)] +mod tests { + use gamut_core::{Dimensions, EncodeImage, ErrorKind, Gray8, ImageRef}; + use gamut_metadata::exif::{ByteOrder, Exif, ExifTag, Value}; + use gamut_metadata::icc::{ + ColorSpace, DeviceClass, IccProfile, ProfileHeader, Signature, TagData, + }; + use gamut_metadata::xmp::{WellKnownNs, XmpMeta}; + + use super::*; + + /// A typed model with all three JPEG-writable carriers populated, normalised through one + /// embed → extract pass so it is an *extracted* model: the facade's keystone equality is + /// extract → embed → extract, and a hand-built model differs from its parsed form in fields the + /// serializer stamps (the ICC header's `size`). + fn typed() -> Metadata { + let mut exif = Exif::new(ByteOrder::LittleEndian); + exif.set_tag(ExifTag::Make, Value::Ascii("gamut".to_owned())); + let mut xmp = XmpMeta::new(); + xmp.set_text(WellKnownNs::Xmp.uri(), "CreatorTool", "gamut"); + let icc = IccProfile { + header: ProfileHeader::new(DeviceClass::Display, ColorSpace::Rgb), + tags: Vec::new(), + }; + let encoded = Metadata::from_carriers(Some(exif), Some(xmp), Some(icc)) + .encode() + .unwrap(); + Metadata::from_blocks(&[ + MetadataBlock::Exif(encoded.exif.as_deref().unwrap()), + MetadataBlock::Xmp(encoded.xmp.as_deref().unwrap()), + MetadataBlock::Icc(encoded.icc.as_deref().unwrap()), + ]) + .unwrap() + } + + /// Encodes an 8×8 grayscale image with `encoder`. + fn encode(encoder: JpegEncoder) -> Vec { + let pixels = vec![128u8; 64]; + let image = ImageRef::::new(&pixels, Dimensions::new(8, 8).unwrap()).unwrap(); + encoder.encode_to_vec(image).unwrap() + } + + #[test] + fn blocks_expose_each_located_payload_in_facade_form() { + let meta = JpegMetadata { + exif: Some(b"II*\0".to_vec()), + xmp: Some(b"".to_vec()), + icc: Some(vec![7u8; 4]), + }; + assert_eq!( + meta.blocks(), + vec![ + MetadataBlock::Exif(b"II*\0"), + MetadataBlock::Xmp(b""), + MetadataBlock::Icc(&[7u8; 4]), + ] + ); + assert!(JpegMetadata::default().blocks().is_empty()); + } + + #[test] + fn typed_metadata_round_trips_through_the_stream() { + // The facade's keystone equality, extended through the APP segments: every carrier the + // model holds comes back as the same typed model. + let typed = typed(); + let jpeg = encode(JpegEncoder::new().with_metadata(&typed).unwrap()); + let read = crate::metadata(&jpeg).unwrap(); + assert!(read.exif.is_some() && read.xmp.is_some() && read.icc.is_some()); + assert_eq!(read.metadata().unwrap(), typed); + } + + #[test] + fn an_empty_model_embeds_nothing() { + let jpeg = encode( + JpegEncoder::new() + .with_metadata(&Metadata::default()) + .unwrap(), + ); + assert_eq!(crate::metadata(&jpeg).unwrap(), JpegMetadata::default()); + } + + #[test] + fn a_manifest_store_is_never_copied_forward() { + // The facade's policy, observed at this crate's boundary: the model's C2PA store produces + // no segment, and the other carriers are unaffected. + let mut typed = typed(); + typed.c2pa = Some(b"\0\0\0\x14jumbc2pa".to_vec()); + let jpeg = encode(JpegEncoder::new().with_metadata(&typed).unwrap()); + let read = crate::metadata(&jpeg).unwrap().metadata().unwrap(); + assert_eq!(read.c2pa, None); + typed.c2pa = None; + assert_eq!(read, typed); + } + + #[test] + fn unwritable_carriers_are_typed_unsupported_errors() { + let mut iim = EncodedMetadata::default(); + iim.iptc_iim = Some(vec![0x1c, 0x02, 0x05]); + let err = JpegEncoder::new().with_encoded_metadata(&iim).unwrap_err(); + assert_eq!(err.kind(), ErrorKind::Unsupported); + assert_eq!( + err.static_message(), + Some("JPEG: IPTC-IIM (APP13) embedding is not supported") + ); + + let mut c2pa = EncodedMetadata::default(); + c2pa.c2pa = Some(vec![0u8; 4]); + let err = JpegEncoder::new().with_encoded_metadata(&c2pa).unwrap_err(); + assert_eq!(err.kind(), ErrorKind::Unsupported); + assert_eq!( + err.static_message(), + Some("JPEG: C2PA manifest store (APP11) embedding is not supported") + ); + } + + #[test] + fn encoded_blocks_route_to_the_raw_setters() { + // Each present field lands in its APP segment; an absent one leaves an earlier setting. + let encoded = typed().encode().unwrap(); + let jpeg = encode(JpegEncoder::new().with_encoded_metadata(&encoded).unwrap()); + let read = crate::metadata(&jpeg).unwrap(); + // `EncodedMetadata::exif` carries the `Exif\0\0` signature; the stream stores the TIFF. + assert_eq!( + read.exif.as_deref(), + encoded + .exif + .as_deref() + .and_then(|e| e.strip_prefix(b"Exif\0\0")) + ); + assert_eq!(read.xmp, encoded.xmp); + assert_eq!(read.icc, encoded.icc); + + let mut only_icc = EncodedMetadata::default(); + only_icc.icc = encoded.icc.clone(); + let jpeg = encode( + JpegEncoder::new() + .with_xmp(b"") + .with_encoded_metadata(&only_icc) + .unwrap(), + ); + let read = crate::metadata(&jpeg).unwrap(); + assert_eq!(read.xmp.as_deref(), Some(&b""[..])); + assert_eq!(read.icc, encoded.icc); + assert_eq!(read.exif, None); + } + + #[test] + fn a_model_that_does_not_serialize_is_invalid_input_with_the_facade_detail() { + // The facade's own serialization failure: an ICC model with a duplicate tag signature is + // rejected by `gamut-icc`'s writer (ICC.1:2022 §7.3), and the encoder reports it as this + // crate's error with the facade's message carried as detail. + let duplicate = (Signature(*b"wtpt"), TagData::Xyz(Vec::new())); + let bad_icc = IccProfile { + header: ProfileHeader::new(DeviceClass::Display, ColorSpace::Rgb), + tags: vec![duplicate.clone(), duplicate], + }; + let model = Metadata::from_carriers(None, None, Some(bad_icc)); + let err = JpegEncoder::new().with_metadata(&model).unwrap_err(); + assert_eq!(err.kind(), ErrorKind::InvalidInput); + assert_eq!( + err.static_message(), + Some("JPEG: metadata does not serialize") + ); + assert_eq!(err.detail(), Some("ICC: icc: duplicate tag signature")); + } +} diff --git a/crates/gamut-jxl/Cargo.toml b/crates/gamut-jxl/Cargo.toml index 4357ba3c..5b813ff0 100644 --- a/crates/gamut-jxl/Cargo.toml +++ b/crates/gamut-jxl/Cargo.toml @@ -28,6 +28,11 @@ gamut-core.workspace = true # build. jxl = { workspace = true, optional = true } +# Typed metadata (issue #420): the unified `Metadata` model over the `Exif` / `xml ` boxes and the +# codestream ICC profile this crate writes and reads. Optional and off by default, and a *normal* +# dependency (never dev-only) so release ordering follows it (`mise run check-release-deps`). +gamut-metadata = { workspace = true, optional = true } + # The libjxl-backed encoder. Target-conditional optional dep (the `getrandom` pattern), present on # every encoder-capable target: everything except wasm32 *without* emscripten. On # `wasm32-unknown-emscripten` gamut-jxl-sys builds libjxl with the emsdk toolchain, so the full @@ -52,3 +57,7 @@ encode = ["dep:gamut-jxl-sys"] # The pure-Rust JPEG XL decoder wrapping the `jxl` crate (jxl-rs). Pure safe Rust with no native # build, so — unlike `encode` — it is available on every target, wasm32 included. decode = ["dep:jxl"] +# Typed metadata wiring: `JxlMetadata::blocks` / `JxlMetadata::metadata` and +# `JxlEncoder::with_metadata` / `with_encoded_metadata` over the `gamut-metadata` facade. Pure Rust, +# target-independent; reading the boxes back (`JxlDecoder::metadata`) needs `decode`. +metadata = ["dep:gamut-metadata"] diff --git a/crates/gamut-jxl/STATUS.md b/crates/gamut-jxl/STATUS.md index 096f9435..6c4c6e8e 100644 --- a/crates/gamut-jxl/STATUS.md +++ b/crates/gamut-jxl/STATUS.md @@ -62,7 +62,21 @@ on a hand-written golden bitstream. (samples stay in coded order, decoders apply the transform). - **Exif / XMP container boxes.** `with_exif` (raw EXIF; the 4-byte tiff-offset prefix is added automatically) and `with_xmp`, written as uncompressed `Exif` / `xml ` boxes; requires - `Container::IsoBmff` (a typed error otherwise). + `Container::IsoBmff` (a typed error otherwise). **Read back** by `JxlDecoder::metadata` → + `JxlMetadata { exif, xmp, icc }` (issue #420): the crate walks the container's top-level box + sequence itself (jxl-rs does not expose auxiliary boxes — ledger below), applies the `Exif` + payload's tiff-header offset, takes the first box of a kind, refuses a Brotli-compressed + (`brob`) `Exif`/`xml ` box as `Unsupported`, and reports the codestream ICC profile alongside. +- **Typed metadata** (issue #420) behind the opt-in **`metadata`** feature (a normal, optional + dependency on `gamut-metadata`): `JxlMetadata::blocks` / `metadata` (the facade's + `MetadataBlock`s / `Metadata`), `JxlEncoder::with_metadata(&Metadata)` (default + `MetadataEmbedder`) and `with_encoded_metadata(&EncodedMetadata)` (caller-chosen policies), + routing EXIF (`Exif\0\0` stripped) and XMP to the boxes and the ICC profile to `ColorSpec::Icc`. + IPTC-IIM and C2PA blocks are typed `Unsupported`; a C2PA store is never copied forward (facade + policy). **Oracle cell not covered:** `tooling/exiv2-oracle` is block-level and in-memory (no + JPEG XL reader), so "exiv2 reads the boxes out of the `.jxl`" is untested; the box payloads are + pinned byte-exact by the raw box scan of libjxl's output (below), and the leaf crates pin the + payloads against exiv2 (#510). - **Full pixel decode (jxl-rs).** Decodes the entire ISO/IEC 18181-1 pixel surface jxl-rs covers — VarDCT and Modular (RCT/palette/squeeze), XYB, splines/patches/noise/spot colours, progressive-encoded streams, and both `jxlc`/`jxlp` container framings — reshaping to the @@ -117,9 +131,10 @@ unlocks it. - **JPEG reconstruction on decode.** gamut writes `jbrd` streams whose original JPEG the *libjxl* decoder reconstructs bit-for-bit; a pure-Rust reconstruction API is blocked on jxl-rs shipping its `jpeg-reconstruction` feature (ledger below). -- **Reading Exif / XMP boxes back on decode.** gamut writes the boxes; surfacing them from incoming - streams is blocked on jxl-rs exposing box contents (ledger below) and ties into the - `gamut-metadata` facade (issue #34) for typed parsing. +- **Brotli-compressed metadata boxes (`brob`).** `JxlDecoder::metadata` locates uncompressed + `Exif` / `xml ` boxes (delivered, #420); a `brob`-wrapped one is a typed `Unsupported` because + decompressing it needs a Brotli dependency this crate does not carry. Unlocks with that + dependency decision (#510). - **Premultiplied (associated) alpha decode.** Rejected today; unlocks with an un-premultiply step in `convert` (deliberately deferred: an integer un-premultiply is an approximate inverse — alpha = 0 is unrecoverable — so it belongs behind an explicit opt-in, not a silent default). @@ -200,8 +215,9 @@ The documented gaps, with upstream links (verified against the tracker 2026-07-1 short prefixes, every single-bit flip over the first 256 bytes) driven through the partial path in `tests/robustness.rs`; no panic has been observed. The default `DecodeImage` path never flushes. - **Container Exif / XMP metadata not exposed.** jxl-rs does not surface the `Exif`/`xml ` box bytes - ([#674](https://github.com/libjxl/jxl-rs/issues/674)) — the upstream half of gamut's deferred - *read-back* support (gamut's encoder writes the boxes today). + ([#674](https://github.com/libjxl/jxl-rs/issues/674)), and its box-header parser is + `pub(super)`. gamut-jxl therefore locates the boxes with its own top-level box walk + (`JxlDecoder::metadata`, #420); when jxl-rs exposes box events the walk can be replaced. - **CMYK.** Parsed but not presentable (see Out of scope). ## Oracle & test regime @@ -257,7 +273,10 @@ The documented gaps, with upstream links (verified against the tracker 2026-07-1 pinned by hand-reversal; explicit Identity is byte-identical to the default stream. - **Metadata boxes.** Exact `Exif` (tiff-offset prefix included) and `xml ` box payloads pinned by a raw box scan; pixels stay bit-exact with boxes present; Codestream+metadata and empty payloads - are typed errors. + are typed errors. Read-back: `JxlDecoder::metadata` returns what `with_exif` / `with_xmp` / + `ColorSpec::Icc` wrote (`tests/metadata.rs`), the box walk's size forms and hostile-input + refusals are unit-tested beside it, and with the `metadata` feature the facade's typed + extract → embed → extract equality holds through the container (`tests/metadata_facade.rs`). - **Signatures / conversions.** Codestream vs. container signature bytes and the gray→RGB / RGBA→RGB / RGB→gray-rejection conversion contracts are pinned. - **Mutants:** zero unjustified survivors; the only `exclude_re` entries carry strong justifications diff --git a/crates/gamut-jxl/src/decoder.rs b/crates/gamut-jxl/src/decoder.rs index 437e8cd5..6b5f5cfb 100644 --- a/crates/gamut-jxl/src/decoder.rs +++ b/crates/gamut-jxl/src/decoder.rs @@ -35,6 +35,216 @@ fn wrong_backend_layout() -> Error { ) } +/// Embedded metadata located in a JPEG XL stream by [`JxlDecoder::metadata`]: the container's +/// `Exif` / `xml ` boxes and the codestream's ICC profile. +/// +/// Each payload is stored in the form the dedicated metadata crates parse (and the +/// `gamut-metadata` facade's `MetadataBlock` borrows) directly; with the `metadata` feature, +/// [`JxlMetadata::blocks`] / [`JxlMetadata::metadata`] do that hand-over. Marked +/// `#[non_exhaustive]` so a later carrier (the `jumb` C2PA box) can be added without a breaking +/// change. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +#[non_exhaustive] +pub struct JxlMetadata { + /// The EXIF TIFF stream (starts `II`/`MM`): the `Exif` box payload with its leading 4-byte + /// big-endian `exif_tiff_header_offset` applied. The JPEG XL container reuses HEIF's + /// `ExifDataBlock` (ISO/IEC 23008-12 §A.2.1; `references/jxl/format_overview.md`), which is + /// also what [`JxlEncoder::with_exif`](crate::JxlEncoder::with_exif) writes (offset `0`). + pub exif: Option>, + /// The XMP packet: the `xml ` box payload, verbatim. + pub xmp: Option>, + /// The ICC profile embedded in the codestream's colour encoding — exactly what + /// [`JxlDecoder::embedded_icc_profile`] reports — or `None` for a structured encoding. + pub icc: Option>, +} + +/// Locates the `Exif` and `xml ` boxes of an ISO BMFF `.jxl` container: the TIFF stream behind +/// the `Exif` box's tiff-header offset, and the `xml ` payload verbatim. For a duplicated box the +/// first wins (the workspace's JPEG convention). +/// +/// jxl-rs consumes auxiliary boxes without exposing them, so the walk is this crate's own: the +/// plain top-level box sequence of ISO/IEC 14496-12 §4.2 (32-bit size, `size == 1` with a 64-bit +/// `largesize`, `size == 0` meaning "to the end of the file"). Box *contents* are never +/// interpreted beyond the two metadata types, so a codestream (`jxlc`/`jxlp`), `jbrd`, or unknown +/// box is stepped over by length. +/// +/// # Errors +/// +/// Returns [`Error::InvalidInput`] for a truncated or malformed box header, a box overrunning the +/// stream, or an `Exif` payload shorter than its offset field or with the offset past its end; +/// [`Error::Unsupported`] for a Brotli-compressed (`brob`) `Exif`/`xml ` box, which this crate +/// cannot decompress. Any other `brob` box is skipped. +#[cfg(feature = "decode")] +fn container_metadata_boxes(data: &[u8]) -> Result { + let mut exif = None; + let mut xmp = None; + let mut rest = data; + while !rest.is_empty() { + let (box_type, header_len, box_len) = parse_box_header(rest)?; + if box_len > rest.len() { + return Err(Error::invalid_input( + env!("CARGO_PKG_NAME"), + "JXL: box overruns the stream", + )); + } + // §4.2: a box's `size` counts its own header, so it is never below `MIN_BOX_HEADER`. The + // walk enforces that here rather than trusting the header parser, which also makes the + // loop's progress local: every iteration consumes at least `MIN_BOX_HEADER` bytes of + // `rest`, so the walk terminates whatever the parser reports. + if box_len < MIN_BOX_HEADER { + return Err(Error::invalid_input( + env!("CARGO_PKG_NAME"), + "JXL: malformed box size", + )); + } + // `header_len > box_len` is the 64-bit form declaring a `largesize` smaller than the + // header it is part of; the body is then not a range at all. + let Some(body) = rest.get(header_len..box_len) else { + return Err(Error::invalid_input( + env!("CARGO_PKG_NAME"), + "JXL: malformed box size", + )); + }; + match &box_type { + b"brob" => { + let Some(inner) = body.get(..4) else { + return Err(Error::invalid_input( + env!("CARGO_PKG_NAME"), + "JXL: truncated brob box", + )); + }; + if inner == b"Exif" || inner == b"xml " { + return Err(Error::unsupported( + env!("CARGO_PKG_NAME"), + "JXL: Brotli-compressed metadata box (brob) is not supported", + )); + } + } + b"Exif" if exif.is_none() => exif = Some(exif_box_tiff_stream(body)?.to_vec()), + b"xml " if xmp.is_none() => xmp = Some(body.to_vec()), + _ => {} + } + rest = &rest[box_len..]; + } + Ok((exif, xmp)) +} + +/// The located `Exif` TIFF stream and `xml ` payload of a container, each `None` when absent. +#[cfg(feature = "decode")] +type MetadataBoxes = (Option>, Option>); + +/// The smallest ISO BMFF box header (§4.2): a 4-byte `size` and a 4-byte type. +#[cfg(feature = "decode")] +const MIN_BOX_HEADER: usize = 8; + +/// The 64-bit box header: [`MIN_BOX_HEADER`] plus the 8-byte `largesize` that follows the type. +#[cfg(feature = "decode")] +const LARGE_BOX_HEADER: usize = 16; + +/// Parses the box header at the start of `data`, returning its type, the length of the header +/// itself, and the length of the whole box (header included) as the header declares it. +/// +/// Nothing here is validated against `data`'s length, and nothing is sliced: the returned lengths +/// are what the header *claims*, and the caller — which owns the walk's progress — is what checks +/// them (see [`container_metadata_boxes`]). +/// +/// # Errors +/// +/// Returns [`Error::InvalidInput`] if `data` is too short to hold the header it announces. +#[cfg(feature = "decode")] +fn parse_box_header(data: &[u8]) -> Result<([u8; 4], usize, usize)> { + let [s0, s1, s2, s3, t0, t1, t2, t3, tail @ ..] = data else { + return Err(Error::invalid_input( + env!("CARGO_PKG_NAME"), + "JXL: truncated box header", + )); + }; + let box_type = [*t0, *t1, *t2, *t3]; + match u32::from_be_bytes([*s0, *s1, *s2, *s3]) { + // `size == 0`: the box extends to the end of the file. + 0 => Ok((box_type, MIN_BOX_HEADER, data.len())), + // `size == 1`: a 64-bit `largesize` follows the type. + 1 => { + let [l0, l1, l2, l3, l4, l5, l6, l7, ..] = tail else { + return Err(Error::invalid_input( + env!("CARGO_PKG_NAME"), + "JXL: truncated box header", + )); + }; + let large = u64::from_be_bytes([*l0, *l1, *l2, *l3, *l4, *l5, *l6, *l7]); + // A length no address space can hold cannot be a length within `data` either, so it + // saturates and the caller's overrun check reports it. + Ok(( + box_type, + LARGE_BOX_HEADER, + usize::try_from(large).unwrap_or(usize::MAX), + )) + } + size => Ok((box_type, MIN_BOX_HEADER, size as usize)), + } +} + +/// The TIFF stream of an `Exif` box payload: skips the 4-byte big-endian `exif_tiff_header_offset` +/// and then `offset` further bytes (ISO/IEC 23008-12 §A.2.1). +#[cfg(feature = "decode")] +fn exif_box_tiff_stream(payload: &[u8]) -> Result<&[u8]> { + let [o0, o1, o2, o3, rest @ ..] = payload else { + return Err(Error::invalid_input( + env!("CARGO_PKG_NAME"), + "JXL: truncated Exif box", + )); + }; + usize::try_from(u32::from_be_bytes([*o0, *o1, *o2, *o3])) + .ok() + .and_then(|offset| rest.get(offset..)) + .ok_or_else(|| { + Error::invalid_input( + env!("CARGO_PKG_NAME"), + "JXL: Exif box tiff-header offset out of range", + ) + }) +} + +#[cfg(feature = "metadata")] +impl JxlMetadata { + /// The located payloads as [`MetadataBlock`](gamut_metadata::MetadataBlock)s, ready for + /// [`Metadata::from_blocks`](gamut_metadata::Metadata::from_blocks) or a + /// [`MetadataExtractor`](gamut_metadata::MetadataExtractor) with a chosen + /// [`ConflictPolicy`](gamut_metadata::ConflictPolicy): the EXIF TIFF stream, the XMP packet + /// and the codestream ICC profile, each present only when the stream carried it. + /// + /// JPEG XL has no IPTC-IIM carrier, and the `jumb` (C2PA) box is not located by this crate + /// (see `STATUS.md`), so no `IptcIim` / `C2pa` block is ever produced here. + #[must_use] + pub fn blocks(&self) -> Vec> { + use gamut_metadata::MetadataBlock; + let mut blocks = Vec::new(); + if let Some(exif) = &self.exif { + blocks.push(MetadataBlock::Exif(exif)); + } + if let Some(xmp) = &self.xmp { + blocks.push(MetadataBlock::Xmp(xmp)); + } + if let Some(icc) = &self.icc { + blocks.push(MetadataBlock::Icc(icc)); + } + blocks + } + + /// Parses the located payloads into the unified + /// [`Metadata`](gamut_metadata::Metadata) model — + /// [`Metadata::from_blocks`](gamut_metadata::Metadata::from_blocks) over + /// [`blocks`](Self::blocks). + /// + /// # Errors + /// + /// Returns the facade's [`MetadataError`](gamut_metadata::MetadataError) naming the carrier + /// whose parse failed. + pub fn metadata(&self) -> gamut_metadata::Result { + gamut_metadata::Metadata::from_blocks(&self.blocks()) + } +} + /// A JPEG XL decoder. /// /// Decodes both JPEG XL framings — a bare codestream and the ISO BMFF `.jxl` container — into any of @@ -177,6 +387,40 @@ impl JxlDecoder { crate::jxlrs::embedded_icc_profile(data) } + /// Reads the stream's embedded metadata without decoding any pixels: the container's `Exif` + /// and `xml ` boxes (what [`JxlEncoder::with_exif`](crate::JxlEncoder::with_exif) / + /// [`with_xmp`](crate::JxlEncoder::with_xmp) wrote) plus the codestream's ICC profile (as + /// [`embedded_icc_profile`](Self::embedded_icc_profile)). A bare codestream has no boxes, so + /// only the ICC field can be set for one. + /// + /// The boxes are located by this crate's own walk of the container's top-level box sequence — + /// the pure-Rust decode tail does not expose them — and a pushed backend is not consulted. The + /// `Exif` payload's 4-byte tiff-header offset is applied, so [`JxlMetadata::exif`] is the TIFF + /// stream itself; for a duplicated box the first wins. A Brotli-compressed (`brob`) `Exif` / + /// `xml ` box is refused rather than silently skipped. + /// + /// # Errors + /// + /// Returns [`Error::InvalidInput`] if the data carries neither signature, is truncated before + /// the colour metadata, or has a malformed box sequence (a truncated header, a box overrunning + /// the stream, an `Exif` payload shorter than its offset field or with the offset past its + /// end); [`Error::Unsupported`] for a `brob`-wrapped `Exif` / `xml ` box. + #[cfg(feature = "decode")] + pub fn metadata(&self, data: &[u8]) -> Result { + let (exif, xmp) = match JxlFraming::detect(data) { + JxlFraming::IsoBmff => container_metadata_boxes(data)?, + JxlFraming::Codestream => (None, None), + JxlFraming::Unknown => { + return Err(Error::invalid_input( + env!("CARGO_PKG_NAME"), + "JXL: neither the codestream nor the container signature", + )); + } + }; + let icc = crate::jxlrs::embedded_icc_profile(data)?; + Ok(JxlMetadata { exif, xmp, icc }) + } + /// The stream's dimensions when the built-in header parser can determine them, else `None`. /// /// Used only to populate [`JxlStreamInfo::dimensions`](crate::JxlStreamInfo::dimensions), and @@ -746,3 +990,231 @@ mod tests { assert!(format!("{dec:?}").contains("backends: 1")); } } + +/// Unit tests for the container box walk behind [`JxlDecoder::metadata`]: the located payloads, +/// the three box-size forms, and the hostile-input refusals. `container_metadata_boxes` is +/// private, so these live beside it. +#[cfg(all(test, feature = "decode"))] +mod box_tests { + use gamut_core::ErrorKind; + + use super::*; + + /// The 12-byte container signature box. + const SIGNATURE: [u8; 12] = [ + 0x00, 0x00, 0x00, 0x0C, 0x4A, 0x58, 0x4C, 0x20, 0x0D, 0x0A, 0x87, 0x0A, + ]; + /// A TIFF-shaped EXIF stream. + const TIFF: &[u8] = b"II\x2A\x00\x08\x00\x00\x00\x00\x00"; + + /// One box in the 32-bit size form. + fn bx(ty: &[u8; 4], body: &[u8]) -> Vec { + let mut out = (8 + body.len() as u32).to_be_bytes().to_vec(); + out.extend_from_slice(ty); + out.extend_from_slice(body); + out + } + + /// One box in the `size == 1` / 64-bit `largesize` form. + fn bx64(ty: &[u8; 4], body: &[u8]) -> Vec { + let mut out = 1u32.to_be_bytes().to_vec(); + out.extend_from_slice(ty); + out.extend_from_slice(&(16 + body.len() as u64).to_be_bytes()); + out.extend_from_slice(body); + out + } + + /// One box in the `size == 0` (to end of file) form. + fn bx0(ty: &[u8; 4], body: &[u8]) -> Vec { + let mut out = 0u32.to_be_bytes().to_vec(); + out.extend_from_slice(ty); + out.extend_from_slice(body); + out + } + + /// The signature followed by `boxes`. + fn container(boxes: &[Vec]) -> Vec { + let mut out = SIGNATURE.to_vec(); + for b in boxes { + out.extend_from_slice(b); + } + out + } + + /// An `Exif` box payload: the big-endian offset, `gap` filler bytes, then the TIFF stream. + fn exif_payload(offset: u32, gap: usize) -> Vec { + let mut out = offset.to_be_bytes().to_vec(); + out.extend(std::iter::repeat_n(0xEE, gap)); + out.extend_from_slice(TIFF); + out + } + + #[test] + fn exif_box_yields_the_tiff_stream_behind_the_offset() { + // Offset 0 (what the encoder writes) and a non-zero offset skipping filler bytes. + for (offset, gap) in [(0, 0), (6, 6)] { + let data = container(&[ + bx(b"ftyp", b"jxl "), + bx(b"Exif", &exif_payload(offset, gap)), + ]); + let (exif, xmp) = container_metadata_boxes(&data).unwrap(); + assert_eq!(exif.as_deref(), Some(TIFF), "offset {offset}"); + assert_eq!(xmp, None); + } + } + + #[test] + fn xml_box_is_verbatim_and_the_first_of_a_kind_wins() { + let data = container(&[ + bx(b"xml ", b"first"), + bx(b"jxlc", &[0xFF, 0x0A]), + bx(b"xml ", b"second"), + bx(b"Exif", &exif_payload(0, 0)), + bx(b"Exif", b"\0\0\0\0MM\0*"), + ]); + let (exif, xmp) = container_metadata_boxes(&data).unwrap(); + assert_eq!(xmp.as_deref(), Some(&b"first"[..])); + assert_eq!(exif.as_deref(), Some(TIFF)); + } + + #[test] + fn largesize_and_to_end_of_file_boxes_are_walked() { + let data = container(&[bx64(b"Exif", &exif_payload(0, 0)), bx0(b"xml ", b"")]); + let (exif, xmp) = container_metadata_boxes(&data).unwrap(); + assert_eq!(exif.as_deref(), Some(TIFF)); + assert_eq!(xmp.as_deref(), Some(&b""[..])); + } + + #[test] + fn an_empty_box_is_walked_over_rather_than_rejected() { + // A box whose `size` is exactly the 8-byte header carries no payload and is legal §4.2 + // framing: the walk must step over it and keep going, so the minimum-size rule is + // `box_len < 8`, not `<= 8`. It is also the smallest step the walk can take, which is what + // makes the loop's progress local to it. + let data = container(&[ + bx(b"free", b""), + bx(b"Exif", &exif_payload(0, 0)), + bx(b"free", b""), + bx(b"xml ", b""), + ]); + let (exif, xmp) = container_metadata_boxes(&data).unwrap(); + assert_eq!(exif.as_deref(), Some(TIFF)); + assert_eq!(xmp.as_deref(), Some(&b""[..])); + + // An empty box of a kind the walk *does* read is an empty payload, not an absent one. + let data = container(&[bx(b"xml ", b"")]); + let (_, xmp) = container_metadata_boxes(&data).unwrap(); + assert_eq!(xmp.as_deref(), Some(&b""[..])); + } + + #[test] + fn a_container_without_metadata_boxes_yields_nothing() { + let data = container(&[bx(b"ftyp", b"jxl "), bx(b"jxlc", &[0xFF, 0x0A])]); + assert_eq!(container_metadata_boxes(&data).unwrap(), (None, None)); + } + + #[test] + fn brob_wrapping_a_metadata_box_is_unsupported() { + for inner in [b"Exif", b"xml "] { + let mut body = inner.to_vec(); + body.extend_from_slice(b"\x0b\x02\x80compressed"); + let data = container(&[bx(b"brob", &body)]); + let err = container_metadata_boxes(&data).unwrap_err(); + assert_eq!(err.kind(), ErrorKind::Unsupported); + assert_eq!( + err.static_message(), + Some("JXL: Brotli-compressed metadata box (brob) is not supported") + ); + } + } + + #[test] + fn brob_wrapping_another_box_is_skipped() { + let data = container(&[ + bx(b"brob", b"jumb\x0b\x02\x80compressed"), + bx(b"xml ", b""), + ]); + let (exif, xmp) = container_metadata_boxes(&data).unwrap(); + assert_eq!(exif, None); + assert_eq!(xmp.as_deref(), Some(&b""[..])); + } + + #[test] + fn malformed_box_sequences_are_invalid_input_with_the_named_fault() { + let cases: [(Vec, &str); 7] = [ + // Seven bytes where a header should be. + ( + container(&[vec![0, 0, 0, 9, b'x', b'm', b'l']]), + "JXL: truncated box header", + ), + // `size == 1` but no `largesize`. + ( + container(&[vec![0, 0, 0, 1, b'E', b'x', b'i', b'f', 0, 0]]), + "JXL: truncated box header", + ), + // A 32-bit size below the header's own length. + ( + container(&[vec![0, 0, 0, 4, b'x', b'm', b'l', b' ']]), + "JXL: malformed box size", + ), + // A `largesize` below its header's own length. + ( + container(&[vec![ + 0, 0, 0, 1, b'x', b'm', b'l', b' ', 0, 0, 0, 0, 0, 0, 0, 8, + ]]), + "JXL: malformed box size", + ), + // A box claiming more bytes than remain. + ( + container(&[vec![ + 0, 0, 0, 20, b'x', b'm', b'l', b' ', b'<', b'x', b'/', b'>', + ]]), + "JXL: box overruns the stream", + ), + // A `largesize` no address space can hold. + ( + container(&[vec![ + 0, 0, 0, 1, b'x', b'm', b'l', b' ', 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, + ]]), + "JXL: box overruns the stream", + ), + // A `brob` box too short to name the box it wraps. + (container(&[bx(b"brob", b"xm")]), "JXL: truncated brob box"), + ]; + for (data, message) in cases { + let err = container_metadata_boxes(&data).unwrap_err(); + assert_eq!(err.kind(), ErrorKind::InvalidInput, "{message}"); + assert_eq!(err.static_message(), Some(message)); + } + } + + #[test] + fn malformed_exif_payloads_are_invalid_input_with_the_named_fault() { + let cases: [(&[u8], &str); 2] = [ + (b"\0\0\0", "JXL: truncated Exif box"), + ( + b"\0\0\0\x0bII*\0", + "JXL: Exif box tiff-header offset out of range", + ), + ]; + for (payload, message) in cases { + let data = container(&[bx(b"Exif", payload)]); + let err = container_metadata_boxes(&data).unwrap_err(); + assert_eq!(err.kind(), ErrorKind::InvalidInput, "{message}"); + assert_eq!(err.static_message(), Some(message)); + } + // An offset landing exactly at the end is an empty (not out-of-range) stream. + assert_eq!(exif_box_tiff_stream(b"\0\0\0\x02ab").unwrap(), b""); + } + + #[test] + fn metadata_of_a_stream_with_neither_signature_is_invalid_input() { + let err = JxlDecoder::new().metadata(b"not a jxl").unwrap_err(); + assert_eq!(err.kind(), ErrorKind::InvalidInput); + assert_eq!( + err.static_message(), + Some("JXL: neither the codestream nor the container signature") + ); + } +} diff --git a/crates/gamut-jxl/src/encoder.rs b/crates/gamut-jxl/src/encoder.rs index 5d3ac5b5..7912b05b 100644 --- a/crates/gamut-jxl/src/encoder.rs +++ b/crates/gamut-jxl/src/encoder.rs @@ -240,6 +240,103 @@ impl JxlEncoder { self } + /// Embeds a unified [`Metadata`](gamut_metadata::Metadata) model: serializes it with the + /// default [`MetadataEmbedder`](gamut_metadata::MetadataEmbedder) and routes each carrier to + /// the matching setter — EXIF to [`with_exif`](Self::with_exif) (the facade's `Exif\0\0` + /// signature stripped, since the `Exif` box carries the TIFF stream), XMP to + /// [`with_xmp`](Self::with_xmp), and the ICC profile to [`with_color`](Self::with_color) as + /// [`ColorSpec::Icc`] — in JPEG XL the profile *is* the codestream's colour encoding, not a + /// container box, and it is validated against the image's colour family at encode time. Returns + /// the updated encoder for chaining. + /// + /// Encoding then requires [`Container::IsoBmff`](crate::Container::IsoBmff) whenever an EXIF or + /// XMP carrier was present, exactly as for the raw setters. The default embedder emits no legacy + /// IPTC-IIM block and **drops** a C2PA manifest store (a store is signed over the file it came + /// from; see [`gamut_metadata::C2paPolicy`]); a caller that must be told about either + /// configures the embedder itself and calls + /// [`with_encoded_metadata`](Self::with_encoded_metadata). + /// + /// # Precedence + /// + /// A carrier **absent** from the model leaves any earlier setting untouched. A carrier + /// **present** in it overwrites one, because each is routed to the raw setter — and for ICC + /// that setter is [`with_color`](Self::with_color), so a profile in the model replaces a + /// [`ColorSpec`] chosen before this call, not merely an earlier profile. Call this before + /// [`with_color`](Self::with_color) when the explicit colour choice is meant to win. Whether + /// last-write-wins is the right rule for a colour encoding, or the conflict should be refused, + /// is open (issue #626); this documents what it does today rather than settling it. + /// + /// # Errors + /// + /// Returns [`Error::InvalidInput`] when the model does not serialize (the facade's message is + /// carried as [`Error::detail`]), and whatever + /// [`with_encoded_metadata`](Self::with_encoded_metadata) returns. + #[cfg(feature = "metadata")] + pub fn with_metadata(self, meta: &gamut_metadata::Metadata) -> Result { + let encoded = gamut_metadata::MetadataEmbedder::new() + .embed(meta) + .map_err(|e| { + Error::invalid_input(env!("CARGO_PKG_NAME"), "JXL: metadata does not serialize") + .with_detail(e.to_string()) + })?; + self.with_encoded_metadata(&encoded) + } + + /// Embeds already-serialized facade blocks — the output of a + /// [`MetadataEmbedder`](gamut_metadata::MetadataEmbedder) configured by the caller — routing + /// each present carrier as [`with_metadata`](Self::with_metadata) does. Returns the updated + /// encoder for chaining. + /// + /// Only the carriers JPEG XL can write are accepted: EXIF (`Exif` box), XMP (`xml ` box) and + /// ICC (the codestream colour encoding). Fields that are `None` leave any earlier setting + /// untouched; a field that is `Some` overwrites one, and for ICC that means replacing the + /// encoder's [`ColorSpec`] — see the precedence note on + /// [`with_metadata`](Self::with_metadata). + /// + /// # Errors + /// + /// Returns [`Error::Unsupported`] when the blocks carry a legacy IPTC-IIM stream (JPEG XL has + /// no carrier for it) or a C2PA manifest store (the `jumb` box is not written by this crate, + /// and a store must not be copied forward in any case), and [`Error::InvalidInput`] when the + /// XMP packet is not UTF-8 (the `xml ` box holds text). Nothing is applied when an error is + /// returned. + #[cfg(feature = "metadata")] + pub fn with_encoded_metadata( + mut self, + encoded: &gamut_metadata::EncodedMetadata, + ) -> Result { + if encoded.iptc_iim.is_some() { + return Err(Error::unsupported( + env!("CARGO_PKG_NAME"), + "JXL: IPTC-IIM has no container carrier", + )); + } + if encoded.c2pa.is_some() { + return Err(Error::unsupported( + env!("CARGO_PKG_NAME"), + "JXL: C2PA manifest store (jumb box) embedding is not supported", + )); + } + let xmp = encoded + .xmp + .as_deref() + .map(std::str::from_utf8) + .transpose() + .map_err(|_| { + Error::invalid_input(env!("CARGO_PKG_NAME"), "JXL: XMP packet is not UTF-8") + })?; + if let Some(exif) = &encoded.exif { + self = self.with_exif(exif.strip_prefix(b"Exif\0\0").unwrap_or(exif)); + } + if let Some(xmp) = xmp { + self = self.with_xmp(xmp); + } + if let Some(icc) = &encoded.icc { + self = self.with_color(ColorSpec::Icc(icc.clone())); + } + Ok(self) + } + /// Declares the samples' **coded bit depth** N, making a 16-bit pixel buffer carry N-bit code /// values (`0 ..= 2^N - 1`) instead of full-range 16-bit. Returns the updated encoder for /// chaining. diff --git a/crates/gamut-jxl/src/lib.rs b/crates/gamut-jxl/src/lib.rs index 1f933317..23f50d64 100644 --- a/crates/gamut-jxl/src/lib.rs +++ b/crates/gamut-jxl/src/lib.rs @@ -85,7 +85,9 @@ //! [`JxlEncoder`] still exists and still encodes — through whatever backend was pushed. With //! neither, encoding returns [`Error::Unsupported`](gamut_core::Error::Unsupported). //! - `decode` (default) includes the jxl-rs decode tail, and additionally provides the header-only -//! accessors ([`JxlDecoder::info`], [`JxlDecoder::embedded_icc_profile`], [`JxlInfo`]) and the +//! accessors ([`JxlDecoder::info`], [`JxlDecoder::embedded_icc_profile`], [`JxlInfo`]), the +//! metadata read-back ([`JxlDecoder::metadata`] → [`JxlMetadata`]: the container's `Exif` / +//! `xml ` boxes, located by this crate's own box walk, plus the codestream ICC profile) and the //! best-effort [`DecodePartialImage`] surface, all of which are always answered by the built-in //! parser. Without it, [`JxlDecoder`] decodes through a pushed backend, or returns //! [`Error::Unsupported`](gamut_core::Error::Unsupported). @@ -93,6 +95,15 @@ //! This is why the encode direction works on `wasm32-unknown-unknown` despite libjxl being //! unbuildable there: push a backend and the tail's absence stops mattering. //! +//! ## The `metadata` feature +//! +//! Off by default. It adds the `gamut-metadata` facade's typed models over the raw surface above: +//! [`JxlMetadata::blocks`] hands the located payloads over as `MetadataBlock`s and +//! [`JxlMetadata::metadata`] parses them into a unified `Metadata`, while +//! [`JxlEncoder::with_metadata`] / [`JxlEncoder::with_encoded_metadata`] embed one, routing EXIF +//! and XMP to the container boxes and the ICC profile to [`ColorSpec::Icc`]. The dependency +//! direction stays `gamut-jxl → gamut-metadata`. +//! //! ## Deferred: container ownership //! //! Container-dependent features — ISO BMFF output, `Exif`/`xml ` boxes, and `jbrd` JPEG @@ -151,7 +162,11 @@ pub use backend::{ JxlImageRef, JxlOwnedSamples, JxlSamples, JxlStreamInfo, }; pub use config::{ColorSpec, Container, Distance, Effort, ModularMode, Orientation}; -pub use decoder::JxlDecoder; #[cfg(feature = "decode")] pub use decoder::{DecodePartialImage, JxlInfo, JxlPartialReport, JxlRender}; +pub use decoder::{JxlDecoder, JxlMetadata}; pub use encoder::JxlEncoder; +// The facade types named in the `metadata`-feature signatures, so a caller can spell +// `JxlMetadata::metadata` / `JxlEncoder::with_metadata` without a direct dependency. +#[cfg(feature = "metadata")] +pub use gamut_metadata::{EncodedMetadata, Metadata, MetadataBlock}; diff --git a/crates/gamut-jxl/tests/metadata.rs b/crates/gamut-jxl/tests/metadata.rs index 0c7dcd2d..8878d8e6 100644 --- a/crates/gamut-jxl/tests/metadata.rs +++ b/crates/gamut-jxl/tests/metadata.rs @@ -129,3 +129,44 @@ fn empty_metadata_payload_is_rejected() { assert_eq!(err.kind(), ErrorKind::InvalidInput, "{err:?}"); assert_eq!(err.static_message(), Some("JXL: empty metadata payload")); } + +#[test] +fn decoder_reads_the_exif_and_xmp_boxes_back() { + // The read-back half of the box contract: what `with_exif` / `with_xmp` wrote comes back as + // the TIFF stream (offset applied) and the packet, with no ICC for the default sRGB signal. + let jxl = encode_with(|enc| enc.with_exif(EXIF).with_xmp(XMP)); + let meta = JxlDecoder::new().metadata(&jxl).expect("metadata"); + assert_eq!(meta.exif.as_deref(), Some(EXIF)); + assert_eq!(meta.xmp.as_deref(), Some(XMP.as_bytes())); + assert_eq!(meta.icc, None); + + // A container with no boxes, and a bare codestream, both report nothing. + let empty = encode_with(|enc| enc); + assert_eq!( + JxlDecoder::new().metadata(&empty).expect("metadata"), + gamut_jxl::JxlMetadata::default() + ); + let dims = Dimensions::new(12, 9).unwrap(); + let samples = gen_u8(12, 9, 3); + let image = ImageRef::::new(&samples, dims).unwrap(); + let bare = JxlEncoder::lossless().encode_to_vec(image).unwrap(); + assert_eq!( + JxlDecoder::new().metadata(&bare).expect("metadata"), + gamut_jxl::JxlMetadata::default() + ); +} + +#[test] +fn metadata_reports_the_codestream_icc_profile() { + // The ICC field is the codestream's embedded profile (the libjxl-synthesized sRGB one, as the + // colour tests use), byte-for-byte, alongside the boxes. + let srgb = common::icc_profile(&encode_with(|enc| enc)).expect("oracle synthesizes sRGB"); + let jxl = encode_with(|enc| { + enc.with_color(gamut_jxl::ColorSpec::Icc(srgb.clone())) + .with_xmp(XMP) + }); + let meta = JxlDecoder::new().metadata(&jxl).expect("metadata"); + assert_eq!(meta.icc.as_deref(), Some(srgb.as_slice())); + assert_eq!(meta.xmp.as_deref(), Some(XMP.as_bytes())); + assert_eq!(meta.exif, None); +} diff --git a/crates/gamut-jxl/tests/metadata_facade.rs b/crates/gamut-jxl/tests/metadata_facade.rs new file mode 100644 index 00000000..422da6cb --- /dev/null +++ b/crates/gamut-jxl/tests/metadata_facade.rs @@ -0,0 +1,160 @@ +//! The `metadata` feature through the container: the facade's keystone equality +//! (extract → embed → extract over EXIF / XMP / ICC) extended through a real `.jxl` stream, the +//! routing of each `EncodedMetadata` carrier to its setter, and the typed refusals for carriers the +//! container cannot write. +//! +//! Needs both codec halves plus the facade; compiled only when all are available. +#![cfg(all( + feature = "encode", + feature = "decode", + feature = "metadata", + any(not(target_arch = "wasm32"), target_os = "emscripten") +))] + +mod common; + +use common::gen_u8; +use gamut_core::{Dimensions, EncodeImage, ErrorKind, ImageRef, Rgb8}; +use gamut_jxl::{Container, EncodedMetadata, JxlDecoder, JxlEncoder, Metadata, MetadataBlock}; +use gamut_metadata::exif::{ByteOrder, Exif, ExifTag, Value}; +use gamut_metadata::xmp::{WellKnownNs, XmpMeta}; + +/// Encodes the deterministic 12×9 RGB8 pattern losslessly with `encoder`. +fn encode(encoder: JxlEncoder) -> Vec { + let dims = Dimensions::new(12, 9).unwrap(); + let samples = gen_u8(12, 9, 3); + let image = ImageRef::::new(&samples, dims).unwrap(); + encoder.encode_to_vec(image).expect("encode failed") +} + +/// A container-framed lossless encoder. +fn container_encoder() -> JxlEncoder { + JxlEncoder::lossless().with_container(Container::IsoBmff) +} + +/// A typed model with EXIF, XMP and the libjxl-synthesized sRGB ICC profile, normalised through +/// one embed → extract pass so it is an *extracted* model (a hand-built model differs from its +/// parsed form in fields the serializer stamps). +fn typed() -> Metadata { + let mut exif = Exif::new(ByteOrder::LittleEndian); + exif.set_tag(ExifTag::Make, Value::Ascii("gamut".to_owned())); + let mut xmp = XmpMeta::new(); + xmp.set_text(WellKnownNs::Xmp.uri(), "CreatorTool", "gamut"); + let srgb = common::icc_profile(&encode(container_encoder())).expect("oracle synthesizes sRGB"); + let encoded = Metadata::from_carriers(Some(exif), Some(xmp), None) + .encode() + .unwrap(); + Metadata::from_blocks(&[ + MetadataBlock::Exif(encoded.exif.as_deref().unwrap()), + MetadataBlock::Xmp(encoded.xmp.as_deref().unwrap()), + MetadataBlock::Icc(&srgb), + ]) + .unwrap() +} + +#[test] +fn typed_metadata_round_trips_through_the_container() { + let typed = typed(); + let jxl = encode(container_encoder().with_metadata(&typed).unwrap()); + let read = JxlDecoder::new().metadata(&jxl).unwrap(); + assert!(read.exif.is_some() && read.xmp.is_some() && read.icc.is_some()); + assert_eq!(read.blocks().len(), 3); + assert_eq!(read.metadata().unwrap(), typed); +} + +#[test] +fn a_manifest_store_is_never_copied_forward() { + let mut typed = typed(); + typed.c2pa = Some(b"\0\0\0\x14jumbc2pa".to_vec()); + let jxl = encode(container_encoder().with_metadata(&typed).unwrap()); + let read = JxlDecoder::new() + .metadata(&jxl) + .unwrap() + .metadata() + .unwrap(); + assert_eq!(read.c2pa, None); + typed.c2pa = None; + assert_eq!(read, typed); +} + +#[test] +fn encoded_blocks_route_to_the_setters_with_the_exif_signature_stripped() { + let encoded = typed().encode().unwrap(); + let jxl = encode(container_encoder().with_encoded_metadata(&encoded).unwrap()); + let read = JxlDecoder::new().metadata(&jxl).unwrap(); + // `EncodedMetadata::exif` carries `Exif\0\0`; the `Exif` box carries the TIFF stream. + assert_eq!( + read.exif.as_deref(), + encoded + .exif + .as_deref() + .and_then(|e| e.strip_prefix(b"Exif\0\0")) + ); + assert_eq!(read.xmp, encoded.xmp); + assert_eq!(read.icc, encoded.icc); + + // An absent field leaves an earlier setting untouched. + let mut only_xmp = EncodedMetadata::default(); + only_xmp.xmp = encoded.xmp.clone(); + let jxl = encode( + container_encoder() + .with_exif(b"II\x2A\x00\x08\x00\x00\x00\x00\x00") + .with_encoded_metadata(&only_xmp) + .unwrap(), + ); + let read = JxlDecoder::new().metadata(&jxl).unwrap(); + assert_eq!( + read.exif.as_deref(), + Some(&b"II\x2A\x00\x08\x00\x00\x00\x00\x00"[..]) + ); + assert_eq!(read.xmp, encoded.xmp); +} + +#[test] +fn unwritable_carriers_are_typed_errors() { + let mut iim = EncodedMetadata::default(); + iim.iptc_iim = Some(vec![0x1c, 0x02, 0x05]); + let err = container_encoder().with_encoded_metadata(&iim).unwrap_err(); + assert_eq!(err.kind(), ErrorKind::Unsupported); + assert_eq!( + err.static_message(), + Some("JXL: IPTC-IIM has no container carrier") + ); + + let mut c2pa = EncodedMetadata::default(); + c2pa.c2pa = Some(vec![0u8; 4]); + let err = container_encoder() + .with_encoded_metadata(&c2pa) + .unwrap_err(); + assert_eq!(err.kind(), ErrorKind::Unsupported); + assert_eq!( + err.static_message(), + Some("JXL: C2PA manifest store (jumb box) embedding is not supported") + ); + + let mut binary_xmp = EncodedMetadata::default(); + binary_xmp.xmp = Some(vec![0xFF, 0xFE, 0x00]); + let err = container_encoder() + .with_encoded_metadata(&binary_xmp) + .unwrap_err(); + assert_eq!(err.kind(), ErrorKind::InvalidInput); + assert_eq!(err.static_message(), Some("JXL: XMP packet is not UTF-8")); +} + +#[test] +fn typed_metadata_still_needs_the_container_framing() { + // The raw setters' rule holds unchanged: boxes need the ISO BMFF container. + let dims = Dimensions::new(12, 9).unwrap(); + let samples = gen_u8(12, 9, 3); + let image = ImageRef::::new(&samples, dims).unwrap(); + let err = JxlEncoder::lossless() + .with_metadata(&typed()) + .unwrap() + .encode_to_vec(image) + .unwrap_err(); + assert_eq!(err.kind(), ErrorKind::InvalidInput); + assert_eq!( + err.static_message(), + Some("JXL: Exif/XMP metadata requires the ISO BMFF container") + ); +} diff --git a/crates/gamut-metadata/README.md b/crates/gamut-metadata/README.md index 5acbf910..5b6e83f9 100644 --- a/crates/gamut-metadata/README.md +++ b/crates/gamut-metadata/README.md @@ -205,11 +205,79 @@ Use `Metadata::default()` plus field assignment when you also need `extensions`, arm to any exhaustive `match` on `MetadataBlock`. Nothing else changed: every existing method keeps its signature and behaviour. +## Capability query + +The facade never parses a container, so it cannot say whether a *file* carries metadata. What it +can say — statically, from a crate that depends on no format crate — is whether gamut's crate for a +format can **locate** or **write** a given carrier at all. `gamut_metadata::capability` is that +table, per (format × carrier × direction), transcribed from each crate's `STATUS.md` with the row +cited on every cell: + +| Format | EXIF | XMP | ICC | IPTC-IIM | C2PA | typed wiring | +| --- | --- | --- | --- | --- | --- | --- | +| JPEG | r/w | r/w | r/w | — | — | ✅ (`metadata` feature) | +| PNG | r/w | r/w | r/w | — | — | — | +| WebP | r/w | r/w | r/w | — | — | — | +| AVIF | r/w | r/w | r/w | — | — | — | +| HEIC | r | r | r | — | r | ✅ (`metadata` feature) | +| JPEG XL | r/w | r/w | r/w | — | — | ✅ (`metadata` feature) | +| TIFF | — | — | — | — | — | — | +| DNG | r/w | r/w | r/w | r/w | — | ✅ | + +```rust +use gamut_metadata::capability::{Carrier, Direction, Format, supports, typed_wiring}; + +assert!(supports(Format::Jpeg, Carrier::Exif, Direction::Write)); +assert!(!supports(Format::Heic, Carrier::Exif, Direction::Write)); // decode-only crate +assert!(typed_wiring(Format::Jpeg)); // `blocks()` / `metadata()` / `with_metadata` exist there +assert!(!typed_wiring(Format::Png)); // raw bytes handed to `Metadata::from_blocks` by hand +``` + +`supports` describes the crate's **raw** surface — its byte-level `metadata()` / `with_*` API; +`typed_wiring` says whether it also exposes this crate's models directly. C2PA is read-only +everywhere by construction — no embedder copies a manifest store forward (see above). + +Both describe the surface a crate **defines**, not the surface a particular build compiled: a +`const` table sees neither another crate's Cargo features nor the target. Two places where that +gap is real, and both are in the table's own docs. `gamut-jxl` gates its reader on its `decode` +feature and its encoder on `encode`, so a build with `default-features = false, features = +["encode"]` has no reader while the table still says `r`. And typed wiring reaches three of the +four wired crates through an optional `metadata` feature of their own (`gamut-jpeg`, `gamut-jxl`, +`gamut-heic`, each off by default and each forwarded weakly by the `gamut` umbrella) — but +**`gamut-dng` has no such feature**: it depends on this crate unconditionally, so `gamut-dng/metadata` +is not something a manifest can ask for. + +The enums are `#[repr(u8)]` with append-only discriminants. `Format` and `Carrier` are +`#[non_exhaustive]` and carry their `ALL` constants as **slices** rather than fixed-length arrays, +so appending a variant does not change a constant's type under a caller who named it; `Direction` +is exhaustive and keeps an array. The audio/video half of the same question is outside an +image-first workspace and stays with issue #216. + ## Consumer integration -The format crates (`gamut-avif`/`gamut-webp`/`gamut-heic`/…) gaining a `gamut-metadata` dependency to -read, preserve, and embed metadata is tracked in their own milestones; the dependency direction -`format → gamut-metadata → per-format crates` is settled here. +The dependency direction is `format → gamut-metadata → per-format crates`, settled here. A format +crate wires the facade in behind an optional `metadata` Cargo feature (a normal, not dev, +dependency — release ordering follows it): its decoded metadata type gains `blocks()` (the located +payloads as `MetadataBlock`s, e.g. a JPEG's EXIF with the `Exif\0\0` signature already stripped, +an ISOBMFF `Exif` item with its `exif_tiff_header_offset` already applied) and `metadata()` +(`Metadata::from_blocks` over them), and its encoder gains `with_metadata(&Metadata)` — embedding +through `MetadataEmbedder::new()` and routing each `EncodedMetadata` field to the crate's raw +setter — plus `with_encoded_metadata(&EncodedMetadata)` for a caller who chose the embedder's +policies. A carrier the container cannot write is a typed `Unsupported` error there, never a silent +drop, and a manifest store is never copied forward. + +**Precedence, as it behaves today.** A carrier absent from the model leaves whatever the caller set +earlier untouched; a carrier *present* in the model overwrites it, because the routing is the raw +setter. That is unremarkable for a container box, and consequential for ICC in JPEG XL, where the +profile is not a box but the codestream's colour encoding: `JxlEncoder::with_metadata` routes a +present profile to `with_color(ColorSpec::Icc(…))`, replacing a `ColorSpec` the caller chose before +the call. Order the two accordingly. Whether last-write-wins is the right rule for a colour +encoding — as against refusing the conflict — is an open question, filed as #626 rather than +decided here. + +Wired today: `gamut-dng` (its `DngMetadata` holds the facade's `Exif` by value), `gamut-jpeg`, +`gamut-jxl` and `gamut-heic` (decode-only). The remaining format crates hand their payloads over as +raw bytes — see the capability table above. ## License diff --git a/crates/gamut-metadata/src/capability.rs b/crates/gamut-metadata/src/capability.rs new file mode 100644 index 00000000..de2566f7 --- /dev/null +++ b/crates/gamut-metadata/src/capability.rs @@ -0,0 +1,356 @@ +//! Which metadata carrier each gamut format crate can locate or write, as a queryable table. +//! +//! The facade is container-agnostic, so it cannot *do* anything with a format; what it can do is +//! answer, statically and without pulling any format crate in, the question a caller asks before +//! reaching for one: "can gamut read (or write) EXIF in a WebP?". The real matrix is not uniform — +//! HEIC is decode-only, TIFF locates nothing yet, DNG alone carries a legacy IPTC-IIM block — so the +//! model is per **(format × carrier × direction)** rather than a flat per-format flag. +//! +//! Two questions, two functions: +//! +//! - [`supports`] — can the format crate **locate** ([`Direction::Read`]) or **write** +//! ([`Direction::Write`]) the carrier as a raw payload? This is the crate's own surface +//! (`metadata()` / `with_exif`-style setters). +//! - [`typed_wiring`] — does the format crate also expose the facade's typed models directly +//! (`blocks()` / `metadata()` accessors and a `with_metadata` encoder builder), behind that +//! crate's optional `metadata` Cargo feature where it has one? +//! +//! Both answer for the surface a crate **defines**, not for what a particular build compiled: a +//! `const fn` sees neither the Cargo features another crate was built with nor the target. Each +//! function's own docs name the cases where that gap is real. +//! +//! The table is a transcription of each crate's `STATUS.md` **as of this facade version**; every +//! arm below cites the row that justifies it, and the cell changes in the pull request that changes +//! the row. It is deliberately a `const` table rather than a runtime registry: the set of formats is +//! the workspace's, fixed at build time, and a query must be answerable from a crate that depends on +//! no format crate at all (the release topology forbids the reverse edge). +//! +//! The audio/video half of the same question — which *media* containers carry which metadata — is +//! outside an image-first workspace and stays with the issue that asked for it. +//! +//! ``` +//! use gamut_metadata::capability::{Carrier, Direction, Format, supports, typed_wiring}; +//! +//! // HEIC is decode-only: EXIF can be located but never written. +//! assert!(supports(Format::Heic, Carrier::Exif, Direction::Read)); +//! assert!(!supports(Format::Heic, Carrier::Exif, Direction::Write)); +//! +//! // Only DNG carries the legacy IPTC-IIM block; everywhere else IPTC rides inside XMP. +//! assert!(supports(Format::Dng, Carrier::IptcIim, Direction::Write)); +//! assert!(!supports(Format::Jpeg, Carrier::IptcIim, Direction::Write)); +//! +//! // A typed `Metadata` accessor exists on the JPEG crate (behind its `metadata` feature)... +//! assert!(typed_wiring(Format::Jpeg)); +//! // ...but not yet on PNG, whose payloads are still handed over as raw bytes. +//! assert!(!typed_wiring(Format::Png)); +//! ``` + +/// A still-image container format with a gamut crate. +/// +/// `#[non_exhaustive]` and `#[repr(u8)]` with **permanent, append-only** discriminants: a later +/// format is added at the end and never renumbers an existing one, so the value is stable across +/// the C ABI. Match with a wildcard arm; iterate with [`Format::ALL`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[non_exhaustive] +#[repr(u8)] +pub enum Format { + /// JPEG-1 (ISO/IEC 10918-1), `gamut-jpeg`. + Jpeg = 0, + /// PNG (W3C, 3rd edition), `gamut-png`. + Png = 1, + /// WebP (RIFF), `gamut-webp`. + WebP = 2, + /// AVIF (ISOBMFF/HEIF over AV1), `gamut-avif`. + Avif = 3, + /// HEIC/HEIF (ISOBMFF over HEVC), `gamut-heic` — decode-only. + Heic = 4, + /// JPEG XL (ISO/IEC 18181), `gamut-jxl`. + Jxl = 5, + /// TIFF 6.0, `gamut-tiff`. + Tiff = 6, + /// DNG 1.7.1 (a TIFF/EP profile), `gamut-dng`. + Dng = 7, +} + +impl Format { + /// Every format, in discriminant order — the way to enumerate a `#[non_exhaustive]` enum. + /// + /// A slice, not a fixed-length array, precisely because the enum is `#[non_exhaustive]`: + /// appending a format would change an array constant's *type*, breaking every caller who + /// named it, which is the opposite of what `#[non_exhaustive]` promises. + pub const ALL: &'static [Self] = &[ + Self::Jpeg, + Self::Png, + Self::WebP, + Self::Avif, + Self::Heic, + Self::Jxl, + Self::Tiff, + Self::Dng, + ]; + + /// The Cargo package that implements the format. + #[must_use] + pub const fn crate_name(self) -> &'static str { + match self { + Self::Jpeg => "gamut-jpeg", + Self::Png => "gamut-png", + Self::WebP => "gamut-webp", + Self::Avif => "gamut-avif", + Self::Heic => "gamut-heic", + Self::Jxl => "gamut-jxl", + Self::Tiff => "gamut-tiff", + Self::Dng => "gamut-dng", + } + } +} + +/// A metadata carrier: one genuinely distinct serialization a container holds, matching the +/// variants of [`MetadataBlock`](crate::MetadataBlock). +/// +/// `#[non_exhaustive]` and `#[repr(u8)]` with permanent, append-only discriminants, like +/// [`Format`]. Iterate with [`Carrier::ALL`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[non_exhaustive] +#[repr(u8)] +pub enum Carrier { + /// An EXIF blob (a TIFF stream, with or without the `Exif\0\0` marker). + Exif = 0, + /// An XMP packet — which is also where IPTC Core/Extension lives. + Xmp = 1, + /// An ICC profile. + Icc = 2, + /// The legacy binary IPTC-IIM dataset stream. + IptcIim = 3, + /// A C2PA manifest store (JUMBF superbox). Read-only by nature everywhere: the facade never + /// copies a store forward (see [`C2paPolicy`](crate::C2paPolicy)), so no format writes one. + C2pa = 4, +} + +impl Carrier { + /// Every carrier, in discriminant order. A slice for the same reason as [`Format::ALL`]. + pub const ALL: &'static [Self] = &[Self::Exif, Self::Xmp, Self::Icc, Self::IptcIim, Self::C2pa]; +} + +/// Which way the metadata moves. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u8)] +pub enum Direction { + /// Locating the carrier's payload in an existing file (the crate's `metadata()` / lens). + Read = 0, + /// Writing the carrier into a file the crate encodes (the crate's `with_*` builders). + Write = 1, +} + +impl Direction { + /// Both directions. An array, since this enum is exhaustive and cannot gain a variant. + pub const ALL: [Self; 2] = [Self::Read, Self::Write]; +} + +/// Whether the crate for `format` can locate (`Read`) or write (`Write`) `carrier` as a raw payload. +/// +/// This is the **raw** surface — the crate's own byte-level `metadata()` / `with_*` API — as +/// against the facade's typed models, which are [`typed_wiring`]. Each arm cites the `STATUS.md` +/// row of the crate it describes. +/// +/// # What a `const` table cannot see +/// +/// It answers for the surface the crate **defines**, not for the surface a given build compiled: +/// a `const fn` observes neither another crate's Cargo features nor the target. Most format crates +/// ship their raw surface unconditionally, but not all — `gamut-jxl` gates its reader +/// (`JxlDecoder::metadata`, `JxlDecoder::embedded_icc_profile`) on its `decode` feature and its +/// encoder on `encode`, so under `default-features = false, features = ["encode"]` no reader +/// exists while this table still answers `true` for [`Direction::Read`]. A caller who switches a +/// format crate's own default features off owns that intersection. +#[must_use] +pub const fn supports(format: Format, carrier: Carrier, direction: Direction) -> bool { + let read = matches!(direction, Direction::Read); + match (format, carrier) { + // gamut-jpeg STATUS.md P7: APP1 EXIF + XMP and multi-segment APP2 ICC, read (`metadata()`) + // and write (`with_exif` / `with_xmp` / `with_icc_profile`). + (Format::Jpeg, Carrier::Exif | Carrier::Xmp | Carrier::Icc) => true, + // gamut-jpeg STATUS.md "Not implemented": APP13 IPTC-IIM deferred; no APP11 (C2PA) carriage. + (Format::Jpeg, Carrier::IptcIim | Carrier::C2pa) => false, + + // gamut-png STATUS.md P8 (eXIf / iCCP / iTXt-XMP setters) and D5 (raw eXIf / iCCP / XMP + // payloads on decode). + (Format::Png, Carrier::Exif | Carrier::Xmp | Carrier::Icc) => true, + // gamut-png STATUS.md: no IPTC-IIM chunk exists in PNG; no C2PA (`caBX`) row today. + (Format::Png, Carrier::IptcIim | Carrier::C2pa) => false, + + // gamut-webp STATUS.md M4: `ICCP` / `EXIF` / `XMP ` chunks embedded on encode and preserved + // on decode (`metadata` / `with_icc_profile` / `with_exif` / `with_xmp`). + (Format::WebP, Carrier::Exif | Carrier::Xmp | Carrier::Icc) => true, + // gamut-webp STATUS.md: RIFF has no IPTC-IIM chunk; no C2PA (`C2PA` chunk) row today. + (Format::WebP, Carrier::IptcIim | Carrier::C2pa) => false, + + // gamut-avif STATUS.md M4: Exif / XMP items with a `cdsc` reference and `colr` `prof` ICC, + // written by `AvifEncoder::with_exif` / `with_xmp` / `with_icc_profile` and read back. + (Format::Avif, Carrier::Exif | Carrier::Xmp | Carrier::Icc) => true, + // gamut-avif STATUS.md: no IPTC-IIM item type; no C2PA `uuid` box row today. + (Format::Avif, Carrier::IptcIim | Carrier::C2pa) => false, + + // gamut-heic STATUS.md B (Exif/XMP lens via `cdsc`, `colr` accessor) and S7 (C2PA manifest + // store located in a top-level `uuid` box). The crate is decode-only: nothing is written. + (Format::Heic, Carrier::Exif | Carrier::Xmp | Carrier::Icc | Carrier::C2pa) => read, + // gamut-heic STATUS.md: HEIF has no IPTC-IIM item type. + (Format::Heic, Carrier::IptcIim) => false, + + // gamut-jxl STATUS.md "Exif / XMP container boxes" (written by `with_exif` / `with_xmp`, + // read back by `JxlDecoder::metadata`) and "Colour signalling" (`ColorSpec::Icc` written, + // `embedded_icc_profile` read). + (Format::Jxl, Carrier::Exif | Carrier::Xmp | Carrier::Icc) => true, + // gamut-jxl STATUS.md: no IPTC-IIM box; the `jumb` (C2PA) box is neither located nor + // written, and a Brotli-compressed `brob` metadata box is a typed `Unsupported`. + (Format::Jxl, Carrier::IptcIim | Carrier::C2pa) => false, + + // gamut-tiff STATUS.md: "metadata tags (§12 beyond `PageNumber`)" deferred — the `XMP`, + // `ExifIFD` and `ICCProfile` tags are recognised by name only; no payload is located and + // there is no `with_exif`-style setter. + (Format::Tiff, _) => false, + + // gamut-dng STATUS.md P16: EXIF sub-IFD + XMP (700) / IPTC-IIM (33723) / ICC (34675), + // embedded and decoded (`DngMetadata`, `DngMetadata::blocks`). + (Format::Dng, Carrier::Exif | Carrier::Xmp | Carrier::Icc | Carrier::IptcIim) => true, + // gamut-dng STATUS.md "Out of scope": C2PA surfaces only as a typed `RawTag`, not as a + // located manifest store. + (Format::Dng, Carrier::C2pa) => false, + } +} + +/// Whether the crate for `format` exposes the facade's typed models directly — `blocks()` / +/// `metadata()` accessors on its decoded metadata and a `with_metadata` builder on its encoder. +/// +/// `false` means the crate still hands its payloads over as raw bytes that a caller feeds to +/// [`Metadata::from_blocks`](crate::Metadata::from_blocks) by hand. +/// +/// # How a `true` cell is switched on +/// +/// Three of the four wired crates put that surface behind an optional `metadata` Cargo feature of +/// their own — `gamut-jpeg`, `gamut-jxl` and `gamut-heic`, each off by default, and each forwarded +/// weakly by the `gamut` umbrella's `metadata` feature. `gamut-dng` has **no such feature**: it +/// depends on this crate unconditionally, because its `DngMetadata` holds the facade's `Exif` by +/// value, so `gamut-dng/metadata` is not a feature that can be asked for. As with [`supports`], +/// the answer describes what the crate defines, not what a given build compiled. +#[must_use] +pub const fn typed_wiring(format: Format) -> bool { + match format { + // gamut-dng STATUS.md P16 (#353): `DngMetadata::exif` is the facade's `Exif`, `blocks()` + // hands over the byte carriers. gamut-jpeg / gamut-jxl / gamut-heic: the `metadata` + // feature (issue #420). + Format::Dng | Format::Jpeg | Format::Jxl | Format::Heic => true, + // Raw payloads only, tracked by the #420 remainder. + Format::Png | Format::WebP | Format::Avif | Format::Tiff => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The complete set of `true` cells, transcribed from the per-crate `STATUS.md` rows the table + /// cites. Every other (format, carrier, direction) is `false`. + const SUPPORTED: &[(Format, Carrier, Direction)] = &[ + (Format::Jpeg, Carrier::Exif, Direction::Read), + (Format::Jpeg, Carrier::Exif, Direction::Write), + (Format::Jpeg, Carrier::Xmp, Direction::Read), + (Format::Jpeg, Carrier::Xmp, Direction::Write), + (Format::Jpeg, Carrier::Icc, Direction::Read), + (Format::Jpeg, Carrier::Icc, Direction::Write), + (Format::Png, Carrier::Exif, Direction::Read), + (Format::Png, Carrier::Exif, Direction::Write), + (Format::Png, Carrier::Xmp, Direction::Read), + (Format::Png, Carrier::Xmp, Direction::Write), + (Format::Png, Carrier::Icc, Direction::Read), + (Format::Png, Carrier::Icc, Direction::Write), + (Format::WebP, Carrier::Exif, Direction::Read), + (Format::WebP, Carrier::Exif, Direction::Write), + (Format::WebP, Carrier::Xmp, Direction::Read), + (Format::WebP, Carrier::Xmp, Direction::Write), + (Format::WebP, Carrier::Icc, Direction::Read), + (Format::WebP, Carrier::Icc, Direction::Write), + (Format::Avif, Carrier::Exif, Direction::Read), + (Format::Avif, Carrier::Exif, Direction::Write), + (Format::Avif, Carrier::Xmp, Direction::Read), + (Format::Avif, Carrier::Xmp, Direction::Write), + (Format::Avif, Carrier::Icc, Direction::Read), + (Format::Avif, Carrier::Icc, Direction::Write), + (Format::Heic, Carrier::Exif, Direction::Read), + (Format::Heic, Carrier::Xmp, Direction::Read), + (Format::Heic, Carrier::Icc, Direction::Read), + (Format::Heic, Carrier::C2pa, Direction::Read), + (Format::Jxl, Carrier::Exif, Direction::Read), + (Format::Jxl, Carrier::Exif, Direction::Write), + (Format::Jxl, Carrier::Xmp, Direction::Read), + (Format::Jxl, Carrier::Xmp, Direction::Write), + (Format::Jxl, Carrier::Icc, Direction::Read), + (Format::Jxl, Carrier::Icc, Direction::Write), + (Format::Dng, Carrier::Exif, Direction::Read), + (Format::Dng, Carrier::Exif, Direction::Write), + (Format::Dng, Carrier::Xmp, Direction::Read), + (Format::Dng, Carrier::Xmp, Direction::Write), + (Format::Dng, Carrier::Icc, Direction::Read), + (Format::Dng, Carrier::Icc, Direction::Write), + (Format::Dng, Carrier::IptcIim, Direction::Read), + (Format::Dng, Carrier::IptcIim, Direction::Write), + ]; + + #[test] + fn supports_equals_the_documented_matrix_in_every_cell() { + // Walks the full product so a flipped arm anywhere in `supports` is a named cell here. + for &format in Format::ALL { + for &carrier in Carrier::ALL { + for direction in Direction::ALL { + let expected = SUPPORTED.contains(&(format, carrier, direction)); + assert_eq!( + supports(format, carrier, direction), + expected, + "{format:?} / {carrier:?} / {direction:?}" + ); + } + } + } + } + + #[test] + fn typed_wiring_names_exactly_the_four_wired_crates() { + let wired: Vec = Format::ALL + .iter() + .copied() + .filter(|&f| typed_wiring(f)) + .collect(); + assert_eq!( + wired, + vec![Format::Jpeg, Format::Heic, Format::Jxl, Format::Dng] + ); + } + + #[test] + fn crate_name_follows_the_workspace_naming() { + for &format in Format::ALL { + let name = format.crate_name(); + assert!(name.starts_with("gamut-"), "{format:?}: {name}"); + assert_eq!( + name.trim_start_matches("gamut-"), + format!("{format:?}").to_ascii_lowercase(), + "{format:?}: {name}" + ); + } + } + + #[test] + fn discriminants_are_the_documented_append_only_values() { + // The `repr(u8)` values are a public contract (C ABI); pin them so a reorder is a failure. + // Collected rather than `map`ped over an array: `ALL` is a slice, so its length is part of + // what these compare, and an appended variant without its discriminant fails here. + assert_eq!( + Format::ALL.iter().map(|&f| f as u8).collect::>(), + (0..8).collect::>() + ); + assert_eq!( + Carrier::ALL.iter().map(|&c| c as u8).collect::>(), + (0..5).collect::>() + ); + assert_eq!(Direction::ALL.map(|d| d as u8), [0, 1]); + } +} diff --git a/crates/gamut-metadata/src/lib.rs b/crates/gamut-metadata/src/lib.rs index 1634349a..1a781c4e 100644 --- a/crates/gamut-metadata/src/lib.rs +++ b/crates/gamut-metadata/src/lib.rs @@ -156,6 +156,22 @@ //! # Ok::<(), gamut_metadata::MetadataError>(()) //! ``` //! +//! # Which formats carry what +//! +//! The facade never parses a container, so it cannot say whether a *file* has metadata — but it can +//! say whether gamut's crate for a format can locate or write a given carrier at all, before a +//! caller pulls that crate in. [`capability::supports`] answers per (format × carrier × direction), +//! and [`capability::typed_wiring`] says whether the format crate also exposes these typed models +//! directly (behind its `metadata` feature) rather than as raw bytes: +//! +//! ``` +//! use gamut_metadata::capability::{Carrier, Direction, Format, supports, typed_wiring}; +//! +//! assert!(supports(Format::Jpeg, Carrier::Exif, Direction::Write)); +//! assert!(!supports(Format::Heic, Carrier::Exif, Direction::Write)); // decode-only crate +//! assert!(typed_wiring(Format::Jpeg)); +//! ``` +//! //! # Quick start //! //! ``` @@ -178,6 +194,7 @@ //! ``` #![forbid(unsafe_code)] +pub mod capability; pub mod embed; pub mod error; pub mod extension; diff --git a/crates/gamut/Cargo.toml b/crates/gamut/Cargo.toml index 6bbfa6f4..c83bb5ab 100644 --- a/crates/gamut/Cargo.toml +++ b/crates/gamut/Cargo.toml @@ -89,6 +89,13 @@ metadata = [ "dep:gamut-icc", "dep:gamut-iptc", "dep:gamut-ifd", + # The format crates' own `metadata` features, which turn on their typed accessors + # (`blocks()` / `metadata()` / `with_metadata`). Weak (`?/`), so each one is enabled only + # when that format's feature already pulled the crate in: `metadata` alone must not drag a + # codec into the build, and a format alone must not drag the facade in. + "gamut-jpeg?/metadata", + "gamut-jxl?/metadata", + "gamut-heic?/metadata", ] # The ICC colour management module (transform engine) over gamut-icc profiles. cmm = ["dep:gamut-cmm"] diff --git a/crates/gamut/tests/feature_forwarding.rs b/crates/gamut/tests/feature_forwarding.rs new file mode 100644 index 00000000..961de247 --- /dev/null +++ b/crates/gamut/tests/feature_forwarding.rs @@ -0,0 +1,141 @@ +//! integration · drift guard — the umbrella's `metadata` feature forwards to the format crates. +//! +//! `gamut-jpeg`, `gamut-jxl` and `gamut-heic` each carry the typed metadata accessors behind their +//! own `metadata` Cargo feature. The umbrella's `metadata` feature forwards to all three weakly +//! (`gamut-jpeg?/metadata`), so that a consumer who asks the umbrella for a format *and* for +//! metadata gets the wiring between them, while neither half drags the other in on its own. +//! +//! **Why this cannot live in a lower crate.** `AGENTS.md` forbids pinning anything under +//! `crates/gamut/tests/` *by choice*, because `.cargo/mutants.toml` sets `test_workspace = false` +//! and excludes `crates/gamut/**`, so nothing here can kill a mutant. This file is not here by +//! choice: what it pins is an edge in the umbrella's own feature graph, and no lower crate can +//! observe that edge — `gamut-jpeg` cannot see who enabled its `metadata` feature, and the three +//! format crates must not gain dev-dependency edges on one another or on the umbrella +//! (`mise run check-release-deps`). That is the linkage exception the same rule names, and this +//! file is in its only legal home. +//! +//! **These tests are therefore mutation-invisible**, and nothing else holds the forwards: the +//! wiring they switch on is covered inside each format crate (`gamut-jpeg`'s, `gamut-jxl`'s and +//! `gamut-heic`'s own `metadata` suites, all mutation-visible), but the *forward* — the three +//! entries in `crates/gamut/Cargo.toml` — is pinned by this file alone. Delete it and a dropped +//! forward becomes silent again. +//! +//! The two directions are pinned by different techniques because only one of them is observable +//! from a compiled build: +//! +//! - **Forward fires.** With the umbrella's `metadata` feature and a format's feature both on, the +//! format crate's `metadata` feature is on too. Observed by *resolution*: each name below exists +//! only under that feature, so a dropped forward is a compile error. +//! - **Forward is weak, and belongs to `metadata`.** Enabling `metadata` alone must not pull a +//! codec into a build that asked for none, and enabling a format alone must not pull the facade +//! in. Both are properties of the feature *table* rather than of any compiled artefact — a +//! single build cannot see either — so they are pinned as a drift guard over the manifest text. +//! +//! The complementary half of the negative direction — that a format feature alone pulls in no +//! facade crate — holds because each format crate's `metadata` feature is off by default, which is +//! that crate's property and is pinned in that crate. It is measured here only as evidence +//! (`cargo tree -p gamut --features "jpeg,jxl,heic"` lists no facade crate), not asserted. + +/// With `metadata` and all three format features on, every forwarded name resolves. +/// +/// Nothing is called and no fixture is built: a fixture bug, a signature change or a parser defect +/// must not be able to fail this test. Two names per crate are enough to observe the edge *and* to +/// say which of the two possible faults broke it — a dropped forward, or a renamed accessor. They +/// fail in different combinations, so the compile error is a diagnosis rather than a puzzle. +#[cfg(all( + feature = "metadata", + feature = "jpeg", + feature = "jxl", + feature = "heic" +))] +#[test] +fn the_metadata_feature_reaches_each_format_crates_accessors() { + /// Accepts any item and does nothing: naming one as the argument is the whole assertion, and + /// the generic parameter keeps the pin independent of the item's signature. + fn resolves(_item: T) {} + + /// Accepts any *type* and does nothing, so a feature-gated re-export can be named without + /// constructing a value of it. + fn type_resolves() {} + + // The feature witness. Each crate re-exports the facade's `Metadata` under its own + // `#[cfg(feature = "metadata")]`, so these three paths resolve exactly when the forward fired, + // and they name a type this branch does not own. A dropped forward breaks the witness *and* + // the accessor beneath it; renaming an accessor breaks only the accessor. Without the witness + // the two produce the same error, and the next reader cannot tell which happened. + type_resolves::(); + type_resolves::(); + type_resolves::(); + + // The accessors the forward exists to deliver. Each is `#[cfg(feature = "metadata")]` inside + // its own crate, so the path resolves only if the umbrella's forward switched that crate's + // `metadata` feature on. A rename here is a signal to update the pin, not a false alarm. + resolves(gamut::jpeg::JpegMetadata::blocks); + resolves(gamut::jxl::JxlMetadata::blocks); + resolves(gamut::heic::HeifImage::blocks); +} + +/// The entry list of the umbrella's own `metadata` feature, sliced out of `manifest`. +/// +/// `None` when the feature's opening line or its closing bracket is not where this file reads +/// them, which the caller reports rather than asserting over text it did not find. +fn metadata_feature_entries(manifest: &str) -> Option<&str> { + let after_open = manifest.split_once("\nmetadata = [")?.1; + Some(after_open.split_once("\n]")?.0) +} + +/// Every forward is weak and is listed under `metadata` alone. +/// +/// `gamut-jpeg?/metadata` enables that crate's feature only if something else already brought the +/// crate in; `gamut-jpeg/metadata` — the same line without the `?` — would enable the optional +/// dependency itself, so asking the umbrella for metadata would silently compile three codecs. +/// A single build cannot observe the difference, so the feature table is read directly. +/// +/// *Which* list an entry sits in matters as much as its form, and matching the whole file cannot +/// see that: moving `"gamut-jpeg?/metadata"` out of `metadata = [ … ]` and into `jpeg = [ … ]` +/// leaves the text present, while making `--features jpeg` alone resolve the format crate with its +/// metadata wiring — and therefore the entire facade — which is the build the weak form exists to +/// prevent. So presence is asserted over the feature's **own entry list**, and a manifest-wide +/// count pins that the entry is not *also* attached to a format feature. +/// +/// This one carries no `cfg`, so under a feature set that compiles the resolution test above out — +/// `--features metadata` with no format — it is also what notices a forward being deleted. +#[test] +fn every_metadata_forward_is_weak_and_listed_under_metadata_alone() { + // Compiled in, not read from disk: the pin travels with the crate, including in a package + // built for publication. + const MANIFEST: &str = include_str!("../Cargo.toml"); + + let Some(entries) = metadata_feature_entries(MANIFEST) else { + panic!( + "the umbrella's `metadata = [ … ]` feature list is not where this pin reads it, so \ + none of the forwards below could be checked at all" + ) + }; + + for crate_name in ["gamut-jpeg", "gamut-jxl", "gamut-heic"] { + let weak = format!("\"{crate_name}?/metadata\""); + let strong = format!("\"{crate_name}/metadata\""); + // The strong form is checked first so that dropping the `?` is diagnosed as dropping the + // `?`. Checking presence first would report a de-weakened forward as a missing one, which + // sends the next reader after the wrong fault. + assert!( + !MANIFEST.contains(&strong), + "the forward {strong} is not weak; enabling `metadata` alone would now pull \ + {crate_name} into builds that asked for no codec" + ); + assert!( + entries.contains(&weak), + "the umbrella's `metadata` feature no longer lists {weak}; a consumer enabling \ + `metadata` with that format can no longer reach its typed accessors — and if the \ + entry moved to that format's own feature, that format alone now drags in the whole \ + facade" + ); + assert_eq!( + MANIFEST.matches(weak.as_str()).count(), + 1, + "{weak} is listed more than once; a second copy under a format feature makes that \ + format alone drag in the whole facade" + ); + } +}