Merge branch 'worktree-agent-a1c8fdfa4258709aa' into integration

This commit is contained in:
2026-08-22 19:24:11 +02:00
10 changed files with 1948 additions and 54 deletions
+73 -3
View File
@@ -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<i32>,
/// 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<String>,
/// TRACES: FR-EXP-8
/// The rights statement (EXIF `Copyright`, 0x8298).
pub copyright: Option<String>,
/// 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<Location>,
}
/// Decoded sensor data, before demosaic.
@@ -302,6 +323,10 @@ pub fn metadata(bytes: &[u8]) -> Result<Metadata, DecodeError> {
.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<Metadata, DecodeError> {
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<Location> {
/// 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<f64> {
(r.d != 0).then(|| r.n as f64 / r.d as f64)
}
fn degrees(dms: &[rawler::formats::tiff::Rational; 3], reference: Option<&String>) -> Option<f64> {
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.
///
+319
View File
@@ -287,6 +287,16 @@ impl<'a> TiffReader<'a> {
/// its offset.
fn scalar(&self, e: &Entry) -> Option<u32> {
match e.kind {
// BYTE, inline when count is 1. The GPS directory's altitude
// reference is one of these, and it is the difference between a
// hilltop and a position 400 m under the Dead Sea.
1 if e.count == 1 => Some(if self.little_endian {
e.value & 0xFF
} else {
// The value field is left-justified whatever the width, so a
// big-endian byte sits in the *top* octet.
e.value >> 24
}),
// SHORT, inline when count is 1.
3 if e.count == 1 => Some(if self.little_endian {
e.value & 0xFFFF
@@ -303,6 +313,35 @@ impl<'a> TiffReader<'a> {
}
}
/// TRACES: FR-EXP-8
/// One RATIONAL from an entry, as a number.
///
/// A rational is eight bytes, so it never fits the four-byte value field
/// and is always read through the offset — which is why `index` is
/// meaningful: the GPS directory stores latitude as three of them in a
/// row.
///
/// A zero denominator yields `None` rather than an infinity. Cameras do
/// write `0/0` into slots they had nothing for, and a shutter speed of
/// `inf` propagated into an exported file is worse than a missing one.
fn rational(&self, e: &Entry, index: u32) -> Option<f64> {
// 5 is RATIONAL (two unsigned longs); 10 is SRATIONAL (two signed).
if (e.kind != 5 && e.kind != 10) || index >= e.count {
return None;
}
let at = (e.value as usize).checked_add(index as usize * 8)?;
let n = read_u32(self.data, at, self.little_endian)?;
let d = read_u32(self.data, at + 4, self.little_endian)?;
if d == 0 {
return None;
}
Some(if e.kind == 10 {
n as i32 as f64 / d as i32 as f64
} else {
n as f64 / d as f64
})
}
/// An ASCII entry's string value.
///
/// EXIF strings are NUL-terminated and often padded, and camera vendors
@@ -444,6 +483,19 @@ pub fn tiff_metadata(tiff_data: &[u8]) -> Result<crate::Metadata, crate::DecodeE
read_exif_entries(&reader, &sub, &mut md, &mut fb);
}
}
// TRACES: FR-EXP-8
// The GPS directory is a third IFD, pointed at from the main one and
// read with its own tag table. First reading wins, as with
// orientation: a thumbnail IFD that repeats the pointer describes the
// same photograph, and the main image's is the one to trust.
if md.location.is_none() {
if let Some(e) = entries.iter().find(|e| e.tag == gps_tag::POINTER) {
if let Some(sub) = reader.scalar(e).and_then(|o| reader.read_ifd(o)) {
md.location = read_gps_entries(&reader, &sub);
}
}
}
}
// Ranked: when the shutter fired, else when the image was digitised, else
@@ -516,6 +568,35 @@ mod exif_tag {
pub const LENS_MODEL: u16 = 0xA434;
pub const PIXEL_X: u16 = 0xA002;
pub const PIXEL_Y: u16 = 0xA003;
/// TRACES: FR-EXP-8
/// Who made the photograph, and under what terms. Both live in the main
/// IFD beside `Make`, not in the Exif sub-IFD.
pub const ARTIST: u16 = 0x013B;
pub const COPYRIGHT: u16 = 0x8298;
/// Exposure, as RATIONALs. Read here as well as from rawler because the
/// JPEG path has no rawler behind it, and a camera JPEG that lost its
/// shutter speed on export lost it for good.
pub const EXPOSURE_TIME: u16 = 0x829A;
pub const FNUMBER: u16 = 0x829D;
pub const FOCAL_LENGTH: u16 = 0x920A;
}
/// TRACES: FR-EXP-8
/// The GPS directory pointer, and the tags inside it.
///
/// A separate module from [`exif_tag`] because the numbers collide: 0x0001 is
/// `GPSLatitudeRef` here and `InteropIndex` there, and a GPS tag read against
/// a main-IFD table is how a file comes to claim an exposure time of "N".
mod gps_tag {
/// The main IFD entry pointing at the GPS directory.
pub const POINTER: u16 = 0x8825;
pub const LATITUDE_REF: u16 = 0x0001;
pub const LATITUDE: u16 = 0x0002;
pub const LONGITUDE_REF: u16 = 0x0003;
pub const LONGITUDE: u16 = 0x0004;
/// 0 above sea level, 1 below. The altitude itself is unsigned.
pub const ALTITUDE_REF: u16 = 0x0005;
pub const ALTITUDE: u16 = 0x0006;
}
/// Dates that stand in for a missing `DateTimeOriginal`.
@@ -542,6 +623,11 @@ fn read_exif_entries(
exif_tag::MODEL => md.model = r.ascii(e),
exif_tag::LENS_MODEL => md.lens = r.ascii(e),
exif_tag::ISO => md.iso = r.scalar(e),
exif_tag::ARTIST => md.artist = r.ascii(e),
exif_tag::COPYRIGHT => md.copyright = r.ascii(e),
exif_tag::EXPOSURE_TIME => md.shutter = r.rational(e, 0).map(|v| v as f32),
exif_tag::FNUMBER => md.aperture = r.rational(e, 0).map(|v| v as f32),
exif_tag::FOCAL_LENGTH => md.focal_length = r.rational(e, 0).map(|v| v as f32),
exif_tag::PIXEL_X => md.width = r.scalar(e),
exif_tag::PIXEL_Y => md.height = r.scalar(e),
// First IFD wins, unlike the fields above, which take the last
@@ -578,6 +664,49 @@ fn read_exif_entries(
}
}
/// TRACES: FR-EXP-8
/// A GPS directory's entries as a position.
///
/// Both coordinates or nothing: a latitude without a longitude is not half a
/// position, it is no position, and half of one written into an export would
/// be a coordinate on the Greenwich meridian.
fn read_gps_entries(r: &TiffReader, entries: &[Entry]) -> Option<dr_types::Location> {
let find = |tag: u16| entries.iter().find(|e| e.tag == tag);
// Degrees, minutes and seconds, each its own rational — and each of the
// three optional in practice, since a body that fixed only to the minute
// still writes the entry.
let degrees = |tag: u16, ref_tag: u16| -> Option<f64> {
let e = find(tag)?;
let d = r.rational(e, 0)? + r.rational(e, 1).unwrap_or(0.0) / 60.0
+ r.rational(e, 2).unwrap_or(0.0) / 3600.0;
// The magnitude is unsigned; the hemisphere is a letter beside it.
let south_or_west = find(ref_tag)
.and_then(|e| r.ascii(e))
.map(|s| {
let s = s.trim().to_ascii_uppercase();
s == "S" || s == "W"
})
.unwrap_or(false);
Some(if south_or_west { -d } else { d })
};
let latitude = degrees(gps_tag::LATITUDE, gps_tag::LATITUDE_REF)?;
let longitude = degrees(gps_tag::LONGITUDE, gps_tag::LONGITUDE_REF)?;
let altitude = find(gps_tag::ALTITUDE)
.and_then(|e| r.rational(e, 0))
.map(|a| {
let below = find(gps_tag::ALTITUDE_REF).and_then(|e| r.scalar(e)) == Some(1);
if below {
-a
} else {
a
}
});
dr_types::Location::new(latitude, longitude, altitude)
}
/// Whether a byte slice is a complete JPEG.
///
/// A truncated JPEG decodes to a partial image rather than an error — the
@@ -968,6 +1097,196 @@ mod tests {
assert_eq!(md.model.as_deref(), Some("CanoScan 9000F Mark II"));
}
/// A JPEG whose EXIF carries a GPS directory, built by hand.
///
/// The offsets are computed rather than written out because the whole
/// point of the exercise is that they are consistent: a GPS directory is
/// three levels of indirection — the main IFD points at it, and each
/// coordinate points at three rationals somewhere else again.
///
/// `lat`/`lon` are `(degrees, minutes, hundredths-of-a-second)` and the
/// refs are the hemisphere letters, exactly as a camera writes them.
fn jpeg_with_gps(
lat: (u32, u32, u32),
lat_ref: u8,
lon: (u32, u32, u32),
lon_ref: u8,
altitude: Option<(u32, u8)>,
) -> Vec<u8> {
// One entry in IFD0 (the GPS pointer), so the blob after it starts at
// the header (8) + count (2) + one entry (12) + the next-IFD link (4).
const GPS_IFD: u32 = 8 + 2 + 12 + 4;
let entries: u32 = if altitude.is_some() { 6 } else { 5 };
// Where the rationals live: after the GPS directory itself.
let heap = GPS_IFD + 2 + entries * 12 + 4;
let mut gps: Vec<(u16, u16, u32, u32)> = vec![
(gps_tag::LATITUDE_REF, 2, 2, u32::from(lat_ref)),
(gps_tag::LATITUDE, 5, 3, heap),
(gps_tag::LONGITUDE_REF, 2, 2, u32::from(lon_ref)),
(gps_tag::LONGITUDE, 5, 3, heap + 24),
];
if let Some((_, reference)) = altitude {
gps.push((gps_tag::ALTITUDE_REF, 1, 1, u32::from(reference)));
gps.push((gps_tag::ALTITUDE, 5, 1, heap + 48));
}
let mut extra = Vec::new();
extra.extend_from_slice(&(gps.len() as u16).to_le_bytes());
for (tag, kind, count, value) in &gps {
extra.extend_from_slice(&tag.to_le_bytes());
extra.extend_from_slice(&kind.to_le_bytes());
extra.extend_from_slice(&count.to_le_bytes());
extra.extend_from_slice(&value.to_le_bytes());
}
extra.extend_from_slice(&0u32.to_le_bytes());
let mut rational = |n: u32, d: u32| {
extra.extend_from_slice(&n.to_le_bytes());
extra.extend_from_slice(&d.to_le_bytes());
};
for (n, d) in [(lat.0, 1), (lat.1, 1), (lat.2, 100)] {
rational(n, d);
}
for (n, d) in [(lon.0, 1), (lon.1, 1), (lon.2, 100)] {
rational(n, d);
}
if let Some((metres, _)) = altitude {
rational(metres, 1);
}
jpeg_with_exif(&[(gps_tag::POINTER, 4, 1, GPS_IFD)], &extra)
}
#[test]
fn a_gps_directory_becomes_signed_degrees() {
// TRACES: FR-EXP-8
// 48° 51' 29.52" N, 2° 17' 40.2" E — the Eiffel Tower. Reading this
// correctly is what makes stripping it meaningful: a parser that
// silently failed would make the export path look private when it was
// only ignorant.
let jpeg = jpeg_with_gps((48, 51, 2952), b'N', (2, 17, 4020), b'E', Some((35, 0)));
let loc = jpeg_metadata(&jpeg).expect("EXIF").location.expect("a fix");
assert!((loc.latitude - 48.858200).abs() < 1e-5, "{loc:?}");
assert!((loc.longitude - 2.294500).abs() < 1e-5, "{loc:?}");
assert_eq!(loc.altitude, Some(35.0));
}
#[test]
fn the_hemisphere_letters_are_applied_not_ignored() {
// The failure this catches puts Sydney in the North Atlantic: the
// magnitudes are identical and only the letters differ.
let jpeg = jpeg_with_gps((33, 51, 3500), b'S', (151, 12, 3600), b'E', None);
let loc = jpeg_metadata(&jpeg).expect("EXIF").location.expect("a fix");
assert!(loc.latitude < 0.0, "southern latitude must be negative");
assert!(loc.longitude > 0.0, "eastern longitude must be positive");
assert!(loc.altitude.is_none());
}
#[test]
fn a_below_sea_level_altitude_keeps_its_sign() {
// Reference 1 means below sea level; the altitude itself is unsigned,
// so dropping the reference turns the Dead Sea into a hilltop.
let jpeg = jpeg_with_gps((31, 33, 0), b'N', (35, 28, 0), b'E', Some((430, 1)));
let loc = jpeg_metadata(&jpeg).expect("EXIF").location.expect("a fix");
assert_eq!(loc.altitude, Some(-430.0));
}
#[test]
fn a_latitude_with_no_longitude_is_not_half_a_position() {
// Half a coordinate written into a file would be a pin on the
// Greenwich meridian, which is worse than no pin.
const GPS_IFD: u32 = 8 + 2 + 12 + 4;
let heap = GPS_IFD + 2 + 12 + 4;
let mut extra = Vec::new();
extra.extend_from_slice(&1u16.to_le_bytes());
for (tag, kind, count, value) in [(gps_tag::LATITUDE, 5u16, 3u32, heap)] {
extra.extend_from_slice(&tag.to_le_bytes());
extra.extend_from_slice(&kind.to_le_bytes());
extra.extend_from_slice(&count.to_le_bytes());
extra.extend_from_slice(&value.to_le_bytes());
}
extra.extend_from_slice(&0u32.to_le_bytes());
for (n, d) in [(48u32, 1u32), (51, 1), (2952, 100)] {
extra.extend_from_slice(&n.to_le_bytes());
extra.extend_from_slice(&d.to_le_bytes());
}
let jpeg = jpeg_with_exif(&[(gps_tag::POINTER, 4, 1, GPS_IFD)], &extra);
assert!(jpeg_metadata(&jpeg).expect("EXIF").location.is_none());
}
#[test]
fn the_byline_and_the_rights_statement_are_read() {
// TRACES: FR-EXP-8
// Both live in the main IFD, and both are the half of FR-EXP-8 that
// must *survive* an export rather than be removed by it.
let artist = b"Duncan Tourolle\0";
let copyright = b"(c) 2026 Duncan Tourolle. All rights reserved.\0";
let base = 8 + 2 + 2 * 12 + 4;
let mut extra = Vec::new();
extra.extend_from_slice(artist);
extra.extend_from_slice(copyright);
let jpeg = jpeg_with_exif(
&[
(exif_tag::ARTIST, 2, artist.len() as u32, base),
(
exif_tag::COPYRIGHT,
2,
copyright.len() as u32,
base + artist.len() as u32,
),
],
&extra,
);
let md = jpeg_metadata(&jpeg).expect("EXIF");
assert_eq!(md.artist.as_deref(), Some("Duncan Tourolle"));
assert_eq!(
md.copyright.as_deref(),
Some("(c) 2026 Duncan Tourolle. All rights reserved.")
);
}
#[test]
fn exposure_rationals_are_read_from_a_jpeg() {
// rawler fills these for a RAW; a camera JPEG has nothing behind it
// but this reader, and an export that lost the shutter speed lost it
// for good.
let base = 8 + 2 + 3 * 12 + 4;
let mut extra = Vec::new();
for (n, d) in [(1u32, 250u32), (28, 10), (850, 10)] {
extra.extend_from_slice(&n.to_le_bytes());
extra.extend_from_slice(&d.to_le_bytes());
}
let jpeg = jpeg_with_exif(
&[
(exif_tag::EXPOSURE_TIME, 5, 1, base),
(exif_tag::FNUMBER, 5, 1, base + 8),
(exif_tag::FOCAL_LENGTH, 5, 1, base + 16),
],
&extra,
);
let md = jpeg_metadata(&jpeg).expect("EXIF");
assert_eq!(md.shutter, Some(1.0 / 250.0));
assert_eq!(md.aperture, Some(2.8));
assert_eq!(md.focal_length, Some(85.0));
}
#[test]
fn a_zero_denominator_is_no_reading_rather_than_an_infinity() {
// Bodies do write `0/0` into a slot they had nothing for, and `inf`
// seconds carried into an exported file is worse than a gap.
let base = 8 + 2 + 12 + 4;
let mut extra = Vec::new();
extra.extend_from_slice(&0u32.to_le_bytes());
extra.extend_from_slice(&0u32.to_le_bytes());
let jpeg = jpeg_with_exif(&[(exif_tag::EXPOSURE_TIME, 5, 1, base)], &extra);
assert_eq!(jpeg_metadata(&jpeg).expect("EXIF").shutter, None);
}
#[test]
fn a_marker_walk_does_not_run_off_a_truncated_file() {
// Untrusted input (NFR-SEC-1): a length field claiming more than the
+28 -2
View File
@@ -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!(
+721 -31
View File
@@ -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<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, ExportError> {
fn png(
rgba: &[u8],
width: u32,
height: u32,
profile: &[u8],
exif: Option<&[u8]>,
) -> Result<Vec<u8>, 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<Vec<u8>,
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<Vec<u8>,
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<Vec<u8>, ExportError> {
fn tiff8(
rgba: &[u8],
width: u32,
height: u32,
profile: &[u8],
source: Option<&SourceMetadata>,
) -> Result<Vec<u8>, 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::<colortype::RGB8>(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<u32>,
gps: Option<u32>,
}
/// 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<W>(
encoder: &mut tiff::encoder::TiffEncoder<W>,
source: Option<&SourceMetadata>,
width: u32,
height: u32,
) -> Result<SubDirectories, ExportError>
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<W, K>(
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<Vec<u8>, ExportError> {
fn tiff16(
rgba: &[u8],
width: u32,
height: u32,
profile: &[u8],
source: Option<&SourceMetadata>,
) -> Result<Vec<u8>, 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<Vec<u8
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::<colortype::RGB16>(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<u8>)> {
[
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<dr_decode::Metadata> {
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<u8> = 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:?}");
}
}
}
+518
View File
@@ -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<u8>),
Short(u16),
Long(u32),
/// Type 7. Used only for `ExifVersion`, which is four characters that are
/// deliberately *not* a string.
Undefined(&'static [u8]),
/// Numerator and denominator pairs. A coordinate is three of them.
Rational(Vec<(u32, u32)>),
}
impl Value {
fn field_type(&self) -> u16 {
match self {
Value::Byte(_) => 1,
Value::Ascii(_) => 2,
Value::Short(_) => 3,
Value::Long(_) => 4,
Value::Rational(_) => 5,
Value::Undefined(_) => 7,
}
}
/// The element count, which is not the byte length: a rational counts as
/// one element per eight bytes.
fn count(&self) -> u32 {
match self {
Value::Ascii(s) => s.len() as u32 + 1,
Value::Byte(b) => b.len() as u32,
Value::Undefined(b) => b.len() as u32,
Value::Short(_) | Value::Long(_) => 1,
Value::Rational(r) => r.len() as u32,
}
}
/// The payload, in file order.
fn payload(&self) -> Vec<u8> {
match self {
Value::Ascii(s) => {
let mut out = s.as_bytes().to_vec();
out.push(0);
out
}
Value::Byte(b) => b.clone(),
Value::Undefined(b) => b.to_vec(),
Value::Short(v) => v.to_le_bytes().to_vec(),
Value::Long(v) => v.to_le_bytes().to_vec(),
Value::Rational(r) => r
.iter()
.flat_map(|(n, d)| {
let mut b = n.to_le_bytes().to_vec();
b.extend_from_slice(&d.to_le_bytes());
b
})
.collect(),
}
}
}
/// An IFD under construction.
type Entries = Vec<(u16, Value)>;
/// Tag numbers. Named rather than inlined because a mistyped one produces a
/// file that still parses and says something else entirely.
pub(crate) mod tag {
pub(crate) const MAKE: u16 = 0x010F;
pub(crate) const MODEL: u16 = 0x0110;
pub(crate) const SOFTWARE: u16 = 0x0131;
pub(crate) const DATE_TIME: u16 = 0x0132;
pub(crate) const ARTIST: u16 = 0x013B;
pub(crate) const COPYRIGHT: u16 = 0x8298;
pub(crate) const EXIF_IFD: u16 = 0x8769;
pub(crate) const GPS_IFD: u16 = 0x8825;
pub(crate) const EXPOSURE_TIME: u16 = 0x829A;
pub(crate) const FNUMBER: u16 = 0x829D;
pub(crate) const ISO: u16 = 0x8827;
pub(crate) const EXIF_VERSION: u16 = 0x9000;
pub(crate) const DATE_TIME_ORIGINAL: u16 = 0x9003;
pub(crate) const OFFSET_TIME_ORIGINAL: u16 = 0x9011;
pub(crate) const FOCAL_LENGTH: u16 = 0x920A;
pub(crate) const PIXEL_X: u16 = 0xA002;
pub(crate) const PIXEL_Y: u16 = 0xA003;
pub(crate) const LENS_MODEL: u16 = 0xA434;
pub(crate) const GPS_VERSION_ID: u16 = 0x0000;
pub(crate) const GPS_LATITUDE_REF: u16 = 0x0001;
pub(crate) const GPS_LATITUDE: u16 = 0x0002;
pub(crate) const GPS_LONGITUDE_REF: u16 = 0x0003;
pub(crate) const GPS_LONGITUDE: u16 = 0x0004;
pub(crate) const GPS_ALTITUDE_REF: u16 = 0x0005;
pub(crate) const GPS_ALTITUDE: u16 = 0x0006;
}
/// What DarkRoom calls itself in a file it wrote.
///
/// Not vanity: an export is a derived file, and a reader that knows which
/// program produced it can tell a camera original from a rendition without
/// guessing from the absence of a maker note.
pub(crate) const SOFTWARE: &str = "DarkRoom";
/// The complete EXIF block for JPEG's APP1 and PNG's `eXIf`.
///
/// `width`/`height` are the *exported* dimensions, not the source's: the
/// pixel-dimension tags describe the file they are in, and a reader that
/// trusts them after a resize would report the wrong size for the image it is
/// holding.
///
/// `None` where there is nothing to say. An empty EXIF block is not the same
/// as no EXIF block — it is a structure a reader must parse to discover it
/// learned nothing — and the second is the better file.
pub(crate) fn block(md: &SourceMetadata, width: u32, height: u32) -> Option<Vec<u8>> {
let ifd0 = main_entries(md);
let exif = exif_entries(md, width, height);
let gps = gps_entries(md);
if ifd0.is_empty() && exif.is_empty() && gps.is_empty() {
return None;
}
Some(assemble(ifd0, exif, gps))
}
/// Lay the three directories and their heap out in the block.
///
/// The order is fixed — IFD0, Exif, GPS, heap — because the pointers have to
/// be known before IFD0 is serialised, and an IFD's size is decided by its
/// entry count alone: two bytes of count, twelve per entry, four for the link
/// to the next directory.
fn assemble(mut ifd0: Entries, exif: Entries, gps: Entries) -> Vec<u8> {
const HEADER: u32 = 8;
let size = |n: usize| 2 + 12 * n as u32 + 4;
// The pointer entries are part of IFD0's count, so they have to be added
// before its size is taken — a chicken-and-egg the specification resolves
// by making entry size fixed.
let pointers = usize::from(!exif.is_empty()) + usize::from(!gps.is_empty());
let ifd0_size = size(ifd0.len() + pointers);
let exif_offset = HEADER + ifd0_size;
let gps_offset = exif_offset + if exif.is_empty() { 0 } else { size(exif.len()) };
let heap_base = gps_offset + if gps.is_empty() { 0 } else { size(gps.len()) };
if !exif.is_empty() {
ifd0.push((tag::EXIF_IFD, Value::Long(exif_offset)));
}
if !gps.is_empty() {
ifd0.push((tag::GPS_IFD, Value::Long(gps_offset)));
}
let mut heap = Vec::new();
let ifd0_bytes = directory(ifd0, heap_base, &mut heap);
let exif_bytes = directory(exif, heap_base, &mut heap);
let gps_bytes = directory(gps, heap_base, &mut heap);
let mut out = Vec::with_capacity(HEADER as usize + heap.len() + 128);
// Little-endian, magic 42, first directory at byte 8. Little-endian
// because every value written below is, and a header that disagreed with
// its own body is the one corruption a reader cannot recover from.
out.extend_from_slice(b"II");
out.extend_from_slice(&42u16.to_le_bytes());
out.extend_from_slice(&HEADER.to_le_bytes());
out.extend_from_slice(&ifd0_bytes);
out.extend_from_slice(&exif_bytes);
out.extend_from_slice(&gps_bytes);
out.extend_from_slice(&heap);
out
}
/// Serialise one directory, spilling long values onto the shared heap.
///
/// Entries are sorted by tag: TIFF requires ascending order within a
/// directory, and while most readers cope with any order, the ones that
/// binary-search stop at the first tag they cannot place.
fn directory(mut entries: Entries, heap_base: u32, heap: &mut Vec<u8>) -> Vec<u8> {
if entries.is_empty() {
return Vec::new();
}
entries.sort_by_key(|(tag, _)| *tag);
let mut out = Vec::with_capacity(2 + entries.len() * 12 + 4);
out.extend_from_slice(&(entries.len() as u16).to_le_bytes());
for (tag, value) in &entries {
out.extend_from_slice(&tag.to_le_bytes());
out.extend_from_slice(&value.field_type().to_le_bytes());
out.extend_from_slice(&value.count().to_le_bytes());
let payload = value.payload();
if payload.len() <= 4 {
// Four bytes or fewer live in the entry itself, left-justified and
// zero-padded.
let mut inline = payload.clone();
inline.resize(4, 0);
out.extend_from_slice(&inline);
} else {
out.extend_from_slice(&(heap_base + heap.len() as u32).to_le_bytes());
heap.extend_from_slice(&payload);
// Values start on even offsets. Not every reader cares; the ones
// that do read a short from an odd address and get nonsense.
if heap.len() % 2 == 1 {
heap.push(0);
}
}
}
// No directory follows this one. The Exif and GPS sub-directories are
// pointed at, not chained, so this is zero in all three.
out.extend_from_slice(&0u32.to_le_bytes());
out
}
/// IFD0: who took it, with what, and who owns it.
///
/// **No orientation tag, deliberately.** The frame reaching the encoder has
/// already had the source's orientation applied by the pipeline — it is
/// upright pixels — so copying the source's tag across would tell every
/// reader to rotate an image that is already the right way up. A portrait
/// frame would come out on its side in exactly the viewers that honour the
/// tag, which is most of them.
fn main_entries(md: &SourceMetadata) -> Entries {
let mut e = Entries::new();
push_ascii(&mut e, tag::MAKE, md.make.as_deref());
push_ascii(&mut e, tag::MODEL, md.model.as_deref());
push_ascii(&mut e, tag::ARTIST, md.artist.as_deref());
push_ascii(&mut e, tag::COPYRIGHT, md.copyright.as_deref());
e.push((tag::SOFTWARE, Value::Ascii(SOFTWARE.to_string())));
// IFD0's `DateTime` is nominally when the file was written, and this is
// the capture time instead. That is what the rest of the world does —
// and it is what `dr-decode` falls back to for scanner output that has no
// `DateTimeOriginal` — so a re-import of an export lands on the timeline
// where the original did rather than on the day it was exported.
if let Some(t) = md.captured_at.map(datetime) {
e.push((tag::DATE_TIME, Value::Ascii(t)));
}
e
}
/// The Exif sub-IFD: the exposure, and what made it.
fn exif_entries(md: &SourceMetadata, width: u32, height: u32) -> Entries {
let mut e = Entries::new();
// "0232" is Exif 2.32. A sub-directory without a version is technically
// malformed, and some readers refuse the whole block over it.
e.push((tag::EXIF_VERSION, Value::Undefined(b"0232")));
e.push((tag::PIXEL_X, Value::Long(width)));
e.push((tag::PIXEL_Y, Value::Long(height)));
push_ascii(&mut e, tag::LENS_MODEL, md.lens.as_deref());
if let Some(t) = md.captured_at.map(datetime) {
e.push((tag::DATE_TIME_ORIGINAL, Value::Ascii(t)));
}
if let Some(o) = md.captured_offset.map(offset) {
e.push((tag::OFFSET_TIME_ORIGINAL, Value::Ascii(o)));
}
if let Some(s) = md.shutter.filter(|s| *s > 0.0) {
e.push((tag::EXPOSURE_TIME, Value::Rational(vec![shutter(s)])));
}
if let Some(f) = md.aperture.filter(|f| *f > 0.0) {
e.push((tag::FNUMBER, Value::Rational(vec![tenths(f)])));
}
if let Some(f) = md.focal_length.filter(|f| *f > 0.0) {
e.push((tag::FOCAL_LENGTH, Value::Rational(vec![tenths(f)])));
}
// The tag is a SHORT, so a sensitivity above 65535 has no representation
// in it. Dropped rather than truncated: ISO 102400 written as 36864 is a
// lie, and an absent tag is not.
if let Some(iso) = md.iso.filter(|v| *v <= u32::from(u16::MAX)) {
e.push((tag::ISO, Value::Short(iso as u16)));
}
e
}
/// The GPS sub-IFD.
///
/// Empty unless the caller has already decided that coordinates may be
/// written — see [`SourceMetadata::sanitised`], which is where the stripping
/// happens. Nothing in this file consults the settings, so there is exactly
/// one place to look to answer "can this export carry a location".
fn gps_entries(md: &SourceMetadata) -> Entries {
let Some(loc) = md.location else {
return Entries::new();
};
let mut e = Entries::new();
// 2.3.0.0, the current GPS tag version.
e.push((tag::GPS_VERSION_ID, Value::Byte(vec![2, 3, 0, 0])));
e.push((
tag::GPS_LATITUDE_REF,
Value::Ascii(if loc.latitude < 0.0 { "S" } else { "N" }.into()),
));
e.push((tag::GPS_LATITUDE, Value::Rational(dms(loc.latitude))));
e.push((
tag::GPS_LONGITUDE_REF,
Value::Ascii(if loc.longitude < 0.0 { "W" } else { "E" }.into()),
));
e.push((tag::GPS_LONGITUDE, Value::Rational(dms(loc.longitude))));
if let Some(alt) = loc.altitude {
// The altitude itself is unsigned; below sea level is a separate byte.
e.push((
tag::GPS_ALTITUDE_REF,
Value::Byte(vec![u8::from(alt < 0.0)]),
));
e.push((
tag::GPS_ALTITUDE,
Value::Rational(vec![((alt.abs() * 100.0).round() as u32, 100)]),
));
}
e
}
fn push_ascii(entries: &mut Entries, tag: u16, value: Option<&str>) {
// An empty string is a tag saying nothing, which is worse than no tag: it
// overwrites whatever a reader would otherwise have inferred.
if let Some(v) = value.map(str::trim).filter(|v| !v.is_empty()) {
entries.push((tag, Value::Ascii(v.to_string())));
}
}
/// Signed degrees back into the tag's degrees/minutes/seconds.
///
/// The sign is carried by the hemisphere letter, so this takes the magnitude.
/// Seconds keep four decimal places, which is about 3 mm — far finer than any
/// consumer fix, and enough that a round trip through the tag does not move
/// the pin.
pub(crate) fn dms(degrees: f64) -> Vec<(u32, u32)> {
let d = degrees.abs();
let whole = d.trunc();
let minutes = (d - whole) * 60.0;
let seconds = (minutes - minutes.trunc()) * 60.0;
vec![
(whole as u32, 1),
(minutes.trunc() as u32, 1),
((seconds * 10_000.0).round() as u32, 10_000),
]
}
/// A shutter speed as the fraction a photographer would recognise.
///
/// `1/250`, not `4/1000`. Both are the same number and every reader computes
/// the same exposure from either, but the first is what the camera wrote and
/// what a properties panel displays verbatim.
pub(crate) fn shutter(seconds: f32) -> (u32, u32) {
if seconds < 1.0 {
(1, (1.0 / seconds).round().max(1.0) as u32)
} else {
((seconds * 10.0).round() as u32, 10)
}
}
/// f/2.8 and 85 mm as tenths, which is how cameras write both.
pub(crate) fn tenths(value: f32) -> (u32, u32) {
((value * 10.0).round().max(0.0) as u32, 10)
}
/// Unix seconds as EXIF's `"YYYY:MM:DD HH:MM:SS"`.
///
/// The reading is a wall clock with no zone — that is what the tag means, and
/// what `dr-decode` parsed it as — so this is the exact inverse of that parse
/// and involves no timezone conversion. The zone, where the source recorded
/// one, travels separately in `OffsetTimeOriginal`.
pub(crate) fn datetime(unix: i64) -> String {
let days = unix.div_euclid(86_400);
let secs = unix.rem_euclid(86_400);
// Howard Hinnant's civil-from-days, the inverse of the days-from-civil
// that `dr-decode` uses to parse. Eras of 400 years, shifted so that the
// arithmetic never sees a negative.
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
format!(
"{y:04}:{m:02}:{d:02} {:02}:{:02}:{:02}",
secs / 3600,
(secs / 60) % 60,
secs % 60
)
}
/// Minutes east of UTC as EXIF's `"+HH:MM"`.
pub(crate) fn offset(minutes: i32) -> String {
let sign = if minutes < 0 { '-' } else { '+' };
let m = minutes.unsigned_abs();
format!("{sign}{:02}:{:02}", m / 60, m % 60)
}
#[cfg(test)]
mod tests {
use super::*;
use dr_types::Location;
#[test]
fn a_capture_time_survives_the_round_trip_through_the_tag() {
// The parse side lives in `dr-decode` and is exercised against real
// files; this is the inverse, and the two meeting in the middle is
// what keeps an exported frame on the same point of the timeline as
// the original.
assert_eq!(datetime(1_372_462_374), "2013:06:28 23:32:54");
assert_eq!(datetime(0), "1970:01:01 00:00:00");
// A leap day, which is where a hand-rolled calendar goes wrong.
assert_eq!(datetime(1_709_164_800), "2024:02:29 00:00:00");
}
#[test]
fn a_zone_is_written_the_way_the_tag_spells_it() {
assert_eq!(offset(120), "+02:00");
assert_eq!(offset(-330), "-05:30");
assert_eq!(offset(0), "+00:00");
}
#[test]
fn a_shutter_speed_keeps_the_photographers_fraction() {
assert_eq!(shutter(1.0 / 250.0), (1, 250));
assert_eq!(shutter(2.5), (25, 10));
}
#[test]
fn degrees_round_trip_through_the_tags_triple() {
// 48.8582 N is the Eiffel Tower; the check is that the three-part
// form comes back to the same place, to well under a metre.
for degrees in [48.8582_f64, -33.8568, 0.0, 179.999] {
let parts = dms(degrees);
let back = parts[0].0 as f64
+ parts[1].0 as f64 / 60.0
+ (parts[2].0 as f64 / parts[2].1 as f64) / 3600.0;
assert!(
(back - degrees.abs()).abs() < 1e-6,
"{degrees} came back as {back}"
);
}
}
#[test]
fn an_empty_source_produces_no_block_at_all() {
// Every field absent means the only entries would be the ones this
// writer adds itself. That is still worth writing — `Software` and
// the pixel dimensions are true statements — so the block exists; what
// must not happen is a *malformed* one.
let md = SourceMetadata::default();
let bytes = block(&md, 100, 50).expect("the writer's own tags");
assert!(bytes.starts_with(b"II*\0"));
}
#[test]
fn the_gps_directory_is_absent_when_there_is_no_position() {
let md = SourceMetadata {
make: Some("Canon".into()),
..Default::default()
};
let bytes = block(&md, 10, 10).unwrap();
assert!(!contains_entry(&bytes, tag::GPS_IFD));
}
#[test]
fn the_gps_directory_is_present_when_there_is_one() {
// The counterpart of the test above: a strip test that passed because
// the writer could never emit GPS at all would prove nothing.
let md = SourceMetadata {
location: Location::new(48.8582, 2.2945, Some(35.0)),
..Default::default()
};
let bytes = block(&md, 10, 10).unwrap();
assert!(contains_entry(&bytes, tag::GPS_IFD));
}
/// Whether a directory entry for `tag` appears anywhere in the block.
///
/// Byte-level on purpose: an entry is a tag, a type and a count, and
/// searching for that twelve-byte shape's first eight bytes is a far
/// stronger statement than asking a parser that might have skipped the
/// directory the tag was in.
fn contains_entry(bytes: &[u8], tag: u16) -> bool {
bytes
.windows(4)
.any(|w| w[..2] == tag.to_le_bytes() && (w[2] == 4 || w[2] == 13) && w[3] == 0)
}
}
+29 -12
View File
@@ -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<Encoded, ExportError> {
// 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}"),
+106
View File
@@ -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<String>,
pub model: Option<String>,
pub lens: Option<String>,
/// Exposure time in seconds.
pub shutter: Option<f32>,
/// The f-number, as in f/2.8.
pub aperture: Option<f32>,
pub iso: Option<u32>,
/// Millimetres, as marked on the lens rather than 35 mm equivalent.
pub focal_length: Option<f32>,
/// When the shutter fired, as Unix seconds read as a wall clock.
pub captured_at: Option<i64>,
/// Minutes east of UTC, where the camera recorded a zone.
pub captured_offset: Option<i32>,
/// Who made the photograph.
pub artist: Option<String>,
/// The rights statement.
pub copyright: Option<String>,
/// 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<Location>,
}
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
}
}
+45
View File
@@ -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<f64>,
}
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<f64>) -> Option<Self> {
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).
///
+41
View File
@@ -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
+68 -6
View File
@@ -617,8 +617,15 @@ fn export_one(
issued: &mut HashSet<String>,
cancel: &Cancel,
) -> Option<Result<Placed, ItemError>> {
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<crate::library::CacheContext>,
cancel: &Cancel,
) -> Option<Result<(String, String, dr_export::Frame), ItemError>> {
) -> Option<
Result<
(
String,
String,
dr_export::Frame,
Option<dr_export::SourceMetadata>,
),
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<String>,
) -> Result<Placed, ItemError> {
// 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,