Harden EXIF orientation: limits after rotation, fallible allocation, strict IFD parsing (#345) (#345)
This commit is contained in:
parent
909efc0f6d
commit
0349b0c0f1
|
|
@ -0,0 +1,146 @@
|
|||
//! EXIF orientation end to end through the `photocraft-cli` binary (#285): a
|
||||
//! phone-style JPEG (stored landscape, Orientation = 6) converts to an upright
|
||||
//! portrait, an explicit rotation turns it back, and a `.pcraft` round trip
|
||||
//! changes nothing.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use photocraft_codecs::{ChannelLayout, DecodeOptions, EncodeOptions, Format, Image, SampleType, decode, decode_with, exif_orientation};
|
||||
use serde_json::Value;
|
||||
|
||||
fn bin() -> Command {
|
||||
Command::new(env!("CARGO_BIN_EXE_photocraft-cli"))
|
||||
}
|
||||
|
||||
fn tmp(name: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("pc-cli-orient-{}-{name}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn ok(cmd: &mut Command) -> String {
|
||||
let o = cmd.output().unwrap();
|
||||
let (out, err) = (String::from_utf8_lossy(&o.stdout).into_owned(), String::from_utf8_lossy(&o.stderr).into_owned());
|
||||
assert!(o.status.success(), "exit {:?}\nstdout: {out}\nstderr: {err}", o.status);
|
||||
out
|
||||
}
|
||||
|
||||
/// EXIF block: Orientation = `o` (SHORT) and an inline Software tag, little-endian.
|
||||
fn exif(o: u16) -> Vec<u8> {
|
||||
let mut v = b"II*\0\x08\0\0\0\x02\0".to_vec();
|
||||
v.extend_from_slice(&[0x12, 0x01, 3, 0, 1, 0, 0, 0]);
|
||||
v.extend_from_slice(&o.to_le_bytes());
|
||||
v.extend_from_slice(&[0, 0]);
|
||||
v.extend_from_slice(&[0x31, 0x01, 2, 0, 4, 0, 0, 0]);
|
||||
v.extend_from_slice(b"PC1\0");
|
||||
v.extend_from_slice(&[0, 0, 0, 0]);
|
||||
v
|
||||
}
|
||||
|
||||
/// Upright 16×32 portrait: red top-left quarter, green top-right, blue bottom half.
|
||||
fn upright_color(x: usize, y: usize) -> [u8; 3] {
|
||||
match (x < 8, y < 16) {
|
||||
(true, true) => [230, 20, 20],
|
||||
(false, true) => [20, 230, 20],
|
||||
_ => [20, 20, 230],
|
||||
}
|
||||
}
|
||||
|
||||
/// The portrait as a camera stores it for Orientation = 6: 32×16, with EXIF.
|
||||
fn phone_jpeg() -> Vec<u8> {
|
||||
let (w, h) = (16, 32);
|
||||
let mut px = vec![0u8; w * h * 3];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
// Upright (x, y) sits at stored (y, w - 1 - x) in a 32-wide image.
|
||||
let (sx, sy) = (y, w - 1 - x);
|
||||
px[(sy * h + sx) * 3..][..3].copy_from_slice(&upright_color(x, y));
|
||||
}
|
||||
}
|
||||
let stored = Image::from_u8(h as u32, w as u32, ChannelLayout::Rgb, px).unwrap();
|
||||
let jpeg =
|
||||
photocraft_codecs::encode(&stored, Format::Jpeg, &EncodeOptions { jpeg_quality: 100, jpeg_chroma_subsampling: false, ..Default::default() }).unwrap();
|
||||
let mut seg = b"Exif\0\0".to_vec();
|
||||
seg.extend_from_slice(&exif(6));
|
||||
let mut out = jpeg[..2].to_vec();
|
||||
out.extend_from_slice(&[0xFF, 0xE1]);
|
||||
out.extend_from_slice(&((seg.len() + 2) as u16).to_be_bytes());
|
||||
out.extend_from_slice(&seg);
|
||||
out.extend_from_slice(&jpeg[2..]);
|
||||
out
|
||||
}
|
||||
|
||||
/// The file as stored (no orientation applied), so its own dimensions and tag can be checked.
|
||||
fn stored(path: &Path) -> Image {
|
||||
decode_with(&std::fs::read(path).unwrap(), &DecodeOptions { keep_orientation: true, ..Default::default() }).unwrap()
|
||||
}
|
||||
|
||||
fn assert_portrait_pixels(img: &Image) {
|
||||
let img = img.convert(ChannelLayout::Rgb, SampleType::U8);
|
||||
for (x, y) in [(3, 4), (12, 4), (3, 28), (12, 28)] {
|
||||
let i = (y * 16 + x) * 3;
|
||||
let got = &img.data()[i..i + 3];
|
||||
let want = upright_color(x, y);
|
||||
assert!(got.iter().zip(want).all(|(a, b)| (i32::from(*a) - i32::from(b)).abs() <= 12), "({x},{y}): {got:?} vs {want:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orientation_6_jpeg_converts_upright_and_rotates_back() {
|
||||
let d = tmp("e2e");
|
||||
let jpg = d.join("IMG_0006.JPG");
|
||||
std::fs::write(&jpg, phone_jpeg()).unwrap();
|
||||
|
||||
// convert → PNG: the stored pixels are already upright, and any EXIF says 1.
|
||||
let png = d.join("upright.png");
|
||||
ok(bin().arg("convert").arg(&jpg).arg(&png));
|
||||
let raw = stored(&png);
|
||||
assert_eq!(raw.dimensions(), (16, 32), "stored upright, not landscape");
|
||||
assert_portrait_pixels(&raw);
|
||||
if let Some(e) = raw.meta.exif.as_deref() {
|
||||
assert_eq!(exif_orientation(e), 1, "written EXIF orientation");
|
||||
}
|
||||
assert_eq!(exif_orientation(&std::fs::read(&png).unwrap()), 1);
|
||||
assert_eq!(decode(&std::fs::read(&png).unwrap()).unwrap().dimensions(), (16, 32), "no second turn on reopen");
|
||||
|
||||
// An explicit Image › Image Rotation › 90° Clockwise makes it landscape again.
|
||||
let turned = d.join("turned.png");
|
||||
ok(bin().arg("run").arg(&jpg).args(["--cmd", "image.imageRotation.90cw", "--out"]).arg(&turned));
|
||||
let t = stored(&turned);
|
||||
assert_eq!(t.dimensions(), (32, 16));
|
||||
if let Some(e) = t.meta.exif.as_deref() {
|
||||
assert_eq!(exif_orientation(e), 1);
|
||||
}
|
||||
// The portrait's top-left (red) is now top-right; its bottom (blue) is on the left.
|
||||
let t = t.convert(ChannelLayout::Rgb, SampleType::U8);
|
||||
let at = |x: usize, y: usize| <[u8; 3]>::try_from(&t.data()[(y * 32 + x) * 3..][..3]).unwrap();
|
||||
let near = |a: [u8; 3], b: [u8; 3]| a.iter().zip(b).all(|(a, b)| (i32::from(*a) - i32::from(b)).abs() <= 12);
|
||||
assert!(near(at(28, 3), upright_color(3, 4)), "{:?}", at(28, 3));
|
||||
assert!(near(at(28, 12), upright_color(12, 4)), "{:?}", at(28, 12));
|
||||
assert!(near(at(4, 8), upright_color(8, 28)), "{:?}", at(4, 8));
|
||||
|
||||
// .pcraft save, reopen and save again: identical document and pixels.
|
||||
let (a, b) = (d.join("a.pcraft"), d.join("b.pcraft"));
|
||||
ok(bin().arg("convert").arg(&jpg).arg(&a));
|
||||
ok(bin().arg("convert").arg(&a).arg(&b));
|
||||
// Everything but the file's own path must match.
|
||||
let info = |p: &Path| {
|
||||
let mut v = serde_json::from_str::<Value>(&ok(bin().arg("info").arg(p))).unwrap();
|
||||
v.as_object_mut().unwrap().remove("file");
|
||||
v
|
||||
};
|
||||
let (ia, ib) = (info(&a), info(&b));
|
||||
assert_eq!((ia["width"].as_u64(), ia["height"].as_u64()), (Some(16), Some(32)));
|
||||
assert_eq!(ia, ib);
|
||||
let (pa, pb) = (d.join("a.png"), d.join("b.png"));
|
||||
ok(bin().arg("convert").arg(&a).arg(&pa));
|
||||
ok(bin().arg("convert").arg(&b).arg(&pb));
|
||||
let (xa, xb) = (stored(&pa), stored(&pb));
|
||||
assert_eq!(xa.dimensions(), (16, 32));
|
||||
assert_eq!(xa.to_rgba8(), xb.to_rgba8());
|
||||
assert_eq!(xa.meta.exif, xb.meta.exif);
|
||||
assert_eq!(xa.to_rgba8(), stored(&png).to_rgba8(), "the .pcraft round trip matches the direct conversion");
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
|
@ -79,9 +79,13 @@ the same.
|
|||
* **Orientation** (EXIF tag 274 in JPEG, PNG `eXIf` and WebP; the IFD0 tag in TIFF) is applied on
|
||||
decode, like Photoshop: the pixels come back upright and the EXIF/XMP orientation is rewritten
|
||||
to 1 (`DecodeOptions::keep_orientation` opts out). Encoders always write Orientation = 1
|
||||
(`upright_exif`, `upright_xmp`), so upright pixels are never rotated twice. Malformed or
|
||||
out-of-range values read as 1. `Image::oriented` turns any layout and depth in parallel bands
|
||||
(about 10 ms for 24 MP RGB8 in release).
|
||||
(`upright_exif`, `upright_xmp`, which change only the tag's value, kept in its own type), so upright pixels
|
||||
are never rotated twice. Parsing is strict: an IFD0 inside the 8-byte header or cut off before
|
||||
its next-IFD pointer, an Orientation that is not exactly one SHORT or LONG, or an out-of-range value
|
||||
reads as 1; with duplicate entries the first wins. `Limits` are checked again on the upright
|
||||
size (5–8 swap width and height), and the rotated buffer is allocated fallibly.
|
||||
`Image::oriented` turns any layout and depth in parallel bands (about 10 ms for 24 MP RGB8 in
|
||||
release).
|
||||
* **JPEG**
|
||||
* 8-bit only. Neither decoder backend supports 12-bit.
|
||||
* CMYK is always written 4:4:4 (subsampled CMYK is not portable) as Adobe-inverted CMYK with an
|
||||
|
|
|
|||
|
|
@ -75,6 +75,11 @@ pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Res
|
|||
Format::Tiff => exif_orientation(bytes),
|
||||
_ => img.meta.exif.as_deref().map_or(1, exif_orientation),
|
||||
};
|
||||
// Orientations 5–8 swap width and height: with asymmetric limits a stored landscape
|
||||
// that fit can turn into a portrait that does not, so check the upright size too.
|
||||
if (5..=8).contains(&o) {
|
||||
l.check(img.height(), img.width(), img.layout(), img.sample_type())?;
|
||||
}
|
||||
img.oriented(o)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,11 @@
|
|||
//! 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.
|
||||
//! 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;
|
||||
|
||||
|
|
@ -18,11 +22,14 @@ use crate::image::Image;
|
|||
|
||||
/// The TIFF/EXIF Orientation tag.
|
||||
const TAG_ORIENTATION: u16 = 274;
|
||||
/// TIFF field types that can hold the orientation.
|
||||
/// 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;
|
||||
/// IFD0 entries we look at, at most (real files have a few dozen).
|
||||
const MAX_ENTRIES: usize = 1024;
|
||||
/// 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;
|
||||
|
||||
/// Byte order of a TIFF structure.
|
||||
#[derive(Clone, Copy)]
|
||||
|
|
@ -54,31 +61,77 @@ fn tiff_body(b: &[u8]) -> &[u8] {
|
|||
b.strip_prefix(b"Exif\0\0").unwrap_or(b)
|
||||
}
|
||||
|
||||
/// Locates the Orientation entry in IFD0: `(byte order, value offset, field type)`.
|
||||
fn find_entry(b: &[u8]) -> Option<(Order, usize, u16)> {
|
||||
/// IFD0 of a TIFF structure: its byte order and the offsets of its entries.
|
||||
///
|
||||
/// 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,
|
||||
_ => return None,
|
||||
};
|
||||
let ifd = usize::try_from(order.u32(b, 4)?).ok()?;
|
||||
let count = usize::from(order.u16(b, ifd)?).min(MAX_ENTRIES);
|
||||
let first = ifd.checked_add(2)?;
|
||||
for i in 0..count {
|
||||
let e = first.checked_add(i.checked_mul(12)?)?;
|
||||
let tag = order.u16(b, e)?;
|
||||
if tag != TAG_ORIENTATION {
|
||||
continue;
|
||||
}
|
||||
let ty = order.u16(b, e.checked_add(2)?)?;
|
||||
let n = order.u32(b, e.checked_add(4)?)?;
|
||||
if n == 0 || !matches!(ty, TYPE_SHORT | TYPE_LONG) {
|
||||
return None;
|
||||
}
|
||||
// One SHORT or LONG always fits in the 4-byte value field.
|
||||
return Some((order, e.checked_add(8)?, ty));
|
||||
if ifd < HEADER {
|
||||
return None;
|
||||
}
|
||||
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() {
|
||||
return None;
|
||||
}
|
||||
// In bounds: every entry ends at or before `next_ifd`.
|
||||
Some((order, (0..count).map(move |i| first + i * ENTRY)))
|
||||
}
|
||||
|
||||
/// 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) -> Option<Value> {
|
||||
if order.u16(b, e)? != TAG_ORIENTATION || order.u32(b, e.checked_add(4)?)? != 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 })
|
||||
}
|
||||
|
||||
/// 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, mut entries) = ifd0_entries(b)?;
|
||||
let e = entries.find(|&e| order.u16(b, e) == Some(TAG_ORIENTATION))?;
|
||||
Some((order, orientation_value(order, b, e)?))
|
||||
}
|
||||
|
||||
/// The orientation (1–8) recorded in a TIFF-structured block: an EXIF payload
|
||||
|
|
@ -86,42 +139,36 @@ fn find_entry(b: &[u8]) -> Option<(Order, usize, u16)> {
|
|||
/// malformed or out-of-range values give 1.
|
||||
pub fn exif_orientation(exif: &[u8]) -> u16 {
|
||||
let b = tiff_body(exif);
|
||||
let value = find_entry(b).and_then(|(order, at, ty)| match ty {
|
||||
TYPE_SHORT => order.u16(b, at),
|
||||
_ => order.u32(b, at).and_then(|v| u16::try_from(v).ok()),
|
||||
});
|
||||
value.filter(|v| (1..=8).contains(v)).unwrap_or(1)
|
||||
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 tag, already 1, or unparseable).
|
||||
/// 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 prefix = exif.len() - tiff_body(exif).len();
|
||||
let Some((order, at, ty)) = find_entry(tiff_body(exif)) else {
|
||||
let body = tiff_body(exif);
|
||||
let prefix = exif.len() - body.len();
|
||||
let Some((order, entries)) = ifd0_entries(body) else {
|
||||
return Cow::Borrowed(exif);
|
||||
};
|
||||
let current = match ty {
|
||||
TYPE_SHORT => order.u16(tiff_body(exif), at).map(u32::from),
|
||||
_ => order.u32(tiff_body(exif), at),
|
||||
};
|
||||
if current == Some(1) {
|
||||
let values: Vec<Value> = entries.filter_map(|e| orientation_value(order, body, e)).collect();
|
||||
if values.iter().all(|v| v.read(order, body) == Some(1)) {
|
||||
return Cow::Borrowed(exif);
|
||||
}
|
||||
let one: Vec<u8> = match (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(),
|
||||
};
|
||||
let mut out = exif.to_vec();
|
||||
let Some(at) = prefix.checked_add(at) else { return Cow::Borrowed(exif) };
|
||||
match at.checked_add(one.len()).and_then(|end| out.get_mut(at..end)) {
|
||||
Some(slot) => {
|
||||
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)
|
||||
}
|
||||
None => Cow::Borrowed(exif),
|
||||
}
|
||||
Cow::Owned(out)
|
||||
}
|
||||
|
||||
/// `xmp` with any `tiff:Orientation` (attribute or element form) set to 1,
|
||||
|
|
@ -200,8 +247,8 @@ fn band<const N: usize>(src: &[[u8; N]], dst: &mut [[u8; N]], o: u16, (w, h): (u
|
|||
}
|
||||
|
||||
/// Orients `src` (`N` bytes per pixel) into a new buffer, a band of rows per task.
|
||||
fn orient_n<const N: usize>(src: &[u8], o: u16, (w, h): (usize, usize), ow: usize) -> Vec<u8> {
|
||||
let mut out = vec![0u8; src.len()];
|
||||
fn orient_n<const N: usize>(src: &[u8], o: u16, (w, h): (usize, usize), ow: usize) -> Result<Vec<u8>, CodecError> {
|
||||
let mut out = alloc_zeroed(src.len())?;
|
||||
let (s, _) = src.as_chunks::<N>();
|
||||
let (d, _) = out.as_chunks_mut::<N>();
|
||||
let per = (BAND * ow).max(1);
|
||||
|
|
@ -214,11 +261,19 @@ fn orient_n<const N: usize>(src: &[u8], o: u16, (w, h): (usize, usize), ow: usiz
|
|||
for (b, chunk) in d.chunks_mut(per).enumerate() {
|
||||
band(s, chunk, o, (w, h), ow, b * BAND);
|
||||
}
|
||||
out
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A zeroed buffer of `n` bytes, or an error (not an abort) when memory runs out.
|
||||
fn alloc_zeroed(n: usize) -> Result<Vec<u8>, CodecError> {
|
||||
let mut out = Vec::new();
|
||||
out.try_reserve_exact(n).map_err(|_| CodecError::LimitExceeded(format!("cannot allocate {n} bytes to orient the image")))?;
|
||||
out.resize(n, 0);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Pixel-format-agnostic orientation: any byte size per pixel.
|
||||
fn orient_bytes(src: &[u8], bpp: usize, o: u16, (w, h): (usize, usize), ow: usize) -> Vec<u8> {
|
||||
fn orient_bytes(src: &[u8], bpp: usize, o: u16, (w, h): (usize, usize), ow: usize) -> Result<Vec<u8>, CodecError> {
|
||||
match bpp {
|
||||
1 => orient_n::<1>(src, o, (w, h), ow),
|
||||
2 => orient_n::<2>(src, o, (w, h), ow),
|
||||
|
|
@ -252,7 +307,7 @@ impl Image {
|
|||
if !matches!(bpp, 1..=6 | 8 | 10 | 12 | 16 | 20) || w == 0 || h == 0 {
|
||||
return Err(CodecError::InvalidImage(format!("cannot orient {layout:?} {sample:?} {w}x{h}")));
|
||||
}
|
||||
let data = orient_bytes(self.data(), bpp, o, (w, h), ow);
|
||||
let data = orient_bytes(self.data(), bpp, o, (w, h), ow)?;
|
||||
let mut meta = self.meta.clone();
|
||||
if let Some(e) = &mut meta.exif
|
||||
&& let Cow::Owned(fixed) = upright_exif(e)
|
||||
|
|
@ -271,3 +326,15 @@ impl Image {
|
|||
Ok(Image::from_raw(ow as u32, oh as u32, layout, sample, data)?.with_icc(icc).with_meta(meta))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn an_impossible_allocation_is_an_error_not_an_abort() {
|
||||
assert!(matches!(alloc_zeroed(usize::MAX), Err(CodecError::LimitExceeded(_))));
|
||||
assert!(matches!(orient_n::<1>(&[], 6, (0, 0), 0), Ok(v) if v.is_empty()));
|
||||
assert_eq!(alloc_zeroed(3).unwrap(), vec![0, 0, 0]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -124,6 +124,8 @@ fn reads_orientation_in_every_encoding() {
|
|||
assert_eq!(exif_orientation(&fixed), 1);
|
||||
assert_eq!(fixed.len(), prefixed.len(), "rewritten in place");
|
||||
assert_eq!(&fixed[..6], b"Exif\0\0");
|
||||
// Rewritten in the entry's own type and width: exactly `exif(1, ..)`.
|
||||
assert_eq!(&fixed[6..], &exif(1, big, long)[..], "o={o} big={big} long={long}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -139,10 +141,11 @@ fn malformed_or_out_of_range_orientation_reads_as_1() {
|
|||
assert_eq!(exif_orientation(&exif(bad, false, true)), 1, "{bad}");
|
||||
}
|
||||
let good = exif(6, false, false);
|
||||
assert_eq!(exif_orientation(&good), 6);
|
||||
for n in 0..good.len() {
|
||||
let o = exif_orientation(&good[..n]);
|
||||
assert!(o == 1 || o == 6, "truncated at {n}: {o}");
|
||||
let _ = upright_exif(&good[..n]);
|
||||
// Any cut, even one that keeps the Orientation entry but loses the next-IFD pointer.
|
||||
assert_eq!(exif_orientation(&good[..n]), 1, "truncated at {n}");
|
||||
assert!(matches!(upright_exif(&good[..n]), std::borrow::Cow::Borrowed(_)), "truncated at {n}");
|
||||
}
|
||||
// IFD offset past the end, a huge entry count, a wrong field type.
|
||||
let mut far = good.clone();
|
||||
|
|
@ -150,7 +153,7 @@ fn malformed_or_out_of_range_orientation_reads_as_1() {
|
|||
assert_eq!(exif_orientation(&far), 1);
|
||||
let mut many = good.clone();
|
||||
many[8..10].copy_from_slice(&u16::MAX.to_le_bytes());
|
||||
assert_eq!(exif_orientation(&many), 6, "entries past the end are ignored, the real one still found");
|
||||
assert_eq!(exif_orientation(&many), 1, "an entry count past the end is a truncated table");
|
||||
let mut typed = good.clone();
|
||||
typed[24..26].copy_from_slice(&2u16.to_le_bytes()); // ASCII
|
||||
assert_eq!(exif_orientation(&typed), 1);
|
||||
|
|
@ -387,3 +390,167 @@ fn rotating_24_mp_is_fast() {
|
|||
assert!(best <= 50.0, "orientation {o} took {best:.1} ms");
|
||||
}
|
||||
}
|
||||
|
||||
/// A TIFF-structured EXIF block: IFD0 at offset 8 holding `entries`
|
||||
/// (`tag, type, count, 4-byte value field`), a zero next-IFD pointer, then `tail`.
|
||||
fn ifd(big: bool, entries: &[(u16, u16, u32, [u8; 4])], tail: &[u8]) -> Vec<u8> {
|
||||
let u16b = |v: u16| if big { v.to_be_bytes() } else { v.to_le_bytes() };
|
||||
let u32b = |v: u32| if big { v.to_be_bytes() } else { v.to_le_bytes() };
|
||||
let mut v = if big { b"MM\0*".to_vec() } else { b"II*\0".to_vec() };
|
||||
v.extend_from_slice(&u32b(8));
|
||||
v.extend_from_slice(&u16b(entries.len() as u16));
|
||||
for &(tag, ty, count, value) in entries {
|
||||
v.extend_from_slice(&u16b(tag));
|
||||
v.extend_from_slice(&u16b(ty));
|
||||
v.extend_from_slice(&u32b(count));
|
||||
v.extend_from_slice(&value);
|
||||
}
|
||||
v.extend_from_slice(&u32b(0));
|
||||
v.extend_from_slice(tail);
|
||||
v
|
||||
}
|
||||
|
||||
/// The 4-byte value field of a SHORT.
|
||||
fn short(v: u16, big: bool) -> [u8; 4] {
|
||||
let b = if big { v.to_be_bytes() } else { v.to_le_bytes() };
|
||||
[b[0], b[1], 0, 0]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_orientation_entries_first_wins() {
|
||||
for big in [false, true] {
|
||||
let o = |v| (0x0112, 3, 1, short(v, big));
|
||||
assert_eq!(exif_orientation(&ifd(big, &[o(6), o(3)], &[])), 6);
|
||||
assert_eq!(exif_orientation(&ifd(big, &[o(3), o(6)], &[])), 3);
|
||||
assert_eq!(exif_orientation(&ifd(big, &[o(1), o(8)], &[])), 1);
|
||||
// A malformed first entry is ignored as a whole: a later good one does not count.
|
||||
let bad_first = ifd(big, &[(0x0112, 3, 2, short(6, big)), o(8)], &[]);
|
||||
assert_eq!(exif_orientation(&bad_first), 1);
|
||||
let long = |v: u32| (0x0112, 4, 1, if big { v.to_be_bytes() } else { v.to_le_bytes() });
|
||||
let long_first = ifd(big, &[long(6), o(8)], &[]);
|
||||
assert_eq!(exif_orientation(&long_first), 6, "a LONG first entry counts");
|
||||
assert_eq!(&upright_exif(&long_first)[..], &ifd(big, &[long(1), o(1)], &[])[..], "each in its own width");
|
||||
let rational_first = ifd(big, &[(0x0112, 5, 1, short(6, big)), o(8)], &[]);
|
||||
assert_eq!(exif_orientation(&rational_first), 1);
|
||||
// Upright: every well-formed duplicate becomes 1, so no reader rotates twice.
|
||||
let dup = ifd(big, &[o(6), o(3)], &[]);
|
||||
let fixed = upright_exif(&dup);
|
||||
assert_eq!(fixed.len(), dup.len());
|
||||
assert_eq!(&fixed[..], &ifd(big, &[o(1), o(1)], &[])[..]);
|
||||
// Only a later duplicate disagrees: it is rewritten too.
|
||||
assert_eq!(&upright_exif(&ifd(big, &[o(1), o(8)], &[]))[..], &ifd(big, &[o(1), o(1)], &[])[..]);
|
||||
assert!(matches!(upright_exif(&ifd(big, &[o(1), o(1)], &[])), std::borrow::Cow::Borrowed(_)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_ifd_parsing_ignores_the_tag() {
|
||||
for big in [false, true] {
|
||||
let o6 = (0x0112, 3, 1, short(6, big));
|
||||
let good = ifd(big, &[o6], &[]);
|
||||
assert_eq!(exif_orientation(&good), 6);
|
||||
// Truncated before (or inside) the next-IFD pointer.
|
||||
for cut in 1..=4 {
|
||||
assert_eq!(exif_orientation(&good[..good.len() - cut]), 1, "cut {cut}");
|
||||
}
|
||||
// IFD offsets inside the 8-byte header.
|
||||
for off in 0..8u32 {
|
||||
let mut b = good.clone();
|
||||
b[4..8].copy_from_slice(&if big { off.to_be_bytes() } else { off.to_le_bytes() });
|
||||
assert_eq!(exif_orientation(&b), 1, "offset {off}");
|
||||
assert!(matches!(upright_exif(&b), std::borrow::Cow::Borrowed(_)));
|
||||
}
|
||||
// Count must be exactly 1, type must be SHORT or LONG.
|
||||
let long6 = if big { 6u32.to_be_bytes() } else { 6u32.to_le_bytes() };
|
||||
for count in [0, 2, 3, u32::MAX] {
|
||||
assert_eq!(exif_orientation(&ifd(big, &[(0x0112, 3, count, short(6, big))], &[])), 1, "count {count}");
|
||||
assert_eq!(exif_orientation(&ifd(big, &[(0x0112, 4, count, long6)], &[])), 1, "LONG count {count}");
|
||||
}
|
||||
for ty in [1, 2, 5, 6, 7, 8, 9, 0, 0xFFFF] {
|
||||
let b = ifd(big, &[(0x0112, ty, 1, short(6, big))], &[]);
|
||||
assert_eq!(exif_orientation(&b), 1, "type {ty}");
|
||||
assert!(matches!(upright_exif(&b), std::borrow::Cow::Borrowed(_)), "type {ty}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upright_exif_keeps_every_other_byte() {
|
||||
for big in [false, true] {
|
||||
let u32b = |v: u32| if big { v.to_be_bytes() } else { v.to_le_bytes() };
|
||||
// IFD0: Make (12-byte ASCII stored after the IFD), Orientation 6, Software (inline ASCII),
|
||||
// ExifIFD pointer to a sub-IFD with its own entries.
|
||||
let ifd_end = 8 + 2 + 4 * 12 + 4;
|
||||
let make_at = ifd_end as u32;
|
||||
let sub_at = make_at + 12;
|
||||
let mut tail = b"PhotoCraftCo".to_vec();
|
||||
// Sub-IFD: one DateTimeOriginal-ish SHORT entry, then next-IFD 0.
|
||||
tail.extend_from_slice(&if big { 1u16.to_be_bytes() } else { 1u16.to_le_bytes() });
|
||||
tail.extend_from_slice(&if big { 0x9000u16.to_be_bytes() } else { 0x9000u16.to_le_bytes() });
|
||||
tail.extend_from_slice(&if big { 7u16.to_be_bytes() } else { 7u16.to_le_bytes() });
|
||||
tail.extend_from_slice(&u32b(4));
|
||||
tail.extend_from_slice(b"0232");
|
||||
tail.extend_from_slice(&u32b(0));
|
||||
let e = ifd(big, &[(0x010F, 2, 12, u32b(make_at)), (0x0112, 3, 1, short(6, big)), (0x0131, 2, 4, *b"PC1\0"), (0x8769, 4, 1, u32b(sub_at))], &tail);
|
||||
let mut prefixed = b"Exif\0\0".to_vec();
|
||||
prefixed.extend_from_slice(&e);
|
||||
let fixed = upright_exif(&prefixed).into_owned();
|
||||
assert_eq!(exif_orientation(&fixed), 1);
|
||||
assert_eq!(fixed.len(), prefixed.len());
|
||||
// Only the Orientation value (2 bytes in the second entry) differs.
|
||||
let value = 6 + 8 + 2 + 12 + 8;
|
||||
let changed: Vec<usize> = (0..fixed.len()).filter(|&i| fixed[i] != prefixed[i]).collect();
|
||||
assert_eq!(changed, vec![if big { value + 1 } else { value }], "big={big}");
|
||||
assert_eq!(&fixed[..value], &prefixed[..value]);
|
||||
assert_eq!(&fixed[value + 2..], &prefixed[value + 2..]);
|
||||
// The offset-based Make string and the inline Software tag are intact.
|
||||
assert_eq!(&fixed[6 + make_at as usize..][..12], b"PhotoCraftCo");
|
||||
assert!(fixed.windows(4).any(|w| w == b"PC1\0"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn limits_apply_to_the_upright_size() {
|
||||
// Stored 32×16 landscape; Orientation 6 makes it a 16×32 portrait.
|
||||
let jpeg = encode(&gray(32, 16, vec![90; 32 * 16]), Format::Jpeg, &EncodeOptions::default()).unwrap();
|
||||
let tall = jpeg_with_exif(&jpeg, &exif(6, false, false));
|
||||
let limits = Limits { max_width: 32, max_height: 16, ..Limits::default() };
|
||||
let opts = DecodeOptions { limits, ..Default::default() };
|
||||
assert!(matches!(decode_with(&tall, &opts), Err(CodecError::LimitExceeded(_))), "the upright portrait is too tall");
|
||||
// As stored it fits, and a 180° turn keeps the size.
|
||||
let kept = decode_with(&tall, &DecodeOptions { keep_orientation: true, ..opts.clone() }).unwrap();
|
||||
assert_eq!(kept.dimensions(), (32, 16));
|
||||
let flipped = decode_with(&jpeg_with_exif(&jpeg, &exif(3, false, false)), &opts).unwrap();
|
||||
assert_eq!(flipped.dimensions(), (32, 16));
|
||||
// The same holds for a TIFF, whose orientation lives in its own IFD0.
|
||||
let tiff = tiff_with_orientation(&[7; 32 * 16], 32, 16, 8);
|
||||
assert!(matches!(decode_with(&tiff, &opts), Err(CodecError::LimitExceeded(_))));
|
||||
assert_eq!(decode_with(&tiff, &DecodeOptions::default()).unwrap().dimensions(), (16, 32));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_orientation_reads_and_rewrites_in_both_byte_orders() {
|
||||
for big in [false, true] {
|
||||
let u32b = |v: u32| if big { v.to_be_bytes() } else { v.to_le_bytes() };
|
||||
let long = |v: u32| (0x0112u16, 4u16, 1u32, u32b(v));
|
||||
for o in 1..=8u32 {
|
||||
let e = ifd(big, &[(0x0131, 2, 4, *b"PC1\0"), long(o)], b"tail");
|
||||
assert_eq!(exif_orientation(&e), o as u16, "o={o} big={big}");
|
||||
let fixed = upright_exif(&e);
|
||||
assert_eq!(exif_orientation(&fixed), 1);
|
||||
// Still a LONG, all 4 value bytes set to 1 in the file's byte order; nothing else moved.
|
||||
assert_eq!(&fixed[..], &ifd(big, &[(0x0131, 2, 4, *b"PC1\0"), long(1)], b"tail")[..], "o={o} big={big}");
|
||||
}
|
||||
// Out of range, including values that only fit in a LONG, reads as 1 and is left alone.
|
||||
for bad in [0, 9, 0x1_0006, u32::MAX] {
|
||||
let e = ifd(big, &[long(bad)], &[]);
|
||||
assert_eq!(exif_orientation(&e), 1, "{bad}");
|
||||
}
|
||||
// A LONG 6 in a JPEG opens upright.
|
||||
let jpeg = encode(&gray(32, 16, vec![90; 32 * 16]), Format::Jpeg, &EncodeOptions::default()).unwrap();
|
||||
let img = decode(&jpeg_with_exif(&jpeg, &ifd(big, &[long(6)], &[]))).unwrap();
|
||||
assert_eq!(img.dimensions(), (16, 32));
|
||||
let kept = img.meta.exif.as_deref().unwrap();
|
||||
assert_eq!(kept.strip_prefix(b"Exif\0\0").unwrap_or(kept), &ifd(big, &[long(1)], &[])[..]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue