Files
DarkRoom/core/dr-export/src/encode.rs
T
dtourolleandClaude Opus 5 59362fcecf Let the copyright survive the export, and the GPS not
Carries the source's metadata all the way to the file the user hands over,
and proves in bytes that the coordinates do not come with it.

The privacy test was the piece that mattered and the piece that was wrong.
It searched the whole file for the two-byte hemisphere reference "N\0" or
"E\0", which is not a fingerprint of a GPS directory at all: sample 14 of the
sRGB tone curve inside the ICC profile every export embeds is 69, written as
`00 45`, and the next sample is below 256, so its high byte is `00`. Every
format would have failed a test about a colour profile. The needle is now the
twenty-four bytes a coordinate actually serialises to — three rationals, both
byte orders, since exif.rs writes little-endian and the tiff crate writes in
the host's — which cannot match by accident, and the retaining test asserts
the same needle is *present* so a search that could never find anything
cannot make the stripping test pass by being useless.

The batch exporter now hands the decoder's reading on to the encoder. It
already read the metadata for the orientation and the {date} token; passing
it through is what puts the camera, the lens and the rights statement into
the file. Nothing about privacy is decided there — dr-export takes that
decision once, from the settings.

The example passes it too, because it is the only place in the tree that
produces files a person can open in exiftool. A unit test can prove a GPS
directory is absent from a byte slice; only a real export proves a real
photograph comes out the far end still knowing which camera took it.

TRACES: FR-EXP-8

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 19:15:06 +02:00

1066 lines
44 KiB
Rust

