Files
craft-libs/crates/metadata/src/datetime.rs
T
+10 8d3f245d2a craft-metadata: validation and location fixes ported from open LightCraft pull requests (#1042)
* craft-metadata: EXIF, IPTC-IIM, XMP and GPX from LightCraft

lightcraft-meta at 5b61ce1e, as one crate with a module per format: exif
(read, write, maker-note lens, focus point, tag listing), iptc, xmp (parse,
write, merge), gpx, and the JPEG, PNG, WebP, TIFF, RAF and CR3 containers.
They share Metadata, DateTime, Gps and Orientation.

Changed in the move:
- TIFF comes from craft-tiff; Point, Rect and Orientation are the crate's own.
- The app namespace is a parameter: parse_xmp_with, write_xmp_with and
  MergeRules::namespaces take it, and XmpData no longer has lc_settings.
- The HEIF, PSD and MRW readers stay out, since craft-heif, craft-psd and
  craft-raw read those containers; from_embedded takes the blocks they find.

* craft-metadata: a RW2 reads ISO and the rest of its Exif from the JpgFromRaw

A RW2's own Exif IFD is a short subset. What it lacks is filled from the Exif
of the embedded JpgFromRaw, then from the ISO tags of the Panasonic IFD0
(0x0017, 0x0037), each only in its recorded field type. 65535 in a SHORT
ISOSpeedRatings is the overflow marker and states no ISO. The private tags
are read only in a RW2 stream, under the caller's parse options.

Ported from storytold/lightcraft#3518.

* craft-metadata: the Exif rating is written, and 65535 reads as rejected

write_exif writes Rating (0x4746), with 0xFFFF for a rejected photo, and
from_tiff reads 65535 as -1 instead of clamping it to 5.

Ported from storytold/lightcraft#1995.

* craft-metadata: one shutter-speed formatter, exact to four digits

format_exposure_time shows a duration within 0.1% of 1/n second as 1/n and
anything else to four significant digits, so 0.3 s, 1/2.8 s and 0.625 s are
no longer rounded to a reciprocal or one decimal. Metadata::exposure_display
and the ExposureTime tag row both use it.

Ported from storytold/lightcraft#1692.

* craft-metadata: JPEG XL containers give their Exif and xml boxes

The first Exif and xml box of a JPEG XL container, plain or Brotli-wrapped
in brob, honouring the Exif box's TIFF header offset. A brob payload is
capped at 32 MiB. A bare codestream has no metadata.

Ported from storytold/lightcraft#3756.

* craft-metadata: XMP Resource structs flatten their attribute fields

A property with rdf:parseType="Resource" may give its fields as attributes
as well as child elements. Only the children were flattened into
XmpData::properties; the attributes now are too, as for a nested
rdf:Description.

Ported from storytold/lightcraft#1226 (the XMP part).

* craft-metadata: a capture date of just a year or a month stays as stated

XMP dates may stop at the year (1983) or the month (1983-04). DateTime now
has a DatePrecision; such a date writes back to XMP as stated and only in
photoshop:DateCreated, and write_exif leaves it out, since an Exif date
cannot say it.

Ported from storytold/lightcraft#3502.

* craft-metadata: Fujifilm in-camera recipes from the maker note

fuji module: the film simulation and the rest of the in-camera recipe (grain,
colour chrome, tone, dynamic range, white balance and its shift, sharpness,
noise reduction, clarity) from a Fujifilm maker note. recipe_from_exif,
recipe_from_bytes (TIFF, JPEG, RAF) and read_recipe_file, which reads only
as far as the metadata. from_embedded fills Metadata::fuji_recipe and
file_tag_rows lists the recipe under Fujifilm.

Ported from storytold/lightcraft#1209.

* craft-metadata: the linear-time scan test compares times, not a fixed bound

The test asserted that 200 000 attributes scan in under 5 s, which failed on
a loaded machine. It now scans 25 000 and 100 000 attributes, takes the best
of three runs of each, and asserts that four times the attributes take less
than ten times as long. Linear scans give about 4; a quadratic duplicate
check gave 18.6 and fails.

* craft-metadata: interleave the linear-time scan test's runs

The best-of-three ratio still failed once under load. The test now alternates
five runs of 50 000 and 200 000 attributes and compares the fastest of each,
so a burst of load slows both sizes. Six runs at load average 55 gave ratios
of 3.9 to 5.4 against the bound of 10.

* craft-metadata: XmpData::toolkit, the x:xmptk of the packet

The name of the program that wrote a packet, from x:xmptk on x:xmpmeta (or
x:xaptk on the older x:xapmeta), whatever prefix the packet binds to
adobe:ns:meta/. write_xmp_with writes XmpWrite::toolkit there, and the test
reads it back, escaped characters included.

* craft-metadata: mpf, the Multi-Picture index of a JPEG

mpf::images lists the images an APP2 MPF index (CIPA DC-007) puts after the
primary image, with their file offsets; the index is read with craft-tiff,
in either byte order, up to 16 entries. mpf::write inserts an index after
the primary's leading APPn segments and appends the other images, with the
primary listed as the representative baseline image: the layout LightCraft's
gain map JPEG writer uses, for any number of images. mpf::markers lists the
marker segments before the first scan, for APPn payloads such as ISO 21496-1
gain map metadata.

* craft-metadata: edit, byte-preserving Exif and XMP edits from PhotoCraft

PhotoCraft's orientation, resolution and Exif colour-space code (codecs
orientation.rs without the pixel transforms, resolution.rs, exif_color.rs),
at photocraft f1b5e7d72, as the edit module:

- upright_exif and upright_xmp set Orientation to 1 in place, in the entry's
  own type and width, every well-formed duplicate included.
- exif_with_resolution and xmp_with_resolution set the resolution in place
  and remove entries that cannot be rewritten; the _adding_missing variant
  appends a new IFD0 when there are none.
- export_exif and export_xmp do both and drop the source Exif thumbnail,
  with the overlap scan bounded.
- exif_orientation, exif_resolution, xmp_resolution, photoshop_resolution,
  exif_color_space and resolution read the same values with the editors'
  rules (first entry wins, SHORT or LONG only, BigTIFF accepted).

The tests that call these functions directly move with them (the codec
round trips stay in PhotoCraft): the orientation, resolution, thumbnail and
compaction tests, the comparison-counting complexity test and the colour
space tests.

* craft-metadata: exif_with_pixel_size, the written size in PixelX/YDimension

A camera records its sensor's size in the Exif IFD's PixelXDimension and
PixelYDimension, which after a crop, resize or rotation names a size the
exported file does not have. exif_with_pixel_size rewrites each one that
holds one SHORT or LONG, in place and as a LONG, in classic and BigTIFF
blocks, with the JPEG Exif prefix kept.

Ported from storytold/photocraft#12407 (the format part).

* craft-metadata: an ISO 8601 clock needs two-digit hours and minutes

Ported from storytold/lightcraft#3523.

* craft-metadata: a UTC offset needs a two-digit minute field

Ported from storytold/lightcraft#3467.

* craft-metadata: tiff:Orientation is read only as an integral code 1 to 8

A fractional or out-of-range value stays a raw property and does not
become the photo's orientation.

Ported from storytold/lightcraft#3495.

* craft-metadata: an XMP ISO is read only when it is a whole number in range

A fractional, non-positive or out-of-range ISO is not truncated or
saturated into one; the next candidate property is tried instead.

Ported from storytold/lightcraft#3633.

* craft-metadata: XMP pixel dimensions are read only as exact whole numbers

A malformed, fractional, negative or out-of-range tiff:ImageWidth or
ImageLength is no longer truncated or saturated, and lets the exif:Pixel
dimension take its place. Numbers are compared as exact decimal digits, so
wide rationals stay exact. The ISO reader now uses the same exact reader
(both PRs added one).

Ported from storytold/lightcraft#3690.

* craft-metadata: XMP GPS minutes and seconds must be under 60; tell when a read was cut short

A coordinate such as 45,60N is no longer normalised to a plausible point;
the raw property stays. XmpData::properties_truncated says the flattened
property map ran past its key budget, Embedded::chunk_walk_incomplete says a
PNG or WebP chunk walk stopped early, and jpeg_segments_with_all_xmp returns
every ordinary XMP packet of a JPEG, so a caller that must prove a file
carries no location can refuse what it could not inspect.

Ported from storytold/lightcraft#3054.

* craft-metadata: IPTC Time Created counts only when it is a whole, valid time

A date with a six-digit clock and no zone stays date-only; HHMMSS±HHMM
carries an offset; clocks and offsets are range-checked. A partial offset,
extra digits, a leap second or trailing text no longer becomes a capture
time, so it cannot block valid XMP or Exif.

Ported from storytold/lightcraft#3448.

* craft-metadata: test hostile XMP attribute references

The one-pass reference scan (entity_end) already covers the bug that
storytold/lightcraft#3007 fixed another way; its test is added here and
passes on this implementation: unknown, unterminated and far-away
references stay verbatim, a value of a million ampersands or of a million
zeros is read once, and multi-byte text around a reference stays whole.

Test from storytold/lightcraft#3007.

* craft-metadata: parse_gps_position; an XMP coordinate's sign comes from its letter

parse_gps_position reads an exif:GPSLatitude and GPSLongitude pair and
checks both axes. A negative degree, minute or second part, or a letter of
the other axis (10,30E as a latitude), is malformed; the letter may be
either case.

Ported from storytold/lightcraft#2088 (the format part).

* craft-metadata: bound the XMP namespace declarations in scope; merge into an empty rdf:RDF

Prefix lookup scans every declaration in scope, so one start tag with
thousands of xmlns declarations made parsing and merging quadratic. More
than 512 declarations in scope is now TooComplex, in parse_xmp and in
merge_xmp's scan. merge_xmp also opens a self-closing <rdf:RDF/> around the
new description instead of failing.

Ported from storytold/lightcraft#3696.

* craft-metadata: XmpData::has_location_metadata, every embedded XMP location

has_location_metadata answers whether a packet names any Exif GPS property or
a non-blank place (IPTC Core location fields, photoshop City/State/Country,
IPTC Extension LocationShown and LocationCreated with a place or coordinates),
including inside array items and repeated structs that the flattened map and
the structured values keep only once (XmpData::names_location, from the parsed
tree). The IPTC Extension namespace is now canonicalised and written.
extended_xmp_guid reports the announced Extended XMP GUID. A caller that must
prove a file has no location checks properties_truncated first.

The source also listed LightCraft's own lc:location; a caller with its own
namespace checks that itself.

Ported from storytold/lightcraft#4121 (the format part).

* craft-metadata: is_lens_spec_name, the inverse check of lens_spec_name

Whether a lens name has the '<focal> mm f/<aperture>' shape lens_spec_name
writes, so a caller can tell a made-up name from a camera's. The test is new.

Ported from storytold/lightcraft#2644 (the format part).

* craft-metadata: Sony focus points for 44 bodies

SONY_MODELS grows from the alpha 1 and A7S III to the 44 bodies whose focus
tags were checked on the public raw.pixls.us samples against ExifTool's
reading; bodies whose 0x201b is not the focus mode stay out. This also covers
the ILCE-6400 and ILCE-7M3 that storytold/lightcraft#3109 adds.

Ported from storytold/lightcraft#1891 (the focus part).

* craft-metadata: GPX points say which track they are on; tracks keep their names

TrackPoint::track counts the <trk> a point came from, Tracklog::names holds
each track's <name> (trimmed, at most 200 characters), and only_track gives
one track's points, so a map can draw a file's tracks apart and tag from one.
At most 10 000 tracks per file (TooComplex past that); a non-finite elevation
is dropped.

Ported from storytold/lightcraft#921 (the GPX part; #916 carries the same).

* craft-metadata: test that from_embedded keeps the EXIF, IPTC, XMP precedence

storytold/lightcraft#2211 adds the same function as extract_embedded; this
crate already has it as from_embedded. Its tests are added: EXIF camera
fields stay, IPTC fills gaps, XMP overrides the user fields, and damaged
blocks read as nothing.

Tests from storytold/lightcraft#2211.

* craft-metadata: the XMP writer keeps a carriage return as &#13;

