mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-26 19:17:02 -06:00
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>
531 lines
26 KiB
Rust
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));
|
|
}
|
|
}
|
|
}
|