//! 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), 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 { 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> { 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 { 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) -> Vec { 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) } }