XML end-of-line handling turns a literal CR into a line feed when a packet is
read, so text with one did not round-trip through other readers. Element
text now writes it as a character reference.

Ported from storytold/lightcraft#4119 (the writer fix only; that PR also
moves LightCraft to quick-xml 0.42, which this crate does not take here, as
craft-xml uses 0.41).

* craft-metadata: README rows for the ported validation and location checks

---------

Co-authored-by: radialmonster <radial@gmail.com>
Co-authored-by: Sebastian Berschneider <berschi@gmail.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
Co-authored-by: Michael Anderson <1427851+endthestart@users.noreply.github.com>
Co-authored-by: Ilya Dorman <ilya@codeforhumans.dev>
Co-authored-by: Clyde C Brown <clyde.c.brown@pm.me>
Co-authored-by: MohammadTeimori <teimori.mohammad@gmail.com>
Co-authored-by: vangel76 <vangel1976@googlemail.com>
Co-authored-by: Max Mittelstädt <62167861+TheGalitube@users.noreply.github.com>
Co-authored-by: NetterSilver <133535268+Silvertree2010@users.noreply.github.com>
Co-authored-by: ronibalthazar <61232335+ronibalthazar@users.noreply.github.com>
Co-authored-by: Andrew Frahn <andrew@emmertex.com>
Co-authored-by: jkalkstein <76634771+jkalkstein@users.noreply.github.com>
Co-authored-by: d-rouge <d-rouge@users.noreply.github.com>
Co-authored-by: espoon <espoon@users.noreply.github.com>
Co-authored-by: Yoann Laissus <yoann.laissus@gmail.com>
Co-authored-by: Jason Baldwin <33934381+bitshiftza@users.noreply.github.com>
Co-authored-by: ingolfteetz <iteetz@me.com>
Co-authored-by: smi-leonivkovic <leon.ivkovic@smi-digital.com>
2026-11-11 21:03:44 -05:00

412 lines
18 KiB
Rust

