mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-13 22:12:12 +00:00
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:
1 parent
428541a512
commit
5fc172eeb5
4 files changed
+552
-3
No files matched your search
@@ -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 = []
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
|
||||
Reference in new issue
Block a user