Files
craft-libs/crates/metadata/src/lib.rs
T
+10 3540cf0a7f craft-metadata: read the Extended XMP a JPEG links, from LightCraft (#1046)
* 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

* craft-metadata: read the Extended XMP a JPEG's standard packet links

A JPEG whose XMP is too large for one APP1 segment puts the rest in Extended
XMP chunks, linked by xmpNote:HasExtendedXMP. When the standard packet links
a GUID, only that GUID's chunks are read: they must be complete segments,
declare one full length of at most 16 MiB, number at most 4096, and cover
that length exactly once (Embedded::xmp_extended_error says why not).
parse_xmp_pair reads the two packets as one (standard properties win; arrays
and structs stated in both are combined), so extract and from_embedded now
see the extension's GPS, caption and other fields. embedded_xmp reports a
missing, malformed or unlinked extension as an EmbeddedXmpError that keeps
the standard packet, for a caller that must see all of a file's metadata.

An extension the standard packet does not link stays visible in
Embedded::xmp_extended as before, and is not merged.

Ported from storytold/lightcraft#2581, onto the current JPEG walker.

---------

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:06:06 -05:00

1062 lines
50 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.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
//! Photo metadata: EXIF, IPTC-IIM, XMP packets and GPX track logs, read into one [`Metadata`] model.
//!
//! One module per format:
//! - EXIF: [`read_exif`] reads a TIFF-structured Exif block, [`from_tiff`] an already parsed stream,
//! [`write_exif`] writes one. Maker notes supply the lens and, in [`focus`], where the camera focused.
//! [`tags`] lists every tag by name.
//! - IPTC-IIM: [`parse_iptc`] reads record 2 datasets.
//! - XMP: [`parse_xmp`] and [`write_xmp`] read and write packets; [`merge_xmp`] updates a packet
//! another program wrote without losing what it does not understand.
//! - GPX: [`parse_gpx`] reads track logs for geotagging by capture time.
//! - Containers: [`embedded`], [`jpeg_segments`], [`png_chunks`] and [`webp_chunks`] locate the Exif,
//! XMP, ICC and IPTC blocks of JPEG, PNG, WebP, TIFF-based, Fujifilm RAF, Canon CR3 ([`cr3`]) and
//! JPEG XL ([`jxl`]) files.
//! [`extract`] does all of the above for a whole file. [`mpf`] reads and writes the Multi-Picture
//! index of a JPEG that carries further images, such as a gain map.
//! - [`edit`]: byte-preserving Exif and XMP edits for export (Orientation, resolution, thumbnail).
//!
//! HEIF, PSD and MRW files are read by their own crates. Put the blocks they return into an
//! [`Embedded`] and call [`from_embedded`].
mod container;
pub mod cr3;
mod datetime;
pub mod edit;
mod exif;
pub mod focus;
mod focus_read;
mod fuji;
mod geom;
mod gpx;
mod iptc;
pub mod jxl;
pub mod mpf;
pub mod tags;
mod xmp;
mod xmp_merge;
pub use container::{Embedded, embedded, jpeg_segments, jpeg_segments_with_all_xmp, png_chunks, webp_chunks};
pub use datetime::{DatePrecision, DateTime};
pub use exif::{from_tiff, read_exif, strip_exif_header, try_read_exif, try_read_exif_with, write_exif};
pub use focus::FocusPoint;
pub use fuji::{FujiRecipe, FujiRow, might_hold_fuji_recipe, read_recipe_file, recipe_from_bytes, recipe_from_exif};
pub use geom::{Orientation, Point, Rect};
pub use gpx::{GpxError, Match, TrackPoint, Tracklog, parse_gpx};
pub use iptc::parse_iptc;
pub use tags::{TagRow, file_tag_rows, tag_rows};
pub use xmp::{
CRS_NS, XmpData, XmpError, XmpNamespace, XmpValue, XmpWrite, parse_gps_position, parse_xmp, parse_xmp_with, write_xmp, write_xmp_with,
};
pub use xmp_merge::{MergeRules, merge_xmp};
use serde::{Deserialize, Serialize};
/// GPS position (WGS 84). Latitude/longitude in signed decimal degrees (north / east positive).
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Gps {
pub latitude: f64,
pub longitude: f64,
/// Metres above sea level (negative below).
pub altitude: Option<f64>,
}
/// Exif `Flash` value decoded.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Flash {
pub fired: bool,
/// The raw Exif `Flash` bit field.
pub raw: u16,
}
/// A named region of interest on a photo (MWG Region Guidelines, `mwg-rs:Regions`): most often a face,
/// drawn by Lightroom, digiKam, Picasa or similar tools. Read only: [`write_xmp`] does not write regions.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Region {
/// Normalized to the image's full, oriented frame (`Metadata::width`/`height`), y-down, 0..1 on
/// both axes — MWG's own convention, and what `AppliedToDimensions` areas are converted to.
pub rect: Rect,
pub kind: RegionKind,
/// `mwg-rs:Name` (e.g. the person's name).
pub name: Option<String>,
pub description: Option<String>,
}
/// `mwg-rs:Type`. Unrecognised values are kept verbatim rather than dropped.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum RegionKind {
Face,
Pet,
Focus,
BarCode,
/// `mwg-rs:Type` was missing, empty, or a value we don't recognise.
Other(String),
}
/// What a photo catalogue shows or searches about a photo. All fields are optional; unknown = `None`/empty.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Metadata {
// camera
pub make: Option<String>,
pub model: Option<String>,
pub serial_number: Option<String>,
pub software: Option<String>,
// lens
pub lens_make: Option<String>,
pub lens_model: Option<String>,
pub lens_serial_number: Option<String>,
/// Min/max focal length (mm) and min/max f-number at those, as in Exif `LensSpecification`.
pub lens_spec: Option<[f64; 4]>,
// capture
pub capture_time: Option<DateTime>,
/// Seconds.
pub exposure_time: Option<f64>,
pub f_number: Option<f64>,
pub iso: Option<u32>,
/// Millimetres.
pub focal_length: Option<f64>,
pub focal_length_35mm: Option<f64>,
pub flash: Option<Flash>,
/// EV.
pub exposure_bias: Option<f64>,
/// Exif `ExposureProgram` code.
pub exposure_program: Option<u16>,
/// Exif `MeteringMode` code.
pub metering_mode: Option<u16>,
/// Exif `WhiteBalance` (0 auto, 1 manual).
pub white_balance: Option<u16>,
pub orientation: Option<Orientation>,
pub gps: Option<Gps>,
/// Pixel dimensions as recorded (not oriented).
pub width: Option<u32>,
pub height: Option<u32>,
// descriptive / user
pub artist: Option<String>,
pub copyright: Option<String>,
/// Copyright status (`xmpRights:Marked`): `true` = copyrighted, `false` = public domain, `None` = unknown.
pub copyright_marked: Option<bool>,
/// Rights usage terms (`xmpRights:UsageTerms`).
pub usage_terms: Option<String>,
/// Copyright info URL (`xmpRights:WebStatement`).
pub copyright_url: Option<String>,
pub title: Option<String>,
pub caption: Option<String>,
/// Accessibility text (`Iptc4xmpCore:AltTextAccessibility`).
pub alt_text: Option<String>,
/// Long accessibility description (`Iptc4xmpCore:ExtDescrAccessibility`).
pub extended_description: Option<String>,
/// Place within the city (`Iptc4xmpCore:Location`).
pub sublocation: Option<String>,
/// `photoshop:City`, `photoshop:State`, `photoshop:Country`.
pub city: Option<String>,
pub state: Option<String>,
pub country: Option<String>,
pub keywords: Vec<String>,
/// Hierarchical keywords, `|`-separated paths (`lr:hierarchicalSubject` convention).
pub hierarchical_keywords: Vec<String>,
/// -1 = rejected, 0 = unrated, 1..=5 stars.
pub rating: Option<i8>,
/// Colour label name (e.g. "Red").
pub label: Option<String>,
/// Face/pet/focus/barcode regions (`mwg-rs:Regions`), read from XMP only.
pub regions: Vec<Region>,
/// Where the camera focused, from its maker notes ([`focus`]; Sony only for now), on the upright photo.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub focus: Option<FocusPoint>,
/// Fujifilm in-camera film recipe from the MakerNote. Not written back.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fuji_recipe: Option<FujiRecipe>,
}
macro_rules! fill {
($self:ident, $o:ident, $($f:ident),*) => { $( if $self.$f.is_none() { $self.$f = $o.$f.clone(); } )* };
}
macro_rules! overlay {
($self:ident, $o:ident, $($f:ident),*) => { $( if $o.$f.is_some() { $self.$f = $o.$f.clone(); } )* };
}
/// How far (relative) a duration may be from an exact `1/n` second and still be shown as `1/n`:
/// absorbs decimal EXIF rationals such as `3333/10000` for 1/3 s without turning 0.3 s or
/// 1/2.8 s into a reciprocal they aren't.
pub const RECIPROCAL_TOLERANCE: f64 = 1e-3;
/// The one shutter-speed formatter for display (catalog `shutter`, Info panel, All Metadata):
/// a near-exact `1/n` second as `1/n` (`1/3`, `1/250`), anything else rounded to four
/// significant digits with trailing zeros dropped (`0.3`, `0.3571`, `2.5`, `30`). Display only:
/// export writes the exact duration. `None` for a time that isn't positive and finite.
pub fn format_exposure_time(t: f64) -> Option<String> {
if !t.is_finite() || t <= 0.0 {
return None;
}
let n = (1.0 / t).round();
if n >= 2.0 && n.is_finite() && (1.0 / (n * t) - 1.0).abs() <= RECIPROCAL_TOLERANCE {
return Some(format!("1/{n}"));
}
// four significant digits; whole seconds never lose digits
let decimals = (3 - t.log10().floor() as i32).max(0) as usize;
let text = format!("{t:.decimals$}");
Some(if text.contains('.') { text.trim_end_matches('0').trim_end_matches('.').to_string() } else { text })
}
impl Metadata {
/// Fill every empty field of `self` from `other`.
pub fn fill_missing(&mut self, other: &Metadata) {
fill!(
self,
other,
make,
model,
serial_number,
software,
lens_make,
lens_model,
lens_serial_number,
lens_spec,
capture_time,
exposure_time,
f_number,
iso,
focal_length,
focal_length_35mm,
flash,
exposure_bias,
exposure_program,
metering_mode,
white_balance,
orientation,
gps,
width,
height
);
self.fill_missing_descriptive(other);
}
/// Fill descriptive metadata separately from the camera and image properties.
fn fill_missing_descriptive(&mut self, other: &Metadata) {
fill!(
self,
other,
artist,
copyright,
copyright_marked,
usage_terms,
copyright_url,
title,
caption,
alt_text,
extended_description,
sublocation,
city,
state,
country,
rating,
label
);
if self.keywords.is_empty() {
self.keywords = other.keywords.clone();
}
if self.hierarchical_keywords.is_empty() {
self.hierarchical_keywords = other.hierarchical_keywords.clone();
}
if self.regions.is_empty() {
self.regions = other.regions.clone();
}
if self.focus.is_none() {
self.focus.clone_from(&other.focus);
}
if self.fuji_recipe.is_none() {
self.fuji_recipe.clone_from(&other.fuji_recipe);
}
}
/// Replace the user-editable fields (rating, label, title, caption, artist, copyright, keywords, GPS) with
/// `other`'s where `other` has them — the XMP-over-EXIF precedence rule.
pub fn overlay_user_fields(&mut self, other: &Metadata) {
overlay!(self, other, rating, label, title, caption, artist, copyright, copyright_marked, usage_terms, copyright_url, gps);
if !other.keywords.is_empty() {
self.keywords = other.keywords.clone();
}
if !other.hierarchical_keywords.is_empty() {
self.hierarchical_keywords = other.hierarchical_keywords.clone();
}
if !other.regions.is_empty() {
self.regions = other.regions.clone();
}
}
/// Exposure time formatted like cameras show it: `1/250`, `0.3571`, `2.5″` (see [`format_exposure_time`]).
pub fn exposure_display(&self) -> Option<String> {
let t = self.exposure_time?;
format_exposure_time(t).map(|text| if t >= 1.0 { format!("{text}″") } else { text })
}
/// Human name of the metering mode.
pub fn metering_name(&self) -> Option<&'static str> {
Some(match self.metering_mode? {
1 => "Average",
2 => "Center-weighted average",
3 => "Spot",
4 => "Multi-spot",
5 => "Pattern",
6 => "Partial",
255 => "Other",
_ => "Unknown",
})
}
/// Human name of the exposure program.
pub fn exposure_program_name(&self) -> Option<&'static str> {
Some(match self.exposure_program? {
1 => "Manual",
2 => "Program AE",
3 => "Aperture-priority AE",
4 => "Shutter speed priority AE",
5 => "Creative (slow speed)",
6 => "Action (high speed)",
7 => "Portrait",
8 => "Landscape",
9 => "Bulb",
_ => "Not defined",
})
}
}
/// Read everything understood from a whole file (JPEG, PNG, WebP, TIFF-based incl. DNG and most raws, RAF, CR3, JPEG XL).
/// Precedence: EXIF, then IPTC fills gaps, then XMP overrides user fields and fills the rest.
pub fn extract(bytes: &[u8]) -> Metadata {
from_embedded(&embedded(bytes))
}
/// [`extract`] for blocks that are already located, for example by a HEIF or PSD reader. Best effort:
/// a block that cannot be read counts as absent. A caller that must tell "no location" from
/// "unreadable" uses [`try_read_exif`] and [`parse_xmp`] on the blocks first.
pub fn from_embedded(e: &Embedded) -> Metadata {
let mut m = e.exif.as_deref().map(read_exif).unwrap_or_default();
let xmp = embedded_xmp(e).unwrap_or_else(|error| error.standard.map(|x| *x));
// the size the Exif states: a size only the XMP states may be the upright one, so it can't tell how the photo is stored
let stored = m.width.zip(m.height);
// and the orientation the Exif states: the photo is displayed by that alone, so an orientation only the XMP
// states ([`Metadata::fill_missing`] reports it below) can't tell how the photo is stored either
let exif_orientation = e.exif.as_deref().and_then(exif::ifd0_orientation);
if let Some(i) = e.iptc.as_deref() {
m.fill_missing(&parse_iptc(i));
}
if let Some(xmp) = xmp {
m.overlay_user_fields(&xmp.metadata);
m.fill_missing(&xmp.metadata);
// boxes another app drew on the photo as stored go where the upright photo has them
if let (Some(drawn_on), Some(orientation), Some(stored)) = (xmp.regions_drawn_on(), exif_orientation, stored) {
upright_regions(&mut m.regions, drawn_on, orientation, stored);
}
}
if let Some(exif) = e.exif.as_deref() {
m.fuji_recipe = fuji::recipe_from_exif(exif);
}
m
}
/// The XMP of `e` read as one packet: the standard packet, with the Extended XMP it links to
/// (`xmpNote:HasExtendedXMP`) merged in (see [`xmp::parse_xmp_pair`]). An error when the standard
/// packet is not XMP, when its link is malformed, when the linked extension is missing or
/// unreadable ([`Embedded::xmp_extended_error`]), or when there is an extension nothing links to:
/// a caller that must see all of a file's metadata (to prove it carries no location, say) refuses
/// the file then. [`from_embedded`] falls back to [`EmbeddedXmpError::standard`].
pub fn embedded_xmp(e: &Embedded) -> Result<Option<XmpData>, EmbeddedXmpError> {
let fail = |standard: Option<XmpData>, message: String| EmbeddedXmpError { standard: standard.map(Box::new), message };
let Some(packet) = e.xmp.as_deref() else {
return match (&e.xmp_extended, &e.xmp_extended_error) {
(None, None) => Ok(None),
_ => Err(fail(None, "Extended XMP is present without a standard packet".into())),
};
};
let parsed = parse_xmp(packet).map_err(|error| fail(None, format!("embedded XMP is invalid: {error}")))?;
let mut standard = parsed.clone();
standard.properties.remove("xmpNote:HasExtendedXMP");
standard.values.remove("xmpNote:HasExtendedXMP");
let reference = xmp::extended_xmp_reference(&parsed).map_err(|error| fail(Some(standard.clone()), error))?;
match (reference, e.xmp_extended.as_deref()) {
(Some(guid), Some(extended)) if e.xmp_extended_guid.as_deref() == Some(guid.as_str()) => xmp::parse_xmp_pair(packet, extended, &[])
.map(Some)
.map_err(|error| fail(Some(standard), format!("the linked Extended XMP is invalid: {error}"))),
(Some(_), _) => {
let why = e.xmp_extended_error.clone().unwrap_or_else(|| "the linked Extended XMP is missing".into());
Err(fail(Some(standard), why))
}
(None, Some(_)) => Err(fail(Some(standard), "Extended XMP is present but the standard packet does not link it".into())),
(None, None) => Ok(Some(standard)),
}
}
/// Why [`embedded_xmp`] could not read a file's XMP whole.
#[derive(Clone, Debug, PartialEq)]
pub struct EmbeddedXmpError {
/// The standard packet, when it could be read: what a best-effort reader uses.
pub standard: Option<Box<XmpData>>,
pub message: String,
}
impl std::fmt::Display for EmbeddedXmpError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.message)
}
}
impl std::error::Error for EmbeddedXmpError {}
/// Turn regions drawn on a photo as it is stored onto the upright photo. MWG gives each box as a fraction of
/// `AppliedToDimensions` (`drawn_on`); for a photo whose Exif `orientation` turns it a quarter turn, some apps give the
/// stored, sideways size there and boxes on the stored pixels to match (Lightroom says so per box with
/// `mwg-rs:Rotation` instead, which [`parse_xmp`] reads). When `drawn_on` has the shape of the photo as stored
/// (`stored`, before `orientation`) rather than the upright one, every box is turned with `orientation`. Returns
/// whether they were. A square photo or `drawn_on` looks the same both ways, and a half turn or a mirror doesn't
/// change the shape: their boxes are left as they are.
pub fn upright_regions(regions: &mut [Region], drawn_on: (f64, f64), orientation: Orientation, stored: (u32, u32)) -> bool {
let ((dw, dh), (sw, sh)) = (drawn_on, stored);
if regions.is_empty() || !orientation.swaps_axes() || sw == sh || dw == dh || (dw > dh) != (sw > sh) {
return false;
}
for r in regions.iter_mut() {
r.rect = orientation.map_norm_rect(r.rect);
}
true
}
/// A lens name from its focal and aperture range (Exif LensSpecification, or a Nikon maker note's Lens), written the
/// way Lightroom shows a lens it has no name for: `50.0 mm f/1.8`, `18.0-135.0 mm f/3.5-5.6`.
pub fn lens_spec_name(spec: [f64; 4]) -> Option<String> {
let [short, long, wide, narrow] = spec;
if ![short, long, wide, narrow].iter().all(|v| v.is_finite() && *v > 0.0) || long < short {
return None;
}
let focal = if (long - short).abs() < 0.05 { format!("{short:.1}") } else { format!("{short:.1}-{long:.1}") };
let aperture = if (narrow - wide).abs() < 0.05 { format!("{wide:.1}") } else { format!("{wide:.1}-{narrow:.1}") };
Some(format!("{focal} mm f/{aperture}"))
}
/// Whether `s` has the `<focal> mm f/<aperture>` shape written by [`lens_spec_name`].
pub fn is_lens_spec_name(s: &str) -> bool {
let Some((focal, aperture)) = s.trim().split_once(" mm f/") else { return false };
let is_num_or_range = |part: &str| {
let mut pieces = part.split('-');
let Some(first) = pieces.next() else { return false };
let second = pieces.next();
pieces.next().is_none()
&& first.parse::<f64>().is_ok_and(|v| v.is_finite() && v > 0.0)
&& second.is_none_or(|b| b.parse::<f64>().is_ok_and(|v| v.is_finite() && v > 0.0))
};
is_num_or_range(focal) && is_num_or_range(aperture)
}
/// Lenient number parsing used by XMP/IPTC: `"28/10"`, `"2.8"`, `"+0.7"`.
pub(crate) fn parse_number(s: &str) -> Option<f64> {
let s = s.trim();
if let Some((n, d)) = s.split_once('/') {
let (n, d) = (n.trim().parse::<f64>().ok()?, d.trim().parse::<f64>().ok()?);
return (d != 0.0).then_some(n / d).filter(|v| v.is_finite());
}
s.parse::<f64>().ok().filter(|v| v.is_finite())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lens_names_from_a_spec_read_like_lightroom() {
assert_eq!(lens_spec_name([50.0, 50.0, 1.8, 1.8]).as_deref(), Some("50.0 mm f/1.8"));
assert_eq!(lens_spec_name([18.0, 135.0, 3.5, 5.6]).as_deref(), Some("18.0-135.0 mm f/3.5-5.6"));
assert_eq!(lens_spec_name([24.0, 70.0, 2.8, 2.8]).as_deref(), Some("24.0-70.0 mm f/2.8"));
assert_eq!(lens_spec_name([70.0, 210.0, 4.0, 5.6]).as_deref(), Some("70.0-210.0 mm f/4.0-5.6"));
assert_eq!(lens_spec_name([0.0, 0.0, 0.0, 0.0]), None);
assert_eq!(lens_spec_name([200.0, 80.0, 2.8, 2.8]), None);
assert_eq!(lens_spec_name([f64::NAN, 50.0, 1.8, 1.8]), None);
}
#[test]
fn lens_spec_names_are_recognised() {
for spec in [[50.0, 50.0, 1.8, 1.8], [18.0, 135.0, 3.5, 5.6]] {
assert!(is_lens_spec_name(&lens_spec_name(spec).unwrap()));
}
assert!(is_lens_spec_name(" 24.0-70.0 mm f/2.8 "));
for not in ["EF 50mm f/1.8", "50 mm f/", "0 mm f/2.8", "50-60-70 mm f/2.8", "nan mm f/2", "50 mm f/1.8-"] {
assert!(!is_lens_spec_name(not), "{not}");
}
}
#[test]
fn from_embedded_preserves_exif_iptc_xmp_precedence() {
let exif = Metadata {
make: Some("Example".into()),
model: Some("Camera".into()),
lens_model: Some("35mm F2".into()),
artist: Some("EXIF artist".into()),
orientation: Some(Orientation::from_exif(6)),
exposure_time: Some(1.0 / 125.0),
..Default::default()
};
let packet = write_xmp(&Metadata {
artist: Some("XMP artist".into()),
caption: Some("XMP caption".into()),
rating: Some(4),
keywords: vec!["xmp-keyword".into()],
..Default::default()
});
let mut iptc = Vec::new();
for (dataset, value) in [(5u8, "IPTC title"), (120u8, "IPTC caption")] {
iptc.extend_from_slice(&[0x1c, 2, dataset]);
iptc.extend_from_slice(&u16::try_from(value.len()).unwrap().to_be_bytes());
iptc.extend_from_slice(value.as_bytes());
}
let blocks = Embedded { exif: Some(write_exif(&exif)), iptc: Some(iptc), xmp: Some(packet.clone()), ..Default::default() };
let parsed = from_embedded(&blocks);
assert_eq!(parsed.make, exif.make);
assert_eq!(parsed.model, exif.model);
assert_eq!(parsed.lens_model, exif.lens_model);
assert_eq!(parsed.orientation, exif.orientation);
assert_eq!(parsed.exposure_time, exif.exposure_time);
assert_eq!(parsed.title.as_deref(), Some("IPTC title"));
assert_eq!(parsed.caption.as_deref(), Some("XMP caption"));
assert_eq!(parsed.artist.as_deref(), Some("XMP artist"));
assert_eq!(parsed.rating, Some(4));
assert_eq!(parsed.keywords, vec!["xmp-keyword"]);
assert_eq!(blocks.xmp.as_deref(), Some(packet.as_str()));
}
#[test]
fn from_embedded_ignores_damaged_blocks() {
let blocks = Embedded { exif: Some(vec![1, 2, 3]), iptc: Some(vec![0x1c, 2]), xmp: Some("<broken".into()), ..Default::default() };
assert_eq!(from_embedded(&blocks), Metadata::default());
}
#[test]
fn numbers() {
assert_eq!(parse_number("28/10"), Some(2.8));
assert_eq!(parse_number(" +0.7 "), Some(0.7));
assert_eq!(parse_number("1/0"), None);
assert_eq!(parse_number("abc"), None);
assert_eq!(parse_number("inf"), None);
}
#[test]
fn exposure_time_formats_reciprocals_and_significant_digits() {
for (t, want) in [
(1.0 / 3.0, "1/3"),
(1.0 / 250.0, "1/250"),
(1.0 / 8000.0, "1/8000"),
(0.5, "1/2"),
(0.3, "0.3"),
(0.4, "0.4"),
(1.0 / 2.8, "0.3571"),
(0.625, "0.625"),
(0.003, "0.003"),
(1.0, "1"),
(1.3, "1.3"),
(2.5, "2.5"),
(30.0, "30"),
(9.99996, "10"),
(1800.0, "1800"),
] {
assert_eq!(format_exposure_time(t).as_deref(), Some(want), "{t}");
}
for t in [0.0, -1.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
assert_eq!(format_exposure_time(t), None, "{t}");
}
}
#[test]
fn exposure_time_reciprocal_tolerance() {
// a camera's decimal rational for 1/3 s, and times just inside the tolerance either side
assert_eq!(format_exposure_time(3333.0 / 10000.0).as_deref(), Some("1/3"));
// a microsecond-level rational error still reads as 1/8000; above ~1/1000 s every time is
// within the tolerance of some 1/n, which is how cameras show those speeds anyway
assert_eq!(format_exposure_time(1.0 / 8000.0 * (1.0 + 1e-5)).as_deref(), Some("1/8000"));
for n in [3.0, 10.0, 250.0] {
let inside = 0.9 * RECIPROCAL_TOLERANCE;
let outside = 1.1 * RECIPROCAL_TOLERANCE;
let want = format!("1/{n}");
assert_eq!(format_exposure_time(1.0 / n * (1.0 + inside)).as_deref(), Some(want.as_str()), "{n} +inside");
assert_eq!(format_exposure_time(1.0 / n * (1.0 - inside)).as_deref(), Some(want.as_str()), "{n} -inside");
assert_ne!(format_exposure_time(1.0 / n * (1.0 + outside)).as_deref(), Some(want.as_str()), "{n} +outside");
assert_ne!(format_exposure_time(1.0 / n * (1.0 - outside)).as_deref(), Some(want.as_str()), "{n} -outside");
}
}
#[test]
fn exposure_display() {
let mut m = Metadata { exposure_time: Some(1.0 / 250.0), ..Default::default() };
assert_eq!(m.exposure_display().as_deref(), Some("1/250"));
m.exposure_time = Some(0.5);
assert_eq!(m.exposure_display().as_deref(), Some("1/2"));
m.exposure_time = Some(0.3);
assert_eq!(m.exposure_display().as_deref(), Some("0.3"));
m.exposure_time = Some(2.0);
assert_eq!(m.exposure_display().as_deref(), Some("2″"));
m.exposure_time = Some(0.0);
assert_eq!(m.exposure_display(), None);
}
#[test]
fn merge_rules() {
let mut a = Metadata { make: Some("A".into()), title: Some("Original".into()), rating: Some(1), ..Default::default() };
let b = Metadata {
make: Some("B".into()),
model: Some("M".into()),
title: Some("Replacement".into()),
caption: Some("Caption".into()),
rating: Some(4),
keywords: vec!["k".into()],
..Default::default()
};
a.fill_missing(&b);
assert_eq!(a.make.as_deref(), Some("A"));
assert_eq!(a.model.as_deref(), Some("M"));
assert_eq!(a.title.as_deref(), Some("Original"));
assert_eq!(a.caption.as_deref(), Some("Caption"));
assert_eq!(a.keywords, vec!["k".to_string()]);
assert_eq!(a.rating, Some(1));
a.overlay_user_fields(&b);
assert_eq!(a.rating, Some(4));
assert_eq!(a.make.as_deref(), Some("A"));
assert_eq!(a.keywords, vec!["k".to_string()]);
}
#[test]
fn names() {
let m = Metadata { metering_mode: Some(5), exposure_program: Some(3), ..Default::default() };
assert_eq!(m.metering_name(), Some("Pattern"));
assert_eq!(m.exposure_program_name(), Some("Aperture-priority AE"));
}
/// An MWG regions packet with one face, its box centred at (0.25, 0.4) and 0.1 × 0.15 of `AppliedToDimensions`
/// `w` × `h`; `extra` goes on the region (e.g. Lightroom's `mwg-rs:Rotation`).
fn regions_packet(w: u32, h: u32, extra: &str) -> String {
format!(
r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about="" xmlns:mwg-rs="http://www.metadataworkinggroup.com/schemas/regions/"
xmlns:stArea="http://ns.adobe.com/xmp/sType/Area#" xmlns:stDim="http://ns.adobe.com/xap/1.0/sType/Dimensions#">
<mwg-rs:Regions rdf:parseType="Resource">
<mwg-rs:AppliedToDimensions rdf:parseType="Resource" stDim:w="{w}" stDim:h="{h}" stDim:unit="pixel"/>
<mwg-rs:RegionList><rdf:Bag>
<rdf:li rdf:parseType="Resource" mwg-rs:Name="Hannah" mwg-rs:Type="Face"{extra}>
<mwg-rs:Area rdf:parseType="Resource" stArea:x="0.25" stArea:y="0.4" stArea:w="0.1" stArea:h="0.15" stArea:unit="normalized"/>
</rdf:li>
</rdf:Bag></mwg-rs:RegionList>
</mwg-rs:Regions>
</rdf:Description></rdf:RDF></x:xmpmeta>"#
)
}
fn near(r: Rect, b: [f64; 4]) -> bool {
[r.x0 - b[0], r.y0 - b[1], r.x1 - b[2], r.y1 - b[3]].iter().all(|d| d.abs() < 1e-9)
}
/// The face's box as drawn: on the photo as stored, or on the upright one by apps that draw there.
const DRAWN: [f64; 4] = [0.2, 0.325, 0.3, 0.475];
/// The same box on the photo turned upright by Exif orientation 6 (a quarter turn clockwise).
const TURNED_6: [f64; 4] = [0.525, 0.2, 0.675, 0.3];
#[test]
fn boxes_drawn_on_the_stored_photo_turn_with_its_orientation() {
let region = || Region {
rect: Rect { x0: DRAWN[0], y0: DRAWN[1], x1: DRAWN[2], y1: DRAWN[3] },
kind: RegionKind::Face,
name: None,
description: None,
};
let turned = |o: Orientation, drawn_on: (f64, f64), stored: (u32, u32)| {
let mut regions = [region()];
let did = upright_regions(&mut regions, drawn_on, o, stored);
(did, regions[0].rect)
};
// drawn on the 6000 × 4000 photo as stored: every quarter turn and its mirror image land elsewhere
for (o, want) in [
(Orientation::Rotate90, TURNED_6),
(Orientation::Rotate270, [0.325, 0.7, 0.475, 0.8]),
(Orientation::Transpose, [0.325, 0.2, 0.475, 0.3]),
(Orientation::Transverse, [0.525, 0.7, 0.675, 0.8]),
] {
let (did, r) = turned(o, (6000.0, 4000.0), (6000, 4000));
assert!(did && near(r, want), "{o:?}: {r:?}");
// a smaller copy of the photo as stored is the same frame
assert!(near(turned(o, (1500.0, 1000.0), (6000, 4000)).1, want), "{o:?} on a smaller size");
}
// drawn on the upright photo (MWG's own frame), or where the shape can't tell the two apart: as they are
for (o, drawn_on, stored) in [
(Orientation::Rotate90, (4000.0, 6000.0), (6000, 4000)),
(Orientation::Rotate90, (4000.0, 4000.0), (6000, 4000)),
(Orientation::Rotate90, (4000.0, 4000.0), (4000, 4000)),
(Orientation::Rotate180, (6000.0, 4000.0), (6000, 4000)),
(Orientation::FlipH, (6000.0, 4000.0), (6000, 4000)),
(Orientation::Normal, (6000.0, 4000.0), (6000, 4000)),
] {
let (did, r) = turned(o, drawn_on, stored);
assert!(!did && near(r, DRAWN), "{o:?} drawn on {drawn_on:?} of {stored:?}: {r:?}");
}
}
/// An app that doesn't say how the photo was turned (no `mwg-rs:Rotation`) drew the faces of a photo
/// with Exif orientation 6 on its stored pixels, with the stored 6000 × 4000 as `AppliedToDimensions`. The boxes
/// land on the faces of the upright photo, not where the stored photo had them.
#[test]
fn regions_drawn_on_the_stored_photo_are_read_onto_the_upright_one() {
let jpeg = |packet: &str| {
let mut j = vec![0xff, 0xd8];
let mut seg = |payload: Vec<u8>| {
j.extend([0xff, 0xe1]);
j.extend(u16::try_from(payload.len() + 2).unwrap().to_be_bytes());
j.extend(payload);
};
// orientation 6, 6000 × 4000 as stored
seg([b"Exif\0\0".as_slice(), &crate::exif::tests::sample_exif(craft_tiff::ByteOrder::Little)].concat());
seg([b"http://ns.adobe.com/xap/1.0/\0".as_slice(), packet.as_bytes()].concat());
j.extend([0xff, 0xd9]);
j
};
let read = |packet: &str| {
let m = extract(&jpeg(packet));
assert_eq!((m.orientation, m.width, m.height), (Some(Orientation::Rotate90), Some(6000), Some(4000)));
assert_eq!(from_embedded(&embedded(&jpeg(packet))).regions, m.regions);
assert_eq!(m.regions.len(), 1);
m.regions[0].rect
};
let stored = read(&regions_packet(6000, 4000, ""));
assert!(near(stored, TURNED_6), "drawn on the photo as stored: {stored:?}");
let upright = read(&regions_packet(4000, 6000, ""));
assert!(near(upright, DRAWN), "drawn on the upright photo, MWG's way: {upright:?}");
// Lightroom's boxes say how the photo was turned: turned once, by that
let lightroom = read(&regions_packet(6000, 4000, r#" mwg-rs:Rotation="-1.57080""#));
assert!(near(lightroom, TURNED_6), "Lightroom's −π/2: {lightroom:?}");
}
/// A size only the XMP states can't tell how the photo is stored: `tiff:ImageWidth` and `tiff:ImageLength` of the
/// upright photo have the shape of boxes drawn on it upright, and those must not be turned.
#[test]
fn a_size_only_the_xmp_states_turns_no_regions() {
// orientation 6, no pixel size
let exif = write_exif(&Metadata { orientation: Some(Orientation::Rotate90), ..Default::default() });
let exif = strip_exif_header(&exif);
let packet = regions_packet(4000, 6000, "")
.replace(r#"rdf:about="""#, r#"rdf:about="" xmlns:tiff="http://ns.adobe.com/tiff/1.0/" tiff:ImageWidth="4000" tiff:ImageLength="6000""#);
let mut j = vec![0xff, 0xd8];
let mut seg = |payload: Vec<u8>| {
j.extend([0xff, 0xe1]);
j.extend(u16::try_from(payload.len() + 2).unwrap().to_be_bytes());
j.extend(payload);
};
seg([b"Exif\0\0".as_slice(), exif].concat());
seg([b"http://ns.adobe.com/xap/1.0/\0".as_slice(), packet.as_bytes()].concat());
j.extend([0xff, 0xd9]);
let m = extract(&j);
assert_eq!((m.orientation, m.width, m.height), (Some(Orientation::Rotate90), Some(4000), Some(6000)));
assert_eq!(m.regions.len(), 1);
assert!(near(m.regions[0].rect, DRAWN), "{:?}", m.regions[0].rect);
assert_eq!(from_embedded(&embedded(&j)).regions, m.regions);
// a TIFF-based file: the whole file is the Exif block, with the XMP in its first IFD
let mut ifd0 = craft_tiff::IfdBuilder::new();
ifd0.set(craft_tiff::tags::ORIENTATION, craft_tiff::Value::Short(vec![6]));
ifd0.set(craft_tiff::tags::XMP, craft_tiff::Value::Byte(packet.into_bytes()));
let m = extract(&craft_tiff::TiffWriter::default().write(&[ifd0]).unwrap());
assert_eq!((m.orientation, m.width, m.height), (Some(Orientation::Rotate90), Some(4000), Some(6000)));
assert!(near(m.regions[0].rect, DRAWN), "TIFF: {:?}", m.regions[0].rect);
}
/// A photo whose Exif has a size but no orientation, and whose XMP says `tiff:Orientation=6`: a viewer
/// displays it by the Exif alone, so it stays as stored, and boxes drawn on that stored frame are right as
/// written. Turning them by an orientation only the XMP states put them where the photo isn't.
#[test]
fn an_orientation_only_the_xmp_states_turns_no_regions() {
// 6000 × 4000 in the Exif, and no orientation
let size_ifd = || {
let mut sub = craft_tiff::IfdBuilder::new();
sub.set(craft_tiff::tags::PIXEL_X_DIMENSION, craft_tiff::Value::Long(vec![6000]));
sub.set(craft_tiff::tags::PIXEL_Y_DIMENSION, craft_tiff::Value::Long(vec![4000]));
sub
};
let mut exif_ifd0 = craft_tiff::IfdBuilder::new();
exif_ifd0.set_child(craft_tiff::tags::EXIF_IFD, size_ifd());
let exif = craft_tiff::TiffWriter::new(craft_tiff::ByteOrder::Little, false).write(&[exif_ifd0]).unwrap();
let packet = regions_packet(6000, 4000, "")
.replace(r#"rdf:about="""#, r#"rdf:about="" xmlns:tiff="http://ns.adobe.com/tiff/1.0/" tiff:Orientation="6""#);
let mut j = vec![0xff, 0xd8];
let mut seg = |payload: Vec<u8>| {
j.extend([0xff, 0xe1]);
j.extend(u16::try_from(payload.len() + 2).unwrap().to_be_bytes());
j.extend(payload);
};
seg([b"Exif\0\0".as_slice(), &exif].concat());
seg([b"http://ns.adobe.com/xap/1.0/\0".as_slice(), packet.as_bytes()].concat());
j.extend([0xff, 0xd9]);
let m = extract(&j);
assert_eq!((m.width, m.height), (Some(6000), Some(4000)));
assert_eq!(m.orientation, Some(Orientation::Rotate90), "the XMP's orientation is still reported");
assert_eq!(m.regions.len(), 1);
assert!(near(m.regions[0].rect, DRAWN), "turned by an orientation the Exif doesn't state: {:?}", m.regions[0].rect);
assert_eq!(from_embedded(&embedded(&j)).regions, m.regions);
// a TIFF-based file: the first IFD has no orientation, its XMP (tag 700) has
let mut ifd0 = craft_tiff::IfdBuilder::new();
ifd0.set(craft_tiff::tags::XMP, craft_tiff::Value::Byte(packet.into_bytes()));
ifd0.set_child(craft_tiff::tags::EXIF_IFD, size_ifd());
let m = extract(&craft_tiff::TiffWriter::default().write(&[ifd0]).unwrap());
assert_eq!(m.orientation, Some(Orientation::Rotate90));
assert!(near(m.regions[0].rect, DRAWN), "TIFF: {:?}", m.regions[0].rect);
}
const JPEG_XMP_SIG: &[u8] = b"http://ns.adobe.com/xap/1.0/\0";
const JPEG_XMP_EXT_SIG: &[u8] = b"http://ns.adobe.com/xmp/extension/\0";
fn jpeg_segment(marker: u8, payload: &[u8]) -> Vec<u8> {
let len = u16::try_from(payload.len() + 2).expect("test APP1 payload fits u16");
let mut segment = vec![0xff, marker];
segment.extend_from_slice(&len.to_be_bytes());
segment.extend_from_slice(payload);
segment
}
fn xmp_jpeg(standard: &str, guid: &str, extension: &str, chunks: &[(u32, &[u8])]) -> Vec<u8> {
let mut jpeg = vec![0xff, 0xd8];
let mut packet = JPEG_XMP_SIG.to_vec();
packet.extend_from_slice(standard.as_bytes());
jpeg.extend(jpeg_segment(0xe1, &packet));
for (offset, data) in chunks {
let mut payload = JPEG_XMP_EXT_SIG.to_vec();
payload.extend_from_slice(guid.as_bytes());
payload.extend_from_slice(&(extension.len() as u32).to_be_bytes());
payload.extend_from_slice(&offset.to_be_bytes());
payload.extend_from_slice(data);
jpeg.extend(jpeg_segment(0xe1, &payload));
}
jpeg
}
fn append_extended_chunk(jpeg: &mut Vec<u8>, guid: &str, total_length: u32, offset: u32, data: &[u8]) {
let mut payload = JPEG_XMP_EXT_SIG.to_vec();
payload.extend_from_slice(guid.as_bytes());
payload.extend_from_slice(&total_length.to_be_bytes());
payload.extend_from_slice(&offset.to_be_bytes());
payload.extend_from_slice(data);
jpeg.extend(jpeg_segment(0xe1, &payload));
}
fn xmp_document(body: &str) -> String {
format!(
r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description rdf:about="" {body}></rdf:Description></rdf:RDF></x:xmpmeta>"#
)
}
fn referenced_standard(guid: &str, extra: &str) -> String {
xmp_document(&format!(r#"xmlns:reference="http://ns.adobe.com/xmp/note/" reference:HasExtendedXMP="{guid}" {extra}"#))
}
fn split_chunks(packet: &str) -> [(u32, &[u8]); 2] {
let split = packet.len() / 2;
let (first, second) = packet.as_bytes().split_at(split);
[(split as u32, second), (0, first)]
}
fn raf_with_jpeg(jpeg: &[u8]) -> Vec<u8> {
let offset = 120u32;
let length = u32::try_from(jpeg.len()).expect("test JPEG fits RAF length");
let mut raf = b"FUJIFILMCCD-RAW 0201FF000000TEST".to_vec();
raf.resize(offset as usize, 0);
raf[84..88].copy_from_slice(&offset.to_be_bytes());
raf[88..92].copy_from_slice(&length.to_be_bytes());
raf.extend_from_slice(jpeg);
raf
}
#[test]
fn extended_xmp_gps_reaches_extractor() {
let guid = "A1234567890123456789012345678901";
let extension = xmp_document(r#"xmlns:exif="http://ns.adobe.com/exif/1.0/" exif:GPSLatitude="45,0N" exif:GPSLongitude="7,0E""#);
let standard = referenced_standard(guid, "");
let jpeg = xmp_jpeg(&standard, guid, &extension, &split_chunks(&extension));
let gps = extract(&jpeg).gps.expect("the extension GPS reaches extraction");
assert_eq!((gps.latitude, gps.longitude), (45.0, 7.0));
}
#[test]
fn extended_xmp_caption_reaches_extractor() {
let guid = "B1234567890123456789012345678901";
let caption = "multi-chunk caption ".repeat(160);
let extension = xmp_document(&format!(r#"xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:description>{caption}</dc:description>"#));
let standard = referenced_standard(guid, "");
let jpeg = xmp_jpeg(&standard, guid, &extension, &split_chunks(&extension));
assert_eq!(extract(&jpeg).caption.as_deref(), Some(caption.trim()));
}
#[test]
fn extended_xmp_split_gps_properties_are_combined() {
let guid = "C1234567890123456789012345678901";
let standard = xmp_document(&format!(
r#"xmlns:note="http://ns.adobe.com/xmp/note/" xmlns:exif="http://ns.adobe.com/exif/1.0/" note:HasExtendedXMP="{guid}" exif:GPSLatitude="45,0N""#
));
let extension = xmp_document(r#"xmlns:exif="http://ns.adobe.com/exif/1.0/" exif:GPSLongitude="7,0E""#);
let jpeg = xmp_jpeg(&standard, guid, &extension, &split_chunks(&extension));
let gps = extract(&jpeg).gps.expect("GPS properties across packets compose");
assert_eq!((gps.latitude, gps.longitude), (45.0, 7.0));
}
#[test]
fn extended_xmp_structured_values_are_composed() {
let guid = "F1234567890123456789012345678901";
let standard = xmp_document(&format!(
r#"xmlns:note="http://ns.adobe.com/xmp/note/" xmlns:exif="http://ns.adobe.com/exif/1.0/" note:HasExtendedXMP="{guid}"><exif:Flash rdf:parseType="Resource"><exif:Fired>True</exif:Fired></exif:Flash>"#
));
let extension =
xmp_document(r#"xmlns:exif="http://ns.adobe.com/exif/1.0/"><exif:Flash rdf:parseType="Resource"><exif:Mode>2</exif:Mode></exif:Flash>"#);
let jpeg = xmp_jpeg(&standard, guid, &extension, &split_chunks(&extension));
assert_eq!(extract(&jpeg).flash.map(|flash| flash.raw), Some(17));
}
#[test]
fn extended_xmp_ignores_malformed_chunks_for_unreferenced_guids() {
let guid = "A1234567890123456789012345678901";
let other_guid = "B1234567890123456789012345678901";
let extension = xmp_document(r#"xmlns:dc="http://purl.org/dc/elements/1.1/" dc:title="Referenced title""#);
let mut jpeg = xmp_jpeg(&referenced_standard(guid, ""), guid, &extension, &split_chunks(&extension));
let mut malformed = JPEG_XMP_EXT_SIG.to_vec();
malformed.extend_from_slice(other_guid.as_bytes());
jpeg.extend(jpeg_segment(0xe1, &malformed));
assert_eq!(extract(&jpeg).title.as_deref(), Some("Referenced title"));
assert_eq!(embedded_xmp(&embedded(&jpeg)).unwrap().unwrap().metadata.title.as_deref(), Some("Referenced title"));
}
#[test]
fn extended_xmp_ignores_chunk_limit_for_unreferenced_guids() {
let guid = "C1234567890123456789012345678901";
let other_guid = "D1234567890123456789012345678901";
let extension = xmp_document(r#"xmlns:dc="http://purl.org/dc/elements/1.1/" dc:title="Referenced title""#);
let mut jpeg = xmp_jpeg(&referenced_standard(guid, ""), guid, &extension, &split_chunks(&extension));
for _ in 0..4097 {
append_extended_chunk(&mut jpeg, other_guid, 1, 0, &[]);
}
assert_eq!(embedded_xmp(&embedded(&jpeg)).unwrap().unwrap().metadata.title.as_deref(), Some("Referenced title"));
}
#[test]
fn extended_xmp_ignores_received_byte_limit_for_unreferenced_guids() {
let guid = "E1234567890123456789012345678901";
let other_guid = "F1234567890123456789012345678901";
let extension = xmp_document(r#"xmlns:dc="http://purl.org/dc/elements/1.1/" dc:title="Referenced title""#);
let mut jpeg = xmp_jpeg(&referenced_standard(guid, ""), guid, &extension, &split_chunks(&extension));
let data = vec![0; 65_200];
for _ in 0..258 {
append_extended_chunk(&mut jpeg, other_guid, 65_200, 0, &data);
}
assert_eq!(embedded_xmp(&embedded(&jpeg)).unwrap().unwrap().metadata.title.as_deref(), Some("Referenced title"));
}
#[test]
fn extended_xmp_rejects_mismatched_guid_and_bad_coverage() {
let guid = "D1234567890123456789012345678901";
let extension = xmp_document(r#"xmlns:dc="http://purl.org/dc/elements/1.1/" dc:title="Extension title""#);
let standard = referenced_standard(guid, r#"xmlns:dc="http://purl.org/dc/elements/1.1/" dc:title="Standard title""#);
let bytes = extension.as_bytes();
let middle = bytes.len() / 2;
let inconsistent_lengths = {
let mut jpeg = vec![0xff, 0xd8];
let mut standard_payload = JPEG_XMP_SIG.to_vec();
standard_payload.extend_from_slice(standard.as_bytes());
jpeg.extend(jpeg_segment(0xe1, &standard_payload));
for (offset, declared, part) in
[(0u32, bytes.len() as u32, &bytes[..middle]), (middle as u32, (bytes.len() + 1) as u32, &bytes[middle..])]
{
let mut payload = JPEG_XMP_EXT_SIG.to_vec();
payload.extend_from_slice(guid.as_bytes());
payload.extend_from_slice(&declared.to_be_bytes());
payload.extend_from_slice(&offset.to_be_bytes());
payload.extend_from_slice(part);
jpeg.extend(jpeg_segment(0xe1, &payload));
}
jpeg
};
let oversized_length = {
let mut jpeg = vec![0xff, 0xd8];
let mut standard_payload = JPEG_XMP_SIG.to_vec();
standard_payload.extend_from_slice(standard.as_bytes());
jpeg.extend(jpeg_segment(0xe1, &standard_payload));
let mut payload = JPEG_XMP_EXT_SIG.to_vec();
payload.extend_from_slice(guid.as_bytes());
payload.extend_from_slice(&(16u32 * 1024 * 1024 + 1).to_be_bytes());
payload.extend_from_slice(&0u32.to_be_bytes());
payload.extend_from_slice(bytes);
jpeg.extend(jpeg_segment(0xe1, &payload));
jpeg
};
let foreign_namespace_reference = {
let foreign_standard = xmp_document(&format!(
r#"xmlns:note="urn:example:foreign-note" note:HasExtendedXMP="{guid}" xmlns:dc="http://purl.org/dc/elements/1.1/" dc:title="Standard title""#
));
xmp_jpeg(&foreign_standard, guid, &extension, &split_chunks(&extension))
};
let mut truncated_segment = xmp_jpeg(&standard, guid, &extension, &split_chunks(&extension));
truncated_segment.pop();
let cases = [
xmp_jpeg(&standard, "E1234567890123456789012345678901", &extension, &split_chunks(&extension)),
xmp_jpeg(&standard, guid, &extension, &[(0, &bytes[..middle]), ((middle + 1) as u32, &bytes[middle..])]),
xmp_jpeg(&standard, guid, &extension, &[(0, &bytes[..middle]), ((middle - 1) as u32, &bytes[middle..])]),
xmp_jpeg(&standard, guid, &extension, &[(0, bytes), (0, bytes)]),
xmp_jpeg(&standard, guid, &extension, &[(0, &bytes[..middle])]),
inconsistent_lengths,
oversized_length,
truncated_segment,
foreign_namespace_reference,
];
for (index, jpeg) in cases.iter().enumerate() {
// the last case links nothing (a foreign HasExtendedXMP): its chunks stay visible as an unlinked
// extension, as before, and embedded_xmp still refuses it below
if index + 1 < cases.len() {
assert!(embedded(jpeg).xmp_extended.is_none(), "malformed extension case {index} was exposed");
}
assert_eq!(extract(jpeg).title.as_deref(), Some("Standard title"), "standard XMP remains readable");
assert!(embedded_xmp(&embedded(jpeg)).is_err(), "embedded_xmp must reject malformed extension case {index}");
let error = embedded_xmp(&embedded(jpeg)).expect_err("invalid referenced extension is reported");
assert_eq!(error.standard.as_deref().and_then(|xmp| xmp.metadata.title.as_deref()), Some("Standard title"));
}
}
#[test]
fn standard_only_jpeg_xmp_still_extracts() {
let standard = xmp_document(r#"xmlns:dc="http://purl.org/dc/elements/1.1/" dc:title="Standard-only title""#);
let mut jpeg = vec![0xff, 0xd8];
let mut packet = JPEG_XMP_SIG.to_vec();
packet.extend_from_slice(standard.as_bytes());
jpeg.extend(jpeg_segment(0xe1, &packet));
assert_eq!(extract(&jpeg).title.as_deref(), Some("Standard-only title"));
}
#[test]
fn embedded_xmp_rejects_malformed_xmp_but_extract_remains_best_effort() {
let mut jpeg = vec![0xff, 0xd8];
let mut packet = JPEG_XMP_SIG.to_vec();
packet.extend_from_slice(b"<x:xmpmeta><broken");
jpeg.extend(jpeg_segment(0xe1, &packet));
assert_eq!(extract(&jpeg), Metadata::default(), "best-effort extraction ignores invalid XMP");
assert!(embedded_xmp(&embedded(&jpeg)).is_err());
}
#[test]
fn raf_extended_xmp_errors_remain_fail_closed() {
let guid = "A1234567890123456789012345678901";
let extension = xmp_document(r#"xmlns:dc="http://purl.org/dc/elements/1.1/" dc:title="Extended title""#);
let standard = xmp_document(r#"xmlns:dc="http://purl.org/dc/elements/1.1/" dc:title="Standard title""#);
let unreferenced_extension = raf_with_jpeg(&xmp_jpeg(&standard, guid, &extension, &split_chunks(&extension)));
let missing_extension = raf_with_jpeg(&xmp_jpeg(&referenced_standard(guid, ""), guid, &extension, &[]));
assert_eq!(extract(&unreferenced_extension).title.as_deref(), Some("Standard title"), "best-effort reads retain standard XMP");
for (name, raf) in [("unreferenced extension", unreferenced_extension), ("missing referenced extension", missing_extension)] {
assert!(embedded_xmp(&embedded(&raf)).is_err(), "{name} must retain the JPEG validation error");
assert!(embedded_xmp(&embedded(&raf)).is_err(), "{name} must fail closed");
}
let valid = raf_with_jpeg(&xmp_jpeg(&referenced_standard(guid, ""), guid, &extension, &split_chunks(&extension)));
assert_eq!(extract(&valid).title.as_deref(), Some("Extended title"));
}
}