fix(codecs): correct TIFF polarity and BigTIFF orientation (#746)

Use the TIFF decoder's normalized WhiteIsZero samples without a second inversion, and parse BigTIFF IFD0 Orientation so decode applies the recorded rotation.

Fixes #691

Fixes #693

Co-authored-by: Copilot <[email protected]>

Co-authored-by: Tanish Manku <[email protected]>
Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d
This commit is contained in:
authored and GitHub committed 2026-10-07 13:50:04 -07:00
1 parent 702b4aa6b0
commit a1f4387e63
3 files changed
+142 -38

No files matched your search

-15
View File
@@ -160,8 +160,6 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
};
let bytes_per_sample = u64::from(bits.max(8) / 8);
limits.check_bytes(w, h, layout.channels() as u64 * bytes_per_sample.max(1))?;
let white_is_zero = dec.find_tag_unsigned::<u16>(Tag::PhotometricInterpretation).ok().flatten() == Some(PhotometricInterpretation::WhiteIsZero.to_u16());
let result = dec.read_image().map_err(map_err)?;
let n = w as usize * h as usize * layout.channels();
let mut img = match result {
@@ -185,19 +183,6 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
if img.sample_count() != n {
return Err(err("sample count mismatch"));
}
if white_is_zero && layout.is_gray() {
let v: Vec<f32> = img
.to_normalized()
.chunks(layout.channels())
.flat_map(|p| {
let mut p = p.to_vec();
p[0] = 1.0 - p[0];
p
})
.collect();
img = Image::from_normalized(w, h, layout, img.sample_type(), &v)?;
}
img.icc = dec.find_tag(Tag::IccProfile).ok().flatten().and_then(value_bytes);
let mut meta = Metadata {
xmp: dec
+45 -23
View File
@@ -30,6 +30,9 @@ const TYPE_LONG: u16 = 4;
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)]
@@ -54,6 +57,14 @@ impl Order {
Order::Big => u32::from_be_bytes(s),
})
}
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).
@@ -61,29 +72,37 @@ fn tiff_body(b: &[u8]) -> &[u8] {
b.strip_prefix(b"Exif\0\0").unwrap_or(b)
}
/// IFD0 of a TIFF structure: its byte order and the offsets of its entries.
/// IFD0 of a TIFF structure: byte order, entry layout, and entry offsets.
///
/// The whole table must be present, up to and including the 4-byte next-IFD
/// pointer, and it must start after the 8-byte header; anything else is
/// treated as unparseable.
fn ifd0_entries(b: &[u8]) -> Option<(Order, impl Iterator<Item = usize>)> {
let order = match b.get(0..4)? {
[b'I', b'I', 42, 0] => Order::Little,
[b'M', b'M', 0, 42] => Order::Big,
/// The full table and next-IFD pointer must be present, and IFD0 must start
/// after the corresponding TIFF or BigTIFF header.
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 = usize::try_from(order.u32(b, 4)?).ok()?;
if ifd < HEADER {
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 = usize::from(order.u16(b, ifd)?);
let first = ifd.checked_add(2)?;
let next_ifd = first.checked_add(count.checked_mul(ENTRY)?)?;
if next_ifd.checked_add(4)? > b.len() {
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, (0..count).map(move |i| first + i * ENTRY)))
Some((order, first, count, entry_bytes, big_tiff))
}
/// A well-formed Orientation entry's value: where it sits and its field type.
@@ -115,12 +134,14 @@ impl Value {
/// 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) -> Option<Value> {
if order.u16(b, e)? != TAG_ORIENTATION || order.u32(b, e.checked_add(4)?)? != 1 {
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)?)?;
matches!(ty, TYPE_SHORT | TYPE_LONG).then_some(Value { at: e.checked_add(8)?, ty })
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.
@@ -129,9 +150,9 @@ fn orientation_value(order: Order, b: &[u8], e: usize) -> Option<Value> {
/// 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, mut entries) = ifd0_entries(b)?;
let e = entries.find(|&e| order.u16(b, e) == Some(TAG_ORIENTATION))?;
Some((order, orientation_value(order, b, e)?))
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
@@ -153,10 +174,11 @@ pub fn exif_orientation(exif: &[u8]) -> u16 {
pub fn upright_exif(exif: &[u8]) -> Cow<'_, [u8]> {
let body = tiff_body(exif);
let prefix = exif.len() - body.len();
let Some((order, entries)) = ifd0_entries(body) else {
let Some((order, first, count, entry_bytes, big_tiff)) = ifd0_entries(body) else {
return Cow::Borrowed(exif);
};
let values: Vec<Value> = entries.filter_map(|e| orientation_value(order, body, e)).collect();
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);
}
+97
View File
@@ -0,0 +1,97 @@
use photocraft_codecs::{Image, decode, exif_orientation, upright_exif};
fn classic_white_is_zero() -> Vec<u8> {
let width = 4u16;
let height = 1u16;
let pixels = [0u8, 64, 192, 255];
let entries: [(u16, u16, u32); 9] = [
(256, 3, u32::from(width)),
(257, 3, u32::from(height)),
(258, 3, 8),
(259, 3, 1),
(262, 3, 0), // WhiteIsZero
(273, 4, 122), // StripOffsets: immediately after IFD0.
(277, 3, 1),
(278, 3, u32::from(height)),
(279, 4, pixels.len() as u32),
];
let mut bytes = b"II*\0\x08\0\0\0".to_vec();
bytes.extend_from_slice(&(entries.len() as u16).to_le_bytes());
for (tag, field_type, value) in entries {
bytes.extend_from_slice(&tag.to_le_bytes());
bytes.extend_from_slice(&field_type.to_le_bytes());
bytes.extend_from_slice(&1u32.to_le_bytes());
if field_type == 3 {
bytes.extend_from_slice(&(value as u16).to_le_bytes());
bytes.extend_from_slice(&[0; 2]);
} else {
bytes.extend_from_slice(&value.to_le_bytes());
}
}
bytes.extend_from_slice(&0u32.to_le_bytes());
bytes.extend_from_slice(&pixels);
bytes
}
fn big_tiff_gray(stored: &[u8], width: u16, height: u16, orientation: u16) -> Vec<u8> {
let mut entries = vec![
(256u16, 3u16, 1u64, u64::from(width)),
(257, 3, 1, u64::from(height)),
(258, 3, 1, 8),
(259, 3, 1, 1),
(262, 3, 1, 1), // BlackIsZero
(273, 4, 1, 0), // StripOffsets, patched below.
(274, 3, 1, u64::from(orientation)),
(277, 3, 1, 1),
(278, 3, 1, u64::from(height)),
(279, 4, 1, stored.len() as u64),
];
entries.sort_by_key(|entry| entry.0);
let pixel_offset = 16u64 + 8 + entries.len() as u64 * 20 + 8;
if let Some(strip_offset) = entries.iter_mut().find(|entry| entry.0 == 273) {
strip_offset.3 = pixel_offset;
}
let mut bytes = b"II+\0\x08\0\0\0\x10\0\0\0\0\0\0\0".to_vec();
bytes.extend_from_slice(&(entries.len() as u64).to_le_bytes());
for (tag, field_type, count, value) in entries {
bytes.extend_from_slice(&tag.to_le_bytes());
bytes.extend_from_slice(&field_type.to_le_bytes());
bytes.extend_from_slice(&count.to_le_bytes());
bytes.extend_from_slice(&value.to_le_bytes());
}
bytes.extend_from_slice(&0u64.to_le_bytes());
bytes.extend_from_slice(stored);
bytes
}
fn stored_for_orientation_6_or_8(orientation: u16) -> Vec<u8> {
match orientation {
6 => vec![3, 6, 2, 5, 1, 4],
8 => vec![4, 1, 5, 2, 6, 3],
_ => unreachable!(),
}
}
#[test]
fn white_is_zero_is_normalized_once_by_the_tiff_decoder() {
let image = decode(&classic_white_is_zero()).expect("synthetic WhiteIsZero TIFF should decode");
assert_eq!(image.dimensions(), (4, 1));
assert_eq!(image.data(), &[255, 191, 63, 0]);
}
#[test]
fn big_tiff_orientation_6_and_8_are_applied_to_decoded_pixels() {
let upright = [1u8, 2, 3, 4, 5, 6];
for orientation in [6, 8] {
let bytes = big_tiff_gray(&stored_for_orientation_6_or_8(orientation), 2, 3, orientation);
let image: Image = decode(&bytes).expect("synthetic BigTIFF should decode");
assert_eq!(exif_orientation(&bytes), orientation);
assert_eq!(exif_orientation(&upright_exif(&bytes)), 1);
assert_eq!(image.dimensions(), (3, 2), "orientation {orientation}");
assert_eq!(image.data(), &upright, "orientation {orientation}");
}
}