diff --git a/crates/metadata/README.md b/crates/metadata/README.md index ce5541b7..309f2963 100644 --- a/crates/metadata/README.md +++ b/crates/metadata/README.md @@ -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. | diff --git a/crates/metadata/src/edit.rs b/crates/metadata/src/edit.rs new file mode 100644 index 00000000..36abbc35 --- /dev/null +++ b/crates/metadata/src/edit.rs @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 = (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 `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) } +} diff --git a/crates/metadata/src/edit/color.rs b/crates/metadata/src/edit/color.rs new file mode 100644 index 00000000..03a4a6bd --- /dev/null +++ b/crates/metadata/src/edit/color.rs @@ -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 { + 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 { + (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 { + 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); + } +} diff --git a/crates/metadata/src/edit/resolution.rs b/crates/metadata/src/edit/resolution.rs new file mode 100644 index 00000000..e997a976 --- /dev/null +++ b/crates/metadata/src/edit/resolution.rs @@ -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, +} + +impl Field { + fn short(&self, order: Order, b: &[u8]) -> Option { + (self.ty == TYPE_SHORT && self.count == 1).then_some(())?; + order.u16(b, self.value?) + } + + fn rational(&self, order: Order, b: &[u8]) -> Option { + (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, +} + +impl Ifd0 { + fn parse(b: &[u8]) -> Option { + 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 { + 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> { + 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> { + 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 { + 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>(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 = 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 +/// `v`), 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 ``, 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 { + let s = s.trim(); + match s.split_once('/') { + Some((n, d)) => { + let (n, d) = (n.trim().parse::().ok()?, d.trim().parse::().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> { + 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 { + 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> = 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; diff --git a/crates/metadata/src/edit/resolution/complexity_tests.rs b/crates/metadata/src/edit/resolution/complexity_tests.rs new file mode 100644 index 00000000..06a23508 --- /dev/null +++ b/crates/metadata/src/edit/resolution/complexity_tests.rs @@ -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 = const { Cell::new(0) }; +} + +#[derive(Clone, Copy)] +struct CountedOffset(usize); + +impl From 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, Vec, Ifd0, Vec) { + 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 = 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"); + } + } + } + } +} diff --git a/crates/metadata/src/lib.rs b/crates/metadata/src/lib.rs index 6af0b09e..7771934c 100644 --- a/crates/metadata/src/lib.rs +++ b/crates/metadata/src/lib.rs @@ -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; diff --git a/crates/metadata/tests/it/edit_cleanup.rs b/crates/metadata/tests/it/edit_cleanup.rs new file mode 100644 index 00000000..07c31033 --- /dev/null +++ b/crates/metadata/tests/it/edit_cleanup.rs @@ -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 { + 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, Vec) { + 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 { + [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 = (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(_))); +} diff --git a/crates/metadata/tests/it/edit_orientation.rs b/crates/metadata/tests/it/edit_orientation.rs new file mode 100644 index 00000000..e4d07e63 --- /dev/null +++ b/crates/metadata/tests/it/edit_orientation.rs @@ -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 { + 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::(), 0..256), le in any::()) { + 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#""#; + assert_eq!(upright_xmp(attr), r#""#); + let elem = "83"; + assert_eq!(upright_xmp(elem), "11"); + 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 { + 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 = (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}"); + } + } +} diff --git a/crates/metadata/tests/it/edit_pixel_size.rs b/crates/metadata/tests/it/edit_pixel_size.rs new file mode 100644 index 00000000..48158f94 --- /dev/null +++ b/crates/metadata/tests/it/edit_pixel_size.rs @@ -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 { + 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 { + 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}"); + } +} diff --git a/crates/metadata/tests/it/edit_resolution.rs b/crates/metadata/tests/it/edit_resolution.rs new file mode 100644 index 00000000..1c7ef4c9 --- /dev/null +++ b/crates/metadata/tests/it/edit_resolution.rs @@ -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); + +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 { + [self.u32(n), self.u32(d)].concat() + } + fn short(&self, v: u16) -> Vec { + 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 { + 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 { + 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::(), 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::(), 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 = (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#""#; + assert_eq!( + xmp_with_resolution(attr, (240.0, 240.0)), + r#""# + ); + let elem = "300/1300/1"; + assert_eq!( + xmp_with_resolution(elem, (72.5, 72.5)), + "72500/100072500/1000" + ); + for same in [r#"tiff:XResolution="240" tiff:YResolution="240/1""#, ""] { + 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 { + 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 = (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"); + } +} diff --git a/crates/metadata/tests/it/main.rs b/crates/metadata/tests/it/main.rs index f56fd47f..4e460020 100644 --- a/crates/metadata/tests/it/main.rs +++ b/crates/metadata/tests/it/main.rs @@ -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;