Files
craft-libs/crates/metadata/tests/it/edit_resolution.rs
df469bb2bd craft-metadata: byte-preserving Exif and XMP edits for export, from PhotoCraft (#1022)
* craft-metadata: EXIF, IPTC-IIM, XMP and GPX from LightCraft

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

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

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

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

Ported from storytold/lightcraft#3518.

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

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

Ported from storytold/lightcraft#1995.

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

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

Ported from storytold/lightcraft#1692.

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

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

Ported from storytold/lightcraft#3756.

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

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

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

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

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

Ported from storytold/lightcraft#3502.

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

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

Ported from storytold/lightcraft#1209.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: radialmonster <radial@gmail.com>
Co-authored-by: Sebastian Berschneider <berschi@gmail.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
Co-authored-by: Michael Anderson <1427851+endthestart@users.noreply.github.com>
Co-authored-by: Ilya Dorman <ilya@codeforhumans.dev>
Co-authored-by: Clyde C Brown <clyde.c.brown@pm.me>
Co-authored-by: MohammadTeimori <teimori.mohammad@gmail.com>
Co-authored-by: vangel76 <vangel1976@googlemail.com>
2026-11-11 20:38:50 -05:00

362 lines
16 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Resolution in Exif and XMP blocks, rewritten in place, and the Exif thumbnail dropped on
//! export. Every block here is built by hand.
use std::borrow::Cow;
use craft_metadata::edit::*;
use proptest::prelude::{any, proptest};
const X: u16 = 282;
const Y: u16 = 283;
const UNIT: u16 = 296;
const SHORT: u16 = 3;
const LONG: u16 = 4;
const RATIONAL: u16 = 5;
const ASCII: u16 = 2;
/// One IFD0 entry: tag, type, count and the value bytes (in the block's byte order).
struct E(u16, u16, u32, Vec<u8>);
struct Exif {
big: bool,
}
impl Exif {
fn u16(&self, v: u16) -> [u8; 2] {
if self.big { v.to_be_bytes() } else { v.to_le_bytes() }
}
fn u32(&self, v: u32) -> [u8; 4] {
if self.big { v.to_be_bytes() } else { v.to_le_bytes() }
}
fn rational(&self, n: u32, d: u32) -> Vec<u8> {
[self.u32(n), self.u32(d)].concat()
}
fn short(&self, v: u16) -> Vec<u8> {
self.u16(v).to_vec()
}
fn x(&self, n: u32, d: u32) -> E {
E(X, RATIONAL, 1, self.rational(n, d))
}
fn y(&self, n: u32, d: u32) -> E {
E(Y, RATIONAL, 1, self.rational(n, d))
}
fn unit(&self, u: u16) -> E {
E(UNIT, SHORT, 1, self.short(u))
}
fn make(&self) -> E {
E(0x010F, ASCII, 6, b"NIKON\0".to_vec())
}
fn orientation(&self, o: u16) -> E {
E(0x0112, SHORT, 1, self.short(o))
}
/// A TIFF-structured EXIF block: header, IFD0 at 8, then the out-of-line values.
fn build(&self, entries: &[E]) -> Vec<u8> {
let mut v = if self.big { b"MM\0*".to_vec() } else { b"II*\0".to_vec() };
v.extend_from_slice(&self.u32(8));
v.extend_from_slice(&self.u16(entries.len() as u16));
let mut data_at = 8 + 2 + entries.len() * 12 + 4;
let mut data = Vec::new();
for E(tag, ty, count, value) in entries {
v.extend_from_slice(&self.u16(*tag));
v.extend_from_slice(&self.u16(*ty));
v.extend_from_slice(&self.u32(*count));
if value.len() <= 4 {
let mut inline = value.clone();
inline.resize(4, 0);
v.extend_from_slice(&inline);
} else {
v.extend_from_slice(&self.u32(data_at as u32));
data.extend_from_slice(value);
data_at += value.len();
}
}
v.extend_from_slice(&self.u32(0));
v.extend_from_slice(&data);
v
}
}
const LE: Exif = Exif { big: false };
const BE: Exif = Exif { big: true };
/// A D3200-style block: Make, XResolution 300/1, YResolution 300/1, ResolutionUnit 2.
fn camera_exif(e: &Exif) -> Vec<u8> {
e.build(&[e.make(), e.x(300, 1), e.y(300, 1), e.unit(2)])
}
fn near(got: Option<(f32, f32)>, want: f32) {
let (x, y) = got.unwrap_or_else(|| panic!("no resolution, wanted {want}"));
assert!((x - want).abs() < 0.01 && (y - want).abs() < 0.01, "got {x}×{y}, wanted {want}");
}
proptest! {
#[test]
fn hostile_bytes_never_panic(bytes in proptest::collection::vec(any::<u8>(), 0..256), ppi in 0.0f32..2e6) {
let _ = exif_resolution(&bytes);
let _ = exif_with_resolution(&bytes, (ppi, ppi));
let _ = photoshop_resolution(&bytes);
let s = String::from_utf8_lossy(&bytes);
let _ = xmp_resolution(&s);
let _ = xmp_with_resolution(&s, (ppi, ppi));
}
#[test]
fn mutated_exif_never_panics(i in 0usize..80, v in any::<u8>(), ppi in 1.0f32..10_000.0) {
let mut exif = camera_exif(&LE);
if let Some(b) = exif.get_mut(i) { *b = v; }
let _ = exif_resolution(&exif);
let fixed = exif_with_resolution(&exif, (ppi, ppi));
let _ = exif_resolution(&fixed);
let _ = exif_orientation(&fixed);
}
}
#[test]
fn rewriting_keeps_every_other_byte() {
for e in [&LE, &BE] {
let src = e.build(&[e.make(), e.orientation(6), e.x(300, 1), e.y(300, 1), e.unit(3)]);
let out = exif_with_resolution(&src, (240.0, 240.0)).into_owned();
assert_eq!(out.len(), src.len());
near(exif_resolution(&out), 240.0);
let want = e.build(&[e.make(), e.orientation(6), e.x(240, 1), e.y(240, 1), e.unit(2)]);
assert_eq!(out, want);
// Fractions keep three decimals.
near(exif_resolution(&exif_with_resolution(&src, (299.5, 72.25))).map(|(x, _)| (x, x)), 299.5);
// Nothing to change: borrowed.
assert!(matches!(exif_with_resolution(&camera_exif(e), (300.0, 300.0)), Cow::Borrowed(_)));
let no_res = e.build(&[e.make()]);
assert!(matches!(exif_with_resolution(&no_res, (240.0, 240.0)), Cow::Borrowed(_)));
// With the JPEG prefix.
let prefixed = [b"Exif\0\0".as_slice(), &src].concat();
assert_eq!(exif_with_resolution(&prefixed, (240.0, 240.0)).as_ref(), [b"Exif\0\0".as_slice(), &want].concat());
}
}
#[test]
fn entries_that_cannot_be_rewritten_are_removed() {
for e in [&LE, &BE] {
// XResolution as a SHORT and a LONG unit: both go, the rest stays readable.
let src = e.build(&[e.make(), E(X, SHORT, 1, e.short(300)), e.y(300, 1), E(UNIT, LONG, 1, e.u32(2).to_vec()), e.orientation(6)]);
let out = exif_with_resolution(&src, (240.0, 240.0)).into_owned();
assert_eq!(out.len(), src.len());
let count = if e.big { u16::from_be_bytes([out[8], out[9]]) } else { u16::from_le_bytes([out[8], out[9]]) };
assert_eq!(count, 3);
assert_eq!(exif_orientation(&out), 6);
// Only YResolution is left (240): no X, so no resolution at all, and no stale 300.
assert_eq!(exif_resolution(&out), None);
let tags: Vec<u16> = (0..3)
.map(|k| {
let at = 10 + k * 12;
if e.big { u16::from_be_bytes([out[at], out[at + 1]]) } else { u16::from_le_bytes([out[at], out[at + 1]]) }
})
.collect();
assert_eq!(tags, vec![0x010F, Y, 0x0112]);
// The next-IFD pointer followed the table, the freed entries are zero.
assert_eq!(&out[10 + 36..10 + 40], &[0, 0, 0, 0]);
assert!(out[10 + 40..10 + 64].iter().all(|&b| b == 0));
// The Make string (out of line) did not move.
assert!(out.windows(6).any(|w| w == b"NIKON\0"));
}
}
#[test]
fn xmp_resolution_is_rewritten() {
let attr = r#"<rdf:Description tiff:XResolution="300/1" tiff:YResolution='300/1' tiff:ResolutionUnit="3" tiff:Make="X"/>"#;
assert_eq!(
xmp_with_resolution(attr, (240.0, 240.0)),
r#"<rdf:Description tiff:XResolution="240/1" tiff:YResolution='240/1' tiff:ResolutionUnit="2" tiff:Make="X"/>"#
);
let elem = "<tiff:XResolution>300/1</tiff:XResolution><tiff:YResolution>300/1</tiff:YResolution>";
assert_eq!(
xmp_with_resolution(elem, (72.5, 72.5)),
"<tiff:XResolution>72500/1000</tiff:XResolution><tiff:YResolution>72500/1000</tiff:YResolution>"
);
for same in [r#"tiff:XResolution="240" tiff:YResolution="240/1""#, "<x/>"] {
assert!(matches!(xmp_with_resolution(same, (240.0, 240.0)), Cow::Borrowed(_)), "{same}");
}
}
/// `camera_exif` plus IFD1, the embedded thumbnail a camera (or the last editor) wrote:
/// JPEGInterchangeFormat / JPEGInterchangeFormatLength naming a fake JPEG after the IFD.
fn exif_with_thumbnail(e: &Exif) -> Vec<u8> {
let mut v = camera_exif(e);
let ifd1 = v.len() as u32;
// IFD0 (4 entries) ends with its next-IFD pointer.
v[8 + 2 + 4 * 12..8 + 2 + 4 * 12 + 4].copy_from_slice(&e.u32(ifd1));
let thumb = b"\xFF\xD8stale thumbnail\xFF\xD9";
let thumb_at = ifd1 + 2 + 2 * 12 + 4;
v.extend_from_slice(&e.u16(2));
for (tag, value) in [(0x0201u16, thumb_at), (0x0202, thumb.len() as u32)] {
v.extend_from_slice(&e.u16(tag));
v.extend_from_slice(&e.u16(LONG));
v.extend_from_slice(&e.u32(1));
v.extend_from_slice(&e.u32(value));
}
v.extend_from_slice(&e.u32(0));
v.extend_from_slice(thumb);
v
}
fn next_ifd(e: &Exif, exif: &[u8]) -> u32 {
let at = 8 + 2 + 4 * 12;
let b: [u8; 4] = exif[at..at + 4].try_into().unwrap();
if e.big { u32::from_be_bytes(b) } else { u32::from_le_bytes(b) }
}
/// #2147 / #2373: an export must not carry the source's EXIF thumbnail (IFD1). Finder, Android
/// and upload pickers show it instead of the image, so an edited photo kept its old thumbnail.
#[test]
fn exports_drop_the_source_exif_thumbnail() {
for e in [&LE, &BE] {
let exif = exif_with_thumbnail(e);
assert_ne!(next_ifd(e, &exif), 0);
for ppi in [None, Some((300.0, 300.0)), Some((240.0, 240.0))] {
let out = export_exif(&exif, ppi);
assert_eq!(next_ifd(e, &out), 0, "IFD1 is unlinked ({ppi:?})");
assert_eq!(out.len(), exif.len(), "nothing moves");
assert!(out.windows(6).any(|w| w == b"NIKON\0"), "the rest of the EXIF is kept");
near(exif_resolution(&out), ppi.map_or(300.0, |p| p.0));
}
// Only the pointer and the thumbnail's own bytes (the JPEG at the end) change.
let out = export_exif(&exif, None);
let thumb_len = b"\xFF\xD8stale thumbnail\xFF\xD9".len();
let differing: Vec<usize> = (0..exif.len()).filter(|&i| exif[i] != out[i]).collect();
assert!(differing.iter().all(|&i| (8 + 2 + 4 * 12..8 + 2 + 4 * 12 + 4).contains(&i) || i >= exif.len() - thumb_len), "{differing:?}");
assert!(!out.windows(5).any(|w| w == b"stale"), "the thumbnail bytes are blanked, not just unlinked");
// With a JPEG-style prefix too.
let prefixed = [b"Exif\0\0".as_slice(), &exif].concat();
assert_eq!(next_ifd(e, &export_exif(&prefixed, None)[6..]), 0);
// An EXIF without a thumbnail comes through untouched.
assert!(matches!(export_exif(&camera_exif(e), None), Cow::Borrowed(_)));
}
}
/// A thumbnail range that covers IFD0, or tags that are not one SHORT/LONG value, are not blanked.
#[test]
fn thumbnail_blanking_keeps_ifd0_and_ignores_malformed_tags() {
for e in [&LE, &BE] {
let exif = exif_with_thumbnail(e);
let ifd1 = camera_exif(e).len();
let table = 8..8 + 2 + 4 * 12;
// Offset 8, length 60: the range covers IFD0's table.
let mut over = exif.clone();
over[ifd1 + 2 + 8..ifd1 + 2 + 12].copy_from_slice(&e.u32(8));
over[ifd1 + 2 + 12 + 8..ifd1 + 2 + 12 + 12].copy_from_slice(&e.u32(60));
let out = export_exif(&over, None);
assert_eq!(next_ifd(e, &out), 0, "IFD1 is still unlinked");
assert_eq!(out[table.start..table.end], over[table.start..table.end], "IFD0's table is untouched");
// Offset 0, length 8: the range covers the TIFF header, which must stay parseable.
let mut header = exif.clone();
header[ifd1 + 2 + 8..ifd1 + 2 + 12].copy_from_slice(&e.u32(0));
header[ifd1 + 2 + 12 + 8..ifd1 + 2 + 12 + 12].copy_from_slice(&e.u32(8));
let out = export_exif(&header, None);
assert_eq!(out[..8], header[..8], "the TIFF header is untouched");
// IFD0's last entry (Unit) turned into an ExifIFD pointer at IFD1's table: that range is not a thumbnail.
let mut sub = exif.clone();
let entry = 8 + 2 + 3 * 12;
sub[entry..entry + 2].copy_from_slice(&e.u16(0x8769));
sub[entry + 2..entry + 4].copy_from_slice(&e.u16(LONG));
sub[entry + 4..entry + 8].copy_from_slice(&e.u32(1));
sub[entry + 8..entry + 12].copy_from_slice(&e.u32(ifd1 as u32));
sub[ifd1 + 2 + 8..ifd1 + 2 + 12].copy_from_slice(&e.u32(ifd1 as u32 + 2));
sub[ifd1 + 2 + 12 + 8..ifd1 + 2 + 12 + 12].copy_from_slice(&e.u32(4));
let out = export_exif(&sub, None);
assert_eq!(out[ifd1 + 2..ifd1 + 6], sub[ifd1 + 2..ifd1 + 6], "the sub-IFD's table is untouched");
// A count of 2 on the offset tag: the thumbnail bytes are not blanked.
let mut counted = exif.clone();
counted[ifd1 + 2 + 4..ifd1 + 2 + 8].copy_from_slice(&e.u32(2));
assert!(export_exif(&counted, None).windows(5).any(|w| w == b"stale"));
}
}
#[test]
fn thumbnail_removal_never_panics_on_truncated_exif() {
for e in [&LE, &BE] {
let exif = exif_with_thumbnail(e);
for n in 0..exif.len() {
let _ = export_exif(&exif[..n], None);
let _ = export_exif(&exif[..n], Some((300.0, 300.0)));
}
}
}
/// Many Exif pointers to one huge sub-IFD must not cost a full scan each: past the scan budget the
/// thumbnail bytes are left alone (the IFD1 pointer is still cleared).
#[test]
fn thumbnail_overlap_scan_is_bounded_by_a_budget() {
let e = &LE;
let (pointers, sub_entries) = (100usize, 65_535usize);
let sub = 8 + 2 + pointers * 12 + 4;
let ifd1 = sub + 2 + sub_entries * 12 + 4;
let thumb = b"\xFF\xD8stale thumbnail\xFF\xD9";
let mut v = b"II*\0".to_vec();
v.extend_from_slice(&e.u32(8));
v.extend_from_slice(&e.u16(pointers as u16));
for _ in 0..pointers {
v.extend_from_slice(&[e.u16(0x8769), e.u16(LONG)].concat());
v.extend_from_slice(&[e.u32(1), e.u32(sub as u32)].concat());
}
v.extend_from_slice(&e.u32(ifd1 as u32));
v.extend_from_slice(&e.u16(sub_entries as u16));
v.resize(ifd1, 0); // the sub-IFD's entries are type 0: nothing to hit
v.extend_from_slice(&e.u16(2));
for (tag, value) in [(0x0201u16, (ifd1 + 2 + 2 * 12 + 4) as u32), (0x0202, thumb.len() as u32)] {
v.extend_from_slice(&[e.u16(tag), e.u16(LONG)].concat());
v.extend_from_slice(&[e.u32(1), e.u32(value)].concat());
}
v.extend_from_slice(&e.u32(0));
v.extend_from_slice(thumb);
let out = export_exif(&v, None);
assert_eq!(next_ifd_at(e, &out, 8 + 2 + pointers * 12), 0, "IFD1 is unlinked");
assert!(out.windows(5).any(|w| w == b"stale"), "over the scan budget the thumbnail bytes stay");
}
fn next_ifd_at(e: &Exif, exif: &[u8], at: usize) -> u32 {
let b: [u8; 4] = exif[at..at + 4].try_into().unwrap();
if e.big { u32::from_be_bytes(b) } else { u32::from_le_bytes(b) }
}
/// A thumbnail range that covers the Interoperability IFD (an Exif sub-IFD's 0xA005 target) is not
/// blanked: its pointer would stay behind, aimed at zeroes.
#[test]
fn thumbnail_blanking_keeps_the_interoperability_ifd() {
for e in [&LE, &BE] {
let (ifd0, sub, interop, ifd1) = (8usize, 8 + 2 + 12 + 4, 8 + 2 + 12 + 4 + 2 + 12 + 4, 8 + 2 + 12 + 4 + 2 + 12 + 4 + 2 + 12 + 4);
let mut v = if e.big { b"MM\0*".to_vec() } else { b"II*\0".to_vec() };
v.extend_from_slice(&e.u32(ifd0 as u32));
// IFD0: one ExifIFD pointer, then IFD1.
v.extend_from_slice(&e.u16(1));
v.extend_from_slice(&[e.u16(0x8769), e.u16(LONG)].concat());
v.extend_from_slice(&[e.u32(1), e.u32(sub as u32)].concat());
v.extend_from_slice(&e.u32(ifd1 as u32));
// The Exif IFD: one Interoperability pointer.
v.extend_from_slice(&e.u16(1));
v.extend_from_slice(&[e.u16(0xA005), e.u16(LONG)].concat());
v.extend_from_slice(&[e.u32(1), e.u32(interop as u32)].concat());
v.extend_from_slice(&e.u32(0));
// The Interoperability IFD: one inline ASCII entry (InteroperabilityIndex).
v.extend_from_slice(&e.u16(1));
v.extend_from_slice(&[e.u16(1), e.u16(ASCII)].concat());
v.extend_from_slice(&[e.u32(4), b"R98\0".to_vec().try_into().unwrap_or([0; 4])].concat());
v.extend_from_slice(&e.u32(0));
assert_eq!(v.len(), ifd1);
// IFD1: a thumbnail whose range is the Interoperability IFD.
v.extend_from_slice(&e.u16(2));
for (tag, value) in [(0x0201u16, interop as u32), (0x0202, 18)] {
v.extend_from_slice(&[e.u16(tag), e.u16(LONG)].concat());
v.extend_from_slice(&[e.u32(1), e.u32(value)].concat());
}
v.extend_from_slice(&e.u32(0));
let out = export_exif(&v, None);
assert_eq!(out[interop..interop + 18], v[interop..interop + 18], "the Interoperability IFD is untouched");
}
}