diff --git a/Cargo.lock b/Cargo.lock index 5d7f5111a..e836d6ba2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3379,6 +3379,7 @@ name = "lightcraft-meta" version = "0.9.1" dependencies = [ "lightcraft-geom", + "lightcraft-heif", "lightcraft-tiff", "proptest", "quick-xml 0.38.4", diff --git a/crates/engine/src/tests_import.rs b/crates/engine/src/tests_import.rs index 13f02ce77..b9455e617 100644 --- a/crates/engine/src/tests_import.rs +++ b/crates/engine/src/tests_import.rs @@ -1606,6 +1606,49 @@ fn heic_imports_as_a_normal_photo_or_says_why_not() { let _ = std::fs::remove_dir_all(&src); } +/// Issue #2284: a photo taken at 13:00 in UTC-5 must be catalogued as 13:00. HEICs carried no Exif +/// (issue #1233), so every one of them fell back to the file's modification time, which the import +/// writes as UTC: an iPhone photo landed five hours late and after 19:00 on the wrong day. +#[test] +fn a_heic_is_dated_by_its_exif_and_not_by_the_file() { + use lightcraft_heif::testdata::{Spec, build}; + use lightcraft_tiff::{ByteOrder, IfdBuilder, TiffWriter, Value, tags as t}; + let src = temp_dir("heic-capture-time"); + let photo = |x: u32, y: u32| [((x * 7 + y * 13) % 200 + 30) as u16, 110, 150]; + let mut exif = IfdBuilder::new(); + exif.set(t::DATE_TIME_ORIGINAL, Value::Ascii("2026:10:18 13:00:00".into())); + exif.set(t::OFFSET_TIME_ORIGINAL, Value::Ascii("-05:00".into())); + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::MAKE, Value::Ascii("Apple".into())); + ifd0.set(t::MODEL, Value::Ascii("iPhone 15 Pro".into())); + // HEIF turns the picture in the container; its Exif Orientation only mirrors that, so reading + // the Exif must not turn it a second time. + ifd0.set(t::ORIENTATION, Value::Short(vec![6])); + ifd0.set_child(t::EXIF_IFD, exif); + let exif = TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap(); + let bytes = build(&Spec { exif: Some(exif), ..Spec::new(96, 64, &photo) }); + let path = src.join("IMG_0002.HEIC"); + std::fs::write(&path, &bytes).unwrap(); + // The probe reads the metadata in every build; only decoding the picture needs the feature. + let info = crate::files::probe_bytes("IMG_0002.HEIC", &bytes); + if lightcraft_codecs::Format::Heif.can_decode() { + let info = info.unwrap(); + assert_eq!(info.captured.as_deref(), Some("2026-10-18T13:00:00-05:00")); + assert_eq!(info.meta.camera, "Apple iPhone 15 Pro"); + let mut s = Session::new().with_fs(); + let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap(); + assert_eq!((ids(&r, "imported"), ids(&r, "failed")), (1, 0), "{r}"); + let p = s.catalog.photos().next().unwrap(); + assert_eq!(p.captured.as_deref(), Some("2026-10-18T13:00:00-05:00"), "the time the camera wrote, not the file's"); + assert_eq!((p.width, p.height), (96, 64), "upright: the container's own transforms, not the Exif tag as well"); + assert_eq!(lightcraft_catalog::dates::display_time(p.date()), "October 18, 2026 at 1:00:00 PM"); + } else { + assert!(info.is_err(), "without the feature a HEIC cannot be opened at all"); + assert_eq!(lightcraft_meta::extract(&bytes).capture_time.map(|d| d.to_iso()).as_deref(), Some("2026-10-18T13:00:00-05:00")); + } + let _ = std::fs::remove_dir_all(&src); +} + /// Issue #705: Nikon (and most cameras) write `.JPG` and `.NEF`, and a user may rename to `.Jpg`. /// The folder scan, `library.browse` and the sidecar lookup match extensions in any letter /// case. (The file dialog's filters, the one place that was case-sensitive on Linux before diff --git a/crates/meta/Cargo.toml b/crates/meta/Cargo.toml index 0ef8903d2..26dffbadb 100644 --- a/crates/meta/Cargo.toml +++ b/crates/meta/Cargo.toml @@ -15,5 +15,7 @@ serde = { workspace = true } quick-xml = { workspace = true } [dev-dependencies] +# Lossless synthetic HEIC files: the HEIF item reader is proved against a real container writer. +lightcraft-heif = { workspace = true, features = ["testdata"] } proptest = { workspace = true } serde_json = { workspace = true } diff --git a/crates/meta/src/container.rs b/crates/meta/src/container.rs index 8dda6b55d..b15251cca 100644 --- a/crates/meta/src/container.rs +++ b/crates/meta/src/container.rs @@ -9,6 +9,8 @@ //! IPTC (33723) from IFD0. //! - Canon CR3 (ISO base media file, see [`crate::cr3`]): `CMT1`/`CMT2`/`CMT4` merged into one Exif block; the //! XMP `uuid` box. +//! - HEIF/HEIC/AVIF (ISO base media file, see [`crate::heif`]): the `Exif` item's TIFF stream and the XMP +//! `mime` item. use lightcraft_tiff::{Tiff, tags}; @@ -39,6 +41,9 @@ pub fn embedded(bytes: &[u8]) -> Embedded { if let Some(c) = crate::cr3::parse_cr3(bytes) { return Embedded { exif: crate::cr3::merged_exif(&c), xmp: c.xmp.map(|b| String::from_utf8_lossy(b).into_owned()), ..Default::default() }; } + if crate::heif::is_heif(bytes) { + return crate::heif::metadata(bytes); + } if bytes.starts_with(&[0xff, 0xd8]) { jpeg_segments(bytes) } else if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { diff --git a/crates/meta/src/cr3.rs b/crates/meta/src/cr3.rs index 55b3ae6a6..77d8dec4a 100644 --- a/crates/meta/src/cr3.rs +++ b/crates/meta/src/cr3.rs @@ -229,7 +229,7 @@ const CANON_UUID: [u8; 16] = [0x85, 0xc0, 0xb6, 0x87, 0x82, 0x0f, 0x11, 0xe0, 0x /// The `uuid` of the top-level box holding the XMP packet. pub const XMP_UUID: [u8; 16] = [0xbe, 0x7a, 0xcf, 0xcb, 0x97, 0xa9, 0x42, 0xe8, 0x9c, 0x71, 0x99, 0x94, 0x91, 0xe3, 0xaf, 0xac]; const MAX_DEPTH: usize = 12; -const MAX_BOXES: usize = 4096; +pub(crate) const MAX_BOXES: usize = 4096; /// A `VisualSampleEntry` (78 bytes after the box header) plus 4 bytes Canon adds before the child boxes. const CRAW_CHILDREN_AT: usize = 82; @@ -256,16 +256,16 @@ pub fn parse_cr3(bytes: &[u8]) -> Option> { Some(out) } -struct BoxRef { - kind: [u8; 4], +pub(crate) struct BoxRef { + pub(crate) kind: [u8; 4], /// Start of the payload (after the header and, for `uuid`, the 16-byte uuid). - body: usize, - end: usize, - uuid: Option<[u8; 16]>, + pub(crate) body: usize, + pub(crate) end: usize, + pub(crate) uuid: Option<[u8; 16]>, } /// The boxes directly inside `[from, to)`; stops at the first malformed header. -fn boxes(bytes: &[u8], from: usize, to: usize, budget: &mut usize) -> Vec { +pub(crate) fn boxes(bytes: &[u8], from: usize, to: usize, budget: &mut usize) -> Vec { let mut out = Vec::new(); let mut at = from; let to = to.min(bytes.len()); @@ -407,13 +407,13 @@ fn sample_entry(bytes: &[u8], e: &BoxRef, budget: &mut usize) -> Cr3TrackKind { Cr3TrackKind::Raw { width, height, cmp1, iad1 } } -fn be16(b: &[u8], at: usize) -> Option { +pub(crate) fn be16(b: &[u8], at: usize) -> Option { b.get(at..at.checked_add(2)?).map(|s| u16::from_be_bytes([s[0], s[1]])) } -fn be32(b: &[u8], at: usize) -> Option { +pub(crate) fn be32(b: &[u8], at: usize) -> Option { b.get(at..at.checked_add(4)?).map(|s| u32::from_be_bytes([s[0], s[1], s[2], s[3]])) } -fn be64(b: &[u8], at: usize) -> Option { +pub(crate) fn be64(b: &[u8], at: usize) -> Option { let s = b.get(at..at.checked_add(8)?)?; let mut a = [0u8; 8]; a.copy_from_slice(s); diff --git a/crates/meta/src/heif.rs b/crates/meta/src/heif.rs new file mode 100644 index 000000000..98f3f0a3e --- /dev/null +++ b/crates/meta/src/heif.rs @@ -0,0 +1,398 @@ +//! HEIF / HEIC / AVIF 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). +//! +//! HEIC is what iPhones and Macs write. Its pixels need an HEVC decoder (the optional +//! `lightcraft-heif` crate), its metadata does not, so this reader is in every build: a HEIC +//! imports with its capture time, camera and lens whether or not the build can show the picture. +//! +//! 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 ICC profile is an item +//! *property*, not an item, and is read by the decoder instead. + +use crate::container::Embedded; +use crate::cr3::{MAX_BOXES, be16, be32, be64, boxes}; + +/// 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. +const MAX_FTYP: usize = 4096; +/// Most items one file's `iinf` / `iloc` describes (an iPhone HEIC has a handful). +const MAX_ITEMS: usize = 1024; +/// Most extents one item's bytes are split into. +const MAX_EXTENTS: u16 = 1024; +/// Largest metadata item this returns; a real photo's Exif and XMP are a few hundred kilobytes. +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. +pub 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: &crate::cr3::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 { + 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 { + 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> { + 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) +} + +#[cfg(test)] +mod tests { + use super::*; + // the whole routing, not just this module: a HEIC must reach the HEIF branch of `embedded` + use crate::embedded; + use lightcraft_tiff::{ByteOrder, IfdBuilder, TiffWriter, Value, tags as t}; + + fn bx(kind: &[u8; 4], payload: &[u8]) -> Vec { + let mut v = ((payload.len() + 8) as u32).to_be_bytes().to_vec(); + v.extend_from_slice(kind); + v.extend_from_slice(payload); + v + } + + fn full_box(kind: &[u8; 4], version: u8, payload: &[u8]) -> Vec { + bx(kind, &[&[version, 0, 0, 0][..], payload].concat()) + } + + struct Item<'a> { + kind: &'a [u8; 4], + content_type: &'a str, + data: Vec, + } + + /// 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 { + 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 = 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 { + // the offsets are into `idat`, so they don't depend on where the box lands + return [ftyp, meta(0)].concat(); + } + let base = (ftyp.len() + meta(0).len() + 8) as u32; + [ftyp, meta(base), bx(b"mdat", &payload)].concat() + } + + /// What an iPhone writes: the civil time it was taken at, plus the UTC offset it was taken in. + fn iphone_exif(date: &str, offset: &str) -> Vec { + let mut exif = IfdBuilder::new(); + exif.set(t::DATE_TIME_ORIGINAL, Value::Ascii(date.into())); + exif.set(t::OFFSET_TIME_ORIGINAL, Value::Ascii(offset.into())); + exif.set(t::ISO_SPEED, Value::Short(vec![64])); + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::MAKE, Value::Ascii("Apple".into())); + ifd0.set(t::MODEL, Value::Ascii("iPhone 15 Pro".into())); + ifd0.set_child(t::EXIF_IFD, exif); + TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap_or_default() + } + + /// 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 { + [&(skip as u32).to_be_bytes()[..], &vec![0u8; skip], tiff].concat() + } + + fn exif_only(tiff: &[u8]) -> Vec> { + vec![Item { kind: b"Exif", content_type: "", data: exif_item(tiff, 0) }] + } + + /// Issue #2284: a photo taken at 13:00 in UTC-5 must stay 13:00, not become 18:00. Before the + /// HEIF items were read a HEIC carried no capture time at all, and the import fell back to the + /// file's modification time, which LightCraft writes as UTC. + #[test] + fn an_iphone_heic_keeps_the_time_the_camera_wrote() { + let file = heic(&exif_only(&iphone_exif("2026:10:18 13:00:00", "-05:00")), 1, false); + let m = crate::extract(&file); + let captured = m.capture_time.expect("a capture time"); + assert_eq!(captured.hour, 13); + assert_eq!(captured.offset_minutes, Some(-5 * 60)); + assert_eq!(captured.to_iso(), "2026-10-18T13:00:00-05:00"); + assert_eq!(m.model.as_deref(), Some("iPhone 15 Pro")); + assert_eq!(m.iso, Some(64)); + } + + #[test] + fn the_xmp_packet_is_the_rdf_mime_item() { + let xmp = crate::write_xmp(&crate::Metadata { title: Some("Cusco".into()), ..Default::default() }, None); + 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: xmp.clone().into_bytes() }, + Item { kind: b"Exif", content_type: "", data: exif_item(&iphone_exif("2026:10:18 13:00:00", "-05:00"), 0) }, + ]; + let e = embedded(&heic(&items, 1, false)); + assert_eq!(e.xmp.as_deref(), Some(xmp.as_str())); + assert!(e.exif.is_some()); + assert_eq!(crate::extract(&heic(&items, 1, false)).title.as_deref(), Some("Cusco")); + } + + /// 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() { + let tiff = iphone_exif("2026:10:18 13:00:00", "-05:00"); + for (version, in_idat) in [(0, false), (1, false), (1, true), (2, false), (2, true)] { + let file = heic(&exif_only(&tiff), version, in_idat); + let found = embedded(&file).exif; + assert_eq!(found.as_deref(), Some(tiff.as_slice()), "iloc v{version}, idat {in_idat}"); + } + } + + #[test] + fn the_exif_items_tiff_header_offset_is_honoured() { + let tiff = iphone_exif("2026:10:18 13:00:00", "-05:00"); + let items = vec![Item { kind: b"Exif", content_type: "", data: exif_item(&tiff, 6) }]; + assert_eq!(embedded(&heic(&items, 1, false)).exif.as_deref(), Some(tiff.as_slice())); + } + + #[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}"); + } + // a compatible brand counts too, a CR3, a JPEG XL and a non-ISO file do not + 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 truncated or scrambled file must come back empty, never panic (the never-crash rule). + #[test] + fn broken_files_yield_nothing_without_panicking() { + let good = heic( + &[ + Item { kind: b"Exif", content_type: "", data: exif_item(&iphone_exif("2026:10:18 13:00:00", "-05:00"), 0) }, + Item { kind: b"mime", content_type: "application/rdf+xml", data: b"".to_vec() }, + ], + 1, + false, + ); + for cut in 0..good.len() { + let _ = embedded(&good[..cut]); + } + for at in 0..good.len() { + let mut bad = good.clone(); + bad[at] ^= 0xff; + let _ = embedded(&bad); + } + // an item whose extent is outside the file, and a `meta` box that claims more than it has + let mut lying = good.clone(); + let item_count = u32::MAX.to_be_bytes(); + if let Some(at) = lying.windows(4).position(|w| w == b"iloc") { + lying.splice(at + 8..at + 12, item_count); + } + assert!(embedded(&lying).exif.is_none()); + } + + /// The real container writer of `lightcraft-heif`, so the reader is proved against the layout + /// a HEIF encoder produces and not only against this module's own test builder. + #[test] + fn a_synthetic_heif_file_carries_its_exif() { + let tiff = iphone_exif("2026:10:18 13:00:00", "-05:00"); + let mut spec = lightcraft_heif::testdata::Spec::new(64, 64, &|x, _| [x as u16 * 4, 0, 0]); + spec.exif = Some(tiff.clone()); + let file = lightcraft_heif::testdata::build(&spec); + assert!(is_heif(&file)); + assert_eq!(embedded(&file).exif.as_deref(), Some(tiff.as_slice())); + assert_eq!(crate::extract(&file).capture_time.map(|d| d.to_iso()).as_deref(), Some("2026-10-18T13:00:00-05:00")); + } +} diff --git a/crates/meta/src/lib.rs b/crates/meta/src/lib.rs index a8c043153..04fe3b532 100644 --- a/crates/meta/src/lib.rs +++ b/crates/meta/src/lib.rs @@ -1,4 +1,4 @@ -//! Photo metadata for LightCraft: EXIF (TIFF/raw files, JPEG APP1, PNG `eXIf`, WebP `EXIF`), XMP packets +//! Photo metadata for LightCraft: EXIF (TIFF/raw files, JPEG APP1, PNG `eXIf`, WebP `EXIF`, HEIF items), XMP packets //! (read + write, standard namespaces plus our own `lc:` namespace) and basic IPTC-IIM. //! //! Entry points: @@ -7,8 +7,9 @@ //! - [`parse_xmp`] / [`write_xmp`] — XMP packets (interchange fields + the opaque `lc:settings` JSON). //! - [`parse_iptc`] — IPTC-IIM record 2 datasets. //! - [`parse_gpx`] — GPS track logs (GPX) for geotagging by capture time. -//! - [`extract`] — all of the above for a whole file (JPEG, PNG, WebP, TIFF/DNG/raw, Canon CR3). +//! - [`extract`] — all of the above for a whole file (JPEG, PNG, WebP, TIFF/DNG/raw, Canon CR3, HEIF/HEIC/AVIF). //! - [`cr3`] — the Canon CR3 container (metadata blocks, XMP, the JPEG and raw tracks). +//! - [`heif`] — the HEIF/HEIC/AVIF container's Exif and XMP items (no HEVC decoder needed). #![forbid(unsafe_code)] mod container; @@ -18,6 +19,7 @@ mod exif; pub mod focus; mod focus_read; mod gpx; +pub mod heif; mod iptc; pub mod tags; mod xmp; diff --git a/crates/meta/tests/robust.rs b/crates/meta/tests/robust.rs index cd60e00a9..e1e1efcbf 100644 --- a/crates/meta/tests/robust.rs +++ b/crates/meta/tests/robust.rs @@ -5,8 +5,8 @@ proptest! { #![proptest_config(ProptestConfig { cases: 1500, .. ProptestConfig::default() })] #[test] - fn random_containers_never_panic(mut data in proptest::collection::vec(any::(), 0..600), kind in 0usize..6) { - let heads: [&[u8]; 6] = [b"\xff\xd8\xff\xe1", b"\x89PNG\r\n\x1a\n", b"RIFF\0\0\0\0WEBP", b"II*\0", b"MM\0*", b"\0\0\0\x18ftypcrx "]; + fn random_containers_never_panic(mut data in proptest::collection::vec(any::(), 0..600), kind in 0usize..7) { + let heads: [&[u8]; 7] = [b"\xff\xd8\xff\xe1", b"\x89PNG\r\n\x1a\n", b"RIFF\0\0\0\0WEBP", b"II*\0", b"MM\0*", b"\0\0\0\x18ftypcrx ", b"\0\0\0\x18ftypheic"]; let h = heads[kind]; if data.len() >= h.len() { data[..h.len()].copy_from_slice(h);