//! Capture date/time with optional UTC offset (Exif `DateTimeOriginal` + `OffsetTimeOriginal`, ISO 8601 in XMP).
use serde::{Deserialize, Serialize};
/// A civil date-time as recorded by the camera; `offset_minutes` is the UTC offset when known.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub struct DateTime {
pub year: u16,
pub month: u8,
pub day: u8,
pub hour: u8,
pub minute: u8,
pub second: u8,
/// Sub-second part in milliseconds.
pub millis: u16,
pub offset_minutes: Option<i16>,
/// How much of the date was stated. XMP dates may stop at the year (`1983`) or the month
/// (`1983-04`), as for a scanned print whose day isn't known; the missing parts read as 1 and
/// the time as midnight, and the date writes back as stated.
#[serde(default, skip_serializing_if = "DatePrecision::is_full")]
pub precision: DatePrecision,
}
/// How much of a [`DateTime`] was stated.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum DatePrecision {
/// The day at least (a time of day may be midnight because none was given).
#[default]
Full,
/// The year and month only (`1983-04`).
Month,
/// The year only (`1983`).
Year,
}
impl DatePrecision {
pub fn is_full(&self) -> bool {
*self == DatePrecision::Full
}
}
fn num(s: &str) -> Option<u32> {
if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
s.parse().ok()
}
impl DateTime {
fn valid(self) -> Option<Self> {
// A timestamp must represent a real Gregorian calendar day, not just
// numbers in the range 1..=31. Invalid dates would otherwise be
// accepted from EXIF/XMP and silently normalized during time arithmetic.
// chrono rejects impossible days (leap years and all); the time is checked
// by hand so a leap second (`second == 60`) stays acceptable.
let day_ok = self.year > 0 && chrono::NaiveDate::from_ymd_opt(i32::from(self.year), u32::from(self.month), u32::from(self.day)).is_some();
let ok = day_ok && self.hour < 24 && self.minute < 60 && self.second < 61 && self.offset_minutes.is_none_or(|o| o.unsigned_abs() <= 18 * 60);
ok.then_some(self)
}
/// Exif form `YYYY:MM:DD HH:MM:SS` (also tolerates `-` date separators and a missing time).
pub fn parse_exif(s: &str) -> Option<DateTime> {
let s = s.trim().trim_end_matches('\0');
let (d, t) = s.split_once([' ', 'T']).unwrap_or((s, ""));
let dp: Vec<&str> = d.split([':', '-']).collect();
if dp.len() != 3 {
return None;
}
let (hour, minute, second) = if t.is_empty() {
(0, 0, 0)
} else {
let mut parts = t.split(':');
let hour = num(parts.next()?.trim())?;
let minute = parts.next().map(|part| num(part.trim())).unwrap_or(Some(0))?;
let second = parts.next().map(|part| num(part.trim())).unwrap_or(Some(0))?;
// A fourth value cannot be represented; accepting only the prefix could turn a
// malformed DateTimeOriginal into a plausible capture time and block fallbacks.
if parts.next().is_some() {
return None;
}
(hour, minute, second)
};
DateTime {
year: num(dp[0])?.try_into().ok()?,
month: num(dp[1])?.try_into().ok()?,
day: num(dp[2])?.try_into().ok()?,
hour: hour.try_into().ok()?,
minute: minute.try_into().ok()?,
second: second.try_into().ok()?,
millis: 0,
offset_minutes: None,
precision: DatePrecision::Full,
}
.valid()
}
/// Exif `OffsetTime*` form `+HH:MM` / `-HH:MM` (or `Z`).
pub fn parse_offset(s: &str) -> Option<i16> {
let s = s.trim().trim_end_matches('\0');
if s == "Z" {
return Some(0);
}
let (sign, rest) = match s.as_bytes().first()? {
b'+' => (1, &s[1..]),
b'-' => (-1, &s[1..]),
_ => return None,
};
let (h, m) = match rest.split_once(':') {
Some((h, m)) if m.len() == 2 => (num(h)?, num(m)?),
Some(_) => return None,
None if rest.len() == 4 => (num(rest.get(..2)?)?, num(rest.get(2..)?)?),
None => (num(rest)?, 0),
};
if h > 18 || m > 59 || (h == 18 && m > 0) {
return None;
}
Some(sign * (h * 60 + m) as i16)
}
/// Set the sub-second part from an Exif `SubSecTime*` string (digits are a decimal fraction).
pub fn with_subsec(mut self, s: &str) -> DateTime {
let digits: String = s.trim().chars().take_while(|c| c.is_ascii_digit()).take(3).collect();
if !digits.is_empty() {
let v: u16 = digits.parse().unwrap_or(0);
self.millis = v * 10u16.pow(3 - digits.len() as u32);
}
self
}
/// ISO 8601 (XMP date): `YYYY`, `YYYY-MM`, `YYYY-MM-DD`, `YYYY-MM-DDThh:mm[:ss[.s+]][TZD]`. A
/// year or a month alone keeps its [`DatePrecision`].
pub fn parse_iso(s: &str) -> Option<DateTime> {
let s = s.trim();
let (d, t) = match s.split_once('T') {
Some((d, t)) if !t.is_empty() => (d, t),
Some(_) => return None,
None => (s, ""),
};
let dp: Vec<&str> = d.split('-').collect();
let year = num(dp.first()?)?.try_into().ok()?;
let month = dp.get(1).map(|m| num(m)).unwrap_or(Some(1))?.try_into().ok()?;
let day = dp.get(2).map(|m| num(m)).unwrap_or(Some(1))?.try_into().ok()?;
if dp.len() > 3 {
return None;
}
let precision = match dp.len() {
1 => DatePrecision::Year,
2 => DatePrecision::Month,
_ => DatePrecision::Full,
};
let mut dt = DateTime { year, month, day, precision, ..Default::default() };
if !t.is_empty() {
// a time of day needs the day it is on
if !dt.precision.is_full() {
return None;
}
let (clock, off) = match t.find(['Z', '+', '-']) {
Some(i) => (&t[..i], Some(&t[i..])),
None => (t, None),
};
let mut cp = clock.split(':');
let hour = cp.next()?;
let minute = cp.next()?;
if hour.len() != 2 || minute.len() != 2 {
return None;
}
dt.hour = num(hour)?.try_into().ok()?;
dt.minute = num(minute)?.try_into().ok()?;
if let Some(sec) = cp.next() {
let (whole, fraction) = match sec.split_once('.') {
Some((whole, fraction)) => {
if fraction.is_empty() || !fraction.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
(whole, Some(fraction))
}
None => (sec, None),
};
dt.second = num(whole)?.try_into().ok()?;
if let Some(fraction) = fraction {
dt = dt.with_subsec(fraction);
}
}
if cp.next().is_some() {
return None;
}
if let Some(o) = off {
dt.offset_minutes = Some(Self::parse_offset(o)?);
}
}
dt.valid()
}
/// Whether the day was stated: Exif's date form can't say a year or a month alone, so only a
/// full date is written there.
pub fn is_full(&self) -> bool {
self.precision.is_full()
}
/// Exif form `YYYY:MM:DD HH:MM:SS`.
pub fn to_exif(&self) -> String {
format!("{:04}:{:02}:{:02} {:02}:{:02}:{:02}", self.year, self.month, self.day, self.hour, self.minute, self.second)
}
/// ISO 8601 with milliseconds when non-zero and the offset when known; a year or a month
/// alone as stated (`1983`, `1983-04`).
pub fn to_iso(&self) -> String {
match self.precision {
DatePrecision::Year => return format!("{:04}", self.year),
DatePrecision::Month => return format!("{:04}-{:02}", self.year, self.month),
DatePrecision::Full => {}
}
let mut s = format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}", self.year, self.month, self.day, self.hour, self.minute, self.second);
if self.millis != 0 {
s += &format!(".{:03}", self.millis);
}
if let Some(o) = self.offset_minutes {
let sign = if o < 0 { '-' } else { '+' };
let o = o.unsigned_abs();
s += &format!("{sign}{:02}:{:02}", o / 60, o % 60);
}
s
}
/// Seconds since 1970-01-01T00:00:00 of the civil time (UTC when the offset is known, else "local" seconds).
pub fn unix_seconds(&self) -> i64 {
// Every `DateTime` reaching here is a real calendar day (`valid()` ran in the parsers);
// an out-of-range one (built by hand) reads as the epoch rather than panicking.
let days = chrono::NaiveDate::from_ymd_opt(i32::from(self.year), u32::from(self.month), u32::from(self.day))
.zip(chrono::NaiveDate::from_ymd_opt(1970, 1, 1))
.map(|(d, epoch)| (d - epoch).num_days())
.unwrap_or(0);
days * 86400 + self.hour as i64 * 3600 + self.minute as i64 * 60 + self.second as i64 - self.offset_minutes.unwrap_or(0) as i64 * 60
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn offset_rejects_short_minute_field() {
assert_eq!(DateTime::parse_offset("+08:5"), None);
assert_eq!(DateTime::parse_offset("+1:2"), None);
assert_eq!(DateTime::parse_offset("+08:05"), Some(485));
assert_eq!(DateTime::parse_offset("-05:30"), Some(-330));
}
#[test]
fn exif_forms() {
let d = DateTime::parse_exif("2024:06:01 13:45:59").unwrap();
assert_eq!((d.year, d.month, d.day, d.hour, d.minute, d.second), (2024, 6, 1, 13, 45, 59));
assert_eq!(d.to_exif(), "2024:06:01 13:45:59");
assert!(DateTime::parse_exif("0000:00:00 00:00:00").is_none());
assert!(DateTime::parse_exif(" : : : : ").is_none());
assert!(DateTime::parse_exif("2024:13:01 00:00:00").is_none());
// Invalid calendar days must never enter persisted capture metadata.
for value in ["2023:02:29", "2024:02:30", "2024:04:31", "1900:02:29", "2024:01:00"] {
assert!(DateTime::parse_exif(value).is_none(), "{value}");
}
assert!(DateTime::parse_exif("2024:02:29").is_some());
assert!(DateTime::parse_exif("2000:02:29").is_some());
for value in ["2023-02-29", "2024-02-30", "1900-02-29", "2024-06-31T10:00:00Z"] {
assert!(DateTime::parse_iso(value).is_none(), "{value}");
}
assert!(DateTime::parse_iso("2000-02-29T23:59:59Z").is_some());
assert_eq!(DateTime::parse_exif("2024-06-01").unwrap().hour, 0);
assert_eq!(DateTime::parse_exif("2024:06:01 13:45:59").unwrap().with_subsec("5").millis, 500);
assert_eq!(DateTime::parse_exif("2024:06:01 13:45:59").unwrap().with_subsec("0123").millis, 12);
}
#[test]
fn exif_clock_rejects_extra_components() {
for value in
["2026:01:01 12:34:56:99", "2026:01:01 12:34:56:junk", "2026:01:01 12:34:56:", "2026:01:01 12:34:56::", "2026:01:01 12:34:56:99:junk"]
{
assert!(DateTime::parse_exif(value).is_none(), "accepted malformed EXIF clock {value:?}");
}
}
#[test]
fn exif_clock_supported_forms_keep_their_meaning() {
for (value, expected) in [
("2026:01:01", "2026-01-01T00:00:00"),
("2026:01:01 12", "2026-01-01T12:00:00"),
("2026:01:01 12:34", "2026-01-01T12:34:00"),
("2026:01:01 12:34:56", "2026-01-01T12:34:56"),
("2026-01-01T12:34:56", "2026-01-01T12:34:56"),
(" 2026:01:01 12:34:56\0", "2026-01-01T12:34:56"),
("2026:01:01T12:34:60", "2026-01-01T12:34:60"),
] {
assert_eq!(DateTime::parse_exif(value).map(|date| date.to_iso()), Some(expected.to_string()), "{value:?}");
}
}
/// XMP dates may stop at the year or the month (a scanned print's `photoshop:DateCreated`):
/// read with that precision, written back as stated, and never as an Exif date.
#[test]
fn a_year_or_month_alone_reads_and_writes_as_stated() {
for (iso, precision) in [("1983", DatePrecision::Year), ("1983-04", DatePrecision::Month)] {
let d = DateTime::parse_iso(iso).unwrap();
assert_eq!((d.year, d.month, d.day, d.precision), (1983, if iso.len() == 7 { 4 } else { 1 }, 1, precision), "{iso}");
assert!(!d.is_full());
assert_eq!(d.to_iso(), iso);
let meta = crate::Metadata { capture_time: Some(d), ..Default::default() };
let xmp = crate::write_xmp(&meta);
assert!(!xmp.contains("DateTimeOriginal"), "{xmp}");
assert_eq!(crate::parse_xmp(&xmp).unwrap().metadata.capture_time, Some(d), "{xmp}");
assert_eq!(crate::read_exif(&crate::write_exif(&meta)).capture_time, None, "Exif can't say {iso}");
}
for not in ["0000", "1983-00", "1983-13", "1983-04T10:00:00", "1983T10:00"] {
assert_eq!(DateTime::parse_iso(not), None, "{not}");
}
let full = DateTime::parse_iso("1983-04-12").unwrap();
assert!(full.is_full());
assert_eq!(full.to_iso(), "1983-04-12T00:00:00");
}
#[test]
fn offsets() {
assert_eq!(DateTime::parse_offset("+02:00"), Some(120));
assert_eq!(DateTime::parse_offset("-05:30"), Some(-330));
assert_eq!(DateTime::parse_offset("Z"), Some(0));
assert_eq!(DateTime::parse_offset("+0930"), Some(570));
assert_eq!(DateTime::parse_offset("+18:00"), Some(1080));
assert_eq!(DateTime::parse_offset("-18:00"), Some(-1080));
assert_eq!(DateTime::parse_offset("+18:01"), None);
assert_eq!(DateTime::parse_offset("-18:01"), None);
assert_eq!(DateTime::parse_offset(" : "), None);
assert_eq!(DateTime::parse_offset("+99:00"), None);
}
#[test]
fn extreme_offsets_do_not_overflow() {
// `DateTime` deserializes with public fields, so an offset can arrive without passing `valid()`
let d = DateTime { offset_minutes: Some(i16::MIN), ..DateTime::parse_iso("2024-06-01T13:45:59").unwrap() };
assert_eq!(d.to_iso(), "2024-06-01T13:45:59-546:08");
assert_eq!(d.valid(), None);
}
#[test]
fn unicode_offsets_are_rejected_without_panicking() {
for offset in ["+1€", "-1€", "+€1", "-€1"] {
assert_eq!(DateTime::parse_offset(offset), None);
let date = format!("2024-06-01T13:45:59{offset}");
let metadata = crate::Metadata { capture_time: DateTime::parse_iso("2024-06-01T13:45:59Z"), ..Default::default() };
let xmp = crate::write_xmp(&metadata).replace("2024-06-01T13:45:59+00:00", &date);
assert!(crate::parse_xmp(&xmp).unwrap().metadata.capture_time.is_none());
}
}
#[test]
fn iso_roundtrip() {
for s in ["2024-06-01T13:45:59", "2024-06-01T13:45:59.250+02:00", "1999-12-31T23:59:00-05:30", "2024-02-29T00:00:00Z"] {
let d = DateTime::parse_iso(s).unwrap();
let again = DateTime::parse_iso(&d.to_iso()).unwrap();
assert_eq!(d, again, "{s}");
}
assert_eq!(DateTime::parse_iso("2024-02-29T00:00:00Z").unwrap().to_iso(), "2024-02-29T00:00:00+00:00");
let d = DateTime::parse_iso("2019").unwrap();
assert_eq!((d.year, d.month, d.day), (2019, 1, 1));
assert_eq!(DateTime::parse_iso("2019-07-04T10:11").unwrap().minute, 11);
assert!(DateTime::parse_iso("nonsense").is_none());
assert!(DateTime::parse_iso("2019-07-04Tab:cd").is_none());
}
#[test]
fn iso_rejects_incomplete_or_unconsumed_clock_text() {
for value in [
"2026-01-01T",
"2026-01-01T12:34:56:99Z",
"2026-01-01T12:34:56.Z",
"2026-01-01T12:34:56.123junkZ",
"2026-01-01T12:34:56.1.2Z",
"2026-01-01T12:34:56.12 3Z",
"2026-01-01T12:34:56:99Z",
"2026-01-01T12::34Z",
] {
assert!(DateTime::parse_iso(value).is_none(), "accepted malformed timestamp {value:?}");
}
}
#[test]
fn iso_rejects_single_digit_clock_fields() {
for value in ["2024-06-01T1:02:03Z", "2024-06-01T01:2:03Z"] {
assert!(DateTime::parse_iso(value).is_none(), "accepted malformed timestamp {value:?}");
}
}
#[test]
fn iso_validates_clock_while_preserving_supported_forms() {
assert_eq!(DateTime::parse_iso("2019").unwrap().to_iso(), "2019");
assert_eq!(DateTime::parse_iso("2019-07").unwrap().to_iso(), "2019-07");
assert_eq!(DateTime::parse_iso("2019-07-04").unwrap().to_iso(), "2019-07-04T00:00:00");
assert_eq!(DateTime::parse_iso("2019-07-04T10:11").unwrap().to_iso(), "2019-07-04T10:11:00");
assert_eq!(DateTime::parse_iso("2019-07-04T10:11:12").unwrap().to_iso(), "2019-07-04T10:11:12");
assert_eq!(DateTime::parse_iso("2019-07-04T10:11:12Z").unwrap().offset_minutes, Some(0));
assert_eq!(DateTime::parse_iso("2019-07-04T10:11:12-05:30").unwrap().offset_minutes, Some(-330));
assert_eq!(DateTime::parse_iso("2019-07-04T10:11:12.123456Z").unwrap().millis, 123);
assert_eq!(DateTime::parse_iso(" 2019-07-04T10:11:12.5Z ").unwrap().millis, 500);
assert_eq!(DateTime::parse_iso("2019-07-04T10:11:60Z").unwrap().second, 60);
}
#[test]
fn unix() {
assert_eq!(DateTime::parse_iso("1970-01-01T00:00:00Z").unwrap().unix_seconds(), 0);
assert_eq!(DateTime::parse_iso("2000-03-01T00:00:00Z").unwrap().unix_seconds(), 951868800);
assert_eq!(DateTime::parse_iso("2000-03-01T02:00:00+02:00").unwrap().unix_seconds(), 951868800);
}
}