Files
lightcraft/crates/meta/src/container.rs
T
Maurice Rogers 13a3eff2e8 LR-IMP-FORMATS: CR3 full-size preview and metadata from the ISO-BMFF container
CR3 files imported as preview-only with the 1620x1080 PRVW JPEG and no metadata
(no camera, capture time, exposure, lens or GPS): nothing read the container.

- crates/meta/src/cr3.rs: bounded ISO base media box parser for CR3 (Canon uuid
  box with CMT1-4 and THMB, tracks with their sample entries, CMP1 coding header
  and sample location, XMP uuid box). Every size/offset checked; depth and box
  count bounded.
- Metadata: CMT1 (IFD0), CMT2 (Exif) and CMT4 (GPS) merged into one TIFF Exif
  block, so CR3 reads like any TIFF-based raw; XMP from its uuid box.
- Preview: the full-size JPEG track (6960x4640 on an EOS R6 Mark III) before
  PRVW/THMB; imports report the full image size.
- Tests: synthetic CR3 (boxes, tracks, merged Exif), truncation/corruption sweep,
  JPEG track preferred over PRVW with fallback.

Raw (CRX) data is still not decoded; CR3 stays preview-only.
2026-10-06 20:00:48 -06:00

332 lines
13 KiB
Rust

//! Locating metadata blocks inside image containers.
//!
//! - JPEG (ITU-T T.81 marker structure): APP1 `Exif\0\0`, APP1 `http://ns.adobe.com/xap/1.0/\0` (XMP),
//! APP1 `http://ns.adobe.com/xmp/extension/\0` (extended XMP, reassembled), APP2 `ICC_PROFILE\0`
//! (multi-chunk, reassembled in sequence order), APP13 `Photoshop 3.0\0` 8BIM resource 0x0404 (IPTC-IIM).
//! - PNG (PNG spec 3rd ed.): `eXIf`, `iTXt` with keyword `XML:com.adobe.xmp` (uncompressed).
//! - WebP (RIFF container spec): `EXIF`, `XMP `, `ICCP` chunks.
//! - TIFF-based files (TIFF, DNG and most raws): the whole file is the Exif block; XMP (700), ICC (34675) and
//! 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.
use lightcraft_tiff::{Tiff, tags};
/// Metadata blocks found in a file.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Embedded {
/// TIFF-structured Exif block (without the `Exif\0\0` prefix).
pub exif: Option<Vec<u8>>,
/// XMP packet (the main one; extended XMP is appended by [`Embedded::xmp_extended`]).
pub xmp: Option<String>,
/// Extended XMP (JPEG only), reassembled.
pub xmp_extended: Option<String>,
pub icc: Option<Vec<u8>>,
/// IPTC-IIM stream.
pub iptc: Option<Vec<u8>>,
}
/// Sniff the container and collect its metadata blocks. Unknown containers yield an empty result.
pub fn embedded(bytes: &[u8]) -> Embedded {
if bytes.starts_with(b"FUJIFILMCCD-RAW") {
// Fujifilm RAF: metadata lives in the embedded JPEG whose (offset, length) are big-endian u32s at byte 84
let be = |i: usize| bytes.get(i..i + 4).map(|s| u32::from_be_bytes([s[0], s[1], s[2], s[3]]) as usize);
return match (be(84), be(88)) {
(Some(o), Some(l)) if o > 0 => bytes.get(o..o.saturating_add(l).min(bytes.len())).map(jpeg_segments).unwrap_or_default(),
_ => Embedded::default(),
};
}
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 bytes.starts_with(&[0xff, 0xd8]) {
jpeg_segments(bytes)
} else if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
png_chunks(bytes)
} else if bytes.len() >= 12 && &bytes[..4] == b"RIFF" && &bytes[8..12] == b"WEBP" {
webp_chunks(bytes)
} else if let Ok(t) = Tiff::parse(bytes) {
let ifd0 = &t.ifds[0];
Embedded {
exif: Some(bytes.to_vec()),
xmp: ifd0.bytes(tags::XMP).map(|b| String::from_utf8_lossy(b).into_owned()),
xmp_extended: None,
icc: ifd0.bytes(tags::ICC_PROFILE).map(|b| b.to_vec()),
iptc: ifd0.value(tags::IPTC_NAA).map(|v| match v {
lightcraft_tiff::Value::Byte(b) | lightcraft_tiff::Value::Undefined(b) => b.clone(),
other => lightcraft_tiff::writer::encode(t.order, other),
}),
}
} else {
Embedded::default()
}
}
const XMP_SIG: &[u8] = b"http://ns.adobe.com/xap/1.0/\0";
const XMP_EXT_SIG: &[u8] = b"http://ns.adobe.com/xmp/extension/\0";
/// Walk JPEG markers up to SOS and collect APP1 Exif / XMP, APP2 ICC and APP13 IPTC.
pub fn jpeg_segments(b: &[u8]) -> Embedded {
let mut out = Embedded::default();
if !b.starts_with(&[0xff, 0xd8]) {
return out;
}
let mut icc_parts: Vec<(u8, Vec<u8>)> = Vec::new();
let mut ext: Vec<(u32, Vec<u8>)> = Vec::new();
let mut i = 2usize;
while i + 4 <= b.len() {
if b[i] != 0xff {
break;
}
let marker = b[i + 1];
if marker == 0xff {
i += 1; // fill byte
continue;
}
if marker == 0xd8 || (0xd0..=0xd7).contains(&marker) || marker == 0x01 {
i += 2;
continue;
}
if marker == 0xd9 || marker == 0xda {
break;
}
let len = u16::from_be_bytes([b[i + 2], b[i + 3]]) as usize;
if len < 2 {
break;
}
let end = (i + 2 + len).min(b.len());
let payload = &b[i + 4..end];
match marker {
0xe1 if payload.starts_with(b"Exif\0") && out.exif.is_none() => out.exif = Some(payload.get(6..).unwrap_or(&[]).to_vec()),
0xe1 if payload.starts_with(XMP_SIG) && out.xmp.is_none() => {
out.xmp = Some(String::from_utf8_lossy(&payload[XMP_SIG.len()..]).into_owned());
}
0xe1 if payload.starts_with(XMP_EXT_SIG) => {
// GUID (32) + full length (4) + offset (4) + data
let p = &payload[XMP_EXT_SIG.len()..];
if p.len() >= 40 {
let off = u32::from_be_bytes([p[36], p[37], p[38], p[39]]);
ext.push((off, p[40..].to_vec()));
}
}
0xe2 if payload.starts_with(b"ICC_PROFILE\0") && payload.len() >= 14 => icc_parts.push((payload[12], payload[14..].to_vec())),
0xed if payload.starts_with(b"Photoshop 3.0\0") && out.iptc.is_none() => out.iptc = photoshop_iptc(&payload[14..]),
_ => {}
}
i = end;
}
if !icc_parts.is_empty() {
icc_parts.sort_by_key(|p| p.0);
out.icc = Some(icc_parts.into_iter().flat_map(|p| p.1).collect());
}
if !ext.is_empty() {
ext.sort_by_key(|p| p.0);
out.xmp_extended = Some(String::from_utf8_lossy(&ext.into_iter().flat_map(|p| p.1).collect::<Vec<u8>>()).into_owned());
}
out
}
/// IPTC-IIM block (resource 0x0404) from a Photoshop image-resource stream (`8BIM` records).
fn photoshop_iptc(mut b: &[u8]) -> Option<Vec<u8>> {
while b.len() >= 12 && &b[..4] == b"8BIM" {
let id = u16::from_be_bytes([b[4], b[5]]);
let name_len = b[6] as usize;
let name_total = (1 + name_len).next_multiple_of(2);
let size_at = 6 + name_total;
let size = u32::from_be_bytes(b.get(size_at..size_at + 4)?.try_into().ok()?) as usize;
let data_at = size_at + 4;
let data = b.get(data_at..data_at.checked_add(size)?)?;
if id == 0x0404 {
return Some(data.to_vec());
}
b = b.get(data_at + size.next_multiple_of(2)..)?;
}
None
}
/// PNG chunks: `eXIf`, uncompressed `iTXt` XMP.
pub fn png_chunks(b: &[u8]) -> Embedded {
let mut out = Embedded::default();
let mut i = 8usize;
while i + 12 <= b.len() {
let len = u32::from_be_bytes([b[i], b[i + 1], b[i + 2], b[i + 3]]) as usize;
let typ = &b[i + 4..i + 8];
let Some(data) = b.get(i + 8..(i + 8).saturating_add(len)) else { break };
match typ {
b"eXIf" => out.exif = Some(crate::strip_exif_header(data).to_vec()),
b"iTXt" if data.starts_with(b"XML:com.adobe.xmp\0") => {
// keyword\0 compression-flag compression-method language\0 translated\0 text
let rest = &data[18..];
if rest.len() >= 2 && rest[0] == 0 {
let rest = &rest[2..];
let mut parts = rest.splitn(3, |&c| c == 0);
let (_, _, text) = (parts.next(), parts.next(), parts.next());
if let Some(t) = text {
out.xmp = Some(String::from_utf8_lossy(t).into_owned());
}
}
}
b"IEND" => break,
_ => {}
}
i += 12 + len;
}
out
}
/// WebP RIFF chunks `EXIF`, `XMP `, `ICCP`.
pub fn webp_chunks(b: &[u8]) -> Embedded {
let mut out = Embedded::default();
let mut i = 12usize;
while i + 8 <= b.len() {
let id = &b[i..i + 4];
let len = u32::from_le_bytes([b[i + 4], b[i + 5], b[i + 6], b[i + 7]]) as usize;
let Some(data) = b.get(i + 8..(i + 8).saturating_add(len)) else { break };
match id {
b"EXIF" => out.exif = Some(crate::strip_exif_header(data).to_vec()),
b"XMP " => out.xmp = Some(String::from_utf8_lossy(data).into_owned()),
b"ICCP" => out.icc = Some(data.to_vec()),
_ => {}
}
i += 8 + len + (len & 1);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::exif::tests::sample_exif;
use lightcraft_tiff::ByteOrder;
fn seg(marker: u8, payload: &[u8]) -> Vec<u8> {
let mut v = vec![0xff, marker];
v.extend_from_slice(&((payload.len() + 2) as u16).to_be_bytes());
v.extend_from_slice(payload);
v
}
pub(crate) fn sample_jpeg() -> Vec<u8> {
let mut j = vec![0xff, 0xd8];
j.extend(seg(0xe0, b"JFIF\0\x01\x02\0\0\x01\0\x01\0\0"));
let mut exif = b"Exif\0\0".to_vec();
exif.extend(sample_exif(ByteOrder::Big));
j.extend(seg(0xe1, &exif));
let meta = crate::Metadata { title: Some("Sunset".into()), rating: Some(4), ..Default::default() };
let mut xmp = XMP_SIG.to_vec();
xmp.extend(crate::write_xmp(&meta, Some("{\"exposure\":1.5}")).into_bytes());
j.extend(seg(0xe1, &xmp));
// ICC in two chunks, out of order
j.extend(seg(0xe2, b"ICC_PROFILE\0\x02\x02WORLD"));
j.extend(seg(0xe2, b"ICC_PROFILE\0\x01\x02HELLO"));
// IPTC
let iim = crate::iptc::tests::sample_iim();
let mut ps = b"Photoshop 3.0\0".to_vec();
ps.extend_from_slice(b"8BIM\x04\x0c\0\0");
ps.extend_from_slice(&2u32.to_be_bytes());
ps.extend_from_slice(&[1, 2]);
ps.extend_from_slice(b"8BIM\x04\x04\0\0");
ps.extend_from_slice(&(iim.len() as u32).to_be_bytes());
ps.extend_from_slice(&iim);
j.extend(seg(0xed, &ps));
j.extend_from_slice(&[0xff, 0xda, 0, 2, 1, 2, 3, 0xff, 0xd9]);
j
}
#[test]
fn jpeg_blocks() {
let j = sample_jpeg();
let e = jpeg_segments(&j);
assert_eq!(e.icc.as_deref(), Some(&b"HELLOWORLD"[..]));
assert!(e.xmp.as_deref().unwrap().contains("Sunset"));
assert!(e.iptc.is_some());
let m = crate::extract(&j);
assert_eq!(m.iso, Some(400));
assert_eq!(m.title.as_deref(), Some("Sunset"));
assert_eq!(m.rating, Some(4)); // XMP overrides EXIF rating 3
assert_eq!(m.keywords, vec!["alpha".to_string(), "beta".to_string()]); // from IPTC
assert_eq!(m.caption.as_deref(), Some("A caption"));
}
#[test]
fn jpeg_extended_xmp() {
let mut j = vec![0xff, 0xd8];
for (off, part) in [(5u32, &b"world"[..]), (0, b"hello")] {
let mut p = XMP_EXT_SIG.to_vec();
p.extend_from_slice(&[b'A'; 32]);
p.extend_from_slice(&10u32.to_be_bytes());
p.extend_from_slice(&off.to_be_bytes());
p.extend_from_slice(part);
j.extend(seg(0xe1, &p));
}
assert_eq!(jpeg_segments(&j).xmp_extended.as_deref(), Some("helloworld"));
}
#[test]
fn png_and_webp() {
let exif = sample_exif(ByteOrder::Little);
let mut p = b"\x89PNG\r\n\x1a\n".to_vec();
let mut chunk = |typ: &[u8], data: &[u8]| {
p.extend_from_slice(&(data.len() as u32).to_be_bytes());
p.extend_from_slice(typ);
p.extend_from_slice(data);
p.extend_from_slice(&[0; 4]);
};
chunk(b"eXIf", &exif);
let mut itxt = b"XML:com.adobe.xmp\0\0\0\0\0".to_vec();
itxt.extend(crate::write_xmp(&crate::Metadata { label: Some("Red".into()), ..Default::default() }, None).into_bytes());
chunk(b"iTXt", &itxt);
chunk(b"IEND", &[]);
let m = crate::extract(&p);
assert_eq!(m.iso, Some(400));
assert_eq!(m.label.as_deref(), Some("Red"));
let mut w = b"RIFF\0\0\0\0WEBP".to_vec();
let mut body = |id: &[u8], data: &[u8]| {
w.extend_from_slice(id);
w.extend_from_slice(&(data.len() as u32).to_le_bytes());
w.extend_from_slice(data);
if data.len() % 2 == 1 {
w.push(0);
}
};
body(b"VP8X", &[0; 10]);
body(b"ICCP", b"abc");
let mut ex = b"Exif\0\0".to_vec();
ex.extend_from_slice(&exif);
body(b"EXIF", &ex);
let e = webp_chunks(&w);
assert_eq!(e.icc.as_deref(), Some(&b"abc"[..]));
assert_eq!(crate::extract(&w).f_number, Some(2.8));
}
#[test]
fn tiff_file_is_its_own_exif() {
let t = sample_exif(ByteOrder::Big);
assert_eq!(crate::extract(&t).model.as_deref(), Some("Model X"));
assert_eq!(embedded(b"not an image"), Embedded::default());
}
#[test]
fn raf_reads_its_embedded_jpeg() {
let j = sample_jpeg();
let mut raf = b"FUJIFILMCCD-RAW 0201FF000000TEST".to_vec();
raf.resize(120, 0);
raf[84..88].copy_from_slice(&120u32.to_be_bytes());
raf[88..92].copy_from_slice(&(j.len() as u32).to_be_bytes());
raf.extend_from_slice(&j);
assert_eq!(crate::extract(&raf).iso, Some(400));
for n in 0..raf.len() {
let _ = crate::extract(&raf[..n]);
}
}
#[test]
fn truncated_containers_do_not_panic() {
let j = sample_jpeg();
for n in 0..j.len() {
let _ = crate::extract(&j[..n]);
}
}
}