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
+666 -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,296 @@ mod tests {
&[255, 0, 0, 0, 255, 0, 0, 0, 255, 10, 20, 30]
);
}
// -----------------------------------------------------------------------
// Metadata (FR-EXP-8)
//
// Read back through `dr-decode`, the same reader the application uses on
// the way in. That is the point: a test with its own parser proves the two
// agree with each other and nothing else, whereas this proves an exported
// file re-imports as the photograph it came from — and, in the stripping
// direction, that the coordinates are not there to be found by the very
// code most likely to find them.
// -----------------------------------------------------------------------
/// A source with every field filled, including a position.
fn source() -> SourceMetadata {
SourceMetadata {
make: Some("Canon".into()),
model: Some("Canon EOS 6D".into()),
lens: Some("EF85mm f/1.8 USM".into()),
shutter: Some(1.0 / 250.0),
aperture: Some(2.8),
iso: Some(400),
focal_length: Some(85.0),
captured_at: Some(1_372_462_374),
captured_offset: Some(120),
artist: Some("Duncan Tourolle".into()),
copyright: Some("(c) 2026 Duncan Tourolle".into()),
// 48° 51' 29.52" N, 2° 17' 40.2" E.
location: dr_types::Location::new(48.8582, 2.2945, Some(35.0)),
}
}
/// Encode one small frame of every format under `settings`.
fn exported(settings: &ExportSettings, md: &SourceMetadata) -> Vec<(ExportFormat, Vec<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)
}
#[test]
fn no_export_carries_a_location_by_default() {
// TRACES: FR-EXP-8
// The important one. The source has a fix, the defaults are what a
// photographer who has changed nothing gets, and the assertion is
// about the *bytes* rather than about a flag having been read.
let settings = ExportSettings::default();
assert!(settings.strip_location, "the default this test rests on");
for (format, bytes) in exported(&settings, &source()) {
assert!(
!has_gps_pointer(&bytes),
"{format:?} carries a GPS directory pointer"
);
let md = read_back(format, &bytes).unwrap_or_else(|| panic!("{format:?} has no EXIF"));
assert_eq!(md.location, None, "{format:?} decodes to a position");
// And the coordinates are not loose in the file under some other
// tag: the hemisphere letters a GPS directory always carries.
assert!(
!bytes.windows(2).any(|w| w == b"N\0" || w == b"E\0"),
"{format:?} contains a hemisphere reference"
);
}
}
#[test]
fn stripping_the_location_keeps_everything_else() {
// The other half of the same export: a photographer loses their
// coordinates, not their byline. A strip that took the copyright with
// it would pass the test above and still be wrong.
for (format, bytes) in exported(&ExportSettings::default(), &source()) {
let md = read_back(format, &bytes).unwrap_or_else(|| panic!("{format:?} has no EXIF"));
assert_eq!(md.make.as_deref(), Some("Canon"), "{format:?}");
assert_eq!(md.model.as_deref(), Some("Canon EOS 6D"), "{format:?}");
assert_eq!(
md.copyright.as_deref(),
Some("(c) 2026 Duncan Tourolle"),
"{format:?}"
);
assert_eq!(md.captured_at, Some(1_372_462_374), "{format:?}");
}
}
#[test]
fn the_camera_and_the_copyright_survive_the_round_trip() {
// TRACES: FR-EXP-8
// The retaining direction, with stripping off so that the position
// travels too — which is also the control for the test above: it
// proves that a missing GPS directory there is the setting working
// rather than the writer being incapable of one.
let settings = ExportSettings {
retain_metadata: true,
strip_location: false,
..Default::default()
};
for (format, bytes) in exported(&settings, &source()) {
assert!(
has_gps_pointer(&bytes),
"{format:?} dropped the position it was asked to keep"
);
let md = read_back(format, &bytes).unwrap_or_else(|| panic!("{format:?} has no EXIF"));
assert_eq!(md.make.as_deref(), Some("Canon"), "{format:?}");
assert_eq!(md.model.as_deref(), Some("Canon EOS 6D"), "{format:?}");
assert_eq!(md.lens.as_deref(), Some("EF85mm f/1.8 USM"), "{format:?}");
assert_eq!(md.artist.as_deref(), Some("Duncan Tourolle"), "{format:?}");
assert_eq!(
md.copyright.as_deref(),
Some("(c) 2026 Duncan Tourolle"),
"{format:?}"
);
assert_eq!(md.captured_at, Some(1_372_462_374), "{format:?}");
assert_eq!(md.captured_offset, Some(120), "{format:?}");
assert_eq!(md.iso, Some(400), "{format:?}");
assert_eq!(md.shutter, Some(1.0 / 250.0), "{format:?}");
assert_eq!(md.aperture, Some(2.8), "{format:?}");
assert_eq!(md.focal_length, Some(85.0), "{format:?}");
let loc = md.location.unwrap_or_else(|| panic!("{format:?} lost the fix"));
// Within a metre of where it started, which is finer than any
// consumer receiver and far finer than the tag's own rounding.
assert!((loc.latitude - 48.8582).abs() < 1e-5, "{format:?} {loc:?}");
assert!((loc.longitude - 2.2945).abs() < 1e-5, "{format:?} {loc:?}");
assert_eq!(loc.altitude, Some(35.0), "{format:?}");
}
}
#[test]
fn retention_off_writes_no_metadata_at_all() {
// Not an emptied block — none. This is the setting for a file that
// must give nothing away, and a reader should find the same absence a
// file that never had EXIF has.
let settings = ExportSettings {
retain_metadata: false,
strip_location: false,
..Default::default()
};
for (format, bytes) in exported(&settings, &source()) {
assert!(!has_gps_pointer(&bytes), "{format:?}");
match format {
// No APP1 segment at all, which is what the error means here.
ExportFormat::Jpeg => assert!(dr_decode::jpeg_metadata(&bytes).is_err()),
ExportFormat::Png => {
let decoder = png::Decoder::new(std::io::Cursor::new(&bytes));
let reader = decoder.read_info().expect("a readable PNG");
assert!(reader.info().exif_metadata.is_none());
}
_ => {
let md = read_back(format, &bytes).expect("a TIFF is always a directory");
assert_eq!(md.make, None, "{format:?}");
assert_eq!(md.copyright, None, "{format:?}");
assert_eq!(md.captured_at, None, "{format:?}");
}
}
}
}
#[test]
fn an_export_is_not_told_to_rotate_pixels_that_are_already_upright() {
// The pipeline applies the source's orientation before this point, so
// an orientation tag here would turn every portrait frame on its side
// in every viewer that honours one. `dr-decode` reporting no
// orientation is the assertion: it reads the tag from the main IFD,
// which is exactly where a careless copy would have put it.
let settings = ExportSettings {
retain_metadata: true,
..Default::default()
};
for (format, bytes) in exported(&settings, &source()) {
let md = read_back(format, &bytes).unwrap_or_else(|| panic!("{format:?} has no EXIF"));
assert_eq!(md.orientation, None, "{format:?} tells a reader to rotate");
}
}
#[test]
fn a_tiff_carrying_metadata_still_decodes_to_its_pixels() {
// Sub-directories are written into the file *before* the image, so a
// mistake here moves the strip offsets — the failure that produces a
// file which opens, reports the right size, and shows noise.
use tiff::decoder::{Decoder, DecodingResult};
let rgba: Vec<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:?}");
}
}
}