craft-metadata: byte-preserving Exif and XMP edits for export, from PhotoCraft (#1022)

* 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).

---------

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>
This commit is contained in:
Dylan M. Taylor
2026-11-11 20:38:50 -05:00
committed by GitHub
co-authored by radialmonster Sebastian Berschneider FredApps Michael Anderson Ilya Dorman Clyde C Brown MohammadTeimori vangel76
parent 45177e282e
commit df469bb2bd
11 changed files with 2217 additions and 0 deletions
+1
View File
@@ -23,6 +23,7 @@ are one crate.
| `embedded(&[u8]) -> Embedded` | Sniffs JPEG, PNG, WebP, TIFF-based files, Fujifilm RAF, Canon CR3 and JPEG XL containers and returns the Exif, XMP (and extended XMP), ICC and IPTC blocks. `jpeg_segments`, `png_chunks`, `webp_chunks` are the walkers. |
| `cr3::parse_cr3` | The Canon CR3 container: `CMT1`…`CMT4`, the XMP `uuid` box, the thumbnail, the tracks. |
| `jxl::metadata` | The `Exif` and `xml ` boxes of a JPEG XL container, plain or Brotli-wrapped in `brob`. |
| `edit::upright_exif`, `edit::upright_xmp`, `edit::exif_with_resolution`, `edit::xmp_with_resolution`, `edit::export_exif`, `edit::export_xmp` | Byte-preserving edits for export: Orientation set to 1, the resolution set (in place, malformed entries removed, or appended when missing), the source thumbnail dropped. Every other byte stays. `edit::exif_orientation`, `edit::exif_resolution`, `edit::xmp_resolution`, `edit::photoshop_resolution` and `edit::exif_color_space` read the same values with the editors' strict rules. |
| `mpf::images`, `mpf::write` | The Multi-Picture Format index (CIPA DC-007) of a JPEG: the images stored after the primary one (a gain map, a preview), and writing an index plus appending the images. `mpf::markers` lists the marker segments before the first scan. |
| `extract(&[u8]) -> Metadata` | All of it for a whole file. Precedence: EXIF, then IPTC fills gaps, then XMP overrides the user fields (rating, label, title, caption, artist, copyright, keywords, GPS) and fills the rest. `from_embedded` does the same for blocks already located. |
+243
View File
@@ -0,0 +1,243 @@
//! Byte-preserving edits of Exif and XMP blocks, for writing them next to pixels that were
//! changed: Orientation, resolution and the Exif thumbnail. Every other byte of a block stays as
//! it was, so tags this crate does not know survive.
//!
//! An application that turns pixels upright on open writes Orientation = 1 on export
//! ([`upright_exif`], [`upright_xmp`]), so a viewer never rotates them a second time;
//! [`export_exif`] and [`export_xmp`] also set the resolution and drop a thumbnail of the
//! source image. [`exif_color_space`] reads the Exif ColorSpace tag.
//!
//! These work on the raw entry tables rather than on a parsed [`craft_tiff::Tiff`], because they
//! rewrite values in place and keep duplicate and unknown entries exactly as stored.
//!
//! ## Orientation (tag 274)
//!
//! Orientation values (TIFF 6.0, EXIF 2.3): 1 = upright, 2 = mirrored, 3 =
//! rotated 180°, 4 = flipped vertically, 5 = transposed, 6 = needs a 90°
//! clockwise turn, 7 = transverse, 8 = needs a 90° counter-clockwise turn.
//! Anything malformed, truncated or out of range reads as 1: an IFD0 that
//! starts inside the 8-byte header or is cut off before its next-IFD pointer,
//! or an Orientation entry that is not exactly one SHORT or LONG (the spec says
//! SHORT, but some cameras and tools write LONG). When IFD0 holds
//! several Orientation entries the first one wins (like libexif).
use std::borrow::Cow;
mod color;
mod resolution;
pub use color::{EXIF_COLOR_SPACE_SRGB, exif_color_space};
pub use resolution::{
exif_resolution, exif_with_pixel_size, exif_with_resolution, exif_with_resolution_adding_missing, export_exif,
export_exif_adding_missing_resolution, export_xmp, photoshop_resolution, resolution, xmp_resolution, xmp_with_resolution,
};
/// The TIFF/EXIF Orientation tag.
const TAG_ORIENTATION: u16 = 274;
/// Field types accepted for the Orientation tag: SHORT (the spec) and LONG
/// (written by some cameras and tools).
const TYPE_SHORT: u16 = 3;
const TYPE_LONG: u16 = 4;
/// Bytes per IFD entry.
const ENTRY: usize = 12;
/// The TIFF header is 8 bytes, so no IFD can start before it.
const HEADER: usize = 8;
/// BigTIFF uses a 16-byte header, 20-byte entries, and 8-byte offsets.
const BIG_ENTRY: usize = 20;
const BIG_HEADER: usize = 16;
/// Byte order of a TIFF structure.
#[derive(Clone, Copy)]
pub(crate) enum Order {
Little,
Big,
}
impl Order {
pub(crate) fn u16(self, b: &[u8], at: usize) -> Option<u16> {
let s: [u8; 2] = b.get(at..at.checked_add(2)?)?.try_into().ok()?;
Some(match self {
Order::Little => u16::from_le_bytes(s),
Order::Big => u16::from_be_bytes(s),
})
}
pub(crate) fn u32(self, b: &[u8], at: usize) -> Option<u32> {
let s: [u8; 4] = b.get(at..at.checked_add(4)?)?.try_into().ok()?;
Some(match self {
Order::Little => u32::from_le_bytes(s),
Order::Big => u32::from_be_bytes(s),
})
}
pub(crate) fn u64(self, b: &[u8], at: usize) -> Option<u64> {
let s: [u8; 8] = b.get(at..at.checked_add(8)?)?.try_into().ok()?;
Some(match self {
Order::Little => u64::from_le_bytes(s),
Order::Big => u64::from_be_bytes(s),
})
}
}
/// The TIFF payload of an EXIF block (a JPEG-style `Exif\0\0` prefix is skipped).
pub(crate) fn tiff_body(b: &[u8]) -> &[u8] {
b.strip_prefix(b"Exif\0\0").unwrap_or(b)
}
/// IFD0 of a TIFF structure: byte order, entry layout, and entry offsets.
///
/// The full table and next-IFD pointer must be present, and IFD0 must start
/// after the corresponding TIFF or BigTIFF header.
pub(crate) fn ifd0_entries(b: &[u8]) -> Option<(Order, usize, usize, usize, bool)> {
let (order, big_tiff) = match b.get(0..4)? {
[b'I', b'I', 42, 0] => (Order::Little, false),
[b'M', b'M', 0, 42] => (Order::Big, false),
[b'I', b'I', 43, 0] => (Order::Little, true),
[b'M', b'M', 0, 43] => (Order::Big, true),
_ => return None,
};
let (ifd, count_bytes, entry_bytes, next_ifd_bytes, header) = if big_tiff {
if order.u16(b, 4)? != 8 || order.u16(b, 6)? != 0 {
return None;
}
(usize::try_from(order.u64(b, 8)?).ok()?, 8usize, BIG_ENTRY, 8usize, BIG_HEADER)
} else {
(usize::try_from(order.u32(b, 4)?).ok()?, 2usize, ENTRY, 4usize, HEADER)
};
if ifd < header {
return None;
}
let count = if big_tiff { usize::try_from(order.u64(b, ifd)?).ok()? } else { usize::from(order.u16(b, ifd)?) };
let first = ifd.checked_add(count_bytes)?;
let next_ifd = first.checked_add(count.checked_mul(entry_bytes)?)?;
if next_ifd.checked_add(next_ifd_bytes)? > b.len() {
return None;
}
// In bounds: every entry ends at or before `next_ifd`.
Some((order, first, count, entry_bytes, big_tiff))
}
/// A well-formed Orientation entry's value: where it sits and its field type.
#[derive(Clone, Copy)]
struct Value {
at: usize,
ty: u16,
}
impl Value {
/// The stored value (a SHORT or LONG, left-justified in the 4-byte field).
fn read(self, order: Order, b: &[u8]) -> Option<u32> {
match self.ty {
TYPE_SHORT => order.u16(b, self.at).map(u32::from),
_ => order.u32(b, self.at),
}
}
/// 1 in this entry's own type, width and byte order.
fn one(self, order: Order) -> Vec<u8> {
match (self.ty, order) {
(TYPE_SHORT, Order::Little) => 1u16.to_le_bytes().to_vec(),
(TYPE_SHORT, Order::Big) => 1u16.to_be_bytes().to_vec(),
(_, Order::Little) => 1u32.to_le_bytes().to_vec(),
(_, Order::Big) => 1u32.to_be_bytes().to_vec(),
}
}
}
/// The value of the entry at `e` if it is a well-formed Orientation entry:
/// exactly one SHORT or LONG.
fn orientation_value(order: Order, b: &[u8], e: usize, big_tiff: bool) -> Option<Value> {
let count = if big_tiff { order.u64(b, e.checked_add(4)?)? } else { u64::from(order.u32(b, e.checked_add(4)?)?) };
if order.u16(b, e)? != TAG_ORIENTATION || count != 1 {
return None;
}
let ty = order.u16(b, e.checked_add(2)?)?;
let value_offset = if big_tiff { 12 } else { 8 };
matches!(ty, TYPE_SHORT | TYPE_LONG).then_some(Value { at: e.checked_add(value_offset)?, ty })
}
/// Locates the Orientation value in IFD0.
///
/// The **first** Orientation entry wins (like libexif and Photoshop); if that
/// one is malformed (count ≠ 1, or neither SHORT nor LONG) the tag is ignored,
/// even when a later duplicate is well formed.
fn find_entry(b: &[u8]) -> Option<(Order, Value)> {
let (order, first, count, entry_bytes, big_tiff) = ifd0_entries(b)?;
let e = (0..count).filter_map(|i| first.checked_add(i.checked_mul(entry_bytes)?)).find(|&e| order.u16(b, e) == Some(TAG_ORIENTATION))?;
Some((order, orientation_value(order, b, e, big_tiff)?))
}
/// The orientation (1–8) recorded in a TIFF-structured block: an EXIF payload
/// (with or without the `Exif\0\0` prefix) or a whole TIFF file. Missing,
/// malformed or out-of-range values give 1.
pub fn exif_orientation(exif: &[u8]) -> u16 {
let b = tiff_body(exif);
find_entry(b).and_then(|(order, v)| v.read(order, b)).and_then(|v| u16::try_from(v).ok()).filter(|v| (1..=8).contains(v)).unwrap_or(1)
}
/// `exif` with its Orientation rewritten to 1, borrowed when there is nothing
/// to change (no well-formed tag, already 1, or unparseable).
///
/// Only the value bytes of each well-formed Orientation entry change, written
/// as 1 in the entry's existing type and width (SHORT or LONG); every other
/// byte (other tags, their offset-based values, sub-IFDs) is kept as is.
/// Well-formed duplicates are all set to 1, so a reader that lets a later
/// duplicate win cannot rotate the upright pixels either.
pub fn upright_exif(exif: &[u8]) -> Cow<'_, [u8]> {
let body = tiff_body(exif);
let prefix = exif.len() - body.len();
let Some((order, first, count, entry_bytes, big_tiff)) = ifd0_entries(body) else {
return Cow::Borrowed(exif);
};
let values: Vec<Value> = (0..count)
.filter_map(|i| first.checked_add(i.checked_mul(entry_bytes)?))
.filter_map(|e| orientation_value(order, body, e, big_tiff))
.collect();
if values.iter().all(|v| v.read(order, body) == Some(1)) {
return Cow::Borrowed(exif);
}
let mut out = exif.to_vec();
for v in values {
let one = v.one(order);
let slot = prefix.checked_add(v.at).and_then(|at| out.get_mut(at..at.checked_add(one.len())?));
if let Some(slot) = slot {
slot.copy_from_slice(&one);
}
}
Cow::Owned(out)
}
/// `xmp` with any `tiff:Orientation` (attribute or element form) set to 1,
/// borrowed when there is nothing to change.
pub fn upright_xmp(xmp: &str) -> Cow<'_, str> {
const KEY: &str = "tiff:Orientation";
if !xmp.contains(KEY) {
return Cow::Borrowed(xmp);
}
let mut out = String::with_capacity(xmp.len());
let mut rest = xmp;
let mut changed = false;
while let Some(i) = rest.find(KEY) {
let (head, tail) = rest.split_at(i + KEY.len());
out.push_str(head);
rest = tail;
// `tiff:Orientation="6"`, `tiff:Orientation='6'` or `<tiff:Orientation>6<`.
let open = rest.char_indices().take_while(|&(_, c)| c.is_ascii_whitespace() || matches!(c, '=' | '"' | '\'' | '>')).last();
let Some((at, c)) = open else { continue };
if !matches!(c, '"' | '\'' | '>') {
continue;
}
let start = at + 1;
let digits = rest.get(start..).map_or(0, |s| s.bytes().take_while(u8::is_ascii_digit).count());
if digits == 0 || rest.get(start..start + digits) == Some("1") {
continue;
}
out.push_str(rest.get(..start).unwrap_or_default());
out.push('1');
rest = rest.get(start + digits..).unwrap_or_default();
changed = true;
}
out.push_str(rest);
if changed { Cow::Owned(out) } else { Cow::Borrowed(xmp) }
}
+138
View File
@@ -0,0 +1,138 @@
//! The EXIF ColorSpace tag (0xA001, in the Exif sub-IFD that IFD0's tag 0x8769 points to):
//! 1 = sRGB, 0xFFFF = uncalibrated (EXIF 2.3, §4.6.5).
//!
//! Cameras write it instead of an ICC profile. Photoshop opens a file with no embedded profile but
//! ColorSpace = sRGB as sRGB, unless Preferences › File Handling › Ignore EXIF Profile Tag is on.
//! Anything malformed, truncated or missing reads as no tag.
use super::{Order, ifd0_entries, tiff_body};
/// IFD0's pointer to the Exif sub-IFD.
const TAG_EXIF_IFD: u16 = 0x8769;
/// The ColorSpace tag in the Exif sub-IFD.
const TAG_COLOR_SPACE: u16 = 0xA001;
const TYPE_SHORT: u16 = 3;
const TYPE_LONG: u16 = 4;
/// TIFF type 13: an IFD offset (some writers use it for sub-IFD pointers).
const TYPE_IFD: u16 = 13;
/// Bytes per classic TIFF IFD entry; EXIF is never BigTIFF.
const ENTRY: usize = 12;
/// No IFD can start inside the 8-byte TIFF header.
const HEADER: usize = 8;
/// The ColorSpace value meaning sRGB.
pub const EXIF_COLOR_SPACE_SRGB: u16 = 1;
/// The EXIF ColorSpace value of a TIFF-structured block (an EXIF payload with or without the
/// `Exif\0\0` prefix), or `None` when it has none or it is malformed.
pub fn exif_color_space(exif: &[u8]) -> Option<u16> {
let b = tiff_body(exif);
let (order, first, count, entry_bytes, big_tiff) = ifd0_entries(b)?;
if big_tiff {
return None;
}
let ptr = find(order, b, first, count, entry_bytes, TAG_EXIF_IFD)?;
if !matches!(order.u16(b, ptr.checked_add(2)?)?, TYPE_LONG | TYPE_IFD) || order.u32(b, ptr.checked_add(4)?)? != 1 {
return None;
}
let ifd = usize::try_from(order.u32(b, ptr.checked_add(8)?)?).ok()?;
if ifd < HEADER {
return None;
}
let n = usize::from(order.u16(b, ifd)?);
let first = ifd.checked_add(2)?;
// The whole table must be present.
if first.checked_add(n.checked_mul(ENTRY)?)? > b.len() {
return None;
}
let e = find(order, b, first, n, ENTRY, TAG_COLOR_SPACE)?;
if order.u32(b, e.checked_add(4)?)? != 1 {
return None;
}
match order.u16(b, e.checked_add(2)?)? {
TYPE_SHORT => order.u16(b, e.checked_add(8)?),
TYPE_LONG => u16::try_from(order.u32(b, e.checked_add(8)?)?).ok(),
_ => None,
}
}
/// Offset of the first entry tagged `tag` in the IFD whose `count` entries start at `first`.
fn find(order: Order, b: &[u8], first: usize, count: usize, entry_bytes: usize, tag: u16) -> Option<usize> {
(0..count).filter_map(|i| first.checked_add(i.checked_mul(entry_bytes)?)).find(|&e| order.u16(b, e) == Some(tag))
}
#[cfg(test)]
mod tests {
use super::*;
/// A little- or big-endian EXIF block: IFD0 holding only the Exif pointer, then an Exif IFD
/// holding `entries` (tag, type, count, value).
fn exif(big_endian: bool, entries: &[(u16, u16, u32, u32)]) -> Vec<u8> {
let u16b = |v: u16| if big_endian { v.to_be_bytes() } else { v.to_le_bytes() };
let u32b = |v: u32| if big_endian { v.to_be_bytes() } else { v.to_le_bytes() };
let mut b = if big_endian { b"MM\0\x2a".to_vec() } else { b"II\x2a\0".to_vec() };
b.extend(u32b(8));
// IFD0: one entry, then the next-IFD pointer. The Exif IFD starts right after (8 + 2 + 12 + 4).
b.extend(u16b(1));
b.extend(u16b(TAG_EXIF_IFD));
b.extend(u16b(TYPE_LONG));
b.extend(u32b(1));
b.extend(u32b(26));
b.extend(u32b(0));
b.extend(u16b(entries.len() as u16));
for &(tag, ty, count, value) in entries {
b.extend(u16b(tag));
b.extend(u16b(ty));
b.extend(u32b(count));
// SHORT values are left-justified in the 4-byte field.
if ty == TYPE_SHORT {
b.extend(u16b(value as u16));
b.extend([0, 0]);
} else {
b.extend(u32b(value));
}
}
b.extend(u32b(0));
b
}
#[test]
fn reads_the_color_space_in_either_byte_order() {
for big in [false, true] {
let srgb = exif(big, &[(0x9000, 7, 4, 0x3032_3330), (TAG_COLOR_SPACE, TYPE_SHORT, 1, 1)]);
assert_eq!(exif_color_space(&srgb), Some(EXIF_COLOR_SPACE_SRGB), "big endian: {big}");
let mut prefixed = b"Exif\0\0".to_vec();
prefixed.extend(&srgb);
assert_eq!(exif_color_space(&prefixed), Some(1));
assert_eq!(exif_color_space(&exif(big, &[(TAG_COLOR_SPACE, TYPE_SHORT, 1, 0xFFFF)])), Some(0xFFFF));
assert_eq!(exif_color_space(&exif(big, &[(TAG_COLOR_SPACE, TYPE_LONG, 1, 1)])), Some(1), "LONG is accepted");
}
}
#[test]
fn missing_or_malformed_reads_as_no_tag() {
assert_eq!(exif_color_space(&exif(false, &[(0x9000, 7, 4, 0)])), None, "no ColorSpace entry");
assert_eq!(exif_color_space(&exif(false, &[(TAG_COLOR_SPACE, TYPE_SHORT, 2, 1)])), None, "count ≠ 1");
assert_eq!(exif_color_space(&exif(false, &[(TAG_COLOR_SPACE, 2, 1, 1)])), None, "ASCII type");
assert_eq!(exif_color_space(&exif(false, &[(TAG_COLOR_SPACE, TYPE_LONG, 1, 0x1_0000)])), None, "LONG over u16");
let good = exif(false, &[(TAG_COLOR_SPACE, TYPE_SHORT, 1, 1)]);
// Every truncation is rejected without panicking.
for len in 0..good.len() - 4 {
assert_eq!(exif_color_space(&good[..len]), None, "truncated to {len}");
}
// An Exif pointer past the end, into the header, or of the wrong type.
for (at, value) in [(18usize, 0xFFFF_FFF0u32), (18, 4), (18, u32::MAX)] {
let mut b = good.clone();
b[at..at + 4].copy_from_slice(&value.to_le_bytes());
assert_eq!(exif_color_space(&b), None, "pointer {value:#x}");
}
let mut b = good.clone();
b[12..14].copy_from_slice(&TYPE_SHORT.to_le_bytes());
assert_eq!(exif_color_space(&b), None, "pointer type SHORT");
// An Exif IFD claiming more entries than the block holds.
let mut b = good;
b[26..28].copy_from_slice(&u16::MAX.to_le_bytes());
assert_eq!(exif_color_space(&b), None);
assert_eq!(exif_color_space(b"not exif"), None);
}
}
+782
View File
@@ -0,0 +1,782 @@
//! Image resolution (pixels per inch) recorded in metadata blocks: EXIF / TIFF IFD0
//! (XResolution 282, YResolution 283, ResolutionUnit 296), XMP (`tiff:XResolution`,
//! `tiff:YResolution`, `tiff:ResolutionUnit`) and Photoshop's image resource
//! ResolutionInfo (`8BIM` resource 0x03ED); reading them, and rewriting the EXIF and XMP
//! copies on export so they agree with the resolution the file is written at.
//!
//! Implemented from TIFF 6.0 (section 8, "Baseline fields": XResolution / YResolution are
//! RATIONAL, ResolutionUnit is SHORT, 1 = no absolute unit, 2 = inch, 3 = centimetre,
//! default 2), EXIF 2.3 (same tags in the 0th IFD, same default) and the Adobe Photoshop File
//! Formats Specification (ResolutionInfo: Fixed 16.16 hRes in pixels per inch, then the
//! display units; the vertical resolution likewise).
//!
//! **Precedence** when a file carries several, as Photoshop resolves them: Photoshop's own
//! ResolutionInfo, then the XMP `tiff:` properties, then EXIF, and only then the format's own
//! density field when it is the generic JFIF one (see the JPEG decoder). JFIF density is last
//! because cameras record the real resolution in EXIF while many tools and libraries write a
//! default JFIF density of 72 (or a bare aspect ratio) without knowing it; Photoshop shows a
//! camera JPEG at its EXIF resolution whatever its JFIF header says. Formats whose own field is
//! written deliberately (PNG `pHYs`, TIFF tags) keep it first; the metadata blocks only fill in
//! when it is missing.
//!
//! Everything read here is untrusted: offsets and counts are bounds-checked, both byte orders
//! and BigTIFF-style EXIF are handled, and anything malformed reads as "no resolution" rather
//! than as a wrong one.
use std::borrow::Cow;
use super::{Order, ifd0_entries, tiff_body, upright_exif, upright_xmp};
const TAG_X_RESOLUTION: u16 = 282;
const TAG_Y_RESOLUTION: u16 = 283;
const TAG_RESOLUTION_UNIT: u16 = 296;
const TYPE_SHORT: u16 = 3;
const TYPE_RATIONAL: u16 = 5;
const CM_PER_INCH: f64 = 2.54;
/// Photoshop's ResolutionInfo image resource.
const RESOLUTION_INFO: u16 = 0x03ED;
/// Resolutions above this (pixels per inch) are treated as damage, not data.
const MAX_PPI: f64 = 1_000_000.0;
/// Two resolutions this close (pixels per inch) are the same.
const SAME_PPI: f64 = 1e-3;
/// A usable resolution pair, or `None`.
fn ppi_pair(x: f64, y: f64) -> Option<(f32, f32)> {
let ok = |v: f64| v.is_finite() && v > 0.0 && v <= MAX_PPI;
(ok(x) && ok(y)).then_some((x as f32, y as f32))
}
fn same(a: (f32, f32), b: (f32, f32)) -> bool {
(f64::from(a.0) - f64::from(b.0)).abs() < SAME_PPI && (f64::from(a.1) - f64::from(b.1)).abs() < SAME_PPI
}
/// `ppi` as a TIFF RATIONAL: a whole number over 1 when it is one, else over 1000.
fn rational(ppi: f32) -> (u32, u32) {
let p = f64::from(ppi);
if (p - p.round()).abs() < SAME_PPI { (p.round() as u32, 1) } else { ((p * 1000.0).round() as u32, 1000) }
}
/// One of the three resolution entries of IFD0.
#[derive(Clone, Copy)]
struct Field {
/// Offset of the 12- (BigTIFF: 20-) byte entry.
entry: usize,
tag: u16,
ty: u16,
count: u64,
/// Where the value bytes are, when the type is one we read and they lie inside the block.
value: Option<usize>,
}
impl Field {
fn short(&self, order: Order, b: &[u8]) -> Option<u16> {
(self.ty == TYPE_SHORT && self.count == 1).then_some(())?;
order.u16(b, self.value?)
}
fn rational(&self, order: Order, b: &[u8]) -> Option<f64> {
(self.ty == TYPE_RATIONAL && self.count == 1).then_some(())?;
let at = self.value?;
let (n, d) = (order.u32(b, at)?, order.u32(b, at.checked_add(4)?)?);
(d != 0).then(|| f64::from(n) / f64::from(d))
}
}
/// IFD0's resolution entries.
struct Ifd0 {
order: Order,
big: bool,
first: usize,
count: usize,
entry_bytes: usize,
fields: Vec<Field>,
}
impl Ifd0 {
fn parse(b: &[u8]) -> Option<Ifd0> {
let (order, first, count, entry_bytes, big) = ifd0_entries(b)?;
let mut fields = Vec::new();
for i in 0..count {
let e = first.checked_add(i.checked_mul(entry_bytes)?)?;
let tag = order.u16(b, e)?;
if !matches!(tag, TAG_X_RESOLUTION | TAG_Y_RESOLUTION | TAG_RESOLUTION_UNIT) {
continue;
}
let ty = order.u16(b, e.checked_add(2)?)?;
let count = if big { order.u64(b, e.checked_add(4)?)? } else { u64::from(order.u32(b, e.checked_add(4)?)?) };
let value = value_at(order, b, e, big, ty, count);
fields.push(Field { entry: e, tag, ty, count, value });
}
Some(Ifd0 { order, big, first, count, entry_bytes, fields })
}
fn first_of(&self, tag: u16) -> Option<&Field> {
self.fields.iter().find(|f| f.tag == tag)
}
}
/// Where an entry's SHORT or RATIONAL value bytes are: inline in the entry when they fit,
/// else at the offset it holds. `None` for other types or when they leave the block.
fn value_at(order: Order, b: &[u8], e: usize, big: bool, ty: u16, count: u64) -> Option<usize> {
let size = match ty {
TYPE_SHORT => 2u64,
TYPE_RATIONAL => 8,
_ => return None,
}
.checked_mul(count)?;
let (field, inline) = if big { (e.checked_add(12)?, 8) } else { (e.checked_add(8)?, 4) };
let at = if size <= inline {
field
} else if big {
usize::try_from(order.u64(b, field)?).ok()?
} else {
usize::try_from(order.u32(b, field)?).ok()?
};
(at.checked_add(usize::try_from(size).ok()?)? <= b.len()).then_some(at)
}
/// The resolution (pixels per inch, horizontal and vertical) recorded in IFD0 of an EXIF block
/// (with or without the JPEG `Exif\0\0` prefix) or a TIFF structure.
///
/// Both XResolution and YResolution must be one RATIONAL each with a non-zero denominator.
/// ResolutionUnit 2 (inch, also when the tag is absent) is used as is, 3 (centimetre) is
/// converted; 1 (no absolute unit), any other value or a malformed unit entry gives `None`.
/// The first of duplicated entries wins.
pub fn exif_resolution(exif: &[u8]) -> Option<(f32, f32)> {
let b = tiff_body(exif);
let ifd = Ifd0::parse(b)?;
let unit = match ifd.first_of(TAG_RESOLUTION_UNIT) {
None => 2,
Some(f) => f.short(ifd.order, b)?,
};
let scale = match unit {
2 => 1.0,
3 => CM_PER_INCH,
_ => return None,
};
let x = ifd.first_of(TAG_X_RESOLUTION)?.rational(ifd.order, b)?;
let y = ifd.first_of(TAG_Y_RESOLUTION)?.rational(ifd.order, b)?;
ppi_pair(x * scale, y * scale)
}
/// `exif` with its IFD0 resolution set to `ppi` (pixels per inch), borrowed when it has no
/// resolution entries, already records that resolution, or cannot be parsed.
///
/// Existing well-formed XResolution / YResolution (one RATIONAL) entries are rewritten in place,
/// and every well-formed ResolutionUnit (one SHORT) becomes 2 (inch). Malformed entries are
/// removed from IFD0 instead, so no stale value survives; all other bytes are kept.
pub fn exif_with_resolution(exif: &[u8], ppi: (f32, f32)) -> Cow<'_, [u8]> {
exif_with_resolution_inner(exif, ppi, false)
}
/// Like [`exif_with_resolution`], but adds X/Y resolution tags when a valid IFD0 has none.
/// Existing TIFF data stays in place, so its offsets remain valid. Used by CMYK JPEG, which
/// cannot carry a JFIF density marker.
pub fn exif_with_resolution_adding_missing(exif: &[u8], ppi: (f32, f32)) -> Cow<'_, [u8]> {
exif_with_resolution_inner(exif, ppi, true)
}
/// Rewrite valid IFD0 resolution tags and optionally append tags that are missing or malformed.
fn exif_with_resolution_inner(exif: &[u8], ppi: (f32, f32), add_missing: bool) -> Cow<'_, [u8]> {
if ppi_pair(f64::from(ppi.0), f64::from(ppi.1)).is_none() {
return Cow::Borrowed(exif);
}
if exif.is_empty() {
if !add_missing {
return Cow::Borrowed(exif);
}
// An image with DPI but no source EXIF still needs a valid EXIF block when the output
// format cannot carry resolution in its own header (CMYK JPEG has no valid JFIF marker).
return Cow::Owned(exif_with_resolution_inner(b"II*\0\x08\0\0\0\0\0\0\0\0\0\0", ppi, true).into_owned());
}
let body = tiff_body(exif);
let prefix = exif.len() - body.len();
let Some(ifd) = Ifd0::parse(body) else { return Cow::Borrowed(exif) };
if exif_resolution(body).is_some_and(|r| same(r, ppi)) {
return Cow::Borrowed(exif);
}
let missing_xy = ifd.first_of(TAG_X_RESOLUTION).and_then(|f| f.rational(ifd.order, body)).is_none()
|| ifd.first_of(TAG_Y_RESOLUTION).and_then(|f| f.rational(ifd.order, body)).is_none();
if add_missing && missing_xy {
let Some(body) = append_resolution_ifd(body, &ifd, ppi) else { return Cow::Borrowed(exif) };
let mut full = exif.get(..prefix).unwrap_or_default().to_vec();
full.extend_from_slice(&body);
return Cow::Owned(full);
}
if ifd.fields.is_empty() {
return Cow::Borrowed(exif);
}
let order = ifd.order;
let mut out = body.to_vec();
let mut removed = Vec::new();
for f in &ifd.fields {
let bytes = match f.tag {
TAG_RESOLUTION_UNIT if f.short(order, body).is_some() => put_u16(order, 2).to_vec(),
TAG_X_RESOLUTION | TAG_Y_RESOLUTION if f.rational(order, body).is_some() => {
let (n, d) = rational(if f.tag == TAG_X_RESOLUTION { ppi.0 } else { ppi.1 });
[put_u32(order, n), put_u32(order, d)].concat()
}
_ => {
removed.push(f.entry);
continue;
}
};
let slot = f.value.and_then(|at| out.get_mut(at..at.checked_add(bytes.len())?));
match slot {
Some(slot) => slot.copy_from_slice(&bytes),
None => removed.push(f.entry),
}
}
if !removed.is_empty() {
remove_entries(&mut out, &ifd, &removed);
}
let mut full = exif.get(..prefix).unwrap_or_default().to_vec();
full.extend_from_slice(&out);
if full == exif { Cow::Borrowed(exif) } else { Cow::Owned(full) }
}
/// Append a replacement IFD0 with fresh resolution tags. Every other entry keeps its original
/// bytes and offsets, since the TIFF block's existing data remains where it was.
fn append_resolution_ifd(body: &[u8], ifd: &Ifd0, ppi: (f32, f32)) -> Option<Vec<u8>> {
let (count_bytes, next_bytes, alignment) = if ifd.big { (8usize, 8usize, 8usize) } else { (2usize, 4usize, 2usize) };
let mut entries = Vec::with_capacity(ifd.count.saturating_add(3));
for index in 0..ifd.count {
let entry = ifd.first.checked_add(index.checked_mul(ifd.entry_bytes)?)?;
let tag = ifd.order.u16(body, entry)?;
if matches!(tag, TAG_X_RESOLUTION | TAG_Y_RESOLUTION | TAG_RESOLUTION_UNIT) {
continue;
}
entries.push((tag, body.get(entry..entry.checked_add(ifd.entry_bytes)?)?.to_vec()));
}
let count = entries.len().checked_add(3)?;
if !ifd.big && count > usize::from(u16::MAX) {
return None;
}
let new_ifd = align_up(body.len(), alignment)?;
let table_end = new_ifd.checked_add(count_bytes)?.checked_add(count.checked_mul(ifd.entry_bytes)?)?;
let next_at = ifd.first.checked_add(ifd.count.checked_mul(ifd.entry_bytes)?)?;
let next_ifd = body.get(next_at..next_at.checked_add(next_bytes)?)?;
let rational_at = align_up(table_end.checked_add(next_bytes)?, if ifd.big { 8 } else { 4 })?;
let y_rational_at = rational_at.checked_add(8)?;
if !ifd.big && (new_ifd > u32::MAX as usize || y_rational_at.checked_add(8)? > u32::MAX as usize) {
return None;
}
entries.push((TAG_X_RESOLUTION, resolution_entry(ifd.order, ifd.big, TAG_X_RESOLUTION, u64::try_from(rational_at).ok()?)?));
entries.push((TAG_Y_RESOLUTION, resolution_entry(ifd.order, ifd.big, TAG_Y_RESOLUTION, u64::try_from(y_rational_at).ok()?)?));
entries.push((TAG_RESOLUTION_UNIT, resolution_entry(ifd.order, ifd.big, TAG_RESOLUTION_UNIT, 2)?));
entries.sort_by_key(|(tag, _)| *tag);
let mut out = body.to_vec();
out.resize(new_ifd, 0);
if ifd.big {
out.extend_from_slice(&put_u64(ifd.order, count as u64));
} else {
out.extend_from_slice(&put_u16(ifd.order, count as u16));
}
for (_, entry) in entries {
out.extend_from_slice(&entry);
}
out.extend_from_slice(next_ifd);
out.resize(rational_at, 0);
let (xn, xd) = rational(ppi.0);
let (yn, yd) = rational(ppi.1);
out.extend_from_slice(&put_u32(ifd.order, xn));
out.extend_from_slice(&put_u32(ifd.order, xd));
out.extend_from_slice(&put_u32(ifd.order, yn));
out.extend_from_slice(&put_u32(ifd.order, yd));
let offset_at: usize = if ifd.big { 8 } else { 4 };
if ifd.big {
out.get_mut(offset_at..offset_at.checked_add(8)?)?.copy_from_slice(&put_u64(ifd.order, new_ifd as u64));
} else {
out.get_mut(offset_at..offset_at.checked_add(4)?)?.copy_from_slice(&put_u32(ifd.order, u32::try_from(new_ifd).ok()?));
}
Some(out)
}
/// One IFD entry. Rational fields store an offset; the SHORT unit is inline.
fn resolution_entry(order: Order, big: bool, tag: u16, value: u64) -> Option<Vec<u8>> {
let entry_bytes = if big { 20 } else { 12 };
let count_at = 4;
let value_at = if big { 12 } else { 8 };
let mut entry = vec![0; entry_bytes];
entry.get_mut(..2)?.copy_from_slice(&put_u16(order, tag));
entry.get_mut(2..4)?.copy_from_slice(&put_u16(order, if tag == TAG_RESOLUTION_UNIT { TYPE_SHORT } else { TYPE_RATIONAL }));
if big {
entry.get_mut(count_at..count_at + 8)?.copy_from_slice(&put_u64(order, 1));
if tag == TAG_RESOLUTION_UNIT {
entry.get_mut(value_at..value_at + 2)?.copy_from_slice(&put_u16(order, value as u16));
} else {
entry.get_mut(value_at..value_at + 8)?.copy_from_slice(&put_u64(order, value));
}
} else {
entry.get_mut(count_at..count_at + 4)?.copy_from_slice(&put_u32(order, 1));
if tag == TAG_RESOLUTION_UNIT {
entry.get_mut(value_at..value_at + 2)?.copy_from_slice(&put_u16(order, value as u16));
} else {
entry.get_mut(value_at..value_at + 4)?.copy_from_slice(&put_u32(order, u32::try_from(value).ok()?));
}
}
Some(entry)
}
/// Round `value` up to the next `alignment` boundary, returning `None` on overflow.
fn align_up(value: usize, alignment: usize) -> Option<usize> {
value.checked_add((alignment - value % alignment) % alignment)
}
fn put_u16(order: Order, v: u16) -> [u8; 2] {
match order {
Order::Little => v.to_le_bytes(),
Order::Big => v.to_be_bytes(),
}
}
fn put_u32(order: Order, v: u32) -> [u8; 4] {
match order {
Order::Little => v.to_le_bytes(),
Order::Big => v.to_be_bytes(),
}
}
fn put_u64(order: Order, v: u64) -> [u8; 8] {
match order {
Order::Little => v.to_le_bytes(),
Order::Big => v.to_be_bytes(),
}
}
/// Removes the IFD0 entries at offsets `removed` (see [`exif_with_resolution`]).
///
/// `removed` is strictly increasing: `Ifd0::parse` visits entries in physical order and the
/// caller records each malformed field at most once. Walk those offsets once alongside the
/// table, keeping compaction linear even when many duplicate resolution entries are malformed.
/// The private offset type lets tests observe real equality checks in this same loop; production
/// instantiates it with `usize`, without a counter or a change to the exported codec API.
fn remove_entries<Offset: Copy + PartialEq + From<usize>>(b: &mut [u8], ifd: &Ifd0, removed: &[Offset]) {
let eb = ifd.entry_bytes;
let next_bytes = if ifd.big { 8 } else { 4 };
let count_bytes = if ifd.big { 8 } else { 2 };
let Some(table_end) = ifd.count.checked_mul(eb).and_then(|n| ifd.first.checked_add(n)) else { return };
let Some(old) = b.get(ifd.first..table_end.saturating_add(next_bytes)).map(<[u8]>::to_vec) else { return };
let mut table = Vec::with_capacity(old.len());
let mut kept = 0usize;
let mut removed = removed.iter().copied().peekable();
for (i, entry) in old.chunks_exact(eb).take(ifd.count).enumerate() {
if removed.peek() == Some(&Offset::from(ifd.first + i * eb)) {
removed.next();
} else {
table.extend_from_slice(entry);
kept += 1;
}
}
table.extend_from_slice(old.get(ifd.count * eb..).unwrap_or_default());
table.resize(old.len(), 0);
let count: Vec<u8> = if ifd.big { put_u64(ifd.order, kept as u64).to_vec() } else { put_u16(ifd.order, kept as u16).to_vec() };
let Some(count_at) = ifd.first.checked_sub(count_bytes) else { return };
if let Some(slot) = b.get_mut(count_at..ifd.first) {
slot.copy_from_slice(&count);
}
if let Some(slot) = b.get_mut(ifd.first..ifd.first + table.len()) {
slot.copy_from_slice(&table);
}
}
/// The resolution in a Photoshop image resource block (a sequence of `8BIM` resources, as in a
/// JPEG's APP13 "Photoshop 3.0" segment or TIFF tag 34377): its ResolutionInfo (0x03ED), whose
/// Fixed 16.16 hRes / vRes are pixels per inch whatever display unit follows them.
pub fn photoshop_resolution(resources: &[u8]) -> Option<(f32, f32)> {
let be16 = |at: usize| resources.get(at..at.checked_add(2)?).map(|s| u16::from_be_bytes([s[0], s[1]]));
let be32 = |at: usize| resources.get(at..at.checked_add(4)?).map(|s| u32::from_be_bytes([s[0], s[1], s[2], s[3]]));
let mut i = 0usize;
while i < resources.len() {
let signature = resources.get(i..i.checked_add(4)?)?;
let id = be16(i.checked_add(4)?)?;
// Pascal name: length byte plus text, padded to an even size.
let name_len = usize::from(*resources.get(i.checked_add(6)?)?);
let name = (name_len + 1).next_multiple_of(2);
let size_at = i.checked_add(6)?.checked_add(name)?;
let size = usize::try_from(be32(size_at)?).ok()?;
let data = size_at.checked_add(4)?;
if signature == b"8BIM" && id == RESOLUTION_INFO {
let info = resources.get(data..data.checked_add(size)?)?;
if info.len() < 16 {
return None;
}
let fixed = |at: usize| be32(data + at).map(|v| f64::from(v) / 65536.0);
return ppi_pair(fixed(0)?, fixed(8)?);
}
i = data.checked_add(size)?.checked_add(size % 2)?;
}
None
}
/// The text ranges of every value of XMP property `key` (`key="v"`, `key='v'` or
/// `<key>v</key>`), in order.
fn xmp_values(xmp: &str, key: &str) -> Vec<(usize, usize)> {
let mut out = Vec::new();
let mut from = 0;
while let Some(k) = xmp.get(from..).and_then(|s| s.find(key)).map(|k| k + from) {
from = k + key.len();
// An attribute or an opening tag of exactly this name (not `</key>`, not `xkey`).
if !xmp.get(..k).and_then(|h| h.chars().next_back()).is_some_and(|c| c == '<' || c.is_ascii_whitespace()) {
continue;
}
let rest = xmp.get(from..).unwrap_or_default();
let after_key = rest.trim_start();
let (value, close) = if let Some(r) = after_key.strip_prefix('=') {
let r = r.trim_start();
match r.chars().next() {
Some(q @ ('"' | '\'')) => (r.get(1..).unwrap_or_default(), q),
_ => continue,
}
} else if let Some(r) = rest.strip_prefix('>') {
(r, '<')
} else {
continue;
};
let start = xmp.len() - value.len();
// Advance past this value before searching for the next key. Without this, `from`
// only moved past the key text, so property-like text repeated inside an unrelated
// attribute re-ran this full-suffix `find(close)` for every occurrence, which is
// quadratic in the packet size. A missing close means the value is unterminated, so
// no later value can parse either; stop scanning in that case.
let Some(len) = value.find(close) else { break };
out.push((start, start + len));
from = start + len + close.len_utf8();
}
out
}
/// An XMP rational (`300/1`) or plain number.
fn xmp_number(s: &str) -> Option<f64> {
let s = s.trim();
match s.split_once('/') {
Some((n, d)) => {
let (n, d) = (n.trim().parse::<f64>().ok()?, d.trim().parse::<f64>().ok()?);
(d != 0.0).then(|| n / d)
}
None => s.parse().ok(),
}
}
const XMP_X: &str = "tiff:XResolution";
const XMP_Y: &str = "tiff:YResolution";
const XMP_UNIT: &str = "tiff:ResolutionUnit";
/// The resolution (pixels per inch) in an XMP packet's `tiff:XResolution` / `tiff:YResolution`
/// (with `tiff:ResolutionUnit`, 2 when absent, 3 converted from centimetres), or `None`.
pub fn xmp_resolution(xmp: &str) -> Option<(f32, f32)> {
let first = |key: &str| xmp_values(xmp, key).first().and_then(|&(a, b)| xmp.get(a..b));
let unit = match first(XMP_UNIT) {
None => 2.0,
Some(u) => xmp_number(u)?,
};
let scale = if unit == 2.0 {
1.0
} else if unit == 3.0 {
CM_PER_INCH
} else {
return None;
};
ppi_pair(xmp_number(first(XMP_X)?)? * scale, xmp_number(first(XMP_Y)?)? * scale)
}
/// `xmp` with its `tiff:` resolution properties set to `ppi` (and the unit to 2, inch),
/// borrowed when it has none or already says so.
pub fn xmp_with_resolution(xmp: &str, ppi: (f32, f32)) -> Cow<'_, str> {
if ppi_pair(f64::from(ppi.0), f64::from(ppi.1)).is_none() || xmp_resolution(xmp).is_some_and(|r| same(r, ppi)) {
return Cow::Borrowed(xmp);
}
let text = |v: f32| {
let (n, d) = rational(v);
format!("{n}/{d}")
};
let mut edits: Vec<(usize, usize, String)> = Vec::new();
for (key, value) in [(XMP_X, text(ppi.0)), (XMP_Y, text(ppi.1)), (XMP_UNIT, "2".to_owned())] {
edits.extend(xmp_values(xmp, key).into_iter().map(|(a, b)| (a, b, value.clone())));
}
if edits.is_empty() {
return Cow::Borrowed(xmp);
}
edits.sort_by_key(|e| e.0);
let mut out = String::with_capacity(xmp.len() + 16);
let mut at = 0;
for (a, b, v) in edits {
// Overlapping ranges can only come from malformed markup; keep the first edit.
if a < at {
continue;
}
out.push_str(xmp.get(at..a).unwrap_or_default());
out.push_str(&v);
at = b;
}
out.push_str(xmp.get(at..).unwrap_or_default());
if out == xmp { Cow::Borrowed(xmp) } else { Cow::Owned(out) }
}
/// `exif` without its embedded thumbnail, or `None` when it has none (or doesn't parse).
///
/// EXIF keeps the thumbnail in IFD1, the IFD IFD0's next-IFD pointer names. Finder, Android's
/// gallery and upload pickers show that thumbnail instead of decoding the image, so a copied one
/// keeps showing the photo as it was before it was edited. The pointer is cleared;
/// nothing else moves, so every other offset in the block stays valid.
fn exif_without_thumbnail(exif: &[u8]) -> Option<Vec<u8>> {
let body = tiff_body(exif);
let prefix = exif.len() - body.len();
let (order, first, count, entry_bytes, big_tiff) = ifd0_entries(body)?;
let next = prefix.checked_add(first)?.checked_add(count.checked_mul(entry_bytes)?)?;
let width = if big_tiff { 8 } else { 4 };
// In bounds: `ifd0_entries` checked that the whole pointer is present.
if exif.get(next..next.checked_add(width)?)?.iter().all(|b| *b == 0) {
return None;
}
// Classic TIFF: also blank the thumbnail JPEG itself (IFD1's JPEGInterchangeFormat/Length),
// which stays recoverable in the block once nothing points at it.
// Only a range clear of the TIFF header, IFD0's table and values, and its Exif/GPS sub-IFDs: one
// that covers them is not a thumbnail.
let thumb = (!big_tiff)
.then(|| thumbnail_range(order, body, next - prefix))
.flatten()
.filter(|&(off, len)| off >= 8 && !overlaps_ifd0(order, body, first - 2, count, off, len));
let mut out = exif.to_vec();
out.get_mut(next..next + width)?.fill(0);
if let Some((off, len)) = thumb
&& let Some(jpeg) = prefix.checked_add(off).and_then(|a| out.get_mut(a..a.checked_add(len)?))
{
jpeg.fill(0);
}
Some(out)
}
/// The Exif IFD pointer tag in IFD0.
const TAG_EXIF_IFD: u16 = 34665;
/// PixelXDimension and PixelYDimension in the Exif IFD (EXIF 2.3, SHORT or LONG).
const TAG_PIXEL_X: u16 = 40962;
const TAG_PIXEL_Y: u16 = 40963;
const TYPE_LONG: u16 = 4;
/// Where the Exif IFD of a TIFF stream starts, from IFD0's pointer (LONG or IFD; in BigTIFF also
/// LONG8 or IFD8). The first pointer wins; one that doesn't hold exactly one value is not followed.
fn exif_ifd(order: Order, b: &[u8], first: usize, count: usize, entry_bytes: usize, big: bool) -> Option<usize> {
let e = (0..count).filter_map(|i| first.checked_add(i.checked_mul(entry_bytes)?)).find(|&e| order.u16(b, e) == Some(TAG_EXIF_IFD))?;
let count_at = e.checked_add(4)?;
let values = if big { order.u64(b, count_at)? } else { u64::from(order.u32(b, count_at)?) };
if values != 1 {
return None;
}
let ty = order.u16(b, e.checked_add(2)?)?;
let at = e.checked_add(if big { 12 } else { 8 })?;
let offset = match ty {
4 | 13 => u64::from(order.u32(b, at)?),
16 | 18 if big => order.u64(b, at)?,
_ => return None,
};
usize::try_from(offset).ok()
}
/// `exif` with the Exif IFD's PixelXDimension and PixelYDimension set to `size`, the pixels the
/// file is written with; borrowed when there is nothing to change (no such tags, already right,
/// or unparseable). A camera records its sensor's size there, which after flattening a cropped,
/// resized or rotated document names a size the file doesn't have. Only a tag with one
/// SHORT or LONG value is rewritten, in place and as a LONG (EXIF allows either), so no offset
/// moves.
pub fn exif_with_pixel_size(exif: &[u8], (width, height): (u32, u32)) -> Cow<'_, [u8]> {
let body = tiff_body(exif);
let prefix = exif.len() - body.len();
let Some((order, first, count, entry_bytes, big)) = ifd0_entries(body) else {
return Cow::Borrowed(exif);
};
let Some(dir) = exif_ifd(order, body, first, count, entry_bytes, big) else {
return Cow::Borrowed(exif);
};
let (count_bytes, value_offset, field) = if big { (8, 12, 8) } else { (2, 8, 4) };
let entries = if big { order.u64(body, dir).and_then(|n| usize::try_from(n).ok()) } else { order.u16(body, dir).map(usize::from) };
let Some(start) = dir.checked_add(count_bytes) else { return Cow::Borrowed(exif) };
// Every entry must be present: a truncated directory is left as it is.
let Some(entries) = entries.filter(|n| n.checked_mul(entry_bytes).and_then(|len| len.checked_add(start)).is_some_and(|end| end <= body.len()))
else {
return Cow::Borrowed(exif);
};
let put = |v: u32| match order {
Order::Little => v.to_le_bytes(),
Order::Big => v.to_be_bytes(),
};
let long = match order {
Order::Little => TYPE_LONG.to_le_bytes(),
Order::Big => TYPE_LONG.to_be_bytes(),
};
// Where entry `i`'s type and value sit in `exif`, and the value to write, when it is a
// PixelX/YDimension entry with one SHORT or LONG value that differs from it.
let stale = |i: usize| -> Option<(usize, usize, u32)> {
let e = start.checked_add(i.checked_mul(entry_bytes)?)?;
let want = match order.u16(body, e)? {
TAG_PIXEL_X => width,
TAG_PIXEL_Y => height,
_ => return None,
};
let count_at = e.checked_add(4)?;
let n = if big { order.u64(body, count_at)? } else { u64::from(order.u32(body, count_at)?) };
let type_at = e.checked_add(2)?;
let at = e.checked_add(value_offset)?;
let now = match order.u16(body, type_at)? {
TYPE_SHORT => u32::from(order.u16(body, at)?),
TYPE_LONG => order.u32(body, at)?,
_ => return None,
};
(n == 1 && now != want).then_some((prefix.checked_add(type_at)?, prefix.checked_add(at)?, want))
};
let mut out: Option<Vec<u8>> = None;
for (type_at, at, want) in (0..entries).filter_map(stale) {
let o = out.get_or_insert_with(|| exif.to_vec());
let mut value = [0u8; 8];
value[..4].copy_from_slice(&put(want));
if let Some(slot) = type_at.checked_add(2).and_then(|end| o.get_mut(type_at..end)) {
slot.copy_from_slice(&long);
}
if let Some(slot) = at.checked_add(field).and_then(|end| o.get_mut(at..end)) {
slot.copy_from_slice(&value[..field]);
}
}
out.map_or(Cow::Borrowed(exif), Cow::Owned)
}
/// Most IFD entries `overlaps_ifd0` examines in total: a hostile block with thousands of Exif/GPS
/// pointers to large sub-IFDs would otherwise cost billions of checks.
const MAX_OVERLAP_ENTRIES: usize = 1 << 20;
/// Whether `off..off + len` of `body` covers part of classic IFD0 (`ifd0` is its entry count's
/// offset) or of its Exif / GPS sub-IFDs. Past [`MAX_OVERLAP_ENTRIES`] checks it answers `true`
/// (the range is not trusted as a thumbnail, so its bytes stay).
fn overlaps_ifd0(order: Order, body: &[u8], ifd0: usize, count: usize, off: usize, len: usize) -> bool {
let mut budget = MAX_OVERLAP_ENTRIES;
overlaps_ifd(order, body, ifd0, count, off, len, &mut budget)
|| (0..count).any(|i| {
let e = ifd0 + 2 + i * 12;
// A LONG pointer (tag 0x8769 ExifIFD, 0x8825 GPSIFD) to a sub-IFD, whose own count says its size.
matches!(order.u16(body, e), Some(0x8769 | 0x8825))
&& order
.u32(body, e + 8)
.and_then(|v| usize::try_from(v).ok())
.is_some_and(|sub| overlaps_sub_ifd(order, body, sub, off, len, &mut budget))
})
}
/// [`overlaps_ifd`] for the Exif / GPS sub-IFD at `sub`, and for the Interoperability IFD (tag
/// 0xA005) it points to, which `jpeg.fill(0)` would otherwise erase from under its pointer.
fn overlaps_sub_ifd(order: Order, body: &[u8], sub: usize, off: usize, len: usize, budget: &mut usize) -> bool {
let Some(n) = order.u16(body, sub).map(usize::from) else { return false };
if overlaps_ifd(order, body, sub, n, off, len, budget) {
return true;
}
(0..n).any(|i| {
let Some(left) = budget.checked_sub(1) else { return true };
*budget = left;
let e = sub + 2 + i * 12;
matches!(order.u16(body, e), Some(0xA005))
&& order
.u32(body, e + 8)
.and_then(|v| usize::try_from(v).ok())
.is_some_and(|interop| order.u16(body, interop).is_some_and(|m| overlaps_ifd(order, body, interop, usize::from(m), off, len, budget)))
})
}
/// Whether `off..off + len` of `body` covers the table of the classic IFD at `ifd` (its entry
/// count's offset) with the next-IFD pointer, or an out-of-line value of one of its entries. Each
/// entry examined is charged to `budget`; when it runs out the answer is `true`.
fn overlaps_ifd(order: Order, body: &[u8], ifd: usize, count: usize, off: usize, len: usize, budget: &mut usize) -> bool {
let end = off.saturating_add(len);
let hits = |a: usize, size: usize| a < end && off < a.saturating_add(size);
if hits(ifd, 2 + count * 12 + 4) {
return true;
}
(0..count).any(|i| {
let Some(left) = budget.checked_sub(1) else { return true };
*budget = left;
let e = ifd + 2 + i * 12;
let unit = match order.u16(body, e + 2) {
Some(1 | 2 | 6 | 7) => 1,
Some(3 | 8) => 2,
Some(4 | 9 | 11) => 4,
Some(5 | 10 | 12) => 8,
_ => return false,
};
let size = usize::try_from(order.u32(body, e + 4).unwrap_or(0)).unwrap_or(usize::MAX).saturating_mul(unit);
size > 4 && order.u32(body, e + 8).and_then(|v| usize::try_from(v).ok()).is_some_and(|v| hits(v, size))
})
}
/// IFD1's thumbnail JPEG as (offset, length) in `body`, from the next-IFD pointer at `ptr`.
fn thumbnail_range(order: Order, body: &[u8], ptr: usize) -> Option<(usize, usize)> {
let ifd1 = usize::try_from(order.u32(body, ptr)?).ok()?;
let (mut off, mut len) = (None, None);
for i in 0..usize::from(order.u16(body, ifd1)?) {
let e = ifd1.checked_add(2)?.checked_add(i.checked_mul(12)?)?;
let tag = order.u16(body, e)?;
if !matches!(tag, 0x0201 | 0x0202) {
continue;
}
// One SHORT or LONG value; anything else holds an unrelated number.
let ty = order.u16(body, e + 2)?;
if order.u32(body, e + 4)? != 1 || !matches!(ty, 3 | 4) {
return None;
}
let value = if ty == 3 { usize::from(order.u16(body, e + 8)?) } else { usize::try_from(order.u32(body, e + 8)?).ok()? };
if tag == 0x0201 {
off = Some(value);
} else {
len = Some(value);
}
}
Some((off?, len?))
}
/// The EXIF to write next to upright pixels at resolution `ppi`: Orientation 1 (see
/// [`upright_exif`]), no thumbnail of the source image (see [`exif_without_thumbnail`]) and,
/// when `ppi` is known, the resolution (see [`exif_with_resolution`]).
pub fn export_exif(exif: &[u8], ppi: Option<(f32, f32)>) -> Cow<'_, [u8]> {
export_exif_inner(exif, ppi, false)
}
/// Export EXIF for a format without its own resolution field, adding missing X/Y resolution
/// entries to valid metadata when necessary.
pub fn export_exif_adding_missing_resolution(exif: &[u8], ppi: Option<(f32, f32)>) -> Cow<'_, [u8]> {
export_exif_inner(exif, ppi, true)
}
/// Normalize EXIF orientation and thumbnails, optionally adding missing output resolution tags.
fn export_exif_inner(exif: &[u8], ppi: Option<(f32, f32)>, add_missing: bool) -> Cow<'_, [u8]> {
let up = upright_exif(exif);
let up = match exif_without_thumbnail(&up) {
Some(v) => Cow::Owned(v),
None => up,
};
let Some(ppi) = ppi else { return up };
let result = if add_missing { exif_with_resolution_adding_missing(&up, ppi) } else { exif_with_resolution(&up, ppi) };
if let Cow::Owned(v) = result {
return Cow::Owned(v);
}
up
}
/// The XMP to write next to upright pixels at resolution `ppi` (see [`upright_xmp`] and
/// [`xmp_with_resolution`]).
pub fn export_xmp(xmp: &str, ppi: Option<(f32, f32)>) -> Cow<'_, str> {
let up = upright_xmp(xmp);
let Some(ppi) = ppi else { return up };
if let Cow::Owned(v) = xmp_with_resolution(&up, ppi) {
return Cow::Owned(v);
}
up
}
/// The resolution a decoded file's metadata blocks record, in precedence order: XMP, then
/// EXIF (Photoshop's ResolutionInfo is format-specific and read by the decoder).
pub fn resolution(exif: Option<&[u8]>, xmp: Option<&str>) -> Option<(f32, f32)> {
xmp.and_then(xmp_resolution).or_else(|| exif.and_then(exif_resolution))
}
#[cfg(test)]
mod complexity_tests;
@@ -0,0 +1,152 @@
//! Count actual offset equality checks, independent of machine speed, while comparing every byte.
use std::cell::Cell;
use super::{Ifd0, Order, TAG_X_RESOLUTION, TYPE_SHORT, exif_with_resolution, put_u16, put_u32, put_u64, remove_entries};
std::thread_local! {
// Each test thread owns its measurement; other parallel tests cannot change the budget.
static COMPARISONS: Cell<usize> = const { Cell::new(0) };
}
#[derive(Clone, Copy)]
struct CountedOffset(usize);
impl From<usize> for CountedOffset {
/// Wrap a real entry offset without counting construction as search work.
fn from(value: usize) -> Self {
Self(value)
}
}
impl PartialEq for CountedOffset {
/// Observe each equality actually requested by compaction, preserving its result.
fn eq(&self, other: &Self) -> bool {
COMPARISONS.with(|count| count.set(count.get() + 1));
self.0 == other.0
}
}
#[derive(Clone, Copy, Debug)]
enum RemovalPattern {
None,
All,
Alternating,
Prefix,
Suffix,
}
impl RemovalPattern {
/// Include exhaustion and long runs before the first removal, not just adjacent matches.
fn removes(self, index: usize, count: usize) -> bool {
match self {
Self::None => false,
Self::All => true,
Self::Alternating => index.is_multiple_of(2),
Self::Prefix => index < count / 2,
Self::Suffix => index >= count / 2,
}
}
}
/// Build a parsed EXIF/TIFF block with malformed duplicate XResolution fields. The expected
/// block is assembled from retained raw entries, leaving their shared payload, a valid empty
/// next IFD and trailing sentinel at the original absolute offsets.
fn fixture(order: Order, big: bool, count: usize, pattern: RemovalPattern) -> (Vec<u8>, Vec<u8>, Ifd0, Vec<usize>) {
let (header, count_bytes, entry_bytes, next_bytes, alignment) = if big { (16, 8, 20, 8, 8) } else { (8, 2, 12, 4, 2) };
let first = header + count_bytes;
let payload_at = first + count * entry_bytes + next_bytes;
let payload = b"unchanged out-of-line payload\0";
let next_at = (payload_at + payload.len()).next_multiple_of(alignment);
let mut header_bytes = match order {
Order::Little => b"II".to_vec(),
Order::Big => b"MM".to_vec(),
};
header_bytes.extend(put_u16(order, if big { 43 } else { 42 }));
if big {
header_bytes.extend(put_u16(order, 8));
header_bytes.extend(put_u16(order, 0));
header_bytes.extend(put_u64(order, header as u64));
} else {
header_bytes.extend(put_u32(order, header as u32));
}
let mut entries = Vec::new();
let mut kept = Vec::new();
for index in 0..count {
let malformed = pattern.removes(index, count);
let tag = if malformed { TAG_X_RESOLUTION } else { 0x8000 + index as u16 };
let mut entry = put_u16(order, tag).to_vec();
entry.extend(put_u16(order, if malformed { TYPE_SHORT } else { 2 }));
let values = if malformed { 1 } else { payload.len() as u64 };
if big {
entry.extend(put_u64(order, values));
} else {
entry.extend(put_u32(order, values as u32));
}
if malformed {
entry.extend(put_u16(order, 300));
} else if big {
entry.extend(put_u64(order, payload_at as u64));
} else {
entry.extend(put_u32(order, payload_at as u32));
}
entry.resize(entry_bytes, 0);
entries.extend_from_slice(&entry);
if !malformed {
kept.extend_from_slice(&entry);
}
}
let next = if big { put_u64(order, next_at as u64).to_vec() } else { put_u32(order, next_at as u32).to_vec() };
let encoded_count = |value: usize| {
if big { put_u64(order, value as u64).to_vec() } else { put_u16(order, value as u16).to_vec() }
};
let mut source = header_bytes.clone();
source.extend(encoded_count(count));
source.extend(entries);
source.extend_from_slice(&next);
source.extend_from_slice(payload);
source.resize(next_at, 0);
source.extend(vec![0; count_bytes + next_bytes]);
source.extend_from_slice(b"trailing sentinel");
let mut expected = header_bytes;
expected.extend(encoded_count(kept.len() / entry_bytes));
expected.extend(kept);
expected.extend(next);
expected.resize(payload_at, 0);
expected.extend_from_slice(&source[payload_at..]);
let ifd = Ifd0::parse(&source).expect("bounded synthetic IFD must parse");
let removed = ifd.fields.iter().map(|field| field.entry).collect();
(source, expected, ifd, removed)
}
/// Thousands of malformed entries must stay within a generous linear comparison budget,
/// while retaining exact bytes and agreeing with the public rewrite route. A per-entry
/// membership scan exceeds this budget even though its cleaned output is identical.
#[test]
fn large_cleanup_has_linear_comparison_budget_and_identical_bytes() {
for count in [4096, 8192] {
for big in [false, true] {
for order in [Order::Little, Order::Big] {
for pattern in
[RemovalPattern::None, RemovalPattern::All, RemovalPattern::Alternating, RemovalPattern::Prefix, RemovalPattern::Suffix]
{
let (source, expected, ifd, removed) = fixture(order, big, count, pattern);
let measured: Vec<CountedOffset> = removed.iter().copied().map(CountedOffset::from).collect();
let mut actual = source.clone();
COMPARISONS.with(|value| value.set(0));
remove_entries(&mut actual, &ifd, &measured);
let comparisons = COMPARISONS.with(Cell::get);
assert_eq!(actual, expected, "count={count}, big={big}, pattern={pattern:?}");
assert_eq!(exif_with_resolution(&source, (240.0, 240.0)).as_ref(), expected);
let budget = 4 * (count + removed.len() + 1);
assert!(
comparisons <= budget,
"offset comparison work {comparisons} exceeds linear budget {budget}: entries={count}, removed={}, big={big}, pattern={pattern:?}",
removed.len()
);
assert!(comparisons >= removed.len(), "measurement must observe the comparisons that find removals");
}
}
}
}
}
+2
View File
@@ -14,6 +14,7 @@
//! 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`].
@@ -21,6 +22,7 @@
mod container;
pub mod cr3;
mod datetime;
pub mod edit;
mod exif;
pub mod focus;
mod focus_read;
+142
View File
@@ -0,0 +1,142 @@
//! Stable IFD0 compaction under repeated malformed resolution tags.
//! Fixtures retain original value offsets and compare the entire metadata block.
use std::borrow::Cow;
use craft_metadata::edit::exif_with_resolution;
#[derive(Clone, Copy)]
struct Layout {
big_tiff: bool,
big_endian: bool,
}
impl Layout {
/// Encode a fixture integer at the selected width and byte order.
fn number(self, value: u64, width: usize) -> Vec<u8> {
if self.big_endian { value.to_be_bytes()[8 - width..].to_vec() } else { value.to_le_bytes()[..width].to_vec() }
}
/// Build source and expected blocks separately, leaving out-of-line data at its original
/// offsets. Cases select unrelated, malformed or rewritable entries in physical order.
fn fixture(self, cases: &[u8]) -> (Vec<u8>, Vec<u8>) {
let (header, count_bytes, entry_bytes, offset_bytes) = if self.big_tiff { (16, 8, 20, 8) } else { (8, 2, 12, 4) };
let first = header + count_bytes;
let table_end = first + cases.len() * entry_bytes;
let mut data = b"unrelated out-of-line value\0".to_vec();
let mut source_table = Vec::new();
let mut kept_table = Vec::new();
for &case in cases {
let rational = |n, d| [self.number(n, 4), self.number(d, 4)].concat();
let (tag, ty, count, value, replacement) = match case {
0 => (270, 2, data.len() as u64, data.clone(), Some(data.clone())),
1 => (282, 3, 1, self.number(300, 2), None), // wrong type
2 => (283, 5, 1, rational(300, 0), None), // zero denominator
3 => (296, 4, 1, self.number(3, 4), None), // wrong unit type
4 => (282, 5, 1, rational(300, 1), Some(rational(240, 1))),
5 => (283, 5, 1, rational(300, 1), Some(rational(240, 1))),
6 => (296, 3, 1, self.number(3, 2), Some(self.number(2, 2))),
7 => (282, 5, 2, [rational(300, 1), rational(300, 1)].concat(), None), // wrong count
9 => (282, 5, u64::MAX, vec![0; 16], None), // oversized value span
_ => (283, 1, 1, vec![99], None), // unknown value type
};
let mut entry = [self.number(tag, 2), self.number(ty, 2), self.number(count, offset_bytes)].concat();
let mut rewritten = entry.clone();
if value.len() > offset_bytes {
let at = table_end + offset_bytes + data.len();
entry.extend(self.number(at as u64, offset_bytes));
rewritten.extend(self.number(at as u64, offset_bytes));
// These values occupy their original slots even if their entry is removed.
data.extend_from_slice(replacement.as_deref().unwrap_or(&value));
} else {
let mut inline = value.clone();
inline.resize(offset_bytes, 0);
entry.extend(inline);
let mut inline = replacement.clone().unwrap_or_else(|| value.clone());
inline.resize(offset_bytes, 0);
rewritten.extend(inline);
}
source_table.extend_from_slice(&entry);
if replacement.is_some() {
kept_table.extend_from_slice(&rewritten);
}
}
let mut prefix = if self.big_endian { b"MM".to_vec() } else { b"II".to_vec() };
prefix.extend(self.number(if self.big_tiff { 43 } else { 42 }, 2));
if self.big_tiff {
prefix.extend(self.number(8, 2));
prefix.extend(self.number(0, 2));
}
prefix.extend(self.number(header as u64, offset_bytes));
let next = self.number(0x10203040, offset_bytes);
let mut source = prefix.clone();
source.extend(self.number(cases.len() as u64, count_bytes));
source.extend_from_slice(&source_table);
source.extend_from_slice(&next);
source.extend_from_slice(&data);
// Replace the rewritten rational payloads in the source with their original values.
// Locate them using source entry offsets, never offsets from the compacted table.
for (case, entry) in cases.iter().zip(source_table.chunks_exact(entry_bytes)) {
if !self.big_tiff && matches!(case, 4 | 5) {
let bytes: [u8; 4] = entry[8..12].try_into().unwrap();
let at = if self.big_endian { u32::from_be_bytes(bytes) } else { u32::from_le_bytes(bytes) } as usize;
source[at..at + 4].copy_from_slice(&self.number(300, 4));
}
}
let mut expected = prefix;
expected.extend(self.number((kept_table.len() / entry_bytes) as u64, count_bytes));
expected.extend_from_slice(&kept_table);
expected.extend_from_slice(&next);
expected.resize(table_end + offset_bytes, 0);
expected.extend_from_slice(&data);
(source, expected)
}
}
/// All layout combinations share the same compaction contract, including JPEG-prefixed EXIF.
fn layouts() -> impl Iterator<Item = Layout> {
[false, true].into_iter().flat_map(|big_tiff| [false, true].into_iter().map(move |big_endian| Layout { big_tiff, big_endian }))
}
/// Every removal mask exercises first/middle/last entries, adjacent runs, all and none removed.
/// Mixed valid duplicates must still be rewritten and unrelated payload offsets must not move.
#[test]
fn compaction_preserves_complete_blocks_for_removal_patterns() {
for layout in layouts() {
for mask in 0..256 {
let cases: Vec<u8> = (0..8).map(|i| if mask & (1 << i) != 0 { 1 + (i % 3) as u8 } else { [0, 4, 5, 6][i % 4] }).collect();
let (source, expected) = layout.fixture(&cases);
for prefix in [b"".as_slice(), b"Exif\0\0".as_slice()] {
let source = [prefix, &source].concat();
let expected = [prefix, &expected].concat();
assert_eq!(exif_with_resolution(&source, (240.0, 240.0)).as_ref(), expected, "mask {mask}");
}
}
let (source, expected) = layout.fixture(&[7, 4, 1, 4, 8, 5, 2, 5, 3, 6, 9, 0]);
assert_eq!(exif_with_resolution(&source, (240.0, 240.0)).as_ref(), expected);
}
}
/// Invalid output resolution, empty/missing metadata and already-correct first duplicates keep
/// the borrowed fast path; truncated or invalid table layouts cannot be partially compacted.
#[test]
fn cleanup_preserves_noop_and_invalid_layout_behavior() {
for layout in layouts() {
let (source, _) = layout.fixture(&[1, 4, 2, 5, 3, 6, 0]);
for ppi in [(0.0, 240.0), (-1.0, 240.0), (f32::NAN, 240.0), (240.0, f32::INFINITY), (1_000_001.0, 240.0)] {
assert!(matches!(exif_with_resolution(&source, ppi), Cow::Borrowed(_)));
}
let table_end = if layout.big_tiff { 24 + 7 * 20 + 8 } else { 10 + 7 * 12 + 4 };
for n in 0..table_end {
assert!(matches!(exif_with_resolution(&source[..n], (240.0, 240.0)), Cow::Borrowed(_)), "length {n}");
}
let mut invalid = source;
invalid[0] = 0;
assert!(matches!(exif_with_resolution(&invalid, (240.0, 240.0)), Cow::Borrowed(_)));
for cases in [&[][..], &[0][..], &[4, 5, 4, 1][..]] {
let (source, _) = layout.fixture(cases);
assert!(matches!(exif_with_resolution(&source, (300.0, 300.0)), Cow::Borrowed(_)));
}
}
assert!(matches!(exif_with_resolution(&[], (240.0, 240.0)), Cow::Borrowed(_)));
}
@@ -0,0 +1,256 @@
//! Orientation in Exif and XMP blocks: read, and rewritten to 1 in place.
use craft_metadata::edit::*;
use proptest::prelude::{any, prop_assert, prop_assert_eq, proptest};
/// A minimal EXIF (TIFF-structured) block: a filler tag, then Orientation.
fn exif(o: u32, big: bool, long: bool) -> Vec<u8> {
let u16b = |v: u16| if big { v.to_be_bytes() } else { v.to_le_bytes() };
let u32b = |v: u32| if big { v.to_be_bytes() } else { v.to_le_bytes() };
let mut v = if big { b"MM\0*".to_vec() } else { b"II*\0".to_vec() };
v.extend_from_slice(&u32b(8));
v.extend_from_slice(&u16b(2));
// ImageDescription-ish filler (ASCII, 4 bytes inline).
v.extend_from_slice(&u16b(0x010E));
v.extend_from_slice(&u16b(2));
v.extend_from_slice(&u32b(4));
v.extend_from_slice(b"abc\0");
v.extend_from_slice(&u16b(0x0112));
if long {
v.extend_from_slice(&u16b(4));
v.extend_from_slice(&u32b(1));
v.extend_from_slice(&u32b(o));
} else {
v.extend_from_slice(&u16b(3));
v.extend_from_slice(&u32b(1));
v.extend_from_slice(&u16b(o as u16));
v.extend_from_slice(&[0, 0]);
}
v.extend_from_slice(&u32b(0));
v
}
#[test]
fn reads_orientation_in_every_encoding() {
for o in 1..=8 {
for big in [false, true] {
for long in [false, true] {
let e = exif(o, big, long);
assert_eq!(exif_orientation(&e), o as u16, "o={o} big={big} long={long}");
let mut prefixed = b"Exif\0\0".to_vec();
prefixed.extend_from_slice(&e);
assert_eq!(exif_orientation(&prefixed), o as u16);
let fixed = upright_exif(&prefixed);
assert_eq!(exif_orientation(&fixed), 1);
assert_eq!(fixed.len(), prefixed.len(), "rewritten in place");
assert_eq!(&fixed[..6], b"Exif\0\0");
// Rewritten in the entry's own type and width: exactly `exif(1, ..)`.
assert_eq!(&fixed[6..], &exif(1, big, long)[..], "o={o} big={big} long={long}");
}
}
}
// Already upright, no tag, or garbage: borrowed unchanged.
assert!(matches!(upright_exif(&exif(1, false, false)), std::borrow::Cow::Borrowed(_)));
assert!(matches!(upright_exif(&ifd(false, &[(0x0131, 2, 4, *b"PC1\0")], &[])), std::borrow::Cow::Borrowed(_)));
assert!(matches!(upright_exif(b"nonsense"), std::borrow::Cow::Borrowed(_)));
}
#[test]
fn malformed_or_out_of_range_orientation_reads_as_1() {
for bad in [0, 9, 255, 0xFFFF, 0x1_0006] {
assert_eq!(exif_orientation(&exif(bad, false, true)), 1, "{bad}");
}
let good = exif(6, false, false);
assert_eq!(exif_orientation(&good), 6);
for n in 0..good.len() {
// Any cut, even one that keeps the Orientation entry but loses the next-IFD pointer.
assert_eq!(exif_orientation(&good[..n]), 1, "truncated at {n}");
assert!(matches!(upright_exif(&good[..n]), std::borrow::Cow::Borrowed(_)), "truncated at {n}");
}
// IFD offset past the end, a huge entry count, a wrong field type.
let mut far = good.clone();
far[4..8].copy_from_slice(&u32::MAX.to_le_bytes());
assert_eq!(exif_orientation(&far), 1);
let mut many = good.clone();
many[8..10].copy_from_slice(&u16::MAX.to_le_bytes());
assert_eq!(exif_orientation(&many), 1, "an entry count past the end is a truncated table");
let mut typed = good.clone();
typed[24..26].copy_from_slice(&2u16.to_le_bytes()); // ASCII
assert_eq!(exif_orientation(&typed), 1);
assert_eq!(exif_orientation(&[]), 1);
assert_eq!(exif_orientation(b"II*\0"), 1);
}
proptest! {
#[test]
fn random_exif_never_panics(bytes in proptest::collection::vec(any::<u8>(), 0..256), le in any::<bool>()) {
let mut b = if le { b"II*\0\x08\0\0\0".to_vec() } else { b"MM\0*\0\0\0\x08".to_vec() };
b.extend_from_slice(&bytes);
let o = exif_orientation(&b);
prop_assert!((1..=8).contains(&o));
prop_assert_eq!(exif_orientation(&upright_exif(&b)), 1);
let _ = exif_orientation(&bytes);
let _ = upright_exif(&bytes);
}
#[test]
fn random_xmp_never_panics(s in ".{0,64}", tail in ".{0,16}") {
let x = format!("{s}tiff:Orientation{tail}");
let _ = upright_xmp(&x);
}
}
#[test]
fn xmp_orientation_is_rewritten() {
let attr = r#"<rdf:Description tiff:Orientation="6" tiff:Make="X"/>"#;
assert_eq!(upright_xmp(attr), r#"<rdf:Description tiff:Orientation="1" tiff:Make="X"/>"#);
let elem = "<tiff:Orientation>8</tiff:Orientation><tiff:Orientation>3</tiff:Orientation>";
assert_eq!(upright_xmp(elem), "<tiff:Orientation>1</tiff:Orientation><tiff:Orientation>1</tiff:Orientation>");
assert_eq!(upright_xmp("tiff:Orientation='12'"), "tiff:Orientation='1'");
for same in [r#"tiff:Orientation="1""#, "no orientation", "tiff:Orientation", "tiff:Orientation=\"\"", "tiff:Orientation: 6"] {
assert!(matches!(upright_xmp(same), std::borrow::Cow::Borrowed(_)), "{same}");
}
}
/// A TIFF-structured EXIF block: IFD0 at offset 8 holding `entries`
/// (`tag, type, count, 4-byte value field`), a zero next-IFD pointer, then `tail`.
fn ifd(big: bool, entries: &[(u16, u16, u32, [u8; 4])], tail: &[u8]) -> Vec<u8> {
let u16b = |v: u16| if big { v.to_be_bytes() } else { v.to_le_bytes() };
let u32b = |v: u32| if big { v.to_be_bytes() } else { v.to_le_bytes() };
let mut v = if big { b"MM\0*".to_vec() } else { b"II*\0".to_vec() };
v.extend_from_slice(&u32b(8));
v.extend_from_slice(&u16b(entries.len() as u16));
for &(tag, ty, count, value) in entries {
v.extend_from_slice(&u16b(tag));
v.extend_from_slice(&u16b(ty));
v.extend_from_slice(&u32b(count));
v.extend_from_slice(&value);
}
v.extend_from_slice(&u32b(0));
v.extend_from_slice(tail);
v
}
/// The 4-byte value field of a SHORT.
fn short(v: u16, big: bool) -> [u8; 4] {
let b = if big { v.to_be_bytes() } else { v.to_le_bytes() };
[b[0], b[1], 0, 0]
}
#[test]
fn duplicate_orientation_entries_first_wins() {
for big in [false, true] {
let o = |v| (0x0112, 3, 1, short(v, big));
assert_eq!(exif_orientation(&ifd(big, &[o(6), o(3)], &[])), 6);
assert_eq!(exif_orientation(&ifd(big, &[o(3), o(6)], &[])), 3);
assert_eq!(exif_orientation(&ifd(big, &[o(1), o(8)], &[])), 1);
// A malformed first entry is ignored as a whole: a later good one does not count.
let bad_first = ifd(big, &[(0x0112, 3, 2, short(6, big)), o(8)], &[]);
assert_eq!(exif_orientation(&bad_first), 1);
let long = |v: u32| (0x0112, 4, 1, if big { v.to_be_bytes() } else { v.to_le_bytes() });
let long_first = ifd(big, &[long(6), o(8)], &[]);
assert_eq!(exif_orientation(&long_first), 6, "a LONG first entry counts");
assert_eq!(&upright_exif(&long_first)[..], &ifd(big, &[long(1), o(1)], &[])[..], "each in its own width");
let rational_first = ifd(big, &[(0x0112, 5, 1, short(6, big)), o(8)], &[]);
assert_eq!(exif_orientation(&rational_first), 1);
// Upright: every well-formed duplicate becomes 1, so no reader rotates twice.
let dup = ifd(big, &[o(6), o(3)], &[]);
let fixed = upright_exif(&dup);
assert_eq!(fixed.len(), dup.len());
assert_eq!(&fixed[..], &ifd(big, &[o(1), o(1)], &[])[..]);
// Only a later duplicate disagrees: it is rewritten too.
assert_eq!(&upright_exif(&ifd(big, &[o(1), o(8)], &[]))[..], &ifd(big, &[o(1), o(1)], &[])[..]);
assert!(matches!(upright_exif(&ifd(big, &[o(1), o(1)], &[])), std::borrow::Cow::Borrowed(_)));
}
}
#[test]
fn strict_ifd_parsing_ignores_the_tag() {
for big in [false, true] {
let o6 = (0x0112, 3, 1, short(6, big));
let good = ifd(big, &[o6], &[]);
assert_eq!(exif_orientation(&good), 6);
// Truncated before (or inside) the next-IFD pointer.
for cut in 1..=4 {
assert_eq!(exif_orientation(&good[..good.len() - cut]), 1, "cut {cut}");
}
// IFD offsets inside the 8-byte header.
for off in 0..8u32 {
let mut b = good.clone();
b[4..8].copy_from_slice(&if big { off.to_be_bytes() } else { off.to_le_bytes() });
assert_eq!(exif_orientation(&b), 1, "offset {off}");
assert!(matches!(upright_exif(&b), std::borrow::Cow::Borrowed(_)));
}
// Count must be exactly 1, type must be SHORT or LONG.
let long6 = if big { 6u32.to_be_bytes() } else { 6u32.to_le_bytes() };
for count in [0, 2, 3, u32::MAX] {
assert_eq!(exif_orientation(&ifd(big, &[(0x0112, 3, count, short(6, big))], &[])), 1, "count {count}");
assert_eq!(exif_orientation(&ifd(big, &[(0x0112, 4, count, long6)], &[])), 1, "LONG count {count}");
}
for ty in [1, 2, 5, 6, 7, 8, 9, 0, 0xFFFF] {
let b = ifd(big, &[(0x0112, ty, 1, short(6, big))], &[]);
assert_eq!(exif_orientation(&b), 1, "type {ty}");
assert!(matches!(upright_exif(&b), std::borrow::Cow::Borrowed(_)), "type {ty}");
}
}
}
#[test]
fn upright_exif_keeps_every_other_byte() {
for big in [false, true] {
let u32b = |v: u32| if big { v.to_be_bytes() } else { v.to_le_bytes() };
// IFD0: Make (12-byte ASCII stored after the IFD), Orientation 6, Software (inline ASCII),
// ExifIFD pointer to a sub-IFD with its own entries.
let ifd_end = 8 + 2 + 4 * 12 + 4;
let make_at = ifd_end as u32;
let sub_at = make_at + 12;
let mut tail = b"PhotoCraftCo".to_vec();
// Sub-IFD: one DateTimeOriginal-ish SHORT entry, then next-IFD 0.
tail.extend_from_slice(&if big { 1u16.to_be_bytes() } else { 1u16.to_le_bytes() });
tail.extend_from_slice(&if big { 0x9000u16.to_be_bytes() } else { 0x9000u16.to_le_bytes() });
tail.extend_from_slice(&if big { 7u16.to_be_bytes() } else { 7u16.to_le_bytes() });
tail.extend_from_slice(&u32b(4));
tail.extend_from_slice(b"0232");
tail.extend_from_slice(&u32b(0));
let e = ifd(
big,
&[(0x010F, 2, 12, u32b(make_at)), (0x0112, 3, 1, short(6, big)), (0x0131, 2, 4, *b"PC1\0"), (0x8769, 4, 1, u32b(sub_at))],
&tail,
);
let mut prefixed = b"Exif\0\0".to_vec();
prefixed.extend_from_slice(&e);
let fixed = upright_exif(&prefixed).into_owned();
assert_eq!(exif_orientation(&fixed), 1);
assert_eq!(fixed.len(), prefixed.len());
// Only the Orientation value (2 bytes in the second entry) differs.
let value = 6 + 8 + 2 + 12 + 8;
let changed: Vec<usize> = (0..fixed.len()).filter(|&i| fixed[i] != prefixed[i]).collect();
assert_eq!(changed, vec![if big { value + 1 } else { value }], "big={big}");
assert_eq!(&fixed[..value], &prefixed[..value]);
assert_eq!(&fixed[value + 2..], &prefixed[value + 2..]);
// The offset-based Make string and the inline Software tag are intact.
assert_eq!(&fixed[6 + make_at as usize..][..12], b"PhotoCraftCo");
assert!(fixed.windows(4).any(|w| w == b"PC1\0"));
}
}
#[test]
fn long_orientation_reads_and_rewrites_in_both_byte_orders() {
for big in [false, true] {
let u32b = |v: u32| if big { v.to_be_bytes() } else { v.to_le_bytes() };
let long = |v: u32| (0x0112u16, 4u16, 1u32, u32b(v));
for o in 1..=8u32 {
let e = ifd(big, &[(0x0131, 2, 4, *b"PC1\0"), long(o)], b"tail");
assert_eq!(exif_orientation(&e), o as u16, "o={o} big={big}");
let fixed = upright_exif(&e);
assert_eq!(exif_orientation(&fixed), 1);
// Still a LONG, all 4 value bytes set to 1 in the file's byte order; nothing else moved.
assert_eq!(&fixed[..], &ifd(big, &[(0x0131, 2, 4, *b"PC1\0"), long(1)], b"tail")[..], "o={o} big={big}");
}
// Out of range, including values that only fit in a LONG, reads as 1 and is left alone.
for bad in [0, 9, 0x1_0006, u32::MAX] {
let e = ifd(big, &[long(bad)], &[]);
assert_eq!(exif_orientation(&e), 1, "{bad}");
}
}
}
+136
View File
@@ -0,0 +1,136 @@
//! An exported file's EXIF names the pixel size it is written at, not the camera
//! sensor's. `exif_with_pixel_size` rewrites PixelXDimension and PixelYDimension in place.
use std::borrow::Cow;
use craft_metadata::edit::exif_with_pixel_size;
const PIXEL_X: u16 = 40962;
const PIXEL_Y: u16 = 40963;
const SHORT: u16 = 3;
const LONG: u16 = 4;
/// A classic TIFF block: IFD0 = Make + the Exif IFD pointer, the Exif IFD = `exif` entries
/// `(tag, type, value)` holding one inline SHORT or LONG each. `big` = big-endian.
fn block(big: bool, exif: &[(u16, u16, u32)]) -> Vec<u8> {
let u16b = |v: u16| if big { v.to_be_bytes() } else { v.to_le_bytes() };
let u32b = |v: u32| if big { v.to_be_bytes() } else { v.to_le_bytes() };
let mut v = if big { b"MM\0*".to_vec() } else { b"II*\0".to_vec() };
v.extend(u32b(8));
// IFD0: two entries, then no next IFD; the Exif IFD follows at 8 + 2 + 24 + 4 = 38.
v.extend(u16b(2));
v.extend(u16b(0x010F));
v.extend(u16b(2));
v.extend(u32b(4));
v.extend(b"Sony");
v.extend(u16b(34665));
v.extend(u16b(LONG));
v.extend(u32b(1));
v.extend(u32b(38));
v.extend(u32b(0));
v.extend(u16b(exif.len() as u16));
for &(tag, ty, value) in exif {
v.extend(u16b(tag));
v.extend(u16b(ty));
v.extend(u32b(1));
if ty == SHORT {
v.extend(u16b(value as u16));
v.extend([0, 0]);
} else {
v.extend(u32b(value));
}
}
v.extend(u32b(0));
v
}
#[test]
fn the_sensor_size_becomes_the_written_size_in_either_byte_order() {
for big in [false, true] {
let src = block(big, &[(33434, LONG, 7), (PIXEL_X, LONG, 8192), (PIXEL_Y, LONG, 5464)]);
let out = exif_with_pixel_size(&src, (900, 600));
// Exactly what an encoder would write for a 900 × 600 image; nothing else changes.
assert_eq!(out.as_ref(), block(big, &[(33434, LONG, 7), (PIXEL_X, LONG, 900), (PIXEL_Y, LONG, 600)]), "big-endian {big}");
}
}
/// A little-endian BigTIFF block: IFD0 holds only the Exif IFD pointer (an IFD8), the Exif IFD
/// `exif` entries `(tag, type, value)`, each one value in the entry's 8-byte field.
fn big_tiff(exif: &[(u16, u16, u32)]) -> Vec<u8> {
let mut v = b"II+\0".to_vec();
v.extend(8u16.to_le_bytes());
v.extend(0u16.to_le_bytes());
v.extend(16u64.to_le_bytes());
// IFD0 at 16: count, one 20-byte entry, next IFD; the Exif IFD follows at 16 + 8 + 20 + 8 = 52.
v.extend(1u64.to_le_bytes());
v.extend(34665u16.to_le_bytes());
v.extend(18u16.to_le_bytes());
v.extend(1u64.to_le_bytes());
v.extend(52u64.to_le_bytes());
v.extend(0u64.to_le_bytes());
v.extend((exif.len() as u64).to_le_bytes());
for &(tag, ty, value) in exif {
v.extend(tag.to_le_bytes());
v.extend(ty.to_le_bytes());
v.extend(1u64.to_le_bytes());
let mut field = [0u8; 8];
if ty == SHORT {
field[..2].copy_from_slice(&(value as u16).to_le_bytes());
} else {
field[..4].copy_from_slice(&value.to_le_bytes());
}
v.extend(field);
}
v.extend(0u64.to_le_bytes());
v
}
#[test]
fn a_big_tiff_block_is_rewritten_too() {
let src = big_tiff(&[(PIXEL_X, SHORT, 6000), (33434, LONG, 7), (PIXEL_Y, LONG, 4000)]);
assert_eq!(exif_with_pixel_size(&src, (90, 60)).as_ref(), big_tiff(&[(PIXEL_X, LONG, 90), (33434, LONG, 7), (PIXEL_Y, LONG, 60)]));
}
#[test]
fn a_short_dimension_is_written_as_a_long_so_a_large_size_fits() {
let src = block(false, &[(PIXEL_X, SHORT, 6000), (PIXEL_Y, SHORT, 4000)]);
assert_eq!(exif_with_pixel_size(&src, (70_000, 300)).as_ref(), block(false, &[(PIXEL_X, LONG, 70_000), (PIXEL_Y, LONG, 300)]));
}
#[test]
fn the_jpeg_exif_prefix_is_kept() {
let src = [b"Exif\0\0".as_slice(), &block(true, &[(PIXEL_X, LONG, 10), (PIXEL_Y, LONG, 10)])].concat();
let want = [b"Exif\0\0".as_slice(), &block(true, &[(PIXEL_X, LONG, 4), (PIXEL_Y, LONG, 3)])].concat();
assert_eq!(exif_with_pixel_size(&src, (4, 3)).as_ref(), want);
}
#[test]
fn nothing_to_change_borrows_the_block() {
let right = block(false, &[(PIXEL_X, SHORT, 4), (PIXEL_Y, LONG, 3)]);
assert!(matches!(exif_with_pixel_size(&right, (4, 3)), Cow::Borrowed(_)));
let without = block(false, &[(33434, LONG, 7)]);
assert!(matches!(exif_with_pixel_size(&without, (4, 3)), Cow::Borrowed(_)));
}
#[test]
fn malformed_blocks_are_left_as_they_are() {
let src = block(false, &[(PIXEL_X, LONG, 8192), (PIXEL_Y, LONG, 5464)]);
// Every truncation up to the Exif IFD's next-IFD pointer (which this never reads), and
// garbage, comes back unchanged without panicking.
for n in 0..src.len() - 4 {
assert_eq!(exif_with_pixel_size(&src[..n], (1, 1)).as_ref(), &src[..n], "truncated to {n}");
}
assert!(matches!(exif_with_pixel_size(b"not a tiff at all", (1, 1)), Cow::Borrowed(_)));
// Dimensions with two values each aren't ones this rewrites (entries at 40 and 52).
let mut two = src.clone();
for e in [40, 52] {
two[e + 4..e + 8].copy_from_slice(&2u32.to_le_bytes());
}
assert!(matches!(exif_with_pixel_size(&two, (1, 1)), Cow::Borrowed(_)));
// An Exif IFD pointer with no value or several (its count at 26) points nowhere reliable.
for n in [0u32, 2] {
let mut odd = src.clone();
odd[26..30].copy_from_slice(&n.to_le_bytes());
assert!(matches!(exif_with_pixel_size(&odd, (1, 1)), Cow::Borrowed(_)), "pointer count {n}");
}
}
+361
View File
@@ -0,0 +1,361 @@
//! Resolution in Exif and XMP blocks, rewritten in place, and the Exif thumbnail dropped on
//! export. Every block here is built by hand.
use std::borrow::Cow;
use craft_metadata::edit::*;
use proptest::prelude::{any, proptest};
const X: u16 = 282;
const Y: u16 = 283;
const UNIT: u16 = 296;
const SHORT: u16 = 3;
const LONG: u16 = 4;
const RATIONAL: u16 = 5;
const ASCII: u16 = 2;
/// One IFD0 entry: tag, type, count and the value bytes (in the block's byte order).
struct E(u16, u16, u32, Vec<u8>);
struct Exif {
big: bool,
}
impl Exif {
fn u16(&self, v: u16) -> [u8; 2] {
if self.big { v.to_be_bytes() } else { v.to_le_bytes() }
}
fn u32(&self, v: u32) -> [u8; 4] {
if self.big { v.to_be_bytes() } else { v.to_le_bytes() }
}
fn rational(&self, n: u32, d: u32) -> Vec<u8> {
[self.u32(n), self.u32(d)].concat()
}
fn short(&self, v: u16) -> Vec<u8> {
self.u16(v).to_vec()
}
fn x(&self, n: u32, d: u32) -> E {
E(X, RATIONAL, 1, self.rational(n, d))
}
fn y(&self, n: u32, d: u32) -> E {
E(Y, RATIONAL, 1, self.rational(n, d))
}
fn unit(&self, u: u16) -> E {
E(UNIT, SHORT, 1, self.short(u))
}
fn make(&self) -> E {
E(0x010F, ASCII, 6, b"NIKON\0".to_vec())
}
fn orientation(&self, o: u16) -> E {
E(0x0112, SHORT, 1, self.short(o))
}
/// A TIFF-structured EXIF block: header, IFD0 at 8, then the out-of-line values.
fn build(&self, entries: &[E]) -> Vec<u8> {
let mut v = if self.big { b"MM\0*".to_vec() } else { b"II*\0".to_vec() };
v.extend_from_slice(&self.u32(8));
v.extend_from_slice(&self.u16(entries.len() as u16));
let mut data_at = 8 + 2 + entries.len() * 12 + 4;
let mut data = Vec::new();
for E(tag, ty, count, value) in entries {
v.extend_from_slice(&self.u16(*tag));
v.extend_from_slice(&self.u16(*ty));
v.extend_from_slice(&self.u32(*count));
if value.len() <= 4 {
let mut inline = value.clone();
inline.resize(4, 0);
v.extend_from_slice(&inline);
} else {
v.extend_from_slice(&self.u32(data_at as u32));
data.extend_from_slice(value);
data_at += value.len();
}
}
v.extend_from_slice(&self.u32(0));
v.extend_from_slice(&data);
v
}
}
const LE: Exif = Exif { big: false };
const BE: Exif = Exif { big: true };
/// A D3200-style block: Make, XResolution 300/1, YResolution 300/1, ResolutionUnit 2.
fn camera_exif(e: &Exif) -> Vec<u8> {
e.build(&[e.make(), e.x(300, 1), e.y(300, 1), e.unit(2)])
}
fn near(got: Option<(f32, f32)>, want: f32) {
let (x, y) = got.unwrap_or_else(|| panic!("no resolution, wanted {want}"));
assert!((x - want).abs() < 0.01 && (y - want).abs() < 0.01, "got {x}×{y}, wanted {want}");
}
proptest! {
#[test]
fn hostile_bytes_never_panic(bytes in proptest::collection::vec(any::<u8>(), 0..256), ppi in 0.0f32..2e6) {
let _ = exif_resolution(&bytes);
let _ = exif_with_resolution(&bytes, (ppi, ppi));
let _ = photoshop_resolution(&bytes);
let s = String::from_utf8_lossy(&bytes);
let _ = xmp_resolution(&s);
let _ = xmp_with_resolution(&s, (ppi, ppi));
}
#[test]
fn mutated_exif_never_panics(i in 0usize..80, v in any::<u8>(), ppi in 1.0f32..10_000.0) {
let mut exif = camera_exif(&LE);
if let Some(b) = exif.get_mut(i) { *b = v; }
let _ = exif_resolution(&exif);
let fixed = exif_with_resolution(&exif, (ppi, ppi));
let _ = exif_resolution(&fixed);
let _ = exif_orientation(&fixed);
}
}
#[test]
fn rewriting_keeps_every_other_byte() {
for e in [&LE, &BE] {
let src = e.build(&[e.make(), e.orientation(6), e.x(300, 1), e.y(300, 1), e.unit(3)]);
let out = exif_with_resolution(&src, (240.0, 240.0)).into_owned();
assert_eq!(out.len(), src.len());
near(exif_resolution(&out), 240.0);
let want = e.build(&[e.make(), e.orientation(6), e.x(240, 1), e.y(240, 1), e.unit(2)]);
assert_eq!(out, want);
// Fractions keep three decimals.
near(exif_resolution(&exif_with_resolution(&src, (299.5, 72.25))).map(|(x, _)| (x, x)), 299.5);
// Nothing to change: borrowed.
assert!(matches!(exif_with_resolution(&camera_exif(e), (300.0, 300.0)), Cow::Borrowed(_)));
let no_res = e.build(&[e.make()]);
assert!(matches!(exif_with_resolution(&no_res, (240.0, 240.0)), Cow::Borrowed(_)));
// With the JPEG prefix.
let prefixed = [b"Exif\0\0".as_slice(), &src].concat();
assert_eq!(exif_with_resolution(&prefixed, (240.0, 240.0)).as_ref(), [b"Exif\0\0".as_slice(), &want].concat());
}
}
#[test]
fn entries_that_cannot_be_rewritten_are_removed() {
for e in [&LE, &BE] {
// XResolution as a SHORT and a LONG unit: both go, the rest stays readable.
let src = e.build(&[e.make(), E(X, SHORT, 1, e.short(300)), e.y(300, 1), E(UNIT, LONG, 1, e.u32(2).to_vec()), e.orientation(6)]);
let out = exif_with_resolution(&src, (240.0, 240.0)).into_owned();
assert_eq!(out.len(), src.len());
let count = if e.big { u16::from_be_bytes([out[8], out[9]]) } else { u16::from_le_bytes([out[8], out[9]]) };
assert_eq!(count, 3);
assert_eq!(exif_orientation(&out), 6);
// Only YResolution is left (240): no X, so no resolution at all, and no stale 300.
assert_eq!(exif_resolution(&out), None);
let tags: Vec<u16> = (0..3)
.map(|k| {
let at = 10 + k * 12;
if e.big { u16::from_be_bytes([out[at], out[at + 1]]) } else { u16::from_le_bytes([out[at], out[at + 1]]) }
})
.collect();
assert_eq!(tags, vec![0x010F, Y, 0x0112]);
// The next-IFD pointer followed the table, the freed entries are zero.
assert_eq!(&out[10 + 36..10 + 40], &[0, 0, 0, 0]);
assert!(out[10 + 40..10 + 64].iter().all(|&b| b == 0));
// The Make string (out of line) did not move.
assert!(out.windows(6).any(|w| w == b"NIKON\0"));
}
}
#[test]
fn xmp_resolution_is_rewritten() {
let attr = r#"<rdf:Description tiff:XResolution="300/1" tiff:YResolution='300/1' tiff:ResolutionUnit="3" tiff:Make="X"/>"#;
assert_eq!(
xmp_with_resolution(attr, (240.0, 240.0)),
r#"<rdf:Description tiff:XResolution="240/1" tiff:YResolution='240/1' tiff:ResolutionUnit="2" tiff:Make="X"/>"#
);
let elem = "<tiff:XResolution>300/1</tiff:XResolution><tiff:YResolution>300/1</tiff:YResolution>";
assert_eq!(
xmp_with_resolution(elem, (72.5, 72.5)),
"<tiff:XResolution>72500/1000</tiff:XResolution><tiff:YResolution>72500/1000</tiff:YResolution>"
);
for same in [r#"tiff:XResolution="240" tiff:YResolution="240/1""#, "<x/>"] {
assert!(matches!(xmp_with_resolution(same, (240.0, 240.0)), Cow::Borrowed(_)), "{same}");
}
}
/// `camera_exif` plus IFD1, the embedded thumbnail a camera (or the last editor) wrote:
/// JPEGInterchangeFormat / JPEGInterchangeFormatLength naming a fake JPEG after the IFD.
fn exif_with_thumbnail(e: &Exif) -> Vec<u8> {
let mut v = camera_exif(e);
let ifd1 = v.len() as u32;
// IFD0 (4 entries) ends with its next-IFD pointer.
v[8 + 2 + 4 * 12..8 + 2 + 4 * 12 + 4].copy_from_slice(&e.u32(ifd1));
let thumb = b"\xFF\xD8stale thumbnail\xFF\xD9";
let thumb_at = ifd1 + 2 + 2 * 12 + 4;
v.extend_from_slice(&e.u16(2));
for (tag, value) in [(0x0201u16, thumb_at), (0x0202, thumb.len() as u32)] {
v.extend_from_slice(&e.u16(tag));
v.extend_from_slice(&e.u16(LONG));
v.extend_from_slice(&e.u32(1));
v.extend_from_slice(&e.u32(value));
}
v.extend_from_slice(&e.u32(0));
v.extend_from_slice(thumb);
v
}
fn next_ifd(e: &Exif, exif: &[u8]) -> u32 {
let at = 8 + 2 + 4 * 12;
let b: [u8; 4] = exif[at..at + 4].try_into().unwrap();
if e.big { u32::from_be_bytes(b) } else { u32::from_le_bytes(b) }
}
/// #2147 / #2373: an export must not carry the source's EXIF thumbnail (IFD1). Finder, Android
/// and upload pickers show it instead of the image, so an edited photo kept its old thumbnail.
#[test]
fn exports_drop_the_source_exif_thumbnail() {
for e in [&LE, &BE] {
let exif = exif_with_thumbnail(e);
assert_ne!(next_ifd(e, &exif), 0);
for ppi in [None, Some((300.0, 300.0)), Some((240.0, 240.0))] {
let out = export_exif(&exif, ppi);
assert_eq!(next_ifd(e, &out), 0, "IFD1 is unlinked ({ppi:?})");
assert_eq!(out.len(), exif.len(), "nothing moves");
assert!(out.windows(6).any(|w| w == b"NIKON\0"), "the rest of the EXIF is kept");
near(exif_resolution(&out), ppi.map_or(300.0, |p| p.0));
}
// Only the pointer and the thumbnail's own bytes (the JPEG at the end) change.
let out = export_exif(&exif, None);
let thumb_len = b"\xFF\xD8stale thumbnail\xFF\xD9".len();
let differing: Vec<usize> = (0..exif.len()).filter(|&i| exif[i] != out[i]).collect();
assert!(differing.iter().all(|&i| (8 + 2 + 4 * 12..8 + 2 + 4 * 12 + 4).contains(&i) || i >= exif.len() - thumb_len), "{differing:?}");
assert!(!out.windows(5).any(|w| w == b"stale"), "the thumbnail bytes are blanked, not just unlinked");
// With a JPEG-style prefix too.
let prefixed = [b"Exif\0\0".as_slice(), &exif].concat();
assert_eq!(next_ifd(e, &export_exif(&prefixed, None)[6..]), 0);
// An EXIF without a thumbnail comes through untouched.
assert!(matches!(export_exif(&camera_exif(e), None), Cow::Borrowed(_)));
}
}
/// A thumbnail range that covers IFD0, or tags that are not one SHORT/LONG value, are not blanked.
#[test]
fn thumbnail_blanking_keeps_ifd0_and_ignores_malformed_tags() {
for e in [&LE, &BE] {
let exif = exif_with_thumbnail(e);
let ifd1 = camera_exif(e).len();
let table = 8..8 + 2 + 4 * 12;
// Offset 8, length 60: the range covers IFD0's table.
let mut over = exif.clone();
over[ifd1 + 2 + 8..ifd1 + 2 + 12].copy_from_slice(&e.u32(8));
over[ifd1 + 2 + 12 + 8..ifd1 + 2 + 12 + 12].copy_from_slice(&e.u32(60));
let out = export_exif(&over, None);
assert_eq!(next_ifd(e, &out), 0, "IFD1 is still unlinked");
assert_eq!(out[table.start..table.end], over[table.start..table.end], "IFD0's table is untouched");
// Offset 0, length 8: the range covers the TIFF header, which must stay parseable.
let mut header = exif.clone();
header[ifd1 + 2 + 8..ifd1 + 2 + 12].copy_from_slice(&e.u32(0));
header[ifd1 + 2 + 12 + 8..ifd1 + 2 + 12 + 12].copy_from_slice(&e.u32(8));
let out = export_exif(&header, None);
assert_eq!(out[..8], header[..8], "the TIFF header is untouched");
// IFD0's last entry (Unit) turned into an ExifIFD pointer at IFD1's table: that range is not a thumbnail.
let mut sub = exif.clone();
let entry = 8 + 2 + 3 * 12;
sub[entry..entry + 2].copy_from_slice(&e.u16(0x8769));
sub[entry + 2..entry + 4].copy_from_slice(&e.u16(LONG));
sub[entry + 4..entry + 8].copy_from_slice(&e.u32(1));
sub[entry + 8..entry + 12].copy_from_slice(&e.u32(ifd1 as u32));
sub[ifd1 + 2 + 8..ifd1 + 2 + 12].copy_from_slice(&e.u32(ifd1 as u32 + 2));
sub[ifd1 + 2 + 12 + 8..ifd1 + 2 + 12 + 12].copy_from_slice(&e.u32(4));
let out = export_exif(&sub, None);
assert_eq!(out[ifd1 + 2..ifd1 + 6], sub[ifd1 + 2..ifd1 + 6], "the sub-IFD's table is untouched");
// A count of 2 on the offset tag: the thumbnail bytes are not blanked.
let mut counted = exif.clone();
counted[ifd1 + 2 + 4..ifd1 + 2 + 8].copy_from_slice(&e.u32(2));
assert!(export_exif(&counted, None).windows(5).any(|w| w == b"stale"));
}
}
#[test]
fn thumbnail_removal_never_panics_on_truncated_exif() {
for e in [&LE, &BE] {
let exif = exif_with_thumbnail(e);
for n in 0..exif.len() {
let _ = export_exif(&exif[..n], None);
let _ = export_exif(&exif[..n], Some((300.0, 300.0)));
}
}
}
/// Many Exif pointers to one huge sub-IFD must not cost a full scan each: past the scan budget the
/// thumbnail bytes are left alone (the IFD1 pointer is still cleared).
#[test]
fn thumbnail_overlap_scan_is_bounded_by_a_budget() {
let e = &LE;
let (pointers, sub_entries) = (100usize, 65_535usize);
let sub = 8 + 2 + pointers * 12 + 4;
let ifd1 = sub + 2 + sub_entries * 12 + 4;
let thumb = b"\xFF\xD8stale thumbnail\xFF\xD9";
let mut v = b"II*\0".to_vec();
v.extend_from_slice(&e.u32(8));
v.extend_from_slice(&e.u16(pointers as u16));
for _ in 0..pointers {
v.extend_from_slice(&[e.u16(0x8769), e.u16(LONG)].concat());
v.extend_from_slice(&[e.u32(1), e.u32(sub as u32)].concat());
}
v.extend_from_slice(&e.u32(ifd1 as u32));
v.extend_from_slice(&e.u16(sub_entries as u16));
v.resize(ifd1, 0); // the sub-IFD's entries are type 0: nothing to hit
v.extend_from_slice(&e.u16(2));
for (tag, value) in [(0x0201u16, (ifd1 + 2 + 2 * 12 + 4) as u32), (0x0202, thumb.len() as u32)] {
v.extend_from_slice(&[e.u16(tag), e.u16(LONG)].concat());
v.extend_from_slice(&[e.u32(1), e.u32(value)].concat());
}
v.extend_from_slice(&e.u32(0));
v.extend_from_slice(thumb);
let out = export_exif(&v, None);
assert_eq!(next_ifd_at(e, &out, 8 + 2 + pointers * 12), 0, "IFD1 is unlinked");
assert!(out.windows(5).any(|w| w == b"stale"), "over the scan budget the thumbnail bytes stay");
}
fn next_ifd_at(e: &Exif, exif: &[u8], at: usize) -> u32 {
let b: [u8; 4] = exif[at..at + 4].try_into().unwrap();
if e.big { u32::from_be_bytes(b) } else { u32::from_le_bytes(b) }
}
/// A thumbnail range that covers the Interoperability IFD (an Exif sub-IFD's 0xA005 target) is not
/// blanked: its pointer would stay behind, aimed at zeroes.
#[test]
fn thumbnail_blanking_keeps_the_interoperability_ifd() {
for e in [&LE, &BE] {
let (ifd0, sub, interop, ifd1) = (8usize, 8 + 2 + 12 + 4, 8 + 2 + 12 + 4 + 2 + 12 + 4, 8 + 2 + 12 + 4 + 2 + 12 + 4 + 2 + 12 + 4);
let mut v = if e.big { b"MM\0*".to_vec() } else { b"II*\0".to_vec() };
v.extend_from_slice(&e.u32(ifd0 as u32));
// IFD0: one ExifIFD pointer, then IFD1.
v.extend_from_slice(&e.u16(1));
v.extend_from_slice(&[e.u16(0x8769), e.u16(LONG)].concat());
v.extend_from_slice(&[e.u32(1), e.u32(sub as u32)].concat());
v.extend_from_slice(&e.u32(ifd1 as u32));
// The Exif IFD: one Interoperability pointer.
v.extend_from_slice(&e.u16(1));
v.extend_from_slice(&[e.u16(0xA005), e.u16(LONG)].concat());
v.extend_from_slice(&[e.u32(1), e.u32(interop as u32)].concat());
v.extend_from_slice(&e.u32(0));
// The Interoperability IFD: one inline ASCII entry (InteroperabilityIndex).
v.extend_from_slice(&e.u16(1));
v.extend_from_slice(&[e.u16(1), e.u16(ASCII)].concat());
v.extend_from_slice(&[e.u32(4), b"R98\0".to_vec().try_into().unwrap_or([0; 4])].concat());
v.extend_from_slice(&e.u32(0));
assert_eq!(v.len(), ifd1);
// IFD1: a thumbnail whose range is the Interoperability IFD.
v.extend_from_slice(&e.u16(2));
for (tag, value) in [(0x0201u16, interop as u32), (0x0202, 18)] {
v.extend_from_slice(&[e.u16(tag), e.u16(LONG)].concat());
v.extend_from_slice(&[e.u32(1), e.u32(value)].concat());
}
v.extend_from_slice(&e.u32(0));
let out = export_exif(&v, None);
assert_eq!(out[interop..interop + 18], v[interop..interop + 18], "the Interoperability IFD is untouched");
}
}
+4
View File
@@ -1,3 +1,7 @@
//! The integration tests, one module per file, linked into one test binary.
mod edit_cleanup;
mod edit_orientation;
mod edit_pixel_size;
mod edit_resolution;
mod robust;