WIP: EXIF metadata on export
Checkpoint committed by the coordinator, not by the authoring agent: the session hit its API limit mid-task and left this work uncommitted. Committed so it survives, NOT because it is finished - expect failing tests and half-applied changes. The agent resumes from here.
This commit is contained in:
@@ -21,8 +21,8 @@ pub mod profile;
|
|||||||
pub use base_curve::BaseCurve;
|
pub use base_curve::BaseCurve;
|
||||||
pub use error::DecodeError;
|
pub use error::DecodeError;
|
||||||
pub use locate::{
|
pub use locate::{
|
||||||
defects, is_complete_jpeg, locate_preview, BadLine, BadPixel, Defects, PreviewLocation,
|
defects, is_complete_jpeg, jpeg_metadata, locate_preview, tiff_metadata, BadLine, BadPixel,
|
||||||
HEADER_BYTES,
|
Defects, PreviewLocation, HEADER_BYTES,
|
||||||
};
|
};
|
||||||
pub use preview::{
|
pub use preview::{
|
||||||
decode_jpeg, extract_embedded_preview, extract_preview, Preview, PreviewSize,
|
decode_jpeg, extract_embedded_preview, extract_preview, Preview, PreviewSize,
|
||||||
@@ -30,7 +30,7 @@ pub use preview::{
|
|||||||
};
|
};
|
||||||
pub use profile::CameraProfile;
|
pub use profile::CameraProfile;
|
||||||
|
|
||||||
use dr_types::{Format, Orientation};
|
use dr_types::{Format, Location, Orientation};
|
||||||
|
|
||||||
/// Capture metadata read from a file header.
|
/// Capture metadata read from a file header.
|
||||||
#[derive(Debug, Clone, Default, PartialEq)]
|
#[derive(Debug, Clone, Default, PartialEq)]
|
||||||
@@ -68,6 +68,27 @@ pub struct Metadata {
|
|||||||
/// to where it was taken, so without this a shoot in Tokyo displays on the
|
/// to where it was taken, so without this a shoot in Tokyo displays on the
|
||||||
/// wrong day in Paris.
|
/// wrong day in Paris.
|
||||||
pub captured_offset: Option<i32>,
|
pub captured_offset: Option<i32>,
|
||||||
|
/// TRACES: FR-EXP-8
|
||||||
|
/// Who made the photograph (EXIF `Artist`, 0x013B).
|
||||||
|
///
|
||||||
|
/// Read for the sake of exporting it again: a photographer who set a byline
|
||||||
|
/// in-camera set it so that it would still be there in the copy they hand
|
||||||
|
/// over, and an export that dropped it would be quietly removing the one
|
||||||
|
/// piece of metadata that says whose work this is.
|
||||||
|
pub artist: Option<String>,
|
||||||
|
/// TRACES: FR-EXP-8
|
||||||
|
/// The rights statement (EXIF `Copyright`, 0x8298).
|
||||||
|
pub copyright: Option<String>,
|
||||||
|
/// TRACES: FR-EXP-8
|
||||||
|
/// Where the shutter fired (the EXIF GPS directory, 0x8825).
|
||||||
|
///
|
||||||
|
/// **Read, but treated as radioactive downstream.** This is the field
|
||||||
|
/// FR-EXP-8's strip option exists for, and the export path drops it unless
|
||||||
|
/// the user has explicitly said otherwise (`ExportSettings::strip_location`
|
||||||
|
/// defaults to on). Parsing it here rather than refusing to look is what
|
||||||
|
/// makes "keep my coordinates" possible at all, and what lets the exporter
|
||||||
|
/// prove the field is gone rather than hope it was never present.
|
||||||
|
pub location: Option<Location>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decoded sensor data, before demosaic.
|
/// Decoded sensor data, before demosaic.
|
||||||
@@ -302,6 +323,10 @@ pub fn metadata(bytes: &[u8]) -> Result<Metadata, DecodeError> {
|
|||||||
.as_deref()
|
.as_deref()
|
||||||
.or(exif.offset_time.as_deref())
|
.or(exif.offset_time.as_deref())
|
||||||
.and_then(parse_exif_offset),
|
.and_then(parse_exif_offset),
|
||||||
|
// TRACES: FR-EXP-8
|
||||||
|
artist: exif.artist.clone().filter(|s| !s.trim().is_empty()),
|
||||||
|
copyright: exif.copyright.clone().filter(|s| !s.trim().is_empty()),
|
||||||
|
location: exif.gps.as_ref().and_then(rawler_location),
|
||||||
};
|
};
|
||||||
|
|
||||||
// rawler reports no capture time for some TIFF-derived files whose tag is
|
// rawler reports no capture time for some TIFF-derived files whose tag is
|
||||||
@@ -322,12 +347,57 @@ pub fn metadata(bytes: &[u8]) -> Result<Metadata, DecodeError> {
|
|||||||
out.iso = out.iso.or(fallback.iso);
|
out.iso = out.iso.or(fallback.iso);
|
||||||
out.lens = out.lens.take().or(fallback.lens);
|
out.lens = out.lens.take().or(fallback.lens);
|
||||||
out.orientation = out.orientation.or(fallback.orientation);
|
out.orientation = out.orientation.or(fallback.orientation);
|
||||||
|
// TRACES: FR-EXP-8
|
||||||
|
// Filled from the same walk for the same reason: a file rawler
|
||||||
|
// answered short on is one whose byline and rights statement would
|
||||||
|
// otherwise be dropped at export, and both sit in the IFD this has
|
||||||
|
// already read.
|
||||||
|
out.artist = out.artist.take().or(fallback.artist);
|
||||||
|
out.copyright = out.copyright.take().or(fallback.copyright);
|
||||||
|
out.location = out.location.or(fallback.location);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-EXP-8
|
||||||
|
/// rawler's GPS directory as a position.
|
||||||
|
///
|
||||||
|
/// The three-rational form is the tag's, not a position's: degrees, minutes
|
||||||
|
/// and seconds, each a fraction, with the hemisphere in a separate letter.
|
||||||
|
/// Everything downstream wants a number it can compare and write back, so the
|
||||||
|
/// conversion happens once, here.
|
||||||
|
fn rawler_location(gps: &rawler::exif::ExifGPS) -> Option<Location> {
|
||||||
|
/// A `Rational` as a float, with a zero denominator refused rather than
|
||||||
|
/// divided by — some bodies write `0/0` into an unfilled slot.
|
||||||
|
fn ratio(r: &rawler::formats::tiff::Rational) -> Option<f64> {
|
||||||
|
(r.d != 0).then(|| r.n as f64 / r.d as f64)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn degrees(dms: &[rawler::formats::tiff::Rational; 3], reference: Option<&String>) -> Option<f64> {
|
||||||
|
let d = ratio(&dms[0])? + ratio(&dms[1])? / 60.0 + ratio(&dms[2])? / 3600.0;
|
||||||
|
// South and west are stored as positive magnitudes with a letter.
|
||||||
|
let negative = matches!(
|
||||||
|
reference.map(|s| s.trim().to_ascii_uppercase()).as_deref(),
|
||||||
|
Some("S") | Some("W")
|
||||||
|
);
|
||||||
|
Some(if negative { -d } else { d })
|
||||||
|
}
|
||||||
|
|
||||||
|
let latitude = degrees(gps.gps_latitude.as_ref()?, gps.gps_latitude_ref.as_ref())?;
|
||||||
|
let longitude = degrees(gps.gps_longitude.as_ref()?, gps.gps_longitude_ref.as_ref())?;
|
||||||
|
// Reference 1 means below sea level; the altitude itself is unsigned.
|
||||||
|
let altitude = gps.gps_altitude.as_ref().and_then(ratio).map(|a| {
|
||||||
|
if gps.gps_altitude_ref == Some(1) {
|
||||||
|
-a
|
||||||
|
} else {
|
||||||
|
a
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Location::new(latitude, longitude, altitude)
|
||||||
|
}
|
||||||
|
|
||||||
/// TRACES: FR-CAT-5 | FR-DEV-3h
|
/// TRACES: FR-CAT-5 | FR-DEV-3h
|
||||||
/// Read just the stored orientation, from a file header.
|
/// Read just the stored orientation, from a file header.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -287,6 +287,16 @@ impl<'a> TiffReader<'a> {
|
|||||||
/// its offset.
|
/// its offset.
|
||||||
fn scalar(&self, e: &Entry) -> Option<u32> {
|
fn scalar(&self, e: &Entry) -> Option<u32> {
|
||||||
match e.kind {
|
match e.kind {
|
||||||
|
// BYTE, inline when count is 1. The GPS directory's altitude
|
||||||
|
// reference is one of these, and it is the difference between a
|
||||||
|
// hilltop and a position 400 m under the Dead Sea.
|
||||||
|
1 if e.count == 1 => Some(if self.little_endian {
|
||||||
|
e.value & 0xFF
|
||||||
|
} else {
|
||||||
|
// The value field is left-justified whatever the width, so a
|
||||||
|
// big-endian byte sits in the *top* octet.
|
||||||
|
e.value >> 24
|
||||||
|
}),
|
||||||
// SHORT, inline when count is 1.
|
// SHORT, inline when count is 1.
|
||||||
3 if e.count == 1 => Some(if self.little_endian {
|
3 if e.count == 1 => Some(if self.little_endian {
|
||||||
e.value & 0xFFFF
|
e.value & 0xFFFF
|
||||||
@@ -303,6 +313,35 @@ impl<'a> TiffReader<'a> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-EXP-8
|
||||||
|
/// One RATIONAL from an entry, as a number.
|
||||||
|
///
|
||||||
|
/// A rational is eight bytes, so it never fits the four-byte value field
|
||||||
|
/// and is always read through the offset — which is why `index` is
|
||||||
|
/// meaningful: the GPS directory stores latitude as three of them in a
|
||||||
|
/// row.
|
||||||
|
///
|
||||||
|
/// A zero denominator yields `None` rather than an infinity. Cameras do
|
||||||
|
/// write `0/0` into slots they had nothing for, and a shutter speed of
|
||||||
|
/// `inf` propagated into an exported file is worse than a missing one.
|
||||||
|
fn rational(&self, e: &Entry, index: u32) -> Option<f64> {
|
||||||
|
// 5 is RATIONAL (two unsigned longs); 10 is SRATIONAL (two signed).
|
||||||
|
if (e.kind != 5 && e.kind != 10) || index >= e.count {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let at = (e.value as usize).checked_add(index as usize * 8)?;
|
||||||
|
let n = read_u32(self.data, at, self.little_endian)?;
|
||||||
|
let d = read_u32(self.data, at + 4, self.little_endian)?;
|
||||||
|
if d == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(if e.kind == 10 {
|
||||||
|
n as i32 as f64 / d as i32 as f64
|
||||||
|
} else {
|
||||||
|
n as f64 / d as f64
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// An ASCII entry's string value.
|
/// An ASCII entry's string value.
|
||||||
///
|
///
|
||||||
/// EXIF strings are NUL-terminated and often padded, and camera vendors
|
/// EXIF strings are NUL-terminated and often padded, and camera vendors
|
||||||
@@ -444,6 +483,19 @@ pub fn tiff_metadata(tiff_data: &[u8]) -> Result<crate::Metadata, crate::DecodeE
|
|||||||
read_exif_entries(&reader, &sub, &mut md, &mut fb);
|
read_exif_entries(&reader, &sub, &mut md, &mut fb);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TRACES: FR-EXP-8
|
||||||
|
// The GPS directory is a third IFD, pointed at from the main one and
|
||||||
|
// read with its own tag table. First reading wins, as with
|
||||||
|
// orientation: a thumbnail IFD that repeats the pointer describes the
|
||||||
|
// same photograph, and the main image's is the one to trust.
|
||||||
|
if md.location.is_none() {
|
||||||
|
if let Some(e) = entries.iter().find(|e| e.tag == gps_tag::POINTER) {
|
||||||
|
if let Some(sub) = reader.scalar(e).and_then(|o| reader.read_ifd(o)) {
|
||||||
|
md.location = read_gps_entries(&reader, &sub);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ranked: when the shutter fired, else when the image was digitised, else
|
// Ranked: when the shutter fired, else when the image was digitised, else
|
||||||
@@ -516,6 +568,35 @@ mod exif_tag {
|
|||||||
pub const LENS_MODEL: u16 = 0xA434;
|
pub const LENS_MODEL: u16 = 0xA434;
|
||||||
pub const PIXEL_X: u16 = 0xA002;
|
pub const PIXEL_X: u16 = 0xA002;
|
||||||
pub const PIXEL_Y: u16 = 0xA003;
|
pub const PIXEL_Y: u16 = 0xA003;
|
||||||
|
/// TRACES: FR-EXP-8
|
||||||
|
/// Who made the photograph, and under what terms. Both live in the main
|
||||||
|
/// IFD beside `Make`, not in the Exif sub-IFD.
|
||||||
|
pub const ARTIST: u16 = 0x013B;
|
||||||
|
pub const COPYRIGHT: u16 = 0x8298;
|
||||||
|
/// Exposure, as RATIONALs. Read here as well as from rawler because the
|
||||||
|
/// JPEG path has no rawler behind it, and a camera JPEG that lost its
|
||||||
|
/// shutter speed on export lost it for good.
|
||||||
|
pub const EXPOSURE_TIME: u16 = 0x829A;
|
||||||
|
pub const FNUMBER: u16 = 0x829D;
|
||||||
|
pub const FOCAL_LENGTH: u16 = 0x920A;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-EXP-8
|
||||||
|
/// The GPS directory pointer, and the tags inside it.
|
||||||
|
///
|
||||||
|
/// A separate module from [`exif_tag`] because the numbers collide: 0x0001 is
|
||||||
|
/// `GPSLatitudeRef` here and `InteropIndex` there, and a GPS tag read against
|
||||||
|
/// a main-IFD table is how a file comes to claim an exposure time of "N".
|
||||||
|
mod gps_tag {
|
||||||
|
/// The main IFD entry pointing at the GPS directory.
|
||||||
|
pub const POINTER: u16 = 0x8825;
|
||||||
|
pub const LATITUDE_REF: u16 = 0x0001;
|
||||||
|
pub const LATITUDE: u16 = 0x0002;
|
||||||
|
pub const LONGITUDE_REF: u16 = 0x0003;
|
||||||
|
pub const LONGITUDE: u16 = 0x0004;
|
||||||
|
/// 0 above sea level, 1 below. The altitude itself is unsigned.
|
||||||
|
pub const ALTITUDE_REF: u16 = 0x0005;
|
||||||
|
pub const ALTITUDE: u16 = 0x0006;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Dates that stand in for a missing `DateTimeOriginal`.
|
/// Dates that stand in for a missing `DateTimeOriginal`.
|
||||||
@@ -542,6 +623,11 @@ fn read_exif_entries(
|
|||||||
exif_tag::MODEL => md.model = r.ascii(e),
|
exif_tag::MODEL => md.model = r.ascii(e),
|
||||||
exif_tag::LENS_MODEL => md.lens = r.ascii(e),
|
exif_tag::LENS_MODEL => md.lens = r.ascii(e),
|
||||||
exif_tag::ISO => md.iso = r.scalar(e),
|
exif_tag::ISO => md.iso = r.scalar(e),
|
||||||
|
exif_tag::ARTIST => md.artist = r.ascii(e),
|
||||||
|
exif_tag::COPYRIGHT => md.copyright = r.ascii(e),
|
||||||
|
exif_tag::EXPOSURE_TIME => md.shutter = r.rational(e, 0).map(|v| v as f32),
|
||||||
|
exif_tag::FNUMBER => md.aperture = r.rational(e, 0).map(|v| v as f32),
|
||||||
|
exif_tag::FOCAL_LENGTH => md.focal_length = r.rational(e, 0).map(|v| v as f32),
|
||||||
exif_tag::PIXEL_X => md.width = r.scalar(e),
|
exif_tag::PIXEL_X => md.width = r.scalar(e),
|
||||||
exif_tag::PIXEL_Y => md.height = r.scalar(e),
|
exif_tag::PIXEL_Y => md.height = r.scalar(e),
|
||||||
// First IFD wins, unlike the fields above, which take the last
|
// First IFD wins, unlike the fields above, which take the last
|
||||||
@@ -578,6 +664,49 @@ fn read_exif_entries(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-EXP-8
|
||||||
|
/// A GPS directory's entries as a position.
|
||||||
|
///
|
||||||
|
/// Both coordinates or nothing: a latitude without a longitude is not half a
|
||||||
|
/// position, it is no position, and half of one written into an export would
|
||||||
|
/// be a coordinate on the Greenwich meridian.
|
||||||
|
fn read_gps_entries(r: &TiffReader, entries: &[Entry]) -> Option<dr_types::Location> {
|
||||||
|
let find = |tag: u16| entries.iter().find(|e| e.tag == tag);
|
||||||
|
|
||||||
|
// Degrees, minutes and seconds, each its own rational — and each of the
|
||||||
|
// three optional in practice, since a body that fixed only to the minute
|
||||||
|
// still writes the entry.
|
||||||
|
let degrees = |tag: u16, ref_tag: u16| -> Option<f64> {
|
||||||
|
let e = find(tag)?;
|
||||||
|
let d = r.rational(e, 0)? + r.rational(e, 1).unwrap_or(0.0) / 60.0
|
||||||
|
+ r.rational(e, 2).unwrap_or(0.0) / 3600.0;
|
||||||
|
// The magnitude is unsigned; the hemisphere is a letter beside it.
|
||||||
|
let south_or_west = find(ref_tag)
|
||||||
|
.and_then(|e| r.ascii(e))
|
||||||
|
.map(|s| {
|
||||||
|
let s = s.trim().to_ascii_uppercase();
|
||||||
|
s == "S" || s == "W"
|
||||||
|
})
|
||||||
|
.unwrap_or(false);
|
||||||
|
Some(if south_or_west { -d } else { d })
|
||||||
|
};
|
||||||
|
|
||||||
|
let latitude = degrees(gps_tag::LATITUDE, gps_tag::LATITUDE_REF)?;
|
||||||
|
let longitude = degrees(gps_tag::LONGITUDE, gps_tag::LONGITUDE_REF)?;
|
||||||
|
let altitude = find(gps_tag::ALTITUDE)
|
||||||
|
.and_then(|e| r.rational(e, 0))
|
||||||
|
.map(|a| {
|
||||||
|
let below = find(gps_tag::ALTITUDE_REF).and_then(|e| r.scalar(e)) == Some(1);
|
||||||
|
if below {
|
||||||
|
-a
|
||||||
|
} else {
|
||||||
|
a
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
dr_types::Location::new(latitude, longitude, altitude)
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether a byte slice is a complete JPEG.
|
/// Whether a byte slice is a complete JPEG.
|
||||||
///
|
///
|
||||||
/// A truncated JPEG decodes to a partial image rather than an error — the
|
/// A truncated JPEG decodes to a partial image rather than an error — the
|
||||||
@@ -968,6 +1097,196 @@ mod tests {
|
|||||||
assert_eq!(md.model.as_deref(), Some("CanoScan 9000F Mark II"));
|
assert_eq!(md.model.as_deref(), Some("CanoScan 9000F Mark II"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A JPEG whose EXIF carries a GPS directory, built by hand.
|
||||||
|
///
|
||||||
|
/// The offsets are computed rather than written out because the whole
|
||||||
|
/// point of the exercise is that they are consistent: a GPS directory is
|
||||||
|
/// three levels of indirection — the main IFD points at it, and each
|
||||||
|
/// coordinate points at three rationals somewhere else again.
|
||||||
|
///
|
||||||
|
/// `lat`/`lon` are `(degrees, minutes, hundredths-of-a-second)` and the
|
||||||
|
/// refs are the hemisphere letters, exactly as a camera writes them.
|
||||||
|
fn jpeg_with_gps(
|
||||||
|
lat: (u32, u32, u32),
|
||||||
|
lat_ref: u8,
|
||||||
|
lon: (u32, u32, u32),
|
||||||
|
lon_ref: u8,
|
||||||
|
altitude: Option<(u32, u8)>,
|
||||||
|
) -> Vec<u8> {
|
||||||
|
// One entry in IFD0 (the GPS pointer), so the blob after it starts at
|
||||||
|
// the header (8) + count (2) + one entry (12) + the next-IFD link (4).
|
||||||
|
const GPS_IFD: u32 = 8 + 2 + 12 + 4;
|
||||||
|
let entries: u32 = if altitude.is_some() { 6 } else { 5 };
|
||||||
|
// Where the rationals live: after the GPS directory itself.
|
||||||
|
let heap = GPS_IFD + 2 + entries * 12 + 4;
|
||||||
|
|
||||||
|
let mut gps: Vec<(u16, u16, u32, u32)> = vec![
|
||||||
|
(gps_tag::LATITUDE_REF, 2, 2, u32::from(lat_ref)),
|
||||||
|
(gps_tag::LATITUDE, 5, 3, heap),
|
||||||
|
(gps_tag::LONGITUDE_REF, 2, 2, u32::from(lon_ref)),
|
||||||
|
(gps_tag::LONGITUDE, 5, 3, heap + 24),
|
||||||
|
];
|
||||||
|
if let Some((_, reference)) = altitude {
|
||||||
|
gps.push((gps_tag::ALTITUDE_REF, 1, 1, u32::from(reference)));
|
||||||
|
gps.push((gps_tag::ALTITUDE, 5, 1, heap + 48));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut extra = Vec::new();
|
||||||
|
extra.extend_from_slice(&(gps.len() as u16).to_le_bytes());
|
||||||
|
for (tag, kind, count, value) in &gps {
|
||||||
|
extra.extend_from_slice(&tag.to_le_bytes());
|
||||||
|
extra.extend_from_slice(&kind.to_le_bytes());
|
||||||
|
extra.extend_from_slice(&count.to_le_bytes());
|
||||||
|
extra.extend_from_slice(&value.to_le_bytes());
|
||||||
|
}
|
||||||
|
extra.extend_from_slice(&0u32.to_le_bytes());
|
||||||
|
|
||||||
|
let mut rational = |n: u32, d: u32| {
|
||||||
|
extra.extend_from_slice(&n.to_le_bytes());
|
||||||
|
extra.extend_from_slice(&d.to_le_bytes());
|
||||||
|
};
|
||||||
|
for (n, d) in [(lat.0, 1), (lat.1, 1), (lat.2, 100)] {
|
||||||
|
rational(n, d);
|
||||||
|
}
|
||||||
|
for (n, d) in [(lon.0, 1), (lon.1, 1), (lon.2, 100)] {
|
||||||
|
rational(n, d);
|
||||||
|
}
|
||||||
|
if let Some((metres, _)) = altitude {
|
||||||
|
rational(metres, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
jpeg_with_exif(&[(gps_tag::POINTER, 4, 1, GPS_IFD)], &extra)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_gps_directory_becomes_signed_degrees() {
|
||||||
|
// TRACES: FR-EXP-8
|
||||||
|
// 48° 51' 29.52" N, 2° 17' 40.2" E — the Eiffel Tower. Reading this
|
||||||
|
// correctly is what makes stripping it meaningful: a parser that
|
||||||
|
// silently failed would make the export path look private when it was
|
||||||
|
// only ignorant.
|
||||||
|
let jpeg = jpeg_with_gps((48, 51, 2952), b'N', (2, 17, 4020), b'E', Some((35, 0)));
|
||||||
|
let loc = jpeg_metadata(&jpeg).expect("EXIF").location.expect("a fix");
|
||||||
|
assert!((loc.latitude - 48.858200).abs() < 1e-5, "{loc:?}");
|
||||||
|
assert!((loc.longitude - 2.294500).abs() < 1e-5, "{loc:?}");
|
||||||
|
assert_eq!(loc.altitude, Some(35.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_hemisphere_letters_are_applied_not_ignored() {
|
||||||
|
// The failure this catches puts Sydney in the North Atlantic: the
|
||||||
|
// magnitudes are identical and only the letters differ.
|
||||||
|
let jpeg = jpeg_with_gps((33, 51, 3500), b'S', (151, 12, 3600), b'E', None);
|
||||||
|
let loc = jpeg_metadata(&jpeg).expect("EXIF").location.expect("a fix");
|
||||||
|
assert!(loc.latitude < 0.0, "southern latitude must be negative");
|
||||||
|
assert!(loc.longitude > 0.0, "eastern longitude must be positive");
|
||||||
|
assert!(loc.altitude.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_below_sea_level_altitude_keeps_its_sign() {
|
||||||
|
// Reference 1 means below sea level; the altitude itself is unsigned,
|
||||||
|
// so dropping the reference turns the Dead Sea into a hilltop.
|
||||||
|
let jpeg = jpeg_with_gps((31, 33, 0), b'N', (35, 28, 0), b'E', Some((430, 1)));
|
||||||
|
let loc = jpeg_metadata(&jpeg).expect("EXIF").location.expect("a fix");
|
||||||
|
assert_eq!(loc.altitude, Some(-430.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_latitude_with_no_longitude_is_not_half_a_position() {
|
||||||
|
// Half a coordinate written into a file would be a pin on the
|
||||||
|
// Greenwich meridian, which is worse than no pin.
|
||||||
|
const GPS_IFD: u32 = 8 + 2 + 12 + 4;
|
||||||
|
let heap = GPS_IFD + 2 + 12 + 4;
|
||||||
|
let mut extra = Vec::new();
|
||||||
|
extra.extend_from_slice(&1u16.to_le_bytes());
|
||||||
|
for (tag, kind, count, value) in [(gps_tag::LATITUDE, 5u16, 3u32, heap)] {
|
||||||
|
extra.extend_from_slice(&tag.to_le_bytes());
|
||||||
|
extra.extend_from_slice(&kind.to_le_bytes());
|
||||||
|
extra.extend_from_slice(&count.to_le_bytes());
|
||||||
|
extra.extend_from_slice(&value.to_le_bytes());
|
||||||
|
}
|
||||||
|
extra.extend_from_slice(&0u32.to_le_bytes());
|
||||||
|
for (n, d) in [(48u32, 1u32), (51, 1), (2952, 100)] {
|
||||||
|
extra.extend_from_slice(&n.to_le_bytes());
|
||||||
|
extra.extend_from_slice(&d.to_le_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
let jpeg = jpeg_with_exif(&[(gps_tag::POINTER, 4, 1, GPS_IFD)], &extra);
|
||||||
|
assert!(jpeg_metadata(&jpeg).expect("EXIF").location.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_byline_and_the_rights_statement_are_read() {
|
||||||
|
// TRACES: FR-EXP-8
|
||||||
|
// Both live in the main IFD, and both are the half of FR-EXP-8 that
|
||||||
|
// must *survive* an export rather than be removed by it.
|
||||||
|
let artist = b"Duncan Tourolle\0";
|
||||||
|
let copyright = b"(c) 2026 Duncan Tourolle. All rights reserved.\0";
|
||||||
|
let base = 8 + 2 + 2 * 12 + 4;
|
||||||
|
let mut extra = Vec::new();
|
||||||
|
extra.extend_from_slice(artist);
|
||||||
|
extra.extend_from_slice(copyright);
|
||||||
|
|
||||||
|
let jpeg = jpeg_with_exif(
|
||||||
|
&[
|
||||||
|
(exif_tag::ARTIST, 2, artist.len() as u32, base),
|
||||||
|
(
|
||||||
|
exif_tag::COPYRIGHT,
|
||||||
|
2,
|
||||||
|
copyright.len() as u32,
|
||||||
|
base + artist.len() as u32,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
&extra,
|
||||||
|
);
|
||||||
|
let md = jpeg_metadata(&jpeg).expect("EXIF");
|
||||||
|
assert_eq!(md.artist.as_deref(), Some("Duncan Tourolle"));
|
||||||
|
assert_eq!(
|
||||||
|
md.copyright.as_deref(),
|
||||||
|
Some("(c) 2026 Duncan Tourolle. All rights reserved.")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exposure_rationals_are_read_from_a_jpeg() {
|
||||||
|
// rawler fills these for a RAW; a camera JPEG has nothing behind it
|
||||||
|
// but this reader, and an export that lost the shutter speed lost it
|
||||||
|
// for good.
|
||||||
|
let base = 8 + 2 + 3 * 12 + 4;
|
||||||
|
let mut extra = Vec::new();
|
||||||
|
for (n, d) in [(1u32, 250u32), (28, 10), (850, 10)] {
|
||||||
|
extra.extend_from_slice(&n.to_le_bytes());
|
||||||
|
extra.extend_from_slice(&d.to_le_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
let jpeg = jpeg_with_exif(
|
||||||
|
&[
|
||||||
|
(exif_tag::EXPOSURE_TIME, 5, 1, base),
|
||||||
|
(exif_tag::FNUMBER, 5, 1, base + 8),
|
||||||
|
(exif_tag::FOCAL_LENGTH, 5, 1, base + 16),
|
||||||
|
],
|
||||||
|
&extra,
|
||||||
|
);
|
||||||
|
let md = jpeg_metadata(&jpeg).expect("EXIF");
|
||||||
|
assert_eq!(md.shutter, Some(1.0 / 250.0));
|
||||||
|
assert_eq!(md.aperture, Some(2.8));
|
||||||
|
assert_eq!(md.focal_length, Some(85.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_zero_denominator_is_no_reading_rather_than_an_infinity() {
|
||||||
|
// Bodies do write `0/0` into a slot they had nothing for, and `inf`
|
||||||
|
// seconds carried into an exported file is worse than a gap.
|
||||||
|
let base = 8 + 2 + 12 + 4;
|
||||||
|
let mut extra = Vec::new();
|
||||||
|
extra.extend_from_slice(&0u32.to_le_bytes());
|
||||||
|
extra.extend_from_slice(&0u32.to_le_bytes());
|
||||||
|
|
||||||
|
let jpeg = jpeg_with_exif(&[(exif_tag::EXPOSURE_TIME, 5, 1, base)], &extra);
|
||||||
|
assert_eq!(jpeg_metadata(&jpeg).expect("EXIF").shutter, None);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_marker_walk_does_not_run_off_a_truncated_file() {
|
fn a_marker_walk_does_not_run_off_a_truncated_file() {
|
||||||
// Untrusted input (NFR-SEC-1): a length field claiming more than the
|
// Untrusted input (NFR-SEC-1): a length field claiming more than the
|
||||||
|
|||||||
+666
-31
@@ -21,38 +21,78 @@
|
|||||||
//!
|
//!
|
||||||
//! # Metadata
|
//! # Metadata
|
||||||
//!
|
//!
|
||||||
//! Nothing is written. `strip_location` defaults to on (FR-EXP-8) and this
|
//! Written the same way the profile is: in the place each container puts it —
|
||||||
//! satisfies it in the strongest possible way: there is no EXIF block, so
|
//! a JPEG APP1 segment behind `Exif\0\0`, a PNG `eXIf` chunk, and for TIFF the
|
||||||
//! there is no GPS tag, no serial number, and no lens history in the file
|
//! image directory itself, since a TIFF's own IFD *is* EXIF and a nested block
|
||||||
//! that leaves the machine.
|
//! would be a second, contradictory copy.
|
||||||
//!
|
//!
|
||||||
//! The other half of FR-EXP-8 — *retaining* camera and copyright metadata
|
//! What may be written is decided before the bytes are: [`SourceMetadata`] is
|
||||||
//! when the user asks for it — is not implemented, and cannot be faked by
|
//! an allowlist of parsed fields rather than a copy of the source's block, and
|
||||||
//! omission. It needs the source's EXIF carried through `dr-decode` and
|
//! `sanitised` empties the location out of it unless the user asked otherwise.
|
||||||
//! re-serialised here, which is a piece of work in its own right and belongs
|
//! By the time any function below runs there is no privacy decision left to
|
||||||
//! with the batch-export path that would make it worth having.
|
//! 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 dr_types::{ExportFormat, ExportSettings};
|
||||||
|
|
||||||
use crate::{icc, ExportError};
|
use crate::metadata::SourceMetadata;
|
||||||
|
use crate::{exif, icc, ExportError};
|
||||||
|
|
||||||
/// Encode a resized, sharpened RGBA buffer to the requested format.
|
/// Encode a resized, sharpened RGBA buffer to the requested format.
|
||||||
///
|
///
|
||||||
/// The buffer is already encoded into `settings.colour_space` — that happened
|
/// The buffer is already encoded into `settings.colour_space` — that happened
|
||||||
/// in the shader, at the only point where the unclipped colour still existed.
|
/// in the shader, at the only point where the unclipped colour still existed.
|
||||||
/// All that is left here is to say so.
|
/// 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(
|
pub fn encode(
|
||||||
rgba: &[u8],
|
rgba: &[u8],
|
||||||
width: u32,
|
width: u32,
|
||||||
height: u32,
|
height: u32,
|
||||||
settings: &ExportSettings,
|
settings: &ExportSettings,
|
||||||
|
source: Option<&SourceMetadata>,
|
||||||
) -> Result<Vec<u8>, ExportError> {
|
) -> Result<Vec<u8>, ExportError> {
|
||||||
let profile = icc::profile(settings.colour_space);
|
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 {
|
match settings.format {
|
||||||
ExportFormat::Jpeg => jpeg(rgba, width, height, settings.quality, &profile),
|
ExportFormat::Jpeg => jpeg(
|
||||||
ExportFormat::Png => png(rgba, width, height, &profile),
|
rgba,
|
||||||
ExportFormat::Tiff8 => tiff8(rgba, width, height, &profile),
|
width,
|
||||||
ExportFormat::Tiff16 => tiff16(rgba, width, height, &profile),
|
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)),
|
other => Err(ExportError::FormatUnsupported(other)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -72,9 +112,20 @@ fn jpeg(
|
|||||||
height: u32,
|
height: u32,
|
||||||
quality: u8,
|
quality: u8,
|
||||||
profile: &[u8],
|
profile: &[u8],
|
||||||
|
exif: Option<&[u8]>,
|
||||||
) -> Result<Vec<u8>, ExportError> {
|
) -> Result<Vec<u8>, ExportError> {
|
||||||
let mut bytes = Vec::new();
|
let mut bytes = Vec::new();
|
||||||
let mut encoder = jpeg_encoder::Encoder::new(&mut bytes, quality);
|
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
|
// 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.
|
// generates fit in one, but the branch is the encoder's rather than ours.
|
||||||
encoder
|
encoder
|
||||||
@@ -91,7 +142,13 @@ fn jpeg(
|
|||||||
Ok(bytes)
|
Ok(bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn png(rgba: &[u8], width: u32, height: u32, profile: &[u8]) -> Result<Vec<u8>, ExportError> {
|
fn png(
|
||||||
|
rgba: &[u8],
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
profile: &[u8],
|
||||||
|
exif: Option<&[u8]>,
|
||||||
|
) -> Result<Vec<u8>, ExportError> {
|
||||||
let mut bytes = Vec::new();
|
let mut bytes = Vec::new();
|
||||||
{
|
{
|
||||||
// Built through `Info` rather than the setters, because the profile is
|
// Built through `Info` rather than the setters, because the profile is
|
||||||
@@ -103,6 +160,13 @@ fn png(rgba: &[u8], width: u32, height: u32, profile: &[u8]) -> Result<Vec<u8>,
|
|||||||
info.color_type = png::ColorType::Rgb;
|
info.color_type = png::ColorType::Rgb;
|
||||||
info.bit_depth = png::BitDepth::Eight;
|
info.bit_depth = png::BitDepth::Eight;
|
||||||
info.icc_profile = Some(std::borrow::Cow::Borrowed(profile));
|
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)
|
let encoder = png::Encoder::with_info(&mut bytes, info)
|
||||||
.map_err(|e| ExportError::Encode(e.to_string()))?;
|
.map_err(|e| ExportError::Encode(e.to_string()))?;
|
||||||
@@ -119,15 +183,16 @@ fn png(rgba: &[u8], width: u32, height: u32, profile: &[u8]) -> Result<Vec<u8>,
|
|||||||
Ok(bytes)
|
Ok(bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The ICC profile as a TIFF tag value.
|
/// Bytes whose TIFF field type is `UNDEFINED` (7).
|
||||||
///
|
///
|
||||||
/// A newtype only because the tag's field type is `UNDEFINED` (7) and the
|
/// A newtype only because the `tiff` crate maps a plain `&[u8]` to `BYTE` (1).
|
||||||
/// `tiff` crate maps a plain `&[u8]` to `BYTE` (1). Both are single bytes and
|
/// Both are single bytes and most readers do not look, but libtiff declares
|
||||||
/// most readers do not look, but libtiff declares `TIFFTAG_ICCPROFILE` as
|
/// `TIFFTAG_ICCPROFILE` as undefined and a strict reader is entitled to agree
|
||||||
/// undefined and a strict reader is entitled to agree with it.
|
/// with it. `ExifVersion` is the same shape for a different reason: it is four
|
||||||
struct IccTag<'a>(&'a [u8]);
|
/// characters that are deliberately not a string.
|
||||||
|
struct Undefined<'a>(&'a [u8]);
|
||||||
|
|
||||||
impl tiff::encoder::TiffValue for IccTag<'_> {
|
impl tiff::encoder::TiffValue for Undefined<'_> {
|
||||||
const BYTE_LEN: u8 = 1;
|
const BYTE_LEN: u8 = 1;
|
||||||
const FIELD_TYPE: tiff::tags::Type = tiff::tags::Type::UNDEFINED;
|
const FIELD_TYPE: tiff::tags::Type = tiff::tags::Type::UNDEFINED;
|
||||||
|
|
||||||
@@ -140,19 +205,85 @@ impl tiff::encoder::TiffValue for IccTag<'_> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-EXP-8
|
||||||
|
/// A string as an EXIF `ASCII` value.
|
||||||
|
///
|
||||||
|
/// The `tiff` crate's own `str` value *rejects* anything non-ASCII, which
|
||||||
|
/// would turn a copyright line reading `© 2026 Frédéric` into a failed export
|
||||||
|
/// — the file not written at all, over a character. Cameras and every other
|
||||||
|
/// editor write UTF-8 into these fields regardless of what the 1992
|
||||||
|
/// specification says, `dr-decode` reads them back with `from_utf8_lossy`, and
|
||||||
|
/// a mangled accent is a far better outcome than a refusal. So the bytes go
|
||||||
|
/// through verbatim with the terminating NUL the type requires.
|
||||||
|
struct Ascii<'a>(&'a str);
|
||||||
|
|
||||||
|
impl tiff::encoder::TiffValue for Ascii<'_> {
|
||||||
|
const BYTE_LEN: u8 = 1;
|
||||||
|
const FIELD_TYPE: tiff::tags::Type = tiff::tags::Type::ASCII;
|
||||||
|
|
||||||
|
fn count(&self) -> usize {
|
||||||
|
// The NUL is part of the count, and a reader that trusts the count
|
||||||
|
// over the terminator reads one character short without it.
|
||||||
|
self.0.len() + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
fn data(&self) -> std::borrow::Cow<'_, [u8]> {
|
||||||
|
let mut out = self.0.as_bytes().to_vec();
|
||||||
|
out.push(0);
|
||||||
|
std::borrow::Cow::Owned(out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-EXP-8
|
||||||
|
/// Several `RATIONAL`s in one tag — a GPS coordinate is three.
|
||||||
|
///
|
||||||
|
/// The bytes are **native-endian** rather than little-endian, unlike
|
||||||
|
/// everything `exif.rs` writes. That is not an inconsistency: the `tiff` crate
|
||||||
|
/// writes the file in the host's byte order and stamps the header to match, so
|
||||||
|
/// a value that forced little-endian would be read back byte-swapped on a
|
||||||
|
/// big-endian machine. `exif.rs` builds its own header and so chooses its own
|
||||||
|
/// order; here the container has already chosen.
|
||||||
|
struct Rationals<'a>(&'a [(u32, u32)]);
|
||||||
|
|
||||||
|
impl tiff::encoder::TiffValue for Rationals<'_> {
|
||||||
|
const BYTE_LEN: u8 = 8;
|
||||||
|
const FIELD_TYPE: tiff::tags::Type = tiff::tags::Type::RATIONAL;
|
||||||
|
|
||||||
|
fn count(&self) -> usize {
|
||||||
|
self.0.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn data(&self) -> std::borrow::Cow<'_, [u8]> {
|
||||||
|
let mut out = Vec::with_capacity(self.0.len() * 8);
|
||||||
|
for (n, d) in self.0 {
|
||||||
|
out.extend_from_slice(&n.to_ne_bytes());
|
||||||
|
out.extend_from_slice(&d.to_ne_bytes());
|
||||||
|
}
|
||||||
|
std::borrow::Cow::Owned(out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Tag 34675, `InterColourProfile`. Not in the `tiff` crate's `Tag` enum.
|
/// Tag 34675, `InterColourProfile`. Not in the `tiff` crate's `Tag` enum.
|
||||||
const TAG_ICC_PROFILE: u16 = 34675;
|
const TAG_ICC_PROFILE: u16 = 34675;
|
||||||
|
|
||||||
fn tiff8(rgba: &[u8], width: u32, height: u32, profile: &[u8]) -> Result<Vec<u8>, ExportError> {
|
fn tiff8(
|
||||||
|
rgba: &[u8],
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
profile: &[u8],
|
||||||
|
source: Option<&SourceMetadata>,
|
||||||
|
) -> Result<Vec<u8>, ExportError> {
|
||||||
use tiff::encoder::{colortype, TiffEncoder};
|
use tiff::encoder::{colortype, TiffEncoder};
|
||||||
|
|
||||||
let mut bytes = std::io::Cursor::new(Vec::new());
|
let mut bytes = std::io::Cursor::new(Vec::new());
|
||||||
let mut encoder =
|
let mut encoder =
|
||||||
TiffEncoder::new(&mut bytes).map_err(|e| ExportError::Encode(e.to_string()))?;
|
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
|
let mut image = encoder
|
||||||
.new_image::<colortype::RGB8>(width, height)
|
.new_image::<colortype::RGB8>(width, height)
|
||||||
.map_err(|e| ExportError::Encode(e.to_string()))?;
|
.map_err(|e| ExportError::Encode(e.to_string()))?;
|
||||||
tag_profile(image.encoder(), profile)?;
|
tag_profile(image.encoder(), profile)?;
|
||||||
|
tag_metadata(image.encoder(), source, &sub)?;
|
||||||
image
|
image
|
||||||
.write_data(&rgb(rgba))
|
.write_data(&rgb(rgba))
|
||||||
.map_err(|e| ExportError::Encode(e.to_string()))?;
|
.map_err(|e| ExportError::Encode(e.to_string()))?;
|
||||||
@@ -172,10 +303,214 @@ where
|
|||||||
W: std::io::Write + std::io::Seek,
|
W: std::io::Write + std::io::Seek,
|
||||||
K: tiff::encoder::TiffKind,
|
K: tiff::encoder::TiffKind,
|
||||||
{
|
{
|
||||||
dir.write_tag(tiff::tags::Tag::Unknown(TAG_ICC_PROFILE), IccTag(profile))
|
dir.write_tag(tiff::tags::Tag::Unknown(TAG_ICC_PROFILE), Undefined(profile))
|
||||||
.map_err(|e| ExportError::Encode(e.to_string()))
|
.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.
|
/// 16-bit TIFF, for work continuing in another editor.
|
||||||
///
|
///
|
||||||
/// **Honest about what it carries.** The adjust pass renders to an 8-bit
|
/// **Honest about what it carries.** The adjust pass renders to an 8-bit
|
||||||
@@ -190,7 +525,13 @@ where
|
|||||||
/// one: the composer has to be told what format to write, and export has to
|
/// 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,
|
/// 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.
|
/// which is still the right thing to hand an editor that works in 16-bit.
|
||||||
fn tiff16(rgba: &[u8], width: u32, height: u32, profile: &[u8]) -> Result<Vec<u8>, ExportError> {
|
fn tiff16(
|
||||||
|
rgba: &[u8],
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
profile: &[u8],
|
||||||
|
source: Option<&SourceMetadata>,
|
||||||
|
) -> Result<Vec<u8>, ExportError> {
|
||||||
use tiff::encoder::{colortype, TiffEncoder};
|
use tiff::encoder::{colortype, TiffEncoder};
|
||||||
|
|
||||||
// `x * 257` rather than `x << 8`: it maps 255 to 65535 exactly, where the
|
// `x * 257` rather than `x << 8`: it maps 255 to 65535 exactly, where the
|
||||||
@@ -200,10 +541,12 @@ fn tiff16(rgba: &[u8], width: u32, height: u32, profile: &[u8]) -> Result<Vec<u8
|
|||||||
let mut bytes = std::io::Cursor::new(Vec::new());
|
let mut bytes = std::io::Cursor::new(Vec::new());
|
||||||
let mut encoder =
|
let mut encoder =
|
||||||
TiffEncoder::new(&mut bytes).map_err(|e| ExportError::Encode(e.to_string()))?;
|
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
|
let mut image = encoder
|
||||||
.new_image::<colortype::RGB16>(width, height)
|
.new_image::<colortype::RGB16>(width, height)
|
||||||
.map_err(|e| ExportError::Encode(e.to_string()))?;
|
.map_err(|e| ExportError::Encode(e.to_string()))?;
|
||||||
tag_profile(image.encoder(), profile)?;
|
tag_profile(image.encoder(), profile)?;
|
||||||
|
tag_metadata(image.encoder(), source, &sub)?;
|
||||||
image
|
image
|
||||||
.write_data(&wide)
|
.write_data(&wide)
|
||||||
.map_err(|e| ExportError::Encode(e.to_string()))?;
|
.map_err(|e| ExportError::Encode(e.to_string()))?;
|
||||||
@@ -242,7 +585,7 @@ mod tests {
|
|||||||
0, 0, 255, 255, // blue
|
0, 0, 255, 255, // blue
|
||||||
10, 20, 30, 255,
|
10, 20, 30, 255,
|
||||||
];
|
];
|
||||||
let bytes = png(&rgba, 2, 2, &icc::profile(ColourSpace::Srgb)).unwrap();
|
let bytes = png(&rgba, 2, 2, &icc::profile(ColourSpace::Srgb), None).unwrap();
|
||||||
|
|
||||||
let decoder = png::Decoder::new(std::io::Cursor::new(&bytes));
|
let decoder = png::Decoder::new(std::io::Cursor::new(&bytes));
|
||||||
let mut reader = decoder.read_info().unwrap();
|
let mut reader = decoder.read_info().unwrap();
|
||||||
@@ -268,7 +611,7 @@ mod tests {
|
|||||||
// discards silently, leaving the file to be guessed at as sRGB.
|
// discards silently, leaving the file to be guessed at as sRGB.
|
||||||
for space in ColourSpace::ALL {
|
for space in ColourSpace::ALL {
|
||||||
let want = icc::profile(space);
|
let want = icc::profile(space);
|
||||||
let bytes = png(&flat(4, 4), 4, 4, &want).unwrap();
|
let bytes = png(&flat(4, 4), 4, 4, &want, None).unwrap();
|
||||||
|
|
||||||
let decoder = png::Decoder::new(std::io::Cursor::new(&bytes));
|
let decoder = png::Decoder::new(std::io::Cursor::new(&bytes));
|
||||||
let reader = decoder.read_info().unwrap();
|
let reader = decoder.read_info().unwrap();
|
||||||
@@ -289,7 +632,7 @@ mod tests {
|
|||||||
// walking it here is the only way to know the file is really tagged.
|
// walking it here is the only way to know the file is really tagged.
|
||||||
for space in ColourSpace::ALL {
|
for space in ColourSpace::ALL {
|
||||||
let want = icc::profile(space);
|
let want = icc::profile(space);
|
||||||
let bytes = jpeg(&flat(4, 4), 4, 4, 90, &want).unwrap();
|
let bytes = jpeg(&flat(4, 4), 4, 4, 90, &want, None).unwrap();
|
||||||
let got = jpeg_icc(&bytes)
|
let got = jpeg_icc(&bytes)
|
||||||
.unwrap_or_else(|| panic!("{space:?} JPEG has no ICC_PROFILE segment"));
|
.unwrap_or_else(|| panic!("{space:?} JPEG has no ICC_PROFILE segment"));
|
||||||
assert_eq!(got, want, "{space:?}");
|
assert_eq!(got, want, "{space:?}");
|
||||||
@@ -336,8 +679,8 @@ mod tests {
|
|||||||
for space in ColourSpace::ALL {
|
for space in ColourSpace::ALL {
|
||||||
let want = icc::profile(space);
|
let want = icc::profile(space);
|
||||||
for (label, bytes) in [
|
for (label, bytes) in [
|
||||||
("8-bit", tiff8(&flat(4, 4), 4, 4, &want).unwrap()),
|
("8-bit", tiff8(&flat(4, 4), 4, 4, &want, None).unwrap()),
|
||||||
("16-bit", tiff16(&flat(4, 4), 4, 4, &want).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 mut d = Decoder::new(std::io::Cursor::new(&bytes)).expect("decode");
|
||||||
let got = d
|
let got = d
|
||||||
@@ -361,7 +704,7 @@ mod tests {
|
|||||||
0, 0, 255, 255, // blue
|
0, 0, 255, 255, // blue
|
||||||
10, 20, 30, 255,
|
10, 20, 30, 255,
|
||||||
];
|
];
|
||||||
let bytes = tiff8(&rgba, 2, 2, &icc::profile(ColourSpace::Srgb)).unwrap();
|
let bytes = tiff8(&rgba, 2, 2, &icc::profile(ColourSpace::Srgb), None).unwrap();
|
||||||
let mut d = Decoder::new(std::io::Cursor::new(&bytes)).expect("decode");
|
let mut d = Decoder::new(std::io::Cursor::new(&bytes)).expect("decode");
|
||||||
assert_eq!(d.dimensions().expect("dimensions"), (2, 2));
|
assert_eq!(d.dimensions().expect("dimensions"), (2, 2));
|
||||||
let DecodingResult::U8(pixels) = d.read_image().expect("read") else {
|
let DecodingResult::U8(pixels) = d.read_image().expect("read") else {
|
||||||
@@ -372,4 +715,296 @@ mod tests {
|
|||||||
&[255, 0, 0, 0, 255, 0, 0, 0, 255, 10, 20, 30]
|
&[255, 0, 0, 0, 255, 0, 0, 0, 255, 10, 20, 30]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Metadata (FR-EXP-8)
|
||||||
|
//
|
||||||
|
// Read back through `dr-decode`, the same reader the application uses on
|
||||||
|
// the way in. That is the point: a test with its own parser proves the two
|
||||||
|
// agree with each other and nothing else, whereas this proves an exported
|
||||||
|
// file re-imports as the photograph it came from — and, in the stripping
|
||||||
|
// direction, that the coordinates are not there to be found by the very
|
||||||
|
// code most likely to find them.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// A source with every field filled, including a position.
|
||||||
|
fn source() -> SourceMetadata {
|
||||||
|
SourceMetadata {
|
||||||
|
make: Some("Canon".into()),
|
||||||
|
model: Some("Canon EOS 6D".into()),
|
||||||
|
lens: Some("EF85mm f/1.8 USM".into()),
|
||||||
|
shutter: Some(1.0 / 250.0),
|
||||||
|
aperture: Some(2.8),
|
||||||
|
iso: Some(400),
|
||||||
|
focal_length: Some(85.0),
|
||||||
|
captured_at: Some(1_372_462_374),
|
||||||
|
captured_offset: Some(120),
|
||||||
|
artist: Some("Duncan Tourolle".into()),
|
||||||
|
copyright: Some("(c) 2026 Duncan Tourolle".into()),
|
||||||
|
// 48° 51' 29.52" N, 2° 17' 40.2" E.
|
||||||
|
location: dr_types::Location::new(48.8582, 2.2945, Some(35.0)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encode one small frame of every format under `settings`.
|
||||||
|
fn exported(settings: &ExportSettings, md: &SourceMetadata) -> Vec<(ExportFormat, Vec<u8>)> {
|
||||||
|
[
|
||||||
|
ExportFormat::Jpeg,
|
||||||
|
ExportFormat::Png,
|
||||||
|
ExportFormat::Tiff8,
|
||||||
|
ExportFormat::Tiff16,
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.map(|format| {
|
||||||
|
let s = ExportSettings {
|
||||||
|
format,
|
||||||
|
..settings.clone()
|
||||||
|
};
|
||||||
|
let bytes = encode(&flat(8, 8), 8, 8, &s, Some(md))
|
||||||
|
.unwrap_or_else(|e| panic!("{format:?}: {e}"));
|
||||||
|
(format, bytes)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The EXIF an exported file carries, as `dr-decode` reads it.
|
||||||
|
///
|
||||||
|
/// Each container hides the same TIFF structure somewhere different, so
|
||||||
|
/// finding it is per-format; what happens to it afterwards is not.
|
||||||
|
fn read_back(format: ExportFormat, bytes: &[u8]) -> Option<dr_decode::Metadata> {
|
||||||
|
match format {
|
||||||
|
ExportFormat::Jpeg => dr_decode::jpeg_metadata(bytes).ok(),
|
||||||
|
ExportFormat::Png => {
|
||||||
|
let decoder = png::Decoder::new(std::io::Cursor::new(bytes));
|
||||||
|
let reader = decoder.read_info().expect("a readable PNG");
|
||||||
|
let chunk = reader.info().exif_metadata.clone()?;
|
||||||
|
dr_decode::tiff_metadata(&chunk).ok()
|
||||||
|
}
|
||||||
|
// A TIFF's own directory is the EXIF, so the file is the block.
|
||||||
|
_ => dr_decode::tiff_metadata(bytes).ok(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the bytes contain a directory entry pointing at a GPS
|
||||||
|
/// directory.
|
||||||
|
///
|
||||||
|
/// TRACES: FR-EXP-8
|
||||||
|
/// Deliberately byte-level, and deliberately not "did the parser find a
|
||||||
|
/// position". A GPS directory is only reachable through tag 0x8825 with
|
||||||
|
/// field type `LONG`, so those four bytes are the whole of the evidence:
|
||||||
|
/// if they are nowhere in the file then no reader — ours, exiftool, a
|
||||||
|
/// social network's ingest pipeline — has a route to a coordinate,
|
||||||
|
/// whatever else the file contains. Both byte orders are checked because
|
||||||
|
/// the `tiff` crate writes in the host's.
|
||||||
|
fn has_gps_pointer(bytes: &[u8]) -> bool {
|
||||||
|
const LITTLE: [u8; 4] = [0x25, 0x88, 0x04, 0x00];
|
||||||
|
const BIG: [u8; 4] = [0x88, 0x25, 0x00, 0x04];
|
||||||
|
bytes.windows(4).any(|w| w == LITTLE || w == BIG)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_export_carries_a_location_by_default() {
|
||||||
|
// TRACES: FR-EXP-8
|
||||||
|
// The important one. The source has a fix, the defaults are what a
|
||||||
|
// photographer who has changed nothing gets, and the assertion is
|
||||||
|
// about the *bytes* rather than about a flag having been read.
|
||||||
|
let settings = ExportSettings::default();
|
||||||
|
assert!(settings.strip_location, "the default this test rests on");
|
||||||
|
|
||||||
|
for (format, bytes) in exported(&settings, &source()) {
|
||||||
|
assert!(
|
||||||
|
!has_gps_pointer(&bytes),
|
||||||
|
"{format:?} carries a GPS directory pointer"
|
||||||
|
);
|
||||||
|
let md = read_back(format, &bytes).unwrap_or_else(|| panic!("{format:?} has no EXIF"));
|
||||||
|
assert_eq!(md.location, None, "{format:?} decodes to a position");
|
||||||
|
// And the coordinates are not loose in the file under some other
|
||||||
|
// tag: the hemisphere letters a GPS directory always carries.
|
||||||
|
assert!(
|
||||||
|
!bytes.windows(2).any(|w| w == b"N\0" || w == b"E\0"),
|
||||||
|
"{format:?} contains a hemisphere reference"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stripping_the_location_keeps_everything_else() {
|
||||||
|
// The other half of the same export: a photographer loses their
|
||||||
|
// coordinates, not their byline. A strip that took the copyright with
|
||||||
|
// it would pass the test above and still be wrong.
|
||||||
|
for (format, bytes) in exported(&ExportSettings::default(), &source()) {
|
||||||
|
let md = read_back(format, &bytes).unwrap_or_else(|| panic!("{format:?} has no EXIF"));
|
||||||
|
assert_eq!(md.make.as_deref(), Some("Canon"), "{format:?}");
|
||||||
|
assert_eq!(md.model.as_deref(), Some("Canon EOS 6D"), "{format:?}");
|
||||||
|
assert_eq!(
|
||||||
|
md.copyright.as_deref(),
|
||||||
|
Some("(c) 2026 Duncan Tourolle"),
|
||||||
|
"{format:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(md.captured_at, Some(1_372_462_374), "{format:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_camera_and_the_copyright_survive_the_round_trip() {
|
||||||
|
// TRACES: FR-EXP-8
|
||||||
|
// The retaining direction, with stripping off so that the position
|
||||||
|
// travels too — which is also the control for the test above: it
|
||||||
|
// proves that a missing GPS directory there is the setting working
|
||||||
|
// rather than the writer being incapable of one.
|
||||||
|
let settings = ExportSettings {
|
||||||
|
retain_metadata: true,
|
||||||
|
strip_location: false,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
for (format, bytes) in exported(&settings, &source()) {
|
||||||
|
assert!(
|
||||||
|
has_gps_pointer(&bytes),
|
||||||
|
"{format:?} dropped the position it was asked to keep"
|
||||||
|
);
|
||||||
|
let md = read_back(format, &bytes).unwrap_or_else(|| panic!("{format:?} has no EXIF"));
|
||||||
|
assert_eq!(md.make.as_deref(), Some("Canon"), "{format:?}");
|
||||||
|
assert_eq!(md.model.as_deref(), Some("Canon EOS 6D"), "{format:?}");
|
||||||
|
assert_eq!(md.lens.as_deref(), Some("EF85mm f/1.8 USM"), "{format:?}");
|
||||||
|
assert_eq!(md.artist.as_deref(), Some("Duncan Tourolle"), "{format:?}");
|
||||||
|
assert_eq!(
|
||||||
|
md.copyright.as_deref(),
|
||||||
|
Some("(c) 2026 Duncan Tourolle"),
|
||||||
|
"{format:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(md.captured_at, Some(1_372_462_374), "{format:?}");
|
||||||
|
assert_eq!(md.captured_offset, Some(120), "{format:?}");
|
||||||
|
assert_eq!(md.iso, Some(400), "{format:?}");
|
||||||
|
assert_eq!(md.shutter, Some(1.0 / 250.0), "{format:?}");
|
||||||
|
assert_eq!(md.aperture, Some(2.8), "{format:?}");
|
||||||
|
assert_eq!(md.focal_length, Some(85.0), "{format:?}");
|
||||||
|
|
||||||
|
let loc = md.location.unwrap_or_else(|| panic!("{format:?} lost the fix"));
|
||||||
|
// Within a metre of where it started, which is finer than any
|
||||||
|
// consumer receiver and far finer than the tag's own rounding.
|
||||||
|
assert!((loc.latitude - 48.8582).abs() < 1e-5, "{format:?} {loc:?}");
|
||||||
|
assert!((loc.longitude - 2.2945).abs() < 1e-5, "{format:?} {loc:?}");
|
||||||
|
assert_eq!(loc.altitude, Some(35.0), "{format:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn retention_off_writes_no_metadata_at_all() {
|
||||||
|
// Not an emptied block — none. This is the setting for a file that
|
||||||
|
// must give nothing away, and a reader should find the same absence a
|
||||||
|
// file that never had EXIF has.
|
||||||
|
let settings = ExportSettings {
|
||||||
|
retain_metadata: false,
|
||||||
|
strip_location: false,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
for (format, bytes) in exported(&settings, &source()) {
|
||||||
|
assert!(!has_gps_pointer(&bytes), "{format:?}");
|
||||||
|
match format {
|
||||||
|
// No APP1 segment at all, which is what the error means here.
|
||||||
|
ExportFormat::Jpeg => assert!(dr_decode::jpeg_metadata(&bytes).is_err()),
|
||||||
|
ExportFormat::Png => {
|
||||||
|
let decoder = png::Decoder::new(std::io::Cursor::new(&bytes));
|
||||||
|
let reader = decoder.read_info().expect("a readable PNG");
|
||||||
|
assert!(reader.info().exif_metadata.is_none());
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let md = read_back(format, &bytes).expect("a TIFF is always a directory");
|
||||||
|
assert_eq!(md.make, None, "{format:?}");
|
||||||
|
assert_eq!(md.copyright, None, "{format:?}");
|
||||||
|
assert_eq!(md.captured_at, None, "{format:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_export_is_not_told_to_rotate_pixels_that_are_already_upright() {
|
||||||
|
// The pipeline applies the source's orientation before this point, so
|
||||||
|
// an orientation tag here would turn every portrait frame on its side
|
||||||
|
// in every viewer that honours one. `dr-decode` reporting no
|
||||||
|
// orientation is the assertion: it reads the tag from the main IFD,
|
||||||
|
// which is exactly where a careless copy would have put it.
|
||||||
|
let settings = ExportSettings {
|
||||||
|
retain_metadata: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
for (format, bytes) in exported(&settings, &source()) {
|
||||||
|
let md = read_back(format, &bytes).unwrap_or_else(|| panic!("{format:?} has no EXIF"));
|
||||||
|
assert_eq!(md.orientation, None, "{format:?} tells a reader to rotate");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_tiff_carrying_metadata_still_decodes_to_its_pixels() {
|
||||||
|
// Sub-directories are written into the file *before* the image, so a
|
||||||
|
// mistake here moves the strip offsets — the failure that produces a
|
||||||
|
// file which opens, reports the right size, and shows noise.
|
||||||
|
use tiff::decoder::{Decoder, DecodingResult};
|
||||||
|
|
||||||
|
let rgba: Vec<u8> = vec![
|
||||||
|
255, 0, 0, 255, // red
|
||||||
|
0, 255, 0, 255, // green
|
||||||
|
0, 0, 255, 255, // blue
|
||||||
|
10, 20, 30, 255,
|
||||||
|
];
|
||||||
|
let bytes = tiff8(
|
||||||
|
&rgba,
|
||||||
|
2,
|
||||||
|
2,
|
||||||
|
&icc::profile(ColourSpace::Srgb),
|
||||||
|
Some(&source()),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut d = Decoder::new(std::io::Cursor::new(&bytes)).expect("decode");
|
||||||
|
assert_eq!(d.dimensions().expect("dimensions"), (2, 2));
|
||||||
|
let DecodingResult::U8(pixels) = d.read_image().expect("read") else {
|
||||||
|
panic!("expected 8-bit samples");
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
&pixels[..12],
|
||||||
|
&[255, 0, 0, 0, 255, 0, 0, 0, 255, 10, 20, 30]
|
||||||
|
);
|
||||||
|
// And the profile is still where it was, beside the new tags.
|
||||||
|
let mut d = Decoder::new(std::io::Cursor::new(&bytes)).expect("decode");
|
||||||
|
assert_eq!(
|
||||||
|
d.get_tag_u8_vec(tiff::tags::Tag::Unknown(TAG_ICC_PROFILE))
|
||||||
|
.expect("profile"),
|
||||||
|
icc::profile(ColourSpace::Srgb)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_jpeg_keeps_both_its_profile_and_its_capture_data() {
|
||||||
|
// Two APP segments now, and adding one must not have displaced the
|
||||||
|
// other: a reader walking the marker chain has to find both.
|
||||||
|
let settings = ExportSettings {
|
||||||
|
retain_metadata: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let bytes = encode(&flat(8, 8), 8, 8, &settings, Some(&source())).unwrap();
|
||||||
|
let profile = jpeg_icc(&bytes).expect("the ICC segment");
|
||||||
|
assert_eq!(profile, icc::profile(ColourSpace::Srgb));
|
||||||
|
let md = dr_decode::jpeg_metadata(&bytes).expect("the EXIF segment");
|
||||||
|
assert_eq!(md.model.as_deref(), Some("Canon EOS 6D"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_frame_with_no_source_metadata_exports_exactly_as_it_used_to() {
|
||||||
|
// The `None` path is the one every caller that has not been taught
|
||||||
|
// about metadata still takes, and it must not have acquired a block.
|
||||||
|
let settings = ExportSettings::default();
|
||||||
|
for format in [ExportFormat::Jpeg, ExportFormat::Png] {
|
||||||
|
let s = ExportSettings {
|
||||||
|
format,
|
||||||
|
..settings.clone()
|
||||||
|
};
|
||||||
|
let bytes = encode(&flat(8, 8), 8, 8, &s, None).unwrap();
|
||||||
|
assert!(!has_gps_pointer(&bytes), "{format:?}");
|
||||||
|
assert!(read_back(format, &bytes).is_none(), "{format:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,518 @@
|
|||||||
|
//! TRACES: FR-EXP-8
|
||||||
|
//! Building an EXIF block, rather than copying one.
|
||||||
|
//!
|
||||||
|
//! # Why this is written by hand and not with a crate
|
||||||
|
//!
|
||||||
|
//! Two reasons, in order of importance.
|
||||||
|
//!
|
||||||
|
//! The first is the privacy behaviour. Every EXIF library worth using offers a
|
||||||
|
//! "load the source block, delete these tags, write it back" shape, and that
|
||||||
|
//! shape is the wrong one here: it makes the file that leaves the machine a
|
||||||
|
//! copy of the source's metadata *minus what we thought to remove*, so every
|
||||||
|
//! tag nobody has thought about — a vendor's proprietary sub-directory, a
|
||||||
|
//! serial number under a tag id this build has never seen — travels by
|
||||||
|
//! default. Constructing the block from a fixed list of parsed values inverts
|
||||||
|
//! that. What is written is exactly what appears in [`crate::SourceMetadata`],
|
||||||
|
//! and a tag that is not in this file cannot end up in the output no matter
|
||||||
|
//! what the source contained. The allowlist *is* the implementation.
|
||||||
|
//!
|
||||||
|
//! The second is the dependency policy. The root `Cargo.toml` explains why
|
||||||
|
//! nothing here may link C — this tree has to build under the Android NDK —
|
||||||
|
//! and the mature EXIF writers are bindings. This is a couple of hundred
|
||||||
|
//! lines of offset arithmetic against a specification that has not changed
|
||||||
|
//! since 2010, and it is the same TIFF structure `dr-decode` already reads.
|
||||||
|
//!
|
||||||
|
//! # What the block is
|
||||||
|
//!
|
||||||
|
//! A complete little-endian TIFF: an 8-byte header, IFD0 with the identity
|
||||||
|
//! and rights tags, an Exif sub-IFD with the capture tags, optionally a GPS
|
||||||
|
//! sub-IFD, and a heap of values too long to sit inside an entry. JPEG carries
|
||||||
|
//! it in an APP1 segment behind the marker `Exif\0\0`; PNG carries the same
|
||||||
|
//! bytes in an `eXIf` chunk with no marker. TIFF does not use this at all —
|
||||||
|
//! its own directory *is* the EXIF, so `encode.rs` writes the tags there
|
||||||
|
//! directly.
|
||||||
|
|
||||||
|
use crate::metadata::SourceMetadata;
|
||||||
|
|
||||||
|
/// One entry's value, in the handful of TIFF types this writer emits.
|
||||||
|
enum Value {
|
||||||
|
/// NUL-terminated, as the specification requires; the terminator is
|
||||||
|
/// counted, which is the detail readers trip over when it is missing.
|
||||||
|
Ascii(String),
|
||||||
|
Byte(Vec<u8>),
|
||||||
|
Short(u16),
|
||||||
|
Long(u32),
|
||||||
|
/// Type 7. Used only for `ExifVersion`, which is four characters that are
|
||||||
|
/// deliberately *not* a string.
|
||||||
|
Undefined(&'static [u8]),
|
||||||
|
/// Numerator and denominator pairs. A coordinate is three of them.
|
||||||
|
Rational(Vec<(u32, u32)>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Value {
|
||||||
|
fn field_type(&self) -> u16 {
|
||||||
|
match self {
|
||||||
|
Value::Byte(_) => 1,
|
||||||
|
Value::Ascii(_) => 2,
|
||||||
|
Value::Short(_) => 3,
|
||||||
|
Value::Long(_) => 4,
|
||||||
|
Value::Rational(_) => 5,
|
||||||
|
Value::Undefined(_) => 7,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The element count, which is not the byte length: a rational counts as
|
||||||
|
/// one element per eight bytes.
|
||||||
|
fn count(&self) -> u32 {
|
||||||
|
match self {
|
||||||
|
Value::Ascii(s) => s.len() as u32 + 1,
|
||||||
|
Value::Byte(b) => b.len() as u32,
|
||||||
|
Value::Undefined(b) => b.len() as u32,
|
||||||
|
Value::Short(_) | Value::Long(_) => 1,
|
||||||
|
Value::Rational(r) => r.len() as u32,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The payload, in file order.
|
||||||
|
fn payload(&self) -> Vec<u8> {
|
||||||
|
match self {
|
||||||
|
Value::Ascii(s) => {
|
||||||
|
let mut out = s.as_bytes().to_vec();
|
||||||
|
out.push(0);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
Value::Byte(b) => b.clone(),
|
||||||
|
Value::Undefined(b) => b.to_vec(),
|
||||||
|
Value::Short(v) => v.to_le_bytes().to_vec(),
|
||||||
|
Value::Long(v) => v.to_le_bytes().to_vec(),
|
||||||
|
Value::Rational(r) => r
|
||||||
|
.iter()
|
||||||
|
.flat_map(|(n, d)| {
|
||||||
|
let mut b = n.to_le_bytes().to_vec();
|
||||||
|
b.extend_from_slice(&d.to_le_bytes());
|
||||||
|
b
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An IFD under construction.
|
||||||
|
type Entries = Vec<(u16, Value)>;
|
||||||
|
|
||||||
|
/// Tag numbers. Named rather than inlined because a mistyped one produces a
|
||||||
|
/// file that still parses and says something else entirely.
|
||||||
|
pub(crate) mod tag {
|
||||||
|
pub(crate) const MAKE: u16 = 0x010F;
|
||||||
|
pub(crate) const MODEL: u16 = 0x0110;
|
||||||
|
pub(crate) const SOFTWARE: u16 = 0x0131;
|
||||||
|
pub(crate) const DATE_TIME: u16 = 0x0132;
|
||||||
|
pub(crate) const ARTIST: u16 = 0x013B;
|
||||||
|
pub(crate) const COPYRIGHT: u16 = 0x8298;
|
||||||
|
pub(crate) const EXIF_IFD: u16 = 0x8769;
|
||||||
|
pub(crate) const GPS_IFD: u16 = 0x8825;
|
||||||
|
|
||||||
|
pub(crate) const EXPOSURE_TIME: u16 = 0x829A;
|
||||||
|
pub(crate) const FNUMBER: u16 = 0x829D;
|
||||||
|
pub(crate) const ISO: u16 = 0x8827;
|
||||||
|
pub(crate) const EXIF_VERSION: u16 = 0x9000;
|
||||||
|
pub(crate) const DATE_TIME_ORIGINAL: u16 = 0x9003;
|
||||||
|
pub(crate) const OFFSET_TIME_ORIGINAL: u16 = 0x9011;
|
||||||
|
pub(crate) const FOCAL_LENGTH: u16 = 0x920A;
|
||||||
|
pub(crate) const PIXEL_X: u16 = 0xA002;
|
||||||
|
pub(crate) const PIXEL_Y: u16 = 0xA003;
|
||||||
|
pub(crate) const LENS_MODEL: u16 = 0xA434;
|
||||||
|
|
||||||
|
pub(crate) const GPS_VERSION_ID: u16 = 0x0000;
|
||||||
|
pub(crate) const GPS_LATITUDE_REF: u16 = 0x0001;
|
||||||
|
pub(crate) const GPS_LATITUDE: u16 = 0x0002;
|
||||||
|
pub(crate) const GPS_LONGITUDE_REF: u16 = 0x0003;
|
||||||
|
pub(crate) const GPS_LONGITUDE: u16 = 0x0004;
|
||||||
|
pub(crate) const GPS_ALTITUDE_REF: u16 = 0x0005;
|
||||||
|
pub(crate) const GPS_ALTITUDE: u16 = 0x0006;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What DarkRoom calls itself in a file it wrote.
|
||||||
|
///
|
||||||
|
/// Not vanity: an export is a derived file, and a reader that knows which
|
||||||
|
/// program produced it can tell a camera original from a rendition without
|
||||||
|
/// guessing from the absence of a maker note.
|
||||||
|
pub(crate) const SOFTWARE: &str = "DarkRoom";
|
||||||
|
|
||||||
|
/// The complete EXIF block for JPEG's APP1 and PNG's `eXIf`.
|
||||||
|
///
|
||||||
|
/// `width`/`height` are the *exported* dimensions, not the source's: the
|
||||||
|
/// pixel-dimension tags describe the file they are in, and a reader that
|
||||||
|
/// trusts them after a resize would report the wrong size for the image it is
|
||||||
|
/// holding.
|
||||||
|
///
|
||||||
|
/// `None` where there is nothing to say. An empty EXIF block is not the same
|
||||||
|
/// as no EXIF block — it is a structure a reader must parse to discover it
|
||||||
|
/// learned nothing — and the second is the better file.
|
||||||
|
pub(crate) fn block(md: &SourceMetadata, width: u32, height: u32) -> Option<Vec<u8>> {
|
||||||
|
let ifd0 = main_entries(md);
|
||||||
|
let exif = exif_entries(md, width, height);
|
||||||
|
let gps = gps_entries(md);
|
||||||
|
if ifd0.is_empty() && exif.is_empty() && gps.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(assemble(ifd0, exif, gps))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lay the three directories and their heap out in the block.
|
||||||
|
///
|
||||||
|
/// The order is fixed — IFD0, Exif, GPS, heap — because the pointers have to
|
||||||
|
/// be known before IFD0 is serialised, and an IFD's size is decided by its
|
||||||
|
/// entry count alone: two bytes of count, twelve per entry, four for the link
|
||||||
|
/// to the next directory.
|
||||||
|
fn assemble(mut ifd0: Entries, exif: Entries, gps: Entries) -> Vec<u8> {
|
||||||
|
const HEADER: u32 = 8;
|
||||||
|
let size = |n: usize| 2 + 12 * n as u32 + 4;
|
||||||
|
|
||||||
|
// The pointer entries are part of IFD0's count, so they have to be added
|
||||||
|
// before its size is taken — a chicken-and-egg the specification resolves
|
||||||
|
// by making entry size fixed.
|
||||||
|
let pointers = usize::from(!exif.is_empty()) + usize::from(!gps.is_empty());
|
||||||
|
let ifd0_size = size(ifd0.len() + pointers);
|
||||||
|
|
||||||
|
let exif_offset = HEADER + ifd0_size;
|
||||||
|
let gps_offset = exif_offset + if exif.is_empty() { 0 } else { size(exif.len()) };
|
||||||
|
let heap_base = gps_offset + if gps.is_empty() { 0 } else { size(gps.len()) };
|
||||||
|
|
||||||
|
if !exif.is_empty() {
|
||||||
|
ifd0.push((tag::EXIF_IFD, Value::Long(exif_offset)));
|
||||||
|
}
|
||||||
|
if !gps.is_empty() {
|
||||||
|
ifd0.push((tag::GPS_IFD, Value::Long(gps_offset)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut heap = Vec::new();
|
||||||
|
let ifd0_bytes = directory(ifd0, heap_base, &mut heap);
|
||||||
|
let exif_bytes = directory(exif, heap_base, &mut heap);
|
||||||
|
let gps_bytes = directory(gps, heap_base, &mut heap);
|
||||||
|
|
||||||
|
let mut out = Vec::with_capacity(HEADER as usize + heap.len() + 128);
|
||||||
|
// Little-endian, magic 42, first directory at byte 8. Little-endian
|
||||||
|
// because every value written below is, and a header that disagreed with
|
||||||
|
// its own body is the one corruption a reader cannot recover from.
|
||||||
|
out.extend_from_slice(b"II");
|
||||||
|
out.extend_from_slice(&42u16.to_le_bytes());
|
||||||
|
out.extend_from_slice(&HEADER.to_le_bytes());
|
||||||
|
out.extend_from_slice(&ifd0_bytes);
|
||||||
|
out.extend_from_slice(&exif_bytes);
|
||||||
|
out.extend_from_slice(&gps_bytes);
|
||||||
|
out.extend_from_slice(&heap);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serialise one directory, spilling long values onto the shared heap.
|
||||||
|
///
|
||||||
|
/// Entries are sorted by tag: TIFF requires ascending order within a
|
||||||
|
/// directory, and while most readers cope with any order, the ones that
|
||||||
|
/// binary-search stop at the first tag they cannot place.
|
||||||
|
fn directory(mut entries: Entries, heap_base: u32, heap: &mut Vec<u8>) -> Vec<u8> {
|
||||||
|
if entries.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
entries.sort_by_key(|(tag, _)| *tag);
|
||||||
|
|
||||||
|
let mut out = Vec::with_capacity(2 + entries.len() * 12 + 4);
|
||||||
|
out.extend_from_slice(&(entries.len() as u16).to_le_bytes());
|
||||||
|
for (tag, value) in &entries {
|
||||||
|
out.extend_from_slice(&tag.to_le_bytes());
|
||||||
|
out.extend_from_slice(&value.field_type().to_le_bytes());
|
||||||
|
out.extend_from_slice(&value.count().to_le_bytes());
|
||||||
|
|
||||||
|
let payload = value.payload();
|
||||||
|
if payload.len() <= 4 {
|
||||||
|
// Four bytes or fewer live in the entry itself, left-justified and
|
||||||
|
// zero-padded.
|
||||||
|
let mut inline = payload.clone();
|
||||||
|
inline.resize(4, 0);
|
||||||
|
out.extend_from_slice(&inline);
|
||||||
|
} else {
|
||||||
|
out.extend_from_slice(&(heap_base + heap.len() as u32).to_le_bytes());
|
||||||
|
heap.extend_from_slice(&payload);
|
||||||
|
// Values start on even offsets. Not every reader cares; the ones
|
||||||
|
// that do read a short from an odd address and get nonsense.
|
||||||
|
if heap.len() % 2 == 1 {
|
||||||
|
heap.push(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// No directory follows this one. The Exif and GPS sub-directories are
|
||||||
|
// pointed at, not chained, so this is zero in all three.
|
||||||
|
out.extend_from_slice(&0u32.to_le_bytes());
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// IFD0: who took it, with what, and who owns it.
|
||||||
|
///
|
||||||
|
/// **No orientation tag, deliberately.** The frame reaching the encoder has
|
||||||
|
/// already had the source's orientation applied by the pipeline — it is
|
||||||
|
/// upright pixels — so copying the source's tag across would tell every
|
||||||
|
/// reader to rotate an image that is already the right way up. A portrait
|
||||||
|
/// frame would come out on its side in exactly the viewers that honour the
|
||||||
|
/// tag, which is most of them.
|
||||||
|
fn main_entries(md: &SourceMetadata) -> Entries {
|
||||||
|
let mut e = Entries::new();
|
||||||
|
push_ascii(&mut e, tag::MAKE, md.make.as_deref());
|
||||||
|
push_ascii(&mut e, tag::MODEL, md.model.as_deref());
|
||||||
|
push_ascii(&mut e, tag::ARTIST, md.artist.as_deref());
|
||||||
|
push_ascii(&mut e, tag::COPYRIGHT, md.copyright.as_deref());
|
||||||
|
e.push((tag::SOFTWARE, Value::Ascii(SOFTWARE.to_string())));
|
||||||
|
// IFD0's `DateTime` is nominally when the file was written, and this is
|
||||||
|
// the capture time instead. That is what the rest of the world does —
|
||||||
|
// and it is what `dr-decode` falls back to for scanner output that has no
|
||||||
|
// `DateTimeOriginal` — so a re-import of an export lands on the timeline
|
||||||
|
// where the original did rather than on the day it was exported.
|
||||||
|
if let Some(t) = md.captured_at.map(datetime) {
|
||||||
|
e.push((tag::DATE_TIME, Value::Ascii(t)));
|
||||||
|
}
|
||||||
|
e
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Exif sub-IFD: the exposure, and what made it.
|
||||||
|
fn exif_entries(md: &SourceMetadata, width: u32, height: u32) -> Entries {
|
||||||
|
let mut e = Entries::new();
|
||||||
|
// "0232" is Exif 2.32. A sub-directory without a version is technically
|
||||||
|
// malformed, and some readers refuse the whole block over it.
|
||||||
|
e.push((tag::EXIF_VERSION, Value::Undefined(b"0232")));
|
||||||
|
e.push((tag::PIXEL_X, Value::Long(width)));
|
||||||
|
e.push((tag::PIXEL_Y, Value::Long(height)));
|
||||||
|
push_ascii(&mut e, tag::LENS_MODEL, md.lens.as_deref());
|
||||||
|
if let Some(t) = md.captured_at.map(datetime) {
|
||||||
|
e.push((tag::DATE_TIME_ORIGINAL, Value::Ascii(t)));
|
||||||
|
}
|
||||||
|
if let Some(o) = md.captured_offset.map(offset) {
|
||||||
|
e.push((tag::OFFSET_TIME_ORIGINAL, Value::Ascii(o)));
|
||||||
|
}
|
||||||
|
if let Some(s) = md.shutter.filter(|s| *s > 0.0) {
|
||||||
|
e.push((tag::EXPOSURE_TIME, Value::Rational(vec![shutter(s)])));
|
||||||
|
}
|
||||||
|
if let Some(f) = md.aperture.filter(|f| *f > 0.0) {
|
||||||
|
e.push((tag::FNUMBER, Value::Rational(vec![tenths(f)])));
|
||||||
|
}
|
||||||
|
if let Some(f) = md.focal_length.filter(|f| *f > 0.0) {
|
||||||
|
e.push((tag::FOCAL_LENGTH, Value::Rational(vec![tenths(f)])));
|
||||||
|
}
|
||||||
|
// The tag is a SHORT, so a sensitivity above 65535 has no representation
|
||||||
|
// in it. Dropped rather than truncated: ISO 102400 written as 36864 is a
|
||||||
|
// lie, and an absent tag is not.
|
||||||
|
if let Some(iso) = md.iso.filter(|v| *v <= u32::from(u16::MAX)) {
|
||||||
|
e.push((tag::ISO, Value::Short(iso as u16)));
|
||||||
|
}
|
||||||
|
e
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The GPS sub-IFD.
|
||||||
|
///
|
||||||
|
/// Empty unless the caller has already decided that coordinates may be
|
||||||
|
/// written — see [`SourceMetadata::sanitised`], which is where the stripping
|
||||||
|
/// happens. Nothing in this file consults the settings, so there is exactly
|
||||||
|
/// one place to look to answer "can this export carry a location".
|
||||||
|
fn gps_entries(md: &SourceMetadata) -> Entries {
|
||||||
|
let Some(loc) = md.location else {
|
||||||
|
return Entries::new();
|
||||||
|
};
|
||||||
|
let mut e = Entries::new();
|
||||||
|
// 2.3.0.0, the current GPS tag version.
|
||||||
|
e.push((tag::GPS_VERSION_ID, Value::Byte(vec![2, 3, 0, 0])));
|
||||||
|
e.push((
|
||||||
|
tag::GPS_LATITUDE_REF,
|
||||||
|
Value::Ascii(if loc.latitude < 0.0 { "S" } else { "N" }.into()),
|
||||||
|
));
|
||||||
|
e.push((tag::GPS_LATITUDE, Value::Rational(dms(loc.latitude))));
|
||||||
|
e.push((
|
||||||
|
tag::GPS_LONGITUDE_REF,
|
||||||
|
Value::Ascii(if loc.longitude < 0.0 { "W" } else { "E" }.into()),
|
||||||
|
));
|
||||||
|
e.push((tag::GPS_LONGITUDE, Value::Rational(dms(loc.longitude))));
|
||||||
|
if let Some(alt) = loc.altitude {
|
||||||
|
// The altitude itself is unsigned; below sea level is a separate byte.
|
||||||
|
e.push((
|
||||||
|
tag::GPS_ALTITUDE_REF,
|
||||||
|
Value::Byte(vec![u8::from(alt < 0.0)]),
|
||||||
|
));
|
||||||
|
e.push((
|
||||||
|
tag::GPS_ALTITUDE,
|
||||||
|
Value::Rational(vec![((alt.abs() * 100.0).round() as u32, 100)]),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
e
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_ascii(entries: &mut Entries, tag: u16, value: Option<&str>) {
|
||||||
|
// An empty string is a tag saying nothing, which is worse than no tag: it
|
||||||
|
// overwrites whatever a reader would otherwise have inferred.
|
||||||
|
if let Some(v) = value.map(str::trim).filter(|v| !v.is_empty()) {
|
||||||
|
entries.push((tag, Value::Ascii(v.to_string())));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Signed degrees back into the tag's degrees/minutes/seconds.
|
||||||
|
///
|
||||||
|
/// The sign is carried by the hemisphere letter, so this takes the magnitude.
|
||||||
|
/// Seconds keep four decimal places, which is about 3 mm — far finer than any
|
||||||
|
/// consumer fix, and enough that a round trip through the tag does not move
|
||||||
|
/// the pin.
|
||||||
|
pub(crate) fn dms(degrees: f64) -> Vec<(u32, u32)> {
|
||||||
|
let d = degrees.abs();
|
||||||
|
let whole = d.trunc();
|
||||||
|
let minutes = (d - whole) * 60.0;
|
||||||
|
let seconds = (minutes - minutes.trunc()) * 60.0;
|
||||||
|
vec![
|
||||||
|
(whole as u32, 1),
|
||||||
|
(minutes.trunc() as u32, 1),
|
||||||
|
((seconds * 10_000.0).round() as u32, 10_000),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A shutter speed as the fraction a photographer would recognise.
|
||||||
|
///
|
||||||
|
/// `1/250`, not `4/1000`. Both are the same number and every reader computes
|
||||||
|
/// the same exposure from either, but the first is what the camera wrote and
|
||||||
|
/// what a properties panel displays verbatim.
|
||||||
|
pub(crate) fn shutter(seconds: f32) -> (u32, u32) {
|
||||||
|
if seconds < 1.0 {
|
||||||
|
(1, (1.0 / seconds).round().max(1.0) as u32)
|
||||||
|
} else {
|
||||||
|
((seconds * 10.0).round() as u32, 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// f/2.8 and 85 mm as tenths, which is how cameras write both.
|
||||||
|
pub(crate) fn tenths(value: f32) -> (u32, u32) {
|
||||||
|
((value * 10.0).round().max(0.0) as u32, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unix seconds as EXIF's `"YYYY:MM:DD HH:MM:SS"`.
|
||||||
|
///
|
||||||
|
/// The reading is a wall clock with no zone — that is what the tag means, and
|
||||||
|
/// what `dr-decode` parsed it as — so this is the exact inverse of that parse
|
||||||
|
/// and involves no timezone conversion. The zone, where the source recorded
|
||||||
|
/// one, travels separately in `OffsetTimeOriginal`.
|
||||||
|
pub(crate) fn datetime(unix: i64) -> String {
|
||||||
|
let days = unix.div_euclid(86_400);
|
||||||
|
let secs = unix.rem_euclid(86_400);
|
||||||
|
|
||||||
|
// Howard Hinnant's civil-from-days, the inverse of the days-from-civil
|
||||||
|
// that `dr-decode` uses to parse. Eras of 400 years, shifted so that the
|
||||||
|
// arithmetic never sees a negative.
|
||||||
|
let z = days + 719_468;
|
||||||
|
let era = z.div_euclid(146_097);
|
||||||
|
let doe = z.rem_euclid(146_097);
|
||||||
|
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||||
|
let y = yoe + era * 400;
|
||||||
|
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||||
|
let mp = (5 * doy + 2) / 153;
|
||||||
|
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||||
|
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||||
|
let y = if m <= 2 { y + 1 } else { y };
|
||||||
|
|
||||||
|
format!(
|
||||||
|
"{y:04}:{m:02}:{d:02} {:02}:{:02}:{:02}",
|
||||||
|
secs / 3600,
|
||||||
|
(secs / 60) % 60,
|
||||||
|
secs % 60
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Minutes east of UTC as EXIF's `"+HH:MM"`.
|
||||||
|
pub(crate) fn offset(minutes: i32) -> String {
|
||||||
|
let sign = if minutes < 0 { '-' } else { '+' };
|
||||||
|
let m = minutes.unsigned_abs();
|
||||||
|
format!("{sign}{:02}:{:02}", m / 60, m % 60)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use dr_types::Location;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_capture_time_survives_the_round_trip_through_the_tag() {
|
||||||
|
// The parse side lives in `dr-decode` and is exercised against real
|
||||||
|
// files; this is the inverse, and the two meeting in the middle is
|
||||||
|
// what keeps an exported frame on the same point of the timeline as
|
||||||
|
// the original.
|
||||||
|
assert_eq!(datetime(1_372_462_374), "2013:06:28 23:32:54");
|
||||||
|
assert_eq!(datetime(0), "1970:01:01 00:00:00");
|
||||||
|
// A leap day, which is where a hand-rolled calendar goes wrong.
|
||||||
|
assert_eq!(datetime(1_709_164_800), "2024:02:29 00:00:00");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_zone_is_written_the_way_the_tag_spells_it() {
|
||||||
|
assert_eq!(offset(120), "+02:00");
|
||||||
|
assert_eq!(offset(-330), "-05:30");
|
||||||
|
assert_eq!(offset(0), "+00:00");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_shutter_speed_keeps_the_photographers_fraction() {
|
||||||
|
assert_eq!(shutter(1.0 / 250.0), (1, 250));
|
||||||
|
assert_eq!(shutter(2.5), (25, 10));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn degrees_round_trip_through_the_tags_triple() {
|
||||||
|
// 48.8582 N is the Eiffel Tower; the check is that the three-part
|
||||||
|
// form comes back to the same place, to well under a metre.
|
||||||
|
for degrees in [48.8582_f64, -33.8568, 0.0, 179.999] {
|
||||||
|
let parts = dms(degrees);
|
||||||
|
let back = parts[0].0 as f64
|
||||||
|
+ parts[1].0 as f64 / 60.0
|
||||||
|
+ (parts[2].0 as f64 / parts[2].1 as f64) / 3600.0;
|
||||||
|
assert!(
|
||||||
|
(back - degrees.abs()).abs() < 1e-6,
|
||||||
|
"{degrees} came back as {back}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_empty_source_produces_no_block_at_all() {
|
||||||
|
// Every field absent means the only entries would be the ones this
|
||||||
|
// writer adds itself. That is still worth writing — `Software` and
|
||||||
|
// the pixel dimensions are true statements — so the block exists; what
|
||||||
|
// must not happen is a *malformed* one.
|
||||||
|
let md = SourceMetadata::default();
|
||||||
|
let bytes = block(&md, 100, 50).expect("the writer's own tags");
|
||||||
|
assert!(bytes.starts_with(b"II*\0"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_gps_directory_is_absent_when_there_is_no_position() {
|
||||||
|
let md = SourceMetadata {
|
||||||
|
make: Some("Canon".into()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let bytes = block(&md, 10, 10).unwrap();
|
||||||
|
assert!(!contains_entry(&bytes, tag::GPS_IFD));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_gps_directory_is_present_when_there_is_one() {
|
||||||
|
// The counterpart of the test above: a strip test that passed because
|
||||||
|
// the writer could never emit GPS at all would prove nothing.
|
||||||
|
let md = SourceMetadata {
|
||||||
|
location: Location::new(48.8582, 2.2945, Some(35.0)),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let bytes = block(&md, 10, 10).unwrap();
|
||||||
|
assert!(contains_entry(&bytes, tag::GPS_IFD));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a directory entry for `tag` appears anywhere in the block.
|
||||||
|
///
|
||||||
|
/// Byte-level on purpose: an entry is a tag, a type and a count, and
|
||||||
|
/// searching for that twelve-byte shape's first eight bytes is a far
|
||||||
|
/// stronger statement than asking a parser that might have skipped the
|
||||||
|
/// directory the tag was in.
|
||||||
|
fn contains_entry(bytes: &[u8], tag: u16) -> bool {
|
||||||
|
bytes
|
||||||
|
.windows(4)
|
||||||
|
.any(|w| w[..2] == tag.to_le_bytes() && (w[2] == 4 || w[2] == 13) && w[3] == 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
+29
-12
@@ -26,12 +26,15 @@ use dr_types::{ColourSpace, ExportFormat, ExportSettings};
|
|||||||
|
|
||||||
mod encode;
|
mod encode;
|
||||||
mod error;
|
mod error;
|
||||||
|
mod exif;
|
||||||
pub mod icc;
|
pub mod icc;
|
||||||
|
mod metadata;
|
||||||
mod name;
|
mod name;
|
||||||
mod sharpen;
|
mod sharpen;
|
||||||
mod size;
|
mod size;
|
||||||
|
|
||||||
pub use error::ExportError;
|
pub use error::ExportError;
|
||||||
|
pub use metadata::SourceMetadata;
|
||||||
pub use name::{resolve_name, NameContext};
|
pub use name::{resolve_name, NameContext};
|
||||||
pub use size::target_size;
|
pub use size::target_size;
|
||||||
|
|
||||||
@@ -129,10 +132,23 @@ pub struct Encoded {
|
|||||||
/// resamples down from it. Exporting from the display proxy would silently
|
/// resamples down from it. Exporting from the display proxy would silently
|
||||||
/// produce a soft file, which is why the develop session's export path renders
|
/// produce a soft file, which is why the develop session's export path renders
|
||||||
/// its own frame rather than reusing the one on screen.
|
/// its own frame rather than reusing the one on screen.
|
||||||
|
///
|
||||||
|
/// TRACES: FR-EXP-8
|
||||||
|
/// `source` is what the photograph's own file said about itself, or `None`
|
||||||
|
/// where the caller has nothing — a frame that came from somewhere other than
|
||||||
|
/// a decoded file, or a caller that has not yet been taught to pass it.
|
||||||
|
///
|
||||||
|
/// **A parameter rather than a field on [`Frame`]**, because it is not a fact
|
||||||
|
/// about the pixels: two exports of the same frame can legitimately disclose
|
||||||
|
/// different amounts, and the settings that decide how much travel beside it.
|
||||||
|
/// It is also why this is an argument and not an `Option` with a default — a
|
||||||
|
/// caller that has the source metadata should have to decide, in one visible
|
||||||
|
/// place, to hand it over.
|
||||||
pub fn export(
|
pub fn export(
|
||||||
frame: &Frame,
|
frame: &Frame,
|
||||||
settings: &ExportSettings,
|
settings: &ExportSettings,
|
||||||
name: String,
|
name: String,
|
||||||
|
source: Option<&SourceMetadata>,
|
||||||
) -> Result<Encoded, ExportError> {
|
) -> Result<Encoded, ExportError> {
|
||||||
// TRACES: FR-EXP-2
|
// TRACES: FR-EXP-2
|
||||||
// Refused rather than mislabelled. Every space the settings page offers
|
// Refused rather than mislabelled. Every space the settings page offers
|
||||||
@@ -170,7 +186,7 @@ pub fn export(
|
|||||||
let scale = width as f32 / frame.width.max(1) as f32;
|
let scale = width as f32 / frame.width.max(1) as f32;
|
||||||
let sharpened = sharpen::apply(resized, width, height, settings.sharpening, scale);
|
let sharpened = sharpen::apply(resized, width, height, settings.sharpening, scale);
|
||||||
|
|
||||||
let bytes = encode::encode(&sharpened, width, height, settings)?;
|
let bytes = encode::encode(&sharpened, width, height, settings, source)?;
|
||||||
|
|
||||||
Ok(Encoded {
|
Ok(Encoded {
|
||||||
name,
|
name,
|
||||||
@@ -223,6 +239,7 @@ mod tests {
|
|||||||
&frame(64, 48),
|
&frame(64, 48),
|
||||||
&settings(ExportFormat::Jpeg),
|
&settings(ExportFormat::Jpeg),
|
||||||
"a.jpg".into(),
|
"a.jpg".into(),
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
// SOI marker. Cheap, and it catches an encoder wired to the wrong
|
// SOI marker. Cheap, and it catches an encoder wired to the wrong
|
||||||
@@ -233,14 +250,14 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn png_export_produces_a_png() {
|
fn png_export_produces_a_png() {
|
||||||
let out = export(&frame(32, 32), &settings(ExportFormat::Png), "a.png".into()).unwrap();
|
let out = export(&frame(32, 32), &settings(ExportFormat::Png), "a.png".into(), None).unwrap();
|
||||||
assert_eq!(&out.bytes[..8], b"\x89PNG\r\n\x1a\n");
|
assert_eq!(&out.bytes[..8], b"\x89PNG\r\n\x1a\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tiff_exports_produce_a_tiff() {
|
fn tiff_exports_produce_a_tiff() {
|
||||||
for format in [ExportFormat::Tiff8, ExportFormat::Tiff16] {
|
for format in [ExportFormat::Tiff8, ExportFormat::Tiff16] {
|
||||||
let out = export(&frame(16, 16), &settings(format), "a.tif".into()).unwrap();
|
let out = export(&frame(16, 16), &settings(format), "a.tif".into(), None).unwrap();
|
||||||
// Either byte order is a valid TIFF; the crate writes little-endian.
|
// Either byte order is a valid TIFF; the crate writes little-endian.
|
||||||
assert!(
|
assert!(
|
||||||
out.bytes.starts_with(b"II*\0") || out.bytes.starts_with(b"MM\0*"),
|
out.bytes.starts_with(b"II*\0") || out.bytes.starts_with(b"MM\0*"),
|
||||||
@@ -253,8 +270,8 @@ mod tests {
|
|||||||
fn a_sixteen_bit_tiff_is_larger_than_an_eight_bit_one() {
|
fn a_sixteen_bit_tiff_is_larger_than_an_eight_bit_one() {
|
||||||
// Both are uncompressed RGB; the only difference is the sample width,
|
// Both are uncompressed RGB; the only difference is the sample width,
|
||||||
// so this is what proves the 16-bit path is not quietly writing 8.
|
// so this is what proves the 16-bit path is not quietly writing 8.
|
||||||
let eight = export(&frame(16, 16), &settings(ExportFormat::Tiff8), "a".into()).unwrap();
|
let eight = export(&frame(16, 16), &settings(ExportFormat::Tiff8), "a".into(), None).unwrap();
|
||||||
let sixteen = export(&frame(16, 16), &settings(ExportFormat::Tiff16), "a".into()).unwrap();
|
let sixteen = export(&frame(16, 16), &settings(ExportFormat::Tiff16), "a".into(), None).unwrap();
|
||||||
assert!(sixteen.bytes.len() > eight.bytes.len());
|
assert!(sixteen.bytes.len() > eight.bytes.len());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,8 +284,8 @@ mod tests {
|
|||||||
let mut high = settings(ExportFormat::Jpeg);
|
let mut high = settings(ExportFormat::Jpeg);
|
||||||
high.quality = 98;
|
high.quality = 98;
|
||||||
|
|
||||||
let small = export(&frame(128, 128), &low, "a".into()).unwrap();
|
let small = export(&frame(128, 128), &low, "a".into(), None).unwrap();
|
||||||
let large = export(&frame(128, 128), &high, "a".into()).unwrap();
|
let large = export(&frame(128, 128), &high, "a".into(), None).unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
large.bytes.len() > small.bytes.len(),
|
large.bytes.len() > small.bytes.len(),
|
||||||
"quality 98 produced {} bytes against quality 20's {}",
|
"quality 98 produced {} bytes against quality 20's {}",
|
||||||
@@ -281,7 +298,7 @@ mod tests {
|
|||||||
fn a_long_edge_export_lands_on_the_requested_size() {
|
fn a_long_edge_export_lands_on_the_requested_size() {
|
||||||
let mut s = settings(ExportFormat::Png);
|
let mut s = settings(ExportFormat::Png);
|
||||||
s.sizing = SizingMode::LongEdge(32);
|
s.sizing = SizingMode::LongEdge(32);
|
||||||
let out = export(&frame(128, 64), &s, "a".into()).unwrap();
|
let out = export(&frame(128, 64), &s, "a".into(), None).unwrap();
|
||||||
assert_eq!((out.width, out.height), (32, 16));
|
assert_eq!((out.width, out.height), (32, 16));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,7 +310,7 @@ mod tests {
|
|||||||
let mut s = settings(ExportFormat::Jpeg);
|
let mut s = settings(ExportFormat::Jpeg);
|
||||||
s.colour_space = ColourSpace::DisplayP3;
|
s.colour_space = ColourSpace::DisplayP3;
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
export(&frame(8, 8), &s, "a".into()),
|
export(&frame(8, 8), &s, "a".into(), None),
|
||||||
Err(ExportError::ColourSpaceMismatch { .. })
|
Err(ExportError::ColourSpaceMismatch { .. })
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -314,7 +331,7 @@ mod tests {
|
|||||||
s.colour_space = space;
|
s.colour_space = space;
|
||||||
let mut f = frame(8, 8);
|
let mut f = frame(8, 8);
|
||||||
f.space = space;
|
f.space = space;
|
||||||
let out = export(&f, &s, "a".into())
|
let out = export(&f, &s, "a".into(), None)
|
||||||
.unwrap_or_else(|e| panic!("{space:?} as {format:?}: {e}"));
|
.unwrap_or_else(|e| panic!("{space:?} as {format:?}: {e}"));
|
||||||
assert!(!out.bytes.is_empty());
|
assert!(!out.bytes.is_empty());
|
||||||
}
|
}
|
||||||
@@ -326,7 +343,7 @@ mod tests {
|
|||||||
for format in [ExportFormat::Avif, ExportFormat::JpegXl] {
|
for format in [ExportFormat::Avif, ExportFormat::JpegXl] {
|
||||||
assert!(
|
assert!(
|
||||||
matches!(
|
matches!(
|
||||||
export(&frame(8, 8), &settings(format), "a".into()),
|
export(&frame(8, 8), &settings(format), "a".into(), None),
|
||||||
Err(ExportError::FormatUnsupported(_))
|
Err(ExportError::FormatUnsupported(_))
|
||||||
),
|
),
|
||||||
"{format:?} should report that it has no encoder yet"
|
"{format:?} should report that it has no encoder yet"
|
||||||
@@ -339,7 +356,7 @@ mod tests {
|
|||||||
// Walks `ExportFormat::ALL`, so a format added to the settings page
|
// Walks `ExportFormat::ALL`, so a format added to the settings page
|
||||||
// cannot quietly reach an encoder that does not handle it.
|
// cannot quietly reach an encoder that does not handle it.
|
||||||
for format in ExportFormat::ALL {
|
for format in ExportFormat::ALL {
|
||||||
match export(&frame(8, 8), &settings(format), "a".into()) {
|
match export(&frame(8, 8), &settings(format), "a".into(), None) {
|
||||||
Ok(out) => assert!(!out.bytes.is_empty(), "{format:?} encoded to nothing"),
|
Ok(out) => assert!(!out.bytes.is_empty(), "{format:?} encoded to nothing"),
|
||||||
Err(ExportError::FormatUnsupported(f)) => assert_eq!(f, format),
|
Err(ExportError::FormatUnsupported(f)) => assert_eq!(f, format),
|
||||||
Err(e) => panic!("{format:?} failed unexpectedly: {e}"),
|
Err(e) => panic!("{format:?} failed unexpectedly: {e}"),
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
//! TRACES: FR-EXP-8
|
||||||
|
//! What an export is allowed to say about where it came from.
|
||||||
|
//!
|
||||||
|
//! # An allowlist, not a filter
|
||||||
|
//!
|
||||||
|
//! [`SourceMetadata`] is the whole of what can reach a file this crate writes.
|
||||||
|
//! It is populated field by field from whatever the caller decoded, and
|
||||||
|
//! nothing else travels — not because each unwanted tag is removed, but
|
||||||
|
//! because there is nowhere in this type for one to sit. That is the
|
||||||
|
//! difference between "we strip GPS" and "GPS cannot be written unless
|
||||||
|
//! [`SourceMetadata::location`] is `Some`", and only the second survives
|
||||||
|
//! somebody adding a field to the decoder next year.
|
||||||
|
//!
|
||||||
|
//! # What is deliberately not here
|
||||||
|
//!
|
||||||
|
//! **The maker note** (EXIF `0x927C`). It is an opaque vendor blob with no
|
||||||
|
//! public format, and its contents differ by body and firmware. Canon's
|
||||||
|
//! carries the body serial number and the shutter count; several bodies put a
|
||||||
|
//! *duplicate copy of the GPS fix* inside it, which is the specific reason it
|
||||||
|
//! cannot be passed through as an unexamined byte range: an export that
|
||||||
|
//! stripped the GPS directory and copied the maker note would have published
|
||||||
|
//! the coordinates anyway, while reporting itself as private. Parsing it per
|
||||||
|
//! vendor to decide what is safe is a research project with a permanent
|
||||||
|
//! maintenance cost, and the value on the other side is a few tags a
|
||||||
|
//! photographer rarely misses. So it is dropped, in both directions, whatever
|
||||||
|
//! the settings say.
|
||||||
|
//!
|
||||||
|
//! **Serial numbers and owner name** (`BodySerialNumber` 0xA431,
|
||||||
|
//! `LensSerialNumber` 0xA435, `CameraOwnerName` 0xA430). These identify a
|
||||||
|
//! person and a specific piece of equipment, and a serial number in a
|
||||||
|
//! published file links every photograph that person has ever posted. They
|
||||||
|
//! have no field here, so no export writes them.
|
||||||
|
//!
|
||||||
|
//! **IPTC and XMP.** FR-EXP-8 names both. Neither is read by `dr-decode`
|
||||||
|
//! today, so there is nothing to carry through; when there is, it arrives as
|
||||||
|
//! fields on this type and is written from them, and the same allowlist
|
||||||
|
//! reasoning applies unchanged.
|
||||||
|
|
||||||
|
use dr_types::Location;
|
||||||
|
|
||||||
|
/// TRACES: FR-EXP-8
|
||||||
|
/// The source metadata an export may carry.
|
||||||
|
///
|
||||||
|
/// Every field is optional because every field is genuinely absent from some
|
||||||
|
/// real file: scanner output has no aperture, a JPEG from a phone has no lens
|
||||||
|
/// model, and most photographs have no copyright statement at all.
|
||||||
|
///
|
||||||
|
/// Built by the caller, which is the only place that has both the decoded
|
||||||
|
/// source and the crate that decoded it — `dr-export` deliberately depends on
|
||||||
|
/// no decoder (see the crate docs), so the copy is made one field at a time
|
||||||
|
/// where both types are in scope. That transcription is a feature: it is the
|
||||||
|
/// point where somebody has to decide, in writing, that a newly-parsed piece
|
||||||
|
/// of the source is allowed to leave the machine.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq)]
|
||||||
|
pub struct SourceMetadata {
|
||||||
|
pub make: Option<String>,
|
||||||
|
pub model: Option<String>,
|
||||||
|
pub lens: Option<String>,
|
||||||
|
/// Exposure time in seconds.
|
||||||
|
pub shutter: Option<f32>,
|
||||||
|
/// The f-number, as in f/2.8.
|
||||||
|
pub aperture: Option<f32>,
|
||||||
|
pub iso: Option<u32>,
|
||||||
|
/// Millimetres, as marked on the lens rather than 35 mm equivalent.
|
||||||
|
pub focal_length: Option<f32>,
|
||||||
|
/// When the shutter fired, as Unix seconds read as a wall clock.
|
||||||
|
pub captured_at: Option<i64>,
|
||||||
|
/// Minutes east of UTC, where the camera recorded a zone.
|
||||||
|
pub captured_offset: Option<i32>,
|
||||||
|
/// Who made the photograph.
|
||||||
|
pub artist: Option<String>,
|
||||||
|
/// The rights statement.
|
||||||
|
pub copyright: Option<String>,
|
||||||
|
/// TRACES: FR-EXP-8
|
||||||
|
/// Where the shutter fired.
|
||||||
|
///
|
||||||
|
/// The one field the strip option is about. It is carried this far so that
|
||||||
|
/// a photographer who *wants* their coordinates can have them; by the time
|
||||||
|
/// the encoder sees the record this field has already been through
|
||||||
|
/// [`Self::sanitised`], and is `None` unless the user turned stripping
|
||||||
|
/// off.
|
||||||
|
pub location: Option<Location>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SourceMetadata {
|
||||||
|
/// This record as the settings permit it to be written.
|
||||||
|
///
|
||||||
|
/// **The single place stripping happens.** The encoders below take a
|
||||||
|
/// record and write what is in it, with no view on privacy; concentrating
|
||||||
|
/// the decision here means there is one function to read to know what an
|
||||||
|
/// export can disclose, and no format can quietly disagree with the
|
||||||
|
/// others — the failure mode where JPEG honours the setting and TIFF, five
|
||||||
|
/// hundred lines away, does not.
|
||||||
|
///
|
||||||
|
/// Stripping empties the field rather than blanking it. A `GPSLatitude` of
|
||||||
|
/// `0/0` still announces that the camera had a fix and that this file has
|
||||||
|
/// been through a scrubber; an absent directory says nothing at all, and
|
||||||
|
/// says it in the same shape as the millions of files that never had one.
|
||||||
|
pub(crate) fn sanitised(&self, strip_location: bool) -> Self {
|
||||||
|
let mut out = self.clone();
|
||||||
|
if strip_location {
|
||||||
|
out.location = None;
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -437,6 +437,51 @@ impl Orientation {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-EXP-8
|
||||||
|
/// Where a photograph was taken.
|
||||||
|
///
|
||||||
|
/// Here rather than in `dr-decode` because two crates that never speak to each
|
||||||
|
/// other both need it: the decoder reads it out of the EXIF GPS directory, and
|
||||||
|
/// the exporter decides whether to write it back. A type in either one would
|
||||||
|
/// have made the other depend on it.
|
||||||
|
///
|
||||||
|
/// **Signed degrees, not the tag's own shape.** EXIF stores three rationals
|
||||||
|
/// and a hemisphere letter — `48/1 51/1 2952/100` and `"N"` — which is a
|
||||||
|
/// representation, not a position. Normalising at the point of parsing means
|
||||||
|
/// nothing downstream can forget the letter and put a Sydney photograph in
|
||||||
|
/// the North Atlantic. Positive is north and east.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub struct Location {
|
||||||
|
/// Degrees north of the equator, -90..=90.
|
||||||
|
pub latitude: f64,
|
||||||
|
/// Degrees east of Greenwich, -180..=180.
|
||||||
|
pub longitude: f64,
|
||||||
|
/// Metres above sea level, where the file recorded one. Below sea level
|
||||||
|
/// is negative, which is the reason this is signed and the tag is not.
|
||||||
|
pub altitude: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Location {
|
||||||
|
/// A position, or `None` where the numbers cannot be one.
|
||||||
|
///
|
||||||
|
/// A GPS directory with an out-of-range value is a corrupt one, and a
|
||||||
|
/// latitude of 3000 placed on a map is a worse answer than no map pin.
|
||||||
|
pub fn new(latitude: f64, longitude: f64, altitude: Option<f64>) -> Option<Self> {
|
||||||
|
if !latitude.is_finite()
|
||||||
|
|| !longitude.is_finite()
|
||||||
|
|| !(-90.0..=90.0).contains(&latitude)
|
||||||
|
|| !(-180.0..=180.0).contains(&longitude)
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(Self {
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
altitude: altitude.filter(|a| a.is_finite()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// An opaque change-validator for a remote entry (an ETag, or an mtime where
|
/// An opaque change-validator for a remote entry (an ETag, or an mtime where
|
||||||
/// no ETag exists).
|
/// no ETag exists).
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -189,6 +189,25 @@ pub struct ExportSettings {
|
|||||||
/// What to do when the output filename already exists.
|
/// What to do when the output filename already exists.
|
||||||
pub collision: CollisionPolicy,
|
pub collision: CollisionPolicy,
|
||||||
|
|
||||||
|
/// TRACES: FR-EXP-8
|
||||||
|
/// Whether the source's camera, lens, capture time and rights statement
|
||||||
|
/// are written into the export.
|
||||||
|
///
|
||||||
|
/// On by default, and the two metadata settings are deliberately not one.
|
||||||
|
/// They answer different questions: this one is "should the copy I hand
|
||||||
|
/// over say what took it and who owns it", where the answer for a
|
||||||
|
/// photographer is nearly always yes, and [`Self::strip_location`] is
|
||||||
|
/// "should it say where I was", where the answer is nearly always no. A
|
||||||
|
/// single switch would force those together and make the safe choice for
|
||||||
|
/// one the wrong choice for the other — either publishing coordinates with
|
||||||
|
/// the copyright notice, or dropping the copyright notice to hide the
|
||||||
|
/// coordinates.
|
||||||
|
///
|
||||||
|
/// Off writes no metadata block whatsoever, which is what a file destined
|
||||||
|
/// for somewhere it must give nothing away wants: not an EXIF block that
|
||||||
|
/// has been emptied, but no EXIF block.
|
||||||
|
pub retain_metadata: bool,
|
||||||
|
|
||||||
/// Whether GPS and other identifying metadata is stripped (FR-EXP-8).
|
/// Whether GPS and other identifying metadata is stripped (FR-EXP-8).
|
||||||
///
|
///
|
||||||
/// Stripping is *on* by default, which is the one place here that departs
|
/// Stripping is *on* by default, which is the one place here that departs
|
||||||
@@ -245,6 +264,7 @@ impl Default for ExportSettings {
|
|||||||
sharpening: OutputSharpening::Screen,
|
sharpening: OutputSharpening::Screen,
|
||||||
filename_template: "{name}".to_string(),
|
filename_template: "{name}".to_string(),
|
||||||
collision: CollisionPolicy::Increment,
|
collision: CollisionPolicy::Increment,
|
||||||
|
retain_metadata: true,
|
||||||
strip_location: true,
|
strip_location: true,
|
||||||
target: ExportTarget::default(),
|
target: ExportTarget::default(),
|
||||||
destination: String::new(),
|
destination: String::new(),
|
||||||
@@ -778,6 +798,27 @@ mod tests {
|
|||||||
assert!(ExportSettings::default().strip_location);
|
assert!(ExportSettings::default().strip_location);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_camera_is_kept_by_default_and_the_place_is_not() {
|
||||||
|
// FR-EXP-8's two halves, and the reason they are two settings. The
|
||||||
|
// defaults have to disagree: an export says what took the photograph
|
||||||
|
// and stays quiet about where it was taken.
|
||||||
|
let s = ExportSettings::default();
|
||||||
|
assert!(s.retain_metadata, "camera and copyright default to kept");
|
||||||
|
assert!(s.strip_location, "coordinates default to stripped");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_settings_file_written_before_metadata_retention_existed_keeps_the_camera() {
|
||||||
|
// The field is newer than files on disk. `#[serde(default)]` fills it
|
||||||
|
// from `Default`, and this pins that the fill is the useful direction:
|
||||||
|
// an upgrade must not silently start writing bare files.
|
||||||
|
let older = r#"{"format":"jpeg","quality":90,"strip_location":true}"#;
|
||||||
|
let s: ExportSettings = serde_json::from_str(older).expect("older settings parse");
|
||||||
|
assert!(s.retain_metadata);
|
||||||
|
assert!(s.strip_location);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn exports_go_to_this_device_unless_asked_otherwise() {
|
fn exports_go_to_this_device_unless_asked_otherwise() {
|
||||||
// A first run must not upload someone's pictures to a server because
|
// A first run must not upload someone's pictures to a server because
|
||||||
|
|||||||
Reference in New Issue
Block a user