WIP: EXIF metadata on export

Checkpoint committed by the coordinator, not by the authoring agent: the
session hit its API limit mid-task and left this work uncommitted. Committed
so it survives, NOT because it is finished - expect failing tests and
half-applied changes. The agent resumes from here.
This commit is contained in:
2026-08-22 19:01:18 +02:00
parent c963dafd09
commit cd8750462f
8 changed files with 1797 additions and 46 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.
///