craft-heif: metadata feature, Exif/XMP items without heic-rs (#1055)

* craft-heif: a metadata feature that reads Exif and XMP without heic-rs

The decoder is now the default feature decode, and heic-rs and rayon are
optional. The new metadata feature adds items::read: the first Exif item
and the XMP mime item from the box structure alone (iinf, iloc, idat), with
limits on boxes, items, extents and item size, moved from LightCraft's
lightcraft-meta. Built with --no-default-features --features metadata the
crate has no heic-rs dependency and read_metadata is that reader; default
builds are unchanged. With both features a test checks that the two readers
agree on synthetic files.

* craft-heif: keep #[cfg(test)] on the test modules so clippy treats them as tests

#[cfg(all(test, feature = "decode"))] hid the test cfg from clippy's
allow-unwrap-in-tests, so the helpers in tests_icc.rs and tests_synthetic.rs
failed all-targets clippy. The modules now carry #[cfg(test)] and
#[cfg(feature = "decode")] as two attributes.
This commit is contained in:
Dylan M. Taylor authored and GitHub committed 2026-11-11 21:58:39 -05:00
1 parent 428541a512
commit 5fc172eeb5
4 files changed
+552 -3

No files matched your search

+9 -3
View File
@@ -12,14 +12,20 @@ repository.workspace = true
# moving the pin is a reviewed change (re-run these tests and each app's HEIF corpus tests).
# heic-rs was chosen over the other permissive candidates (heif-oxide's HEVC decoder, rust_h265, follows FFmpeg's (LGPL)
# code structure; gamut-heic has no HEVC decoder of its own).
heic-rs = { version = "=0.1.4", default-features = false, features = ["std"] }
heic-rs = { version = "=0.1.4", default-features = false, features = ["std"], optional = true }
# The tiles of a HEIC photo decode in parallel on native; wasm stays single-threaded.
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
heic-rs = { version = "=0.1.4", default-features = false, features = ["std", "parallel"] }
rayon = { workspace = true }
heic-rs = { version = "=0.1.4", default-features = false, features = ["std", "parallel"], optional = true }
rayon = { workspace = true, optional = true }
[features]
default = ["decode"]
# The decoder (probe, decode, decode_thumbnail, read_metadata): heic-rs for the container and HEVC.
decode = ["dep:heic-rs", "dep:rayon"]
# `items`: the Exif and XMP items from the box structure alone, without heic-rs. Without `decode`,
# `read_metadata` is this reader.
metadata = []
# `craft_heif::testdata`: lossless synthetic HEIF files, for the tests of the crates above.
testdata = []
+6
View File
@@ -36,6 +36,12 @@ Errors are `Error::Unsupported` (image sequences, overlays, 4:2:2/4:4:4, …), `
`Error::Malformed`. It never panics: every call into heic-rs runs under `catch_unwind`, and a panic
inside it (PhotoCraft's fuzzing found two in 0.1.1) becomes `Error::Malformed`.
Features: `decode` (default) is the decoder above. `metadata` adds `craft_heif::items::read`: the
first `Exif` item and XMP packet from the box structure alone (`iinf`, `iloc`, `idat`; at most 4096
boxes, 1024 items, 1024 extents per item, 32 MiB per item), without heic-rs. A build with
`default-features = false, features = ["metadata"]` has no heic-rs dependency, and its
`read_metadata` is that reader. With both features, a test checks that the two readers agree.
Feature `testdata` exposes `craft_heif::testdata`: lossless synthetic HEIF files (PCM-coded
32 × 32 HEVC pictures, grids, every property above) for the tests of the crates above.
+489
View File
@@ -0,0 +1,489 @@
//! HEIF / HEIC / AVIF metadata from the box structure alone (feature `metadata`): the `Exif` and
//! XMP items of the ISO base media file container (box structure from ISO/IEC 14496-12, item model
//! from ISO/IEC 23008-12). It needs no HEVC decoder and no heic-rs, so a build that only catalogues
//! photos can read their capture time, camera and lens.
//!
//! The `meta` box holds an item information box (`iinf`, one `infe` per item) naming each item's
//! type, and an item location box (`iloc`) saying where its bytes are: in the file, or in the
//! `idat` box next to it. The `Exif` item is a four-byte offset followed by a TIFF stream; the XMP
//! packet is a `mime` item whose content type is `application/rdf+xml`. The first such item counts
//! (the decoder's [`crate::read_metadata`] takes the primary image's instead). Moved from LightCraft's
//! `lightcraft-meta`.
use crate::{Error, SEQUENCE};
/// The Exif and XMP blocks found.
#[derive(Default)]
struct Embedded {
exif: Option<Vec<u8>>,
xmp: Option<String>,
}
/// The first `Exif` item's TIFF stream (without HEIF's offset header) and the first XMP packet of a
/// HEIF file, as [`crate::read_metadata`] returns them. An error when the file is not an ISO base
/// media still image ([`is_heif`]), or has no `meta` box (an image sequence); an item that can't be
/// located is left out rather than reported.
pub fn read(bytes: &[u8]) -> Result<(Option<Vec<u8>>, Option<String>), Error> {
if !is_heif(bytes) {
return Err(Error::Malformed("not a HEIF file".into()));
}
let mut budget = MAX_BOXES;
if !boxes(bytes, 0, bytes.len(), &mut budget).iter().any(|b| &b.kind == b"meta") {
return Err(Error::Unsupported(SEQUENCE));
}
let e = metadata(bytes);
Ok((e.exif, e.xmp))
}
/// At most this many boxes are walked in one file.
pub const MAX_BOXES: usize = 4096;
/// Brands that declare a still image in the ISO base media container.
const IMAGE_BRANDS: [&[u8]; 12] = [b"heic", b"heix", b"hevc", b"hevx", b"heim", b"heis", b"hevm", b"hevs", b"mif1", b"msf1", b"avif", b"avis"];
/// Longest `ftyp` box this reads brands from.
pub const MAX_FTYP: usize = 4096;
/// Most items one file's `iinf` / `iloc` describes (an iPhone HEIC has a handful).
pub const MAX_ITEMS: usize = 1024;
/// Most extents one item's bytes are split into.
pub const MAX_EXTENTS: u16 = 1024;
/// Largest metadata item this returns; a real photo's Exif and XMP are a few hundred kilobytes.
pub const MAX_ITEM: usize = 32 << 20;
/// Does this file declare an ISO base media still image (HEIF / HEIC / AVIF)?
pub fn is_heif(bytes: &[u8]) -> bool {
if bytes.get(4..8) != Some(b"ftyp".as_slice()) {
return false;
}
let Some(size) = be32(bytes, 0).map(|v| v as usize) else {
return false;
};
let end = size.min(bytes.len()).min(MAX_FTYP);
let brand = |at: usize| bytes.get(at..at.saturating_add(4)).is_some_and(|b| IMAGE_BRANDS.contains(&b));
// the major brand, then (after the minor version) the compatible brands
brand(8) || (16..end).step_by(4).any(brand)
}
/// The Exif and XMP blocks of a HEIF file. Anything malformed is left out rather than reported:
/// a file whose items can't be located simply has no metadata, as before this reader existed.
fn metadata(bytes: &[u8]) -> Embedded {
let mut out = Embedded::default();
let mut budget = MAX_BOXES;
let Some(meta) = boxes(bytes, 0, bytes.len(), &mut budget).into_iter().find(|b| &b.kind == b"meta") else {
return out;
};
// `meta` is a FullBox: a version and flags before its children
let (mut items, mut locs, mut idat) = (Vec::new(), Vec::new(), None);
for b in boxes(bytes, meta.body.saturating_add(4), meta.end, &mut budget) {
match &b.kind {
b"iinf" => items = item_infos(bytes, &b, &mut budget),
b"iloc" => locs = item_locations(bytes.get(b.body..b.end).unwrap_or_default()),
b"idat" => idat = bytes.get(b.body..b.end),
_ => {}
}
}
for (id, kind, content_type) in items {
let want_exif = &kind == b"Exif" && out.exif.is_none();
let want_xmp = &kind == b"mime" && content_type == "application/rdf+xml" && out.xmp.is_none();
if !want_exif && !want_xmp {
continue;
}
let Some(loc) = locs.iter().find(|l| l.id == id) else { continue };
let Some(block) = item_bytes(bytes, idat, loc) else { continue };
if want_exif {
// ISO/IEC 23008-12 A.2.1: a four-byte offset from the end of itself to the TIFF header
let skip = be32(&block, 0).and_then(|v| usize::try_from(v).ok()).and_then(|v| v.checked_add(4));
out.exif = skip.and_then(|at| block.get(at..)).filter(|b| !b.is_empty()).map(<[u8]>::to_vec);
} else {
out.xmp = Some(String::from_utf8_lossy(&block).trim_end_matches('\0').to_string());
}
}
out
}
/// `iinf` → one `(item_ID, item_type, content_type)` per `infe` entry. Entries of version 0 and 1
/// name no item type and are skipped; HEIF files use version 2 or 3.
fn item_infos(bytes: &[u8], iinf: &BoxRef, budget: &mut usize) -> Vec<(u32, [u8; 4], String)> {
// FullBox version and flags, then a 16- or 32-bit entry count before the `infe` boxes
let version = *bytes.get(iinf.body).unwrap_or(&0);
let entries_at = iinf.body.saturating_add(if version == 0 { 6 } else { 8 });
let mut out = Vec::new();
for b in boxes(bytes, entries_at, iinf.end, budget) {
if &b.kind != b"infe" || out.len() >= MAX_ITEMS {
continue;
}
let Some(e) = bytes.get(b.body..b.end) else { continue };
let (id, kind_at) = match *e.first().unwrap_or(&0) {
2 => (be16(e, 4).map(u32::from), 8),
3 => (be32(e, 4), 10),
_ => continue,
};
let (Some(id), Some(kind)) = (id, e.get(kind_at..kind_at + 4)) else { continue };
let mut kind4 = [0u8; 4];
kind4.copy_from_slice(kind);
// the item name, then (for a `mime` item) the content type, both NUL-terminated
let mut strings = e.get(kind_at + 4..).unwrap_or_default().split(|&c| c == 0).skip(1);
let content_type = String::from_utf8_lossy(strings.next().unwrap_or_default()).into_owned();
out.push((id, kind4, content_type));
}
out
}
/// Where one item's bytes are.
struct Loc {
id: u32,
/// 0 = offsets into the file, 1 = into the `idat` box, 2 = another item (not read).
construction: u8,
base: u64,
/// `(offset, length)` per extent, in order.
extents: Vec<(u64, u64)>,
}
/// `iloc` (payload after the box header) → one [`Loc`] per entry, stopping at the first
/// unreadable one.
fn item_locations(p: &[u8]) -> Vec<Loc> {
let version = *p.first().unwrap_or(&0);
let (Some(sizes), Some(base_index)) = (p.get(4), p.get(5)) else { return Vec::new() };
let (offset_size, length_size) = ((sizes >> 4) as usize, (sizes & 0xf) as usize);
let (base_size, index_size) = ((base_index >> 4) as usize, if version > 0 { (base_index & 0xf) as usize } else { 0 });
let (count, mut at) = match version {
0 | 1 => (be16(p, 6).map(usize::from), 8),
2 => (be32(p, 6).and_then(|v| usize::try_from(v).ok()), 10),
_ => return Vec::new(),
};
let mut out = Vec::new();
for _ in 0..count.unwrap_or(0).min(MAX_ITEMS) {
let (id, after_id) = match version {
2 => (be32(p, at), at + 4),
_ => (be16(p, at).map(u32::from), at + 2),
};
// version 1 and 2 put the construction method in the low nibble of a reserved 16-bit field
let construction = if version == 0 { Some(0) } else { be16(p, after_id).map(|v| (v & 0xf) as u8) };
let after_method = after_id + if version == 0 { 0 } else { 2 };
// a data reference index of 0 means this file; an external reference has no bytes here
let external = be16(p, after_method).is_some_and(|v| v != 0);
let base = uint(p, after_method + 2, base_size);
let extent_at = after_method + 2 + base_size;
let (Some(id), Some(construction), Some(base), Some(extent_count)) = (id, construction, base, be16(p, extent_at)) else {
break;
};
let stride = index_size + offset_size + length_size;
let mut extents = Vec::new();
let mut e = extent_at + 2;
for _ in 0..extent_count.min(MAX_EXTENTS) {
let (Some(offset), Some(length)) = (uint(p, e + index_size, offset_size), uint(p, e + index_size + offset_size, length_size)) else {
return out;
};
extents.push((offset, length));
e += stride;
}
at = e;
if !external {
out.push(Loc { id, construction, base, extents });
}
}
out
}
/// A big-endian unsigned integer of `size` bytes; `size` 0 is the value 0, and the item boxes
/// allow no other width than 4 or 8.
fn uint(b: &[u8], at: usize, size: usize) -> Option<u64> {
match size {
0 => Some(0),
4 => be32(b, at).map(u64::from),
8 => be64(b, at),
_ => None,
}
}
/// An item's bytes, its extents concatenated. `None` when they don't fit the file (or the `idat`
/// box), when the item is built by referencing another one, or when it is implausibly large.
fn item_bytes(bytes: &[u8], idat: Option<&[u8]>, loc: &Loc) -> Option<Vec<u8>> {
let src = match loc.construction {
0 => bytes,
1 => idat?,
_ => return None,
};
let mut out = Vec::new();
for &(offset, length) in &loc.extents {
let at = loc.base.checked_add(offset)?;
let (start, end) = (usize::try_from(at).ok()?, usize::try_from(at.checked_add(length)?).ok()?);
let extent = src.get(start..end)?;
if out.len().checked_add(extent.len())? > MAX_ITEM {
return None;
}
out.extend_from_slice(extent);
}
(!out.is_empty()).then_some(out)
}
/// A box: its type and where its payload ends.
struct BoxRef {
kind: [u8; 4],
/// Start of the payload (after the header and, for `uuid`, the 16-byte uuid).
body: usize,
end: usize,
}
/// The boxes directly inside `[from, to)`; stops at the first malformed header.
fn boxes(bytes: &[u8], from: usize, to: usize, budget: &mut usize) -> Vec<BoxRef> {
let mut out = Vec::new();
let mut at = from;
let to = to.min(bytes.len());
while at.saturating_add(8) <= to && *budget > 0 {
*budget -= 1;
let (Some(size), Some(kind)) = (be32(bytes, at), bytes.get(at + 4..at + 8)) else { break };
let mut kind4 = [0u8; 4];
kind4.copy_from_slice(kind);
let (len, mut hdr) = match size {
0 => (to - at, 8),
1 => match be64(bytes, at + 8).and_then(|v| usize::try_from(v).ok()) {
Some(l) => (l, 16),
None => break,
},
n => (n as usize, 8),
};
let Some(end) = at.checked_add(len).filter(|&e| e <= to && len >= hdr) else { break };
if &kind4 == b"uuid" {
// the 16-byte extended type is part of the header
if at + hdr + 16 > end {
break;
}
hdr += 16;
}
out.push(BoxRef { kind: kind4, body: at + hdr, end });
at = end;
}
out
}
/// A big-endian `u16` at `at`, or `None` when the buffer is shorter than that.
fn be16(b: &[u8], at: usize) -> Option<u16> {
b.get(at..at.checked_add(2)?).map(|s| u16::from_be_bytes([s[0], s[1]]))
}
/// A big-endian `u32` at `at`, or `None` when the buffer is shorter than that.
fn be32(b: &[u8], at: usize) -> Option<u32> {
b.get(at..at.checked_add(4)?).map(|s| u32::from_be_bytes([s[0], s[1], s[2], s[3]]))
}
/// A big-endian `u64` at `at`, or `None` when the buffer is shorter than that.
fn be64(b: &[u8], at: usize) -> Option<u64> {
let s = b.get(at..at.checked_add(8)?)?;
let mut a = [0u8; 8];
a.copy_from_slice(s);
Some(u64::from_be_bytes(a))
}
#[cfg(test)]
mod tests {
use super::*;
/// One box: its own size, its four-letter kind, then its payload.
fn bx(kind: &[u8; 4], payload: &[u8]) -> Vec<u8> {
let mut v = ((payload.len() + 8) as u32).to_be_bytes().to_vec();
v.extend_from_slice(kind);
v.extend_from_slice(payload);
v
}
/// A FullBox: a box whose payload begins with a version byte and three flag bytes.
fn full_box(kind: &[u8; 4], version: u8, payload: &[u8]) -> Vec<u8> {
bx(kind, &[&[version, 0, 0, 0][..], payload].concat())
}
struct Item<'a> {
kind: &'a [u8; 4],
content_type: &'a str,
data: Vec<u8>,
}
/// A HEIC-shaped file holding `items`: `ftyp`, a `meta` box with one `infe` per item, and the
/// item bytes in `idat` (`in_idat`, construction method 1) or in `mdat` (method 0).
fn heic(items: &[Item<'_>], iloc_version: u8, in_idat: bool) -> Vec<u8> {
let mut iinf = (items.len() as u16).to_be_bytes().to_vec();
for (i, it) in items.iter().enumerate() {
let mut e = vec![2u8, 0, 0, 0]; // version 2, flags
e.extend_from_slice(&(i as u16 + 1).to_be_bytes());
e.extend_from_slice(&[0, 0]); // protection index
e.extend_from_slice(it.kind);
e.extend_from_slice(b"an item\0");
if it.kind == b"mime" {
e.extend_from_slice(it.content_type.as_bytes());
e.push(0);
}
iinf.extend_from_slice(&bx(b"infe", &e));
}
let iinf = full_box(b"iinf", 0, &iinf);
let payload: Vec<u8> = items.iter().flat_map(|i| i.data.iter().copied()).collect();
let idat = if in_idat { bx(b"idat", &payload) } else { Vec::new() };
let iloc = |base: u32| {
// four-byte offsets and lengths, no base offset and no extent index
let mut l = vec![0x44, 0x00];
match iloc_version {
2 => l.extend_from_slice(&(items.len() as u32).to_be_bytes()),
_ => l.extend_from_slice(&(items.len() as u16).to_be_bytes()),
}
let mut at = base;
for (i, it) in items.iter().enumerate() {
match iloc_version {
2 => l.extend_from_slice(&(i as u32 + 1).to_be_bytes()),
_ => l.extend_from_slice(&(i as u16 + 1).to_be_bytes()),
}
if iloc_version > 0 {
l.extend_from_slice(&[0, u8::from(in_idat)]); // construction method
}
l.extend_from_slice(&[0, 0]); // data reference index: this file
l.extend_from_slice(&[0, 1]); // one extent
l.extend_from_slice(&at.to_be_bytes());
l.extend_from_slice(&(it.data.len() as u32).to_be_bytes());
at += it.data.len() as u32;
}
full_box(b"iloc", iloc_version, &l)
};
let ftyp = bx(b"ftyp", b"heic\0\0\0\0mif1heic");
let meta = |base| full_box(b"meta", 0, &[iloc(base), iinf.clone(), idat.clone()].concat());
if in_idat {
return [ftyp, meta(0)].concat();
}
let base = (ftyp.len() + meta(0).len() + 8) as u32;
[ftyp, meta(base), bx(b"mdat", &payload)].concat()
}
const TIFF: &[u8] = b"MM\0\x2a\0\0\0\x08\0\0\0\0\0\0";
/// An `Exif` item's payload: the offset from the end of this field to the TIFF header, then
/// `skip` bytes of padding, then the TIFF stream.
fn exif_item(tiff: &[u8], skip: usize) -> Vec<u8> {
[&(skip as u32).to_be_bytes()[..], &vec![0u8; skip], tiff].concat()
}
fn exif_only(tiff: &[u8]) -> Vec<Item<'static>> {
vec![Item { kind: b"Exif", content_type: "", data: exif_item(tiff, 0) }]
}
/// Every item location box the format allows: counts and ids are 16-bit before version 2 and
/// 32-bit in it, and the bytes live in `mdat` or in `idat` next to the location box itself.
#[test]
fn items_are_located_in_every_iloc_version() {
for (version, in_idat) in [(0, false), (1, false), (1, true), (2, false), (2, true)] {
let file = heic(&exif_only(TIFF), version, in_idat);
assert_eq!(read(&file).unwrap(), (Some(TIFF.to_vec()), None), "iloc v{version}, idat {in_idat}");
}
}
/// The XMP packet is the `mime` item that declares `application/rdf+xml`, not any other one.
#[test]
fn the_xmp_packet_is_the_rdf_mime_item() {
let items = vec![
Item { kind: b"mime", content_type: "text/plain", data: b"not the packet".to_vec() },
Item { kind: b"mime", content_type: "application/rdf+xml", data: b"<x:xmpmeta/>\0".to_vec() },
Item { kind: b"Exif", content_type: "", data: exif_item(TIFF, 0) },
];
assert_eq!(read(&heic(&items, 1, false)).unwrap(), (Some(TIFF.to_vec()), Some("<x:xmpmeta/>".into())));
}
/// The `Exif` item's leading offset may point past padding, not only straight at the TIFF header.
#[test]
fn the_exif_items_tiff_header_offset_is_honoured() {
let items = vec![Item { kind: b"Exif", content_type: "", data: exif_item(TIFF, 6) }];
assert_eq!(read(&heic(&items, 1, false)).unwrap().0.as_deref(), Some(TIFF));
}
/// Which `ftyp` brands count as a still image in this container, major or compatible.
#[test]
fn brands() {
for brand in ["heic", "heix", "mif1", "avif", "hevc", "msf1"] {
let file = bx(b"ftyp", format!("{brand}\0\0\0\0mif1").as_bytes());
assert!(is_heif(&file), "{brand}");
}
assert!(is_heif(&bx(b"ftyp", b"mp41\0\0\0\0isomheic")));
assert!(!is_heif(&bx(b"ftyp", b"crx \0\0\0\0crx isom")));
assert!(!is_heif(&bx(b"ftyp", b"jxl \0\0\0\0jxl ")));
assert!(!is_heif(b"\xff\xd8\xff\xe1 a jpeg"));
assert!(!is_heif(b""));
// a compatible brand past MAX_FTYP bytes is not read
let mut long = b"mp41\0\0\0\0".to_vec();
long.resize(MAX_FTYP, b'x');
long.extend_from_slice(b"heic");
assert!(!is_heif(&bx(b"ftyp", &long)));
}
/// Not a HEIF file, an image sequence (no `meta`), garbage: errors as the decoder reports them.
#[test]
fn what_is_not_a_still_image_is_an_error() {
assert!(matches!(read(b"\0\0\0\x18ftypmsf1\0\0\0\0msf1hevc"), Err(Error::Unsupported(m)) if m.contains("sequence")));
for bytes in [&b""[..], b"\0\0\0\x18ftypheic\0\0\0\0mif1heic", &[0xFF; 64]] {
assert!(read(bytes).is_err());
}
}
/// A truncated or scrambled file never panics, and lies about sizes find nothing.
#[test]
fn broken_files_never_panic() {
let good = heic(
&[
Item { kind: b"Exif", content_type: "", data: exif_item(TIFF, 0) },
Item { kind: b"mime", content_type: "application/rdf+xml", data: b"<x:xmpmeta/>".to_vec() },
],
1,
false,
);
for cut in 0..good.len() {
let _ = read(&good[..cut]);
}
for at in 0..good.len() {
let mut bad = good.clone();
bad[at] ^= 0xff;
let _ = read(&bad);
}
let mut lying = good.clone();
if let Some(at) = lying.windows(4).position(|w| w == b"iloc") {
lying.splice(at + 8..at + 12, u32::MAX.to_be_bytes());
}
assert_eq!(read(&lying).unwrap().0, None);
}
/// Past MAX_ITEMS `infe` entries the rest are not read; an item larger than MAX_ITEM is left out.
#[test]
fn item_limits() {
let mut items: Vec<Item<'_>> = (0..MAX_ITEMS).map(|_| Item { kind: b"mime", content_type: "text/plain", data: vec![b'x'] }).collect();
items.push(Item { kind: b"Exif", content_type: "", data: exif_item(TIFF, 0) });
assert_eq!(read(&heic(&items, 2, false)).unwrap().0, None, "the Exif item is entry {}", MAX_ITEMS + 1);
items.remove(0);
assert_eq!(read(&heic(&items, 2, false)).unwrap().0.as_deref(), Some(TIFF));
let big = vec![Item { kind: b"Exif", content_type: "", data: exif_item(&vec![0; MAX_ITEM], 0) }];
assert_eq!(read(&heic(&big, 1, true)).unwrap().0, None);
}
/// The box walk stops after MAX_BOXES boxes: a `meta` box after that many is not found.
#[test]
fn box_limit() {
let mut file = bx(b"ftyp", b"heic\0\0\0\0mif1heic");
for _ in 0..MAX_BOXES {
file.extend(bx(b"free", &[]));
}
file.extend(heic(&exif_only(TIFF), 1, true).split_off(bx(b"ftyp", b"heic\0\0\0\0mif1heic").len()));
assert!(matches!(read(&file), Err(Error::Unsupported(_))));
}
/// The container writer of this crate's tests: the reader agrees with the layout an encoder writes.
#[test]
fn a_synthetic_heif_file_carries_its_exif() {
let mut spec = crate::testdata::Spec::new(16, 16, &|x, _| [x as u16 * 4, 0, 0]);
spec.exif = Some(TIFF.to_vec());
assert_eq!(read(&crate::testdata::build(&spec)).unwrap(), (Some(TIFF.to_vec()), None));
assert_eq!(read(&crate::testdata::build(&crate::testdata::Spec::new(16, 16, &|_, _| [0; 3]))).unwrap(), (None, None));
}
/// With the decoder built too, both readers give the same answer on files with one Exif item.
#[cfg(feature = "decode")]
#[test]
fn agrees_with_the_decoders_read_metadata() {
let mut spec = crate::testdata::Spec::new(16, 16, &|x, _| [x as u16 * 4, 0, 0]);
spec.exif = Some(TIFF.to_vec());
for file in [crate::testdata::build(&spec), crate::testdata::build(&crate::testdata::Spec::new(8, 8, &|_, _| [0; 3]))] {
assert_eq!(read(&file), crate::read_metadata(&file));
}
let sequence = b"\0\0\0\x18ftypmsf1\0\0\0\0msf1hevc";
assert_eq!(read(sequence), crate::read_metadata(sequence));
}
}
+48
View File
@@ -20,20 +20,31 @@
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#[cfg(feature = "decode")]
mod census;
#[cfg(feature = "metadata")]
pub mod items;
#[cfg(feature = "decode")]
mod orient;
/// Lossless synthetic HEIF files for tests, here and in the crates above (feature `testdata`).
#[cfg(any(test, feature = "testdata"))]
#[doc(hidden)]
pub mod testdata;
#[cfg(feature = "decode")]
mod vui;
#[cfg(feature = "decode")]
mod ycc;
#[cfg(feature = "decode")]
use std::borrow::Cow;
#[cfg(feature = "decode")]
use heic_rs::context::Context;
#[cfg(feature = "decode")]
use heic_rs::hevc::Frame;
#[cfg(feature = "decode")]
use heic_rs::props::ItemProps;
#[cfg(feature = "decode")]
use heic_rs::props::colr::Range;
/// What a HEIF file declares, read from the container alone (no pixel is decoded).
@@ -132,12 +143,22 @@ impl std::fmt::Display for Error {
impl std::error::Error for Error {}
/// The primary image's EXIF and XMP packet, read from the box structure alone ([`items::read`]):
/// what a build without the decoder (`--no-default-features --features metadata`) has.
#[cfg(all(feature = "metadata", not(feature = "decode")))]
pub fn read_metadata(bytes: &[u8]) -> Result<(Option<Vec<u8>>, Option<String>), Error> {
items::read(bytes)
}
#[cfg(any(feature = "decode", feature = "metadata"))]
const SEQUENCE: &str = "this file holds an image sequence; only HEIF still images can be opened";
#[cfg(feature = "decode")]
/// heic-rs 0.1.1 reconstructs 4:2:0 and monochrome pictures exactly but fails on the 4:2:2 and
/// 4:4:4 ones some cameras write (Canon and Sony "HIF"); those failures are reported as this.
const CHROMA_422_444: &str = "4:2:2 and 4:4:4 HEIF images (as some Canon and Sony cameras write) can't be decoded yet";
#[cfg(feature = "decode")]
pub(crate) fn err(e: heic_rs::Error) -> Error {
match e {
heic_rs::Error::Unsupported(what) => Error::Unsupported(what),
@@ -148,6 +169,7 @@ pub(crate) fn err(e: heic_rs::Error) -> Error {
}
}
#[cfg(feature = "decode")]
/// Runs `f`, turning a heic-rs panic into an error. PhotoCraft's fuzzing found two
/// out-of-range slices in heic-rs 0.1.1 on malformed files: in its box parser (`boxes.rs:130`)
/// and during reconstruction (`hevc/decode/recon.rs:70`).
@@ -155,6 +177,7 @@ fn guarded<T>(f: impl FnOnce() -> Result<T, Error>) -> Result<T, Error> {
std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).unwrap_or_else(|_| Err(Error::Malformed("the HEIF decoder failed on this file".into())))
}
#[cfg(feature = "decode")]
/// Reads the container's declared size, depth and alpha without decoding pixels, so callers can
/// check their limits first. Also refuses a file cut short: every coded picture of the primary
/// image must lie within `bytes`.
@@ -162,6 +185,7 @@ pub fn probe(bytes: &[u8]) -> Result<Info, Error> {
guarded(|| probe_unguarded(bytes))
}
#[cfg(feature = "decode")]
fn probe_unguarded(bytes: &[u8]) -> Result<Info, Error> {
census::cap(bytes)?;
// No `meta` box: no still image, only a `moov` image sequence (a video track).
@@ -227,12 +251,15 @@ fn probe_unguarded(bytes: &[u8]) -> Result<Info, Error> {
})
}
#[cfg(feature = "decode")]
/// Most extent visits heic-rs may spend reconstructing the primary image's coded items; real
/// files need one per tile (plus a few for the rare derived item).
const MAX_ITEM_VISITS: usize = 4096;
#[cfg(feature = "decode")]
/// Deepest chain of items built from items we follow (heic-rs refuses beyond eight itself).
const MAX_ITEM_CHAIN: u32 = 8;
#[cfg(feature = "decode")]
/// Adds to `visits` the extents heic-rs would read to reconstruct `id`, following items built
/// from other items, and fails once that passes [`MAX_ITEM_VISITS`] or the chain is too deep.
fn item_visits(ctx: &Context<'_>, id: u32, depth: u32, visits: &mut usize) -> Result<(), Error> {
@@ -257,6 +284,7 @@ fn item_visits(ctx: &Context<'_>, id: u32, depth: u32, visits: &mut usize) -> Re
Ok(())
}
#[cfg(feature = "decode")]
/// The primary image's EXIF (a TIFF structure, as in [`Decoded::exif`]) and XMP packet, read
/// from the container alone: no picture is decoded, so cataloguing a photo costs about a
/// [`probe`]. `(None, None)` for a file that carries neither.
@@ -271,11 +299,13 @@ pub fn read_metadata(bytes: &[u8]) -> Result<(Option<Vec<u8>>, Option<String>),
})
}
#[cfg(feature = "decode")]
/// Decodes the primary image, with its metadata.
pub fn decode(bytes: &[u8], options: &Options) -> Result<Decoded, Error> {
guarded(|| decode_unguarded(bytes, options))
}
#[cfg(feature = "decode")]
/// Decodes the smallest thumbnail image the file carries whose long edge is at least
/// `min_edge`, if any (iPhones store one of about 320 × 240). Transforms are applied as for the
/// primary image; the metadata (and, when the thumbnail has no profile of its own, the ICC
@@ -343,6 +373,7 @@ pub fn decode_thumbnail(bytes: &[u8], min_edge: u32, options: &Options) -> Resul
})
}
#[cfg(feature = "decode")]
/// An item's ICC profile; a grid whose own properties carry none takes its first tile's (as on
/// some iPhones).
fn item_icc(ctx: &Context<'_>, id: u32) -> Option<Vec<u8>> {
@@ -354,6 +385,7 @@ fn item_icc(ctx: &Context<'_>, id: u32) -> Option<Vec<u8>> {
icc.map(<[u8]>::to_vec)
}
#[cfg(feature = "decode")]
fn decode_unguarded(bytes: &[u8], options: &Options) -> Result<Decoded, Error> {
// Refuses sequences with a clear message before anything else is read.
probe_unguarded(bytes)?;
@@ -365,6 +397,7 @@ fn decode_unguarded(bytes: &[u8], options: &Options) -> Result<Decoded, Error> {
Ok(decoded)
}
#[cfg(feature = "decode")]
/// Decodes one image item (a coded picture or a grid of them) to RGB(A), alpha included when
/// `with_alpha` and the item has an alpha auxiliary image.
fn decode_item(ctx: &Context<'_>, id: u32, options: &Options, with_alpha: bool) -> Result<Decoded, Error> {
@@ -449,26 +482,33 @@ fn decode_item(ctx: &Context<'_>, id: u32, options: &Options, with_alpha: bool)
})
}
#[cfg(feature = "decode")]
/// How many times `max_pixels` the coded pictures of one item may hold together: a grid's tiles
/// cover its canvas rounded up to whole tiles, so legitimate grids sit near 1.
const GRID_BUDGET: u64 = 2;
#[cfg(feature = "decode")]
/// The most transform properties (`clap`/`irot`/`imir`) one item may carry. Real files have at
/// most one of each, so anything approaching this is a reordered or hostile association list.
const MAX_TRANSFORMS: usize = 12;
#[cfg(feature = "decode")]
/// The most parameter sets one `hvcC` may carry: one per id H.265 allows (16 VPS, 16 SPS, 64 PPS).
/// heic-rs 0.1.4 replaces a repeated id by scanning every set kept so far, so more sets (repeats,
/// or ids past the standard's range) only make that collection quadratic.
const MAX_PARAMETER_SETS: usize = 16 + 16 + 64;
#[cfg(feature = "decode")]
/// Highest `pps_pic_parameter_set_id` H.265 allows (7.4.3.3.1).
const MAX_PPS_ID: u32 = 63;
#[cfg(feature = "decode")]
/// Most tile columns and rows a picture may have: H.265's level limits (Table A.8, levels 6 to
/// 6.2). heic-rs 0.1.4 builds its tile tables by walking the tile boundaries for every coding tree
/// block, so its work grows with blocks × tiles; these keep that a small multiple of the blocks.
const MAX_TILE_COLS: u32 = 20;
#[cfg(feature = "decode")]
const MAX_TILE_ROWS: u32 = 22;
#[cfg(feature = "decode")]
/// Refuses parameter sets that would make heic-rs's set collection or tile tables cost far more
/// than the picture: too many sets, a PPS id out of range, or more tiles than any level allows.
fn check_parameter_sets(sets: &[&[u8]]) -> Result<(), Error> {
@@ -490,11 +530,13 @@ fn check_parameter_sets(sets: &[&[u8]]) -> Result<(), Error> {
Ok(())
}
#[cfg(feature = "decode")]
/// [`check_parameter_sets`] of a coded item's `hvcC`, if it has one.
fn check_item_parameter_sets(p: &ItemProps<'_>) -> Result<(), Error> {
p.hvcc.as_ref().map_or(Ok(()), |h| check_parameter_sets(&h.parameter_sets()))
}
#[cfg(feature = "decode")]
/// The decoded pictures of an item: one, or a grid's tiles in `dimg` order.
struct Coded<'a> {
frames: Vec<Frame>,
@@ -506,6 +548,7 @@ struct Coded<'a> {
chroma_format: u8,
}
#[cfg(feature = "decode")]
impl<'a> Coded<'a> {
fn decode(ctx: &Context<'a>, id: u32, p: &ItemProps<'a>, limit: u64) -> Result<Coded<'a>, Error> {
let (grid, ids) = match ctx.grid(id).map_err(err)? {
@@ -579,6 +622,7 @@ impl<'a> Coded<'a> {
}
}
#[cfg(feature = "decode")]
/// The colour description in force: `nclx` on the item (or, for a grid without one, on its
/// first tile), else the VUI of its (first) coded picture, else the H.265 defaults.
fn colour(p: &ItemProps<'_>, coded: &ItemProps<'_>) -> Colour {
@@ -589,6 +633,7 @@ fn colour(p: &ItemProps<'_>, coded: &ItemProps<'_>) -> Colour {
Colour { primaries: s.primaries.into(), transfer: s.transfer.into(), matrix: s.matrix.into(), full_range: s.full_range }
}
#[cfg(feature = "decode")]
/// The primary image's EXIF (TIFF structure) and XMP packet. Metadata is a courtesy: a broken
/// metadata item never fails a decode whose pixels came out fine.
fn metadata(ctx: &Context<'_>) -> (Option<Vec<u8>>, Option<String>) {
@@ -606,11 +651,14 @@ fn metadata(ctx: &Context<'_>) -> (Option<Vec<u8>>, Option<String>) {
}
#[cfg(test)]
#[cfg(feature = "decode")]
mod tests_icc;
#[cfg(test)]
#[cfg(feature = "decode")]
mod tests_synthetic;
#[cfg(test)]
#[cfg(feature = "decode")]
mod tests {
use super::*;