//! TRACES: FR-EXP-1 | FR-EXP-2 | FR-EXP-8
//! The encoders.
//!
//! All four write RGB, not RGBA. The pipeline produces an opaque frame — no
//! operation makes a pixel transparent, and the shader writes 1.0 into alpha
//! unconditionally — so a fourth channel would be a third more bytes carrying
//! the same value in every pixel, and a PNG that some tools then treat as
//! having meaningful transparency.
//!
//! # The colour profile
//!
//! All four embed one, in the place their container puts it: a JPEG APP2
//! segment, a PNG `iCCP` chunk, TIFF tag 34675. Encoding correctly and
//! labelling correctly are separate jobs and both are required — pixels in
//! Display P3 with no profile are read as sRGB and come out desaturated,
//! which is a worse outcome than not offering the space at all.
//!
//! sRGB gets one too, rather than relying on it being everyone's default.
//! Untagged is not the same as tagged sRGB: it means "guess", and the guess
//! differs between a browser, a phone gallery and a print shop.
//!
//! # Metadata
//!
//! 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.
//!
//! 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::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,
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)),
}
}
/// Drop alpha, which the pipeline never varies.
fn rgb(rgba: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(rgba.len() / 4 * 3);
for px in rgba.chunks_exact(4) {
out.extend_from_slice(&px[..3]);
}
out
}
fn jpeg(
rgba: &[u8],
width: u32,
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
.add_icc_profile(profile)
.map_err(|e| ExportError::Encode(e.to_string()))?;
encoder
.encode(
&rgb(rgba),
width as u16,
height as u16,
jpeg_encoder::ColorType::Rgb,
)
.map_err(|e| ExportError::Encode(e.to_string()))?;
Ok(bytes)
}
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
// the one field `png::Encoder` has no setter for. The `sRGB` chunk is
// deliberately left unset: the crate writes `iCCP` only in its
// absence, and an sRGB chunk beside a P3 profile is a contradiction a
// reader has to pick a side of.
let mut info = png::Info::with_size(width, height);
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()))?;
let mut writer = encoder
.write_header()
.map_err(|e| ExportError::Encode(e.to_string()))?;
writer
.write_image_data(&rgb(rgba))
.map_err(|e| ExportError::Encode(e.to_string()))?;
writer
.finish()
.map_err(|e| ExportError::Encode(e.to_string()))?;
}
Ok(bytes)
}
/// Bytes whose TIFF field type is `UNDEFINED` (7).
///
/// 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 Undefined<'_> {
const BYTE_LEN: u8 = 1;
const FIELD_TYPE: tiff::tags::Type = tiff::tags::Type::UNDEFINED;
fn count(&self) -> usize {
self.0.len()
}
fn data(&self) -> std::borrow::Cow<'_, [u8]> {
std::borrow::Cow::Borrowed(self.0)
}
}
/// 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],
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()))?;
Ok(bytes.into_inner())
}
/// Add the profile tag to a directory being built.
///
/// Shared by both TIFF widths, and separate from them because `write_image`
/// cannot be used once there is a tag to add — the directory has to be opened,
/// written into, and closed by hand.
fn tag_profile<W, K>(
dir: &mut tiff::encoder::DirectoryEncoder<'_, W, K>,
profile: &[u8],
) -> Result<(), ExportError>
where
W: std::io::Write + std::io::Seek,
K: tiff::encoder::TiffKind,
{
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
/// target (`AdjustPass::FORMAT` is `Rgba8Unorm`, and the generated shader
/// declares `texture_storage_2d<rgba8unorm, write>`), so the samples widened
/// here hold eight bits of information in a sixteen-bit container. The file
/// is a correct 16-bit TIFF and will round-trip through any editor without
/// further loss — but it does not resurrect precision the pipeline already
/// quantised away.
///
/// Making it mean what it says is a pipeline change rather than an encoder
/// 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],
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
// shift maps it to 65280 and makes white slightly grey.
let wide: Vec<u16> = rgb(rgba).iter().map(|&v| u16::from(v) * 257).collect();
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()))?;
Ok(bytes.into_inner())
}
#[cfg(test)]
mod tests {
use super::*;
use dr_types::ColourSpace;
#[test]
fn alpha_is_dropped_before_encoding() {
let rgba = vec![1, 2, 3, 255, 4, 5, 6, 255];
assert_eq!(rgb(&rgba), vec![1, 2, 3, 4, 5, 6]);
}
#[test]
fn white_widens_to_full_scale_not_almost() {
// The bug a left-shift introduces: 255 << 8 is 65280, so pure white
// comes out a quarter of a percent grey in every 16-bit export.
assert_eq!(u16::from(255u8) * 257, u16::MAX);
assert_eq!(u16::from(0u8) * 257, 0);
// And the midpoint stays the midpoint.
assert_eq!(u16::from(128u8) * 257, 32896);
}
#[test]
fn a_png_round_trips_its_pixels_exactly() {
// PNG is lossless, so this is a real end-to-end check that the buffer
// reaching the encoder is the one we think it is — channel order
// included, which a size assertion would not catch.
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 = 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();
let mut out = vec![0; reader.output_buffer_size().unwrap()];
let info = reader.next_frame(&mut out).unwrap();
assert_eq!((info.width, info.height), (2, 2));
assert_eq!(info.color_type, png::ColorType::Rgb);
assert_eq!(&out[..12], &[255, 0, 0, 0, 255, 0, 0, 0, 255, 10, 20, 30]);
}
/// A flat frame, for the tests that care only about what surrounds the
/// pixels.
fn flat(w: u32, h: u32) -> Vec<u8> {
vec![128; (w * h * 4) as usize]
}
#[test]
fn a_png_carries_a_profile_a_decoder_gets_back_intact() {
// Read out through the PNG decoder, so this exercises the iCCP
// chunk's deflate round-trip rather than asserting the encoder was
// called. A truncated or mis-deflated profile is one a reader
// 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, None).unwrap();
let decoder = png::Decoder::new(std::io::Cursor::new(&bytes));
let reader = decoder.read_info().unwrap();
let got = reader
.info()
.icc_profile
.as_ref()
.unwrap_or_else(|| panic!("{space:?} PNG carries no profile"));
assert_eq!(got.as_ref(), want.as_slice(), "{space:?}");
}
}
#[test]
fn a_jpeg_carries_its_profile_in_a_well_formed_app2_segment() {
// The APP2 form is exacting: the marker, a length, the string
// "ICC_PROFILE\0", then a chunk index and count before the payload.
// A reader that does not find that header ignores the segment, so
// 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, None).unwrap();
let got = jpeg_icc(&bytes)
.unwrap_or_else(|| panic!("{space:?} JPEG has no ICC_PROFILE segment"));
assert_eq!(got, want, "{space:?}");
}
}
/// Walk a JPEG's marker segments and reassemble the ICC profile.
///
/// Hand-rolled because `zune-jpeg` decodes pixels and this is about what
/// travels beside them.
fn jpeg_icc(bytes: &[u8]) -> Option<Vec<u8>> {
const TAG: &[u8] = b"ICC_PROFILE\0";
let mut out = Vec::new();
let mut i = 2; // past the SOI
while i + 4 <= bytes.len() {
if bytes[i] != 0xFF {
return None;
}
let marker = bytes[i + 1];
// Start of scan: entropy-coded data follows and there are no more
// parseable segments.
if marker == 0xDA {
break;
}
let len = usize::from(u16::from_be_bytes([bytes[i + 2], bytes[i + 3]]));
let payload = &bytes[i + 4..i + 2 + len];
if marker == 0xE2 && payload.starts_with(TAG) {
// Two bytes of chunk index and count follow the tag.
out.extend_from_slice(&payload[TAG.len() + 2..]);
}
i += 2 + len;
}
(!out.is_empty()).then_some(out)
}
#[test]
fn both_tiff_widths_carry_the_profile_in_tag_34675() {
// Read back through the `tiff` decoder's own tag lookup. Writing the
// tag with the wrong field type or a stale offset produces a file that
// still opens — with no profile, and therefore the wrong colours.
use tiff::decoder::Decoder;
use tiff::tags::Tag;
for space in ColourSpace::ALL {
let want = icc::profile(space);
for (label, bytes) in [
("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
.get_tag_u8_vec(Tag::Unknown(TAG_ICC_PROFILE))
.unwrap_or_else(|e| panic!("{space:?} {label} TIFF: {e}"));
assert_eq!(got, want, "{space:?} {label}");
}
}
}
#[test]
fn a_tiff_still_decodes_to_its_pixels_with_the_extra_tag_present() {
// Adding a tag means opening the directory by hand instead of using
// `write_image`, which is the sort of change that produces a valid
// header over unreadable strips.
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), 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 {
panic!("expected 8-bit samples");
};
assert_eq!(
&pixels[..12],
&[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:?}");
}
}
}