Files
DarkRoom/core/dr-export/src/encode.rs
T
dtourolleandClaude Opus 5 c75849040c Format the tree the way the gate asks for it
`cargo fmt --check` is a required step and had drifted across 45 files. Most of
it arrived this week: several operations were written in parallel worktrees and
merged by hand, and a hand-merge resolves conflicts without ever running the
formatter over the result.

No behaviour changes — this is `cargo fmt --all` and nothing else, kept as its
own commit so the next reader can skip it wholesale rather than search it for
one that matters.

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

1075 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:?}");
}
}
}