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/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 6495a33..9535a72 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,351 @@ 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(LATITUDE, LONGITUDE, 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) + } + + /// 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 + // 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 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!( + !contains_coordinate(&bytes, LATITUDE), + "{format:?} still contains the latitude" + ); + assert!( + !contains_coordinate(&bytes, LONGITUDE), + "{format:?} still contains the longitude" + ); + } + } + + #[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" + ); + // 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:?}"); + 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 - LATITUDE).abs() < 1e-5, "{format:?} {loc:?}"); + assert!((loc.longitude - LONGITUDE).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 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,