Files
dtourolle cd8750462f 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.
2026-08-22 19:01:18 +02:00

519 lines
20 KiB
Rust

//! TRACES: FR-EXP-8
//! Building an EXIF block, rather than copying one.
//!
//! # Why this is written by hand and not with a crate
//!
//! Two reasons, in order of importance.
//!
//! The first is the privacy behaviour. Every EXIF library worth using offers a
//! "load the source block, delete these tags, write it back" shape, and that
//! shape is the wrong one here: it makes the file that leaves the machine a
//! copy of the source's metadata *minus what we thought to remove*, so every
//! tag nobody has thought about — a vendor's proprietary sub-directory, a
//! serial number under a tag id this build has never seen — travels by
//! default. Constructing the block from a fixed list of parsed values inverts
//! that. What is written is exactly what appears in [`crate::SourceMetadata`],
//! and a tag that is not in this file cannot end up in the output no matter
//! what the source contained. The allowlist *is* the implementation.
//!
//! The second is the dependency policy. The root `Cargo.toml` explains why
//! nothing here may link C — this tree has to build under the Android NDK —
//! and the mature EXIF writers are bindings. This is a couple of hundred
//! lines of offset arithmetic against a specification that has not changed
//! since 2010, and it is the same TIFF structure `dr-decode` already reads.
//!
//! # What the block is
//!
//! A complete little-endian TIFF: an 8-byte header, IFD0 with the identity
//! and rights tags, an Exif sub-IFD with the capture tags, optionally a GPS
//! sub-IFD, and a heap of values too long to sit inside an entry. JPEG carries
//! it in an APP1 segment behind the marker `Exif\0\0`; PNG carries the same
//! bytes in an `eXIf` chunk with no marker. TIFF does not use this at all —
//! its own directory *is* the EXIF, so `encode.rs` writes the tags there
//! directly.
use crate::metadata::SourceMetadata;
/// One entry's value, in the handful of TIFF types this writer emits.
enum Value {
/// NUL-terminated, as the specification requires; the terminator is
/// counted, which is the detail readers trip over when it is missing.
Ascii(String),
Byte(Vec<u8>),
Short(u16),
Long(u32),
/// Type 7. Used only for `ExifVersion`, which is four characters that are
/// deliberately *not* a string.
Undefined(&'static [u8]),
/// Numerator and denominator pairs. A coordinate is three of them.
Rational(Vec<(u32, u32)>),
}
impl Value {
fn field_type(&self) -> u16 {
match self {
Value::Byte(_) => 1,
Value::Ascii(_) => 2,
Value::Short(_) => 3,
Value::Long(_) => 4,
Value::Rational(_) => 5,
Value::Undefined(_) => 7,
}
}
/// The element count, which is not the byte length: a rational counts as
/// one element per eight bytes.
fn count(&self) -> u32 {
match self {
Value::Ascii(s) => s.len() as u32 + 1,
Value::Byte(b) => b.len() as u32,
Value::Undefined(b) => b.len() as u32,
Value::Short(_) | Value::Long(_) => 1,
Value::Rational(r) => r.len() as u32,
}
}
/// The payload, in file order.
fn payload(&self) -> Vec<u8> {
match self {
Value::Ascii(s) => {
let mut out = s.as_bytes().to_vec();
out.push(0);
out
}
Value::Byte(b) => b.clone(),
Value::Undefined(b) => b.to_vec(),
Value::Short(v) => v.to_le_bytes().to_vec(),
Value::Long(v) => v.to_le_bytes().to_vec(),
Value::Rational(r) => r
.iter()
.flat_map(|(n, d)| {
let mut b = n.to_le_bytes().to_vec();
b.extend_from_slice(&d.to_le_bytes());
b
})
.collect(),
}
}
}
/// An IFD under construction.
type Entries = Vec<(u16, Value)>;
/// Tag numbers. Named rather than inlined because a mistyped one produces a
/// file that still parses and says something else entirely.
pub(crate) mod tag {
pub(crate) const MAKE: u16 = 0x010F;
pub(crate) const MODEL: u16 = 0x0110;
pub(crate) const SOFTWARE: u16 = 0x0131;
pub(crate) const DATE_TIME: u16 = 0x0132;
pub(crate) const ARTIST: u16 = 0x013B;
pub(crate) const COPYRIGHT: u16 = 0x8298;
pub(crate) const EXIF_IFD: u16 = 0x8769;
pub(crate) const GPS_IFD: u16 = 0x8825;
pub(crate) const EXPOSURE_TIME: u16 = 0x829A;
pub(crate) const FNUMBER: u16 = 0x829D;
pub(crate) const ISO: u16 = 0x8827;
pub(crate) const EXIF_VERSION: u16 = 0x9000;
pub(crate) const DATE_TIME_ORIGINAL: u16 = 0x9003;
pub(crate) const OFFSET_TIME_ORIGINAL: u16 = 0x9011;
pub(crate) const FOCAL_LENGTH: u16 = 0x920A;
pub(crate) const PIXEL_X: u16 = 0xA002;
pub(crate) const PIXEL_Y: u16 = 0xA003;
pub(crate) const LENS_MODEL: u16 = 0xA434;
pub(crate) const GPS_VERSION_ID: u16 = 0x0000;
pub(crate) const GPS_LATITUDE_REF: u16 = 0x0001;
pub(crate) const GPS_LATITUDE: u16 = 0x0002;
pub(crate) const GPS_LONGITUDE_REF: u16 = 0x0003;
pub(crate) const GPS_LONGITUDE: u16 = 0x0004;
pub(crate) const GPS_ALTITUDE_REF: u16 = 0x0005;
pub(crate) const GPS_ALTITUDE: u16 = 0x0006;
}
/// What DarkRoom calls itself in a file it wrote.
///
/// Not vanity: an export is a derived file, and a reader that knows which
/// program produced it can tell a camera original from a rendition without
/// guessing from the absence of a maker note.
pub(crate) const SOFTWARE: &str = "DarkRoom";
/// The complete EXIF block for JPEG's APP1 and PNG's `eXIf`.
///
/// `width`/`height` are the *exported* dimensions, not the source's: the
/// pixel-dimension tags describe the file they are in, and a reader that
/// trusts them after a resize would report the wrong size for the image it is
/// holding.
///
/// `None` where there is nothing to say. An empty EXIF block is not the same
/// as no EXIF block — it is a structure a reader must parse to discover it
/// learned nothing — and the second is the better file.
pub(crate) fn block(md: &SourceMetadata, width: u32, height: u32) -> Option<Vec<u8>> {
let ifd0 = main_entries(md);
let exif = exif_entries(md, width, height);
let gps = gps_entries(md);
if ifd0.is_empty() && exif.is_empty() && gps.is_empty() {
return None;
}
Some(assemble(ifd0, exif, gps))
}
/// Lay the three directories and their heap out in the block.
///
/// The order is fixed — IFD0, Exif, GPS, heap — because the pointers have to
/// be known before IFD0 is serialised, and an IFD's size is decided by its
/// entry count alone: two bytes of count, twelve per entry, four for the link
/// to the next directory.
fn assemble(mut ifd0: Entries, exif: Entries, gps: Entries) -> Vec<u8> {
const HEADER: u32 = 8;
let size = |n: usize| 2 + 12 * n as u32 + 4;
// The pointer entries are part of IFD0's count, so they have to be added
// before its size is taken — a chicken-and-egg the specification resolves
// by making entry size fixed.
let pointers = usize::from(!exif.is_empty()) + usize::from(!gps.is_empty());
let ifd0_size = size(ifd0.len() + pointers);
let exif_offset = HEADER + ifd0_size;
let gps_offset = exif_offset + if exif.is_empty() { 0 } else { size(exif.len()) };
let heap_base = gps_offset + if gps.is_empty() { 0 } else { size(gps.len()) };
if !exif.is_empty() {
ifd0.push((tag::EXIF_IFD, Value::Long(exif_offset)));
}
if !gps.is_empty() {
ifd0.push((tag::GPS_IFD, Value::Long(gps_offset)));
}
let mut heap = Vec::new();
let ifd0_bytes = directory(ifd0, heap_base, &mut heap);
let exif_bytes = directory(exif, heap_base, &mut heap);
let gps_bytes = directory(gps, heap_base, &mut heap);
let mut out = Vec::with_capacity(HEADER as usize + heap.len() + 128);
// Little-endian, magic 42, first directory at byte 8. Little-endian
// because every value written below is, and a header that disagreed with
// its own body is the one corruption a reader cannot recover from.
out.extend_from_slice(b"II");
out.extend_from_slice(&42u16.to_le_bytes());
out.extend_from_slice(&HEADER.to_le_bytes());
out.extend_from_slice(&ifd0_bytes);
out.extend_from_slice(&exif_bytes);
out.extend_from_slice(&gps_bytes);
out.extend_from_slice(&heap);
out
}
/// Serialise one directory, spilling long values onto the shared heap.
///
/// Entries are sorted by tag: TIFF requires ascending order within a
/// directory, and while most readers cope with any order, the ones that
/// binary-search stop at the first tag they cannot place.
fn directory(mut entries: Entries, heap_base: u32, heap: &mut Vec<u8>) -> Vec<u8> {
if entries.is_empty() {
return Vec::new();
}
entries.sort_by_key(|(tag, _)| *tag);
let mut out = Vec::with_capacity(2 + entries.len() * 12 + 4);
out.extend_from_slice(&(entries.len() as u16).to_le_bytes());
for (tag, value) in &entries {
out.extend_from_slice(&tag.to_le_bytes());
out.extend_from_slice(&value.field_type().to_le_bytes());
out.extend_from_slice(&value.count().to_le_bytes());
let payload = value.payload();
if payload.len() <= 4 {
// Four bytes or fewer live in the entry itself, left-justified and
// zero-padded.
let mut inline = payload.clone();
inline.resize(4, 0);
out.extend_from_slice(&inline);
} else {
out.extend_from_slice(&(heap_base + heap.len() as u32).to_le_bytes());
heap.extend_from_slice(&payload);
// Values start on even offsets. Not every reader cares; the ones
// that do read a short from an odd address and get nonsense.
if heap.len() % 2 == 1 {
heap.push(0);
}
}
}
// No directory follows this one. The Exif and GPS sub-directories are
// pointed at, not chained, so this is zero in all three.
out.extend_from_slice(&0u32.to_le_bytes());
out
}
/// IFD0: who took it, with what, and who owns it.
///
/// **No orientation tag, deliberately.** The frame reaching the encoder has
/// already had the source's orientation applied by the pipeline — it is
/// upright pixels — so copying the source's tag across would tell every
/// reader to rotate an image that is already the right way up. A portrait
/// frame would come out on its side in exactly the viewers that honour the
/// tag, which is most of them.
fn main_entries(md: &SourceMetadata) -> Entries {
let mut e = Entries::new();
push_ascii(&mut e, tag::MAKE, md.make.as_deref());
push_ascii(&mut e, tag::MODEL, md.model.as_deref());
push_ascii(&mut e, tag::ARTIST, md.artist.as_deref());
push_ascii(&mut e, tag::COPYRIGHT, md.copyright.as_deref());
e.push((tag::SOFTWARE, Value::Ascii(SOFTWARE.to_string())));
// IFD0's `DateTime` is nominally when the file was written, and this is
// the capture time instead. That is what the rest of the world does —
// and it is what `dr-decode` falls back to for scanner output that has no
// `DateTimeOriginal` — so a re-import of an export lands on the timeline
// where the original did rather than on the day it was exported.
if let Some(t) = md.captured_at.map(datetime) {
e.push((tag::DATE_TIME, Value::Ascii(t)));
}
e
}
/// The Exif sub-IFD: the exposure, and what made it.
fn exif_entries(md: &SourceMetadata, width: u32, height: u32) -> Entries {
let mut e = Entries::new();
// "0232" is Exif 2.32. A sub-directory without a version is technically
// malformed, and some readers refuse the whole block over it.
e.push((tag::EXIF_VERSION, Value::Undefined(b"0232")));
e.push((tag::PIXEL_X, Value::Long(width)));
e.push((tag::PIXEL_Y, Value::Long(height)));
push_ascii(&mut e, tag::LENS_MODEL, md.lens.as_deref());
if let Some(t) = md.captured_at.map(datetime) {
e.push((tag::DATE_TIME_ORIGINAL, Value::Ascii(t)));
}
if let Some(o) = md.captured_offset.map(offset) {
e.push((tag::OFFSET_TIME_ORIGINAL, Value::Ascii(o)));
}
if let Some(s) = md.shutter.filter(|s| *s > 0.0) {
e.push((tag::EXPOSURE_TIME, Value::Rational(vec![shutter(s)])));
}
if let Some(f) = md.aperture.filter(|f| *f > 0.0) {
e.push((tag::FNUMBER, Value::Rational(vec![tenths(f)])));
}
if let Some(f) = md.focal_length.filter(|f| *f > 0.0) {
e.push((tag::FOCAL_LENGTH, Value::Rational(vec![tenths(f)])));
}
// The tag is a SHORT, so a sensitivity above 65535 has no representation
// in it. Dropped rather than truncated: ISO 102400 written as 36864 is a
// lie, and an absent tag is not.
if let Some(iso) = md.iso.filter(|v| *v <= u32::from(u16::MAX)) {
e.push((tag::ISO, Value::Short(iso as u16)));
}
e
}
/// The GPS sub-IFD.
///
/// Empty unless the caller has already decided that coordinates may be
/// written — see [`SourceMetadata::sanitised`], which is where the stripping
/// happens. Nothing in this file consults the settings, so there is exactly
/// one place to look to answer "can this export carry a location".
fn gps_entries(md: &SourceMetadata) -> Entries {
let Some(loc) = md.location else {
return Entries::new();
};
let mut e = Entries::new();
// 2.3.0.0, the current GPS tag version.
e.push((tag::GPS_VERSION_ID, Value::Byte(vec![2, 3, 0, 0])));
e.push((
tag::GPS_LATITUDE_REF,
Value::Ascii(if loc.latitude < 0.0 { "S" } else { "N" }.into()),
));
e.push((tag::GPS_LATITUDE, Value::Rational(dms(loc.latitude))));
e.push((
tag::GPS_LONGITUDE_REF,
Value::Ascii(if loc.longitude < 0.0 { "W" } else { "E" }.into()),
));
e.push((tag::GPS_LONGITUDE, Value::Rational(dms(loc.longitude))));
if let Some(alt) = loc.altitude {
// The altitude itself is unsigned; below sea level is a separate byte.
e.push((
tag::GPS_ALTITUDE_REF,
Value::Byte(vec![u8::from(alt < 0.0)]),
));
e.push((
tag::GPS_ALTITUDE,
Value::Rational(vec![((alt.abs() * 100.0).round() as u32, 100)]),
));
}
e
}
fn push_ascii(entries: &mut Entries, tag: u16, value: Option<&str>) {
// An empty string is a tag saying nothing, which is worse than no tag: it
// overwrites whatever a reader would otherwise have inferred.
if let Some(v) = value.map(str::trim).filter(|v| !v.is_empty()) {
entries.push((tag, Value::Ascii(v.to_string())));
}
}
/// Signed degrees back into the tag's degrees/minutes/seconds.
///
/// The sign is carried by the hemisphere letter, so this takes the magnitude.
/// Seconds keep four decimal places, which is about 3 mm — far finer than any
/// consumer fix, and enough that a round trip through the tag does not move
/// the pin.
pub(crate) fn dms(degrees: f64) -> Vec<(u32, u32)> {
let d = degrees.abs();
let whole = d.trunc();
let minutes = (d - whole) * 60.0;
let seconds = (minutes - minutes.trunc()) * 60.0;
vec![
(whole as u32, 1),
(minutes.trunc() as u32, 1),
((seconds * 10_000.0).round() as u32, 10_000),
]
}
/// A shutter speed as the fraction a photographer would recognise.
///
/// `1/250`, not `4/1000`. Both are the same number and every reader computes
/// the same exposure from either, but the first is what the camera wrote and
/// what a properties panel displays verbatim.
pub(crate) fn shutter(seconds: f32) -> (u32, u32) {
if seconds < 1.0 {
(1, (1.0 / seconds).round().max(1.0) as u32)
} else {
((seconds * 10.0).round() as u32, 10)
}
}
/// f/2.8 and 85 mm as tenths, which is how cameras write both.
pub(crate) fn tenths(value: f32) -> (u32, u32) {
((value * 10.0).round().max(0.0) as u32, 10)
}
/// Unix seconds as EXIF's `"YYYY:MM:DD HH:MM:SS"`.
///
/// The reading is a wall clock with no zone — that is what the tag means, and
/// what `dr-decode` parsed it as — so this is the exact inverse of that parse
/// and involves no timezone conversion. The zone, where the source recorded
/// one, travels separately in `OffsetTimeOriginal`.
pub(crate) fn datetime(unix: i64) -> String {
let days = unix.div_euclid(86_400);
let secs = unix.rem_euclid(86_400);
// Howard Hinnant's civil-from-days, the inverse of the days-from-civil
// that `dr-decode` uses to parse. Eras of 400 years, shifted so that the
// arithmetic never sees a negative.
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
format!(
"{y:04}:{m:02}:{d:02} {:02}:{:02}:{:02}",
secs / 3600,
(secs / 60) % 60,
secs % 60
)
}
/// Minutes east of UTC as EXIF's `"+HH:MM"`.
pub(crate) fn offset(minutes: i32) -> String {
let sign = if minutes < 0 { '-' } else { '+' };
let m = minutes.unsigned_abs();
format!("{sign}{:02}:{:02}", m / 60, m % 60)
}
#[cfg(test)]
mod tests {
use super::*;
use dr_types::Location;
#[test]
fn a_capture_time_survives_the_round_trip_through_the_tag() {
// The parse side lives in `dr-decode` and is exercised against real
// files; this is the inverse, and the two meeting in the middle is
// what keeps an exported frame on the same point of the timeline as
// the original.
assert_eq!(datetime(1_372_462_374), "2013:06:28 23:32:54");
assert_eq!(datetime(0), "1970:01:01 00:00:00");
// A leap day, which is where a hand-rolled calendar goes wrong.
assert_eq!(datetime(1_709_164_800), "2024:02:29 00:00:00");
}
#[test]
fn a_zone_is_written_the_way_the_tag_spells_it() {
assert_eq!(offset(120), "+02:00");
assert_eq!(offset(-330), "-05:30");
assert_eq!(offset(0), "+00:00");
}
#[test]
fn a_shutter_speed_keeps_the_photographers_fraction() {
assert_eq!(shutter(1.0 / 250.0), (1, 250));
assert_eq!(shutter(2.5), (25, 10));
}
#[test]
fn degrees_round_trip_through_the_tags_triple() {
// 48.8582 N is the Eiffel Tower; the check is that the three-part
// form comes back to the same place, to well under a metre.
for degrees in [48.8582_f64, -33.8568, 0.0, 179.999] {
let parts = dms(degrees);
let back = parts[0].0 as f64
+ parts[1].0 as f64 / 60.0
+ (parts[2].0 as f64 / parts[2].1 as f64) / 3600.0;
assert!(
(back - degrees.abs()).abs() < 1e-6,
"{degrees} came back as {back}"
);
}
}
#[test]
fn an_empty_source_produces_no_block_at_all() {
// Every field absent means the only entries would be the ones this
// writer adds itself. That is still worth writing — `Software` and
// the pixel dimensions are true statements — so the block exists; what
// must not happen is a *malformed* one.
let md = SourceMetadata::default();
let bytes = block(&md, 100, 50).expect("the writer's own tags");
assert!(bytes.starts_with(b"II*\0"));
}
#[test]
fn the_gps_directory_is_absent_when_there_is_no_position() {
let md = SourceMetadata {
make: Some("Canon".into()),
..Default::default()
};
let bytes = block(&md, 10, 10).unwrap();
assert!(!contains_entry(&bytes, tag::GPS_IFD));
}
#[test]
fn the_gps_directory_is_present_when_there_is_one() {
// The counterpart of the test above: a strip test that passed because
// the writer could never emit GPS at all would prove nothing.
let md = SourceMetadata {
location: Location::new(48.8582, 2.2945, Some(35.0)),
..Default::default()
};
let bytes = block(&md, 10, 10).unwrap();
assert!(contains_entry(&bytes, tag::GPS_IFD));
}
/// Whether a directory entry for `tag` appears anywhere in the block.
///
/// Byte-level on purpose: an entry is a tag, a type and a count, and
/// searching for that twelve-byte shape's first eight bytes is a far
/// stronger statement than asking a parser that might have skipped the
/// directory the tag was in.
fn contains_entry(bytes: &[u8], tag: u16) -> bool {
bytes
.windows(4)
.any(|w| w[..2] == tag.to_le_bytes() && (w[2] == 4 || w[2] == 13) && w[3] == 0)
}
}