diff --git a/contributors/people.toml b/contributors/people.toml index 6f630a0c12..8694c72698 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -158,3 +158,7 @@ sync_github_name = false [people.yeniufuklar-com] real_name = "Ufuk Efe" sync_github_name = true + +[people.sidney] +real_name = "Sidney Markowitz" +sync_github_name = true diff --git a/crates/heif/src/lib.rs b/crates/heif/src/lib.rs index ae3aff5a74..7c88fc1b9c 100644 --- a/crates/heif/src/lib.rs +++ b/crates/heif/src/lib.rs @@ -5,6 +5,9 @@ //! EXIF and XMP. Its API is plain data ([`Info`], [`Decoded`], [`Error`]) so the crate knows //! nothing about the rest of PhotoCraft; `photocraft-codecs` adapts it behind its `heif` feature. //! +//! A grid's ICC profile is inherited from its tiles when the grid item itself carries no `colr`, +//! which is how phone cameras write it: see [`icc_for_item`]. +//! //! HEIF records orientation in the container (`irot`/`imir`, plus a `clap` crop), not in EXIF. //! [`Options::apply_transforms`] applies them; with it off, the pixels come back as coded. //! @@ -163,7 +166,7 @@ fn metadata(bytes: &[u8]) -> (Option>, Option>, Option) return (None, None, None); }; let id = ctx.meta.primary; - let icc = ctx.props(id).ok().and_then(|p| ctx.icc(&p).map(<[u8]>::to_vec)); + let icc = icc_for_item(&ctx, id); let exif = ctx.exif(id).ok().flatten().map(<[u8]>::to_vec); let xmp = ctx.xmp_item(id).and_then(|x| ctx.item_data(x).ok()).and_then(|d| match d { Cow::Borrowed(b) => String::from_utf8(b.to_vec()).ok(), @@ -172,6 +175,28 @@ fn metadata(bytes: &[u8]) -> (Option>, Option>, Option) (icc, exif, xmp.map(|x| x.trim_end_matches('\0').to_string())) } +/// The ICC profile describing `item`, inherited from the coded tiles when the item itself carries +/// none. +/// +/// `colr` is optional on a `grid` derivation, and phone cameras write it on the tiles instead: a +/// OnePlus 11 photograph carries one `prof` (Display P3) on each of its 48 `hvc1` tiles and +/// nothing on the grid. Asking the primary item alone therefore loses the profile and the photo +/// opens as if it were sRGB. The tiles are what the mosaic is composed from, so their profile is +/// the composed image's profile. +/// +/// heic-rs resolves `nclx` from the primary item in the same way and has the same gap +/// (tbraun96/heic-rs#6). The sample range cannot be recovered here as well: `DecodeOptions` has no +/// override for it, so that half has to be fixed upstream. +fn icc_for_item(ctx: &heic_rs::context::Context<'_>, item: u32) -> Option> { + let own = ctx.props(item).ok().and_then(|p| ctx.icc(&p).map(<[u8]>::to_vec)); + if own.is_some() { + return own; + } + // `Ok(None)` is a coded picture rather than a derivation: no tiles to inherit from. + let (_, tiles) = ctx.grid(item).ok()??; + tiles.iter().find_map(|&t| ctx.props(t).ok().and_then(|p| ctx.icc(&p).map(<[u8]>::to_vec))) +} + #[cfg(test)] mod tests { use super::*; @@ -215,4 +240,200 @@ mod tests { assert!(Error::Limit("x".into()).to_string().contains("limit")); assert!(Error::Malformed("x".into()).to_string().contains("malformed")); } + + // A grid whose colour description lives on its tiles, not on the grid item. + // + // The fixtures below are synthetic containers with no coded data at all: resolving a profile + // never decodes a pixel. Each test first asserts the fixture is the shape it claims to be, so + // a mistake in the builder cannot look like a passing fix. + + /// Stands in for a profile. `colr` hands its payload back without parsing it. + const TILE_ICC: &[u8] = b"tile-profile"; + const GRID_ICC: &[u8] = b"grid-profile"; + + const GRID_ID: u16 = 1; + const PICTURE_ID: u16 = 2; + + fn bx(kind: &[u8; 4], payload: &[u8]) -> Vec { + let mut v = u32::try_from(payload.len() + 8).unwrap().to_be_bytes().to_vec(); + v.extend_from_slice(kind); + v.extend_from_slice(payload); + v + } + + /// A box whose first four payload bytes are a version and 24 bits of flags. + fn full(kind: &[u8; 4], version: u8, payload: &[u8]) -> Vec { + let mut p = vec![version, 0, 0, 0]; + p.extend_from_slice(payload); + bx(kind, &p) + } + + fn hdlr() -> Vec { + let mut p = vec![0; 4]; + p.extend_from_slice(b"pict"); + p.extend_from_slice(&[0; 12]); + p.push(0); + full(b"hdlr", 0, &p) + } + + fn infe(id: u16, item_type: &[u8; 4]) -> Vec { + let mut p = id.to_be_bytes().to_vec(); + p.extend_from_slice(&0u16.to_be_bytes()); + p.extend_from_slice(item_type); + p.push(0); + full(b"infe", 2, &p) + } + + fn iref_dimg(from: u16, to: u16) -> Vec { + let mut p = from.to_be_bytes().to_vec(); + p.extend_from_slice(&1u16.to_be_bytes()); + p.extend_from_slice(&to.to_be_bytes()); + full(b"iref", 0, &bx(b"dimg", &p)) + } + + fn colr_prof(icc: &[u8]) -> Vec { + let mut p = b"prof".to_vec(); + p.extend_from_slice(icc); + bx(b"colr", &p) + } + + fn ispe(w: u32, h: u32) -> Vec { + let mut p = w.to_be_bytes().to_vec(); + p.extend_from_slice(&h.to_be_bytes()); + full(b"ispe", 0, &p) + } + + fn ipma(entries: &[(u16, &[u8])]) -> Vec { + let mut p = u32::try_from(entries.len()).unwrap().to_be_bytes().to_vec(); + for (item, assoc) in entries { + p.extend_from_slice(&item.to_be_bytes()); + p.push(u8::try_from(assoc.len()).unwrap()); + p.extend_from_slice(assoc); + } + full(b"ipma", 0, &p) + } + + /// `item`'s bytes live in `idat` (construction method 1) at offset 0. + fn iloc_in_idat(item: u16, length: u32) -> Vec { + let mut p = vec![0x44, 0x00]; + p.extend_from_slice(&1u16.to_be_bytes()); + p.extend_from_slice(&item.to_be_bytes()); + p.extend_from_slice(&1u16.to_be_bytes()); + p.extend_from_slice(&0u16.to_be_bytes()); + p.extend_from_slice(&1u16.to_be_bytes()); + p.extend_from_slice(&0u32.to_be_bytes()); + p.extend_from_slice(&length.to_be_bytes()); + full(b"iloc", 1, &p) + } + + fn file(meta: &[u8]) -> Vec { + let mut ftyp = b"heic".to_vec(); + ftyp.extend_from_slice(&0u32.to_be_bytes()); + ftyp.extend_from_slice(b"mif1heic"); + let mut out = bx(b"ftyp", &ftyp); + out.extend_from_slice(&full(b"meta", 0, meta)); + out + } + + /// A 1x1 64x64 grid, with `grid_icc` on the grid item and `tile_icc` on its single tile. + /// + /// `ipco` children are numbered from 1 in file order; an association byte's top bit marks the + /// property essential, as macOS writes it. + fn grid_file(grid_icc: Option<&[u8]>, tile_icc: Option<&[u8]>) -> Vec { + let mut ipco = ispe(64, 64); + let (mut grid_assoc, mut tile_assoc, mut index) = (vec![0x81], vec![0x81], 1u8); + if let Some(icc) = grid_icc { + index += 1; + ipco.extend_from_slice(&colr_prof(icc)); + grid_assoc.push(0x80 | index); + } + if let Some(icc) = tile_icc { + index += 1; + ipco.extend_from_slice(&colr_prof(icc)); + tile_assoc.push(0x80 | index); + } + let mut iprp = bx(b"ipco", &ipco); + iprp.extend_from_slice(&ipma(&[(GRID_ID, grid_assoc.as_slice()), (PICTURE_ID, tile_assoc.as_slice())])); + + let mut items = 2u16.to_be_bytes().to_vec(); + items.extend_from_slice(&infe(GRID_ID, b"grid")); + items.extend_from_slice(&infe(PICTURE_ID, b"hvc1")); + + // Version 0, flags 0, one row, one column, 64x64 output. + const DESCRIPTOR: &[u8] = &[0, 0, 0, 0, 0, 64, 0, 64]; + + let mut meta = hdlr(); + meta.extend_from_slice(&full(b"pitm", 0, &GRID_ID.to_be_bytes())); + meta.extend_from_slice(&full(b"iinf", 0, &items)); + meta.extend_from_slice(&iref_dimg(GRID_ID, PICTURE_ID)); + meta.extend_from_slice(&bx(b"iprp", &iprp)); + meta.extend_from_slice(&bx(b"idat", DESCRIPTOR)); + meta.extend_from_slice(&iloc_in_idat(GRID_ID, u32::try_from(DESCRIPTOR.len()).unwrap())); + file(&meta) + } + + /// One coded picture rather than a derivation, with `icc` on the item itself. + fn picture_file(icc: Option<&[u8]>) -> Vec { + let mut ipco = ispe(64, 64); + let mut assoc = vec![0x81]; + if let Some(icc) = icc { + ipco.extend_from_slice(&colr_prof(icc)); + assoc.push(0x82); + } + let mut iprp = bx(b"ipco", &ipco); + iprp.extend_from_slice(&ipma(&[(PICTURE_ID, assoc.as_slice())])); + + let mut items = 1u16.to_be_bytes().to_vec(); + items.extend_from_slice(&infe(PICTURE_ID, b"hvc1")); + + let mut meta = hdlr(); + meta.extend_from_slice(&full(b"pitm", 0, &PICTURE_ID.to_be_bytes())); + meta.extend_from_slice(&full(b"iinf", 0, &items)); + meta.extend_from_slice(&bx(b"iprp", &iprp)); + file(&meta) + } + + fn ctx_of(bytes: &[u8]) -> heic_rs::context::Context<'_> { + heic_rs::context::Context::open(bytes).expect("the synthetic container must parse") + } + + #[test] + fn a_grid_inherits_its_tiles_icc_profile() { + let bytes = grid_file(None, Some(TILE_ICC)); + let ctx = ctx_of(&bytes); + let primary = ctx.meta.primary; + assert_eq!(primary, u32::from(GRID_ID)); + let (grid, tiles) = ctx.grid(primary).unwrap().expect("a grid derivation"); + assert_eq!((grid.rows, grid.columns, tiles.len()), (1, 1, 1)); + assert!(ctx.icc(&ctx.props(primary).unwrap()).is_none(), "the grid item carries no colr of its own"); + assert_eq!(ctx.icc(&ctx.props(tiles[0]).unwrap()), Some(TILE_ICC), "the tile carries the profile"); + + assert_eq!(icc_for_item(&ctx, primary).as_deref(), Some(TILE_ICC)); + } + + #[test] + fn a_grids_own_profile_wins_over_its_tiles() { + let bytes = grid_file(Some(GRID_ICC), Some(TILE_ICC)); + let ctx = ctx_of(&bytes); + assert_eq!(icc_for_item(&ctx, ctx.meta.primary).as_deref(), Some(GRID_ICC)); + } + + #[test] + fn an_unlabelled_grid_has_no_profile() { + let bytes = grid_file(None, None); + let ctx = ctx_of(&bytes); + assert_eq!(icc_for_item(&ctx, ctx.meta.primary), None); + } + + #[test] + fn a_coded_picture_resolves_its_own_profile() { + let labelled = picture_file(Some(GRID_ICC)); + let ctx = ctx_of(&labelled); + assert!(ctx.grid(ctx.meta.primary).unwrap().is_none(), "not a derivation"); + assert_eq!(icc_for_item(&ctx, ctx.meta.primary).as_deref(), Some(GRID_ICC)); + + let bare = picture_file(None); + let ctx = ctx_of(&bare); + assert_eq!(icc_for_item(&ctx, ctx.meta.primary), None); + } }