fix(heif): a grid's ICC profile is inherited from its tiles (#5553)

fix(heif): a grid's ICC profile is inherited from its tiles

Also covers the same fix as #5591; thanks to its author.

Co-authored-by: nrq6993-a11y <342501791+nrq6993-a11y@users.noreply.github.com>
This commit is contained in:
Sidney Markowitz 2026-10-15 11:04:55 +13:00 committed by GitHub
parent 914d51f9bb
commit d334d32c12
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 226 additions and 1 deletions

View File

@ -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

View File

@ -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<Vec<u8>>, Option<Vec<u8>>, Option<String>)
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<Vec<u8>>, Option<Vec<u8>>, Option<String>)
(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<Vec<u8>> {
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<u8> {
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<u8> {
let mut p = vec![version, 0, 0, 0];
p.extend_from_slice(payload);
bx(kind, &p)
}
fn hdlr() -> Vec<u8> {
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<u8> {
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<u8> {
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<u8> {
let mut p = b"prof".to_vec();
p.extend_from_slice(icc);
bx(b"colr", &p)
}
fn ispe(w: u32, h: u32) -> Vec<u8> {
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<u8> {
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<u8> {
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<u8> {
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<u8> {
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<u8> {
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);
}
}