Files
lightcraft/crates/meta/src/exif.rs
NetterSilverandClaude Haiku 5.5 a3d00301cf Write ISOSpeed extension tag for ISO above 65534 in write_exif
Found while testing crates/meta/src/exif.rs:455 (write_exif)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 05:16:03 +00:00

531 lines
26 KiB
Rust

//! 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 lightcraft_tiff::{Ifd, 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> {
let data = strip_exif_header(bytes);
let tiff = Tiff::parse(data)?;
let mut m = from_tiff(&tiff);
// Panasonic writes no LensModel, only the name in its maker note (and in a RW2 that note is not in the raw's own Exif)
if m.lens_model.is_none() {
m.lens_model = panasonic_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)
}
/// 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 {
lightcraft_tiff::Value::Byte(b) | lightcraft_tiff::Value::Undefined(b) => b.clone(),
other => lightcraft_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)
{
m.overlay_user_fields(&x.metadata);
m.fill_missing(&x.metadata);
}
m
}
fn dms(ifd: &Ifd, tag: u16) -> Option<f64> {
let v = ifd.f64s(tag)?;
let d = *v.first()?;
let deg = d + v.get(1).copied().unwrap_or(0.0) / 60.0 + v.get(2).copied().unwrap_or(0.0) / 3600.0;
deg.is_finite().then_some(deg)
}
fn read_gps(g: &Ifd) -> Option<Gps> {
let mut lat = dms(g, t::GPS_LATITUDE)?;
let mut lon = dms(g, t::GPS_LONGITUDE)?;
if g.string(t::GPS_LATITUDE_REF).is_some_and(|s| s.starts_with('S')) {
lat = -lat;
}
if g.string(t::GPS_LONGITUDE_REF).is_some_and(|s| s.starts_with('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 = lightcraft_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()?)
}
/// 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: lightcraft_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 = lightcraft_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 lightcraft_tiff::{ByteOrder, IfdBuilder, TiffWriter, Value};
/// 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"));
}
/// 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 exif_prefix_and_garbage() {
let mut b = b"Exif\0\0".to_vec();
b.extend(sample_exif(ByteOrder::Little));
assert_eq!(read_exif(&b).iso, Some(400));
assert_eq!(read_exif(b"garbage"), Metadata::default());
assert!(try_read_exif(b"garbage").is_err());
}
#[test]
fn apex_fallbacks_and_extended_iso() {
let mut exif = IfdBuilder::new();
exif.set(t::SHUTTER_SPEED_VALUE, Value::SRational(vec![(8, 1)]));
exif.set(t::APERTURE_VALUE, Value::Rational(vec![(4, 1)]));
exif.set(t::ISO_SPEED, Value::Short(vec![65535]));
exif.set(t::RECOMMENDED_EXPOSURE_INDEX, Value::Long(vec![102400]));
let mut ifd0 = IfdBuilder::new();
ifd0.set(t::DATE_TIME, Value::Ascii("2020:01:02 03:04:05".into()));
ifd0.set_child(t::EXIF_IFD, exif);
let m = read_exif(&TiffWriter::default().write(&[ifd0]).unwrap());
assert_eq!(m.exposure_time, Some(1.0 / 256.0));
assert_eq!(m.f_number, Some(4.0));
assert_eq!(m.iso, Some(102400));
assert_eq!(m.capture_time.unwrap().to_exif(), "2020:01:02 03:04:05");
}
#[test]
fn xmp_in_ifd0_overrides_user_fields() {
let mut meta = Metadata { rating: Some(5), title: Some("From XMP".into()), ..Default::default() };
meta.keywords = vec!["a".into()];
let x = crate::write_xmp(&meta, None);
let mut ifd0 = IfdBuilder::new();
ifd0.set(t::RATING, Value::Short(vec![1]));
ifd0.set(t::XMP, Value::Byte(x.into_bytes()));
let m = read_exif(&TiffWriter::default().write(&[ifd0]).unwrap());
assert_eq!(m.rating, Some(5));
assert_eq!(m.title.as_deref(), Some("From XMP"));
}
}
/// Serialise the interchange subset of `meta` as a TIFF-structured Exif block (little-endian, no
/// `Exif\0\0` prefix): IFD0 (make, model, software, artist, copyright, description, orientation,
/// date), the Exif IFD (capture settings, lens) and, when present, the GPS IFD.
pub fn write_exif(meta: &Metadata) -> Vec<u8> {
use lightcraft_tiff::writer::{rational, srational};
use lightcraft_tiff::{ByteOrder, IfdBuilder, TiffWriter, Value};
let ascii = |s: &Option<String>| s.as_ref().filter(|v| !v.is_empty()).map(|v| Value::Ascii(v.clone()));
let mut ifd0 = IfdBuilder::new();
for (tag, v) in [
(t::MAKE, ascii(&meta.make)),
(t::MODEL, ascii(&meta.model)),
(t::SOFTWARE, ascii(&meta.software)),
(t::ARTIST, ascii(&meta.artist)),
(t::COPYRIGHT, ascii(&meta.copyright)),
(t::IMAGE_DESCRIPTION, ascii(&meta.caption)),
(t::DATE_TIME, meta.capture_time.map(|d| Value::Ascii(d.to_exif()))),
(t::ORIENTATION, meta.orientation.map(|o| Value::Short(vec![o.to_exif()]))),
] {
if let Some(v) = v {
ifd0.set(tag, v);
}
}
let mut ex = IfdBuilder::new();
ex.set(t::EXIF_VERSION, Value::Undefined(b"0232".to_vec()));
let r = |v: Option<f64>| v.filter(|v| v.is_finite() && *v > 0.0).map(|v| Value::Rational(vec![rational(v)]));
for (tag, v) in [
(t::DATE_TIME_ORIGINAL, meta.capture_time.map(|d| Value::Ascii(d.to_exif()))),
(t::EXPOSURE_TIME, r(meta.exposure_time)),
(t::F_NUMBER, r(meta.f_number)),
(t::FOCAL_LENGTH, r(meta.focal_length)),
(t::ISO_SPEED, meta.iso.map(|i| Value::Short(vec![i.min(65_535) as u16]))),
(t::ISO_SPEED_RATINGS_EXT, meta.iso.filter(|&i| i > 65_534).map(|i| Value::Long(vec![i]))),
(t::LENS_MAKE, ascii(&meta.lens_make)),
(t::LENS_MODEL, ascii(&meta.lens_model)),
(t::EXPOSURE_BIAS, meta.exposure_bias.map(|v| Value::SRational(vec![srational(v)]))),
] {
if let Some(v) = v {
ex.set(tag, v);
}
}
ifd0.set_child(t::EXIF_IFD, ex);
if let Some(g) = meta.gps {
let dms = |v: f64| {
let v = v.abs();
let d = v.floor();
let m = ((v - d) * 60.0).floor();
let s = (v - d - m / 60.0) * 3600.0;
Value::Rational(vec![(d as u32, 1), (m as u32, 1), ((s * 1000.0).round() as u32, 1000)])
};
let mut gps = IfdBuilder::new();
gps.set(t::GPS_VERSION_ID, Value::Byte(vec![2, 3, 0, 0]));
gps.set(t::GPS_LATITUDE_REF, Value::Ascii(if g.latitude < 0.0 { "S" } else { "N" }.into()));
gps.set(t::GPS_LATITUDE, dms(g.latitude));
gps.set(t::GPS_LONGITUDE_REF, Value::Ascii(if g.longitude < 0.0 { "W" } else { "E" }.into()));
gps.set(t::GPS_LONGITUDE, dms(g.longitude));
if let Some(a) = g.altitude {
gps.set(t::GPS_ALTITUDE_REF, Value::Byte(vec![u8::from(a < 0.0)]));
gps.set(t::GPS_ALTITUDE, Value::Rational(vec![rational(a.abs())]));
}
ifd0.set_child(t::GPS_IFD, gps);
}
TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap_or_default()
}
#[cfg(test)]
mod write_tests {
use super::*;
#[test]
fn exif_round_trips() {
let m = Metadata {
make: Some("Synthetic".into()),
model: Some("X2".into()),
copyright: Some("© 2026 A. Person".into()),
artist: Some("A. Person".into()),
capture_time: DateTime::parse_iso("2026-09-20T05:40:00"),
exposure_time: Some(1.0 / 250.0),
f_number: Some(2.8),
iso: Some(400),
focal_length: Some(35.0),
lens_model: Some("35mm F2".into()),
gps: Some(Gps { latitude: 43.7904, longitude: -110.6818, altitude: Some(2000.0) }),
..Default::default()
};
let back = read_exif(&write_exif(&m));
assert_eq!(back.make, m.make);
assert_eq!(back.model, m.model);
assert_eq!(back.copyright, m.copyright);
assert_eq!(back.artist, m.artist);
assert_eq!(back.capture_time.map(|d| d.to_exif()), m.capture_time.map(|d| d.to_exif()));
assert_eq!(back.iso, Some(400));
assert!((back.f_number.unwrap() - 2.8).abs() < 1e-6);
assert!((back.exposure_time.unwrap() - 0.004).abs() < 1e-9);
assert_eq!(back.lens_model, m.lens_model);
let g = back.gps.unwrap();
assert!((g.latitude - 43.7904).abs() < 1e-5 && (g.longitude + 110.6818).abs() < 1e-5, "{g:?}");
}
#[test]
fn iso_above_65534_round_trips() {
for iso in [65_534, 65_535, 102_400, 409_600] {
let back = read_exif(&write_exif(&Metadata { iso: Some(iso), ..Default::default() }));
assert_eq!(back.iso, Some(iso));
}
}
}