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.
519 lines
20 KiB
Rust
519 lines
20 KiB
Rust
//! 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)
|
|
}
|
|
}
|