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 error::DecodeError;
|
||||
pub use locate::{
|
||||
defects, is_complete_jpeg, locate_preview, BadLine, BadPixel, Defects, PreviewLocation,
|
||||
HEADER_BYTES,
|
||||
defects, is_complete_jpeg, jpeg_metadata, locate_preview, tiff_metadata, BadLine, BadPixel,
|
||||
Defects, PreviewLocation, HEADER_BYTES,
|
||||
};
|
||||
pub use preview::{
|
||||
decode_jpeg, extract_embedded_preview, extract_preview, Preview, PreviewSize,
|
||||
@@ -30,7 +30,7 @@ pub use preview::{
|
||||
};
|
||||
pub use profile::CameraProfile;
|
||||
|
||||
use dr_types::{Format, Orientation};
|
||||
use dr_types::{Format, Location, Orientation};
|
||||
|
||||
/// Capture metadata read from a file header.
|
||||
#[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
|
||||
/// wrong day in Paris.
|
||||
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.
|
||||
@@ -302,6 +323,10 @@ pub fn metadata(bytes: &[u8]) -> Result<Metadata, DecodeError> {
|
||||
.as_deref()
|
||||
.or(exif.offset_time.as_deref())
|
||||
.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
|
||||
@@ -322,12 +347,57 @@ pub fn metadata(bytes: &[u8]) -> Result<Metadata, DecodeError> {
|
||||
out.iso = out.iso.or(fallback.iso);
|
||||
out.lens = out.lens.take().or(fallback.lens);
|
||||
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)
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// Read just the stored orientation, from a file header.
|
||||
///
|
||||
|
||||
@@ -287,6 +287,16 @@ impl<'a> TiffReader<'a> {
|
||||
/// its offset.
|
||||
fn scalar(&self, e: &Entry) -> Option<u32> {
|
||||
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.
|
||||
3 if e.count == 1 => Some(if self.little_endian {
|
||||
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.
|
||||
///
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -516,6 +568,35 @@ mod exif_tag {
|
||||
pub const LENS_MODEL: u16 = 0xA434;
|
||||
pub const PIXEL_X: u16 = 0xA002;
|
||||
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`.
|
||||
@@ -542,6 +623,11 @@ fn read_exif_entries(
|
||||
exif_tag::MODEL => md.model = r.ascii(e),
|
||||
exif_tag::LENS_MODEL => md.lens = r.ascii(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_Y => md.height = r.scalar(e),
|
||||
// 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.
|
||||
///
|
||||
/// 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"));
|
||||
}
|
||||
|
||||
/// 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]
|
||||
fn a_marker_walk_does_not_run_off_a_truncated_file() {
|
||||
// Untrusted input (NFR-SEC-1): a length field claiming more than the
|
||||
|
||||
Reference in New Issue
Block a user