mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-14 22:42:46 +00:00
* 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>
244 lines
9.9 KiB
Rust
244 lines
9.9 KiB
Rust
//! Byte-preserving edits of Exif and XMP blocks, for writing them next to pixels that were
|
||
//! changed: Orientation, resolution and the Exif thumbnail. Every other byte of a block stays as
|
||
//! it was, so tags this crate does not know survive.
|
||
//!
|
||
//! An application that turns pixels upright on open writes Orientation = 1 on export
|
||
//! ([`upright_exif`], [`upright_xmp`]), so a viewer never rotates them a second time;
|
||
//! [`export_exif`] and [`export_xmp`] also set the resolution and drop a thumbnail of the
|
||
//! source image. [`exif_color_space`] reads the Exif ColorSpace tag.
|
||
//!
|
||
//! These work on the raw entry tables rather than on a parsed [`craft_tiff::Tiff`], because they
|
||
//! rewrite values in place and keep duplicate and unknown entries exactly as stored.
|
||
//!
|
||
//! ## Orientation (tag 274)
|
||
//!
|
||
|
||
//! Orientation values (TIFF 6.0, EXIF 2.3): 1 = upright, 2 = mirrored, 3 =
|
||
//! rotated 180°, 4 = flipped vertically, 5 = transposed, 6 = needs a 90°
|
||
//! clockwise turn, 7 = transverse, 8 = needs a 90° counter-clockwise turn.
|
||
//! Anything malformed, truncated or out of range reads as 1: an IFD0 that
|
||
//! starts inside the 8-byte header or is cut off before its next-IFD pointer,
|
||
//! or an Orientation entry that is not exactly one SHORT or LONG (the spec says
|
||
//! SHORT, but some cameras and tools write LONG). When IFD0 holds
|
||
//! several Orientation entries the first one wins (like libexif).
|
||
|
||
use std::borrow::Cow;
|
||
|
||
mod color;
|
||
mod resolution;
|
||
|
||
pub use color::{EXIF_COLOR_SPACE_SRGB, exif_color_space};
|
||
pub use resolution::{
|
||
exif_resolution, exif_with_pixel_size, exif_with_resolution, exif_with_resolution_adding_missing, export_exif,
|
||
export_exif_adding_missing_resolution, export_xmp, photoshop_resolution, resolution, xmp_resolution, xmp_with_resolution,
|
||
};
|
||
|
||
/// The TIFF/EXIF Orientation tag.
|
||
const TAG_ORIENTATION: u16 = 274;
|
||
/// Field types accepted for the Orientation tag: SHORT (the spec) and LONG
|
||
/// (written by some cameras and tools).
|
||
const TYPE_SHORT: u16 = 3;
|
||
const TYPE_LONG: u16 = 4;
|
||
/// Bytes per IFD entry.
|
||
const ENTRY: usize = 12;
|
||
/// The TIFF header is 8 bytes, so no IFD can start before it.
|
||
const HEADER: usize = 8;
|
||
/// BigTIFF uses a 16-byte header, 20-byte entries, and 8-byte offsets.
|
||
const BIG_ENTRY: usize = 20;
|
||
const BIG_HEADER: usize = 16;
|
||
|
||
/// Byte order of a TIFF structure.
|
||
#[derive(Clone, Copy)]
|
||
pub(crate) enum Order {
|
||
Little,
|
||
Big,
|
||
}
|
||
|
||
impl Order {
|
||
pub(crate) fn u16(self, b: &[u8], at: usize) -> Option<u16> {
|
||
let s: [u8; 2] = b.get(at..at.checked_add(2)?)?.try_into().ok()?;
|
||
Some(match self {
|
||
Order::Little => u16::from_le_bytes(s),
|
||
Order::Big => u16::from_be_bytes(s),
|
||
})
|
||
}
|
||
|
||
pub(crate) fn u32(self, b: &[u8], at: usize) -> Option<u32> {
|
||
let s: [u8; 4] = b.get(at..at.checked_add(4)?)?.try_into().ok()?;
|
||
Some(match self {
|
||
Order::Little => u32::from_le_bytes(s),
|
||
Order::Big => u32::from_be_bytes(s),
|
||
})
|
||
}
|
||
|
||
pub(crate) fn u64(self, b: &[u8], at: usize) -> Option<u64> {
|
||
let s: [u8; 8] = b.get(at..at.checked_add(8)?)?.try_into().ok()?;
|
||
Some(match self {
|
||
Order::Little => u64::from_le_bytes(s),
|
||
Order::Big => u64::from_be_bytes(s),
|
||
})
|
||
}
|
||
}
|
||
|
||
/// The TIFF payload of an EXIF block (a JPEG-style `Exif\0\0` prefix is skipped).
|
||
pub(crate) fn tiff_body(b: &[u8]) -> &[u8] {
|
||
b.strip_prefix(b"Exif\0\0").unwrap_or(b)
|
||
}
|
||
|
||
/// IFD0 of a TIFF structure: byte order, entry layout, and entry offsets.
|
||
///
|
||
/// The full table and next-IFD pointer must be present, and IFD0 must start
|
||
/// after the corresponding TIFF or BigTIFF header.
|
||
pub(crate) fn ifd0_entries(b: &[u8]) -> Option<(Order, usize, usize, usize, bool)> {
|
||
let (order, big_tiff) = match b.get(0..4)? {
|
||
[b'I', b'I', 42, 0] => (Order::Little, false),
|
||
[b'M', b'M', 0, 42] => (Order::Big, false),
|
||
[b'I', b'I', 43, 0] => (Order::Little, true),
|
||
[b'M', b'M', 0, 43] => (Order::Big, true),
|
||
_ => return None,
|
||
};
|
||
let (ifd, count_bytes, entry_bytes, next_ifd_bytes, header) = if big_tiff {
|
||
if order.u16(b, 4)? != 8 || order.u16(b, 6)? != 0 {
|
||
return None;
|
||
}
|
||
(usize::try_from(order.u64(b, 8)?).ok()?, 8usize, BIG_ENTRY, 8usize, BIG_HEADER)
|
||
} else {
|
||
(usize::try_from(order.u32(b, 4)?).ok()?, 2usize, ENTRY, 4usize, HEADER)
|
||
};
|
||
if ifd < header {
|
||
return None;
|
||
}
|
||
let count = if big_tiff { usize::try_from(order.u64(b, ifd)?).ok()? } else { usize::from(order.u16(b, ifd)?) };
|
||
let first = ifd.checked_add(count_bytes)?;
|
||
let next_ifd = first.checked_add(count.checked_mul(entry_bytes)?)?;
|
||
if next_ifd.checked_add(next_ifd_bytes)? > b.len() {
|
||
return None;
|
||
}
|
||
// In bounds: every entry ends at or before `next_ifd`.
|
||
Some((order, first, count, entry_bytes, big_tiff))
|
||
}
|
||
|
||
/// A well-formed Orientation entry's value: where it sits and its field type.
|
||
#[derive(Clone, Copy)]
|
||
struct Value {
|
||
at: usize,
|
||
ty: u16,
|
||
}
|
||
|
||
impl Value {
|
||
/// The stored value (a SHORT or LONG, left-justified in the 4-byte field).
|
||
fn read(self, order: Order, b: &[u8]) -> Option<u32> {
|
||
match self.ty {
|
||
TYPE_SHORT => order.u16(b, self.at).map(u32::from),
|
||
_ => order.u32(b, self.at),
|
||
}
|
||
}
|
||
|
||
/// 1 in this entry's own type, width and byte order.
|
||
fn one(self, order: Order) -> Vec<u8> {
|
||
match (self.ty, order) {
|
||
(TYPE_SHORT, Order::Little) => 1u16.to_le_bytes().to_vec(),
|
||
(TYPE_SHORT, Order::Big) => 1u16.to_be_bytes().to_vec(),
|
||
(_, Order::Little) => 1u32.to_le_bytes().to_vec(),
|
||
(_, Order::Big) => 1u32.to_be_bytes().to_vec(),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The value of the entry at `e` if it is a well-formed Orientation entry:
|
||
/// exactly one SHORT or LONG.
|
||
fn orientation_value(order: Order, b: &[u8], e: usize, big_tiff: bool) -> Option<Value> {
|
||
let count = if big_tiff { order.u64(b, e.checked_add(4)?)? } else { u64::from(order.u32(b, e.checked_add(4)?)?) };
|
||
if order.u16(b, e)? != TAG_ORIENTATION || count != 1 {
|
||
return None;
|
||
}
|
||
let ty = order.u16(b, e.checked_add(2)?)?;
|
||
let value_offset = if big_tiff { 12 } else { 8 };
|
||
matches!(ty, TYPE_SHORT | TYPE_LONG).then_some(Value { at: e.checked_add(value_offset)?, ty })
|
||
}
|
||
|
||
/// Locates the Orientation value in IFD0.
|
||
///
|
||
/// The **first** Orientation entry wins (like libexif and Photoshop); if that
|
||
/// one is malformed (count ≠ 1, or neither SHORT nor LONG) the tag is ignored,
|
||
/// even when a later duplicate is well formed.
|
||
fn find_entry(b: &[u8]) -> Option<(Order, Value)> {
|
||
let (order, first, count, entry_bytes, big_tiff) = ifd0_entries(b)?;
|
||
let e = (0..count).filter_map(|i| first.checked_add(i.checked_mul(entry_bytes)?)).find(|&e| order.u16(b, e) == Some(TAG_ORIENTATION))?;
|
||
Some((order, orientation_value(order, b, e, big_tiff)?))
|
||
}
|
||
|
||
/// The orientation (1–8) recorded in a TIFF-structured block: an EXIF payload
|
||
/// (with or without the `Exif\0\0` prefix) or a whole TIFF file. Missing,
|
||
/// malformed or out-of-range values give 1.
|
||
pub fn exif_orientation(exif: &[u8]) -> u16 {
|
||
let b = tiff_body(exif);
|
||
find_entry(b).and_then(|(order, v)| v.read(order, b)).and_then(|v| u16::try_from(v).ok()).filter(|v| (1..=8).contains(v)).unwrap_or(1)
|
||
}
|
||
|
||
/// `exif` with its Orientation rewritten to 1, borrowed when there is nothing
|
||
/// to change (no well-formed tag, already 1, or unparseable).
|
||
///
|
||
/// Only the value bytes of each well-formed Orientation entry change, written
|
||
/// as 1 in the entry's existing type and width (SHORT or LONG); every other
|
||
/// byte (other tags, their offset-based values, sub-IFDs) is kept as is.
|
||
/// Well-formed duplicates are all set to 1, so a reader that lets a later
|
||
/// duplicate win cannot rotate the upright pixels either.
|
||
pub fn upright_exif(exif: &[u8]) -> Cow<'_, [u8]> {
|
||
let body = tiff_body(exif);
|
||
let prefix = exif.len() - body.len();
|
||
let Some((order, first, count, entry_bytes, big_tiff)) = ifd0_entries(body) else {
|
||
return Cow::Borrowed(exif);
|
||
};
|
||
let values: Vec<Value> = (0..count)
|
||
.filter_map(|i| first.checked_add(i.checked_mul(entry_bytes)?))
|
||
.filter_map(|e| orientation_value(order, body, e, big_tiff))
|
||
.collect();
|
||
if values.iter().all(|v| v.read(order, body) == Some(1)) {
|
||
return Cow::Borrowed(exif);
|
||
}
|
||
let mut out = exif.to_vec();
|
||
for v in values {
|
||
let one = v.one(order);
|
||
let slot = prefix.checked_add(v.at).and_then(|at| out.get_mut(at..at.checked_add(one.len())?));
|
||
if let Some(slot) = slot {
|
||
slot.copy_from_slice(&one);
|
||
}
|
||
}
|
||
Cow::Owned(out)
|
||
}
|
||
|
||
/// `xmp` with any `tiff:Orientation` (attribute or element form) set to 1,
|
||
/// borrowed when there is nothing to change.
|
||
pub fn upright_xmp(xmp: &str) -> Cow<'_, str> {
|
||
const KEY: &str = "tiff:Orientation";
|
||
if !xmp.contains(KEY) {
|
||
return Cow::Borrowed(xmp);
|
||
}
|
||
let mut out = String::with_capacity(xmp.len());
|
||
let mut rest = xmp;
|
||
let mut changed = false;
|
||
while let Some(i) = rest.find(KEY) {
|
||
let (head, tail) = rest.split_at(i + KEY.len());
|
||
out.push_str(head);
|
||
rest = tail;
|
||
// `tiff:Orientation="6"`, `tiff:Orientation='6'` or `<tiff:Orientation>6<`.
|
||
let open = rest.char_indices().take_while(|&(_, c)| c.is_ascii_whitespace() || matches!(c, '=' | '"' | '\'' | '>')).last();
|
||
let Some((at, c)) = open else { continue };
|
||
if !matches!(c, '"' | '\'' | '>') {
|
||
continue;
|
||
}
|
||
let start = at + 1;
|
||
let digits = rest.get(start..).map_or(0, |s| s.bytes().take_while(u8::is_ascii_digit).count());
|
||
if digits == 0 || rest.get(start..start + digits) == Some("1") {
|
||
continue;
|
||
}
|
||
out.push_str(rest.get(..start).unwrap_or_default());
|
||
out.push('1');
|
||
rest = rest.get(start + digits..).unwrap_or_default();
|
||
changed = true;
|
||
}
|
||
out.push_str(rest);
|
||
if changed { Cow::Owned(out) } else { Cow::Borrowed(xmp) }
|
||
}
|