mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-12 07:46:08 +00:00
craft-metadata: EXIF, IPTC-IIM, XMP and GPX from LightCraft (#949)
lightcraft-meta at 5b61ce1e, as one crate with a module per format: exif (read, write, maker-note lens, focus point, tag listing), iptc, xmp (parse, write, merge), gpx, and the JPEG, PNG, WebP, TIFF, RAF and CR3 containers. They share Metadata, DateTime, Gps and Orientation. Changed in the move: - TIFF comes from craft-tiff; Point, Rect and Orientation are the crate's own. - The app namespace is a parameter: parse_xmp_with, write_xmp_with and MergeRules::namespaces take it, and XmpData no longer has lc_settings. - The HEIF, PSD and MRW readers stay out, since craft-heif, craft-psd and craft-raw read those containers; from_embedded takes the blocks they find.
This commit is contained in:
Generated
+12
@@ -1275,6 +1275,18 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-metadata"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"craft-tiff",
|
||||
"proptest",
|
||||
"quick-xml 0.41.0",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-metafile"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -95,6 +95,7 @@ craft-text = { path = "crates/text" }
|
||||
craft-tga = { path = "crates/tga" }
|
||||
craft-timeline = { path = "crates/timeline" }
|
||||
craft-tiff = { path = "crates/tiff" }
|
||||
craft-metadata = { path = "crates/metadata" }
|
||||
craft-ui = { path = "crates/ui" }
|
||||
craft-update = { path = "crates/update" }
|
||||
craft-vcard = { path = "crates/vcard" }
|
||||
|
||||
@@ -144,6 +144,10 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
|
||||
hostile input.
|
||||
- [`craft-idml`](crates/idml/README.md): IDML (InDesign Markup Language) packages: the ZIP container read with bounded part sizes and lenient part names, `designmap.xml` with its includes in place, the `<Properties>` form on craft-xml elements, value codecs and the package writer; and InDesign Tagged Text, read and written.
|
||||
- [`craft-timeline`](crates/timeline/README.md): editorial timeline interchange: exact media time and SMPTE timecode, import/export reports, media path and `file://` URL handling, Avid Log Exchange (ALE) and CMX 3600 EDL documents, format detection. From FilmCraft.
|
||||
- [`craft-metadata`](crates/metadata/README.md): photo metadata, from LightCraft: EXIF (read and write,
|
||||
maker-note lens and focus point, every tag by name), IPTC-IIM, XMP packets (read, write, and a merge
|
||||
that keeps what another program wrote), GPX track logs, and the blocks that carry them in JPEG, PNG,
|
||||
WebP, TIFF-based, RAF and CR3 files. One `Metadata` model for all of them. Built on `craft-tiff`.
|
||||
- [`craft-tiff`](crates/tiff/README.md): TIFF and BigTIFF IFD reader and writer, from LightCraft:
|
||||
every field type, the IFD chain, SubIFDs and Exif/GPS/Interop pointer IFDs, maker-note IFDs
|
||||
(Canon, Nikon, Sony, Fujifilm, Olympus, Panasonic, Pentax), strip and tile layout. Bounded and
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "craft-metadata"
|
||||
description = "Photo metadata: EXIF, IPTC-IIM, XMP packets (read, write, merge) and GPX track logs, the blocks that carry them in JPEG, PNG, WebP, TIFF and CR3 files, and one model they all read into."
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
craft-tiff = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
quick-xml = "0.41"
|
||||
chrono = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
@@ -0,0 +1,61 @@
|
||||
# craft-metadata
|
||||
|
||||
Photo metadata: EXIF, IPTC-IIM, XMP packets and GPX track logs, the blocks that carry them in image
|
||||
files, and one `Metadata` model they all read into. From LightCraft (`lightcraft-meta`). TIFF
|
||||
structure comes from `craft-tiff`.
|
||||
|
||||
One module per format; they share `Metadata`, `DateTime`, `Gps` and `Orientation`, which is why they
|
||||
are one crate.
|
||||
|
||||
| Function | Purpose |
|
||||
|---|---|
|
||||
| `read_exif(&[u8]) -> Metadata` | A TIFF-structured Exif block: a whole TIFF/DNG/raw file, or a JPEG APP1 payload with or without `Exif\0\0`. `try_read_exif` reports header errors, `try_read_exif_with` takes `craft_tiff::ParseOptions`. |
|
||||
| `from_tiff(&Tiff) -> Metadata` | The same from an already parsed stream. |
|
||||
| `write_exif(&Metadata) -> Vec<u8>` | An Exif block for an exported file. |
|
||||
| `tags::tag_rows`, `tags::file_tag_rows` | Every tag with its name and value as text, maker notes included. |
|
||||
| `focus::read`, `focus::read_header` | Where the camera focused, from the maker note (Sony), on the upright photo. `read_header` reads only the first kilobytes of a file through a callback. |
|
||||
| `parse_iptc(&[u8]) -> Metadata` | IIM record 2: title (2:5), keywords (2:25), by-line (2:80), copyright (2:116), caption (2:120), date and time created (2:55, 2:60). |
|
||||
| `parse_xmp(&str) -> Result<XmpData>` | `Metadata`, every property (`prefix:name`, structs flattened as `a:b/c:d`) and the structured values. `parse_xmp_with` also canonicalises the caller's own namespaces. |
|
||||
| `write_xmp(&Metadata) -> String` | A complete `<?xpacket?>` packet. `write_xmp_with` sets the toolkit name and adds simple properties in the caller's namespace. |
|
||||
| `merge_xmp(existing, fresh, MergeRules)` | Updates a packet another program wrote: owned properties are replaced, everything else stays byte for byte. |
|
||||
| `parse_gpx(&str) -> Result<Tracklog>` | GPX track points; `Tracklog::locate` gives the position at a capture time. |
|
||||
| `embedded(&[u8]) -> Embedded` | Sniffs JPEG, PNG, WebP, TIFF-based files, Fujifilm RAF and Canon CR3 and returns the Exif, XMP (and extended XMP), ICC and IPTC blocks. `jpeg_segments`, `png_chunks`, `webp_chunks` are the walkers. |
|
||||
| `cr3::parse_cr3` | The Canon CR3 container: `CMT1`…`CMT4`, the XMP `uuid` box, the thumbnail, the tracks. |
|
||||
| `extract(&[u8]) -> Metadata` | All of it for a whole file. Precedence: EXIF, then IPTC fills gaps, then XMP overrides the user fields (rating, label, title, caption, artist, copyright, keywords, GPS) and fills the rest. `from_embedded` does the same for blocks already located. |
|
||||
|
||||
HEIF, PSD and MRW files are read by `craft-heif`, `craft-psd` and `craft-raw`. Put the blocks they
|
||||
return into an `Embedded` and call `from_embedded`.
|
||||
|
||||
## Capture time
|
||||
|
||||
EXIF candidates are tried in order: DateTimeOriginal, DateTimeDigitized, then IFD0 DateTime. A clock
|
||||
with more than three colon-separated components is rejected as a whole, so the next candidate or the
|
||||
XMP supplies the time. ISO 8601 times in XMP need a complete clock. Calendar checks go through
|
||||
`chrono`. Invalid GPX point times count as untimed.
|
||||
|
||||
## XMP namespaces
|
||||
|
||||
Written: `dc`, `xmp`, `xmpRights`, `photoshop`, `exif`, `exifEX`, `tiff`, `aux`, `lr`,
|
||||
`Iptc4xmpCore`, and the one namespace the caller passes. Read as well: `crs`, `mwg-rs` regions
|
||||
(`stArea`, `stDim`). Reading canonicalises prefixes by namespace URI and handles attribute and element
|
||||
forms, `rdf:Seq`/`Bag`/`Alt` (x-default first), `rdf:parseType="Resource"` and nested-description
|
||||
structs, predefined and numeric entities.
|
||||
|
||||
A foreign namespace that uses a recognised prefix cannot supply recognised properties. Its keys are
|
||||
`Q{hex-encoded namespace URI}local`. Merging keeps the original foreign XML.
|
||||
|
||||
## Limits
|
||||
|
||||
XMP: 64 levels, 200 000 nodes, 1 MiB of flattened keys and 8 MiB of opaque names per packet
|
||||
(`XmpError::TooComplex`). GPX: 64 levels, 5 000 000 points. CR3: 4096 boxes. JPEG ICC is returned only when its APP2 chunks form a
|
||||
complete, consistent, uniquely numbered sequence. TIFF limits are `craft-tiff`'s. No input panics:
|
||||
proptests feed random bytes to every container walker and random text to the XMP parser, and a sample
|
||||
JPEG is cut at every byte.
|
||||
|
||||
## Sources
|
||||
|
||||
CIPA DC-008 (Exif 2.32), TIFF 6.0, Adobe DNG 1.7 (tag numbers), ISO 16684-1 and the Adobe XMP
|
||||
Specification Parts 1 to 3, IPTC-IIM 4.2 and the IPTC Photo Metadata Standard, the MWG Region
|
||||
Guidelines, ITU-T T.81 (JPEG markers), ICC.1 (APP2 embedding), the PNG Specification 3rd ed. (`eXIf`,
|
||||
`iTXt`), the WebP RIFF container specification, ISO/IEC 14496-12 (box structure), GPX 1.1. XML parsing:
|
||||
`quick-xml` (MIT). No third-party metadata code was consulted.
|
||||
@@ -0,0 +1,450 @@
|
||||
//! 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.
|
||||
//!
|
||||
//! PSD, MRW and HEIF files are read by `craft-psd`, `craft-raw` and `craft-heif`.
|
||||
|
||||
use craft_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 {
|
||||
craft_tiff::Value::Byte(b) | craft_tiff::Value::Undefined(b) => b.clone(),
|
||||
other => craft_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: [Option<&[u8]>; 255] = [None; 255];
|
||||
let mut icc_total = None;
|
||||
let mut icc_invalid = false;
|
||||
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 Some(start) = i.checked_add(4) else { break };
|
||||
let Some(declared_end) = i.checked_add(2).and_then(|at| at.checked_add(len)) else { break };
|
||||
let complete = declared_end <= b.len();
|
||||
let end = declared_end.min(b.len());
|
||||
let Some(payload) = b.get(start..end) else { break };
|
||||
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") => {
|
||||
if !complete || payload.len() < 14 {
|
||||
icc_invalid = true;
|
||||
} else if let Some(header) = payload.get(12..14) {
|
||||
let (Some(&sequence), Some(&total)) = (header.first(), header.get(1)) else {
|
||||
icc_invalid = true;
|
||||
i = end;
|
||||
continue;
|
||||
};
|
||||
if sequence == 0 || total == 0 || sequence > total || icc_total.is_some_and(|expected| expected != total) {
|
||||
icc_invalid = true;
|
||||
} else {
|
||||
icc_total = Some(total);
|
||||
if let Some(slot) = icc_parts.get_mut(usize::from(sequence - 1)) {
|
||||
if slot.is_some() {
|
||||
icc_invalid = true;
|
||||
} else if let Some(data) = payload.get(14..) {
|
||||
*slot = Some(data);
|
||||
} else {
|
||||
icc_invalid = true;
|
||||
}
|
||||
} else {
|
||||
icc_invalid = true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
icc_invalid = true;
|
||||
}
|
||||
}
|
||||
0xed if payload.starts_with(b"Photoshop 3.0\0") && out.iptc.is_none() => out.iptc = photoshop_iptc(&payload[14..]),
|
||||
_ => {}
|
||||
}
|
||||
i = end;
|
||||
}
|
||||
if let Some(total) = icc_total {
|
||||
let mut profile_len = 0usize;
|
||||
for chunk in icc_parts.iter().take(usize::from(total)) {
|
||||
let Some(bytes) = chunk else {
|
||||
icc_invalid = true;
|
||||
break;
|
||||
};
|
||||
let Some(next_len) = profile_len.checked_add(bytes.len()) else {
|
||||
icc_invalid = true;
|
||||
break;
|
||||
};
|
||||
profile_len = next_len;
|
||||
}
|
||||
let maximum_len = usize::from(total).checked_mul(65_519);
|
||||
if maximum_len.is_none_or(|maximum| profile_len > maximum) {
|
||||
icc_invalid = true;
|
||||
}
|
||||
if !icc_invalid {
|
||||
let mut profile = Vec::with_capacity(profile_len);
|
||||
for bytes in icc_parts.iter().take(usize::from(total)).flatten() {
|
||||
profile.extend_from_slice(bytes);
|
||||
}
|
||||
out.icc = Some(profile);
|
||||
}
|
||||
}
|
||||
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 craft_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
|
||||
}
|
||||
|
||||
fn icc_seg(sequence: u8, total: u8, data: &[u8]) -> Vec<u8> {
|
||||
let mut payload = b"ICC_PROFILE\0".to_vec();
|
||||
payload.extend_from_slice(&[sequence, total]);
|
||||
payload.extend_from_slice(data);
|
||||
seg(0xe2, &payload)
|
||||
}
|
||||
|
||||
fn jpeg_with_icc(parts: &[Vec<u8>]) -> Vec<u8> {
|
||||
let mut jpeg = vec![0xff, 0xd8];
|
||||
let packet = crate::write_xmp(&crate::Metadata { title: Some("retained".into()), ..Default::default() });
|
||||
let mut xmp = XMP_SIG.to_vec();
|
||||
xmp.extend_from_slice(packet.as_bytes());
|
||||
jpeg.extend_from_slice(&seg(0xe1, &xmp));
|
||||
for part in parts {
|
||||
jpeg.extend_from_slice(part);
|
||||
}
|
||||
jpeg.extend_from_slice(&[0xff, 0xda, 0, 2]);
|
||||
jpeg
|
||||
}
|
||||
|
||||
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).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 jpeg_icc_accepts_reordered_chunks() {
|
||||
let parts = [icc_seg(2, 2, b"world"), icc_seg(1, 2, b"hello")];
|
||||
let embedded = jpeg_segments(&jpeg_with_icc(&parts));
|
||||
assert_eq!(embedded.icc.as_deref(), Some(&b"helloworld"[..]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jpeg_icc_rejects_incomplete_or_incoherent_chunks() {
|
||||
let cases = [
|
||||
("missing", vec![icc_seg(1, 2, b"helloworld")]),
|
||||
("duplicate", vec![icc_seg(1, 2, b"hello"), icc_seg(1, 2, b"world")]),
|
||||
("zero sequence", vec![icc_seg(0, 1, b"helloworld")]),
|
||||
("zero total", vec![icc_seg(1, 0, b"helloworld")]),
|
||||
("out of range", vec![icc_seg(2, 1, b"helloworld")]),
|
||||
("gap", vec![icc_seg(1, 3, b"hello"), icc_seg(3, 3, b"world")]),
|
||||
("inconsistent totals", vec![icc_seg(1, 2, b"hello"), icc_seg(2, 3, b"world")]),
|
||||
("short header", vec![seg(0xe2, b"ICC_PROFILE\0\x01")]),
|
||||
];
|
||||
for (name, parts) in cases {
|
||||
let embedded = jpeg_segments(&jpeg_with_icc(&parts));
|
||||
assert!(embedded.icc.is_none(), "{name} ICC chunks must be rejected");
|
||||
assert!(embedded.xmp.as_deref().is_some_and(|x| x.contains("retained")), "{name}: unrelated XMP must remain readable");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jpeg_icc_rejects_truncated_segment_and_keeps_other_metadata() {
|
||||
let mut truncated = icc_seg(1, 1, b"profile");
|
||||
let declared = u16::from_be_bytes([truncated[2], truncated[3]]).saturating_add(1);
|
||||
truncated[2..4].copy_from_slice(&declared.to_be_bytes());
|
||||
let mut jpeg = vec![0xff, 0xd8];
|
||||
let xmp = crate::write_xmp(&crate::Metadata { title: Some("kept".into()), ..Default::default() });
|
||||
let mut packet = XMP_SIG.to_vec();
|
||||
packet.extend_from_slice(xmp.as_bytes());
|
||||
jpeg.extend_from_slice(&seg(0xe1, &packet));
|
||||
jpeg.extend_from_slice(&truncated);
|
||||
|
||||
let embedded = jpeg_segments(&jpeg);
|
||||
assert!(embedded.icc.is_none());
|
||||
assert!(embedded.xmp.as_deref().is_some_and(|x| x.contains("kept")));
|
||||
}
|
||||
|
||||
#[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() }).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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,742 @@
|
||||
//! Canon CR3: the ISO base media file container (ISO/IEC 14496-12 box structure; CR3 layout from Laurent
|
||||
//! Clévy's public CR3 format notes, confirmed black-box on EOS R6 Mark III files).
|
||||
//!
|
||||
//! - `ftyp` with major brand `crx `.
|
||||
//! - `moov` holds a Canon `uuid` box (85c0b687-820f-11e0-8111-f4ce462b6a48) whose children are `CMT1` (a TIFF
|
||||
//! stream: IFD0), `CMT2` (TIFF: the Exif IFD), `CMT3` (TIFF: the Canon maker note), `CMT4` (TIFF: GPS) and the
|
||||
//! `THMB` thumbnail, then one `trak` per stream: a full-size JPEG, a reduced raw, the full raw (`CRAW` sample
|
||||
//! entries; raws carry a `CMP1` coding header) and timed metadata (`CTMD`).
|
||||
//! - A top-level `uuid` box with the XMP uuid (be7acfcb-97a9-42e8-9c71-999491e3afac, XMP spec part 3) holds the
|
||||
//! XMP packet.
|
||||
//!
|
||||
//! Every size and offset is checked against the buffer; nesting and box counts are bounded.
|
||||
|
||||
/// A track's sample-entry kind.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Cr3TrackKind {
|
||||
/// `CRAW` with a `JPEG` child: the full-size JPEG.
|
||||
Jpeg,
|
||||
/// `CRAW` raw image: dimensions and the `CMP1` coding header (byte range in the file).
|
||||
Raw {
|
||||
width: u16,
|
||||
height: u16,
|
||||
cmp1: Option<(usize, usize)>,
|
||||
/// `CDI1/IAD1` image-area descriptor (byte range in the file).
|
||||
iad1: Option<(usize, usize)>,
|
||||
},
|
||||
/// Anything else (`CTMD` timed metadata, unknown entries).
|
||||
Other([u8; 4]),
|
||||
}
|
||||
|
||||
/// One track's single sample: where it is in the file.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Cr3Track {
|
||||
pub kind: Cr3TrackKind,
|
||||
/// Byte offset and length of the sample (the first one) in the file, when the tables give them.
|
||||
pub data: Option<(usize, usize)>,
|
||||
}
|
||||
|
||||
/// Canon's `CMP1` image coding descriptor, separate from the encoded track sample.
|
||||
///
|
||||
/// Parsed from the payload (after the eight-byte box header). Geometry is the full sensor mosaic,
|
||||
/// including optically masked borders. Unsupported encoding modes are retained for the decoder to
|
||||
/// report explicitly, rather than confused with the full-size embedded JPEG.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Cr3Compression {
|
||||
pub version: u16,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub tile_width: u32,
|
||||
pub tile_height: u32,
|
||||
pub bit_depth: u8,
|
||||
pub planes: u8,
|
||||
/// 0 = RGGB, 1 = GRBG, 2 = GBRG, 3 = BGGR.
|
||||
pub cfa_pattern: u8,
|
||||
pub encoding: u8,
|
||||
pub levels: u8,
|
||||
pub tile_flags: u8,
|
||||
/// Number of bytes at the start of the sample before its entropy-coded data.
|
||||
pub header_size: u32,
|
||||
pub median_bit_depth: Option<u8>,
|
||||
}
|
||||
|
||||
impl Cr3Compression {
|
||||
/// Read the public `CMP1` field layout. Truncated or impossible descriptors return `None`.
|
||||
pub fn parse(payload: &[u8]) -> Option<Self> {
|
||||
// The first word is reserved in the public notes; real files use FF00, although older
|
||||
// descriptions guessed FFFF. It is not a reliable format signature.
|
||||
let header_len = usize::from(be16(payload, 2)?).checked_add(4)?;
|
||||
if header_len < 52 || header_len > payload.len() {
|
||||
return None;
|
||||
}
|
||||
let packed = *payload.get(25)?;
|
||||
let coding = *payload.get(26)?;
|
||||
let mut out = Self {
|
||||
version: be16(payload, 4)?,
|
||||
width: be32(payload, 8)?,
|
||||
height: be32(payload, 12)?,
|
||||
tile_width: be32(payload, 16)?,
|
||||
tile_height: be32(payload, 20)?,
|
||||
bit_depth: *payload.get(24)?,
|
||||
planes: packed >> 4,
|
||||
cfa_pattern: packed & 15,
|
||||
encoding: coding >> 4,
|
||||
levels: coding & 15,
|
||||
tile_flags: *payload.get(27)?,
|
||||
header_size: be32(payload, 28)?,
|
||||
median_bit_depth: None,
|
||||
};
|
||||
if out.width == 0
|
||||
|| out.height == 0
|
||||
|| out.tile_width == 0
|
||||
|| out.tile_height == 0
|
||||
|| out.tile_width > out.width
|
||||
|| out.tile_height > out.height
|
||||
|| !(1..=16).contains(&out.bit_depth)
|
||||
|| !(1..=4).contains(&out.planes)
|
||||
|| (out.planes > 1 && out.cfa_pattern > 3)
|
||||
|| out.header_size == 0
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if payload.get(32).is_some_and(|flags| flags & 0x80 != 0) {
|
||||
// Extended CMP1: a four-byte length, flags, reserved bytes and six words. The optional
|
||||
// median precision is used by type-3 burst encodings, not ordinary Bayer raw/craw.
|
||||
let extension_len = usize::try_from(be32(payload, 52)?).ok()?;
|
||||
let extension_end = 52usize.checked_add(extension_len)?;
|
||||
if extension_len < 32 || extension_end > payload.len() {
|
||||
return None;
|
||||
}
|
||||
if payload.get(56).is_some_and(|flags| flags & 0x40 != 0) {
|
||||
let precision = *payload.get(84).filter(|_| extension_end > 84)?;
|
||||
if !(1..=16).contains(&precision) {
|
||||
return None;
|
||||
}
|
||||
out.median_bit_depth = Some(precision);
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// `IAD1` geometry. Coordinates are inclusive sensor offsets: `[left, top, right, bottom]`.
|
||||
/// A full raw descriptor distinguishes valid sensor pixels from the recommended final crop.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Cr3ImageArea {
|
||||
pub width: u16,
|
||||
pub height: u16,
|
||||
pub crop: [u16; 4],
|
||||
pub active: Option<[u16; 4]>,
|
||||
pub masked_left: [u16; 4],
|
||||
pub masked_top: Option<[u16; 4]>,
|
||||
}
|
||||
|
||||
impl Cr3ImageArea {
|
||||
pub fn parse(payload: &[u8]) -> Option<Self> {
|
||||
let bounds = |at: usize| Some([be16(payload, at)?, be16(payload, at + 2)?, be16(payload, at + 4)?, be16(payload, at + 6)?]);
|
||||
let width = be16(payload, 4)?;
|
||||
let height = be16(payload, 6)?;
|
||||
if width == 0 || height == 0 {
|
||||
return None;
|
||||
}
|
||||
let full = be16(payload, 10)? == 2;
|
||||
Some(Self {
|
||||
width,
|
||||
height,
|
||||
crop: bounds(16)?,
|
||||
masked_left: bounds(24)?,
|
||||
masked_top: if full { Some(bounds(32)?) } else { None },
|
||||
active: if full { Some(bounds(40)?) } else { None },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Cr3Track {
|
||||
/// Decode this track's image coding descriptor without reading its pixel data.
|
||||
pub fn compression(&self, bytes: &[u8]) -> Option<Cr3Compression> {
|
||||
let Cr3TrackKind::Raw { cmp1: Some((at, len)), .. } = self.kind else { return None };
|
||||
Cr3Compression::parse(bytes.get(at..at.checked_add(len)?)?)
|
||||
}
|
||||
|
||||
pub fn image_area(&self, bytes: &[u8]) -> Option<Cr3ImageArea> {
|
||||
let Cr3TrackKind::Raw { iad1: Some((at, len)), .. } = self.kind else { return None };
|
||||
Cr3ImageArea::parse(bytes.get(at..at.checked_add(len)?)?)
|
||||
}
|
||||
}
|
||||
|
||||
/// The parts of a CR3 file that are read.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Cr3<'a> {
|
||||
/// `CMT1`…`CMT4` (index 0…3).
|
||||
pub cmt: [Option<&'a [u8]>; 4],
|
||||
pub thumbnail: Option<&'a [u8]>,
|
||||
pub xmp: Option<&'a [u8]>,
|
||||
pub tracks: Vec<Cr3Track>,
|
||||
}
|
||||
|
||||
/// One independent TIFF block from Canon timed metadata. Its offsets remain relative to this block.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Cr3TimedExif<'a> {
|
||||
/// CTMD record type (7, 8 or 9).
|
||||
pub record_type: u16,
|
||||
/// Exif IFD (`0x8769`) or Canon maker note (`0x927c`).
|
||||
pub tag: u32,
|
||||
pub data: &'a [u8],
|
||||
}
|
||||
|
||||
impl Cr3<'_> {
|
||||
/// Read TIFF blocks in the first samples of CTMD tracks. As-shot white balance and sensor
|
||||
/// levels often live here rather than in the static `CMT3` maker note. Both record layers use
|
||||
/// little-endian byte lengths; TIFF blocks retain their own byte order and offset origin.
|
||||
pub fn timed_exif<'a>(&self, bytes: &'a [u8]) -> Vec<Cr3TimedExif<'a>> {
|
||||
let mut out = Vec::new();
|
||||
let mut budget = MAX_BOXES;
|
||||
for track in &self.tracks {
|
||||
if track.kind != Cr3TrackKind::Other(*b"CTMD") {
|
||||
continue;
|
||||
}
|
||||
let Some(sample) = track.data.and_then(|(at, len)| at.checked_add(len).and_then(|end| bytes.get(at..end))) else { continue };
|
||||
let mut at = 0usize;
|
||||
while budget > 0 {
|
||||
budget -= 1;
|
||||
let Some(len) = le32(sample, at).and_then(|v| usize::try_from(v).ok()) else { break };
|
||||
let Some(end) = at.checked_add(len).filter(|&end| len >= 12 && end <= sample.len()) else { break };
|
||||
let Some(record) = sample.get(at..end) else { break };
|
||||
let Some(kind) = le16(record, 4) else { break };
|
||||
if matches!(kind, 7..=9) {
|
||||
let mut item_at = 12usize;
|
||||
while budget > 0 {
|
||||
budget -= 1;
|
||||
let Some(item_len) = le32(record, item_at).and_then(|v| usize::try_from(v).ok()) else { break };
|
||||
let Some(item_end) = item_at.checked_add(item_len).filter(|&end| item_len >= 8 && end <= record.len()) else { break };
|
||||
let Some(item) = record.get(item_at..item_end) else { break };
|
||||
if let Some(tag @ (0x8769 | 0x927c)) = le32(item, 4)
|
||||
&& let Some(data) = item.get(8..)
|
||||
{
|
||||
out.push(Cr3TimedExif { record_type: kind, tag, data });
|
||||
}
|
||||
item_at = item_end;
|
||||
}
|
||||
}
|
||||
at = end;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
const CANON_UUID: [u8; 16] = [0x85, 0xc0, 0xb6, 0x87, 0x82, 0x0f, 0x11, 0xe0, 0x81, 0x11, 0xf4, 0xce, 0x46, 0x2b, 0x6a, 0x48];
|
||||
/// 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;
|
||||
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;
|
||||
|
||||
/// Does this look like a CR3 file (`ftyp` box with major brand `crx `)?
|
||||
pub fn is_cr3(bytes: &[u8]) -> bool {
|
||||
bytes.get(4..12) == Some(b"ftypcrx ".as_slice())
|
||||
}
|
||||
|
||||
/// Parse a CR3 file's boxes; `None` when it is not a CR3 file. Of the top-level boxes only `moov`
|
||||
/// and the XMP `uuid` are read (a reader of a sparse file relies on it to skip the others).
|
||||
pub fn parse_cr3(bytes: &[u8]) -> Option<Cr3<'_>> {
|
||||
if !is_cr3(bytes) {
|
||||
return None;
|
||||
}
|
||||
let mut out = Cr3::default();
|
||||
let mut budget = MAX_BOXES;
|
||||
for b in boxes(bytes, 0, bytes.len(), &mut budget) {
|
||||
match &b.kind {
|
||||
b"moov" => moov(bytes, &b, &mut out, &mut budget),
|
||||
b"uuid" if b.uuid == Some(XMP_UUID) => out.xmp = bytes.get(b.body..b.end),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
pub(crate) struct BoxRef {
|
||||
pub(crate) kind: [u8; 4],
|
||||
/// Start of the payload (after the header and, for `uuid`, the 16-byte uuid).
|
||||
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.
|
||||
pub(crate) 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 };
|
||||
let mut uuid = None;
|
||||
if &kind4 == b"uuid" {
|
||||
let Some(u) = bytes.get(at + hdr..at + hdr + 16).filter(|_| at + hdr + 16 <= end) else { break };
|
||||
let mut a = [0u8; 16];
|
||||
a.copy_from_slice(u);
|
||||
uuid = Some(a);
|
||||
hdr += 16;
|
||||
}
|
||||
out.push(BoxRef { kind: kind4, body: at + hdr, end, uuid });
|
||||
at = end;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn moov<'a>(bytes: &'a [u8], m: &BoxRef, out: &mut Cr3<'a>, budget: &mut usize) {
|
||||
for b in boxes(bytes, m.body, m.end, budget) {
|
||||
match &b.kind {
|
||||
b"uuid" if b.uuid == Some(CANON_UUID) => {
|
||||
for c in boxes(bytes, b.body, b.end, budget) {
|
||||
let slot = match &c.kind {
|
||||
b"CMT1" => 0,
|
||||
b"CMT2" => 1,
|
||||
b"CMT3" => 2,
|
||||
b"CMT4" => 3,
|
||||
b"THMB" => {
|
||||
out.thumbnail = bytes.get(c.body..c.end);
|
||||
continue;
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
out.cmt[slot] = bytes.get(c.body..c.end);
|
||||
}
|
||||
}
|
||||
b"trak" => {
|
||||
if let Some(t) = trak(bytes, &b, budget) {
|
||||
out.tracks.push(t);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Descend `trak` → `mdia` → `minf` → `stbl` and read its sample entry and first sample's location.
|
||||
fn trak(bytes: &[u8], t: &BoxRef, budget: &mut usize) -> Option<Cr3Track> {
|
||||
let mut stbl = None;
|
||||
let mut stack = vec![(t.body, t.end, 0usize)];
|
||||
while let Some((from, to, depth)) = stack.pop() {
|
||||
if depth > MAX_DEPTH {
|
||||
continue;
|
||||
}
|
||||
for b in boxes(bytes, from, to, budget) {
|
||||
match &b.kind {
|
||||
b"mdia" | b"minf" => stack.push((b.body, b.end, depth + 1)),
|
||||
b"stbl" => stbl = Some(b),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let stbl = stbl?;
|
||||
let (mut kind, mut size, mut offset) = (None, None, None);
|
||||
for b in boxes(bytes, stbl.body, stbl.end, budget) {
|
||||
let body = bytes.get(b.body..b.end)?;
|
||||
match &b.kind {
|
||||
// full box: version/flags, entry count, entries
|
||||
b"stsd" if be32(body, 4)? > 0 => kind = boxes(bytes, b.body + 8, b.end, budget).first().map(|e| sample_entry(bytes, e, budget)),
|
||||
// version/flags, sample_size (0 = per-sample table), count, sizes
|
||||
b"stsz" => {
|
||||
size = match be32(body, 4)? {
|
||||
0 if be32(body, 8)? > 0 => be32(body, 12),
|
||||
0 => None,
|
||||
n if be32(body, 8)? > 0 => Some(n),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
b"co64" if be32(body, 4)? > 0 => offset = be64(body, 8).and_then(|v| usize::try_from(v).ok()),
|
||||
b"stco" if be32(body, 4)? > 0 => offset = be32(body, 8).map(|v| v as usize),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let data = match (offset, size) {
|
||||
(Some(o), Some(s)) if o.checked_add(s as usize).is_some_and(|e| e <= bytes.len()) => Some((o, s as usize)),
|
||||
_ => None,
|
||||
};
|
||||
Some(Cr3Track { kind: kind?, data })
|
||||
}
|
||||
|
||||
fn sample_entry(bytes: &[u8], e: &BoxRef, budget: &mut usize) -> Cr3TrackKind {
|
||||
if &e.kind != b"CRAW" {
|
||||
return Cr3TrackKind::Other(e.kind);
|
||||
}
|
||||
let Some(body) = bytes.get(e.body..e.end) else { return Cr3TrackKind::Other(e.kind) };
|
||||
let (Some(width), Some(height)) = (be16(body, 24), be16(body, 26)) else {
|
||||
return Cr3TrackKind::Other(e.kind);
|
||||
};
|
||||
let (mut cmp1, mut iad1, mut rendered) = (None, None, None);
|
||||
for c in boxes(bytes, e.body.saturating_add(CRAW_CHILDREN_AT), e.end, budget) {
|
||||
match &c.kind {
|
||||
b"JPEG" => rendered = Some(Cr3TrackKind::Jpeg),
|
||||
// HDR-capable cameras also put an HEVC preview in a CRAW entry,
|
||||
// often with the same displayed dimensions as the main raw track.
|
||||
b"HEVC" => rendered = Some(Cr3TrackKind::Other(c.kind)),
|
||||
b"CMP1" => cmp1 = Some((c.body, c.end - c.body)),
|
||||
b"CDI1" => {
|
||||
for area in boxes(bytes, c.body.saturating_add(4), c.end, budget) {
|
||||
if &area.kind == b"IAD1" {
|
||||
iad1 = Some((area.body, area.end - area.body));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// A present, malformed CMP1 must remain a raw descriptor so its corruption
|
||||
// is reported. Only an explicitly identified rendered codec can establish
|
||||
// that a CRAW entry without CMP1 is a preview rather than a broken raw.
|
||||
if cmp1.is_none()
|
||||
&& let Some(kind) = rendered
|
||||
{
|
||||
return kind;
|
||||
}
|
||||
Cr3TrackKind::Raw { width, height, cmp1, iad1 }
|
||||
}
|
||||
|
||||
/// A big-endian `u16` at `at`, or `None` when the buffer is shorter than that.
|
||||
pub(crate) 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.
|
||||
pub(crate) 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.
|
||||
pub(crate) 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))
|
||||
}
|
||||
|
||||
fn le16(b: &[u8], at: usize) -> Option<u16> {
|
||||
let s = b.get(at..at.checked_add(2)?)?;
|
||||
Some(u16::from_le_bytes([s[0], s[1]]))
|
||||
}
|
||||
|
||||
fn le32(b: &[u8], at: usize) -> Option<u32> {
|
||||
let s = b.get(at..at.checked_add(4)?)?;
|
||||
Some(u32::from_le_bytes([s[0], s[1], s[2], s[3]]))
|
||||
}
|
||||
|
||||
/// Merge `CMT1` (IFD0), `CMT2` (Exif) and `CMT4` (GPS) into one TIFF stream, so the Exif readers see a CR3
|
||||
/// like any TIFF-based raw. The maker note (`CMT3`) is left out: its offsets are relative to its own block.
|
||||
pub fn merged_exif(c: &Cr3<'_>) -> Option<Vec<u8>> {
|
||||
use craft_tiff::{IfdBuilder, Tiff, TiffWriter, tags as t};
|
||||
let ifd0 = Tiff::parse(c.cmt[0]?).ok()?;
|
||||
let order = ifd0.order;
|
||||
let first = |i: usize| c.cmt[i].and_then(|b| Tiff::parse(b).ok()).and_then(|t| t.ifds.into_iter().next());
|
||||
// pointers and offsets that would dangle once the IFDs move
|
||||
const SKIP: [u16; 9] = [t::EXIF_IFD, t::GPS_IFD, t::INTEROP_IFD, t::SUB_IFDS, t::MAKER_NOTE, 273, 279, 513, 514];
|
||||
let build = |ifd: &craft_tiff::Ifd| {
|
||||
let mut b = IfdBuilder::new();
|
||||
for e in ifd.entries.iter().filter(|e| !SKIP.contains(&e.tag)) {
|
||||
b.set(e.tag, e.value.clone());
|
||||
}
|
||||
b
|
||||
};
|
||||
let mut root = build(ifd0.ifds.first()?);
|
||||
if let Some(exif) = first(1) {
|
||||
root.set_child(t::EXIF_IFD, build(&exif));
|
||||
}
|
||||
if let Some(gps) = first(3) {
|
||||
root.set_child(t::GPS_IFD, build(&gps));
|
||||
}
|
||||
TiffWriter::new(order, false).write(&[root]).ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn bx(kind: &[u8; 4], body: &[u8]) -> Vec<u8> {
|
||||
let mut v = ((body.len() + 8) as u32).to_be_bytes().to_vec();
|
||||
v.extend_from_slice(kind);
|
||||
v.extend_from_slice(body);
|
||||
v
|
||||
}
|
||||
fn uuid(u: &[u8; 16], body: &[u8]) -> Vec<u8> {
|
||||
let mut b = u.to_vec();
|
||||
b.extend_from_slice(body);
|
||||
bx(b"uuid", &b)
|
||||
}
|
||||
fn full(kind: &[u8; 4], body: &[u8]) -> Vec<u8> {
|
||||
let mut b = vec![0u8; 4];
|
||||
b.extend_from_slice(body);
|
||||
bx(kind, &b)
|
||||
}
|
||||
fn craw(w: u16, h: u16, children: &[u8]) -> Vec<u8> {
|
||||
let mut b = vec![0u8; CRAW_CHILDREN_AT];
|
||||
b[24..26].copy_from_slice(&w.to_be_bytes());
|
||||
b[26..28].copy_from_slice(&h.to_be_bytes());
|
||||
b.extend_from_slice(children);
|
||||
bx(b"CRAW", &b)
|
||||
}
|
||||
fn trak(entry: Vec<u8>, offset: u64, size: u32) -> Vec<u8> {
|
||||
let mut stsd = 1u32.to_be_bytes().to_vec();
|
||||
stsd.extend(entry);
|
||||
let mut stsz = 0u32.to_be_bytes().to_vec();
|
||||
stsz.extend(1u32.to_be_bytes());
|
||||
stsz.extend(size.to_be_bytes());
|
||||
let mut co64 = 1u32.to_be_bytes().to_vec();
|
||||
co64.extend(offset.to_be_bytes());
|
||||
let stbl = [full(b"stsd", &stsd), full(b"stsz", &stsz), full(b"co64", &co64)].concat();
|
||||
bx(b"trak", &bx(b"mdia", &bx(b"minf", &bx(b"stbl", &stbl))))
|
||||
}
|
||||
fn tiff_with(tag: u16, v: craft_tiff::Value) -> Vec<u8> {
|
||||
let b = craft_tiff::IfdBuilder::new().with(tag, v);
|
||||
craft_tiff::TiffWriter::new(craft_tiff::ByteOrder::Little, false).write(&[b]).unwrap()
|
||||
}
|
||||
|
||||
/// A small synthetic CR3: Canon uuid with CMT1/CMT2/CMT4, a JPEG track, a raw track, XMP.
|
||||
pub(crate) fn sample() -> Vec<u8> {
|
||||
use craft_tiff::{Value, tags as t};
|
||||
let cmt1 = tiff_with(t::MODEL, Value::Ascii("Canon EOS Test".into()));
|
||||
let cmt2 = tiff_with(t::ISO_SPEED, Value::Short(vec![400]));
|
||||
let cmt4 = tiff_with(1, Value::Ascii("N".into()));
|
||||
let canon = uuid(&CANON_UUID, &[bx(b"CMT1", &cmt1), bx(b"CMT2", &cmt2), bx(b"CMT4", &cmt4), bx(b"THMB", b"thumb")].concat());
|
||||
let mut file = bx(b"ftyp", b"crx \0\0\0\x01crx isom");
|
||||
let payload_at = 4096u64;
|
||||
let jpeg_t = trak(craw(64, 48, &bx(b"JPEG", &[0; 4])), payload_at, 16);
|
||||
let raw_t = trak(craw(60, 40, &bx(b"CMP1", &[0xff, 0x10, 0, 0x30])), payload_at + 16, 32);
|
||||
file.extend(bx(b"moov", &[canon, jpeg_t, raw_t].concat()));
|
||||
file.extend(uuid(&XMP_UUID, b"<x:xmpmeta/>"));
|
||||
file.resize(payload_at as usize + 64, 0);
|
||||
file
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_canon_boxes_tracks_and_xmp() {
|
||||
let f = sample();
|
||||
assert!(is_cr3(&f));
|
||||
let c = parse_cr3(&f).unwrap();
|
||||
assert!(c.cmt[0].is_some() && c.cmt[1].is_some() && c.cmt[2].is_none() && c.cmt[3].is_some());
|
||||
assert_eq!(c.thumbnail, Some(b"thumb".as_slice()));
|
||||
assert_eq!(c.xmp, Some(b"<x:xmpmeta/>".as_slice()));
|
||||
assert_eq!(c.tracks.len(), 2);
|
||||
assert_eq!(c.tracks[0], Cr3Track { kind: Cr3TrackKind::Jpeg, data: Some((4096, 16)) });
|
||||
let Cr3TrackKind::Raw { width, height, cmp1: Some((at, len)), .. } = c.tracks[1].kind else { panic!("{:?}", c.tracks[1]) };
|
||||
assert_eq!((width, height, len), (60, 40, 4));
|
||||
assert_eq!(&f[at..at + 2], &[0xff, 0x10]);
|
||||
assert_eq!(c.tracks[1].data, Some((4112, 32)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hevc_preview_is_distinct_from_an_equal_size_raw_track() {
|
||||
let preview = trak(craw(6000, 4000, &bx(b"HEVC", &[0; 4])), 4096, 16);
|
||||
let reduced = trak(craw(1624, 1080, &bx(b"CMP1", &compression_payload(0))), 4112, 16);
|
||||
let sensor = trak(craw(6000, 4000, &bx(b"CMP1", &compression_payload(0))), 4128, 16);
|
||||
let mut file = bx(b"ftyp", b"crx \0\0\0\x01crx isom");
|
||||
file.extend(bx(b"moov", &[preview, reduced, sensor].concat()));
|
||||
file.resize(4144, 0);
|
||||
let container = parse_cr3(&file).unwrap();
|
||||
assert_eq!(container.tracks[0].kind, Cr3TrackKind::Other(*b"HEVC"));
|
||||
assert!(container.tracks[0].compression(&file).is_none());
|
||||
assert!(matches!(container.tracks[2].kind, Cr3TrackKind::Raw { width: 6000, height: 4000, cmp1: Some(_), .. }));
|
||||
assert_eq!(container.tracks[2].compression(&file).unwrap().width, 6888);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rendered_child_does_not_hide_a_malformed_raw_descriptor() {
|
||||
for codec in [b"JPEG", b"HEVC"] {
|
||||
for first in [true, false] {
|
||||
let rendered = bx(codec, &[0; 4]);
|
||||
let corrupt = bx(b"CMP1", &[0xff, 0]);
|
||||
let children = if first { [rendered, corrupt] } else { [corrupt, rendered] }.concat();
|
||||
let mut file = bx(b"ftyp", b"crx \0\0\0\x01crx isom");
|
||||
file.extend(bx(b"moov", &trak(craw(6000, 4000, &children), 4096, 16)));
|
||||
file.resize(4112, 0);
|
||||
let container = parse_cr3(&file).unwrap();
|
||||
assert!(matches!(container.tracks[0].kind, Cr3TrackKind::Raw { cmp1: Some(_), .. }));
|
||||
assert!(container.tracks[0].compression(&file).is_none());
|
||||
}
|
||||
}
|
||||
// Absence alone is not evidence of a preview: retain a broken raw so
|
||||
// main-track selection cannot quietly fall back to a reduced image.
|
||||
let mut file = bx(b"ftyp", b"crx \0\0\0\x01crx isom");
|
||||
file.extend(bx(b"moov", &trak(craw(6000, 4000, &bx(b"free", &[])), 4096, 16)));
|
||||
file.resize(4112, 0);
|
||||
assert!(matches!(parse_cr3(&file).unwrap().tracks[0].kind, Cr3TrackKind::Raw { cmp1: None, .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merged_exif_reads_like_a_tiff_raw() {
|
||||
let f = sample();
|
||||
let m = crate::read_exif(&merged_exif(&parse_cr3(&f).unwrap()).unwrap());
|
||||
assert_eq!(m.model.as_deref(), Some("Canon EOS Test"));
|
||||
assert_eq!(m.iso, Some(400));
|
||||
}
|
||||
|
||||
fn compression_payload(levels: u8) -> Vec<u8> {
|
||||
let mut b = vec![0u8; 52];
|
||||
b[0..2].copy_from_slice(&0xff00u16.to_be_bytes());
|
||||
b[2..4].copy_from_slice(&48u16.to_be_bytes());
|
||||
b[4..6].copy_from_slice(&0x100u16.to_be_bytes());
|
||||
for (at, value) in [(8, 6888u32), (12, 4546), (16, 3444), (20, 4546), (28, 216)] {
|
||||
b[at..at + 4].copy_from_slice(&value.to_be_bytes());
|
||||
}
|
||||
b[24] = 14;
|
||||
b[25] = 0x40;
|
||||
b[26] = levels;
|
||||
b
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compression_descriptor_retains_sensor_geometry_and_cfa() {
|
||||
let b = compression_payload(0);
|
||||
let h = Cr3Compression::parse(&b).unwrap();
|
||||
assert_eq!((h.width, h.height, h.tile_width, h.tile_height), (6888, 4546, 3444, 4546));
|
||||
assert_eq!((h.version, h.bit_depth, h.planes, h.cfa_pattern, h.encoding, h.levels, h.header_size), (0x100, 14, 4, 0, 0, 0, 216));
|
||||
let mut craw = compression_payload(3);
|
||||
craw[25] = 0x41;
|
||||
craw[27] = 0x80;
|
||||
let h = Cr3Compression::parse(&craw).unwrap();
|
||||
assert_eq!((h.cfa_pattern, h.levels, h.tile_flags), (1, 3, 0x80));
|
||||
for len in 0..b.len() {
|
||||
assert!(Cr3Compression::parse(&b[..len]).is_none());
|
||||
}
|
||||
for (at, value) in [(24, 0), (24, 17), (25, 0), (25, 0x44)] {
|
||||
let mut bad = b.clone();
|
||||
bad[at] = value;
|
||||
assert!(Cr3Compression::parse(&bad).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extended_descriptor_checks_optional_median_precision() {
|
||||
let mut b = compression_payload(0x30);
|
||||
b[32] = 0x80;
|
||||
b.extend_from_slice(&33u32.to_be_bytes());
|
||||
b.resize(85, 0);
|
||||
b[56] = 0xc0;
|
||||
b[84] = 14;
|
||||
assert_eq!(Cr3Compression::parse(&b).unwrap().median_bit_depth, Some(14));
|
||||
assert!(Cr3Compression::parse(&b[..84]).is_none());
|
||||
b[84] = 0;
|
||||
assert!(Cr3Compression::parse(&b).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_track_reads_distinct_active_area_and_default_crop() {
|
||||
let mut area = vec![0u8; 48];
|
||||
for (at, value) in [
|
||||
(4, 6888u16),
|
||||
(6, 4546),
|
||||
(10, 2),
|
||||
(16, 156),
|
||||
(18, 158),
|
||||
(20, 6875),
|
||||
(22, 4537),
|
||||
(24, 0),
|
||||
(26, 0),
|
||||
(28, 143),
|
||||
(30, 4545),
|
||||
(32, 144),
|
||||
(34, 0),
|
||||
(36, 6887),
|
||||
(38, 45),
|
||||
(40, 144),
|
||||
(42, 46),
|
||||
(44, 6887),
|
||||
(46, 4545),
|
||||
] {
|
||||
area[at..at + 2].copy_from_slice(&value.to_be_bytes());
|
||||
}
|
||||
let cmp1 = bx(b"CMP1", &compression_payload(0));
|
||||
let iad1 = full(b"CDI1", &bx(b"IAD1", &area));
|
||||
let track = trak(craw(6888, 4546, &[cmp1, iad1].concat()), 4096, 32);
|
||||
let mut file = bx(b"ftyp", b"crx \0\0\0\x01crx isom");
|
||||
file.extend(bx(b"moov", &track));
|
||||
file.resize(4128, 0);
|
||||
let c = parse_cr3(&file).unwrap();
|
||||
assert_eq!(c.tracks[0].compression(&file).unwrap().width, 6888);
|
||||
let geometry = c.tracks[0].image_area(&file).unwrap();
|
||||
assert_eq!(geometry.crop, [156, 158, 6875, 4537]);
|
||||
assert_eq!(geometry.active, Some([144, 46, 6887, 4545]));
|
||||
assert_eq!(geometry.masked_left, [0, 0, 143, 4545]);
|
||||
for len in 0..area.len() {
|
||||
assert!(Cr3ImageArea::parse(&area[..len]).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_sample_table_does_not_read_the_next_box() {
|
||||
let stbl = [
|
||||
full(b"stsd", &[1u32.to_be_bytes().to_vec(), craw(16, 16, &bx(b"CMP1", &compression_payload(0)))].concat()),
|
||||
bx(b"stsz", &[]),
|
||||
full(b"co64", &[1u32.to_be_bytes().to_vec(), 256u64.to_be_bytes().to_vec()].concat()),
|
||||
]
|
||||
.concat();
|
||||
let mut file = bx(b"ftyp", b"crx \0\0\0\x01crx isom");
|
||||
file.extend(bx(b"moov", &bx(b"trak", &bx(b"mdia", &bx(b"minf", &bx(b"stbl", &stbl))))));
|
||||
file.resize(512, 0);
|
||||
assert!(parse_cr3(&file).unwrap().tracks.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timed_metadata_exposes_the_dynamic_maker_note() {
|
||||
use craft_tiff::{Value, tags as t};
|
||||
let exif = tiff_with(t::ISO_SPEED, Value::Short(vec![800]));
|
||||
let maker = tiff_with(0x4001, Value::Short(vec![48, 100, 200]));
|
||||
let item = |tag: u32, data: &[u8]| {
|
||||
let mut b = ((data.len() + 8) as u32).to_le_bytes().to_vec();
|
||||
b.extend_from_slice(&tag.to_le_bytes());
|
||||
b.extend_from_slice(data);
|
||||
b
|
||||
};
|
||||
let items = [item(0x8769, &exif), item(0x927c, &maker)].concat();
|
||||
let mut sample = vec![0u8; 12];
|
||||
sample[0..4].copy_from_slice(&((12 + items.len()) as u32).to_le_bytes());
|
||||
sample[4..6].copy_from_slice(&8u16.to_le_bytes());
|
||||
sample.extend(items);
|
||||
let container = Cr3 { tracks: vec![Cr3Track { kind: Cr3TrackKind::Other(*b"CTMD"), data: Some((0, sample.len())) }], ..Default::default() };
|
||||
let parts = container.timed_exif(&sample);
|
||||
assert_eq!(parts.len(), 2);
|
||||
assert_eq!((parts[0].record_type, parts[0].tag, parts[0].data), (8, 0x8769, exif.as_slice()));
|
||||
assert_eq!((parts[1].record_type, parts[1].tag, parts[1].data), (8, 0x927c, maker.as_slice()));
|
||||
for len in 0..sample.len() {
|
||||
assert!(container.timed_exif(&sample[..len]).is_empty());
|
||||
}
|
||||
for at in [0, 12] {
|
||||
let mut corrupt = sample.clone();
|
||||
corrupt[at..at + 4].copy_from_slice(&u32::MAX.to_le_bytes());
|
||||
assert!(container.timed_exif(&corrupt).is_empty());
|
||||
corrupt[at..at + 4].copy_from_slice(&0u32.to_le_bytes());
|
||||
assert!(container.timed_exif(&corrupt).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_input_never_panics() {
|
||||
let f = sample();
|
||||
// every truncation and single-byte corruption parses to something or nothing
|
||||
for n in 0..f.len().min(800) {
|
||||
let _ = parse_cr3(&f[..n]).map(|c| merged_exif(&c));
|
||||
}
|
||||
for i in 0..f.len().min(800) {
|
||||
for v in [0u8, 1, 0x7f, 0xff] {
|
||||
let mut g = f.clone();
|
||||
g[i] = v;
|
||||
let _ = parse_cr3(&g).map(|c| merged_exif(&c));
|
||||
}
|
||||
}
|
||||
// a sample offset past the end is not reported
|
||||
let mut g = f.clone();
|
||||
g.truncate(4100);
|
||||
assert!(parse_cr3(&g).unwrap().tracks.iter().all(|t| t.data.is_none()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
//! Capture date/time with optional UTC offset (Exif `DateTimeOriginal` + `OffsetTimeOriginal`, ISO 8601 in XMP).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A civil date-time as recorded by the camera; `offset_minutes` is the UTC offset when known.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
pub struct DateTime {
|
||||
pub year: u16,
|
||||
pub month: u8,
|
||||
pub day: u8,
|
||||
pub hour: u8,
|
||||
pub minute: u8,
|
||||
pub second: u8,
|
||||
/// Sub-second part in milliseconds.
|
||||
pub millis: u16,
|
||||
pub offset_minutes: Option<i16>,
|
||||
}
|
||||
|
||||
fn num(s: &str) -> Option<u32> {
|
||||
if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
s.parse().ok()
|
||||
}
|
||||
|
||||
impl DateTime {
|
||||
fn valid(self) -> Option<Self> {
|
||||
// A timestamp must represent a real Gregorian calendar day, not just
|
||||
// numbers in the range 1..=31. Invalid dates would otherwise be
|
||||
// accepted from EXIF/XMP and silently normalized during time arithmetic.
|
||||
// chrono rejects impossible days (leap years and all); the time is checked
|
||||
// by hand so a leap second (`second == 60`) stays acceptable.
|
||||
let day_ok = self.year > 0 && chrono::NaiveDate::from_ymd_opt(i32::from(self.year), u32::from(self.month), u32::from(self.day)).is_some();
|
||||
let ok = day_ok && self.hour < 24 && self.minute < 60 && self.second < 61 && self.offset_minutes.is_none_or(|o| o.unsigned_abs() <= 18 * 60);
|
||||
ok.then_some(self)
|
||||
}
|
||||
|
||||
/// Exif form `YYYY:MM:DD HH:MM:SS` (also tolerates `-` date separators and a missing time).
|
||||
pub fn parse_exif(s: &str) -> Option<DateTime> {
|
||||
let s = s.trim().trim_end_matches('\0');
|
||||
let (d, t) = s.split_once([' ', 'T']).unwrap_or((s, ""));
|
||||
let dp: Vec<&str> = d.split([':', '-']).collect();
|
||||
if dp.len() != 3 {
|
||||
return None;
|
||||
}
|
||||
let (hour, minute, second) = if t.is_empty() {
|
||||
(0, 0, 0)
|
||||
} else {
|
||||
let mut parts = t.split(':');
|
||||
let hour = num(parts.next()?.trim())?;
|
||||
let minute = parts.next().map(|part| num(part.trim())).unwrap_or(Some(0))?;
|
||||
let second = parts.next().map(|part| num(part.trim())).unwrap_or(Some(0))?;
|
||||
// A fourth value cannot be represented; accepting only the prefix could turn a
|
||||
// malformed DateTimeOriginal into a plausible capture time and block fallbacks.
|
||||
if parts.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
(hour, minute, second)
|
||||
};
|
||||
DateTime {
|
||||
year: num(dp[0])?.try_into().ok()?,
|
||||
month: num(dp[1])?.try_into().ok()?,
|
||||
day: num(dp[2])?.try_into().ok()?,
|
||||
hour: hour.try_into().ok()?,
|
||||
minute: minute.try_into().ok()?,
|
||||
second: second.try_into().ok()?,
|
||||
millis: 0,
|
||||
offset_minutes: None,
|
||||
}
|
||||
.valid()
|
||||
}
|
||||
|
||||
/// Exif `OffsetTime*` form `+HH:MM` / `-HH:MM` (or `Z`).
|
||||
pub fn parse_offset(s: &str) -> Option<i16> {
|
||||
let s = s.trim().trim_end_matches('\0');
|
||||
if s == "Z" {
|
||||
return Some(0);
|
||||
}
|
||||
let (sign, rest) = match s.as_bytes().first()? {
|
||||
b'+' => (1, &s[1..]),
|
||||
b'-' => (-1, &s[1..]),
|
||||
_ => return None,
|
||||
};
|
||||
let (h, m) = match rest.split_once(':') {
|
||||
Some((h, m)) => (num(h)?, num(m)?),
|
||||
None if rest.len() == 4 => (num(rest.get(..2)?)?, num(rest.get(2..)?)?),
|
||||
None => (num(rest)?, 0),
|
||||
};
|
||||
if h > 18 || m > 59 || (h == 18 && m > 0) {
|
||||
return None;
|
||||
}
|
||||
Some(sign * (h * 60 + m) as i16)
|
||||
}
|
||||
|
||||
/// Set the sub-second part from an Exif `SubSecTime*` string (digits are a decimal fraction).
|
||||
pub fn with_subsec(mut self, s: &str) -> DateTime {
|
||||
let digits: String = s.trim().chars().take_while(|c| c.is_ascii_digit()).take(3).collect();
|
||||
if !digits.is_empty() {
|
||||
let v: u16 = digits.parse().unwrap_or(0);
|
||||
self.millis = v * 10u16.pow(3 - digits.len() as u32);
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// ISO 8601 (XMP date): `YYYY`, `YYYY-MM`, `YYYY-MM-DD`, `YYYY-MM-DDThh:mm[:ss[.s+]][TZD]`.
|
||||
pub fn parse_iso(s: &str) -> Option<DateTime> {
|
||||
let s = s.trim();
|
||||
let (d, t) = match s.split_once('T') {
|
||||
Some((d, t)) if !t.is_empty() => (d, t),
|
||||
Some(_) => return None,
|
||||
None => (s, ""),
|
||||
};
|
||||
let dp: Vec<&str> = d.split('-').collect();
|
||||
let year = num(dp.first()?)?.try_into().ok()?;
|
||||
let month = dp.get(1).map(|m| num(m)).unwrap_or(Some(1))?.try_into().ok()?;
|
||||
let day = dp.get(2).map(|m| num(m)).unwrap_or(Some(1))?.try_into().ok()?;
|
||||
if dp.len() > 3 {
|
||||
return None;
|
||||
}
|
||||
let mut dt = DateTime { year, month, day, ..Default::default() };
|
||||
if !t.is_empty() {
|
||||
let (clock, off) = match t.find(['Z', '+', '-']) {
|
||||
Some(i) => (&t[..i], Some(&t[i..])),
|
||||
None => (t, None),
|
||||
};
|
||||
let mut cp = clock.split(':');
|
||||
dt.hour = num(cp.next()?)?.try_into().ok()?;
|
||||
dt.minute = num(cp.next()?)?.try_into().ok()?;
|
||||
if let Some(sec) = cp.next() {
|
||||
let (whole, fraction) = match sec.split_once('.') {
|
||||
Some((whole, fraction)) => {
|
||||
if fraction.is_empty() || !fraction.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
(whole, Some(fraction))
|
||||
}
|
||||
None => (sec, None),
|
||||
};
|
||||
dt.second = num(whole)?.try_into().ok()?;
|
||||
if let Some(fraction) = fraction {
|
||||
dt = dt.with_subsec(fraction);
|
||||
}
|
||||
}
|
||||
if cp.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
if let Some(o) = off {
|
||||
dt.offset_minutes = Some(Self::parse_offset(o)?);
|
||||
}
|
||||
}
|
||||
dt.valid()
|
||||
}
|
||||
|
||||
/// Exif form `YYYY:MM:DD HH:MM:SS`.
|
||||
pub fn to_exif(&self) -> String {
|
||||
format!("{:04}:{:02}:{:02} {:02}:{:02}:{:02}", self.year, self.month, self.day, self.hour, self.minute, self.second)
|
||||
}
|
||||
|
||||
/// ISO 8601 with milliseconds when non-zero and the offset when known.
|
||||
pub fn to_iso(&self) -> String {
|
||||
let mut s = format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}", self.year, self.month, self.day, self.hour, self.minute, self.second);
|
||||
if self.millis != 0 {
|
||||
s += &format!(".{:03}", self.millis);
|
||||
}
|
||||
if let Some(o) = self.offset_minutes {
|
||||
let sign = if o < 0 { '-' } else { '+' };
|
||||
let o = o.unsigned_abs();
|
||||
s += &format!("{sign}{:02}:{:02}", o / 60, o % 60);
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Seconds since 1970-01-01T00:00:00 of the civil time (UTC when the offset is known, else "local" seconds).
|
||||
pub fn unix_seconds(&self) -> i64 {
|
||||
// Every `DateTime` reaching here is a real calendar day (`valid()` ran in the parsers);
|
||||
// an out-of-range one (built by hand) reads as the epoch rather than panicking.
|
||||
let days = chrono::NaiveDate::from_ymd_opt(i32::from(self.year), u32::from(self.month), u32::from(self.day))
|
||||
.zip(chrono::NaiveDate::from_ymd_opt(1970, 1, 1))
|
||||
.map(|(d, epoch)| (d - epoch).num_days())
|
||||
.unwrap_or(0);
|
||||
days * 86400 + self.hour as i64 * 3600 + self.minute as i64 * 60 + self.second as i64 - self.offset_minutes.unwrap_or(0) as i64 * 60
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn exif_forms() {
|
||||
let d = DateTime::parse_exif("2024:06:01 13:45:59").unwrap();
|
||||
assert_eq!((d.year, d.month, d.day, d.hour, d.minute, d.second), (2024, 6, 1, 13, 45, 59));
|
||||
assert_eq!(d.to_exif(), "2024:06:01 13:45:59");
|
||||
assert!(DateTime::parse_exif("0000:00:00 00:00:00").is_none());
|
||||
assert!(DateTime::parse_exif(" : : : : ").is_none());
|
||||
assert!(DateTime::parse_exif("2024:13:01 00:00:00").is_none());
|
||||
// Invalid calendar days must never enter persisted capture metadata.
|
||||
for value in ["2023:02:29", "2024:02:30", "2024:04:31", "1900:02:29", "2024:01:00"] {
|
||||
assert!(DateTime::parse_exif(value).is_none(), "{value}");
|
||||
}
|
||||
assert!(DateTime::parse_exif("2024:02:29").is_some());
|
||||
assert!(DateTime::parse_exif("2000:02:29").is_some());
|
||||
for value in ["2023-02-29", "2024-02-30", "1900-02-29", "2024-06-31T10:00:00Z"] {
|
||||
assert!(DateTime::parse_iso(value).is_none(), "{value}");
|
||||
}
|
||||
assert!(DateTime::parse_iso("2000-02-29T23:59:59Z").is_some());
|
||||
assert_eq!(DateTime::parse_exif("2024-06-01").unwrap().hour, 0);
|
||||
assert_eq!(DateTime::parse_exif("2024:06:01 13:45:59").unwrap().with_subsec("5").millis, 500);
|
||||
assert_eq!(DateTime::parse_exif("2024:06:01 13:45:59").unwrap().with_subsec("0123").millis, 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exif_clock_rejects_extra_components() {
|
||||
for value in
|
||||
["2026:01:01 12:34:56:99", "2026:01:01 12:34:56:junk", "2026:01:01 12:34:56:", "2026:01:01 12:34:56::", "2026:01:01 12:34:56:99:junk"]
|
||||
{
|
||||
assert!(DateTime::parse_exif(value).is_none(), "accepted malformed EXIF clock {value:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exif_clock_supported_forms_keep_their_meaning() {
|
||||
for (value, expected) in [
|
||||
("2026:01:01", "2026-01-01T00:00:00"),
|
||||
("2026:01:01 12", "2026-01-01T12:00:00"),
|
||||
("2026:01:01 12:34", "2026-01-01T12:34:00"),
|
||||
("2026:01:01 12:34:56", "2026-01-01T12:34:56"),
|
||||
("2026-01-01T12:34:56", "2026-01-01T12:34:56"),
|
||||
(" 2026:01:01 12:34:56\0", "2026-01-01T12:34:56"),
|
||||
("2026:01:01T12:34:60", "2026-01-01T12:34:60"),
|
||||
] {
|
||||
assert_eq!(DateTime::parse_exif(value).map(|date| date.to_iso()), Some(expected.to_string()), "{value:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offsets() {
|
||||
assert_eq!(DateTime::parse_offset("+02:00"), Some(120));
|
||||
assert_eq!(DateTime::parse_offset("-05:30"), Some(-330));
|
||||
assert_eq!(DateTime::parse_offset("Z"), Some(0));
|
||||
assert_eq!(DateTime::parse_offset("+0930"), Some(570));
|
||||
assert_eq!(DateTime::parse_offset("+18:00"), Some(1080));
|
||||
assert_eq!(DateTime::parse_offset("-18:00"), Some(-1080));
|
||||
assert_eq!(DateTime::parse_offset("+18:01"), None);
|
||||
assert_eq!(DateTime::parse_offset("-18:01"), None);
|
||||
assert_eq!(DateTime::parse_offset(" : "), None);
|
||||
assert_eq!(DateTime::parse_offset("+99:00"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extreme_offsets_do_not_overflow() {
|
||||
// `DateTime` deserializes with public fields, so an offset can arrive without passing `valid()`
|
||||
let d = DateTime { offset_minutes: Some(i16::MIN), ..DateTime::parse_iso("2024-06-01T13:45:59").unwrap() };
|
||||
assert_eq!(d.to_iso(), "2024-06-01T13:45:59-546:08");
|
||||
assert_eq!(d.valid(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_offsets_are_rejected_without_panicking() {
|
||||
for offset in ["+1€", "-1€", "+€1", "-€1"] {
|
||||
assert_eq!(DateTime::parse_offset(offset), None);
|
||||
let date = format!("2024-06-01T13:45:59{offset}");
|
||||
let metadata = crate::Metadata { capture_time: DateTime::parse_iso("2024-06-01T13:45:59Z"), ..Default::default() };
|
||||
let xmp = crate::write_xmp(&metadata).replace("2024-06-01T13:45:59+00:00", &date);
|
||||
assert!(crate::parse_xmp(&xmp).unwrap().metadata.capture_time.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iso_roundtrip() {
|
||||
for s in ["2024-06-01T13:45:59", "2024-06-01T13:45:59.250+02:00", "1999-12-31T23:59:00-05:30", "2024-02-29T00:00:00Z"] {
|
||||
let d = DateTime::parse_iso(s).unwrap();
|
||||
let again = DateTime::parse_iso(&d.to_iso()).unwrap();
|
||||
assert_eq!(d, again, "{s}");
|
||||
}
|
||||
assert_eq!(DateTime::parse_iso("2024-02-29T00:00:00Z").unwrap().to_iso(), "2024-02-29T00:00:00+00:00");
|
||||
let d = DateTime::parse_iso("2019").unwrap();
|
||||
assert_eq!((d.year, d.month, d.day), (2019, 1, 1));
|
||||
assert_eq!(DateTime::parse_iso("2019-07-04T10:11").unwrap().minute, 11);
|
||||
assert!(DateTime::parse_iso("nonsense").is_none());
|
||||
assert!(DateTime::parse_iso("2019-07-04Tab:cd").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iso_rejects_incomplete_or_unconsumed_clock_text() {
|
||||
for value in [
|
||||
"2026-01-01T",
|
||||
"2026-01-01T12:34:56:99Z",
|
||||
"2026-01-01T12:34:56.Z",
|
||||
"2026-01-01T12:34:56.123junkZ",
|
||||
"2026-01-01T12:34:56.1.2Z",
|
||||
"2026-01-01T12:34:56.12 3Z",
|
||||
"2026-01-01T12:34:56:99Z",
|
||||
"2026-01-01T12::34Z",
|
||||
] {
|
||||
assert!(DateTime::parse_iso(value).is_none(), "accepted malformed timestamp {value:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iso_validates_clock_while_preserving_supported_forms() {
|
||||
assert_eq!(DateTime::parse_iso("2019").unwrap().to_iso(), "2019-01-01T00:00:00");
|
||||
assert_eq!(DateTime::parse_iso("2019-07").unwrap().to_iso(), "2019-07-01T00:00:00");
|
||||
assert_eq!(DateTime::parse_iso("2019-07-04").unwrap().to_iso(), "2019-07-04T00:00:00");
|
||||
assert_eq!(DateTime::parse_iso("2019-07-04T10:11").unwrap().to_iso(), "2019-07-04T10:11:00");
|
||||
assert_eq!(DateTime::parse_iso("2019-07-04T10:11:12").unwrap().to_iso(), "2019-07-04T10:11:12");
|
||||
assert_eq!(DateTime::parse_iso("2019-07-04T10:11:12Z").unwrap().offset_minutes, Some(0));
|
||||
assert_eq!(DateTime::parse_iso("2019-07-04T10:11:12-05:30").unwrap().offset_minutes, Some(-330));
|
||||
assert_eq!(DateTime::parse_iso("2019-07-04T10:11:12.123456Z").unwrap().millis, 123);
|
||||
assert_eq!(DateTime::parse_iso(" 2019-07-04T10:11:12.5Z ").unwrap().millis, 500);
|
||||
assert_eq!(DateTime::parse_iso("2019-07-04T10:11:60Z").unwrap().second, 60);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unix() {
|
||||
assert_eq!(DateTime::parse_iso("1970-01-01T00:00:00Z").unwrap().unix_seconds(), 0);
|
||||
assert_eq!(DateTime::parse_iso("2000-03-01T00:00:00Z").unwrap().unix_seconds(), 951868800);
|
||||
assert_eq!(DateTime::parse_iso("2000-03-01T02:00:00+02:00").unwrap().unix_seconds(), 951868800);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,759 @@
|
||||
//! Exif / TIFF tags → [`Metadata`] (CIPA DC-008 Exif 2.32, TIFF 6.0, DNG 1.7 tag semantics).
|
||||
|
||||
use crate::{DateTime, Flash, Gps, Metadata, Orientation};
|
||||
use craft_tiff::{Ifd, ParseOptions, Tiff, TiffError, tags as t};
|
||||
|
||||
/// Strip an `Exif\0\0` (or `Exif\0\xff`) prefix as found in JPEG APP1 and some WebP `EXIF` chunks.
|
||||
pub fn strip_exif_header(b: &[u8]) -> &[u8] {
|
||||
if b.len() >= 6 && &b[..4] == b"Exif" && b[4] == 0 { &b[6..] } else { b }
|
||||
}
|
||||
|
||||
/// Read metadata from a TIFF-structured Exif block (a whole TIFF/DNG/raw file, or the payload of a JPEG APP1
|
||||
/// Exif segment with or without the `Exif\0\0` prefix). Malformed input yields whatever could be read.
|
||||
pub fn read_exif(bytes: &[u8]) -> Metadata {
|
||||
try_read_exif(bytes).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Like [`read_exif`] but reports a structurally invalid TIFF header / IFD0.
|
||||
pub fn try_read_exif(bytes: &[u8]) -> Result<Metadata, TiffError> {
|
||||
try_read_exif_with(bytes, &ParseOptions::default())
|
||||
}
|
||||
|
||||
/// [`try_read_exif`] with the caller's parser options (its value allocation budget in particular).
|
||||
/// A decoder working from a sparse buffer — only selected ranges of the logical file are actually
|
||||
/// loaded, the rest is zeros — must not size its allocations by the logical file length, which
|
||||
/// unrelated bytes of the file can inflate at no cost to the attacker.
|
||||
pub fn try_read_exif_with(bytes: &[u8], opts: &ParseOptions) -> Result<Metadata, TiffError> {
|
||||
let data = strip_exif_header(bytes);
|
||||
let tiff = Tiff::parse_with(data, opts)?;
|
||||
let mut m = from_tiff(&tiff);
|
||||
// Panasonic (and some Canons) write no LensModel, only the name in their maker note
|
||||
if m.lens_model.is_none() {
|
||||
m.lens_model = panasonic_maker_note_lens(data, &tiff).or_else(|| canon_maker_note_lens(data, &tiff));
|
||||
}
|
||||
// the maker note's values are offsets into the stream, which `from_tiff` doesn't have
|
||||
m.focus = crate::focus::read(data, &tiff, m.make.as_deref(), m.model.as_deref(), m.orientation);
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
fn text(ifd: Option<&Ifd>, tag: u16) -> Option<String> {
|
||||
let s = ifd?.string(tag)?;
|
||||
let s = s.trim().to_string();
|
||||
(!s.is_empty() && !s.bytes().all(|b| b == b' ' || b == 0)).then_some(s)
|
||||
}
|
||||
|
||||
fn pos(v: Option<f64>) -> Option<f64> {
|
||||
v.filter(|x| x.is_finite() && *x > 0.0)
|
||||
}
|
||||
|
||||
/// The orientation the Exif's own first IFD states. [`from_tiff`] also reports one that only the XMP packet in
|
||||
/// the Exif's tag 700 states; a viewer displays a photo by the Exif's alone, so that one can't tell how a photo
|
||||
/// is stored.
|
||||
pub(crate) fn ifd0_orientation(bytes: &[u8]) -> Option<Orientation> {
|
||||
let tiff = Tiff::parse_with(strip_exif_header(bytes), &ParseOptions::default()).ok()?;
|
||||
tiff.ifds.first().and_then(|i| i.u16(t::ORIENTATION)).filter(|v| (1..=8).contains(v)).map(Orientation::from_exif)
|
||||
}
|
||||
|
||||
/// Build [`Metadata`] from a parsed TIFF stream (IFD0 + Exif + GPS; DNG tags as fallbacks).
|
||||
pub fn from_tiff(tiff: &Tiff) -> Metadata {
|
||||
let ifd0 = tiff.ifds.first();
|
||||
let exif = tiff.exif();
|
||||
let gps = tiff.gps();
|
||||
let either = |tag: u16| exif.and_then(|e| e.value(tag)).or_else(|| ifd0.and_then(|i| i.value(tag)));
|
||||
let mut m = Metadata {
|
||||
make: text(ifd0, t::MAKE),
|
||||
model: text(ifd0, t::MODEL).or_else(|| text(ifd0, t::UNIQUE_CAMERA_MODEL)),
|
||||
software: text(ifd0, t::SOFTWARE),
|
||||
artist: text(ifd0, t::ARTIST),
|
||||
copyright: text(ifd0, t::COPYRIGHT),
|
||||
caption: text(ifd0, t::IMAGE_DESCRIPTION),
|
||||
serial_number: text(exif, t::BODY_SERIAL_NUMBER).or_else(|| text(ifd0, t::CAMERA_SERIAL_NUMBER)),
|
||||
lens_make: text(exif, t::LENS_MAKE),
|
||||
lens_model: text(exif, t::LENS_MODEL),
|
||||
lens_serial_number: text(exif, t::LENS_SERIAL_NUMBER),
|
||||
..Default::default()
|
||||
};
|
||||
// Exif requires LensSpecification/LensInfo as 4 rationals.
|
||||
let spec = exif.and_then(|e| e.f64s(t::LENS_SPECIFICATION)).or_else(|| ifd0.and_then(|i| i.f64s(t::LENS_INFO)));
|
||||
if let Some(s) = spec.filter(|s| s.len() == 4 && s[0] > 0.0) {
|
||||
m.lens_spec = Some([s[0], s[1], s[2], s[3]]);
|
||||
}
|
||||
// Nikon DSLRs write neither LensModel nor LensSpecification, only the same four values in their maker note
|
||||
if m.lens_spec.is_none() {
|
||||
m.lens_spec = nikon_lens_spec(exif, m.make.as_deref(), tiff.order);
|
||||
}
|
||||
m.orientation = ifd0.and_then(|i| i.u16(t::ORIENTATION)).filter(|v| (1..=8).contains(v)).map(Orientation::from_exif);
|
||||
m.rating = ifd0.and_then(|i| i.i64(t::RATING)).map(|r| r.clamp(-1, 5) as i8);
|
||||
|
||||
m.exposure_time = pos(either(t::EXPOSURE_TIME).and_then(|v| v.get_f64(0)))
|
||||
.or_else(|| exif.and_then(|e| e.f64(t::SHUTTER_SPEED_VALUE)).filter(|v| v.abs() < 64.0).map(|tv| 2f64.powf(-tv)));
|
||||
m.f_number = pos(either(t::F_NUMBER).and_then(|v| v.get_f64(0)))
|
||||
.or_else(|| exif.and_then(|e| e.f64(t::APERTURE_VALUE)).filter(|v| v.abs() < 64.0).map(|av| 2f64.powf(av / 2.0)));
|
||||
let iso = either(t::ISO_SPEED).and_then(|v| v.get_u64(0)).filter(|&v| v > 0);
|
||||
let iso_ext = exif.and_then(|e| e.u64(t::RECOMMENDED_EXPOSURE_INDEX).or_else(|| e.u64(t::ISO_SPEED_RATINGS_EXT))).filter(|&v| v > 0);
|
||||
m.iso = match (iso, iso_ext) {
|
||||
(Some(65535), Some(x)) | (None, Some(x)) => Some(x),
|
||||
(Some(v), _) => Some(v),
|
||||
_ => None,
|
||||
}
|
||||
.map(|v| v.min(u32::MAX as u64) as u32);
|
||||
m.focal_length = pos(either(t::FOCAL_LENGTH).and_then(|v| v.get_f64(0)));
|
||||
m.focal_length_35mm = pos(exif.and_then(|e| e.f64(t::FOCAL_LENGTH_35MM)));
|
||||
m.exposure_bias = exif.and_then(|e| e.f64(t::EXPOSURE_BIAS)).filter(|v| v.is_finite() && v.abs() < 100.0);
|
||||
m.exposure_program = exif.and_then(|e| e.u16(t::EXPOSURE_PROGRAM));
|
||||
m.metering_mode = exif.and_then(|e| e.u16(t::METERING_MODE));
|
||||
m.white_balance = exif.and_then(|e| e.u16(t::WHITE_BALANCE));
|
||||
m.flash = exif.and_then(|e| e.u16(t::FLASH)).map(|raw| Flash { fired: raw & 1 != 0, raw });
|
||||
|
||||
// capture time: DateTimeOriginal, then DateTimeDigitized, then IFD0 DateTime
|
||||
let candidates = [
|
||||
(exif, t::DATE_TIME_ORIGINAL, t::OFFSET_TIME_ORIGINAL, t::SUBSEC_TIME_ORIGINAL),
|
||||
(exif, t::DATE_TIME_DIGITIZED, t::OFFSET_TIME_DIGITIZED, t::SUBSEC_TIME_DIGITIZED),
|
||||
(ifd0, t::DATE_TIME, t::OFFSET_TIME, t::SUBSEC_TIME),
|
||||
];
|
||||
for (ifd, dt_tag, off_tag, sub_tag) in candidates {
|
||||
if let Some(mut dt) = text(ifd, dt_tag).and_then(|s| DateTime::parse_exif(&s)) {
|
||||
if let Some(s) = text(exif, sub_tag) {
|
||||
dt = dt.with_subsec(&s);
|
||||
}
|
||||
dt.offset_minutes = text(exif, off_tag).and_then(|s| DateTime::parse_offset(&s));
|
||||
m.capture_time = Some(dt);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
m.width = exif.and_then(|e| e.u32(t::PIXEL_X_DIMENSION)).filter(|&v| v > 0).or_else(|| ifd0.and_then(|i| i.u32(t::IMAGE_WIDTH)));
|
||||
m.height = exif.and_then(|e| e.u32(t::PIXEL_Y_DIMENSION)).filter(|&v| v > 0).or_else(|| ifd0.and_then(|i| i.u32(t::IMAGE_LENGTH)));
|
||||
|
||||
m.gps = gps.and_then(read_gps);
|
||||
if let Some(iptc) = ifd0.and_then(|i| i.value(t::IPTC_NAA)) {
|
||||
// IPTC-NAA is often typed LONG; re-serialise to bytes in file order.
|
||||
let bytes = match iptc {
|
||||
craft_tiff::Value::Byte(b) | craft_tiff::Value::Undefined(b) => b.clone(),
|
||||
other => craft_tiff::writer::encode(tiff.order, other),
|
||||
};
|
||||
m.fill_missing(&crate::parse_iptc(&bytes));
|
||||
}
|
||||
if let Some(x) = ifd0.and_then(|i| i.bytes(t::XMP)).map(String::from_utf8_lossy)
|
||||
&& let Ok(x) = crate::parse_xmp(&x)
|
||||
{
|
||||
// (the size stays the one Exif states: `extract` turns regions by it, and a size only the XMP states may be the upright one)
|
||||
let size = (m.width, m.height);
|
||||
m.overlay_user_fields(&x.metadata);
|
||||
m.fill_missing(&x.metadata);
|
||||
(m.width, m.height) = size;
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
/// Degrees/minutes/seconds triplet as decimal degrees. A component that is present but not a number (a rational
|
||||
/// with a zero denominator, as in the placeholder GPS block phones write without a fix) makes the whole value unusable.
|
||||
fn dms(ifd: &Ifd, tag: u16) -> Option<f64> {
|
||||
let v = ifd.value(tag)?;
|
||||
let part = |i: usize| if i < v.count() { v.get_f64(i) } else { Some(0.0) };
|
||||
let d = v.get_f64(0)?;
|
||||
let deg = d + part(1)? / 60.0 + part(2)? / 3600.0;
|
||||
deg.is_finite().then_some(deg)
|
||||
}
|
||||
|
||||
/// `Some(true)` for the negative hemisphere (`neg`), `Some(false)` for `pos`, `None` when the ref is missing or invalid.
|
||||
fn hemisphere(g: &Ifd, tag: u16, pos: char, neg: char) -> Option<bool> {
|
||||
match g.string(tag)?.trim_start().chars().next()?.to_ascii_uppercase() {
|
||||
c if c == neg => Some(true),
|
||||
c if c == pos => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn read_gps(g: &Ifd) -> Option<Gps> {
|
||||
let mut lat = dms(g, t::GPS_LATITUDE)?;
|
||||
let mut lon = dms(g, t::GPS_LONGITUDE)?;
|
||||
if hemisphere(g, t::GPS_LATITUDE_REF, 'N', 'S')? {
|
||||
lat = -lat;
|
||||
}
|
||||
if hemisphere(g, t::GPS_LONGITUDE_REF, 'E', 'W')? {
|
||||
lon = -lon;
|
||||
}
|
||||
if !(-90.0..=90.0).contains(&lat) || !(-180.0..=180.0).contains(&lon) {
|
||||
return None;
|
||||
}
|
||||
let altitude = g.f64(t::GPS_ALTITUDE).filter(|a| a.is_finite()).map(|a| if g.u32(t::GPS_ALTITUDE_REF) == Some(1) { -a } else { a });
|
||||
Some(Gps { latitude: lat, longitude: lon, altitude })
|
||||
}
|
||||
|
||||
/// Panasonic maker note tag `0x0051` (LensType in the ExifTool Panasonic tag names): the lens name, ASCII.
|
||||
const PANASONIC_LENS_TYPE: u16 = 0x0051;
|
||||
/// RW2/RWL IFD0 tag `0x002E` (JpgFromRaw): a JPEG whose APP1 holds the full Exif, maker note included.
|
||||
const PANASONIC_JPG_FROM_RAW: u16 = 0x002E;
|
||||
|
||||
/// The lens name in the Panasonic maker note (tag `0x0051`) of this Exif or RW2 stream (`data` starts at its
|
||||
/// TIFF header): the note of its own Exif IFD, else, for a raw, the note in its JpgFromRaw's Exif.
|
||||
pub(crate) fn panasonic_maker_note_lens(data: &[u8], tiff: &Tiff) -> Option<String> {
|
||||
panasonic_lens(data, tiff).or_else(|| panasonic_jpg_from_raw_lens(tiff))
|
||||
}
|
||||
|
||||
/// The lens name in a `Panasonic\0` maker note of this Exif stream (`data` starts at its TIFF header).
|
||||
fn panasonic_lens(data: &[u8], tiff: &Tiff) -> Option<String> {
|
||||
let exif = tiff.exif()?;
|
||||
if !exif.bytes(t::MAKER_NOTE)?.starts_with(b"Panasonic\0") {
|
||||
return None;
|
||||
}
|
||||
let e = exif.get(t::MAKER_NOTE)?;
|
||||
let make = tiff.ifds.first().and_then(|i| i.string(t::MAKE)).unwrap_or_default();
|
||||
let mn = craft_tiff::makernote::parse_makernote(data, e.offset, e.count() as u64, tiff.order, &make)?;
|
||||
text(Some(&mn.ifd), PANASONIC_LENS_TYPE)
|
||||
}
|
||||
|
||||
/// The lens name from the Exif of a RW2's (or Leica RWL's) embedded JpgFromRaw.
|
||||
fn panasonic_jpg_from_raw_lens(tiff: &Tiff) -> Option<String> {
|
||||
let jpg = tiff.ifds.first()?.bytes(PANASONIC_JPG_FROM_RAW)?;
|
||||
let exif = crate::jpeg_segments(jpg).exif?;
|
||||
let data = strip_exif_header(&exif);
|
||||
panasonic_lens(data, &Tiff::parse(data).ok()?)
|
||||
}
|
||||
|
||||
/// Canon maker note tag `0x0095` (LensModel in the ExifTool Canon tag names): the lens name as ASCII. Older
|
||||
/// bodies instead record a numeric LensType (CameraSettings, tag `0x0001`, index 22), which is no name to read.
|
||||
const CANON_LENS_MODEL: u16 = 0x0095;
|
||||
|
||||
/// The lens name in a Canon maker note of this Exif stream (`data` starts at its TIFF header): the note of
|
||||
/// its own Exif IFD. Only consulted when Make is Canon, so other vendors' plain notes are never read as lenses.
|
||||
fn canon_maker_note_lens(data: &[u8], tiff: &Tiff) -> Option<String> {
|
||||
let make = tiff.ifds.first().and_then(|i| i.string(t::MAKE)).unwrap_or_default();
|
||||
if !make.trim().to_ascii_uppercase().starts_with("CANON") {
|
||||
return None;
|
||||
}
|
||||
let exif = tiff.exif()?;
|
||||
let e = exif.get(t::MAKER_NOTE)?;
|
||||
let mn = craft_tiff::makernote::parse_makernote(data, e.offset, e.count() as u64, tiff.order, &make)?;
|
||||
text(Some(&mn.ifd), CANON_LENS_MODEL)
|
||||
}
|
||||
|
||||
/// Nikon maker note tag `0x0084` (Lens in the ExifTool Nikon tag names): shortest and longest focal length, then the
|
||||
/// widest aperture at each, as rationals, like Exif LensSpecification. Zero for a lens that doesn't report itself.
|
||||
const NIKON_LENS: u16 = 0x0084;
|
||||
|
||||
/// The lens a Nikon camera records in its maker note, when it's plausible.
|
||||
fn nikon_lens_spec(exif: Option<&Ifd>, make: Option<&str>, order: craft_tiff::ByteOrder) -> Option<[f64; 4]> {
|
||||
let make = make?;
|
||||
if !make.trim().to_ascii_uppercase().starts_with("NIKON") {
|
||||
return None;
|
||||
}
|
||||
let note = exif?.bytes(t::MAKER_NOTE)?;
|
||||
let mn = craft_tiff::makernote::parse_makernote(note, 0, note.len() as u64, order, make)?;
|
||||
let s = mn.ifd.f64s(NIKON_LENS)?;
|
||||
let [short, long, wide, narrow] = s.as_slice() else { return None };
|
||||
let focal = |f: f64| f.is_finite() && f > 0.0 && f < 10_000.0;
|
||||
let aperture = |a: f64| a.is_finite() && a > 0.0 && a < 100.0;
|
||||
(focal(*short) && focal(*long) && long >= short && aperture(*wide) && aperture(*narrow)).then_some([*short, *long, *wide, *narrow])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
use craft_tiff::{ByteOrder, IfdBuilder, TiffWriter, Value};
|
||||
|
||||
fn capture_time_tiff(original: Option<&str>, digitized: Option<&str>, ifd0_time: Option<&str>, xmp: Option<&str>) -> Vec<u8> {
|
||||
let mut exif = IfdBuilder::new();
|
||||
if let Some(value) = original {
|
||||
exif.set(t::DATE_TIME_ORIGINAL, Value::Ascii(value.into()));
|
||||
}
|
||||
if let Some(value) = digitized {
|
||||
exif.set(t::DATE_TIME_DIGITIZED, Value::Ascii(value.into()));
|
||||
}
|
||||
let mut ifd0 = IfdBuilder::new();
|
||||
if let Some(value) = ifd0_time {
|
||||
ifd0.set(t::DATE_TIME, Value::Ascii(value.into()));
|
||||
}
|
||||
ifd0.set_child(t::EXIF_IFD, exif);
|
||||
if let Some(packet) = xmp {
|
||||
ifd0.set(t::XMP, Value::Byte(packet.as_bytes().to_vec()));
|
||||
}
|
||||
TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exif_clock_invalid_original_uses_the_next_valid_exif_time() {
|
||||
let invalid = "2026:01:01 12:34:56:99";
|
||||
let digitized = capture_time_tiff(Some(invalid), Some("2026:01:02 10:00:00"), Some("2026:01:03 11:00:00"), None);
|
||||
assert_eq!(read_exif(&digitized).capture_time.map(|date| date.to_iso()), Some("2026-01-02T10:00:00".into()));
|
||||
|
||||
let ifd0 = capture_time_tiff(Some(invalid), Some("2026:01:02 10:00:00:"), Some("2026:01:03 11:00:00"), None);
|
||||
assert_eq!(read_exif(&ifd0).capture_time.map(|date| date.to_iso()), Some("2026-01-03T11:00:00".into()));
|
||||
|
||||
let none = capture_time_tiff(Some(invalid), Some("2026:01:02 10:00:00:junk"), Some("2026:01:03 11:00:00:"), None);
|
||||
assert_eq!(read_exif(&none).capture_time, None);
|
||||
|
||||
let original = capture_time_tiff(Some("2026:01:04 12:00:00"), Some("2026:01:02 10:00:00"), Some("2026:01:03 11:00:00"), None);
|
||||
assert_eq!(read_exif(&original).capture_time.map(|date| date.to_iso()), Some("2026-01-04T12:00:00".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exif_clock_invalid_original_allows_xmp_fallback_and_keeps_raw_tag() {
|
||||
let capture = DateTime::parse_iso("2026-01-02T10:00:00Z");
|
||||
let packet = crate::write_xmp(&Metadata { capture_time: capture, ..Default::default() });
|
||||
let bytes = capture_time_tiff(Some("2026:01:01 12:34:56:99"), None, None, Some(&packet));
|
||||
assert_eq!(crate::extract(&bytes).capture_time.map(|date| date.to_iso()), Some("2026-01-02T10:00:00+00:00".into()));
|
||||
|
||||
let without_fallback = capture_time_tiff(Some("2026:01:01 12:34:56:junk"), None, None, None);
|
||||
assert_eq!(crate::extract(&without_fallback).capture_time, None);
|
||||
assert!(crate::embedded(&without_fallback).exif.is_some(), "rejecting the projection must retain the embedded EXIF block");
|
||||
}
|
||||
|
||||
/// An Exif block from a Panasonic body: no LensModel, the lens name in maker note tag 0x0051, whose value
|
||||
/// lies outside the note's IFD at an offset relative to the Exif TIFF header (as Panasonic writes it).
|
||||
pub(crate) fn panasonic_exif(order: ByteOrder, lens: Option<&str>, lens_model: Option<&str>) -> Vec<u8> {
|
||||
let note = |at: u32| {
|
||||
let mut v = b"Panasonic\0\0\0".to_vec();
|
||||
order.put_u16(&mut v, 1);
|
||||
order.put_u16(&mut v, if lens.is_some() { PANASONIC_LENS_TYPE } else { 0x0001 });
|
||||
order.put_u16(&mut v, 2);
|
||||
let name = format!("{:<34}\0", lens.unwrap_or("0210"));
|
||||
order.put_u32(&mut v, name.len() as u32);
|
||||
order.put_u32(&mut v, at + 30);
|
||||
order.put_u32(&mut v, 0);
|
||||
v.extend_from_slice(name.as_bytes());
|
||||
v
|
||||
};
|
||||
let build = |at: u32| {
|
||||
let mut exif = IfdBuilder::new();
|
||||
exif.set(t::MAKER_NOTE, Value::Undefined(note(at)));
|
||||
exif.set(t::FOCAL_LENGTH, Value::Rational(vec![(25, 1)]));
|
||||
if let Some(l) = lens_model {
|
||||
exif.set(t::LENS_MODEL, Value::Ascii(l.into()));
|
||||
}
|
||||
let mut ifd0 = IfdBuilder::new();
|
||||
ifd0.set(t::MAKE, Value::Ascii("Panasonic".into()));
|
||||
ifd0.set_child(t::EXIF_IFD, exif);
|
||||
TiffWriter::new(order, false).write(&[ifd0]).unwrap()
|
||||
};
|
||||
// the note's own offset is only known once it is laid out; its length doesn't change
|
||||
let first = build(0);
|
||||
let at = Tiff::parse(&first).unwrap().exif().unwrap().get(t::MAKER_NOTE).unwrap().offset as u32;
|
||||
build(at)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panasonic_maker_note_names_the_lens() {
|
||||
for order in [ByteOrder::Little, ByteOrder::Big] {
|
||||
let m = read_exif(&panasonic_exif(order, Some("LUMIX G 25/F1.7"), None));
|
||||
assert_eq!(m.lens_model.as_deref(), Some("LUMIX G 25/F1.7"));
|
||||
assert_eq!(m.focal_length, Some(25.0));
|
||||
}
|
||||
// Exif's own LensModel wins, and a note without 0x0051 leaves the lens empty
|
||||
let m = read_exif(&panasonic_exif(ByteOrder::Little, Some("LUMIX G 25/F1.7"), Some("Other 25mm")));
|
||||
assert_eq!(m.lens_model.as_deref(), Some("Other 25mm"));
|
||||
assert_eq!(read_exif(&panasonic_exif(ByteOrder::Little, None, None)).lens_model, None);
|
||||
}
|
||||
|
||||
/// A RW2 has the full Exif only in the APP1 of its JpgFromRaw (IFD0 0x002E), not in its own Exif IFD.
|
||||
pub(crate) fn panasonic_rw2(lens: &str) -> Vec<u8> {
|
||||
let mut jpg = vec![0xff, 0xd8, 0xff, 0xe1];
|
||||
let mut app1 = b"Exif\0\0".to_vec();
|
||||
app1.extend(panasonic_exif(ByteOrder::Little, Some(lens), None));
|
||||
jpg.extend_from_slice(&((app1.len() + 2) as u16).to_be_bytes());
|
||||
jpg.extend(app1);
|
||||
jpg.extend_from_slice(&[0xff, 0xda, 0, 2, 0xff, 0xd9]);
|
||||
let mut exif = IfdBuilder::new();
|
||||
exif.set(t::FOCAL_LENGTH, Value::Rational(vec![(85, 1)]));
|
||||
let mut ifd0 = IfdBuilder::new();
|
||||
ifd0.set(t::MAKE, Value::Ascii("Panasonic".into()));
|
||||
ifd0.set(PANASONIC_JPG_FROM_RAW, Value::Undefined(jpg));
|
||||
ifd0.set_child(t::EXIF_IFD, exif);
|
||||
TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rw2_reads_the_lens_from_its_jpg_from_raw() {
|
||||
let rw2 = panasonic_rw2("LUMIX S 85/F1.8");
|
||||
let m = read_exif(&rw2);
|
||||
assert_eq!(m.lens_model.as_deref(), Some("LUMIX S 85/F1.8"));
|
||||
assert_eq!(m.focal_length, Some(85.0));
|
||||
assert_eq!(crate::extract(&rw2).lens_model.as_deref(), Some("LUMIX S 85/F1.8"));
|
||||
}
|
||||
|
||||
/// Canon maker note tags: `0x0095` holds the lens name as ASCII; `0x0001` is CameraSettings, a table of
|
||||
/// int16s whose index 22 is the numeric LensType.
|
||||
const CANON_LENS_MODEL_TAG: u16 = 0x0095;
|
||||
const CANON_CAMERA_SETTINGS: u16 = 0x0001;
|
||||
|
||||
/// An Exif block from a Canon EOS 5D: `Canon` / `Canon EOS 5D` in IFD0, no LensModel tag, and a plain maker
|
||||
/// note whose IFD (value offsets relative to the enclosing TIFF header, as Canon writes it) records
|
||||
/// CameraSettings with the numeric LensType at index 22 and, when `lens` is given, the name at `0x0095`.
|
||||
fn canon_exif(order: ByteOrder, lens: Option<&str>) -> Vec<u8> {
|
||||
let note = |at: u32| {
|
||||
// The plain IFD: entry count, one or two 12-byte entries and the next-IFD pointer, then the
|
||||
// out-of-line values (CameraSettings, then the lens name when present).
|
||||
// pointer, then the out-of-line values (CameraSettings, then the lens name when present).
|
||||
let settings_at = at + 2 + 12 * (1 + lens.is_some() as u32) + 4;
|
||||
let name_at = settings_at + 60;
|
||||
let mut v = Vec::new();
|
||||
let entry = |v: &mut Vec<u8>, tag: u16, kind: u16, count: u32, off: u32| {
|
||||
order.put_u16(v, tag);
|
||||
order.put_u16(v, kind);
|
||||
order.put_u32(v, count);
|
||||
order.put_u32(v, off);
|
||||
};
|
||||
order.put_u16(&mut v, 1 + lens.is_some() as u16);
|
||||
entry(&mut v, CANON_CAMERA_SETTINGS, 3, 30, settings_at);
|
||||
if let Some(name) = lens {
|
||||
entry(&mut v, CANON_LENS_MODEL_TAG, 2, name.len() as u32, name_at);
|
||||
}
|
||||
order.put_u32(&mut v, 0);
|
||||
for i in 0..30u16 {
|
||||
order.put_u16(&mut v, if i == 22 { 178 } else { 0 }); // 22: numeric LensType, no name
|
||||
}
|
||||
if let Some(name) = lens {
|
||||
v.extend_from_slice(name.as_bytes());
|
||||
}
|
||||
v
|
||||
};
|
||||
let build = |at: u32| {
|
||||
let mut exif = IfdBuilder::new();
|
||||
exif.set(t::MAKER_NOTE, Value::Undefined(note(at)));
|
||||
exif.set(t::FOCAL_LENGTH, Value::Rational(vec![(50, 1)]));
|
||||
let mut ifd0 = IfdBuilder::new();
|
||||
ifd0.set(t::MAKE, Value::Ascii("Canon".into()));
|
||||
ifd0.set(t::MODEL, Value::Ascii("Canon EOS 5D".into()));
|
||||
ifd0.set_child(t::EXIF_IFD, exif);
|
||||
TiffWriter::new(order, false).write(&[ifd0]).unwrap()
|
||||
};
|
||||
// the note's own offset is only known once it is laid out; its length doesn't change
|
||||
let first = build(0);
|
||||
let at = Tiff::parse(&first).unwrap().exif().unwrap().get(t::MAKER_NOTE).unwrap().offset as u32;
|
||||
build(at)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canon_maker_note_lens_fills_lens_model() {
|
||||
for order in [ByteOrder::Little, ByteOrder::Big] {
|
||||
let m = read_exif(&canon_exif(order, Some("Canon EF 50mm f/1.8 II")));
|
||||
assert_eq!(m.lens_model.as_deref(), Some("Canon EF 50mm f/1.8 II"));
|
||||
// a note that only records the numeric LensType has no name to fill in
|
||||
assert_eq!(read_exif(&canon_exif(order, None)).lens_model, None);
|
||||
}
|
||||
}
|
||||
|
||||
/// An Exif block from a Nikon body whose maker note records the lens (tag 0x0084) and no LensModel or
|
||||
/// LensSpecification, as the D700, D750, D80 and D5300 write it.
|
||||
fn nikon_exif(make: &str, lens: Option<[(u32, u32); 4]>, spec: Option<[(u32, u32); 4]>) -> Vec<u8> {
|
||||
let mut note = IfdBuilder::new();
|
||||
note.set(0x0001, Value::Undefined(b"0210".to_vec()));
|
||||
if let Some(l) = lens {
|
||||
note.set(NIKON_LENS, Value::Rational(l.to_vec()));
|
||||
}
|
||||
let mut blob = b"Nikon\0\x02\x10\0\0".to_vec();
|
||||
blob.extend(TiffWriter::new(ByteOrder::Little, false).write(&[note]).unwrap());
|
||||
let mut exif = IfdBuilder::new();
|
||||
exif.set(t::MAKER_NOTE, Value::Undefined(blob));
|
||||
if let Some(sp) = spec {
|
||||
exif.set(t::LENS_SPECIFICATION, Value::Rational(sp.to_vec()));
|
||||
}
|
||||
let mut ifd0 = IfdBuilder::new();
|
||||
ifd0.set(t::MAKE, Value::Ascii(make.into()));
|
||||
ifd0.set_child(t::EXIF_IFD, exif);
|
||||
TiffWriter::new(ByteOrder::Big, false).write(&[ifd0]).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nikon_maker_note_lens_fills_the_lens_spec() {
|
||||
let zoom = [(18, 1), (135, 1), (35, 10), (56, 10)];
|
||||
assert_eq!(read_exif(&nikon_exif("NIKON CORPORATION", Some(zoom), None)).lens_spec, Some([18.0, 135.0, 3.5, 5.6]));
|
||||
// Exif's own LensSpecification wins, another make's note is not read, and a lens that reports nothing is left out
|
||||
let prime = [(50, 1), (50, 1), (18, 10), (18, 10)];
|
||||
assert_eq!(read_exif(&nikon_exif("NIKON CORPORATION", Some(zoom), Some(prime))).lens_spec, Some([50.0, 50.0, 1.8, 1.8]));
|
||||
assert_eq!(read_exif(&nikon_exif("Canon", Some(zoom), None)).lens_spec, None);
|
||||
assert_eq!(read_exif(&nikon_exif("NIKON CORPORATION", Some([(0, 1), (0, 1), (0, 1), (0, 1)]), None)).lens_spec, None);
|
||||
assert_eq!(read_exif(&nikon_exif("NIKON CORPORATION", Some([(200, 1), (80, 1), (28, 10), (28, 10)]), None)).lens_spec, None);
|
||||
assert_eq!(read_exif(&nikon_exif("NIKON CORPORATION", None, None)).lens_spec, None);
|
||||
}
|
||||
|
||||
pub(crate) fn sample_exif(order: ByteOrder) -> Vec<u8> {
|
||||
let mut exif = IfdBuilder::new();
|
||||
exif.set(t::EXPOSURE_TIME, Value::Rational(vec![(1, 250)]));
|
||||
exif.set(t::F_NUMBER, Value::Rational(vec![(28, 10)]));
|
||||
exif.set(t::ISO_SPEED, Value::Short(vec![400]));
|
||||
exif.set(t::DATE_TIME_ORIGINAL, Value::Ascii("2023:07:14 18:30:05".into()));
|
||||
exif.set(t::OFFSET_TIME_ORIGINAL, Value::Ascii("+02:00".into()));
|
||||
exif.set(t::SUBSEC_TIME_ORIGINAL, Value::Ascii("42".into()));
|
||||
exif.set(t::EXPOSURE_BIAS, Value::SRational(vec![(-2, 3)]));
|
||||
exif.set(t::METERING_MODE, Value::Short(vec![5]));
|
||||
exif.set(t::FLASH, Value::Short(vec![0x19]));
|
||||
exif.set(t::FOCAL_LENGTH, Value::Rational(vec![(50, 1)]));
|
||||
exif.set(t::FOCAL_LENGTH_35MM, Value::Short(vec![75]));
|
||||
exif.set(t::WHITE_BALANCE, Value::Short(vec![0]));
|
||||
exif.set(t::EXPOSURE_PROGRAM, Value::Short(vec![3]));
|
||||
exif.set(t::LENS_MAKE, Value::Ascii("LensCo".into()));
|
||||
exif.set(t::LENS_MODEL, Value::Ascii("50mm F1.8".into()));
|
||||
exif.set(t::BODY_SERIAL_NUMBER, Value::Ascii("12345".into()));
|
||||
exif.set(t::LENS_SPECIFICATION, Value::Rational(vec![(50, 1), (50, 1), (18, 10), (18, 10)]));
|
||||
exif.set(t::PIXEL_X_DIMENSION, Value::Long(vec![6000]));
|
||||
exif.set(t::PIXEL_Y_DIMENSION, Value::Long(vec![4000]));
|
||||
let mut gps = IfdBuilder::new();
|
||||
gps.set(t::GPS_LATITUDE_REF, Value::Ascii("S".into()));
|
||||
gps.set(t::GPS_LATITUDE, Value::Rational(vec![(33, 1), (51, 1), (3564, 100)]));
|
||||
gps.set(t::GPS_LONGITUDE_REF, Value::Ascii("E".into()));
|
||||
gps.set(t::GPS_LONGITUDE, Value::Rational(vec![(151, 1), (12, 1), (4032, 100)]));
|
||||
gps.set(t::GPS_ALTITUDE_REF, Value::Byte(vec![1]));
|
||||
gps.set(t::GPS_ALTITUDE, Value::Rational(vec![(125, 10)]));
|
||||
let mut ifd0 = IfdBuilder::new();
|
||||
ifd0.set(t::MAKE, Value::Ascii("Maker Inc.".into()));
|
||||
ifd0.set(t::MODEL, Value::Ascii("Model X ".into()));
|
||||
ifd0.set(t::ORIENTATION, Value::Short(vec![6]));
|
||||
ifd0.set(t::ARTIST, Value::Ascii("Jane".into()));
|
||||
ifd0.set(t::COPYRIGHT, Value::Ascii("(c) Jane".into()));
|
||||
ifd0.set(t::RATING, Value::Short(vec![3]));
|
||||
ifd0.set_child(t::EXIF_IFD, exif);
|
||||
ifd0.set_child(t::GPS_IFD, gps);
|
||||
TiffWriter::new(order, false).write(&[ifd0]).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_all_fields() {
|
||||
for order in [ByteOrder::Little, ByteOrder::Big] {
|
||||
let m = read_exif(&sample_exif(order));
|
||||
assert_eq!(m.make.as_deref(), Some("Maker Inc."));
|
||||
assert_eq!(m.model.as_deref(), Some("Model X"));
|
||||
assert_eq!(m.exposure_time, Some(0.004));
|
||||
assert_eq!(m.f_number, Some(2.8));
|
||||
assert_eq!(m.iso, Some(400));
|
||||
let dt = m.capture_time.unwrap();
|
||||
assert_eq!(dt.to_iso(), "2023-07-14T18:30:05.420+02:00");
|
||||
assert!((m.exposure_bias.unwrap() + 0.6667).abs() < 1e-3);
|
||||
assert_eq!(m.metering_mode, Some(5));
|
||||
assert_eq!(m.flash, Some(Flash { fired: true, raw: 0x19 }));
|
||||
assert_eq!(m.focal_length, Some(50.0));
|
||||
assert_eq!(m.focal_length_35mm, Some(75.0));
|
||||
assert_eq!(m.orientation, Some(Orientation::Rotate90));
|
||||
assert_eq!(m.lens_model.as_deref(), Some("50mm F1.8"));
|
||||
assert_eq!(m.lens_make.as_deref(), Some("LensCo"));
|
||||
assert_eq!(m.serial_number.as_deref(), Some("12345"));
|
||||
assert_eq!(m.lens_spec, Some([50.0, 50.0, 1.8, 1.8]));
|
||||
assert_eq!((m.width, m.height), (Some(6000), Some(4000)));
|
||||
assert_eq!(m.artist.as_deref(), Some("Jane"));
|
||||
assert_eq!(m.rating, Some(3));
|
||||
let g = m.gps.unwrap();
|
||||
assert!((g.latitude + 33.8599).abs() < 1e-4, "{g:?}");
|
||||
assert!((g.longitude - 151.2112).abs() < 1e-4);
|
||||
assert_eq!(g.altitude, Some(-12.5));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placeholder_gps_block_is_no_location() {
|
||||
// Android phones without a fix write NUL refs and 0/0 rationals.
|
||||
let read = |lat_ref: &str, lat: Vec<(u32, u32)>| {
|
||||
let mut gps = IfdBuilder::new();
|
||||
gps.set(t::GPS_LATITUDE_REF, Value::Ascii(lat_ref.into()));
|
||||
gps.set(t::GPS_LATITUDE, Value::Rational(lat));
|
||||
gps.set(t::GPS_LONGITUDE_REF, Value::Ascii("E".into()));
|
||||
gps.set(t::GPS_LONGITUDE, Value::Rational(vec![(151, 1), (12, 1), (4032, 100)]));
|
||||
let mut ifd0 = IfdBuilder::new();
|
||||
ifd0.set_child(t::GPS_IFD, gps);
|
||||
read_exif(&TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap()).gps
|
||||
};
|
||||
let valid = vec![(33, 1), (51, 1), (3564, 100)];
|
||||
assert!(read("N", valid.clone()).is_some());
|
||||
assert_eq!(read("N", vec![(0, 0), (0, 0), (0, 0)]), None, "0/0 rationals");
|
||||
assert_eq!(read("N", vec![(33, 1), (0, 0), (0, 0)]), None, "0/0 minutes");
|
||||
assert_eq!(read(" | ||||