`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>
1075 lines
44 KiB
Rust
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:?}");
|
|
}
|
|
}
|
|
}
|