WIP: EXIF metadata on export

Checkpoint committed by the coordinator, not by the authoring agent: the
session hit its API limit mid-task and left this work uncommitted. Committed
so it survives, NOT because it is finished - expect failing tests and
half-applied changes. The agent resumes from here.
This commit is contained in:
2026-08-22 19:01:18 +02:00
parent c963dafd09
commit cd8750462f
8 changed files with 1797 additions and 46 deletions
+319
View File
@@ -287,6 +287,16 @@ impl<'a> TiffReader<'a> {
/// its offset.
fn scalar(&self, e: &Entry) -> Option<u32> {
match e.kind {
// BYTE, inline when count is 1. The GPS directory's altitude
// reference is one of these, and it is the difference between a
// hilltop and a position 400 m under the Dead Sea.
1 if e.count == 1 => Some(if self.little_endian {
e.value & 0xFF
} else {
// The value field is left-justified whatever the width, so a
// big-endian byte sits in the *top* octet.
e.value >> 24
}),
// SHORT, inline when count is 1.
3 if e.count == 1 => Some(if self.little_endian {
e.value & 0xFFFF
@@ -303,6 +313,35 @@ impl<'a> TiffReader<'a> {
}
}
/// TRACES: FR-EXP-8
/// One RATIONAL from an entry, as a number.
///
/// A rational is eight bytes, so it never fits the four-byte value field
/// and is always read through the offset — which is why `index` is
/// meaningful: the GPS directory stores latitude as three of them in a
/// row.
///
/// A zero denominator yields `None` rather than an infinity. Cameras do
/// write `0/0` into slots they had nothing for, and a shutter speed of
/// `inf` propagated into an exported file is worse than a missing one.
fn rational(&self, e: &Entry, index: u32) -> Option<f64> {
// 5 is RATIONAL (two unsigned longs); 10 is SRATIONAL (two signed).
if (e.kind != 5 && e.kind != 10) || index >= e.count {
return None;
}
let at = (e.value as usize).checked_add(index as usize * 8)?;
let n = read_u32(self.data, at, self.little_endian)?;
let d = read_u32(self.data, at + 4, self.little_endian)?;
if d == 0 {
return None;
}
Some(if e.kind == 10 {
n as i32 as f64 / d as i32 as f64
} else {
n as f64 / d as f64
})
}
/// An ASCII entry's string value.
///
/// EXIF strings are NUL-terminated and often padded, and camera vendors
@@ -444,6 +483,19 @@ pub fn tiff_metadata(tiff_data: &[u8]) -> Result<crate::Metadata, crate::DecodeE
read_exif_entries(&reader, &sub, &mut md, &mut fb);
}
}
// TRACES: FR-EXP-8
// The GPS directory is a third IFD, pointed at from the main one and
// read with its own tag table. First reading wins, as with
// orientation: a thumbnail IFD that repeats the pointer describes the
// same photograph, and the main image's is the one to trust.
if md.location.is_none() {
if let Some(e) = entries.iter().find(|e| e.tag == gps_tag::POINTER) {
if let Some(sub) = reader.scalar(e).and_then(|o| reader.read_ifd(o)) {
md.location = read_gps_entries(&reader, &sub);
}
}
}
}
// Ranked: when the shutter fired, else when the image was digitised, else
@@ -516,6 +568,35 @@ mod exif_tag {
pub const LENS_MODEL: u16 = 0xA434;
pub const PIXEL_X: u16 = 0xA002;
pub const PIXEL_Y: u16 = 0xA003;
/// TRACES: FR-EXP-8
/// Who made the photograph, and under what terms. Both live in the main
/// IFD beside `Make`, not in the Exif sub-IFD.
pub const ARTIST: u16 = 0x013B;
pub const COPYRIGHT: u16 = 0x8298;
/// Exposure, as RATIONALs. Read here as well as from rawler because the
/// JPEG path has no rawler behind it, and a camera JPEG that lost its
/// shutter speed on export lost it for good.
pub const EXPOSURE_TIME: u16 = 0x829A;
pub const FNUMBER: u16 = 0x829D;
pub const FOCAL_LENGTH: u16 = 0x920A;
}
/// TRACES: FR-EXP-8
/// The GPS directory pointer, and the tags inside it.
///
/// A separate module from [`exif_tag`] because the numbers collide: 0x0001 is
/// `GPSLatitudeRef` here and `InteropIndex` there, and a GPS tag read against
/// a main-IFD table is how a file comes to claim an exposure time of "N".
mod gps_tag {
/// The main IFD entry pointing at the GPS directory.
pub const POINTER: u16 = 0x8825;
pub const LATITUDE_REF: u16 = 0x0001;
pub const LATITUDE: u16 = 0x0002;
pub const LONGITUDE_REF: u16 = 0x0003;
pub const LONGITUDE: u16 = 0x0004;
/// 0 above sea level, 1 below. The altitude itself is unsigned.
pub const ALTITUDE_REF: u16 = 0x0005;
pub const ALTITUDE: u16 = 0x0006;
}
/// Dates that stand in for a missing `DateTimeOriginal`.
@@ -542,6 +623,11 @@ fn read_exif_entries(
exif_tag::MODEL => md.model = r.ascii(e),
exif_tag::LENS_MODEL => md.lens = r.ascii(e),
exif_tag::ISO => md.iso = r.scalar(e),
exif_tag::ARTIST => md.artist = r.ascii(e),
exif_tag::COPYRIGHT => md.copyright = r.ascii(e),
exif_tag::EXPOSURE_TIME => md.shutter = r.rational(e, 0).map(|v| v as f32),
exif_tag::FNUMBER => md.aperture = r.rational(e, 0).map(|v| v as f32),
exif_tag::FOCAL_LENGTH => md.focal_length = r.rational(e, 0).map(|v| v as f32),
exif_tag::PIXEL_X => md.width = r.scalar(e),
exif_tag::PIXEL_Y => md.height = r.scalar(e),
// First IFD wins, unlike the fields above, which take the last
@@ -578,6 +664,49 @@ fn read_exif_entries(
}
}
/// TRACES: FR-EXP-8
/// A GPS directory's entries as a position.
///
/// Both coordinates or nothing: a latitude without a longitude is not half a
/// position, it is no position, and half of one written into an export would
/// be a coordinate on the Greenwich meridian.
fn read_gps_entries(r: &TiffReader, entries: &[Entry]) -> Option<dr_types::Location> {
let find = |tag: u16| entries.iter().find(|e| e.tag == tag);
// Degrees, minutes and seconds, each its own rational — and each of the
// three optional in practice, since a body that fixed only to the minute
// still writes the entry.
let degrees = |tag: u16, ref_tag: u16| -> Option<f64> {
let e = find(tag)?;
let d = r.rational(e, 0)? + r.rational(e, 1).unwrap_or(0.0) / 60.0
+ r.rational(e, 2).unwrap_or(0.0) / 3600.0;
// The magnitude is unsigned; the hemisphere is a letter beside it.
let south_or_west = find(ref_tag)
.and_then(|e| r.ascii(e))
.map(|s| {
let s = s.trim().to_ascii_uppercase();
s == "S" || s == "W"
})
.unwrap_or(false);
Some(if south_or_west { -d } else { d })
};
let latitude = degrees(gps_tag::LATITUDE, gps_tag::LATITUDE_REF)?;
let longitude = degrees(gps_tag::LONGITUDE, gps_tag::LONGITUDE_REF)?;
let altitude = find(gps_tag::ALTITUDE)
.and_then(|e| r.rational(e, 0))
.map(|a| {
let below = find(gps_tag::ALTITUDE_REF).and_then(|e| r.scalar(e)) == Some(1);
if below {
-a
} else {
a
}
});
dr_types::Location::new(latitude, longitude, altitude)
}
/// Whether a byte slice is a complete JPEG.
///
/// A truncated JPEG decodes to a partial image rather than an error — the
@@ -968,6 +1097,196 @@ mod tests {
assert_eq!(md.model.as_deref(), Some("CanoScan 9000F Mark II"));
}
/// A JPEG whose EXIF carries a GPS directory, built by hand.
///
/// The offsets are computed rather than written out because the whole
/// point of the exercise is that they are consistent: a GPS directory is
/// three levels of indirection — the main IFD points at it, and each
/// coordinate points at three rationals somewhere else again.
///
/// `lat`/`lon` are `(degrees, minutes, hundredths-of-a-second)` and the
/// refs are the hemisphere letters, exactly as a camera writes them.
fn jpeg_with_gps(
lat: (u32, u32, u32),
lat_ref: u8,
lon: (u32, u32, u32),
lon_ref: u8,
altitude: Option<(u32, u8)>,
) -> Vec<u8> {
// One entry in IFD0 (the GPS pointer), so the blob after it starts at
// the header (8) + count (2) + one entry (12) + the next-IFD link (4).
const GPS_IFD: u32 = 8 + 2 + 12 + 4;
let entries: u32 = if altitude.is_some() { 6 } else { 5 };
// Where the rationals live: after the GPS directory itself.
let heap = GPS_IFD + 2 + entries * 12 + 4;
let mut gps: Vec<(u16, u16, u32, u32)> = vec![
(gps_tag::LATITUDE_REF, 2, 2, u32::from(lat_ref)),
(gps_tag::LATITUDE, 5, 3, heap),
(gps_tag::LONGITUDE_REF, 2, 2, u32::from(lon_ref)),
(gps_tag::LONGITUDE, 5, 3, heap + 24),
];
if let Some((_, reference)) = altitude {
gps.push((gps_tag::ALTITUDE_REF, 1, 1, u32::from(reference)));
gps.push((gps_tag::ALTITUDE, 5, 1, heap + 48));
}
let mut extra = Vec::new();
extra.extend_from_slice(&(gps.len() as u16).to_le_bytes());
for (tag, kind, count, value) in &gps {
extra.extend_from_slice(&tag.to_le_bytes());
extra.extend_from_slice(&kind.to_le_bytes());
extra.extend_from_slice(&count.to_le_bytes());
extra.extend_from_slice(&value.to_le_bytes());
}
extra.extend_from_slice(&0u32.to_le_bytes());
let mut rational = |n: u32, d: u32| {
extra.extend_from_slice(&n.to_le_bytes());
extra.extend_from_slice(&d.to_le_bytes());
};
for (n, d) in [(lat.0, 1), (lat.1, 1), (lat.2, 100)] {
rational(n, d);
}
for (n, d) in [(lon.0, 1), (lon.1, 1), (lon.2, 100)] {
rational(n, d);
}
if let Some((metres, _)) = altitude {
rational(metres, 1);
}
jpeg_with_exif(&[(gps_tag::POINTER, 4, 1, GPS_IFD)], &extra)
}
#[test]
fn a_gps_directory_becomes_signed_degrees() {
// TRACES: FR-EXP-8
// 48° 51' 29.52" N, 2° 17' 40.2" E — the Eiffel Tower. Reading this
// correctly is what makes stripping it meaningful: a parser that
// silently failed would make the export path look private when it was
// only ignorant.
let jpeg = jpeg_with_gps((48, 51, 2952), b'N', (2, 17, 4020), b'E', Some((35, 0)));
let loc = jpeg_metadata(&jpeg).expect("EXIF").location.expect("a fix");
assert!((loc.latitude - 48.858200).abs() < 1e-5, "{loc:?}");
assert!((loc.longitude - 2.294500).abs() < 1e-5, "{loc:?}");
assert_eq!(loc.altitude, Some(35.0));
}
#[test]
fn the_hemisphere_letters_are_applied_not_ignored() {
// The failure this catches puts Sydney in the North Atlantic: the
// magnitudes are identical and only the letters differ.
let jpeg = jpeg_with_gps((33, 51, 3500), b'S', (151, 12, 3600), b'E', None);
let loc = jpeg_metadata(&jpeg).expect("EXIF").location.expect("a fix");
assert!(loc.latitude < 0.0, "southern latitude must be negative");
assert!(loc.longitude > 0.0, "eastern longitude must be positive");
assert!(loc.altitude.is_none());
}
#[test]
fn a_below_sea_level_altitude_keeps_its_sign() {
// Reference 1 means below sea level; the altitude itself is unsigned,
// so dropping the reference turns the Dead Sea into a hilltop.
let jpeg = jpeg_with_gps((31, 33, 0), b'N', (35, 28, 0), b'E', Some((430, 1)));
let loc = jpeg_metadata(&jpeg).expect("EXIF").location.expect("a fix");
assert_eq!(loc.altitude, Some(-430.0));
}
#[test]
fn a_latitude_with_no_longitude_is_not_half_a_position() {
// Half a coordinate written into a file would be a pin on the
// Greenwich meridian, which is worse than no pin.
const GPS_IFD: u32 = 8 + 2 + 12 + 4;
let heap = GPS_IFD + 2 + 12 + 4;
let mut extra = Vec::new();
extra.extend_from_slice(&1u16.to_le_bytes());
for (tag, kind, count, value) in [(gps_tag::LATITUDE, 5u16, 3u32, heap)] {
extra.extend_from_slice(&tag.to_le_bytes());
extra.extend_from_slice(&kind.to_le_bytes());
extra.extend_from_slice(&count.to_le_bytes());
extra.extend_from_slice(&value.to_le_bytes());
}
extra.extend_from_slice(&0u32.to_le_bytes());
for (n, d) in [(48u32, 1u32), (51, 1), (2952, 100)] {
extra.extend_from_slice(&n.to_le_bytes());
extra.extend_from_slice(&d.to_le_bytes());
}
let jpeg = jpeg_with_exif(&[(gps_tag::POINTER, 4, 1, GPS_IFD)], &extra);
assert!(jpeg_metadata(&jpeg).expect("EXIF").location.is_none());
}
#[test]
fn the_byline_and_the_rights_statement_are_read() {
// TRACES: FR-EXP-8
// Both live in the main IFD, and both are the half of FR-EXP-8 that
// must *survive* an export rather than be removed by it.
let artist = b"Duncan Tourolle\0";
let copyright = b"(c) 2026 Duncan Tourolle. All rights reserved.\0";
let base = 8 + 2 + 2 * 12 + 4;
let mut extra = Vec::new();
extra.extend_from_slice(artist);
extra.extend_from_slice(copyright);
let jpeg = jpeg_with_exif(
&[
(exif_tag::ARTIST, 2, artist.len() as u32, base),
(
exif_tag::COPYRIGHT,
2,
copyright.len() as u32,
base + artist.len() as u32,
),
],
&extra,
);
let md = jpeg_metadata(&jpeg).expect("EXIF");
assert_eq!(md.artist.as_deref(), Some("Duncan Tourolle"));
assert_eq!(
md.copyright.as_deref(),
Some("(c) 2026 Duncan Tourolle. All rights reserved.")
);
}
#[test]
fn exposure_rationals_are_read_from_a_jpeg() {
// rawler fills these for a RAW; a camera JPEG has nothing behind it
// but this reader, and an export that lost the shutter speed lost it
// for good.
let base = 8 + 2 + 3 * 12 + 4;
let mut extra = Vec::new();
for (n, d) in [(1u32, 250u32), (28, 10), (850, 10)] {
extra.extend_from_slice(&n.to_le_bytes());
extra.extend_from_slice(&d.to_le_bytes());
}
let jpeg = jpeg_with_exif(
&[
(exif_tag::EXPOSURE_TIME, 5, 1, base),
(exif_tag::FNUMBER, 5, 1, base + 8),
(exif_tag::FOCAL_LENGTH, 5, 1, base + 16),
],
&extra,
);
let md = jpeg_metadata(&jpeg).expect("EXIF");
assert_eq!(md.shutter, Some(1.0 / 250.0));
assert_eq!(md.aperture, Some(2.8));
assert_eq!(md.focal_length, Some(85.0));
}
#[test]
fn a_zero_denominator_is_no_reading_rather_than_an_infinity() {
// Bodies do write `0/0` into a slot they had nothing for, and `inf`
// seconds carried into an exported file is worse than a gap.
let base = 8 + 2 + 12 + 4;
let mut extra = Vec::new();
extra.extend_from_slice(&0u32.to_le_bytes());
extra.extend_from_slice(&0u32.to_le_bytes());
let jpeg = jpeg_with_exif(&[(exif_tag::EXPOSURE_TIME, 5, 1, base)], &extra);
assert_eq!(jpeg_metadata(&jpeg).expect("EXIF").shutter, None);
}
#[test]
fn a_marker_walk_does_not_run_off_a_truncated_file() {
// Untrusted input (NFR-SEC-1): a length field claiming more than the