mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-24 09:57:08 -06:00
Every tests/*.rs file is its own executable that links the whole crate graph. Move each crate's test files into tests/it/ as modules of one tests/it/main.rs; helper modules under tests/common move with them. These keep their own binary (own process): - engine panic_hunt: a process-wide panic hook counts every panic. - engine text_gamma: changes the process-wide text gamma. - engine smart_filter_stages, mask_cache_purge: assert on global caches. - codecs pnm_peak_memory: reads the process's peak RSS. - photocraft mac_menu_appkit: harness = false.
263 lines
12 KiB
Rust
263 lines
12 KiB
Rust
//! EXIF in TIFF: an EXIF block's Exif and GPS directories and IFD0's descriptive tags are
|
|
//! written as the TIFF's own directories (IFD0 pointing to them, as cameras and Photoshop write
|
|
//! them) and read back into a block.
|
|
|
|
use photocraft_codecs::*;
|
|
|
|
/// A classic TIFF stream builder for EXIF blocks: directories of (tag, type, count, value bytes).
|
|
struct Block {
|
|
le: bool,
|
|
}
|
|
|
|
type Entry = (u16, u16, u32, Vec<u8>);
|
|
|
|
impl Block {
|
|
fn u16(&self, v: u16) -> [u8; 2] {
|
|
if self.le { v.to_le_bytes() } else { v.to_be_bytes() }
|
|
}
|
|
fn u32(&self, v: u32) -> [u8; 4] {
|
|
if self.le { v.to_le_bytes() } else { v.to_be_bytes() }
|
|
}
|
|
fn rational(&self, n: u32, d: u32) -> Vec<u8> {
|
|
[self.u32(n), self.u32(d)].concat()
|
|
}
|
|
/// IFD0 (with pointers to `exif` and `gps` appended) followed by the two directories.
|
|
fn build(&self, mut ifd0: Vec<Entry>, exif: Vec<Entry>, gps: Vec<Entry>) -> Vec<u8> {
|
|
let size = |d: &[Entry]| 2 + 12 * d.len() + 4 + d.iter().filter(|e| e.3.len() > 4).map(|e| e.3.len().next_multiple_of(2)).sum::<usize>();
|
|
ifd0.push((34665, 4, 1, vec![0; 4]));
|
|
ifd0.push((34853, 4, 1, vec![0; 4]));
|
|
let at_exif = 8 + size(&ifd0);
|
|
let at_gps = at_exif + size(&exif);
|
|
let n = ifd0.len();
|
|
ifd0[n - 2].3 = self.u32(at_exif as u32).to_vec();
|
|
ifd0[n - 1].3 = self.u32(at_gps as u32).to_vec();
|
|
let mut out = if self.le { b"II".to_vec() } else { b"MM".to_vec() };
|
|
out.extend(self.u16(42));
|
|
out.extend(self.u32(8));
|
|
for d in [ifd0, exif, gps] {
|
|
let start = out.len();
|
|
let mut extra = start + 2 + 12 * d.len() + 4;
|
|
let mut values = Vec::new();
|
|
out.extend(self.u16(d.len() as u16));
|
|
for (tag, ty, count, v) in &d {
|
|
out.extend(self.u16(*tag));
|
|
out.extend(self.u16(*ty));
|
|
out.extend(self.u32(*count));
|
|
if v.len() <= 4 {
|
|
let mut inline = v.clone();
|
|
inline.resize(4, 0);
|
|
out.extend(inline);
|
|
} else {
|
|
out.extend(self.u32(extra as u32));
|
|
values.extend(v);
|
|
if v.len() % 2 == 1 {
|
|
values.push(0);
|
|
}
|
|
extra += v.len().next_multiple_of(2);
|
|
}
|
|
}
|
|
out.extend(self.u32(0));
|
|
out.extend(values);
|
|
}
|
|
out
|
|
}
|
|
}
|
|
|
|
/// The entries (tag, type, count, value bytes in the block's order) of IFD0 and of the
|
|
/// directories its tags `34665` and `34853` point to.
|
|
fn dirs(b: &[u8]) -> (bool, Vec<Entry>, Vec<Entry>, Vec<Entry>) {
|
|
let le = &b[..2] == b"II";
|
|
let u16 = |at: usize| if le { u16::from_le_bytes([b[at], b[at + 1]]) } else { u16::from_be_bytes([b[at], b[at + 1]]) };
|
|
let u32 = |at: usize| {
|
|
let s = [b[at], b[at + 1], b[at + 2], b[at + 3]];
|
|
if le { u32::from_le_bytes(s) } else { u32::from_be_bytes(s) }
|
|
};
|
|
let size = |ty: u16| match ty {
|
|
3 | 8 => 2,
|
|
4 | 9 | 11 | 13 => 4,
|
|
5 | 10 | 12 => 8,
|
|
_ => 1,
|
|
};
|
|
let read = |at: usize| -> Vec<Entry> {
|
|
(0..usize::from(u16(at)))
|
|
.map(|i| {
|
|
let e = at + 2 + 12 * i;
|
|
let (tag, ty, count) = (u16(e), u16(e + 2), u32(e + 4));
|
|
let len = count as usize * size(ty);
|
|
let at = if len <= 4 { e + 8 } else { u32(e + 8) as usize };
|
|
(tag, ty, count, b[at..at + len].to_vec())
|
|
})
|
|
.collect()
|
|
};
|
|
let ifd0 = read(u32(4) as usize);
|
|
let sub = |tag: u16| ifd0.iter().find(|e| e.0 == tag).map(|e| read(u32_of(&e.3, le) as usize)).unwrap_or_default();
|
|
let (exif, gps) = (sub(34665), sub(34853));
|
|
(le, ifd0.into_iter().filter(|e| !matches!(e.0, 34665 | 34853)).collect(), exif, gps)
|
|
}
|
|
|
|
fn u32_of(v: &[u8], le: bool) -> u32 {
|
|
let s = [v[0], v[1], v[2], v[3]];
|
|
if le { u32::from_le_bytes(s) } else { u32::from_be_bytes(s) }
|
|
}
|
|
|
|
/// The entries of `d` with their values in little-endian order, sorted by tag.
|
|
fn normalized(d: &[Entry], le: bool) -> Vec<(u16, u16, u32, Vec<u8>)> {
|
|
let swap = |ty: u16, v: &[u8]| -> Vec<u8> {
|
|
let n = match ty {
|
|
3 | 8 => 2,
|
|
4 | 5 | 9 | 10 | 11 | 13 => 4,
|
|
12 => 8,
|
|
_ => 1,
|
|
};
|
|
if le || n == 1 { v.to_vec() } else { v.chunks(n).flat_map(|c| c.iter().rev().copied()).collect() }
|
|
};
|
|
let mut v: Vec<_> = d.iter().map(|e| (e.0, e.1, e.2, swap(e.1, &e.3))).collect();
|
|
v.sort_by_key(|e| e.0);
|
|
v
|
|
}
|
|
|
|
fn camera_block(le: bool) -> Vec<u8> {
|
|
let b = Block { le };
|
|
let ascii = |s: &str| [s.as_bytes(), b"\0"].concat();
|
|
b.build(
|
|
vec![(271, 2, 4, ascii("Cam")), (272, 2, 9, ascii("Model X1")), (315, 2, 16, ascii("A. Photographer")), (274, 3, 1, b.u16(1).to_vec())],
|
|
vec![
|
|
(33434, 5, 1, b.rational(1, 125)),
|
|
(34855, 3, 1, b.u16(200).to_vec()),
|
|
(36867, 2, 20, ascii("2026:10:13 09:30:00")),
|
|
(37386, 5, 1, b.rational(50, 1)),
|
|
(37500, 7, 6, b"maker!".to_vec()),
|
|
],
|
|
vec![(1, 2, 2, ascii("N")), (2, 5, 3, [b.rational(51, 1), b.rational(30, 1), b.rational(0, 1)].concat())],
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn exif_round_trips_through_a_tiff_in_either_byte_order() {
|
|
for le in [true, false] {
|
|
let block = camera_block(le);
|
|
let mut img = Image::from_raw(4, 3, ChannelLayout::Rgb, SampleType::U8, vec![90; 36]).unwrap();
|
|
img.meta.exif = Some(block.clone());
|
|
let tiff = encode(&img, Format::Tiff, &EncodeOptions::default()).unwrap();
|
|
assert!(fidelity_warnings(&img, Format::Tiff).iter().all(|w| *w != FidelityWarning::ExifDropped));
|
|
let back = decode(&tiff).unwrap().meta.exif.expect("EXIF read back");
|
|
let (ble, b0, bexif, bgps) = dirs(&back);
|
|
let (ole, o0, oexif, ogps) = dirs(&block);
|
|
assert_eq!(normalized(&bexif, ble), normalized(&oexif, ole), "Exif directory (le {le})");
|
|
assert_eq!(normalized(&bgps, ble), normalized(&ogps, ole), "GPS directory (le {le})");
|
|
// IFD0's descriptive tags come along; its orientation is the image's own.
|
|
let descriptive: Vec<_> = o0.into_iter().filter(|e| e.0 != 274).collect();
|
|
assert_eq!(normalized(&b0, ble), normalized(&descriptive, ole), "IFD0 (le {le})");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_tiff_without_exif_directories_reads_no_exif() {
|
|
let mut img = Image::from_raw(2, 2, ChannelLayout::Gray, SampleType::U8, vec![0; 4]).unwrap();
|
|
img.meta.text = vec![("Artist".into(), "A. Photographer".into())];
|
|
let tiff = encode(&img, Format::Tiff, &EncodeOptions::default()).unwrap();
|
|
assert_eq!(decode(&tiff).unwrap().meta.exif, None);
|
|
}
|
|
|
|
#[test]
|
|
fn no_exif_is_written_without_embed_metadata() {
|
|
let mut img = Image::from_raw(2, 2, ChannelLayout::Gray, SampleType::U8, vec![0; 4]).unwrap();
|
|
img.meta.exif = Some(camera_block(true));
|
|
let tiff = encode(&img, Format::Tiff, &EncodeOptions { embed_metadata: false, ..Default::default() }).unwrap();
|
|
assert_eq!(decode(&tiff).unwrap().meta.exif, None);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Bounded copying: an entry's value only has to lie inside the file, so a hostile file can have
|
|
// many entries point at the same bytes.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// A valid 1x1 gray TIFF whose Exif directory has `entries` UNDEFINED entries of `count` bytes
|
|
/// that all point at the same `count` bytes: the file is about `count` bytes long and asks to
|
|
/// copy `entries * count`.
|
|
fn overlapping_tiff(entries: usize, count: u32) -> Vec<u8> {
|
|
let entry = |tag: u16, ty: u16, count: u32, value: u32| {
|
|
[tag.to_le_bytes().to_vec(), ty.to_le_bytes().to_vec(), count.to_le_bytes().to_vec(), value.to_le_bytes().to_vec()].concat()
|
|
};
|
|
let at_exif = 8 + 2 + 12 * 10 + 4;
|
|
let data_at = at_exif + 2 + 12 * entries + 4;
|
|
let mut out = b"II".to_vec();
|
|
out.extend(42u16.to_le_bytes());
|
|
out.extend(8u32.to_le_bytes());
|
|
out.extend(10u16.to_le_bytes());
|
|
for (tag, ty, value) in
|
|
[(256, 3, 1), (257, 3, 1), (258, 3, 8), (259, 3, 1), (262, 3, 1), (273, 4, 0), (277, 3, 1), (278, 3, 1), (279, 4, 1), (34665, 4, at_exif as u32)]
|
|
{
|
|
out.extend(entry(tag, ty, 1, value));
|
|
}
|
|
out.extend(0u32.to_le_bytes());
|
|
out.extend((entries as u16).to_le_bytes());
|
|
for i in 0..entries {
|
|
out.extend(entry(i as u16 + 1, 7, count, data_at as u32));
|
|
}
|
|
out.extend(0u32.to_le_bytes());
|
|
out.extend(vec![0x5A; count as usize]);
|
|
out
|
|
}
|
|
|
|
#[test]
|
|
fn overlapping_field_values_are_bounded() {
|
|
// About 62 KB on disk; copied entry by entry it would be 1,000 x 50,000 bytes.
|
|
let tiff = overlapping_tiff(1000, 50_000);
|
|
assert!(tiff.len() < 64 * 1024);
|
|
let img = decode(&tiff).expect("the image itself is fine");
|
|
assert_eq!(img.dimensions(), (1, 1));
|
|
let exif = img.meta.exif.expect("the first entries are kept");
|
|
assert!(exif.len() <= 2 * tiff.len(), "{} bytes of EXIF from a {} byte file", exif.len(), tiff.len());
|
|
}
|
|
|
|
#[test]
|
|
fn overlapping_field_values_in_an_exif_block_are_bounded_on_export() {
|
|
// The same block as an image's EXIF: the writer reads it with the same code.
|
|
let block = overlapping_tiff(1000, 50_000);
|
|
let mut img = Image::from_raw(2, 2, ChannelLayout::Gray, SampleType::U8, vec![0; 4]).unwrap();
|
|
img.meta.exif = Some(block.clone());
|
|
let tiff = encode(&img, Format::Tiff, &EncodeOptions::default()).unwrap();
|
|
assert!(tiff.len() <= 2 * block.len(), "a {} byte block wrote a {} byte file", block.len(), tiff.len());
|
|
}
|
|
|
|
/// An EXIF block with a maker note of `len` bytes, an exposure time and a GPS latitude reference.
|
|
fn block_with_maker_note(le: bool, len: usize) -> Vec<u8> {
|
|
let b = Block { le };
|
|
b.build(
|
|
vec![(271, 2, 4, b"Cam\0".to_vec())],
|
|
vec![(33434, 5, 1, b.rational(1, 125)), (37500, 7, len as u32, (0..len).map(|i| (i * 7) as u8).collect())],
|
|
vec![(1, 2, 2, b"N\0".to_vec())],
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn large_maker_notes_still_survive() {
|
|
// Nikon maker notes run from 100 KB to nearly 1 MB.
|
|
for len in [200 * 1024, 1000 * 1024] {
|
|
let block = block_with_maker_note(true, len);
|
|
let mut img = Image::from_raw(4, 3, ChannelLayout::Rgb, SampleType::U8, vec![90; 36]).unwrap();
|
|
img.meta.exif = Some(block.clone());
|
|
let tiff = encode(&img, Format::Tiff, &EncodeOptions::default()).unwrap();
|
|
let back = decode(&tiff).unwrap().meta.exif.expect("EXIF read back");
|
|
let (ble, _, bexif, bgps) = dirs(&back);
|
|
let (ole, _, oexif, ogps) = dirs(&block);
|
|
assert_eq!(normalized(&bexif, ble), normalized(&oexif, ole), "{len} byte maker note");
|
|
assert_eq!(normalized(&bgps, ble), normalized(&ogps, ole));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_field_beyond_the_cap_is_dropped_and_the_rest_kept() {
|
|
let block = block_with_maker_note(false, 6 * 1024 * 1024);
|
|
let mut img = Image::from_raw(2, 2, ChannelLayout::Gray, SampleType::U8, vec![0; 4]).unwrap();
|
|
img.meta.exif = Some(block);
|
|
let tiff = encode(&img, Format::Tiff, &EncodeOptions::default()).unwrap();
|
|
assert!(tiff.len() < 1024 * 1024, "{} bytes", tiff.len());
|
|
let back = decode(&tiff).unwrap().meta.exif.expect("EXIF read back");
|
|
let (_, ifd0, exif, gps) = dirs(&back);
|
|
assert_eq!(exif.iter().map(|e| e.0).collect::<Vec<_>>(), [33434], "the exposure time stays, the 6 MiB maker note goes");
|
|
assert_eq!(gps.iter().map(|e| e.0).collect::<Vec<_>>(), [1]);
|
|
assert_eq!(ifd0.iter().map(|e| e.0).collect::<Vec<_>>(), [271]);
|
|
}
|