| // 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; | |
| let mut followed_exif_ifd = false; | |
| 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. | |
| // The Exif specification permits only one in the root IFD. Following | |
| // duplicates can otherwise cause quadratic work on malformed input. | |
| TAG_EXIF_IFD if is_root && !followed_exif_ifd && type_ == TYPE_LONG && cnt == 1 => { | |
| followed_exif_ifd = true; | |
| 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}" | |
| ); | |
| } | |
| fn exif_with_two_ifd_pointers() -> Vec<u8> { | |
| const ROOT_IFD_OFFSET: usize = 8; | |
| const FIRST_IFD_OFFSET: usize = 34; | |
| const SECOND_IFD_OFFSET: usize = 48; | |
| let mut data = vec![0u8; 62]; | |
| data[0..2].copy_from_slice(b"MM"); | |
| data[2..4].copy_from_slice(&42u16.to_be_bytes()); | |
| data[4..8].copy_from_slice(&(ROOT_IFD_OFFSET as u32).to_be_bytes()); | |
| data[ROOT_IFD_OFFSET..ROOT_IFD_OFFSET + 2].copy_from_slice(&2u16.to_be_bytes()); | |
| for (entry_offset, ifd_offset) in [ | |
| (10, FIRST_IFD_OFFSET as u32), | |
| (22, SECOND_IFD_OFFSET as u32), | |
| ] { | |
| data[entry_offset..entry_offset + 2].copy_from_slice(&TAG_EXIF_IFD.to_be_bytes()); | |
| data[entry_offset + 2..entry_offset + 4].copy_from_slice(&TYPE_LONG.to_be_bytes()); | |
| data[entry_offset + 4..entry_offset + 8].copy_from_slice(&1u32.to_be_bytes()); | |
| data[entry_offset + 8..entry_offset + 12].copy_from_slice(&ifd_offset.to_be_bytes()); | |
| } | |
| data[FIRST_IFD_OFFSET..FIRST_IFD_OFFSET + 2].copy_from_slice(&1u16.to_be_bytes()); | |
| data[36..38].copy_from_slice(&TAG_PIXEL_Y_DIMENSION.to_be_bytes()); | |
| data[38..40].copy_from_slice(&TYPE_LONG.to_be_bytes()); | |
| data[40..44].copy_from_slice(&1u32.to_be_bytes()); | |
| data[44..48].copy_from_slice(&480u32.to_be_bytes()); | |
| data[SECOND_IFD_OFFSET..SECOND_IFD_OFFSET + 2].copy_from_slice(&1u16.to_be_bytes()); | |
| data[50..52].copy_from_slice(&TAG_PIXEL_X_DIMENSION.to_be_bytes()); | |
| data[52..54].copy_from_slice(&TYPE_LONG.to_be_bytes()); | |
| data[54..58].copy_from_slice(&1u32.to_be_bytes()); | |
| data[58..62].copy_from_slice(&640u32.to_be_bytes()); | |
| data | |
| } | |
| fn empty_metadata() -> ExifMetadata { | |
| ExifMetadata { | |
| has_origin: false, | |
| origin: 0, | |
| has_hdr_headroom: false, | |
| hdr_headroom: 0.0, | |
| has_resolution_unit: false, | |
| resolution_unit: 0, | |
| has_x_resolution: false, | |
| x_resolution: 0.0, | |
| has_y_resolution: false, | |
| y_resolution: 0.0, | |
| has_pixel_x_dimension: false, | |
| pixel_x_dimension: 0, | |
| has_pixel_y_dimension: false, | |
| pixel_y_dimension: 0, | |
| } | |
| } | |
| #[test] | |
| fn test_only_first_exif_ifd_is_followed() { | |
| let data = exif_with_two_ifd_pointers(); | |
| let mut out = empty_metadata(); | |
| assert!(parse(&data, &mut out)); | |
| assert!(out.has_pixel_y_dimension); | |
| assert_eq!(out.pixel_y_dimension, 480); | |
| assert!(!out.has_pixel_x_dimension); | |
| } | |
| } |