From cd8750462f096df6457ca84f2b3fcd69a0cec8b5 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 22 Aug 2026 19:01:18 +0200 Subject: [PATCH 1/2] 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. --- core/dr-decode/src/lib.rs | 76 +++- core/dr-decode/src/locate.rs | 319 +++++++++++++++ core/dr-export/src/encode.rs | 697 +++++++++++++++++++++++++++++++-- core/dr-export/src/exif.rs | 518 ++++++++++++++++++++++++ core/dr-export/src/lib.rs | 41 +- core/dr-export/src/metadata.rs | 106 +++++ core/dr-types/src/lib.rs | 45 +++ core/dr-types/src/settings.rs | 41 ++ 8 files changed, 1797 insertions(+), 46 deletions(-) create mode 100644 core/dr-export/src/exif.rs create mode 100644 core/dr-export/src/metadata.rs diff --git a/core/dr-decode/src/lib.rs b/core/dr-decode/src/lib.rs index 66892ee..a591fad 100644 --- a/core/dr-decode/src/lib.rs +++ b/core/dr-decode/src/lib.rs @@ -21,8 +21,8 @@ pub mod profile; pub use base_curve::BaseCurve; pub use error::DecodeError; pub use locate::{ - defects, is_complete_jpeg, locate_preview, BadLine, BadPixel, Defects, PreviewLocation, - HEADER_BYTES, + defects, is_complete_jpeg, jpeg_metadata, locate_preview, tiff_metadata, BadLine, BadPixel, + Defects, PreviewLocation, HEADER_BYTES, }; pub use preview::{ decode_jpeg, extract_embedded_preview, extract_preview, Preview, PreviewSize, @@ -30,7 +30,7 @@ pub use preview::{ }; pub use profile::CameraProfile; -use dr_types::{Format, Orientation}; +use dr_types::{Format, Location, Orientation}; /// Capture metadata read from a file header. #[derive(Debug, Clone, Default, PartialEq)] @@ -68,6 +68,27 @@ pub struct Metadata { /// to where it was taken, so without this a shoot in Tokyo displays on the /// wrong day in Paris. pub captured_offset: Option, + /// TRACES: FR-EXP-8 + /// Who made the photograph (EXIF `Artist`, 0x013B). + /// + /// Read for the sake of exporting it again: a photographer who set a byline + /// in-camera set it so that it would still be there in the copy they hand + /// over, and an export that dropped it would be quietly removing the one + /// piece of metadata that says whose work this is. + pub artist: Option, + /// TRACES: FR-EXP-8 + /// The rights statement (EXIF `Copyright`, 0x8298). + pub copyright: Option, + /// TRACES: FR-EXP-8 + /// Where the shutter fired (the EXIF GPS directory, 0x8825). + /// + /// **Read, but treated as radioactive downstream.** This is the field + /// FR-EXP-8's strip option exists for, and the export path drops it unless + /// the user has explicitly said otherwise (`ExportSettings::strip_location` + /// defaults to on). Parsing it here rather than refusing to look is what + /// makes "keep my coordinates" possible at all, and what lets the exporter + /// prove the field is gone rather than hope it was never present. + pub location: Option, } /// Decoded sensor data, before demosaic. @@ -302,6 +323,10 @@ pub fn metadata(bytes: &[u8]) -> Result { .as_deref() .or(exif.offset_time.as_deref()) .and_then(parse_exif_offset), + // TRACES: FR-EXP-8 + artist: exif.artist.clone().filter(|s| !s.trim().is_empty()), + copyright: exif.copyright.clone().filter(|s| !s.trim().is_empty()), + location: exif.gps.as_ref().and_then(rawler_location), }; // rawler reports no capture time for some TIFF-derived files whose tag is @@ -322,12 +347,57 @@ pub fn metadata(bytes: &[u8]) -> Result { out.iso = out.iso.or(fallback.iso); out.lens = out.lens.take().or(fallback.lens); out.orientation = out.orientation.or(fallback.orientation); + // TRACES: FR-EXP-8 + // Filled from the same walk for the same reason: a file rawler + // answered short on is one whose byline and rights statement would + // otherwise be dropped at export, and both sit in the IFD this has + // already read. + out.artist = out.artist.take().or(fallback.artist); + out.copyright = out.copyright.take().or(fallback.copyright); + out.location = out.location.or(fallback.location); } } Ok(out) } +/// TRACES: FR-EXP-8 +/// rawler's GPS directory as a position. +/// +/// The three-rational form is the tag's, not a position's: degrees, minutes +/// and seconds, each a fraction, with the hemisphere in a separate letter. +/// Everything downstream wants a number it can compare and write back, so the +/// conversion happens once, here. +fn rawler_location(gps: &rawler::exif::ExifGPS) -> Option { + /// A `Rational` as a float, with a zero denominator refused rather than + /// divided by — some bodies write `0/0` into an unfilled slot. + fn ratio(r: &rawler::formats::tiff::Rational) -> Option { + (r.d != 0).then(|| r.n as f64 / r.d as f64) + } + + fn degrees(dms: &[rawler::formats::tiff::Rational; 3], reference: Option<&String>) -> Option { + let d = ratio(&dms[0])? + ratio(&dms[1])? / 60.0 + ratio(&dms[2])? / 3600.0; + // South and west are stored as positive magnitudes with a letter. + let negative = matches!( + reference.map(|s| s.trim().to_ascii_uppercase()).as_deref(), + Some("S") | Some("W") + ); + Some(if negative { -d } else { d }) + } + + let latitude = degrees(gps.gps_latitude.as_ref()?, gps.gps_latitude_ref.as_ref())?; + let longitude = degrees(gps.gps_longitude.as_ref()?, gps.gps_longitude_ref.as_ref())?; + // Reference 1 means below sea level; the altitude itself is unsigned. + let altitude = gps.gps_altitude.as_ref().and_then(ratio).map(|a| { + if gps.gps_altitude_ref == Some(1) { + -a + } else { + a + } + }); + Location::new(latitude, longitude, altitude) +} + /// TRACES: FR-CAT-5 | FR-DEV-3h /// Read just the stored orientation, from a file header. /// diff --git a/core/dr-decode/src/locate.rs b/core/dr-decode/src/locate.rs index 025ab7b..465cc22 100644 --- a/core/dr-decode/src/locate.rs +++ b/core/dr-decode/src/locate.rs @@ -287,6 +287,16 @@ impl<'a> TiffReader<'a> { /// its offset. fn scalar(&self, e: &Entry) -> Option { 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 { + // 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 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 { + 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 { + 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 { + // 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 diff --git a/core/dr-export/src/encode.rs b/core/dr-export/src/encode.rs index 6495a33..01f96d2 100644 --- a/core/dr-export/src/encode.rs +++ b/core/dr-export/src/encode.rs @@ -21,38 +21,78 @@ //! //! # Metadata //! -//! Nothing is written. `strip_location` defaults to on (FR-EXP-8) and this -//! satisfies it in the strongest possible way: there is no EXIF block, so -//! there is no GPS tag, no serial number, and no lens history in the file -//! that leaves the machine. +//! Written the same way the profile is: in the place each container puts it — +//! a JPEG APP1 segment behind `Exif\0\0`, a PNG `eXIf` chunk, and for TIFF the +//! image directory itself, since a TIFF's own IFD *is* EXIF and a nested block +//! would be a second, contradictory copy. //! -//! The other half of FR-EXP-8 — *retaining* camera and copyright metadata -//! when the user asks for it — is not implemented, and cannot be faked by -//! omission. It needs the source's EXIF carried through `dr-decode` and -//! re-serialised here, which is a piece of work in its own right and belongs -//! with the batch-export path that would make it worth having. +//! What may be written is decided before the bytes are: [`SourceMetadata`] is +//! an allowlist of parsed fields rather than a copy of the source's block, and +//! `sanitised` empties the location out of it unless the user asked otherwise. +//! By the time any function below runs there is no privacy decision left to +//! make, which is deliberate — the alternative is four encoders each of which +//! could disagree with the others about what a setting meant. +//! +//! Two settings govern it and they are not the same question (FR-EXP-8). +//! `retain_metadata` decides whether the copy says what took the photograph +//! and who owns it; off, nothing at all is written and the file is as bare as +//! this module used to make every export. `strip_location` decides whether it +//! says where, and defaults to on — so the ordinary export carries the camera, +//! the lens, the capture time and the copyright, and carries no coordinates. +//! +//! Absence, not blanking. A stripped export has no GPS directory: not one +//! full of zeroes, which would still tell a reader that this file had a fix +//! and that somebody removed it. use dr_types::{ExportFormat, ExportSettings}; -use crate::{icc, ExportError}; +use crate::metadata::SourceMetadata; +use crate::{exif, icc, ExportError}; /// Encode a resized, sharpened RGBA buffer to the requested format. /// /// The buffer is already encoded into `settings.colour_space` — that happened /// in the shader, at the only point where the unclipped colour still existed. /// All that is left here is to say so. +/// +/// `source` is what the file being exported was read from, or `None` where the +/// caller has none — a frame assembled rather than decoded. It is sanitised +/// here, once, before any encoder sees it. pub fn encode( rgba: &[u8], width: u32, height: u32, settings: &ExportSettings, + source: Option<&SourceMetadata>, ) -> Result, ExportError> { let profile = icc::profile(settings.colour_space); + + // TRACES: FR-EXP-8 + // The one place the settings are consulted. Retention off means the + // `None` propagates and every encoder below writes the bare file it always + // did; retention on means what travels is the sanitised copy, which has + // already lost the location unless the user turned stripping off. + let carried = source + .filter(|_| settings.retain_metadata) + .map(|m| m.sanitised(settings.strip_location)); + // JPEG and PNG take a finished block; TIFF writes the tags into its own + // directory and needs the fields. + let block = carried + .as_ref() + .and_then(|m| exif::block(m, width, height)); + match settings.format { - ExportFormat::Jpeg => jpeg(rgba, width, height, settings.quality, &profile), - ExportFormat::Png => png(rgba, width, height, &profile), - ExportFormat::Tiff8 => tiff8(rgba, width, height, &profile), - ExportFormat::Tiff16 => tiff16(rgba, width, height, &profile), + ExportFormat::Jpeg => jpeg( + rgba, + width, + height, + settings.quality, + &profile, + block.as_deref(), + ), + ExportFormat::Png => png(rgba, width, height, &profile, block.as_deref()), + ExportFormat::Tiff8 => tiff8(rgba, width, height, &profile, carried.as_ref()), + ExportFormat::Tiff16 => tiff16(rgba, width, height, &profile, carried.as_ref()), other => Err(ExportError::FormatUnsupported(other)), } } @@ -72,9 +112,20 @@ fn jpeg( height: u32, quality: u8, profile: &[u8], + exif: Option<&[u8]>, ) -> Result, ExportError> { let mut bytes = Vec::new(); let mut encoder = jpeg_encoder::Encoder::new(&mut bytes, quality); + // TRACES: FR-EXP-8 + // Before the profile, because segments are written in the order they are + // added and EXIF conventionally comes first — APP1 then APP2. Readers that + // stop at the first APP2 they find would otherwise have to walk past the + // profile to reach the capture data. + if let Some(exif) = exif { + encoder + .add_exif_metadata(exif) + .map_err(|e| ExportError::Encode(e.to_string()))?; + } // Splits across APP2 segments itself if it has to. The profiles this crate // generates fit in one, but the branch is the encoder's rather than ours. encoder @@ -91,7 +142,13 @@ fn jpeg( Ok(bytes) } -fn png(rgba: &[u8], width: u32, height: u32, profile: &[u8]) -> Result, ExportError> { +fn png( + rgba: &[u8], + width: u32, + height: u32, + profile: &[u8], + exif: Option<&[u8]>, +) -> Result, ExportError> { let mut bytes = Vec::new(); { // Built through `Info` rather than the setters, because the profile is @@ -103,6 +160,13 @@ fn png(rgba: &[u8], width: u32, height: u32, profile: &[u8]) -> Result, info.color_type = png::ColorType::Rgb; info.bit_depth = png::BitDepth::Eight; info.icc_profile = Some(std::borrow::Cow::Borrowed(profile)); + // TRACES: FR-EXP-8 + // PNG's `eXIf` chunk holds the same TIFF structure a JPEG's APP1 does, + // minus the `Exif\0\0` marker — the chunk name has already said what + // it is. Standardised in PNG's third edition and read by every current + // viewer; older ones ignore an unknown ancillary chunk, which is the + // correct failure. + info.exif_metadata = exif.map(std::borrow::Cow::Borrowed); let encoder = png::Encoder::with_info(&mut bytes, info) .map_err(|e| ExportError::Encode(e.to_string()))?; @@ -119,15 +183,16 @@ fn png(rgba: &[u8], width: u32, height: u32, profile: &[u8]) -> Result, Ok(bytes) } -/// The ICC profile as a TIFF tag value. +/// Bytes whose TIFF field type is `UNDEFINED` (7). /// -/// A newtype only because the tag's field type is `UNDEFINED` (7) and the -/// `tiff` crate maps a plain `&[u8]` to `BYTE` (1). Both are single bytes and -/// most readers do not look, but libtiff declares `TIFFTAG_ICCPROFILE` as -/// undefined and a strict reader is entitled to agree with it. -struct IccTag<'a>(&'a [u8]); +/// A newtype only because the `tiff` crate maps a plain `&[u8]` to `BYTE` (1). +/// Both are single bytes and most readers do not look, but libtiff declares +/// `TIFFTAG_ICCPROFILE` as undefined and a strict reader is entitled to agree +/// with it. `ExifVersion` is the same shape for a different reason: it is four +/// characters that are deliberately not a string. +struct Undefined<'a>(&'a [u8]); -impl tiff::encoder::TiffValue for IccTag<'_> { +impl tiff::encoder::TiffValue for Undefined<'_> { const BYTE_LEN: u8 = 1; const FIELD_TYPE: tiff::tags::Type = tiff::tags::Type::UNDEFINED; @@ -140,19 +205,85 @@ impl tiff::encoder::TiffValue for IccTag<'_> { } } +/// TRACES: FR-EXP-8 +/// A string as an EXIF `ASCII` value. +/// +/// The `tiff` crate's own `str` value *rejects* anything non-ASCII, which +/// would turn a copyright line reading `© 2026 Frédéric` into a failed export +/// — the file not written at all, over a character. Cameras and every other +/// editor write UTF-8 into these fields regardless of what the 1992 +/// specification says, `dr-decode` reads them back with `from_utf8_lossy`, and +/// a mangled accent is a far better outcome than a refusal. So the bytes go +/// through verbatim with the terminating NUL the type requires. +struct Ascii<'a>(&'a str); + +impl tiff::encoder::TiffValue for Ascii<'_> { + const BYTE_LEN: u8 = 1; + const FIELD_TYPE: tiff::tags::Type = tiff::tags::Type::ASCII; + + fn count(&self) -> usize { + // The NUL is part of the count, and a reader that trusts the count + // over the terminator reads one character short without it. + self.0.len() + 1 + } + + fn data(&self) -> std::borrow::Cow<'_, [u8]> { + let mut out = self.0.as_bytes().to_vec(); + out.push(0); + std::borrow::Cow::Owned(out) + } +} + +/// TRACES: FR-EXP-8 +/// Several `RATIONAL`s in one tag — a GPS coordinate is three. +/// +/// The bytes are **native-endian** rather than little-endian, unlike +/// everything `exif.rs` writes. That is not an inconsistency: the `tiff` crate +/// writes the file in the host's byte order and stamps the header to match, so +/// a value that forced little-endian would be read back byte-swapped on a +/// big-endian machine. `exif.rs` builds its own header and so chooses its own +/// order; here the container has already chosen. +struct Rationals<'a>(&'a [(u32, u32)]); + +impl tiff::encoder::TiffValue for Rationals<'_> { + const BYTE_LEN: u8 = 8; + const FIELD_TYPE: tiff::tags::Type = tiff::tags::Type::RATIONAL; + + fn count(&self) -> usize { + self.0.len() + } + + fn data(&self) -> std::borrow::Cow<'_, [u8]> { + let mut out = Vec::with_capacity(self.0.len() * 8); + for (n, d) in self.0 { + out.extend_from_slice(&n.to_ne_bytes()); + out.extend_from_slice(&d.to_ne_bytes()); + } + std::borrow::Cow::Owned(out) + } +} + /// Tag 34675, `InterColourProfile`. Not in the `tiff` crate's `Tag` enum. const TAG_ICC_PROFILE: u16 = 34675; -fn tiff8(rgba: &[u8], width: u32, height: u32, profile: &[u8]) -> Result, ExportError> { +fn tiff8( + rgba: &[u8], + width: u32, + height: u32, + profile: &[u8], + source: Option<&SourceMetadata>, +) -> Result, ExportError> { use tiff::encoder::{colortype, TiffEncoder}; let mut bytes = std::io::Cursor::new(Vec::new()); let mut encoder = TiffEncoder::new(&mut bytes).map_err(|e| ExportError::Encode(e.to_string()))?; + let sub = sub_directories(&mut encoder, source, width, height)?; let mut image = encoder .new_image::(width, height) .map_err(|e| ExportError::Encode(e.to_string()))?; tag_profile(image.encoder(), profile)?; + tag_metadata(image.encoder(), source, &sub)?; image .write_data(&rgb(rgba)) .map_err(|e| ExportError::Encode(e.to_string()))?; @@ -172,10 +303,214 @@ where W: std::io::Write + std::io::Seek, K: tiff::encoder::TiffKind, { - dir.write_tag(tiff::tags::Tag::Unknown(TAG_ICC_PROFILE), IccTag(profile)) + dir.write_tag(tiff::tags::Tag::Unknown(TAG_ICC_PROFILE), Undefined(profile)) .map_err(|e| ExportError::Encode(e.to_string())) } +/// TRACES: FR-EXP-8 +/// Where the Exif and GPS directories ended up in the file. +/// +/// Byte offsets from the start of the TIFF, which is what the pointer tags in +/// the image directory hold. `None` where that directory was not written at +/// all — the GPS one is `None` for every export that stripped the location, +/// and then no pointer is written either, so the file has no trace of the +/// directory rather than a pointer to an empty one. +#[derive(Default)] +struct SubDirectories { + exif: Option, + gps: Option, +} + +/// TRACES: FR-EXP-8 +/// Write the Exif and GPS sub-directories, ahead of the image. +/// +/// **Ahead of it because a pointer has to point at something.** The image +/// directory carries `ExifDirectory` and `GpsDirectory` as byte offsets, so +/// the directories they name have to exist and have known positions before +/// that entry is written. The `tiff` crate calls these "extra" directories: +/// written into the file but not linked into the chain a reader walks for +/// images, which is exactly what a sub-IFD is. +/// +/// A TIFF gets no separate EXIF *block* — no APP1, no `eXIf` chunk. Its own +/// directory is the EXIF structure, and adding a second copy inside it would +/// give a reader two answers to every question. +fn sub_directories( + encoder: &mut tiff::encoder::TiffEncoder, + source: Option<&SourceMetadata>, + width: u32, + height: u32, +) -> Result +where + W: std::io::Write + std::io::Seek, +{ + use tiff::tags::Tag; + + let Some(md) = source else { + return Ok(SubDirectories::default()); + }; + let mut out = SubDirectories::default(); + + { + let mut dir = encoder + .extra_directory() + .map_err(|e| ExportError::Encode(e.to_string()))?; + let write = |dir: &mut tiff::encoder::DirectoryEncoder<'_, W, _>| -> tiff::TiffResult<()> { + // "0232" is Exif 2.32. A directory without a version is malformed, + // and some readers discard the whole thing over it. + dir.write_tag(Tag::ExifVersion, Undefined(b"0232"))?; + dir.write_tag(Tag::Unknown(exif::tag::PIXEL_X), width)?; + dir.write_tag(Tag::Unknown(exif::tag::PIXEL_Y), height)?; + if let Some(lens) = trimmed(md.lens.as_deref()) { + dir.write_tag(Tag::Unknown(exif::tag::LENS_MODEL), Ascii(lens))?; + } + if let Some(t) = md.captured_at.map(exif::datetime) { + dir.write_tag(Tag::Unknown(exif::tag::DATE_TIME_ORIGINAL), Ascii(&t))?; + } + if let Some(o) = md.captured_offset.map(exif::offset) { + dir.write_tag(Tag::Unknown(exif::tag::OFFSET_TIME_ORIGINAL), Ascii(&o))?; + } + if let Some(s) = md.shutter.filter(|s| *s > 0.0) { + dir.write_tag( + Tag::Unknown(exif::tag::EXPOSURE_TIME), + Rationals(&[exif::shutter(s)]), + )?; + } + if let Some(f) = md.aperture.filter(|f| *f > 0.0) { + dir.write_tag(Tag::Unknown(exif::tag::FNUMBER), Rationals(&[exif::tenths(f)]))?; + } + if let Some(f) = md.focal_length.filter(|f| *f > 0.0) { + dir.write_tag( + Tag::Unknown(exif::tag::FOCAL_LENGTH), + Rationals(&[exif::tenths(f)]), + )?; + } + // A SHORT cannot hold ISO 102400, so it is dropped rather than + // wrapped round to a number that looks plausible and is not. + if let Some(iso) = md.iso.filter(|v| *v <= u32::from(u16::MAX)) { + dir.write_tag(Tag::Unknown(exif::tag::ISO), iso as u16)?; + } + Ok(()) + }; + write(&mut dir).map_err(|e| ExportError::Encode(e.to_string()))?; + let offsets = dir + .finish_with_offsets() + .map_err(|e| ExportError::Encode(e.to_string()))?; + // A classic TIFF cannot exceed 4 GB, so the pointer is a `LONG`; the + // crate keeps the offset as a `u64` only because BigTIFF shares the + // type. + out.exif = Some(offsets.pointer.0 as u32); + } + + // TRACES: FR-EXP-8 + // Only reached when a location survived sanitising, which it does only + // when the user turned stripping off. There is no "write an empty GPS + // directory" branch, deliberately. + if let Some(loc) = md.location { + let mut dir = encoder + .extra_directory() + .map_err(|e| ExportError::Encode(e.to_string()))?; + let write = |dir: &mut tiff::encoder::DirectoryEncoder<'_, W, _>| -> tiff::TiffResult<()> { + dir.write_tag(Tag::Unknown(exif::tag::GPS_VERSION_ID), &[2u8, 3, 0, 0][..])?; + dir.write_tag( + Tag::Unknown(exif::tag::GPS_LATITUDE_REF), + Ascii(if loc.latitude < 0.0 { "S" } else { "N" }), + )?; + dir.write_tag( + Tag::Unknown(exif::tag::GPS_LATITUDE), + Rationals(&exif::dms(loc.latitude)), + )?; + dir.write_tag( + Tag::Unknown(exif::tag::GPS_LONGITUDE_REF), + Ascii(if loc.longitude < 0.0 { "W" } else { "E" }), + )?; + dir.write_tag( + Tag::Unknown(exif::tag::GPS_LONGITUDE), + Rationals(&exif::dms(loc.longitude)), + )?; + if let Some(alt) = loc.altitude { + dir.write_tag( + Tag::Unknown(exif::tag::GPS_ALTITUDE_REF), + &[u8::from(alt < 0.0)][..], + )?; + dir.write_tag( + Tag::Unknown(exif::tag::GPS_ALTITUDE), + Rationals(&[((alt.abs() * 100.0).round() as u32, 100)]), + )?; + } + Ok(()) + }; + write(&mut dir).map_err(|e| ExportError::Encode(e.to_string()))?; + let offsets = dir + .finish_with_offsets() + .map_err(|e| ExportError::Encode(e.to_string()))?; + out.gps = Some(offsets.pointer.0 as u32); + } + + Ok(out) +} + +/// TRACES: FR-EXP-8 +/// The identity and rights tags, in the image directory itself. +/// +/// These are baseline TIFF tags rather than EXIF private ones — `Make`, +/// `Model`, `Artist`, `Copyright` and `DateTime` have been in the TIFF +/// specification since 1992 — so a reader that knows nothing about EXIF still +/// finds them. The capture tags cannot join them: `ExposureTime` and the rest +/// are only meaningful inside an Exif directory, which is why the pointers +/// exist. +/// +/// No `Orientation`, for the reason `exif.rs` gives at length: the pixels +/// arriving here are already upright. +fn tag_metadata( + dir: &mut tiff::encoder::DirectoryEncoder<'_, W, K>, + source: Option<&SourceMetadata>, + sub: &SubDirectories, +) -> Result<(), ExportError> +where + W: std::io::Write + std::io::Seek, + K: tiff::encoder::TiffKind, +{ + use tiff::tags::Tag; + + let Some(md) = source else { + return Ok(()); + }; + let write = |dir: &mut tiff::encoder::DirectoryEncoder<'_, W, K>| -> tiff::TiffResult<()> { + if let Some(v) = trimmed(md.make.as_deref()) { + dir.write_tag(Tag::Make, Ascii(v))?; + } + if let Some(v) = trimmed(md.model.as_deref()) { + dir.write_tag(Tag::Model, Ascii(v))?; + } + if let Some(v) = trimmed(md.artist.as_deref()) { + dir.write_tag(Tag::Artist, Ascii(v))?; + } + if let Some(v) = trimmed(md.copyright.as_deref()) { + dir.write_tag(Tag::Copyright, Ascii(v))?; + } + dir.write_tag(Tag::Software, Ascii(exif::SOFTWARE))?; + if let Some(t) = md.captured_at.map(exif::datetime) { + dir.write_tag(Tag::DateTime, Ascii(&t))?; + } + if let Some(offset) = sub.exif { + dir.write_tag(Tag::ExifDirectory, offset)?; + } + if let Some(offset) = sub.gps { + dir.write_tag(Tag::GpsDirectory, offset)?; + } + Ok(()) + }; + write(dir).map_err(|e| ExportError::Encode(e.to_string())) +} + +/// A string worth writing, or nothing. +/// +/// An empty tag is worse than an absent one: it asserts that the camera had no +/// name, where absence merely says nobody recorded it. +fn trimmed(value: Option<&str>) -> Option<&str> { + value.map(str::trim).filter(|v| !v.is_empty()) +} + /// 16-bit TIFF, for work continuing in another editor. /// /// **Honest about what it carries.** The adjust pass renders to an 8-bit @@ -190,7 +525,13 @@ where /// one: the composer has to be told what format to write, and export has to /// ask for the wide one (FR-EXP-9). Until then this is a container promotion, /// which is still the right thing to hand an editor that works in 16-bit. -fn tiff16(rgba: &[u8], width: u32, height: u32, profile: &[u8]) -> Result, ExportError> { +fn tiff16( + rgba: &[u8], + width: u32, + height: u32, + profile: &[u8], + source: Option<&SourceMetadata>, +) -> Result, ExportError> { use tiff::encoder::{colortype, TiffEncoder}; // `x * 257` rather than `x << 8`: it maps 255 to 65535 exactly, where the @@ -200,10 +541,12 @@ fn tiff16(rgba: &[u8], width: u32, height: u32, profile: &[u8]) -> Result(width, height) .map_err(|e| ExportError::Encode(e.to_string()))?; tag_profile(image.encoder(), profile)?; + tag_metadata(image.encoder(), source, &sub)?; image .write_data(&wide) .map_err(|e| ExportError::Encode(e.to_string()))?; @@ -242,7 +585,7 @@ mod tests { 0, 0, 255, 255, // blue 10, 20, 30, 255, ]; - let bytes = png(&rgba, 2, 2, &icc::profile(ColourSpace::Srgb)).unwrap(); + let bytes = png(&rgba, 2, 2, &icc::profile(ColourSpace::Srgb), None).unwrap(); let decoder = png::Decoder::new(std::io::Cursor::new(&bytes)); let mut reader = decoder.read_info().unwrap(); @@ -268,7 +611,7 @@ mod tests { // discards silently, leaving the file to be guessed at as sRGB. for space in ColourSpace::ALL { let want = icc::profile(space); - let bytes = png(&flat(4, 4), 4, 4, &want).unwrap(); + let bytes = png(&flat(4, 4), 4, 4, &want, None).unwrap(); let decoder = png::Decoder::new(std::io::Cursor::new(&bytes)); let reader = decoder.read_info().unwrap(); @@ -289,7 +632,7 @@ mod tests { // walking it here is the only way to know the file is really tagged. for space in ColourSpace::ALL { let want = icc::profile(space); - let bytes = jpeg(&flat(4, 4), 4, 4, 90, &want).unwrap(); + let bytes = jpeg(&flat(4, 4), 4, 4, 90, &want, None).unwrap(); let got = jpeg_icc(&bytes) .unwrap_or_else(|| panic!("{space:?} JPEG has no ICC_PROFILE segment")); assert_eq!(got, want, "{space:?}"); @@ -336,8 +679,8 @@ mod tests { for space in ColourSpace::ALL { let want = icc::profile(space); for (label, bytes) in [ - ("8-bit", tiff8(&flat(4, 4), 4, 4, &want).unwrap()), - ("16-bit", tiff16(&flat(4, 4), 4, 4, &want).unwrap()), + ("8-bit", tiff8(&flat(4, 4), 4, 4, &want, None).unwrap()), + ("16-bit", tiff16(&flat(4, 4), 4, 4, &want, None).unwrap()), ] { let mut d = Decoder::new(std::io::Cursor::new(&bytes)).expect("decode"); let got = d @@ -361,7 +704,7 @@ mod tests { 0, 0, 255, 255, // blue 10, 20, 30, 255, ]; - let bytes = tiff8(&rgba, 2, 2, &icc::profile(ColourSpace::Srgb)).unwrap(); + let bytes = tiff8(&rgba, 2, 2, &icc::profile(ColourSpace::Srgb), None).unwrap(); let mut d = Decoder::new(std::io::Cursor::new(&bytes)).expect("decode"); assert_eq!(d.dimensions().expect("dimensions"), (2, 2)); let DecodingResult::U8(pixels) = d.read_image().expect("read") else { @@ -372,4 +715,296 @@ mod tests { &[255, 0, 0, 0, 255, 0, 0, 0, 255, 10, 20, 30] ); } + + // ----------------------------------------------------------------------- + // Metadata (FR-EXP-8) + // + // Read back through `dr-decode`, the same reader the application uses on + // the way in. That is the point: a test with its own parser proves the two + // agree with each other and nothing else, whereas this proves an exported + // file re-imports as the photograph it came from — and, in the stripping + // direction, that the coordinates are not there to be found by the very + // code most likely to find them. + // ----------------------------------------------------------------------- + + /// A source with every field filled, including a position. + fn source() -> SourceMetadata { + SourceMetadata { + make: Some("Canon".into()), + model: Some("Canon EOS 6D".into()), + lens: Some("EF85mm f/1.8 USM".into()), + shutter: Some(1.0 / 250.0), + aperture: Some(2.8), + iso: Some(400), + focal_length: Some(85.0), + captured_at: Some(1_372_462_374), + captured_offset: Some(120), + artist: Some("Duncan Tourolle".into()), + copyright: Some("(c) 2026 Duncan Tourolle".into()), + // 48° 51' 29.52" N, 2° 17' 40.2" E. + location: dr_types::Location::new(48.8582, 2.2945, Some(35.0)), + } + } + + /// Encode one small frame of every format under `settings`. + fn exported(settings: &ExportSettings, md: &SourceMetadata) -> Vec<(ExportFormat, Vec)> { + [ + ExportFormat::Jpeg, + ExportFormat::Png, + ExportFormat::Tiff8, + ExportFormat::Tiff16, + ] + .into_iter() + .map(|format| { + let s = ExportSettings { + format, + ..settings.clone() + }; + let bytes = encode(&flat(8, 8), 8, 8, &s, Some(md)) + .unwrap_or_else(|e| panic!("{format:?}: {e}")); + (format, bytes) + }) + .collect() + } + + /// The EXIF an exported file carries, as `dr-decode` reads it. + /// + /// Each container hides the same TIFF structure somewhere different, so + /// finding it is per-format; what happens to it afterwards is not. + fn read_back(format: ExportFormat, bytes: &[u8]) -> Option { + match format { + ExportFormat::Jpeg => dr_decode::jpeg_metadata(bytes).ok(), + ExportFormat::Png => { + let decoder = png::Decoder::new(std::io::Cursor::new(bytes)); + let reader = decoder.read_info().expect("a readable PNG"); + let chunk = reader.info().exif_metadata.clone()?; + dr_decode::tiff_metadata(&chunk).ok() + } + // A TIFF's own directory is the EXIF, so the file is the block. + _ => dr_decode::tiff_metadata(bytes).ok(), + } + } + + /// Whether the bytes contain a directory entry pointing at a GPS + /// directory. + /// + /// TRACES: FR-EXP-8 + /// Deliberately byte-level, and deliberately not "did the parser find a + /// position". A GPS directory is only reachable through tag 0x8825 with + /// field type `LONG`, so those four bytes are the whole of the evidence: + /// if they are nowhere in the file then no reader — ours, exiftool, a + /// social network's ingest pipeline — has a route to a coordinate, + /// whatever else the file contains. Both byte orders are checked because + /// the `tiff` crate writes in the host's. + fn has_gps_pointer(bytes: &[u8]) -> bool { + const LITTLE: [u8; 4] = [0x25, 0x88, 0x04, 0x00]; + const BIG: [u8; 4] = [0x88, 0x25, 0x00, 0x04]; + bytes.windows(4).any(|w| w == LITTLE || w == BIG) + } + + #[test] + fn no_export_carries_a_location_by_default() { + // TRACES: FR-EXP-8 + // The important one. The source has a fix, the defaults are what a + // photographer who has changed nothing gets, and the assertion is + // about the *bytes* rather than about a flag having been read. + let settings = ExportSettings::default(); + assert!(settings.strip_location, "the default this test rests on"); + + for (format, bytes) in exported(&settings, &source()) { + assert!( + !has_gps_pointer(&bytes), + "{format:?} carries a GPS directory pointer" + ); + let md = read_back(format, &bytes).unwrap_or_else(|| panic!("{format:?} has no EXIF")); + assert_eq!(md.location, None, "{format:?} decodes to a position"); + // And the coordinates are not loose in the file under some other + // tag: the hemisphere letters a GPS directory always carries. + assert!( + !bytes.windows(2).any(|w| w == b"N\0" || w == b"E\0"), + "{format:?} contains a hemisphere reference" + ); + } + } + + #[test] + fn stripping_the_location_keeps_everything_else() { + // The other half of the same export: a photographer loses their + // coordinates, not their byline. A strip that took the copyright with + // it would pass the test above and still be wrong. + for (format, bytes) in exported(&ExportSettings::default(), &source()) { + let md = read_back(format, &bytes).unwrap_or_else(|| panic!("{format:?} has no EXIF")); + assert_eq!(md.make.as_deref(), Some("Canon"), "{format:?}"); + assert_eq!(md.model.as_deref(), Some("Canon EOS 6D"), "{format:?}"); + assert_eq!( + md.copyright.as_deref(), + Some("(c) 2026 Duncan Tourolle"), + "{format:?}" + ); + assert_eq!(md.captured_at, Some(1_372_462_374), "{format:?}"); + } + } + + #[test] + fn the_camera_and_the_copyright_survive_the_round_trip() { + // TRACES: FR-EXP-8 + // The retaining direction, with stripping off so that the position + // travels too — which is also the control for the test above: it + // proves that a missing GPS directory there is the setting working + // rather than the writer being incapable of one. + let settings = ExportSettings { + retain_metadata: true, + strip_location: false, + ..Default::default() + }; + + for (format, bytes) in exported(&settings, &source()) { + assert!( + has_gps_pointer(&bytes), + "{format:?} dropped the position it was asked to keep" + ); + let md = read_back(format, &bytes).unwrap_or_else(|| panic!("{format:?} has no EXIF")); + assert_eq!(md.make.as_deref(), Some("Canon"), "{format:?}"); + assert_eq!(md.model.as_deref(), Some("Canon EOS 6D"), "{format:?}"); + assert_eq!(md.lens.as_deref(), Some("EF85mm f/1.8 USM"), "{format:?}"); + assert_eq!(md.artist.as_deref(), Some("Duncan Tourolle"), "{format:?}"); + assert_eq!( + md.copyright.as_deref(), + Some("(c) 2026 Duncan Tourolle"), + "{format:?}" + ); + assert_eq!(md.captured_at, Some(1_372_462_374), "{format:?}"); + assert_eq!(md.captured_offset, Some(120), "{format:?}"); + assert_eq!(md.iso, Some(400), "{format:?}"); + assert_eq!(md.shutter, Some(1.0 / 250.0), "{format:?}"); + assert_eq!(md.aperture, Some(2.8), "{format:?}"); + assert_eq!(md.focal_length, Some(85.0), "{format:?}"); + + let loc = md.location.unwrap_or_else(|| panic!("{format:?} lost the fix")); + // Within a metre of where it started, which is finer than any + // consumer receiver and far finer than the tag's own rounding. + assert!((loc.latitude - 48.8582).abs() < 1e-5, "{format:?} {loc:?}"); + assert!((loc.longitude - 2.2945).abs() < 1e-5, "{format:?} {loc:?}"); + assert_eq!(loc.altitude, Some(35.0), "{format:?}"); + } + } + + #[test] + fn retention_off_writes_no_metadata_at_all() { + // Not an emptied block — none. This is the setting for a file that + // must give nothing away, and a reader should find the same absence a + // file that never had EXIF has. + let settings = ExportSettings { + retain_metadata: false, + strip_location: false, + ..Default::default() + }; + + for (format, bytes) in exported(&settings, &source()) { + assert!(!has_gps_pointer(&bytes), "{format:?}"); + match format { + // No APP1 segment at all, which is what the error means here. + ExportFormat::Jpeg => assert!(dr_decode::jpeg_metadata(&bytes).is_err()), + ExportFormat::Png => { + let decoder = png::Decoder::new(std::io::Cursor::new(&bytes)); + let reader = decoder.read_info().expect("a readable PNG"); + assert!(reader.info().exif_metadata.is_none()); + } + _ => { + let md = read_back(format, &bytes).expect("a TIFF is always a directory"); + assert_eq!(md.make, None, "{format:?}"); + assert_eq!(md.copyright, None, "{format:?}"); + assert_eq!(md.captured_at, None, "{format:?}"); + } + } + } + } + + #[test] + fn an_export_is_not_told_to_rotate_pixels_that_are_already_upright() { + // The pipeline applies the source's orientation before this point, so + // an orientation tag here would turn every portrait frame on its side + // in every viewer that honours one. `dr-decode` reporting no + // orientation is the assertion: it reads the tag from the main IFD, + // which is exactly where a careless copy would have put it. + let settings = ExportSettings { + retain_metadata: true, + ..Default::default() + }; + for (format, bytes) in exported(&settings, &source()) { + let md = read_back(format, &bytes).unwrap_or_else(|| panic!("{format:?} has no EXIF")); + assert_eq!(md.orientation, None, "{format:?} tells a reader to rotate"); + } + } + + #[test] + fn a_tiff_carrying_metadata_still_decodes_to_its_pixels() { + // Sub-directories are written into the file *before* the image, so a + // mistake here moves the strip offsets — the failure that produces a + // file which opens, reports the right size, and shows noise. + use tiff::decoder::{Decoder, DecodingResult}; + + let rgba: Vec = vec![ + 255, 0, 0, 255, // red + 0, 255, 0, 255, // green + 0, 0, 255, 255, // blue + 10, 20, 30, 255, + ]; + let bytes = tiff8( + &rgba, + 2, + 2, + &icc::profile(ColourSpace::Srgb), + Some(&source()), + ) + .unwrap(); + + let mut d = Decoder::new(std::io::Cursor::new(&bytes)).expect("decode"); + assert_eq!(d.dimensions().expect("dimensions"), (2, 2)); + let DecodingResult::U8(pixels) = d.read_image().expect("read") else { + panic!("expected 8-bit samples"); + }; + assert_eq!( + &pixels[..12], + &[255, 0, 0, 0, 255, 0, 0, 0, 255, 10, 20, 30] + ); + // And the profile is still where it was, beside the new tags. + let mut d = Decoder::new(std::io::Cursor::new(&bytes)).expect("decode"); + assert_eq!( + d.get_tag_u8_vec(tiff::tags::Tag::Unknown(TAG_ICC_PROFILE)) + .expect("profile"), + icc::profile(ColourSpace::Srgb) + ); + } + + #[test] + fn a_jpeg_keeps_both_its_profile_and_its_capture_data() { + // Two APP segments now, and adding one must not have displaced the + // other: a reader walking the marker chain has to find both. + let settings = ExportSettings { + retain_metadata: true, + ..Default::default() + }; + let bytes = encode(&flat(8, 8), 8, 8, &settings, Some(&source())).unwrap(); + let profile = jpeg_icc(&bytes).expect("the ICC segment"); + assert_eq!(profile, icc::profile(ColourSpace::Srgb)); + let md = dr_decode::jpeg_metadata(&bytes).expect("the EXIF segment"); + assert_eq!(md.model.as_deref(), Some("Canon EOS 6D")); + } + + #[test] + fn a_frame_with_no_source_metadata_exports_exactly_as_it_used_to() { + // The `None` path is the one every caller that has not been taught + // about metadata still takes, and it must not have acquired a block. + let settings = ExportSettings::default(); + for format in [ExportFormat::Jpeg, ExportFormat::Png] { + let s = ExportSettings { + format, + ..settings.clone() + }; + let bytes = encode(&flat(8, 8), 8, 8, &s, None).unwrap(); + assert!(!has_gps_pointer(&bytes), "{format:?}"); + assert!(read_back(format, &bytes).is_none(), "{format:?}"); + } + } } diff --git a/core/dr-export/src/exif.rs b/core/dr-export/src/exif.rs new file mode 100644 index 0000000..239848c --- /dev/null +++ b/core/dr-export/src/exif.rs @@ -0,0 +1,518 @@ +//! 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) + } +} diff --git a/core/dr-export/src/lib.rs b/core/dr-export/src/lib.rs index 3f9b8b2..4642064 100644 --- a/core/dr-export/src/lib.rs +++ b/core/dr-export/src/lib.rs @@ -26,12 +26,15 @@ use dr_types::{ColourSpace, ExportFormat, ExportSettings}; mod encode; mod error; +mod exif; pub mod icc; +mod metadata; mod name; mod sharpen; mod size; pub use error::ExportError; +pub use metadata::SourceMetadata; pub use name::{resolve_name, NameContext}; pub use size::target_size; @@ -129,10 +132,23 @@ pub struct Encoded { /// resamples down from it. Exporting from the display proxy would silently /// produce a soft file, which is why the develop session's export path renders /// its own frame rather than reusing the one on screen. +/// +/// TRACES: FR-EXP-8 +/// `source` is what the photograph's own file said about itself, or `None` +/// where the caller has nothing — a frame that came from somewhere other than +/// a decoded file, or a caller that has not yet been taught to pass it. +/// +/// **A parameter rather than a field on [`Frame`]**, because it is not a fact +/// about the pixels: two exports of the same frame can legitimately disclose +/// different amounts, and the settings that decide how much travel beside it. +/// It is also why this is an argument and not an `Option` with a default — a +/// caller that has the source metadata should have to decide, in one visible +/// place, to hand it over. pub fn export( frame: &Frame, settings: &ExportSettings, name: String, + source: Option<&SourceMetadata>, ) -> Result { // TRACES: FR-EXP-2 // Refused rather than mislabelled. Every space the settings page offers @@ -170,7 +186,7 @@ pub fn export( let scale = width as f32 / frame.width.max(1) as f32; let sharpened = sharpen::apply(resized, width, height, settings.sharpening, scale); - let bytes = encode::encode(&sharpened, width, height, settings)?; + let bytes = encode::encode(&sharpened, width, height, settings, source)?; Ok(Encoded { name, @@ -223,6 +239,7 @@ mod tests { &frame(64, 48), &settings(ExportFormat::Jpeg), "a.jpg".into(), + None, ) .unwrap(); // SOI marker. Cheap, and it catches an encoder wired to the wrong @@ -233,14 +250,14 @@ mod tests { #[test] fn png_export_produces_a_png() { - let out = export(&frame(32, 32), &settings(ExportFormat::Png), "a.png".into()).unwrap(); + let out = export(&frame(32, 32), &settings(ExportFormat::Png), "a.png".into(), None).unwrap(); assert_eq!(&out.bytes[..8], b"\x89PNG\r\n\x1a\n"); } #[test] fn tiff_exports_produce_a_tiff() { for format in [ExportFormat::Tiff8, ExportFormat::Tiff16] { - let out = export(&frame(16, 16), &settings(format), "a.tif".into()).unwrap(); + let out = export(&frame(16, 16), &settings(format), "a.tif".into(), None).unwrap(); // Either byte order is a valid TIFF; the crate writes little-endian. assert!( out.bytes.starts_with(b"II*\0") || out.bytes.starts_with(b"MM\0*"), @@ -253,8 +270,8 @@ mod tests { fn a_sixteen_bit_tiff_is_larger_than_an_eight_bit_one() { // Both are uncompressed RGB; the only difference is the sample width, // so this is what proves the 16-bit path is not quietly writing 8. - let eight = export(&frame(16, 16), &settings(ExportFormat::Tiff8), "a".into()).unwrap(); - let sixteen = export(&frame(16, 16), &settings(ExportFormat::Tiff16), "a".into()).unwrap(); + let eight = export(&frame(16, 16), &settings(ExportFormat::Tiff8), "a".into(), None).unwrap(); + let sixteen = export(&frame(16, 16), &settings(ExportFormat::Tiff16), "a".into(), None).unwrap(); assert!(sixteen.bytes.len() > eight.bytes.len()); } @@ -267,8 +284,8 @@ mod tests { let mut high = settings(ExportFormat::Jpeg); high.quality = 98; - let small = export(&frame(128, 128), &low, "a".into()).unwrap(); - let large = export(&frame(128, 128), &high, "a".into()).unwrap(); + let small = export(&frame(128, 128), &low, "a".into(), None).unwrap(); + let large = export(&frame(128, 128), &high, "a".into(), None).unwrap(); assert!( large.bytes.len() > small.bytes.len(), "quality 98 produced {} bytes against quality 20's {}", @@ -281,7 +298,7 @@ mod tests { fn a_long_edge_export_lands_on_the_requested_size() { let mut s = settings(ExportFormat::Png); s.sizing = SizingMode::LongEdge(32); - let out = export(&frame(128, 64), &s, "a".into()).unwrap(); + let out = export(&frame(128, 64), &s, "a".into(), None).unwrap(); assert_eq!((out.width, out.height), (32, 16)); } @@ -293,7 +310,7 @@ mod tests { let mut s = settings(ExportFormat::Jpeg); s.colour_space = ColourSpace::DisplayP3; assert!(matches!( - export(&frame(8, 8), &s, "a".into()), + export(&frame(8, 8), &s, "a".into(), None), Err(ExportError::ColourSpaceMismatch { .. }) )); } @@ -314,7 +331,7 @@ mod tests { s.colour_space = space; let mut f = frame(8, 8); f.space = space; - let out = export(&f, &s, "a".into()) + let out = export(&f, &s, "a".into(), None) .unwrap_or_else(|e| panic!("{space:?} as {format:?}: {e}")); assert!(!out.bytes.is_empty()); } @@ -326,7 +343,7 @@ mod tests { for format in [ExportFormat::Avif, ExportFormat::JpegXl] { assert!( matches!( - export(&frame(8, 8), &settings(format), "a".into()), + export(&frame(8, 8), &settings(format), "a".into(), None), Err(ExportError::FormatUnsupported(_)) ), "{format:?} should report that it has no encoder yet" @@ -339,7 +356,7 @@ mod tests { // Walks `ExportFormat::ALL`, so a format added to the settings page // cannot quietly reach an encoder that does not handle it. for format in ExportFormat::ALL { - match export(&frame(8, 8), &settings(format), "a".into()) { + match export(&frame(8, 8), &settings(format), "a".into(), None) { Ok(out) => assert!(!out.bytes.is_empty(), "{format:?} encoded to nothing"), Err(ExportError::FormatUnsupported(f)) => assert_eq!(f, format), Err(e) => panic!("{format:?} failed unexpectedly: {e}"), diff --git a/core/dr-export/src/metadata.rs b/core/dr-export/src/metadata.rs new file mode 100644 index 0000000..a8bde43 --- /dev/null +++ b/core/dr-export/src/metadata.rs @@ -0,0 +1,106 @@ +//! TRACES: FR-EXP-8 +//! What an export is allowed to say about where it came from. +//! +//! # An allowlist, not a filter +//! +//! [`SourceMetadata`] is the whole of what can reach a file this crate writes. +//! It is populated field by field from whatever the caller decoded, and +//! nothing else travels — not because each unwanted tag is removed, but +//! because there is nowhere in this type for one to sit. That is the +//! difference between "we strip GPS" and "GPS cannot be written unless +//! [`SourceMetadata::location`] is `Some`", and only the second survives +//! somebody adding a field to the decoder next year. +//! +//! # What is deliberately not here +//! +//! **The maker note** (EXIF `0x927C`). It is an opaque vendor blob with no +//! public format, and its contents differ by body and firmware. Canon's +//! carries the body serial number and the shutter count; several bodies put a +//! *duplicate copy of the GPS fix* inside it, which is the specific reason it +//! cannot be passed through as an unexamined byte range: an export that +//! stripped the GPS directory and copied the maker note would have published +//! the coordinates anyway, while reporting itself as private. Parsing it per +//! vendor to decide what is safe is a research project with a permanent +//! maintenance cost, and the value on the other side is a few tags a +//! photographer rarely misses. So it is dropped, in both directions, whatever +//! the settings say. +//! +//! **Serial numbers and owner name** (`BodySerialNumber` 0xA431, +//! `LensSerialNumber` 0xA435, `CameraOwnerName` 0xA430). These identify a +//! person and a specific piece of equipment, and a serial number in a +//! published file links every photograph that person has ever posted. They +//! have no field here, so no export writes them. +//! +//! **IPTC and XMP.** FR-EXP-8 names both. Neither is read by `dr-decode` +//! today, so there is nothing to carry through; when there is, it arrives as +//! fields on this type and is written from them, and the same allowlist +//! reasoning applies unchanged. + +use dr_types::Location; + +/// TRACES: FR-EXP-8 +/// The source metadata an export may carry. +/// +/// Every field is optional because every field is genuinely absent from some +/// real file: scanner output has no aperture, a JPEG from a phone has no lens +/// model, and most photographs have no copyright statement at all. +/// +/// Built by the caller, which is the only place that has both the decoded +/// source and the crate that decoded it — `dr-export` deliberately depends on +/// no decoder (see the crate docs), so the copy is made one field at a time +/// where both types are in scope. That transcription is a feature: it is the +/// point where somebody has to decide, in writing, that a newly-parsed piece +/// of the source is allowed to leave the machine. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct SourceMetadata { + pub make: Option, + pub model: Option, + pub lens: Option, + /// Exposure time in seconds. + pub shutter: Option, + /// The f-number, as in f/2.8. + pub aperture: Option, + pub iso: Option, + /// Millimetres, as marked on the lens rather than 35 mm equivalent. + pub focal_length: Option, + /// When the shutter fired, as Unix seconds read as a wall clock. + pub captured_at: Option, + /// Minutes east of UTC, where the camera recorded a zone. + pub captured_offset: Option, + /// Who made the photograph. + pub artist: Option, + /// The rights statement. + pub copyright: Option, + /// TRACES: FR-EXP-8 + /// Where the shutter fired. + /// + /// The one field the strip option is about. It is carried this far so that + /// a photographer who *wants* their coordinates can have them; by the time + /// the encoder sees the record this field has already been through + /// [`Self::sanitised`], and is `None` unless the user turned stripping + /// off. + pub location: Option, +} + +impl SourceMetadata { + /// This record as the settings permit it to be written. + /// + /// **The single place stripping happens.** The encoders below take a + /// record and write what is in it, with no view on privacy; concentrating + /// the decision here means there is one function to read to know what an + /// export can disclose, and no format can quietly disagree with the + /// others — the failure mode where JPEG honours the setting and TIFF, five + /// hundred lines away, does not. + /// + /// Stripping empties the field rather than blanking it. A `GPSLatitude` of + /// `0/0` still announces that the camera had a fix and that this file has + /// been through a scrubber; an absent directory says nothing at all, and + /// says it in the same shape as the millions of files that never had one. + pub(crate) fn sanitised(&self, strip_location: bool) -> Self { + let mut out = self.clone(); + if strip_location { + out.location = None; + } + out + } +} diff --git a/core/dr-types/src/lib.rs b/core/dr-types/src/lib.rs index b24c3fa..c3ba27f 100644 --- a/core/dr-types/src/lib.rs +++ b/core/dr-types/src/lib.rs @@ -437,6 +437,51 @@ impl Orientation { } } +/// TRACES: FR-EXP-8 +/// Where a photograph was taken. +/// +/// Here rather than in `dr-decode` because two crates that never speak to each +/// other both need it: the decoder reads it out of the EXIF GPS directory, and +/// the exporter decides whether to write it back. A type in either one would +/// have made the other depend on it. +/// +/// **Signed degrees, not the tag's own shape.** EXIF stores three rationals +/// and a hemisphere letter — `48/1 51/1 2952/100` and `"N"` — which is a +/// representation, not a position. Normalising at the point of parsing means +/// nothing downstream can forget the letter and put a Sydney photograph in +/// the North Atlantic. Positive is north and east. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Location { + /// Degrees north of the equator, -90..=90. + pub latitude: f64, + /// Degrees east of Greenwich, -180..=180. + pub longitude: f64, + /// Metres above sea level, where the file recorded one. Below sea level + /// is negative, which is the reason this is signed and the tag is not. + pub altitude: Option, +} + +impl Location { + /// A position, or `None` where the numbers cannot be one. + /// + /// A GPS directory with an out-of-range value is a corrupt one, and a + /// latitude of 3000 placed on a map is a worse answer than no map pin. + pub fn new(latitude: f64, longitude: f64, altitude: Option) -> Option { + if !latitude.is_finite() + || !longitude.is_finite() + || !(-90.0..=90.0).contains(&latitude) + || !(-180.0..=180.0).contains(&longitude) + { + return None; + } + Some(Self { + latitude, + longitude, + altitude: altitude.filter(|a| a.is_finite()), + }) + } +} + /// An opaque change-validator for a remote entry (an ETag, or an mtime where /// no ETag exists). /// diff --git a/core/dr-types/src/settings.rs b/core/dr-types/src/settings.rs index e2eb157..62f7c0d 100644 --- a/core/dr-types/src/settings.rs +++ b/core/dr-types/src/settings.rs @@ -189,6 +189,25 @@ pub struct ExportSettings { /// What to do when the output filename already exists. pub collision: CollisionPolicy, + /// TRACES: FR-EXP-8 + /// Whether the source's camera, lens, capture time and rights statement + /// are written into the export. + /// + /// On by default, and the two metadata settings are deliberately not one. + /// They answer different questions: this one is "should the copy I hand + /// over say what took it and who owns it", where the answer for a + /// photographer is nearly always yes, and [`Self::strip_location`] is + /// "should it say where I was", where the answer is nearly always no. A + /// single switch would force those together and make the safe choice for + /// one the wrong choice for the other — either publishing coordinates with + /// the copyright notice, or dropping the copyright notice to hide the + /// coordinates. + /// + /// Off writes no metadata block whatsoever, which is what a file destined + /// for somewhere it must give nothing away wants: not an EXIF block that + /// has been emptied, but no EXIF block. + pub retain_metadata: bool, + /// Whether GPS and other identifying metadata is stripped (FR-EXP-8). /// /// Stripping is *on* by default, which is the one place here that departs @@ -245,6 +264,7 @@ impl Default for ExportSettings { sharpening: OutputSharpening::Screen, filename_template: "{name}".to_string(), collision: CollisionPolicy::Increment, + retain_metadata: true, strip_location: true, target: ExportTarget::default(), destination: String::new(), @@ -778,6 +798,27 @@ mod tests { assert!(ExportSettings::default().strip_location); } + #[test] + fn the_camera_is_kept_by_default_and_the_place_is_not() { + // FR-EXP-8's two halves, and the reason they are two settings. The + // defaults have to disagree: an export says what took the photograph + // and stays quiet about where it was taken. + let s = ExportSettings::default(); + assert!(s.retain_metadata, "camera and copyright default to kept"); + assert!(s.strip_location, "coordinates default to stripped"); + } + + #[test] + fn a_settings_file_written_before_metadata_retention_existed_keeps_the_camera() { + // The field is newer than files on disk. `#[serde(default)]` fills it + // from `Default`, and this pins that the fill is the useful direction: + // an upgrade must not silently start writing bare files. + let older = r#"{"format":"jpeg","quality":90,"strip_location":true}"#; + let s: ExportSettings = serde_json::from_str(older).expect("older settings parse"); + assert!(s.retain_metadata); + assert!(s.strip_location); + } + #[test] fn exports_go_to_this_device_unless_asked_otherwise() { // A first run must not upload someone's pictures to a server because From 59362fcecf8fca4f7656b537e83e9337d943b529 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 22 Aug 2026 19:15:06 +0200 Subject: [PATCH 2/2] Let the copyright survive the export, and the GPS not MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Carries the source's metadata all the way to the file the user hands over, and proves in bytes that the coordinates do not come with it. The privacy test was the piece that mattered and the piece that was wrong. It searched the whole file for the two-byte hemisphere reference "N\0" or "E\0", which is not a fingerprint of a GPS directory at all: sample 14 of the sRGB tone curve inside the ICC profile every export embeds is 69, written as `00 45`, and the next sample is below 256, so its high byte is `00`. Every format would have failed a test about a colour profile. The needle is now the twenty-four bytes a coordinate actually serialises to — three rationals, both byte orders, since exif.rs writes little-endian and the tiff crate writes in the host's — which cannot match by accident, and the retaining test asserts the same needle is *present* so a search that could never find anything cannot make the stripping test pass by being useless. The batch exporter now hands the decoder's reading on to the encoder. It already read the metadata for the orientation and the {date} token; passing it through is what puts the camera, the lens and the rights statement into the file. Nothing about privacy is decided there — dr-export takes that decision once, from the settings. The example passes it too, because it is the only place in the tree that produces files a person can open in exiftool. A unit test can prove a GPS directory is absent from a byte slice; only a real export proves a real photograph comes out the far end still knowing which camera took it. TRACES: FR-EXP-8 Co-Authored-By: Claude Opus 5 (1M context) --- core/dr-export/examples/export.rs | 30 ++++++++++++- core/dr-export/src/encode.rs | 69 +++++++++++++++++++++++++--- ui/dr-ui/src/export.rs | 74 ++++++++++++++++++++++++++++--- 3 files changed, 158 insertions(+), 15 deletions(-) diff --git a/core/dr-export/examples/export.rs b/core/dr-export/examples/export.rs index 9612aa6..1aeb3b2 100644 --- a/core/dr-export/examples/export.rs +++ b/core/dr-export/examples/export.rs @@ -10,7 +10,7 @@ use std::path::PathBuf; -use dr_export::{export, Frame, NameContext}; +use dr_export::{export, Frame, NameContext, SourceMetadata}; use dr_gpu::{AdjustPass, DemosaicedImage, Demosaicer, GpuContext}; use dr_pipeline::EditGraph; use dr_types::{ColourSpace, ExportFormat, ExportSettings, OutputSharpening, SizingMode}; @@ -96,6 +96,32 @@ fn main() { .map(|s| s.to_string_lossy().into_owned()) .unwrap_or_else(|| "export".into()); + // TRACES: FR-EXP-8 + // What the input said about itself, transcribed field by field into the + // allowlist `dr-export` will write from. The example passes it because + // this is the one place in the tree that produces files a person can open + // in exiftool — a unit test can prove a GPS directory is absent from a + // byte slice, but only a real export proves that a real photograph comes + // out of the far end still knowing which camera took it. + // + // The defaults apply, so the files written here carry the camera, the + // lens, the exposure and the rights statement, and carry no coordinates. + let meta = dr_decode::metadata(&bytes).unwrap_or_default(); + let source_metadata = SourceMetadata { + make: meta.make.clone(), + model: meta.model.clone(), + lens: meta.lens.clone(), + shutter: meta.shutter, + aperture: meta.aperture, + iso: meta.iso, + focal_length: meta.focal_length, + captured_at: meta.captured_at, + captured_offset: meta.captured_offset, + artist: meta.artist.clone(), + copyright: meta.copyright.clone(), + location: meta.location, + }; + // One of each format, so the run exercises every encoder that exists. for (format, sizing, sharpening) in [ ( @@ -155,7 +181,7 @@ fn main() { .expect("a free name"); let t = std::time::Instant::now(); - let out = export(&frame, &settings, name).expect("export"); + let out = export(&frame, &settings, name, Some(&source_metadata)).expect("export"); let path = out_dir.join(&out.name); std::fs::write(&path, &out.bytes).expect("write"); println!( diff --git a/core/dr-export/src/encode.rs b/core/dr-export/src/encode.rs index 01f96d2..9535a72 100644 --- a/core/dr-export/src/encode.rs +++ b/core/dr-export/src/encode.rs @@ -742,7 +742,7 @@ mod tests { artist: Some("Duncan Tourolle".into()), copyright: Some("(c) 2026 Duncan Tourolle".into()), // 48° 51' 29.52" N, 2° 17' 40.2" E. - location: dr_types::Location::new(48.8582, 2.2945, Some(35.0)), + location: dr_types::Location::new(LATITUDE, LONGITUDE, Some(35.0)), } } @@ -802,6 +802,45 @@ mod tests { bytes.windows(4).any(|w| w == LITTLE || w == BIG) } + /// TRACES: FR-EXP-8 + /// Whether the source's own coordinates appear anywhere in the bytes. + /// + /// The complement of [`has_gps_pointer`]. That one says no reader has a + /// *route* to a position; this says the numbers themselves are not in the + /// file at all — not under some other tag, not in a directory this test + /// did not think to look in, not left behind in a heap after the entry + /// pointing at it was dropped. + /// + /// The needle is the twenty-four bytes a coordinate serialises to: three + /// rationals, degrees, minutes and seconds. Specific enough that a match + /// is the coordinate rather than a coincidence, which matters because the + /// obvious cheaper needle is not: searching for the hemisphere letter as + /// `"N\0"` or `"E\0"` matches the tone curve inside the ICC profile every + /// export carries — sample 14 of the sRGB curve is 69, which is `00 45`, + /// beside a sample below 256, which is `00 xx`. A privacy test that fails + /// on the colour profile teaches nobody anything. + /// + /// Both byte orders, because `exif.rs` writes little-endian and the `tiff` + /// crate writes in the host's. + fn contains_coordinate(bytes: &[u8], degrees: f64) -> bool { + let mut little = Vec::new(); + let mut big = Vec::new(); + for (n, d) in exif::dms(degrees) { + little.extend_from_slice(&n.to_le_bytes()); + little.extend_from_slice(&d.to_le_bytes()); + big.extend_from_slice(&n.to_be_bytes()); + big.extend_from_slice(&d.to_be_bytes()); + } + bytes + .windows(little.len()) + .any(|w| w == little.as_slice() || w == big.as_slice()) + } + + /// The latitude and longitude [`source`] carries, for the two tests that + /// look for them in the bytes. + const LATITUDE: f64 = 48.8582; + const LONGITUDE: f64 = 2.2945; + #[test] fn no_export_carries_a_location_by_default() { // TRACES: FR-EXP-8 @@ -818,11 +857,16 @@ mod tests { ); let md = read_back(format, &bytes).unwrap_or_else(|| panic!("{format:?} has no EXIF")); assert_eq!(md.location, None, "{format:?} decodes to a position"); - // And the coordinates are not loose in the file under some other - // tag: the hemisphere letters a GPS directory always carries. + // And the numbers are not loose in the file with nothing pointing + // at them, which is what a scrubber that unlinked the directory + // without dropping its values would leave behind. assert!( - !bytes.windows(2).any(|w| w == b"N\0" || w == b"E\0"), - "{format:?} contains a hemisphere reference" + !contains_coordinate(&bytes, LATITUDE), + "{format:?} still contains the latitude" + ); + assert!( + !contains_coordinate(&bytes, LONGITUDE), + "{format:?} still contains the longitude" ); } } @@ -863,6 +907,17 @@ mod tests { has_gps_pointer(&bytes), "{format:?} dropped the position it was asked to keep" ); + // The control for `contains_coordinate` as well as for the + // pointer: a search that could never find the numbers would make + // the stripping test above pass without proving anything. + assert!( + contains_coordinate(&bytes, LATITUDE), + "{format:?} carries no latitude for the strip test to be about" + ); + assert!( + contains_coordinate(&bytes, LONGITUDE), + "{format:?} carries no longitude for the strip test to be about" + ); let md = read_back(format, &bytes).unwrap_or_else(|| panic!("{format:?} has no EXIF")); assert_eq!(md.make.as_deref(), Some("Canon"), "{format:?}"); assert_eq!(md.model.as_deref(), Some("Canon EOS 6D"), "{format:?}"); @@ -883,8 +938,8 @@ mod tests { let loc = md.location.unwrap_or_else(|| panic!("{format:?} lost the fix")); // Within a metre of where it started, which is finer than any // consumer receiver and far finer than the tag's own rounding. - assert!((loc.latitude - 48.8582).abs() < 1e-5, "{format:?} {loc:?}"); - assert!((loc.longitude - 2.2945).abs() < 1e-5, "{format:?} {loc:?}"); + assert!((loc.latitude - LATITUDE).abs() < 1e-5, "{format:?} {loc:?}"); + assert!((loc.longitude - LONGITUDE).abs() < 1e-5, "{format:?} {loc:?}"); assert_eq!(loc.altitude, Some(35.0), "{format:?}"); } } diff --git a/ui/dr-ui/src/export.rs b/ui/dr-ui/src/export.rs index a3e6553..2a5be24 100644 --- a/ui/dr-ui/src/export.rs +++ b/ui/dr-ui/src/export.rs @@ -617,8 +617,15 @@ fn export_one( issued: &mut HashSet, cancel: &Cancel, ) -> Option> { - let (stem, date, frame) = match source { - Source::Rendered { stem, frame } => (stem, String::new(), frame), + // TRACES: FR-EXP-8 + // The fourth element is what the photograph's own file said about itself. + // A library image is decoded here, so it has one; a frame handed over + // already rendered does not — the develop session holds pixels and an edit + // graph, not the header they came from, so an export from the develop + // button carries only what `dr-export` writes about itself until that is + // plumbed through the session. + let (stem, date, frame, source_metadata) = match source { + Source::Rendered { stem, frame } => (stem, String::new(), frame, None), Source::Library { path, cache } => { match render_from_library(request, &path, cache, cancel)? { Ok(rendered) => rendered, @@ -627,7 +634,42 @@ fn export_one( } }; - Some(place_frame(request, &stem, &date, sequence, &frame, issued)) + Some(place_frame( + request, + &stem, + &date, + sequence, + &frame, + source_metadata.as_ref(), + issued, + )) +} + +/// TRACES: FR-EXP-8 +/// What an export is allowed to carry from the file it was decoded from. +/// +/// Field by field rather than a conversion trait, and that is the point: +/// `dr_export::SourceMetadata` is an allowlist, so a tag newly parsed by +/// `dr-decode` reaches an exported file only when somebody adds a line here +/// and thereby decides, in writing, that it may leave the machine. The +/// location travels — `dr-export` is where the stripping decision is taken, +/// once, from the settings, and duplicating it here would give two places to +/// disagree. +fn carried_metadata(meta: &dr_decode::Metadata) -> dr_export::SourceMetadata { + dr_export::SourceMetadata { + make: meta.make.clone(), + model: meta.model.clone(), + lens: meta.lens.clone(), + shutter: meta.shutter, + aperture: meta.aperture, + iso: meta.iso, + focal_length: meta.focal_length, + captured_at: meta.captured_at, + captured_offset: meta.captured_offset, + artist: meta.artist.clone(), + copyright: meta.copyright.clone(), + location: meta.location, + } } /// Fetch a photograph, apply its stored edit, and render it at full size. @@ -636,7 +678,17 @@ fn render_from_library( path: &str, cache: Option, cancel: &Cancel, -) -> Option> { +) -> Option< + Result< + ( + String, + String, + dr_export::Frame, + Option, + ), + ItemError, + >, +> { let Some((creds, user_id)) = request.creds.clone() else { return Some(Err(ItemError::Fetch("no library is open".into()))); }; @@ -721,7 +773,12 @@ fn render_from_library( .map(|s| s.to_string_lossy().into_owned()) .unwrap_or_else(|| "export".into()); - Some(Ok((stem, date, frame))) + // TRACES: FR-EXP-8 + // `meta` was read at the top of this function for the orientation and the + // `{date}` token; carrying it on to the encoder is what puts the camera, + // the lens and the rights statement into the exported file. What is + // *dropped* from it is decided in `dr-export` from the settings, not here. + Some(Ok((stem, date, frame, Some(carried_metadata(&meta))))) } /// Name, encode and write one rendered frame. @@ -733,6 +790,11 @@ fn place_frame( date: &str, sequence: u32, frame: &dr_export::Frame, + // TRACES: FR-EXP-8 + // What the source file said about itself, or `None` where the caller has + // nothing to say. Handed straight through: every decision about what of it + // reaches the file is taken inside `dr-export`, from the settings. + source: Option<&dr_export::SourceMetadata>, issued: &mut HashSet, ) -> Result { // The size is resolved before the name because `{dimensions}` is one of the @@ -754,7 +816,7 @@ fn place_frame( }; let name = resolve_batch_name(&request.settings, &ctx, issued).ok_or(ItemError::NameTaken)?; - let encoded = dr_export::export(frame, &request.settings, name)?; + let encoded = dr_export::export(frame, &request.settings, name, source)?; place( &encoded,