blob: bf6b61d4aff0a7e3eac124ac3cbf26843541b8b5 [file]
// Copyright 2026 Google LLC
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
//! Custom TIFF/EXIF parser for the SkExif metadata fields.
//!
//! Handles both big-endian (Motorola, "MM") and little-endian (Intel, "II")
//! TIFF byte orders. Follows the ExifIFD sub-IFD (tag 0x8769) from the root
//! IFD to match the behavior of SkExif::Parse / SkTiff::ImageFileDirectory.
use crate::ffi::ExifMetadata;
#[derive(Clone, Copy)]
enum Endianness {
Little,
Big,
}
// Tag numbers parsed by this module.
const TAG_ORIENTATION: u16 = 0x0112;
const TAG_X_RESOLUTION: u16 = 0x011a;
const TAG_Y_RESOLUTION: u16 = 0x011b;
const TAG_RESOLUTION_UNIT: u16 = 0x0128;
const TAG_MAKER_NOTE: u16 = 0x927c;
const TAG_EXIF_IFD: u16 = 0x8769;
const TAG_PIXEL_X_DIMENSION: u16 = 0xa002;
const TAG_PIXEL_Y_DIMENSION: u16 = 0xa003;
// TIFF type codes.
const TYPE_SHORT: u16 = 3;
const TYPE_LONG: u16 = 4;
const TYPE_RATIONAL: u16 = 5;
const TYPE_UNDEFINED: u16 = 7;
// Apple MakerNote signature (14 bytes).
const APPLE_MAKER_NOTE_SIG: &[u8] = b"Apple iOS\x00\x00\x01MM";
fn read_u16(data: &[u8], off: usize, endianness: Endianness) -> Option<u16> {
let bytes: [u8; 2] = data.get(off..off + 2)?.try_into().unwrap();
Some(match endianness {
Endianness::Little => u16::from_le_bytes(bytes),
Endianness::Big => u16::from_be_bytes(bytes),
})
}
fn read_u32(data: &[u8], off: usize, endianness: Endianness) -> Option<u32> {
let bytes: [u8; 4] = data.get(off..off + 4)?.try_into().unwrap();
Some(match endianness {
Endianness::Little => u32::from_le_bytes(bytes),
Endianness::Big => u32::from_be_bytes(bytes),
})
}
fn read_i32(data: &[u8], off: usize, endianness: Endianness) -> Option<i32> {
let bytes: [u8; 4] = data.get(off..off + 4)?.try_into().unwrap();
Some(match endianness {
Endianness::Little => i32::from_le_bytes(bytes),
Endianness::Big => i32::from_be_bytes(bytes),
})
}
/// Computes Apple HDR headroom from the raw MakerNote IFD bytes.
/// Formula: <https://developer.apple.com/documentation/appkit/images_and_pdf/applying_apple_hdr_effect_to_your_photos>
fn parse_apple_hdr_headroom(data: &[u8]) -> Option<f32> {
// The Apple MakerNote is big-endian only. No little-endian images using
// this data have been observed (matches the comment in SkExif.cpp).
if !data.starts_with(APPLE_MAKER_NOTE_SIG) {
return None;
}
let ifd_start = APPLE_MAKER_NOTE_SIG.len();
let entry_count = read_u16(data, ifd_start, Endianness::Big)? as usize;
let entries_start = ifd_start + 2;
const ENTRY_SIZE: usize = 12;
let mut maker33: Option<f32> = None;
let mut maker48: Option<f32> = None;
for i in 0..entry_count {
let off = entries_start + i * ENTRY_SIZE;
let entry = match data.get(off..off + ENTRY_SIZE) {
Some(e) => e,
None => break,
};
let tag = read_u16(entry, 0, Endianness::Big).unwrap();
let type_ = read_u16(entry, 2, Endianness::Big).unwrap();
let count = read_u32(entry, 4, Endianness::Big).unwrap();
// Only process tags 33 and 48, type SRATIONAL (10), count 1.
if (tag == 33 || tag == 48) && type_ == 10 && count == 1 {
let value_off = read_u32(entry, 8, Endianness::Big).unwrap() as usize;
let numer = match read_i32(data, value_off, Endianness::Big) {
Some(v) => v,
None => continue,
};
let denom = match read_i32(data, value_off + 4, Endianness::Big) {
Some(v) => v,
None => continue,
};
let value = if denom != 0 {
numer as f32 / denom as f32
} else {
0.0
};
match tag {
33 if maker33.is_none() => maker33 = Some(value),
48 if maker48.is_none() => maker48 = Some(value),
_ => {}
}
}
}
let maker33 = maker33?;
let maker48 = maker48.unwrap_or(0.0);
let stops = if maker33 < 1.0 {
if maker48 <= 0.01 {
-20.0 * maker48 + 1.8
} else {
-0.101 * maker48 + 1.601
}
} else {
if maker48 <= 0.01 {
-70.0 * maker48 + 3.0
} else {
-0.303 * maker48 + 2.303
}
};
Some(f32::powf(2.0, stops.max(0.0)))
}
fn parse_ifd(
data: &[u8],
ifd_off: usize,
endianness: Endianness,
is_root: bool,
out: &mut ExifMetadata,
) {
let count = match read_u16(data, ifd_off, endianness) {
Some(c) => c as usize,
None => return,
};
const ENTRY_SIZE: usize = 12;
let entries_start = ifd_off + 2;
for i in 0..count {
let e = entries_start + i * ENTRY_SIZE;
// Read the four fields of the 12-byte IFD entry.
let tag = match read_u16(data, e, endianness) {
Some(v) => v,
None => break,
};
let type_ = match read_u16(data, e + 2, endianness) {
Some(v) => v,
None => break,
};
let cnt = match read_u32(data, e + 4, endianness) {
Some(v) => v,
None => break,
};
// The last 4 bytes are either an inline value (if total ≤ 4 bytes) or
// a file offset to the value. We read it as u32 here regardless and
// treat it as an offset only when total > 4.
let val_field = match read_u32(data, e + 8, endianness) {
Some(v) => v,
None => break,
};
match tag {
// Orientation: SHORT, count=1, 2 bytes → inline at e+8.
TAG_ORIENTATION if !out.has_origin && type_ == TYPE_SHORT && cnt == 1 => {
if let Some(v) = read_u16(data, e + 8, endianness) {
if v >= 1 && v <= 8 {
out.has_origin = true;
out.origin = v as u32;
}
}
}
// ResolutionUnit: SHORT, count=1, 2 bytes → inline at e+8.
TAG_RESOLUTION_UNIT if !out.has_resolution_unit && type_ == TYPE_SHORT && cnt == 1 => {
if let Some(v) = read_u16(data, e + 8, endianness) {
out.has_resolution_unit = true;
out.resolution_unit = v;
}
}
// XResolution: RATIONAL, count=1, 8 bytes → val_field is a file offset.
TAG_X_RESOLUTION if !out.has_x_resolution && type_ == TYPE_RATIONAL && cnt == 1 => {
let off = val_field as usize;
if let (Some(n), Some(d)) = (
read_u32(data, off, endianness),
read_u32(data, off + 4, endianness),
) {
out.has_x_resolution = true;
out.x_resolution = if d != 0 { n as f32 / d as f32 } else { 0.0 };
}
}
// YResolution: RATIONAL, count=1, 8 bytes → val_field is a file offset.
TAG_Y_RESOLUTION if !out.has_y_resolution && type_ == TYPE_RATIONAL && cnt == 1 => {
let off = val_field as usize;
if let (Some(n), Some(d)) = (
read_u32(data, off, endianness),
read_u32(data, off + 4, endianness),
) {
out.has_y_resolution = true;
out.y_resolution = if d != 0 { n as f32 / d as f32 } else { 0.0 };
}
}
// PixelXDimension: SHORT or LONG per Exif 2.3 spec, count=1 → inline at e+8.
TAG_PIXEL_X_DIMENSION if !out.has_pixel_x_dimension && cnt == 1 => {
if type_ == TYPE_SHORT {
if let Some(v) = read_u16(data, e + 8, endianness) {
out.has_pixel_x_dimension = true;
out.pixel_x_dimension = v as u32;
}
} else if type_ == TYPE_LONG {
if let Some(v) = read_u32(data, e + 8, endianness) {
out.has_pixel_x_dimension = true;
out.pixel_x_dimension = v;
}
}
}
// PixelYDimension: SHORT or LONG per Exif 2.3 spec, count=1 → inline at e+8.
TAG_PIXEL_Y_DIMENSION if !out.has_pixel_y_dimension && cnt == 1 => {
if type_ == TYPE_SHORT {
if let Some(v) = read_u16(data, e + 8, endianness) {
out.has_pixel_y_dimension = true;
out.pixel_y_dimension = v as u32;
}
} else if type_ == TYPE_LONG {
if let Some(v) = read_u32(data, e + 8, endianness) {
out.has_pixel_y_dimension = true;
out.pixel_y_dimension = v;
}
}
}
// MakerNote: UNDEFINED bytes, typically large → val_field is a file offset.
TAG_MAKER_NOTE if !out.has_hdr_headroom && type_ == TYPE_UNDEFINED && cnt > 4 => {
let off = val_field as usize;
let len = cnt as usize;
if let Some(bytes) = data.get(off..off + len) {
if let Some(h) = parse_apple_hdr_headroom(bytes) {
out.has_hdr_headroom = true;
out.hdr_headroom = h;
}
}
}
// ExifIFD pointer: LONG, count=1 → val_field is the sub-IFD offset.
// Only follow from the root IFD to match SkExif.cpp behavior.
TAG_EXIF_IFD if is_root && type_ == TYPE_LONG && cnt == 1 => {
parse_ifd(data, val_field as usize, endianness, false, out);
}
_ => {}
}
}
}
/// Parses a raw TIFF-formatted EXIF blob into `out`.
/// Returns `true` if the header is valid; `false` otherwise.
/// Missing individual fields are indicated by their `has_*` flags.
pub fn parse(data: &[u8], out: &mut ExifMetadata) -> bool {
if data.len() < 8 {
return false;
}
let endianness = match data.get(0..2) {
Some(b"MM") => Endianness::Big,
Some(b"II") => Endianness::Little,
_ => return false,
};
// TIFF magic: 42 (0x002A).
if read_u16(data, 2, endianness) != Some(0x002A) {
return false;
}
let ifd_off = match read_u32(data, 4, endianness) {
Some(off) => off as usize,
None => return false,
};
parse_ifd(data, ifd_off, endianness, true, out);
true
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_read_u16() {
let data = [0x01, 0x02, 0x03, 0x04];
assert_eq!(read_u16(&data, 0, Endianness::Big), Some(0x0102));
assert_eq!(read_u16(&data, 0, Endianness::Little), Some(0x0201));
assert_eq!(read_u16(&data, 2, Endianness::Big), Some(0x0304));
assert_eq!(read_u16(&data, 2, Endianness::Little), Some(0x0403));
assert_eq!(read_u16(&data, 3, Endianness::Big), None); // out of bounds
}
#[test]
fn test_read_u32() {
let data = [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08];
assert_eq!(read_u32(&data, 0, Endianness::Big), Some(0x01020304));
assert_eq!(read_u32(&data, 0, Endianness::Little), Some(0x04030201));
assert_eq!(read_u32(&data, 4, Endianness::Big), Some(0x05060708));
assert_eq!(read_u32(&data, 4, Endianness::Little), Some(0x08070605));
assert_eq!(read_u32(&data, 5, Endianness::Big), None); // out of bounds
}
#[test]
fn test_read_i32() {
// 0xFF000001 is -16777215 in two's complement big-endian.
let data = [0xFF, 0x00, 0x00, 0x01, 0x01, 0x00, 0x00, 0xFF];
assert_eq!(read_i32(&data, 0, Endianness::Big), Some(-16777215));
assert_eq!(
read_i32(&data, 0, Endianness::Little),
Some(0x010000FF_u32 as i32)
);
assert_eq!(
read_i32(&data, 4, Endianness::Big),
Some(0x010000FF_u32 as i32)
);
assert_eq!(read_i32(&data, 4, Endianness::Little), Some(-16777215));
assert_eq!(read_i32(&data, 5, Endianness::Big), None); // out of bounds
}
#[test]
fn test_apple_hdr_headroom_sig_mismatch() {
assert!(parse_apple_hdr_headroom(b"").is_none());
assert!(parse_apple_hdr_headroom(b"Not Apple MakerNote data").is_none());
}
#[test]
fn test_apple_hdr_headroom_truncated() {
// Signature present but no IFD data following it.
assert!(parse_apple_hdr_headroom(APPLE_MAKER_NOTE_SIG).is_none());
}
#[test]
fn test_apple_hdr_headroom_valid() {
// Build a minimal Apple MakerNote (56 bytes):
// 14-byte signature | 2-byte count | 2×12-byte IFD entries | 2×8-byte SRATIONAL values
const IFD_START: usize = APPLE_MAKER_NOTE_SIG.len(); // 14
const ENTRIES_START: usize = IFD_START + 2; // 16
const SRATIONAL_0: usize = ENTRIES_START + 24; // 40 (after 2 entries)
const SRATIONAL_1: usize = SRATIONAL_0 + 8; // 48
let mut data = vec![0u8; SRATIONAL_1 + 8];
data[..IFD_START].copy_from_slice(APPLE_MAKER_NOTE_SIG);
data[IFD_START..IFD_START + 2].copy_from_slice(&2u16.to_be_bytes()); // count=2
// Entry 0: tag=33, type=10 (SRATIONAL), count=1, offset → SRATIONAL_0.
let e0 = ENTRIES_START;
data[e0..e0 + 2].copy_from_slice(&33u16.to_be_bytes());
data[e0 + 2..e0 + 4].copy_from_slice(&10u16.to_be_bytes());
data[e0 + 4..e0 + 8].copy_from_slice(&1u32.to_be_bytes());
data[e0 + 8..e0 + 12].copy_from_slice(&(SRATIONAL_0 as u32).to_be_bytes());
// Entry 1: tag=48, type=10 (SRATIONAL), count=1, offset → SRATIONAL_1.
let e1 = e0 + 12;
data[e1..e1 + 2].copy_from_slice(&48u16.to_be_bytes());
data[e1 + 2..e1 + 4].copy_from_slice(&10u16.to_be_bytes());
data[e1 + 4..e1 + 8].copy_from_slice(&1u32.to_be_bytes());
data[e1 + 8..e1 + 12].copy_from_slice(&(SRATIONAL_1 as u32).to_be_bytes());
// SRATIONAL for tag 33: 1/1 = 1.0 (maker33 ≥ 1.0 branch).
data[SRATIONAL_0..SRATIONAL_0 + 4].copy_from_slice(&1i32.to_be_bytes());
data[SRATIONAL_0 + 4..SRATIONAL_0 + 8].copy_from_slice(&1i32.to_be_bytes());
// SRATIONAL for tag 48: 1/10 = 0.1 (maker48 > 0.01 branch).
data[SRATIONAL_1..SRATIONAL_1 + 4].copy_from_slice(&1i32.to_be_bytes());
data[SRATIONAL_1 + 4..SRATIONAL_1 + 8].copy_from_slice(&10i32.to_be_bytes());
// maker33=1.0 ≥ 1.0, maker48=0.1 > 0.01:
// stops = -0.303 * 0.1 + 2.303 = 2.2727
// headroom = 2^2.2727 ≈ 4.82
let headroom = parse_apple_hdr_headroom(&data);
assert!(headroom.is_some(), "expected a valid headroom value");
let h = headroom.unwrap();
assert!(
(h - 4.82_f32).abs() < 0.05,
"expected headroom ≈ 4.82, got {h}"
);
}
}