Files
ImageApi/src/exif.rs
T
Cameron Cordes 2f2107d4fa feat(dates): foundation for the true-instant date contract (#114)
Populate the new-contract date columns at ingest while keeping the
legacy date_taken column byte-identical (ADR-0001):

- camera_tz module: offset cascade (offset tag -> GPS zone -> server
  zone) as a pure, testable function; bundled offline TZF geometry for
  GPS -> IANA resolution; DST-correct offsets for the capture date;
  display-zone helpers (DEFAULT_DISPLAY_TZ, display_zone_for,
  display_zone_offset_minutes) for the read-side cutover.
- EXIF branch: kamadak-exif now collects OffsetTime* tags paired with
  the winning date tag; resolve_date_taken takes an ExifPrior and
  emits (date_utc, camera_offset_minutes, date_taken_tz_source) per
  branch: exif -> cascade, exiftool/fs_time -> utc_direct, filename
  -> unclassified (pending #116).
- Schema: migration adds image_exif.date_utc /
  camera_offset_minutes / date_taken_tz_source and
  user_ai_prefs.display_tz (all nullable; down.sql drops them).
- DAO + ingest: backfill_date_taken persists the triple alongside the
  legacy pair; watcher, upload, and set_image_gps call sites feed the
  cascade.
- Response surface: ExifMetadata and ExifSummary carry the triple so
  /image/metadata and /photos/exif expose it (null for unconverted
  rows); reel requests mirror display_tz into prefs.

cargo build + clippy clean; 625 lib + 702 bin tests pass (the one
failing doctest in utils.rs is pre-existing at the base commit).
2026-09-24 21:58:46 -04:00

683 lines
24 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::fs::File;
use std::io::{BufReader, Read, Seek, SeekFrom};
use std::path::Path;
use std::process::Command;
use anyhow::{Result, anyhow};
use exif::{In, Reader, Tag, Value};
use image::DynamicImage;
use log::debug;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ExifData {
pub camera_make: Option<String>,
pub camera_model: Option<String>,
pub lens_model: Option<String>,
pub width: Option<i32>,
pub height: Option<i32>,
pub orientation: Option<i32>,
pub gps_latitude: Option<f64>,
pub gps_longitude: Option<f64>,
pub gps_altitude: Option<f64>,
pub focal_length: Option<f64>,
pub aperture: Option<f64>,
pub shutter_speed: Option<String>,
pub iso: Option<i32>,
pub date_taken: Option<i64>,
/// Camera offset (local − UTC, minutes) from the EXIF `OffsetTime*`
/// tag paired with the winning `DateTime*` tag, when the camera
/// stamped one. Feeds the offset cascade in `camera_tz`.
pub offset_time_minutes: Option<i32>,
// Raw per-tag offsets, collected in the field loop and paired with the
// winning date tag after it. Not part of the wire format.
#[serde(skip)]
offset_time: Option<i32>,
#[serde(skip)]
offset_time_original: Option<i32>,
#[serde(skip)]
offset_time_digitized: Option<i32>,
#[serde(skip)]
date_tag: Option<Tag>,
}
/// TIFF-based RAW formats where `JPEGInterchangeFormat` offsets are
/// absolute file offsets (the file itself is a TIFF container).
pub fn is_tiff_raw(path: &Path) -> bool {
matches!(
path.extension()
.and_then(|e| e.to_str())
.map(|s| s.to_lowercase())
.as_deref(),
Some(
"tiff" | "tif" | "nef" | "cr2" | "arw" | "dng" | "raf" | "orf" | "rw2" | "pef" | "srw"
)
)
}
/// Read the JPEG bytes pointed to by `JPEGInterchangeFormat` /
/// `JPEGInterchangeFormatLength` in a single IFD. Returns `None` on any
/// failure: tags missing, length zero, file read failure, or bytes that
/// don't start with the JPEG SOI marker (some MakerNote pointers reference
/// TIFF-wrapped previews or other non-JPEG payloads we can't load).
fn read_jpeg_at_ifd(exif: &exif::Exif, path: &Path, ifd: In) -> Option<Vec<u8>> {
let offset = exif
.get_field(Tag::JPEGInterchangeFormat, ifd)?
.value
.get_uint(0)?;
let length = exif
.get_field(Tag::JPEGInterchangeFormatLength, ifd)?
.value
.get_uint(0)?;
if length == 0 {
return None;
}
let mut file = File::open(path).ok()?;
file.seek(SeekFrom::Start(offset as u64)).ok()?;
let mut buf = vec![0u8; length as usize];
file.read_exact(&mut buf).ok()?;
if buf.len() < 2 || buf[0] != 0xFF || buf[1] != 0xD8 {
return None;
}
Some(buf)
}
/// Shell out to `exiftool -j -G -n <path>` and return the per-file tag map.
///
/// `-j` requests JSON; the response is always an array of one element per
/// input path. `-G` prefixes each key with the group name (`EXIF:Make`,
/// `MakerNotes:LensInfo`, `File:FileSize`, …) so a UI can group the dump.
/// `-n` returns numeric / raw values rather than exiftool's pretty-printed
/// human strings, which keeps the output stable for clients that want to
/// reformat (e.g. divide a focal-length numerator/denominator).
///
/// Returns:
/// - `Ok(Some(value))` — the parsed object for this file.
/// - `Ok(None)` — exiftool ran but the array was empty / not an object.
/// - `Err(_)` — exiftool isn't on PATH, the spawn failed, or its stderr
/// indicates an unsupported file. Caller surfaces a 503 / 422.
///
/// Used by `GET /image/exif/full` to power Apollo's DETAILS modal "FULL
/// EXIF" pane. Per-file shell-out is fine for this on-demand surface;
/// the indexer does NOT call this on the hot path (kamadak-exif covers
/// the indexed columns; exiftool is the slow-path preview helper).
pub fn read_full_exif_via_exiftool(path: &Path) -> Result<Option<serde_json::Value>> {
let output = Command::new("exiftool")
.arg("-j")
.arg("-G")
.arg("-n")
.arg(path)
.output()
.map_err(|e| anyhow!("exiftool spawn failed (is it on PATH?): {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow!(
"exiftool exited with {}: {}",
output.status,
stderr.trim()
));
}
let parsed: serde_json::Value = serde_json::from_slice(&output.stdout)
.map_err(|e| anyhow!("exiftool returned non-JSON output: {}", e))?;
// `-j` always wraps the result in an array — pull out the first object.
let arr = parsed
.as_array()
.ok_or_else(|| anyhow!("expected JSON array from exiftool -j"))?;
Ok(arr.first().cloned())
}
/// Tags exiftool exposes for embedded JPEG previews, in priority order. The
/// largest valid JPEG returned by any of them wins. Different camera makers
/// stash their largest preview under different names: Nikon's full-res
/// preview lives under `PreviewImage` in the MakerNote `PreviewIFD`, Canon /
/// Sony often expose theirs as `JpgFromRaw`, and `OtherImage` is a catch-all
/// some sub-IFD chains use.
const EXIFTOOL_PREVIEW_TAGS: &[&str] = &["PreviewImage", "JpgFromRaw", "OtherImage"];
/// Shell out to `exiftool -b -<tag>` for one tag. Returns the response bytes
/// only if exiftool succeeded AND the bytes start with the JPEG SOI marker
/// (some MakerNote tags hold TIFF-wrapped previews or other non-JPEG payloads
/// we can't load).
fn extract_exiftool_tag(path: &Path, tag: &str) -> Option<Vec<u8>> {
let output = Command::new("exiftool")
.arg("-b")
.arg(format!("-{}", tag))
.arg(path)
.output()
.ok()?;
if !output.status.success() {
return None;
}
let bytes = output.stdout;
if bytes.len() < 2 || bytes[0] != 0xFF || bytes[1] != 0xD8 {
return None;
}
Some(bytes)
}
/// Try each EXIFTOOL_PREVIEW_TAGS in turn and return the largest valid JPEG.
/// If `exiftool` isn't on PATH the very first spawn returns `None` and we
/// silently bail — callers fall back to whatever the IFD0/IFD1 fast path
/// found.
fn extract_preview_via_exiftool(path: &Path) -> Option<Vec<u8>> {
let mut best: Option<Vec<u8>> = None;
for &tag in EXIFTOOL_PREVIEW_TAGS {
let Some(bytes) = extract_exiftool_tag(path, tag) else {
continue;
};
match &best {
None => best = Some(bytes),
Some(b) if b.len() < bytes.len() => best = Some(bytes),
_ => {}
}
}
best
}
/// Returns the bytes of the embedded JPEG preview in a TIFF-based RAW or
/// TIFF file. Used to thumbnail formats whose RAW pixel data can't be decoded
/// by our normal tools (e.g. Sony ARW), and to serve a usable full-size
/// image for clients that can't decode the RAW container directly. Returns
/// `None` if no preview is present, the file isn't a TIFF container, or the
/// data doesn't look like a valid JPEG.
///
/// Strategy:
/// 1. Fast path: read `JPEGInterchangeFormat` from IFD0 (PRIMARY) and IFD1
/// (THUMBNAIL) directly via kamadak-exif. No subprocess, no external
/// dependency.
/// 2. Slow path: shell out to `exiftool -b -<tag>` for each of
/// `PreviewImage` / `JpgFromRaw` / `OtherImage`. kamadak-exif can't
/// reach SubIFDs or MakerNote sub-IFDs, but most modern Nikon bodies
/// stash their large preview JPEG in the Nikon MakerNote's PreviewIFD;
/// Canon / Sony often use `JpgFromRaw` in a SubIFD chain. Skipped
/// gracefully if exiftool isn't on PATH.
///
/// All candidates are pooled and the largest valid JPEG wins, so a deploy
/// without exiftool degrades to "fast-path only" behavior rather than
/// breaking outright.
pub fn extract_embedded_jpeg_preview(path: &Path) -> Option<Vec<u8>> {
if !is_tiff_raw(path) {
return None;
}
let file = File::open(path).ok()?;
let mut bufreader = BufReader::new(file);
let exif = Reader::new().read_from_container(&mut bufreader).ok()?;
let primary = read_jpeg_at_ifd(&exif, path, In::PRIMARY);
let thumbnail = read_jpeg_at_ifd(&exif, path, In::THUMBNAIL);
let exiftool = extract_preview_via_exiftool(path);
[primary, thumbnail, exiftool]
.into_iter()
.flatten()
.max_by_key(|v| v.len())
}
/// Write GPS lat/lon into the file's EXIF in place via exiftool. Touches
/// nothing else — camera, dates, MakerNote, etc. all stay as-is. Uses
/// `-overwrite_original` so no `.orig` sidecar is left behind (the
/// caller's responsibility to back up the file system if they want
/// rollback). Returns Err if exiftool isn't on PATH, the file format
/// doesn't support EXIF, lat/lon are out of range, or exiftool prints
/// to stderr.
///
/// We pass lat/lon as positive decimal numbers and let the *Ref tags
/// carry the sign (N/S, E/W). exiftool happily accepts signed decimals
/// too, but the explicit ref form is unambiguous across exiftool
/// versions and matches what cameras write.
pub fn write_gps(path: &Path, lat: f64, lon: f64) -> Result<()> {
if !supports_exif(path) {
return Err(anyhow!(
"Format does not support EXIF GPS write: {}",
path.display()
));
}
if !(-90.0..=90.0).contains(&lat) || !(-180.0..=180.0).contains(&lon) {
return Err(anyhow!("GPS coordinates out of range: {}, {}", lat, lon));
}
let lat_ref = if lat >= 0.0 { "N" } else { "S" };
let lon_ref = if lon >= 0.0 { "E" } else { "W" };
let lat_abs = lat.abs();
let lon_abs = lon.abs();
let output = Command::new("exiftool")
.arg("-overwrite_original")
.arg("-P")
.arg(format!("-GPSLatitude={}", lat_abs))
.arg(format!("-GPSLatitudeRef={}", lat_ref))
.arg(format!("-GPSLongitude={}", lon_abs))
.arg(format!("-GPSLongitudeRef={}", lon_ref))
.arg(path)
.output()
.map_err(|e| anyhow!("exiftool spawn failed (is it on PATH?): {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow!(
"exiftool failed (exit {}): {}",
output.status.code().unwrap_or(-1),
stderr.trim()
));
}
Ok(())
}
pub fn supports_exif(path: &Path) -> bool {
if let Some(ext) = path.extension() {
let ext_lower = ext.to_string_lossy().to_lowercase();
matches!(
ext_lower.as_str(),
// JPEG formats
"jpg" | "jpeg" |
// TIFF and RAW formats based on TIFF
"tiff" | "tif" | "nef" | "cr2" | "cr3" | "arw" | "dng" | "raf" | "orf" | "rw2" | "pef" | "srw" |
// HEIF and variants
"heif" | "heic" | "avif" |
// PNG
"png" |
// WebP
"webp"
)
} else {
false
}
}
pub fn extract_exif_from_path(path: &Path) -> Result<ExifData> {
debug!("Extracting EXIF from: {:?}", path);
if !supports_exif(path) {
return Err(anyhow!("File type does not support EXIF"));
}
let file = File::open(path)?;
let mut bufreader = BufReader::new(file);
let exifreader = Reader::new();
let exif = exifreader.read_from_container(&mut bufreader)?;
let mut data = ExifData::default();
for field in exif.fields() {
match field.tag {
Tag::Make => {
data.camera_make = get_string_value(field);
}
Tag::Model => {
data.camera_model = get_string_value(field);
}
Tag::LensModel => {
data.lens_model = get_string_value(field);
}
Tag::PixelXDimension | Tag::ImageWidth => {
if data.width.is_none() {
data.width = get_u32_value(field).map(|v| v as i32);
}
}
Tag::PixelYDimension | Tag::ImageLength => {
if data.height.is_none() {
data.height = get_u32_value(field).map(|v| v as i32);
}
}
Tag::Orientation => {
data.orientation = get_u32_value(field).map(|v| v as i32);
}
Tag::FocalLength => {
data.focal_length = get_rational_value(field);
}
Tag::FNumber => {
data.aperture = get_rational_value(field);
}
Tag::ExposureTime => {
data.shutter_speed = get_rational_string(field);
}
Tag::PhotographicSensitivity | Tag::ISOSpeed => {
if data.iso.is_none() {
data.iso = get_u32_value(field).map(|v| v as i32);
}
}
Tag::DateTime | Tag::DateTimeOriginal => {
if data.date_taken.is_none() {
data.date_taken = parse_exif_datetime(field);
data.date_tag = Some(field.tag);
}
}
Tag::OffsetTime => {
if data.offset_time.is_none() {
data.offset_time = parse_offset_time(field);
}
}
Tag::OffsetTimeOriginal => {
if data.offset_time_original.is_none() {
data.offset_time_original = parse_offset_time(field);
}
}
Tag::OffsetTimeDigitized => {
if data.offset_time_digitized.is_none() {
data.offset_time_digitized = parse_offset_time(field);
}
}
_ => {}
}
}
// Pair the offset with the date tag that won (DateTimeOriginal pairs
// with OffsetTimeOriginal, etc.); fall back to any stamped offset.
data.offset_time_minutes = match data.date_tag {
Some(Tag::DateTimeOriginal) => data
.offset_time_original
.or(data.offset_time)
.or(data.offset_time_digitized),
_ => data
.offset_time
.or(data.offset_time_original)
.or(data.offset_time_digitized),
};
// Extract GPS coordinates
if let Some(lat) = extract_gps_coordinate(&exif, Tag::GPSLatitude, Tag::GPSLatitudeRef) {
data.gps_latitude = Some(lat);
}
if let Some(lon) = extract_gps_coordinate(&exif, Tag::GPSLongitude, Tag::GPSLongitudeRef) {
data.gps_longitude = Some(lon);
}
if let Some(alt) = extract_gps_altitude(&exif) {
data.gps_altitude = Some(alt);
}
debug!("Extracted EXIF data: {:?}", data);
Ok(data)
}
/// Read just the EXIF Orientation tag (1..=8) from a file. Cheaper than a
/// full `extract_exif_from_path` when the caller only needs orientation —
/// e.g. the thumbnail pipeline, which has to bake the rotation into the
/// resized pixels because the saved thumb has no EXIF chunk for the browser
/// to apply.
pub fn read_orientation(path: &Path) -> Option<i32> {
let file = File::open(path).ok()?;
let mut reader = BufReader::new(file);
let exif = Reader::new().read_from_container(&mut reader).ok()?;
let field = exif.get_field(Tag::Orientation, In::PRIMARY)?;
get_u32_value(field).map(|v| v as i32)
}
/// Apply an EXIF Orientation (1..=8) to a `DynamicImage`, returning a
/// canonically-oriented copy. Orientations:
/// 1 → as-is, 2 → flipH, 3 → rot180, 4 → flipV,
/// 5 → rot90CW + flipH, 6 → rot90CW, 7 → rot270CW + flipH, 8 → rot270CW.
/// Anything else (missing tag, garbage values) is returned unchanged.
pub fn apply_orientation(img: DynamicImage, orientation: i32) -> DynamicImage {
match orientation {
2 => img.fliph(),
3 => img.rotate180(),
4 => img.flipv(),
5 => img.rotate90().fliph(),
6 => img.rotate90(),
7 => img.rotate270().fliph(),
8 => img.rotate270(),
_ => img,
}
}
fn get_string_value(field: &exif::Field) -> Option<String> {
match &field.value {
Value::Ascii(vec) => {
if let Some(bytes) = vec.first() {
String::from_utf8(bytes.to_vec())
.ok()
.map(|s| s.trim_end_matches('\0').to_string())
} else {
None
}
}
_ => {
let display = field.display_value().to_string();
if display.is_empty() {
None
} else {
Some(display)
}
}
}
}
fn get_u32_value(field: &exif::Field) -> Option<u32> {
match &field.value {
Value::Short(vec) => vec.first().map(|&v| v as u32),
Value::Long(vec) => vec.first().copied(),
_ => None,
}
}
fn get_rational_value(field: &exif::Field) -> Option<f64> {
match &field.value {
Value::Rational(vec) => {
if let Some(rational) = vec.first() {
if rational.denom == 0 {
None
} else {
Some(rational.num as f64 / rational.denom as f64)
}
} else {
None
}
}
_ => None,
}
}
fn get_rational_string(field: &exif::Field) -> Option<String> {
match &field.value {
Value::Rational(vec) => {
if let Some(rational) = vec.first() {
if rational.denom == 0 {
None
} else if rational.num < rational.denom {
Some(format!("{}/{}", rational.num, rational.denom))
} else {
let value = rational.num as f64 / rational.denom as f64;
Some(format!("{:.2}", value))
}
} else {
None
}
}
_ => None,
}
}
fn parse_exif_datetime(field: &exif::Field) -> Option<i64> {
if let Some(datetime_str) = get_string_value(field) {
use chrono::NaiveDateTime;
// EXIF datetime format: "YYYY:MM:DD HH:MM:SS"
// Note: EXIF dates are local time without timezone info
// We return the timestamp as if it were UTC, and the client will display it as-is
NaiveDateTime::parse_from_str(&datetime_str, "%Y:%m:%d %H:%M:%S")
.ok()
.map(|dt| dt.and_utc().timestamp())
} else {
None
}
}
/// Parse an EXIF `OffsetTime*` value into signed minutes from UTC.
///
/// EXIF stores `+HH:MM` or `-HH:MM` (seconds are optional and ignored); the
/// special value `Z` means UTC (zero minutes). Returns `None` for anything
/// that isn't a plausible offset (hour > 14, minute > 59, unparseable).
fn parse_offset_time(field: &exif::Field) -> Option<i32> {
let s = get_string_value(field)?.trim().to_string();
if s == "Z" {
return Some(0);
}
let sign: i32 = if s.starts_with('-') { -1 } else { 1 };
let digits = s.trim_start_matches(['+', '-']);
let mut parts = digits.split(':');
let hh: i32 = parts.next()?.parse().ok()?;
let mm: i32 = parts.next()?.parse().ok()?;
if hh > 14 || mm > 59 {
return None;
}
Some(sign * (hh * 60 + mm))
}
fn extract_gps_coordinate(exif: &exif::Exif, coord_tag: Tag, ref_tag: Tag) -> Option<f64> {
let coord_field = exif.get_field(coord_tag, In::PRIMARY)?;
let ref_field = exif.get_field(ref_tag, In::PRIMARY)?;
let coordinates = match &coord_field.value {
Value::Rational(vec) => {
if vec.len() < 3 {
return None;
}
let degrees = vec[0].num as f64 / vec[0].denom as f64;
let minutes = vec[1].num as f64 / vec[1].denom as f64;
let seconds = vec[2].num as f64 / vec[2].denom as f64;
degrees + (minutes / 60.0) + (seconds / 3600.0)
}
_ => return None,
};
let reference = get_string_value(ref_field)?;
let sign = if reference.starts_with('S') || reference.starts_with('W') {
-1.0
} else {
1.0
};
Some(coordinates * sign)
}
fn extract_gps_altitude(exif: &exif::Exif) -> Option<f64> {
let alt_field = exif.get_field(Tag::GPSAltitude, In::PRIMARY)?;
match &alt_field.value {
Value::Rational(vec) => {
if let Some(rational) = vec.first() {
if rational.denom == 0 {
None
} else {
let altitude = rational.num as f64 / rational.denom as f64;
// Check if below sea level
if let Some(ref_field) = exif.get_field(Tag::GPSAltitudeRef, In::PRIMARY)
&& let Some(ref_val) = get_u32_value(ref_field)
&& ref_val == 1
{
return Some(-altitude);
}
Some(altitude)
}
} else {
None
}
}
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_supports_exif_jpeg() {
assert!(supports_exif(Path::new("test.jpg")));
assert!(supports_exif(Path::new("test.jpeg")));
assert!(supports_exif(Path::new("test.JPG")));
}
#[test]
fn test_supports_exif_raw_formats() {
assert!(supports_exif(Path::new("test.nef"))); // Nikon
assert!(supports_exif(Path::new("test.NEF")));
assert!(supports_exif(Path::new("test.cr2"))); // Canon
assert!(supports_exif(Path::new("test.cr3"))); // Canon
assert!(supports_exif(Path::new("test.arw"))); // Sony
assert!(supports_exif(Path::new("test.dng"))); // Adobe DNG
}
#[test]
fn test_supports_exif_tiff() {
assert!(supports_exif(Path::new("test.tiff")));
assert!(supports_exif(Path::new("test.tif")));
assert!(supports_exif(Path::new("test.TIFF")));
}
#[test]
fn test_supports_exif_heif() {
assert!(supports_exif(Path::new("test.heif")));
assert!(supports_exif(Path::new("test.heic")));
assert!(supports_exif(Path::new("test.avif")));
}
#[test]
fn test_supports_exif_png_webp() {
assert!(supports_exif(Path::new("test.png")));
assert!(supports_exif(Path::new("test.PNG")));
assert!(supports_exif(Path::new("test.webp")));
assert!(supports_exif(Path::new("test.WEBP")));
}
#[test]
fn test_supports_exif_unsupported() {
assert!(!supports_exif(Path::new("test.mp4")));
assert!(!supports_exif(Path::new("test.mov")));
assert!(!supports_exif(Path::new("test.txt")));
assert!(!supports_exif(Path::new("test.gif")));
}
#[test]
fn test_supports_exif_no_extension() {
assert!(!supports_exif(Path::new("test")));
}
fn offset_field(s: &[u8]) -> exif::Field {
exif::Field {
tag: Tag::OffsetTimeOriginal,
ifd_num: In::PRIMARY,
value: Value::Ascii(vec![s.to_vec()]),
}
}
#[test]
fn test_parse_offset_time_positive_and_negative() {
assert_eq!(parse_offset_time(&offset_field(b"-04:00")), Some(-240));
assert_eq!(parse_offset_time(&offset_field(b"+05:30")), Some(330));
assert_eq!(parse_offset_time(&offset_field(b"+00:00")), Some(0));
}
#[test]
fn test_parse_offset_time_with_seconds_and_z() {
assert_eq!(parse_offset_time(&offset_field(b"-04:00:00")), Some(-240));
assert_eq!(parse_offset_time(&offset_field(b"Z")), Some(0));
}
#[test]
fn test_parse_offset_time_rejects_garbage() {
assert_eq!(parse_offset_time(&offset_field(b"garbage")), None);
assert_eq!(parse_offset_time(&offset_field(b"+15:00")), None);
assert_eq!(parse_offset_time(&offset_field(b"+04:60")), None);
assert_eq!(parse_offset_time(&offset_field(b"")), None);
}
#[test]
fn test_parse_offset_time_missing_tag_is_none() {
let empty = exif::Field {
tag: Tag::OffsetTimeOriginal,
ifd_num: In::PRIMARY,
value: Value::Ascii(Vec::new()),
};
assert_eq!(parse_offset_time(&empty), None);
}
}