//! 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, 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 { 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, 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, 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, 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::(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( 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, gps: Option, } /// 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( encoder: &mut tiff::encoder::TiffEncoder, source: Option<&SourceMetadata>, width: u32, height: u32, ) -> Result 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( 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`), 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, 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 = 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::(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 = 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 { 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> { 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 = 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)> { [ 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 { 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 = 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:?}"); } } }