diff --git a/Cargo.lock b/Cargo.lock index fe13521a..d844d9f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -140,6 +140,10 @@ dependencies = [ "serde", ] +[[package]] +name = "lightcraft-meta" +version = "0.1.0" + [[package]] name = "lightcraft-raster" version = "0.1.0" @@ -151,6 +155,19 @@ dependencies = [ "serde", ] +[[package]] +name = "lightcraft-raw" +version = "0.1.0" + +[[package]] +name = "lightcraft-tiff" +version = "0.1.0" +dependencies = [ + "proptest", + "serde", + "thiserror", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -409,6 +426,26 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "unarray" version = "0.1.4" diff --git a/crates/meta/Cargo.toml b/crates/meta/Cargo.toml new file mode 100644 index 00000000..8161325b --- /dev/null +++ b/crates/meta/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "lightcraft-meta" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "LightCraft metadata: EXIF, XMP, IPTC (L1)" + +[lints] +workspace = true + +[dependencies] diff --git a/crates/meta/src/lib.rs b/crates/meta/src/lib.rs new file mode 100644 index 00000000..45278f22 --- /dev/null +++ b/crates/meta/src/lib.rs @@ -0,0 +1 @@ +#![forbid(unsafe_code)] diff --git a/crates/raw/Cargo.toml b/crates/raw/Cargo.toml new file mode 100644 index 00000000..8cafc30f --- /dev/null +++ b/crates/raw/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "lightcraft-raw" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "LightCraft RAW decoding, demosaicing and DNG colour model (L1)" + +[lints] +workspace = true + +[dependencies] diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs new file mode 100644 index 00000000..45278f22 --- /dev/null +++ b/crates/raw/src/lib.rs @@ -0,0 +1 @@ +#![forbid(unsafe_code)] diff --git a/crates/tiff/Cargo.toml b/crates/tiff/Cargo.toml new file mode 100644 index 00000000..74919c69 --- /dev/null +++ b/crates/tiff/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "lightcraft-tiff" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "LightCraft TIFF/IFD reader and writer (L0)" + +[lints] +workspace = true + +[dependencies] +serde = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +proptest = { workspace = true } diff --git a/crates/tiff/README.md b/crates/tiff/README.md new file mode 100644 index 00000000..c691dd64 --- /dev/null +++ b/crates/tiff/README.md @@ -0,0 +1,36 @@ +# lightcraft-tiff (L0) + +TIFF / IFD reader and writer. Used by `lightcraft-raw` (DNG, CR2, NEF, ARW…), `lightcraft-meta` (Exif) and +later the TIFF/DNG exporters. + +## Features + +- Classic TIFF and BigTIFF, little- and big-endian (`II`/`MM`). Also accepts the TIFF-like magic numbers of + some raw formats (ORF `IIRO`/`IIRS`, RW2 `IIU`) when `ParseOptions::accept_raw_magic` is set (default). +- Every field type: BYTE, ASCII, SHORT, LONG, RATIONAL, SBYTE, UNDEFINED, SSHORT, SLONG, SRATIONAL, FLOAT, + DOUBLE, IFD, LONG8, SLONG8, IFD8. +- IFD chain + `SubIFDs` (330) + Exif (34665) / GPS (34853) / Interoperability (40965) pointer IFDs, resolved + into a tree (`Ifd::sub_ifds`, `exif`, `gps`, `interop`). `Entry::offset` records the absolute position of + each value (needed for maker notes and embedded blobs). +- `parse_ifd_at(data, offset, order, base, bigtiff, opts)` parses an IFD whose offsets are relative to an + arbitrary base; `makernote::parse_makernote` knows the header / byte-order / offset-base conventions of + Canon, Nikon (types 1 and 3), Sony, Fujifilm, Olympus (old and new), Panasonic and Pentax notes. +- `image::ImageInfo` — width/height, bits, samples, compression, photometric, planar config, predictor, + sample format and the strip or tile chunk list (`chunks()`, `chunk_bytes()`); missing byte counts are + inferred. +- `TiffWriter` writes IFD chains with strips or tiles, `SubIFDs` and pointer IFDs, in either byte order, + classic or BigTIFF; `writer::rational` / `srational` approximate floats as rationals. + +## Robustness + +The reader never panics on malformed input (proptests feed random and mutated files): offsets are +bounds-checked; out-of-range or unknown-type entries are skipped; IFD loops are detected; IFD count, +nesting depth and entries per IFD are limited; the total bytes of decoded values are capped at +`8 × input + 1 MiB` so a file cannot make us allocate far more than its own size. + +## Sources + +Public specifications only: TIFF 6.0 (Adobe, 1992), the TIFF 6.0 supplements (IFD type, SubIFDs), the +BigTIFF format description (AWare Systems), Adobe DNG Specification 1.7 (tag numbers), CIPA DC-008 Exif 2.32, +and the ExifTool tag-name documentation (maker-note header conventions, prose only). No third-party code +was consulted. diff --git a/crates/tiff/src/image.rs b/crates/tiff/src/image.rs new file mode 100644 index 00000000..2188ab50 --- /dev/null +++ b/crates/tiff/src/image.rs @@ -0,0 +1,164 @@ +//! Image layout of an IFD: dimensions, sample format and the strip / tile chunks holding the pixels. + +use crate::{Ifd, Result, TiffError, tags}; +use serde::{Deserialize, Serialize}; + +/// How pixel data is split into chunks. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum Layout { + Strips { rows_per_strip: u32 }, + Tiles { tile_width: u32, tile_height: u32 }, +} + +/// Pixel layout description of an IFD (TIFF 6.0 §3–§8, §15). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ImageInfo { + pub width: u32, + pub height: u32, + pub bits_per_sample: Vec, + pub samples_per_pixel: u16, + pub compression: u16, + pub photometric: u16, + /// 1 = chunky (interleaved), 2 = planar. + pub planar: u16, + pub predictor: u16, + /// 1 = unsigned int, 2 = signed int, 3 = IEEE float. + pub sample_format: u16, + pub new_subfile_type: u32, + pub layout: Layout, + pub offsets: Vec, + pub byte_counts: Vec, +} + +/// One strip or tile. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Chunk { + pub index: usize, + /// Top-left pixel of the chunk. + pub x: u32, + pub y: u32, + /// Nominal chunk size (tiles may extend past the image; strips are clipped to the image height). + pub width: u32, + pub height: u32, + /// Plane index for planar configuration 2 (else 0). + pub plane: u16, + pub offset: u64, + pub len: u64, +} + +impl ImageInfo { + pub fn from_ifd(ifd: &Ifd) -> Result { + let width = ifd.u32(tags::IMAGE_WIDTH).ok_or(TiffError::MissingTag(tags::IMAGE_WIDTH))?; + let height = ifd.u32(tags::IMAGE_LENGTH).ok_or(TiffError::MissingTag(tags::IMAGE_LENGTH))?; + if width == 0 || height == 0 { + return Err(TiffError::Invalid("zero image dimension".into())); + } + let samples_per_pixel = ifd.u16(tags::SAMPLES_PER_PIXEL).unwrap_or(1).max(1); + let bits_per_sample: Vec = + ifd.u64s(tags::BITS_PER_SAMPLE).map(|v| v.into_iter().map(|b| b.min(u16::MAX as u64) as u16).collect()).unwrap_or_else(|| vec![1]); + let bits_per_sample = if bits_per_sample.is_empty() { vec![1] } else { bits_per_sample }; + let planar = ifd.u16(tags::PLANAR_CONFIGURATION).unwrap_or(1); + let (layout, offsets, byte_counts) = if let Some(tw) = ifd.u32(tags::TILE_WIDTH) { + let th = ifd.u32(tags::TILE_LENGTH).ok_or(TiffError::MissingTag(tags::TILE_LENGTH))?; + if tw == 0 || th == 0 { + return Err(TiffError::Invalid("zero tile size".into())); + } + let offs = ifd.u64s(tags::TILE_OFFSETS).or_else(|| ifd.u64s(tags::STRIP_OFFSETS)).ok_or(TiffError::MissingTag(tags::TILE_OFFSETS))?; + let cnts = ifd + .u64s(tags::TILE_BYTE_COUNTS) + .or_else(|| ifd.u64s(tags::STRIP_BYTE_COUNTS)) + .ok_or(TiffError::MissingTag(tags::TILE_BYTE_COUNTS))?; + (Layout::Tiles { tile_width: tw, tile_height: th }, offs, cnts) + } else { + let offs = ifd.u64s(tags::STRIP_OFFSETS).ok_or(TiffError::MissingTag(tags::STRIP_OFFSETS))?; + let cnts = ifd.u64s(tags::STRIP_BYTE_COUNTS).unwrap_or_default(); + let rps = ifd.u32(tags::ROWS_PER_STRIP).unwrap_or(height).clamp(1, height); + (Layout::Strips { rows_per_strip: rps }, offs, cnts) + }; + Ok(ImageInfo { + width, + height, + bits_per_sample, + samples_per_pixel, + compression: ifd.u16(tags::COMPRESSION).unwrap_or(1), + photometric: ifd.u16(tags::PHOTOMETRIC).unwrap_or(1), + planar, + predictor: ifd.u16(tags::PREDICTOR).unwrap_or(1), + sample_format: ifd.u16(tags::SAMPLE_FORMAT).unwrap_or(1), + new_subfile_type: ifd.u32(tags::NEW_SUBFILE_TYPE).unwrap_or(0), + layout, + offsets, + byte_counts, + }) + } + + /// Bits of the first sample. + pub fn bits(&self) -> u16 { + self.bits_per_sample[0] + } + + /// Whether this IFD is a reduced-resolution image (NewSubfileType bit 0). + pub fn is_reduced(&self) -> bool { + self.new_subfile_type & 1 != 0 + } + + /// Number of chunks across and down per plane. + pub fn grid(&self) -> (u32, u32) { + match self.layout { + Layout::Strips { rows_per_strip } => (1, self.height.div_ceil(rows_per_strip)), + Layout::Tiles { tile_width, tile_height } => (self.width.div_ceil(tile_width), self.height.div_ceil(tile_height)), + } + } + + /// Nominal chunk dimensions. + pub fn chunk_size(&self) -> (u32, u32) { + match self.layout { + Layout::Strips { rows_per_strip } => (self.width, rows_per_strip), + Layout::Tiles { tile_width, tile_height } => (tile_width, tile_height), + } + } + + /// All chunks that have an offset. Missing byte counts are inferred as "to the next offset / end of data" + /// using `data_len`. + pub fn chunks(&self, data_len: u64) -> Vec { + let (across, down) = self.grid(); + let per_plane = across as usize * down as usize; + let planes = if self.planar == 2 { self.samples_per_pixel as usize } else { 1 }; + let (cw, ch) = self.chunk_size(); + let n = self.offsets.len().min(per_plane.saturating_mul(planes)); + let mut out = Vec::with_capacity(n); + for i in 0..n { + let plane = i / per_plane.max(1); + let j = i % per_plane.max(1); + let (cx, cy) = (j as u32 % across, j as u32 / across); + let x = cx * cw; + let y = cy * ch; + let height = match self.layout { + Layout::Strips { .. } => ch.min(self.height - y), + Layout::Tiles { .. } => ch, + }; + let offset = self.offsets[i]; + let len = match self.byte_counts.get(i) { + Some(&l) if l > 0 => l, + _ => { + // infer: up to the next larger offset, else end of data + let next = self.offsets.iter().copied().filter(|&o| o > offset).min().unwrap_or(data_len); + next.saturating_sub(offset) + } + }; + out.push(Chunk { index: i, x, y, width: cw, height, plane: plane as u16, offset, len }); + } + out + } +} + +/// The bytes of a chunk, clipped to the available data (truncated files yield a shorter slice). +/// Returns `None` only when the chunk starts past the end of `data`. +pub fn chunk_bytes<'a>(data: &'a [u8], c: &Chunk) -> Option<&'a [u8]> { + let start = usize::try_from(c.offset).ok()?; + if start >= data.len() { + return None; + } + let end = (c.offset.saturating_add(c.len)).min(data.len() as u64) as usize; + Some(&data[start..end]) +} diff --git a/crates/tiff/src/lib.rs b/crates/tiff/src/lib.rs new file mode 100644 index 00000000..f7637fb7 --- /dev/null +++ b/crates/tiff/src/lib.rs @@ -0,0 +1,466 @@ +//! TIFF / IFD reading and writing for LightCraft. +//! +//! - [`Tiff::parse`] reads classic TIFF and BigTIFF in both byte orders, following the IFD chain, +//! `SubIFDs`, and the Exif / GPS / Interoperability IFD pointers. All field types of TIFF 6.0, +//! the TIFF 6.0 supplement (`IFD`) and BigTIFF (`LONG8`, `SLONG8`, `IFD8`) are decoded. +//! - [`parse_ifd_at`] parses a single IFD with an arbitrary offset base — the building block for +//! maker-note IFDs ([`makernote`]). +//! - [`image::ImageInfo`] describes the pixel layout of an IFD (strips or tiles, planar config, +//! compression, predictor) and enumerates its [`image::Chunk`]s. +//! - [`writer::TiffWriter`] writes IFD trees (with strips/tiles, `SubIFDs` and pointer IFDs) in +//! either byte order, classic or BigTIFF. +//! +//! The reader never panics on malformed input: every offset is bounds-checked, IFD loops are +//! detected, nesting depth / IFD count are limited and the total amount of decoded value bytes is +//! capped relative to the input size (so a hostile file cannot make us allocate unboundedly). +//! +//! Sources: TIFF 6.0 specification (Adobe, 1992), TIFF Technical Note / supplements, the BigTIFF +//! design notes (AWare Systems), Adobe DNG specification 1.7 (tag numbers), CIPA DC-008 (Exif 2.32). +#![forbid(unsafe_code)] + +pub mod image; +pub mod makernote; +mod reader; +pub mod tags; +pub mod writer; + +pub use reader::{ParseOptions, parse_ifd_at}; +pub use writer::{IfdBuilder, ImageData, TiffWriter}; + +use serde::{Deserialize, Serialize}; + +/// Errors from TIFF parsing / writing. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum TiffError { + #[error("not a TIFF stream (bad header)")] + NotTiff, + #[error("data truncated at offset {0}")] + Truncated(u64), + #[error("invalid TIFF structure: {0}")] + Invalid(String), + #[error("resource limit exceeded: {0}")] + Limit(&'static str), + #[error("missing required tag {0}")] + MissingTag(u16), +} + +pub type Result = std::result::Result; + +/// Byte order of a TIFF stream. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] +pub enum ByteOrder { + /// `II` — Intel, little-endian. + #[default] + Little, + /// `MM` — Motorola, big-endian. + Big, +} + +impl ByteOrder { + #[inline] + pub fn u16(self, b: [u8; 2]) -> u16 { + match self { + ByteOrder::Little => u16::from_le_bytes(b), + ByteOrder::Big => u16::from_be_bytes(b), + } + } + #[inline] + pub fn u32(self, b: [u8; 4]) -> u32 { + match self { + ByteOrder::Little => u32::from_le_bytes(b), + ByteOrder::Big => u32::from_be_bytes(b), + } + } + #[inline] + pub fn u64(self, b: [u8; 8]) -> u64 { + match self { + ByteOrder::Little => u64::from_le_bytes(b), + ByteOrder::Big => u64::from_be_bytes(b), + } + } + /// Read a `u16` at `off`, or `None` if out of bounds. + #[inline] + pub fn read_u16(self, data: &[u8], off: usize) -> Option { + data.get(off..off.checked_add(2)?).map(|s| self.u16([s[0], s[1]])) + } + #[inline] + pub fn read_u32(self, data: &[u8], off: usize) -> Option { + data.get(off..off.checked_add(4)?).map(|s| self.u32([s[0], s[1], s[2], s[3]])) + } + #[inline] + pub fn read_u64(self, data: &[u8], off: usize) -> Option { + data.get(off..off.checked_add(8)?).map(|s| self.u64([s[0], s[1], s[2], s[3], s[4], s[5], s[6], s[7]])) + } + pub fn put_u16(self, out: &mut Vec, v: u16) { + out.extend_from_slice(&match self { + ByteOrder::Little => v.to_le_bytes(), + ByteOrder::Big => v.to_be_bytes(), + }); + } + pub fn put_u32(self, out: &mut Vec, v: u32) { + out.extend_from_slice(&match self { + ByteOrder::Little => v.to_le_bytes(), + ByteOrder::Big => v.to_be_bytes(), + }); + } + pub fn put_u64(self, out: &mut Vec, v: u64) { + out.extend_from_slice(&match self { + ByteOrder::Little => v.to_le_bytes(), + ByteOrder::Big => v.to_be_bytes(), + }); + } +} + +/// TIFF field types (TIFF 6.0 §2 + supplements + BigTIFF). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[repr(u16)] +pub enum FieldType { + Byte = 1, + Ascii = 2, + Short = 3, + Long = 4, + Rational = 5, + SByte = 6, + Undefined = 7, + SShort = 8, + SLong = 9, + SRational = 10, + Float = 11, + Double = 12, + Ifd = 13, + Long8 = 16, + SLong8 = 17, + Ifd8 = 18, +} + +impl FieldType { + pub fn from_u16(v: u16) -> Option { + use FieldType::*; + Some(match v { + 1 => Byte, + 2 => Ascii, + 3 => Short, + 4 => Long, + 5 => Rational, + 6 => SByte, + 7 => Undefined, + 8 => SShort, + 9 => SLong, + 10 => SRational, + 11 => Float, + 12 => Double, + 13 => Ifd, + 16 => Long8, + 17 => SLong8, + 18 => Ifd8, + _ => return None, + }) + } + /// Size in bytes of one value. + pub fn size(self) -> usize { + use FieldType::*; + match self { + Byte | Ascii | SByte | Undefined => 1, + Short | SShort => 2, + Long | SLong | Float | Ifd => 4, + Rational | SRational | Double | Long8 | SLong8 | Ifd8 => 8, + } + } +} + +/// A decoded field value. Arrays are stored in their native type. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub enum Value { + Byte(Vec), + /// ASCII text (bytes up to the first NUL, decoded lossily as UTF-8). + Ascii(String), + Short(Vec), + Long(Vec), + Rational(Vec<(u32, u32)>), + SByte(Vec), + Undefined(Vec), + SShort(Vec), + SLong(Vec), + SRational(Vec<(i32, i32)>), + Float(Vec), + Double(Vec), + Ifd(Vec), + Long8(Vec), + SLong8(Vec), + Ifd8(Vec), +} + +impl Value { + pub fn field_type(&self) -> FieldType { + match self { + Value::Byte(_) => FieldType::Byte, + Value::Ascii(_) => FieldType::Ascii, + Value::Short(_) => FieldType::Short, + Value::Long(_) => FieldType::Long, + Value::Rational(_) => FieldType::Rational, + Value::SByte(_) => FieldType::SByte, + Value::Undefined(_) => FieldType::Undefined, + Value::SShort(_) => FieldType::SShort, + Value::SLong(_) => FieldType::SLong, + Value::SRational(_) => FieldType::SRational, + Value::Float(_) => FieldType::Float, + Value::Double(_) => FieldType::Double, + Value::Ifd(_) => FieldType::Ifd, + Value::Long8(_) => FieldType::Long8, + Value::SLong8(_) => FieldType::SLong8, + Value::Ifd8(_) => FieldType::Ifd8, + } + } + + /// Number of values (for ASCII: bytes including the terminating NUL). + pub fn count(&self) -> usize { + match self { + Value::Byte(v) | Value::Undefined(v) => v.len(), + Value::Ascii(s) => s.len() + 1, + Value::Short(v) => v.len(), + Value::Long(v) | Value::Ifd(v) => v.len(), + Value::Rational(v) => v.len(), + Value::SByte(v) => v.len(), + Value::SShort(v) => v.len(), + Value::SLong(v) => v.len(), + Value::SRational(v) => v.len(), + Value::Float(v) => v.len(), + Value::Double(v) => v.len(), + Value::Long8(v) | Value::Ifd8(v) => v.len(), + Value::SLong8(v) => v.len(), + } + } + + /// Value `i` as `f64` (rationals divided; zero denominators give `None`). ASCII has no numbers. + pub fn get_f64(&self, i: usize) -> Option { + Some(match self { + Value::Byte(v) | Value::Undefined(v) => *v.get(i)? as f64, + Value::Ascii(_) => return None, + Value::Short(v) => *v.get(i)? as f64, + Value::Long(v) | Value::Ifd(v) => *v.get(i)? as f64, + Value::Rational(v) => { + let (n, d) = *v.get(i)?; + if d == 0 { + return None; + } + n as f64 / d as f64 + } + Value::SByte(v) => *v.get(i)? as f64, + Value::SShort(v) => *v.get(i)? as f64, + Value::SLong(v) => *v.get(i)? as f64, + Value::SRational(v) => { + let (n, d) = *v.get(i)?; + if d == 0 { + return None; + } + n as f64 / d as f64 + } + Value::Float(v) => *v.get(i)? as f64, + Value::Double(v) => *v.get(i)?, + Value::Long8(v) | Value::Ifd8(v) => *v.get(i)? as f64, + Value::SLong8(v) => *v.get(i)? as f64, + }) + } + + /// Value `i` as an unsigned integer (integer types only; negative values give `None`). + pub fn get_u64(&self, i: usize) -> Option { + match self { + Value::Byte(v) | Value::Undefined(v) => v.get(i).map(|&x| x as u64), + Value::Short(v) => v.get(i).map(|&x| x as u64), + Value::Long(v) | Value::Ifd(v) => v.get(i).map(|&x| x as u64), + Value::Long8(v) | Value::Ifd8(v) => v.get(i).copied(), + Value::SByte(v) => v.get(i).and_then(|&x| u64::try_from(x).ok()), + Value::SShort(v) => v.get(i).and_then(|&x| u64::try_from(x).ok()), + Value::SLong(v) => v.get(i).and_then(|&x| u64::try_from(x).ok()), + Value::SLong8(v) => v.get(i).and_then(|&x| u64::try_from(x).ok()), + _ => None, + } + } + + /// Value `i` as a signed integer (integer types only). + pub fn get_i64(&self, i: usize) -> Option { + match self { + Value::SByte(v) => v.get(i).map(|&x| x as i64), + Value::SShort(v) => v.get(i).map(|&x| x as i64), + Value::SLong(v) => v.get(i).map(|&x| x as i64), + Value::SLong8(v) => v.get(i).copied(), + _ => self.get_u64(i).and_then(|x| i64::try_from(x).ok()), + } + } + + /// All values as `f64` (missing/invalid entries skipped for rationals with zero denominators → 0). + pub fn to_f64_vec(&self) -> Vec { + (0..self.len_values()).map(|i| self.get_f64(i).unwrap_or(0.0)).collect() + } + + /// All values as `u64` (integer types only; otherwise empty). + pub fn to_u64_vec(&self) -> Vec { + (0..self.len_values()).map_while(|i| self.get_u64(i)).collect() + } + + fn len_values(&self) -> usize { + match self { + Value::Ascii(_) => 0, + _ => self.count(), + } + } + + /// Text for ASCII values (trimmed of trailing NULs / spaces); `Byte`/`Undefined` are decoded as UTF-8 (lossy) + /// up to the first NUL, which covers XMP packets and UCS-less Exif strings. + pub fn as_str(&self) -> Option { + match self { + Value::Ascii(s) => Some(s.trim_end_matches([' ', '\0']).to_string()), + Value::Byte(b) | Value::Undefined(b) => { + let end = b.iter().position(|&c| c == 0).unwrap_or(b.len()); + Some(String::from_utf8_lossy(&b[..end]).trim_end().to_string()) + } + _ => None, + } + } + + /// Raw bytes for `Byte` / `Undefined` / `SByte` / `Ascii`. + pub fn as_bytes(&self) -> Option<&[u8]> { + match self { + Value::Byte(b) | Value::Undefined(b) => Some(b), + Value::Ascii(s) => Some(s.as_bytes()), + _ => None, + } + } +} + +/// One IFD entry. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Entry { + pub tag: u16, + pub value: Value, + /// Absolute position in the parsed buffer of the value bytes (inline or out-of-line). + pub offset: u64, +} + +impl Entry { + pub fn field_type(&self) -> FieldType { + self.value.field_type() + } + pub fn count(&self) -> usize { + self.value.count() + } +} + +/// An Image File Directory with its child IFDs already resolved. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct Ifd { + /// Absolute offset of the IFD in the buffer. + pub offset: u64, + /// Entries sorted by tag (duplicate tags: the first occurrence wins). + pub entries: Vec, + /// IFDs referenced by `SubIFDs` (330). + pub sub_ifds: Vec, + /// Exif private IFD (34665). + pub exif: Option>, + /// GPS IFD (34853). + pub gps: Option>, + /// Interoperability IFD (40965). + pub interop: Option>, +} + +impl Ifd { + pub fn get(&self, tag: u16) -> Option<&Entry> { + self.entries.binary_search_by_key(&tag, |e| e.tag).ok().map(|i| &self.entries[i]) + } + pub fn contains(&self, tag: u16) -> bool { + self.get(tag).is_some() + } + pub fn value(&self, tag: u16) -> Option<&Value> { + self.get(tag).map(|e| &e.value) + } + /// First value as `u32` (integer types, saturating from wider types). + pub fn u32(&self, tag: u16) -> Option { + self.value(tag)?.get_u64(0).map(|v| v.min(u32::MAX as u64) as u32) + } + pub fn u64(&self, tag: u16) -> Option { + self.value(tag)?.get_u64(0) + } + pub fn u16(&self, tag: u16) -> Option { + self.value(tag)?.get_u64(0).map(|v| v.min(u16::MAX as u64) as u16) + } + pub fn i64(&self, tag: u16) -> Option { + self.value(tag)?.get_i64(0) + } + pub fn f64(&self, tag: u16) -> Option { + self.value(tag)?.get_f64(0) + } + pub fn f64s(&self, tag: u16) -> Option> { + self.value(tag).map(|v| v.to_f64_vec()) + } + pub fn u64s(&self, tag: u16) -> Option> { + self.value(tag).map(|v| v.to_u64_vec()) + } + /// Text value (ASCII, or UTF-8 bytes). + pub fn string(&self, tag: u16) -> Option { + self.value(tag)?.as_str().filter(|s| !s.is_empty()) + } + pub fn bytes(&self, tag: u16) -> Option<&[u8]> { + self.value(tag)?.as_bytes() + } + + /// This IFD and all descendants (sub-IFDs, Exif, GPS, Interop), depth-first, pre-order. + pub fn walk(&self) -> Vec<&Ifd> { + let mut out = vec![self]; + for s in &self.sub_ifds { + out.extend(s.walk()); + } + for c in [&self.exif, &self.gps, &self.interop].into_iter().flatten() { + out.extend(c.walk()); + } + out + } + + /// Image layout of this IFD, if it describes an image. + pub fn image(&self) -> Result { + image::ImageInfo::from_ifd(self) + } +} + +/// A parsed TIFF stream (IFD chain with resolved children). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Tiff { + pub order: ByteOrder, + pub bigtiff: bool, + /// The main IFD chain (IFD0, IFD1, …). + pub ifds: Vec, +} + +impl Tiff { + /// Parse a TIFF stream (the header must be at offset 0 of `data`). + pub fn parse(data: &[u8]) -> Result { + reader::parse(data, &ParseOptions::default()) + } + pub fn parse_with(data: &[u8], opts: &ParseOptions) -> Result { + reader::parse(data, opts) + } + /// Header check only: returns the byte order and BigTIFF flag. + pub fn sniff(data: &[u8]) -> Option<(ByteOrder, bool)> { + reader::header(data).ok().map(|(o, b, _)| (o, b)) + } + /// Every IFD (main chain and all children), depth-first. + pub fn all_ifds(&self) -> Vec<&Ifd> { + self.ifds.iter().flat_map(|i| i.walk()).collect() + } + /// The Exif IFD attached to IFD0 (or any IFD). + pub fn exif(&self) -> Option<&Ifd> { + self.all_ifds().into_iter().find_map(|i| i.exif.as_deref()) + } + pub fn gps(&self) -> Option<&Ifd> { + self.all_ifds().into_iter().find_map(|i| i.gps.as_deref()) + } + /// First entry for `tag` in IFD0, then the Exif IFD, then any IFD. + pub fn find(&self, tag: u16) -> Option<&Entry> { + if let Some(e) = self.ifds.first().and_then(|i| i.get(tag)) { + return Some(e); + } + if let Some(e) = self.exif().and_then(|i| i.get(tag)) { + return Some(e); + } + self.all_ifds().into_iter().find_map(|i| i.get(tag)) + } +} diff --git a/crates/tiff/src/makernote.rs b/crates/tiff/src/makernote.rs new file mode 100644 index 00000000..7ed5e199 --- /dev/null +++ b/crates/tiff/src/makernote.rs @@ -0,0 +1,202 @@ +//! Maker-note IFD helpers. +//! +//! Most vendors store their maker note (Exif tag 37500) as a TIFF IFD, but each uses its own header, +//! byte order and *offset base* (what stored value offsets are relative to). The header layouts here +//! follow the public descriptions in the ExifTool tag documentation (`MakerNotes` table) and the +//! vendors' own published notes; no third-party code was consulted. + +use crate::{ByteOrder, Ifd, ParseOptions, parse_ifd_at}; +use serde::{Deserialize, Serialize}; + +/// Maker-note dialects we know how to locate. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum MakerNoteKind { + /// Plain IFD at the start of the note, offsets relative to the enclosing TIFF header (Canon, many others). + Plain, + /// `Nikon\0` + version + embedded TIFF header at +10; offsets relative to that header. + NikonV3, + /// `Nikon\0\x01\0`: IFD at +8, offsets relative to the enclosing TIFF header. + NikonV1, + /// `SONY DSC `/`SONY CAM `/`SONY MOBILE` etc.: 12-byte header, offsets relative to the TIFF header. + Sony, + /// `FUJIFILM` + LE u32 IFD offset; little-endian, offsets relative to the note start. + Fujifilm, + /// `OLYMPUS\0` + byte order + version: IFD at +12, offsets relative to the note start. + OlympusNew, + /// `OLYMP\0` / `EPSON\0` etc.: IFD at +8, offsets relative to the TIFF header. + OlympusOld, + /// `Panasonic\0\0\0`: IFD at +12, offsets relative to the TIFF header. + Panasonic, + /// `AOC\0` + byte order: IFD at +6, offsets relative to the TIFF header. + PentaxAoc, + /// `PENTAX \0` + byte order: IFD at +10, offsets relative to the note start. + Pentax, +} + +/// A parsed maker-note IFD. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct MakerNote { + pub kind: MakerNoteKind, + pub order: ByteOrder, + /// Absolute offset that stored value offsets are relative to. + pub base: u64, + pub ifd: Ifd, +} + +/// Locate and parse the maker note stored at absolute `offset` (length `len`) inside `data`, the buffer whose +/// start is the enclosing TIFF header. `order` is the enclosing stream's byte order. `make` (the Exif Make) is +/// used only as a hint for header-less notes. Returns `None` for unknown or unparsable notes. +pub fn parse_makernote(data: &[u8], offset: u64, len: u64, order: ByteOrder, make: &str) -> Option { + let start = usize::try_from(offset).ok()?; + let end = start.checked_add(usize::try_from(len).ok()?)?.min(data.len()); + let note = data.get(start..end)?; + let order_at = |i: usize| match note.get(i..i + 2) { + Some(b"II") => Some(ByteOrder::Little), + Some(b"MM") => Some(ByteOrder::Big), + _ => None, + }; + let opts = ParseOptions { max_ifds: 64, max_depth: 3, ..Default::default() }; + let try_parse = |kind: MakerNoteKind, ifd_at: u64, base: u64, ord: ByteOrder| -> Option { + let (ifd, _) = parse_ifd_at(data, ifd_at, ord, base, false, &opts).ok()?; + (!ifd.entries.is_empty()).then_some(MakerNote { kind, order: ord, base, ifd }) + }; + if note.starts_with(b"Nikon\0") { + if note.get(6) == Some(&1) { + return try_parse(MakerNoteKind::NikonV1, offset + 8, 0, order); + } + let ord = order_at(10)?; + let ifd_rel = ord.read_u32(note, 14)? as u64; + let base = offset + 10; + return try_parse(MakerNoteKind::NikonV3, base + ifd_rel, base, ord); + } + if note.starts_with(b"FUJIFILM") || note.starts_with(b"GENERALE") { + let rel = ByteOrder::Little.read_u32(note, 8)? as u64; + return try_parse(MakerNoteKind::Fujifilm, offset + rel, offset, ByteOrder::Little); + } + if note.starts_with(b"OLYMPUS\0") || note.starts_with(b"OM SYSTEM\0") { + let (ord_at, ifd_at) = if note.starts_with(b"OM SYSTEM\0") { (12, 16) } else { (8, 12) }; + let ord = order_at(ord_at).unwrap_or(order); + return try_parse(MakerNoteKind::OlympusNew, offset + ifd_at, offset, ord); + } + for p in [b"OLYMP\0", b"EPSON\0", b"MINOL\0", b"CAMER\0", b"SANYO\0"] { + if note.starts_with(p) { + return try_parse(MakerNoteKind::OlympusOld, offset + 8, 0, order); + } + } + if note.starts_with(b"Panasonic\0") { + return try_parse(MakerNoteKind::Panasonic, offset + 12, 0, order); + } + if note.starts_with(b"AOC\0") { + let ord = order_at(4).unwrap_or(order); + return try_parse(MakerNoteKind::PentaxAoc, offset + 6, 0, ord); + } + if note.starts_with(b"PENTAX \0") { + let ord = order_at(8).unwrap_or(order); + return try_parse(MakerNoteKind::Pentax, offset + 10, offset, ord); + } + if (note.starts_with(b"SONY") || note.starts_with(b"VHAB \0")) + && let Some(m) = try_parse(MakerNoteKind::Sony, offset + 12, 0, order) + { + return Some(m); + } + let _ = make; + try_parse(MakerNoteKind::Plain, offset, 0, order) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{IfdBuilder, TiffWriter, Value, tags}; + + /// Build a TIFF whose Exif IFD carries `note` as the maker note, return (bytes, note offset, len). + fn with_note(order: ByteOrder, note: Vec) -> (Vec, u64, u64) { + let mut exif = IfdBuilder::new(); + exif.set(tags::MAKER_NOTE, Value::Undefined(note)); + let mut ifd0 = IfdBuilder::new(); + ifd0.set(tags::MAKE, Value::Ascii("Test".into())); + ifd0.set_child(tags::EXIF_IFD, exif); + let bytes = TiffWriter { order, bigtiff: false }.write(&[ifd0]).unwrap(); + let t = crate::Tiff::parse(&bytes).unwrap(); + let e = t.exif().unwrap().get(tags::MAKER_NOTE).unwrap(); + let (off, len) = (e.offset, e.count() as u64); + (bytes, off, len) + } + + /// An IFD with one SHORT entry (tag 1, value 7) and one out-of-line LONG[2] entry at `rel` (relative to base). + fn tiny_ifd(order: ByteOrder, rel_values: u32) -> Vec { + let mut v = Vec::new(); + order.put_u16(&mut v, 2); + order.put_u16(&mut v, 1); + order.put_u16(&mut v, 3); + order.put_u32(&mut v, 1); + order.put_u16(&mut v, 7); + order.put_u16(&mut v, 0); + order.put_u16(&mut v, 2); + order.put_u16(&mut v, 4); + order.put_u32(&mut v, 2); + order.put_u32(&mut v, rel_values); + order.put_u32(&mut v, 0); + v + } + + #[test] + fn nikon_v3_uses_embedded_header_base() { + for order in [ByteOrder::Little, ByteOrder::Big] { + let mut note = b"Nikon\0\x02\x10\0\0".to_vec(); + let hdr_at = note.len(); + note.extend_from_slice(if order == ByteOrder::Little { b"II" } else { b"MM" }); + order.put_u16(&mut note, 42); + order.put_u32(&mut note, 8); + // IFD at +8 (relative to embedded header), values after IFD: 8 + 2 + 24 + 4 = 38 + note.extend(tiny_ifd(order, 38)); + order.put_u32(&mut note, 111); + order.put_u32(&mut note, 222); + let (bytes, off, len) = with_note(order, note); + let m = parse_makernote(&bytes, off, len, order, "NIKON CORPORATION").unwrap(); + assert_eq!(m.kind, MakerNoteKind::NikonV3); + assert_eq!(m.base, off + hdr_at as u64); + assert_eq!(m.ifd.u32(1), Some(7)); + assert_eq!(m.ifd.u64s(2).unwrap(), vec![111, 222]); + } + } + + #[test] + fn fujifilm_offsets_relative_to_note() { + let mut note = b"FUJIFILM".to_vec(); + ByteOrder::Little.put_u32(&mut note, 12); + // IFD at 12 (relative to note), values at 12 + 30 = 42 + note.extend(tiny_ifd(ByteOrder::Little, 42)); + ByteOrder::Little.put_u32(&mut note, 5); + ByteOrder::Little.put_u32(&mut note, 6); + // enclosing file is big-endian: Fuji notes are always little-endian + let (bytes, off, len) = with_note(ByteOrder::Big, note); + let m = parse_makernote(&bytes, off, len, ByteOrder::Big, "FUJIFILM").unwrap(); + assert_eq!(m.kind, MakerNoteKind::Fujifilm); + assert_eq!(m.order, ByteOrder::Little); + assert_eq!(m.ifd.u64s(2).unwrap(), vec![5, 6]); + } + + #[test] + fn plain_canon_style_note() { + // Plain IFD whose out-of-line offset is relative to the TIFF header: we need the absolute + // position, which we only know after writing. Write once to learn the offset, then patch. + let order = ByteOrder::Little; + let mut note = tiny_ifd(order, 0); + note.extend_from_slice(&[0; 8]); + let (mut bytes, off, len) = with_note(order, note); + let values_abs = off as u32 + 30; + bytes[off as usize + 22..off as usize + 26].copy_from_slice(&values_abs.to_le_bytes()); + bytes[off as usize + 30..off as usize + 34].copy_from_slice(&9u32.to_le_bytes()); + let m = parse_makernote(&bytes, off, len, order, "Canon").unwrap(); + assert_eq!(m.kind, MakerNoteKind::Plain); + assert_eq!(m.ifd.u64s(2).unwrap(), vec![9, 0]); + } + + #[test] + fn garbage_note_is_none() { + let (bytes, off, len) = with_note(ByteOrder::Little, vec![0xff; 40]); + assert!(parse_makernote(&bytes, off, len, ByteOrder::Little, "X").is_none()); + assert!(parse_makernote(&bytes, 1 << 40, 10, ByteOrder::Little, "X").is_none()); + assert!(parse_makernote(&bytes, off, u64::MAX, ByteOrder::Little, "X").is_none()); + } +} diff --git a/crates/tiff/src/reader.rs b/crates/tiff/src/reader.rs new file mode 100644 index 00000000..a491f0dd --- /dev/null +++ b/crates/tiff/src/reader.rs @@ -0,0 +1,236 @@ +//! Bounds-checked IFD parsing. + +use crate::{ByteOrder, Entry, FieldType, Ifd, Result, Tiff, TiffError, Value, tags}; +use std::collections::HashSet; + +/// Limits applied while parsing. The defaults are generous for real files and safe for hostile ones. +#[derive(Clone, Debug)] +pub struct ParseOptions { + /// Maximum number of IFDs visited in total (chain + children). + pub max_ifds: usize, + /// Maximum nesting depth of child IFDs. + pub max_depth: usize, + /// Maximum number of entries read from one IFD. + pub max_entries: usize, + /// Maximum total bytes of decoded values; `None` = `8 × input length + 1 MiB`. + pub value_budget: Option, + /// Follow `SubIFDs` / Exif / GPS / Interop pointers. + pub follow_children: bool, + /// Accept the TIFF-like magic numbers used by some raw formats (ORF `IIRO`/`IIRS`, RW2 `IIU`). + pub accept_raw_magic: bool, +} + +impl Default for ParseOptions { + fn default() -> Self { + Self { max_ifds: 1024, max_depth: 10, max_entries: 8192, value_budget: None, follow_children: true, accept_raw_magic: true } + } +} + +/// Parse the header: (byte order, bigtiff, first IFD offset). +pub(crate) fn header(data: &[u8]) -> Result<(ByteOrder, bool, u64)> { + header_with(data, true) +} + +fn header_with(data: &[u8], raw_magic: bool) -> Result<(ByteOrder, bool, u64)> { + let order = match data.get(0..2) { + Some(b"II") => ByteOrder::Little, + Some(b"MM") => ByteOrder::Big, + _ => return Err(TiffError::NotTiff), + }; + let magic = order.read_u16(data, 2).ok_or(TiffError::NotTiff)?; + match magic { + 42 => Ok((order, false, order.read_u32(data, 4).ok_or(TiffError::Truncated(4))? as u64)), + 43 => { + let bytesize = order.read_u16(data, 4).ok_or(TiffError::Truncated(4))?; + let zero = order.read_u16(data, 6).ok_or(TiffError::Truncated(6))?; + if bytesize != 8 || zero != 0 { + return Err(TiffError::NotTiff); + } + Ok((order, true, order.read_u64(data, 8).ok_or(TiffError::Truncated(8))?)) + } + 0x4f52 | 0x5352 | 0x0055 if raw_magic => Ok((order, false, order.read_u32(data, 4).ok_or(TiffError::Truncated(4))? as u64)), + _ => Err(TiffError::NotTiff), + } +} + +pub(crate) fn parse(data: &[u8], opts: &ParseOptions) -> Result { + let (order, big, first) = header_with(data, opts.accept_raw_magic)?; + let mut ctx = Ctx::new(data, order, big, opts); + let mut ifds = Vec::new(); + let mut off = first; + while off != 0 { + match ctx.ifd(off, 0, 0) { + Ok((ifd, next)) => { + ifds.push(ifd); + off = next; + } + Err(e) => { + if ifds.is_empty() { + return Err(e); + } + break; + } + } + } + if ifds.is_empty() { + return Err(TiffError::Invalid("no IFDs".into())); + } + Ok(Tiff { order, bigtiff: big, ifds }) +} + +/// Parse one IFD at absolute `offset` whose value offsets (and child IFD pointers) are relative to +/// `base` (absolute position = `base + stored offset`). Returns the IFD and the absolute offset of the +/// next IFD (0 when there is none). Used for maker notes, which often use their own offset base. +pub fn parse_ifd_at(data: &[u8], offset: u64, order: ByteOrder, base: u64, bigtiff: bool, opts: &ParseOptions) -> Result<(Ifd, u64)> { + let mut ctx = Ctx::new(data, order, bigtiff, opts); + ctx.ifd(offset, base, 0) +} + +struct Ctx<'a> { + data: &'a [u8], + order: ByteOrder, + big: bool, + opts: &'a ParseOptions, + visited: HashSet, + budget: u64, +} + +impl<'a> Ctx<'a> { + fn new(data: &'a [u8], order: ByteOrder, big: bool, opts: &'a ParseOptions) -> Self { + let budget = opts.value_budget.unwrap_or((data.len() as u64).saturating_mul(8).saturating_add(1 << 20)); + Self { data, order, big, opts, visited: HashSet::new(), budget } + } + + fn read_off(&self, pos: usize) -> Option { + if self.big { self.order.read_u64(self.data, pos) } else { self.order.read_u32(self.data, pos).map(|v| v as u64) } + } + + /// Returns (ifd, absolute next offset or 0). + fn ifd(&mut self, offset: u64, base: u64, depth: usize) -> Result<(Ifd, u64)> { + if depth > self.opts.max_depth { + return Err(TiffError::Limit("IFD nesting depth")); + } + if self.visited.len() >= self.opts.max_ifds { + return Err(TiffError::Limit("IFD count")); + } + if !self.visited.insert(offset) { + return Err(TiffError::Invalid(format!("IFD loop at {offset}"))); + } + let pos = usize::try_from(offset).map_err(|_| TiffError::Truncated(offset))?; + let (n, head, entry_size, inline) = if self.big { + (self.order.read_u64(self.data, pos).ok_or(TiffError::Truncated(offset))?, 8usize, 20usize, 8usize) + } else { + (self.order.read_u16(self.data, pos).ok_or(TiffError::Truncated(offset))? as u64, 2, 12, 4) + }; + let table = pos + head; + let fit = (self.data.len().saturating_sub(table) / entry_size) as u64; + let n_read = n.min(fit).min(self.opts.max_entries as u64) as usize; + if n == 0 { + return Err(TiffError::Invalid(format!("empty IFD at {offset}"))); + } + if n_read == 0 { + return Err(TiffError::Truncated(offset)); + } + let mut entries = Vec::with_capacity(n_read); + for i in 0..n_read { + let p = table + i * entry_size; + if let Some(e) = self.entry(p, base, inline) { + entries.push(e); + } + } + entries.sort_by_key(|e| e.tag); + entries.dedup_by_key(|e| e.tag); + let next_pos = table + (n as usize).saturating_mul(entry_size); + let next = if n as usize == n_read { + match self.read_off(next_pos) { + Some(0) | None => 0, + Some(v) => base.checked_add(v).unwrap_or(0), + } + } else { + 0 + }; + let mut ifd = Ifd { offset, entries, ..Default::default() }; + if self.opts.follow_children { + self.children(&mut ifd, base, depth); + } + Ok((ifd, next)) + } + + fn children(&mut self, ifd: &mut Ifd, base: u64, depth: usize) { + if let Some(v) = ifd.value(tags::SUB_IFDS) { + let offs = v.to_u64_vec(); + for o in offs { + if let Some(abs) = base.checked_add(o) + && let Ok((sub, _)) = self.ifd(abs, base, depth + 1) + { + ifd.sub_ifds.push(sub); + } + } + } + let child = |tag: u16, ctx: &mut Self| -> Option> { + let o = ifd.value(tag)?.get_u64(0)?; + let abs = base.checked_add(o)?; + ctx.ifd(abs, base, depth + 1).ok().map(|(i, _)| Box::new(i)) + }; + let exif = child(tags::EXIF_IFD, self); + let gps = child(tags::GPS_IFD, self); + let interop = child(tags::INTEROP_IFD, self); + ifd.exif = exif; + ifd.gps = gps; + ifd.interop = interop; + } + + fn entry(&mut self, p: usize, base: u64, inline: usize) -> Option { + let o = self.order; + let tag = o.read_u16(self.data, p)?; + let typ = FieldType::from_u16(o.read_u16(self.data, p + 2)?)?; + let (count, field) = if self.big { (o.read_u64(self.data, p + 4)?, p + 12) } else { (o.read_u32(self.data, p + 4)? as u64, p + 8) }; + let total = count.checked_mul(typ.size() as u64)?; + let start = if total <= inline as u64 { + field as u64 + } else { + let rel = self.read_off(field)?; + base.checked_add(rel)? + }; + let end = start.checked_add(total)?; + if end > self.data.len() as u64 { + return None; + } + if total > self.budget { + return None; + } + self.budget -= total; + let bytes = &self.data[start as usize..end as usize]; + Some(Entry { tag, value: decode(o, typ, bytes), offset: start }) + } +} + +/// Decode `bytes` (exactly `count × size` long) as values of type `typ`. +pub(crate) fn decode(o: ByteOrder, typ: FieldType, b: &[u8]) -> Value { + fn arr(c: &[u8]) -> [u8; N] { + let mut a = [0u8; N]; + a.copy_from_slice(c); + a + } + match typ { + FieldType::Byte => Value::Byte(b.to_vec()), + FieldType::Undefined => Value::Undefined(b.to_vec()), + FieldType::Ascii => { + let end = b.iter().position(|&c| c == 0).unwrap_or(b.len()); + Value::Ascii(String::from_utf8_lossy(&b[..end]).into_owned()) + } + FieldType::SByte => Value::SByte(b.iter().map(|&x| x as i8).collect()), + FieldType::Short => Value::Short(b.chunks_exact(2).map(|c| o.u16(arr(c))).collect()), + FieldType::SShort => Value::SShort(b.chunks_exact(2).map(|c| o.u16(arr(c)) as i16).collect()), + FieldType::Long => Value::Long(b.chunks_exact(4).map(|c| o.u32(arr(c))).collect()), + FieldType::Ifd => Value::Ifd(b.chunks_exact(4).map(|c| o.u32(arr(c))).collect()), + FieldType::SLong => Value::SLong(b.chunks_exact(4).map(|c| o.u32(arr(c)) as i32).collect()), + FieldType::Float => Value::Float(b.chunks_exact(4).map(|c| f32::from_bits(o.u32(arr(c)))).collect()), + FieldType::Rational => Value::Rational(b.chunks_exact(8).map(|c| (o.u32(arr(&c[..4])), o.u32(arr(&c[4..])))).collect()), + FieldType::SRational => Value::SRational(b.chunks_exact(8).map(|c| (o.u32(arr(&c[..4])) as i32, o.u32(arr(&c[4..])) as i32)).collect()), + FieldType::Double => Value::Double(b.chunks_exact(8).map(|c| f64::from_bits(o.u64(arr(c)))).collect()), + FieldType::Long8 => Value::Long8(b.chunks_exact(8).map(|c| o.u64(arr(c))).collect()), + FieldType::Ifd8 => Value::Ifd8(b.chunks_exact(8).map(|c| o.u64(arr(c))).collect()), + FieldType::SLong8 => Value::SLong8(b.chunks_exact(8).map(|c| o.u64(arr(c)) as i64).collect()), + } +} diff --git a/crates/tiff/src/tags.rs b/crates/tiff/src/tags.rs new file mode 100644 index 00000000..c7b21e34 --- /dev/null +++ b/crates/tiff/src/tags.rs @@ -0,0 +1,200 @@ +//! Tag numbers (TIFF 6.0, TIFF/EP, Exif 2.32, GPS, DNG 1.7). Public specification constants. + +// --- TIFF baseline / extensions +pub const NEW_SUBFILE_TYPE: u16 = 254; +pub const SUBFILE_TYPE: u16 = 255; +pub const IMAGE_WIDTH: u16 = 256; +pub const IMAGE_LENGTH: u16 = 257; +pub const BITS_PER_SAMPLE: u16 = 258; +pub const COMPRESSION: u16 = 259; +pub const PHOTOMETRIC: u16 = 262; +pub const FILL_ORDER: u16 = 266; +pub const DOCUMENT_NAME: u16 = 269; +pub const IMAGE_DESCRIPTION: u16 = 270; +pub const MAKE: u16 = 271; +pub const MODEL: u16 = 272; +pub const STRIP_OFFSETS: u16 = 273; +pub const ORIENTATION: u16 = 274; +pub const SAMPLES_PER_PIXEL: u16 = 277; +pub const ROWS_PER_STRIP: u16 = 278; +pub const STRIP_BYTE_COUNTS: u16 = 279; +pub const X_RESOLUTION: u16 = 282; +pub const Y_RESOLUTION: u16 = 283; +pub const PLANAR_CONFIGURATION: u16 = 284; +pub const RESOLUTION_UNIT: u16 = 296; +pub const SOFTWARE: u16 = 305; +pub const DATE_TIME: u16 = 306; +pub const ARTIST: u16 = 315; +pub const PREDICTOR: u16 = 317; +pub const TILE_WIDTH: u16 = 322; +pub const TILE_LENGTH: u16 = 323; +pub const TILE_OFFSETS: u16 = 324; +pub const TILE_BYTE_COUNTS: u16 = 325; +pub const SUB_IFDS: u16 = 330; +pub const EXTRA_SAMPLES: u16 = 338; +pub const SAMPLE_FORMAT: u16 = 339; +pub const JPEG_INTERCHANGE_FORMAT: u16 = 513; +pub const JPEG_INTERCHANGE_FORMAT_LENGTH: u16 = 514; +pub const YCBCR_SUBSAMPLING: u16 = 530; +pub const XMP: u16 = 700; +pub const RATING: u16 = 18246; +pub const RATING_PERCENT: u16 = 18249; +pub const CFA_REPEAT_PATTERN_DIM: u16 = 33421; +pub const CFA_PATTERN_EP: u16 = 33422; +pub const COPYRIGHT: u16 = 33432; +pub const EXPOSURE_TIME_EP: u16 = 33434; +pub const IPTC_NAA: u16 = 33723; +pub const EXIF_IFD: u16 = 34665; +pub const ICC_PROFILE: u16 = 34675; +pub const GPS_IFD: u16 = 34853; +pub const INTEROP_IFD: u16 = 40965; + +// --- Exif private IFD +pub const EXPOSURE_TIME: u16 = 33434; +pub const F_NUMBER: u16 = 33437; +pub const EXPOSURE_PROGRAM: u16 = 34850; +pub const ISO_SPEED: u16 = 34855; +pub const SENSITIVITY_TYPE: u16 = 34864; +pub const RECOMMENDED_EXPOSURE_INDEX: u16 = 34866; +pub const ISO_SPEED_RATINGS_EXT: u16 = 34867; +pub const EXIF_VERSION: u16 = 36864; +pub const DATE_TIME_ORIGINAL: u16 = 36867; +pub const DATE_TIME_DIGITIZED: u16 = 36868; +pub const OFFSET_TIME: u16 = 36880; +pub const OFFSET_TIME_ORIGINAL: u16 = 36881; +pub const OFFSET_TIME_DIGITIZED: u16 = 36882; +pub const SHUTTER_SPEED_VALUE: u16 = 37377; +pub const APERTURE_VALUE: u16 = 37378; +pub const EXPOSURE_BIAS: u16 = 37380; +pub const MAX_APERTURE: u16 = 37381; +pub const METERING_MODE: u16 = 37383; +pub const LIGHT_SOURCE: u16 = 37384; +pub const FLASH: u16 = 37385; +pub const FOCAL_LENGTH: u16 = 37386; +pub const MAKER_NOTE: u16 = 37500; +pub const USER_COMMENT: u16 = 37510; +pub const SUBSEC_TIME: u16 = 37520; +pub const SUBSEC_TIME_ORIGINAL: u16 = 37521; +pub const SUBSEC_TIME_DIGITIZED: u16 = 37522; +pub const PIXEL_X_DIMENSION: u16 = 40962; +pub const PIXEL_Y_DIMENSION: u16 = 40963; +pub const FOCAL_PLANE_X_RESOLUTION: u16 = 41486; +pub const EXPOSURE_MODE: u16 = 41986; +pub const WHITE_BALANCE: u16 = 41987; +pub const FOCAL_LENGTH_35MM: u16 = 41989; +pub const SCENE_CAPTURE_TYPE: u16 = 41990; +pub const BODY_SERIAL_NUMBER: u16 = 42033; +pub const LENS_SPECIFICATION: u16 = 42034; +pub const LENS_MAKE: u16 = 42035; +pub const LENS_MODEL: u16 = 42036; +pub const LENS_SERIAL_NUMBER: u16 = 42037; + +// --- GPS IFD +pub const GPS_VERSION_ID: u16 = 0; +pub const GPS_LATITUDE_REF: u16 = 1; +pub const GPS_LATITUDE: u16 = 2; +pub const GPS_LONGITUDE_REF: u16 = 3; +pub const GPS_LONGITUDE: u16 = 4; +pub const GPS_ALTITUDE_REF: u16 = 5; +pub const GPS_ALTITUDE: u16 = 6; +pub const GPS_TIME_STAMP: u16 = 7; +pub const GPS_IMG_DIRECTION_REF: u16 = 16; +pub const GPS_IMG_DIRECTION: u16 = 17; +pub const GPS_DATE_STAMP: u16 = 29; + +// --- DNG +pub const DNG_VERSION: u16 = 50706; +pub const DNG_BACKWARD_VERSION: u16 = 50707; +pub const UNIQUE_CAMERA_MODEL: u16 = 50708; +pub const LOCALIZED_CAMERA_MODEL: u16 = 50709; +pub const CFA_PLANE_COLOR: u16 = 50710; +pub const CFA_LAYOUT: u16 = 50711; +pub const LINEARIZATION_TABLE: u16 = 50712; +pub const BLACK_LEVEL_REPEAT_DIM: u16 = 50713; +pub const BLACK_LEVEL: u16 = 50714; +pub const BLACK_LEVEL_DELTA_H: u16 = 50715; +pub const BLACK_LEVEL_DELTA_V: u16 = 50716; +pub const WHITE_LEVEL: u16 = 50717; +pub const DEFAULT_SCALE: u16 = 50718; +pub const DEFAULT_CROP_ORIGIN: u16 = 50719; +pub const DEFAULT_CROP_SIZE: u16 = 50720; +pub const COLOR_MATRIX_1: u16 = 50721; +pub const COLOR_MATRIX_2: u16 = 50722; +pub const CAMERA_CALIBRATION_1: u16 = 50723; +pub const CAMERA_CALIBRATION_2: u16 = 50724; +pub const REDUCTION_MATRIX_1: u16 = 50725; +pub const REDUCTION_MATRIX_2: u16 = 50726; +pub const ANALOG_BALANCE: u16 = 50727; +pub const AS_SHOT_NEUTRAL: u16 = 50728; +pub const AS_SHOT_WHITE_XY: u16 = 50729; +pub const BASELINE_EXPOSURE: u16 = 50730; +pub const BASELINE_NOISE: u16 = 50731; +pub const BASELINE_SHARPNESS: u16 = 50732; +pub const BAYER_GREEN_SPLIT: u16 = 50733; +pub const LINEAR_RESPONSE_LIMIT: u16 = 50734; +pub const CAMERA_SERIAL_NUMBER: u16 = 50735; +pub const LENS_INFO: u16 = 50736; +pub const CHROMA_BLUR_RADIUS: u16 = 50737; +pub const ANTI_ALIAS_STRENGTH: u16 = 50738; +pub const SHADOW_SCALE: u16 = 50739; +pub const DNG_PRIVATE_DATA: u16 = 50740; +pub const MAKER_NOTE_SAFETY: u16 = 50741; +pub const CALIBRATION_ILLUMINANT_1: u16 = 50778; +pub const CALIBRATION_ILLUMINANT_2: u16 = 50779; +pub const BEST_QUALITY_SCALE: u16 = 50780; +pub const RAW_DATA_UNIQUE_ID: u16 = 50781; +pub const ORIGINAL_RAW_FILE_NAME: u16 = 50827; +pub const ACTIVE_AREA: u16 = 50829; +pub const MASKED_AREAS: u16 = 50830; +pub const AS_SHOT_ICC_PROFILE: u16 = 50831; +pub const COLORIMETRIC_REFERENCE: u16 = 50879; +pub const CAMERA_CALIBRATION_SIGNATURE: u16 = 50931; +pub const PROFILE_CALIBRATION_SIGNATURE: u16 = 50932; +pub const AS_SHOT_PROFILE_NAME: u16 = 50934; +pub const PROFILE_NAME: u16 = 50936; +pub const BASELINE_EXPOSURE_OFFSET: u16 = 51109; +pub const DEFAULT_BLACK_RENDER: u16 = 51110; +pub const FORWARD_MATRIX_1: u16 = 50964; +pub const FORWARD_MATRIX_2: u16 = 50965; +pub const PREVIEW_COLOR_SPACE: u16 = 50970; +pub const CURRENT_PREVIEW_DATE_TIME: u16 = 50971; +pub const OPCODE_LIST_1: u16 = 51008; +pub const OPCODE_LIST_2: u16 = 51009; +pub const OPCODE_LIST_3: u16 = 51022; +pub const NOISE_PROFILE: u16 = 51041; +pub const DEFAULT_USER_CROP: u16 = 51125; +pub const CALIBRATION_ILLUMINANT_3: u16 = 52529; +pub const CAMERA_CALIBRATION_3: u16 = 52530; +pub const COLOR_MATRIX_3: u16 = 52531; +pub const FORWARD_MATRIX_3: u16 = 52532; +pub const ILLUMINANT_DATA_1: u16 = 52533; +pub const ILLUMINANT_DATA_2: u16 = 52534; +pub const ILLUMINANT_DATA_3: u16 = 52535; + +// --- Compression codes (TIFF 6.0, DNG, and widely used registrations) +pub mod compression { + pub const NONE: u16 = 1; + pub const LZW: u16 = 5; + pub const OLD_JPEG: u16 = 6; + pub const JPEG: u16 = 7; + pub const ADOBE_DEFLATE: u16 = 8; + pub const DEFLATE: u16 = 32946; + pub const PACKBITS: u16 = 32773; + /// DNG lossy JPEG (baseline DCT, 8-bit). + pub const LOSSY_JPEG: u16 = 34892; + pub const JPEG_XL: u16 = 52546; + /// Nikon NEF Huffman compression. + pub const NIKON: u16 = 34713; + /// Sony ARW compressed (the value used by ARW files). + pub const SONY_ARW: u16 = 32767; +} + +// --- PhotometricInterpretation +pub mod photometric { + pub const WHITE_IS_ZERO: u16 = 0; + pub const BLACK_IS_ZERO: u16 = 1; + pub const RGB: u16 = 2; + pub const YCBCR: u16 = 6; + pub const CFA: u16 = 32803; + pub const LINEAR_RAW: u16 = 34892; +} diff --git a/crates/tiff/src/writer.rs b/crates/tiff/src/writer.rs new file mode 100644 index 00000000..5732e7d0 --- /dev/null +++ b/crates/tiff/src/writer.rs @@ -0,0 +1,293 @@ +//! TIFF writer: IFD trees with strips / tiles, `SubIFDs` and pointer IFDs (Exif, GPS, Interop, …). +//! +//! Layout: header, then for each IFD of the main chain its image chunks, child IFDs, out-of-line values +//! and finally the IFD itself (all word-aligned). Children are written before their parent so every +//! pointer is known when the parent's entries are emitted; only the chain's next-IFD pointers are patched. + +use crate::{ByteOrder, Result, TiffError, Value, tags}; +use std::collections::BTreeMap; + +/// Pixel data attached to an IFD. The writer fills in the offset / byte-count tags. +#[derive(Clone, Debug, PartialEq)] +pub enum ImageData { + /// Strips (each already compressed as the IFD's `Compression` says). Sets `RowsPerStrip`. + Strips { rows_per_strip: u32, strips: Vec> }, + /// Tiles in row-major tile order. Sets `TileWidth` / `TileLength`. + Tiles { tile_width: u32, tile_height: u32, tiles: Vec> }, +} + +/// An IFD to be written. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct IfdBuilder { + pub entries: BTreeMap, + /// Written and referenced through the `SubIFDs` (330) tag. + pub sub_ifds: Vec, + /// Pointer IFDs keyed by the pointer tag (e.g. [`tags::EXIF_IFD`], [`tags::GPS_IFD`], [`tags::INTEROP_IFD`]). + pub children: BTreeMap, + pub image: Option, +} + +impl IfdBuilder { + pub fn new() -> Self { + Self::default() + } + pub fn set(&mut self, tag: u16, value: Value) -> &mut Self { + self.entries.insert(tag, value); + self + } + /// Builder-style [`IfdBuilder::set`]. + pub fn with(mut self, tag: u16, value: Value) -> Self { + self.entries.insert(tag, value); + self + } + pub fn set_child(&mut self, tag: u16, child: IfdBuilder) -> &mut Self { + self.children.insert(tag, child); + self + } + pub fn add_sub_ifd(&mut self, sub: IfdBuilder) -> &mut Self { + self.sub_ifds.push(sub); + self + } + pub fn set_image(&mut self, image: ImageData) -> &mut Self { + self.image = Some(image); + self + } +} + +/// Serialises IFD chains. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct TiffWriter { + pub order: ByteOrder, + pub bigtiff: bool, +} + +impl TiffWriter { + pub fn new(order: ByteOrder, bigtiff: bool) -> Self { + Self { order, bigtiff } + } + + /// Write `chain` (IFD0, IFD1, …) as a complete TIFF stream. + pub fn write(&self, chain: &[IfdBuilder]) -> Result> { + if chain.is_empty() { + return Err(TiffError::Invalid("no IFDs to write".into())); + } + let o = self.order; + let mut buf = Vec::new(); + buf.extend_from_slice(match o { + ByteOrder::Little => b"II", + ByteOrder::Big => b"MM", + }); + let mut patch = if self.bigtiff { + o.put_u16(&mut buf, 43); + o.put_u16(&mut buf, 8); + o.put_u16(&mut buf, 0); + let p = buf.len(); + o.put_u64(&mut buf, 0); + p + } else { + o.put_u16(&mut buf, 42); + let p = buf.len(); + o.put_u32(&mut buf, 0); + p + }; + for ifd in chain { + let (off, next_pos) = self.write_ifd(&mut buf, ifd, 0)?; + self.patch_offset(&mut buf, patch, off)?; + patch = next_pos; + } + Ok(buf) + } + + fn patch_offset(&self, buf: &mut [u8], pos: usize, v: u64) -> Result<()> { + let mut tmp = Vec::with_capacity(8); + if self.bigtiff { + self.order.put_u64(&mut tmp, v); + } else { + self.order.put_u32(&mut tmp, u32::try_from(v).map_err(|_| TiffError::Limit("classic TIFF larger than 4 GiB"))?); + } + buf[pos..pos + tmp.len()].copy_from_slice(&tmp); + Ok(()) + } + + fn offset_value(&self, v: Vec) -> Result { + if self.bigtiff { + Ok(Value::Long8(v)) + } else { + Ok(Value::Long( + v.into_iter().map(|x| u32::try_from(x).map_err(|_| TiffError::Limit("classic TIFF larger than 4 GiB"))).collect::>()?, + )) + } + } + + /// Writes the IFD (and everything it references); returns (IFD offset, position of its next-IFD pointer). + fn write_ifd(&self, buf: &mut Vec, ifd: &IfdBuilder, depth: usize) -> Result<(u64, usize)> { + if depth > 16 { + return Err(TiffError::Limit("IFD nesting depth")); + } + let o = self.order; + let mut entries = ifd.entries.clone(); + if let Some(img) = &ifd.image { + let chunks = match img { + ImageData::Strips { rows_per_strip, strips } => { + entries.insert(tags::ROWS_PER_STRIP, Value::Long(vec![*rows_per_strip])); + strips + } + ImageData::Tiles { tile_width, tile_height, tiles } => { + entries.insert(tags::TILE_WIDTH, Value::Long(vec![*tile_width])); + entries.insert(tags::TILE_LENGTH, Value::Long(vec![*tile_height])); + tiles + } + }; + let mut offs = Vec::with_capacity(chunks.len()); + let mut counts = Vec::with_capacity(chunks.len()); + for c in chunks { + align(buf); + offs.push(buf.len() as u64); + counts.push(c.len() as u64); + buf.extend_from_slice(c); + } + let (ot, ct) = match img { + ImageData::Strips { .. } => (tags::STRIP_OFFSETS, tags::STRIP_BYTE_COUNTS), + ImageData::Tiles { .. } => (tags::TILE_OFFSETS, tags::TILE_BYTE_COUNTS), + }; + entries.insert(ot, self.offset_value(offs)?); + entries.insert(ct, self.offset_value(counts)?); + } + if !ifd.sub_ifds.is_empty() { + let mut offs = Vec::new(); + for s in &ifd.sub_ifds { + offs.push(self.write_ifd(buf, s, depth + 1)?.0); + } + entries.insert(tags::SUB_IFDS, self.offset_value(offs)?); + } + for (&tag, child) in &ifd.children { + let off = self.write_ifd(buf, child, depth + 1)?.0; + entries.insert(tag, self.offset_value(vec![off])?); + } + let inline = if self.bigtiff { 8 } else { 4 }; + // out-of-line values + let mut encoded: Vec<(u16, &Value, Vec, Option)> = Vec::with_capacity(entries.len()); + for (&tag, v) in &entries { + let bytes = encode(o, v); + let pos = if bytes.len() > inline { + align(buf); + let p = buf.len() as u64; + buf.extend_from_slice(&bytes); + Some(p) + } else { + None + }; + encoded.push((tag, v, bytes, pos)); + } + align(buf); + let ifd_off = buf.len() as u64; + if !self.bigtiff && ifd_off > u32::MAX as u64 { + return Err(TiffError::Limit("classic TIFF larger than 4 GiB")); + } + if self.bigtiff { + o.put_u64(buf, encoded.len() as u64); + } else { + o.put_u16(buf, u16::try_from(encoded.len()).map_err(|_| TiffError::Limit("more than 65535 entries"))?); + } + for (tag, v, bytes, pos) in &encoded { + o.put_u16(buf, *tag); + o.put_u16(buf, v.field_type() as u16); + if self.bigtiff { + o.put_u64(buf, v.count() as u64); + } else { + o.put_u32(buf, u32::try_from(v.count()).map_err(|_| TiffError::Limit("value count"))?); + } + match pos { + Some(p) => { + if self.bigtiff { + o.put_u64(buf, *p); + } else { + o.put_u32(buf, u32::try_from(*p).map_err(|_| TiffError::Limit("classic TIFF larger than 4 GiB"))?); + } + } + None => { + buf.extend_from_slice(bytes); + buf.extend(std::iter::repeat_n(0u8, inline - bytes.len())); + } + } + } + let next_pos = buf.len(); + if self.bigtiff { + o.put_u64(buf, 0); + } else { + o.put_u32(buf, 0); + } + Ok((ifd_off, next_pos)) + } +} + +fn align(buf: &mut Vec) { + if buf.len() % 2 == 1 { + buf.push(0); + } +} + +/// Serialise a value's bytes in `o` order. +pub fn encode(o: ByteOrder, v: &Value) -> Vec { + let mut b = Vec::new(); + match v { + Value::Byte(x) | Value::Undefined(x) => b.extend_from_slice(x), + Value::Ascii(s) => { + b.extend_from_slice(s.as_bytes()); + b.push(0); + } + Value::SByte(x) => b.extend(x.iter().map(|&c| c as u8)), + Value::Short(x) => x.iter().for_each(|&c| o.put_u16(&mut b, c)), + Value::SShort(x) => x.iter().for_each(|&c| o.put_u16(&mut b, c as u16)), + Value::Long(x) | Value::Ifd(x) => x.iter().for_each(|&c| o.put_u32(&mut b, c)), + Value::SLong(x) => x.iter().for_each(|&c| o.put_u32(&mut b, c as u32)), + Value::Float(x) => x.iter().for_each(|&c| o.put_u32(&mut b, c.to_bits())), + Value::Rational(x) => x.iter().for_each(|&(n, d)| { + o.put_u32(&mut b, n); + o.put_u32(&mut b, d); + }), + Value::SRational(x) => x.iter().for_each(|&(n, d)| { + o.put_u32(&mut b, n as u32); + o.put_u32(&mut b, d as u32); + }), + Value::Double(x) => x.iter().for_each(|&c| o.put_u64(&mut b, c.to_bits())), + Value::Long8(x) | Value::Ifd8(x) => x.iter().for_each(|&c| o.put_u64(&mut b, c)), + Value::SLong8(x) => x.iter().for_each(|&c| o.put_u64(&mut b, c as u64)), + } + b +} + +/// Convenience: approximate `v` by an unsigned rational with denominator up to 1e6 (for writing Exif rationals). +pub fn rational(v: f64) -> (u32, u32) { + if !v.is_finite() || v <= 0.0 { + return (0, 1); + } + for d in [1u32, 10, 100, 1000, 10_000, 100_000, 1_000_000] { + let n = v * d as f64; + if n > u32::MAX as f64 { + return ((v * (d / 10).max(1) as f64).round().min(u32::MAX as f64) as u32, (d / 10).max(1)); + } + if (n - n.round()).abs() < 1e-9 * d as f64 || d == 1_000_000 { + return (n.round() as u32, d); + } + } + (0, 1) +} + +/// Signed variant of [`rational`]. +pub fn srational(v: f64) -> (i32, i32) { + if !v.is_finite() { + return (0, 1); + } + for d in [1i32, 10, 100, 1000, 10_000, 100_000, 1_000_000] { + let n = v * d as f64; + if n.abs() > i32::MAX as f64 { + let d = (d / 10).max(1); + return ((v * d as f64).round().clamp(i32::MIN as f64, i32::MAX as f64) as i32, d); + } + if (n - n.round()).abs() < 1e-9 * d as f64 || d == 1_000_000 { + return (n.round() as i32, d); + } + } + (0, 1) +} diff --git a/crates/tiff/tests/roundtrip.rs b/crates/tiff/tests/roundtrip.rs new file mode 100644 index 00000000..54fc8237 --- /dev/null +++ b/crates/tiff/tests/roundtrip.rs @@ -0,0 +1,395 @@ +use lightcraft_tiff::image::{Layout, chunk_bytes}; +use lightcraft_tiff::writer::{rational, srational}; +use lightcraft_tiff::{ByteOrder, FieldType, IfdBuilder, ImageData, ParseOptions, Tiff, TiffError, TiffWriter, Value, parse_ifd_at, tags}; +use proptest::prelude::*; + +const ORDERS: [ByteOrder; 2] = [ByteOrder::Little, ByteOrder::Big]; + +fn all_values() -> Vec<(u16, Value)> { + vec![ + (1000, Value::Byte(vec![1, 2, 3, 255])), + (1001, Value::Ascii("hello tiff".into())), + (1002, Value::Short(vec![1, 65535, 3])), + (1003, Value::Long(vec![7])), + (1004, Value::Rational(vec![(1, 3), (22, 7)])), + (1005, Value::SByte(vec![-1, 0, 127, -128])), + (1006, Value::Undefined(vec![9; 17])), + (1007, Value::SShort(vec![-32768, 32767])), + (1008, Value::SLong(vec![-5, i32::MAX])), + (1009, Value::SRational(vec![(-1, 3), (5, -2)])), + (1010, Value::Float(vec![1.5, -0.25, f32::MAX])), + (1011, Value::Double(vec![std::f64::consts::PI])), + (1012, Value::Ifd(vec![0])), + (1013, Value::Short(vec![42])), + (1014, Value::Ascii(String::new())), + ] +} + +fn big_values() -> Vec<(u16, Value)> { + vec![(1020, Value::Long8(vec![1 << 40, 3])), (1021, Value::SLong8(vec![-(1 << 50)])), (1022, Value::Ifd8(vec![0]))] +} + +#[test] +fn all_field_types_roundtrip_every_variant() { + for order in ORDERS { + for big in [false, true] { + let mut ifd = IfdBuilder::new(); + for (t, v) in all_values() { + ifd.set(t, v); + } + if big { + for (t, v) in big_values() { + ifd.set(t, v); + } + } + let bytes = TiffWriter::new(order, big).write(&[ifd]).unwrap(); + let t = Tiff::parse(&bytes).unwrap(); + assert_eq!(t.order, order); + assert_eq!(t.bigtiff, big); + assert_eq!(Tiff::sniff(&bytes), Some((order, big))); + let ifd0 = &t.ifds[0]; + for (tag, v) in all_values().into_iter().chain(if big { big_values() } else { vec![] }) { + assert_eq!(ifd0.value(tag), Some(&v), "tag {tag} order {order:?} big {big}"); + } + } + } +} + +#[test] +fn accessors() { + let mut ifd = IfdBuilder::new(); + ifd.set(1, Value::Rational(vec![(28, 10), (0, 0)])); + ifd.set(2, Value::Short(vec![5, 6])); + ifd.set(3, Value::SLong(vec![-3])); + ifd.set(4, Value::Ascii("Canon ".into())); + ifd.set(5, Value::Byte(b"\0junk".to_vec())); + let bytes = TiffWriter::default().write(&[ifd]).unwrap(); + let t = Tiff::parse(&bytes).unwrap(); + let i = &t.ifds[0]; + assert_eq!(i.f64(1), Some(2.8)); + assert_eq!(i.f64s(1).unwrap(), vec![2.8, 0.0]); + assert_eq!(i.u32(2), Some(5)); + assert_eq!(i.u64s(2).unwrap(), vec![5, 6]); + assert_eq!(i.u32(3), None); + assert_eq!(i.i64(3), Some(-3)); + assert_eq!(i.string(4).as_deref(), Some("Canon")); + assert_eq!(i.string(5).as_deref(), Some("")); + assert_eq!(i.get(4).unwrap().field_type(), FieldType::Ascii); + assert!(i.u32(999).is_none()); + assert_eq!(t.find(2).map(|e| e.tag), Some(2)); +} + +#[test] +fn chain_subifds_and_pointer_ifds() { + for order in ORDERS { + for big in [false, true] { + let mut interop = IfdBuilder::new(); + interop.set(1, Value::Ascii("R98".into())); + let mut exif = IfdBuilder::new(); + exif.set(tags::F_NUMBER, Value::Rational(vec![(56, 10)])); + exif.set_child(tags::INTEROP_IFD, interop); + let mut gps = IfdBuilder::new(); + gps.set(tags::GPS_LATITUDE_REF, Value::Ascii("N".into())); + let mut ifd0 = IfdBuilder::new(); + ifd0.set(tags::MAKE, Value::Ascii("Maker".into())); + ifd0.set_child(tags::EXIF_IFD, exif); + ifd0.set_child(tags::GPS_IFD, gps); + let mut raw = IfdBuilder::new(); + raw.set(tags::IMAGE_WIDTH, Value::Long(vec![4])); + let mut nested = IfdBuilder::new(); + nested.set(tags::IMAGE_WIDTH, Value::Long(vec![2])); + raw.add_sub_ifd(nested); + ifd0.add_sub_ifd(raw); + ifd0.add_sub_ifd(IfdBuilder::new().with(tags::IMAGE_WIDTH, Value::Short(vec![3]))); + let ifd1 = IfdBuilder::new().with(tags::IMAGE_WIDTH, Value::Short(vec![160])); + let ifd2 = IfdBuilder::new().with(tags::IMAGE_WIDTH, Value::Short(vec![80])); + let bytes = TiffWriter::new(order, big).write(&[ifd0, ifd1, ifd2]).unwrap(); + let t = Tiff::parse(&bytes).unwrap(); + assert_eq!(t.ifds.len(), 3); + assert_eq!(t.ifds[1].u32(tags::IMAGE_WIDTH), Some(160)); + assert_eq!(t.ifds[2].u32(tags::IMAGE_WIDTH), Some(80)); + let i0 = &t.ifds[0]; + assert_eq!(i0.sub_ifds.len(), 2); + assert_eq!(i0.sub_ifds[0].u32(tags::IMAGE_WIDTH), Some(4)); + assert_eq!(i0.sub_ifds[0].sub_ifds[0].u32(tags::IMAGE_WIDTH), Some(2)); + assert_eq!(i0.sub_ifds[1].u32(tags::IMAGE_WIDTH), Some(3)); + let exif = t.exif().unwrap(); + assert_eq!(exif.f64(tags::F_NUMBER), Some(5.6)); + assert_eq!(exif.interop.as_ref().unwrap().string(1).as_deref(), Some("R98")); + assert_eq!(t.gps().unwrap().string(tags::GPS_LATITUDE_REF).as_deref(), Some("N")); + // IFD0, sub0, sub0.sub, sub1, exif, interop, gps, ifd1, ifd2 + assert_eq!(t.all_ifds().len(), 9); + } + } +} + +fn gradient(w: usize, h: usize) -> Vec { + (0..w * h).map(|i| (i * 7 % 251) as u8).collect() +} + +#[test] +fn strips_layout_and_bytes() { + let (w, h) = (10usize, 7usize); + let px = gradient(w, h); + let rps = 3; + let strips: Vec> = px.chunks(w * rps).map(|c| c.to_vec()).collect(); + let mut ifd = IfdBuilder::new(); + ifd.set(tags::IMAGE_WIDTH, Value::Long(vec![w as u32])); + ifd.set(tags::IMAGE_LENGTH, Value::Long(vec![h as u32])); + ifd.set(tags::BITS_PER_SAMPLE, Value::Short(vec![8])); + ifd.set(tags::COMPRESSION, Value::Short(vec![1])); + ifd.set_image(ImageData::Strips { rows_per_strip: rps as u32, strips }); + for order in ORDERS { + for big in [false, true] { + let bytes = TiffWriter::new(order, big).write(std::slice::from_ref(&ifd)).unwrap(); + let t = Tiff::parse(&bytes).unwrap(); + let info = t.ifds[0].image().unwrap(); + assert_eq!(info.layout, Layout::Strips { rows_per_strip: 3 }); + assert_eq!(info.grid(), (1, 3)); + let chunks = info.chunks(bytes.len() as u64); + assert_eq!(chunks.len(), 3); + assert_eq!(chunks[2].height, 1); + let mut out = Vec::new(); + for c in &chunks { + out.extend_from_slice(chunk_bytes(&bytes, c).unwrap()); + } + assert_eq!(out, px); + } + } +} + +#[test] +fn tiles_layout_and_bytes() { + let (w, h, tw, th) = (20usize, 13usize, 16usize, 8usize); + let px = gradient(w, h); + let (ta, td) = (w.div_ceil(tw), h.div_ceil(th)); + let mut tiles = Vec::new(); + for ty in 0..td { + for tx in 0..ta { + let mut t = vec![0u8; tw * th]; + for y in 0..th { + for x in 0..tw { + let (sx, sy) = (tx * tw + x, ty * th + y); + if sx < w && sy < h { + t[y * tw + x] = px[sy * w + sx]; + } + } + } + tiles.push(t); + } + } + let mut ifd = IfdBuilder::new(); + ifd.set(tags::IMAGE_WIDTH, Value::Short(vec![w as u16])); + ifd.set(tags::IMAGE_LENGTH, Value::Short(vec![h as u16])); + ifd.set_image(ImageData::Tiles { tile_width: tw as u32, tile_height: th as u32, tiles }); + let bytes = TiffWriter::new(ByteOrder::Big, false).write(&[ifd]).unwrap(); + let t = Tiff::parse(&bytes).unwrap(); + let info = t.ifds[0].image().unwrap(); + assert_eq!(info.grid(), (2, 2)); + let chunks = info.chunks(bytes.len() as u64); + assert_eq!(chunks.len(), 4); + let mut out = vec![0u8; w * h]; + for c in &chunks { + let b = chunk_bytes(&bytes, c).unwrap(); + for y in 0..c.height as usize { + for x in 0..c.width as usize { + let (sx, sy) = (c.x as usize + x, c.y as usize + y); + if sx < w && sy < h { + out[sy * w + sx] = b[y * tw + x]; + } + } + } + } + assert_eq!(out, px); +} + +#[test] +fn missing_byte_counts_are_inferred() { + let mut ifd = IfdBuilder::new(); + ifd.set(tags::IMAGE_WIDTH, Value::Long(vec![4])); + ifd.set(tags::IMAGE_LENGTH, Value::Long(vec![2])); + ifd.set_image(ImageData::Strips { rows_per_strip: 1, strips: vec![vec![1; 4], vec![2; 4]] }); + let bytes = TiffWriter::default().write(&[ifd]).unwrap(); + let t = Tiff::parse(&bytes).unwrap(); + let mut info = t.ifds[0].image().unwrap(); + info.byte_counts.clear(); + let c = info.chunks(bytes.len() as u64); + assert_eq!(c[0].len, 4); + assert_eq!(&chunk_bytes(&bytes, &c[0]).unwrap()[..4], &[1; 4]); + assert!(c[1].len >= 4); +} + +#[test] +fn bad_headers() { + assert_eq!(Tiff::parse(b""), Err(TiffError::NotTiff)); + assert_eq!(Tiff::parse(b"XX*\0\0\0\0\0"), Err(TiffError::NotTiff)); + assert_eq!(Tiff::parse(b"II\x2b\0\x04\0\0\0"), Err(TiffError::NotTiff)); + assert!(Tiff::parse(b"II*\0").is_err()); + assert!(Tiff::parse(b"II*\0\x08\0\0\0").is_err()); + assert!(Tiff::parse(b"II*\0\x08\0\0\0\0\0").is_err()); + assert!(Tiff::sniff(b"MM\0*\0\0\0\x08").is_some()); +} + +#[test] +fn ifd_loops_are_detected() { + // IFD0 at 8 whose next pointer points back to itself, and whose SubIFDs point to itself too. + let mut b = b"II*\0\x08\0\0\0".to_vec(); + b.extend_from_slice(&1u16.to_le_bytes()); + b.extend_from_slice(&330u16.to_le_bytes()); + b.extend_from_slice(&4u16.to_le_bytes()); + b.extend_from_slice(&1u32.to_le_bytes()); + b.extend_from_slice(&8u32.to_le_bytes()); + b.extend_from_slice(&8u32.to_le_bytes()); + let t = Tiff::parse(&b).unwrap(); + assert_eq!(t.ifds.len(), 1); + assert!(t.ifds[0].sub_ifds.is_empty()); +} + +#[test] +fn out_of_range_entries_are_skipped() { + let mut ifd = IfdBuilder::new(); + ifd.set(1, Value::Long(vec![1, 2, 3])); + ifd.set(2, Value::Short(vec![5])); + let mut bytes = TiffWriter::default().write(&[ifd]).unwrap(); + let t = Tiff::parse(&bytes).unwrap(); + let e = t.ifds[0].get(1).unwrap().clone(); + // point tag 1's value offset past EOF + let ifd_off = t.ifds[0].offset as usize; + let field = ifd_off + 2 + 8; + assert_eq!(u32::from_le_bytes(bytes[field..field + 4].try_into().unwrap()) as u64, e.offset); + bytes[field..field + 4].copy_from_slice(&0xffff_fff0u32.to_le_bytes()); + let t = Tiff::parse(&bytes).unwrap(); + assert!(t.ifds[0].get(1).is_none()); + assert_eq!(t.ifds[0].u32(2), Some(5)); +} + +#[test] +fn value_budget_limits_allocation() { + // 50 entries all pointing at the same 1000-byte blob. + let mut ifd = IfdBuilder::new(); + ifd.set(1, Value::Undefined(vec![7; 1000])); + let bytes = TiffWriter::default().write(&[ifd]).unwrap(); + let t = Tiff::parse(&bytes).unwrap(); + let blob = t.ifds[0].get(1).unwrap().offset as u32; + let mut b = b"II*\0".to_vec(); + b.extend_from_slice(&(bytes.len() as u32).to_le_bytes()); + let mut file = bytes.clone(); + file[..8].copy_from_slice(&b); + file.extend_from_slice(&50u16.to_le_bytes()); + for i in 0..50u16 { + file.extend_from_slice(&i.to_le_bytes()); + file.extend_from_slice(&7u16.to_le_bytes()); + file.extend_from_slice(&1000u32.to_le_bytes()); + file.extend_from_slice(&blob.to_le_bytes()); + } + file.extend_from_slice(&0u32.to_le_bytes()); + let opts = ParseOptions { value_budget: Some(10_000), ..Default::default() }; + let t = Tiff::parse_with(&file, &opts).unwrap(); + assert_eq!(t.ifds[0].entries.len(), 10); + let t = Tiff::parse(&file).unwrap(); + assert_eq!(t.ifds[0].entries.len(), 50); +} + +#[test] +fn parse_ifd_with_base() { + // an IFD blob embedded at 100 whose offsets are relative to 100 + let mut blob = Vec::new(); + let o = ByteOrder::Big; + o.put_u16(&mut blob, 1); + o.put_u16(&mut blob, 5); + o.put_u16(&mut blob, 4); + o.put_u32(&mut blob, 3); + o.put_u32(&mut blob, 18); + o.put_u32(&mut blob, 0); + for v in [10u32, 20, 30] { + o.put_u32(&mut blob, v); + } + let mut data = vec![0u8; 100]; + data.extend(blob); + let (ifd, next) = parse_ifd_at(&data, 100, o, 100, false, &ParseOptions::default()).unwrap(); + assert_eq!(next, 0); + assert_eq!(ifd.u64s(5).unwrap(), vec![10, 20, 30]); + assert_eq!(ifd.get(5).unwrap().offset, 118); +} + +#[test] +fn rationals() { + assert_eq!(rational(2.8), (28, 10)); + assert_eq!(rational(0.004), (4, 1000)); + assert_eq!(rational(-1.0), (0, 1)); + assert_eq!(srational(-0.333), (-333, 1000)); + assert_eq!(srational(7.0), (7, 1)); + let (n, d) = rational(1.0 / 3.0); + assert!((n as f64 / d as f64 - 1.0 / 3.0).abs() < 1e-6); +} + +fn sample_file() -> Vec { + let mut exif = IfdBuilder::new(); + exif.set(tags::F_NUMBER, Value::Rational(vec![(4, 1)])); + exif.set(tags::MAKER_NOTE, Value::Undefined(b"Nikon\0\x02\x10\0\0II*\0\x08\0\0\0\x01\0\x01\0\x03\0\x01\0\0\0\x05\0\0\0\0\0\0\0".to_vec())); + let mut ifd0 = IfdBuilder::new(); + ifd0.set(tags::IMAGE_WIDTH, Value::Long(vec![8])); + ifd0.set(tags::IMAGE_LENGTH, Value::Long(vec![4])); + ifd0.set(tags::MAKE, Value::Ascii("NIKON".into())); + ifd0.set_child(tags::EXIF_IFD, exif); + ifd0.set_image(ImageData::Strips { rows_per_strip: 2, strips: vec![vec![1; 16], vec![2; 16]] }); + ifd0.add_sub_ifd(IfdBuilder::new().with(tags::IMAGE_WIDTH, Value::Long(vec![2]))); + TiffWriter::default().write(&[ifd0, IfdBuilder::new().with(1, Value::Short(vec![1]))]).unwrap() +} + +fn exercise(t: &Tiff, data: &[u8]) { + for ifd in t.all_ifds() { + if let Ok(info) = ifd.image() { + for c in info.chunks(data.len() as u64).iter().take(64) { + let _ = chunk_bytes(data, c); + } + } + for e in &ifd.entries { + let _ = e.value.to_f64_vec(); + let _ = e.value.as_str(); + } + } + if let Some(e) = t.exif().and_then(|x| x.get(tags::MAKER_NOTE)) { + let _ = lightcraft_tiff::makernote::parse_makernote(data, e.offset, e.count() as u64, t.order, "NIKON"); + } +} + +proptest! { + #![proptest_config(ProptestConfig { cases: 2000, .. ProptestConfig::default() })] + + #[test] + fn random_bytes_never_panic(mut data in proptest::collection::vec(any::(), 0..512), hdr in 0usize..4) { + let heads: [&[u8]; 4] = [b"II*\0", b"MM\0*", b"II+\0\x08\0\0\0", b"MM\0+\0\x08\0\0"]; + let h = heads[hdr]; + if data.len() >= h.len() { + data[..h.len()].copy_from_slice(h); + } + if let Ok(t) = Tiff::parse(&data) { + exercise(&t, &data); + } + } + + #[test] + fn mutated_files_never_panic(flips in proptest::collection::vec((any::(), any::()), 1..16), cut in any::()) { + let mut data = sample_file(); + for (i, v) in flips { + let n = data.len(); + data[i % n] = v; + } + let keep = if cut % 4 == 0 { cut % data.len() } else { data.len() }; + let data = &data[..keep]; + if let Ok(t) = Tiff::parse(data) { + exercise(&t, data); + } + } + + #[test] + fn random_short_arrays_roundtrip(v in proptest::collection::vec(any::(), 1..300), big in any::(), le in any::()) { + let order = if le { ByteOrder::Little } else { ByteOrder::Big }; + let ifd = IfdBuilder::new().with(500, Value::Short(v.clone())).with(501, Value::Double(v.iter().map(|&x| x as f64 / 3.0).collect())); + let bytes = TiffWriter::new(order, big).write(&[ifd]).unwrap(); + let t = Tiff::parse(&bytes).unwrap(); + prop_assert_eq!(t.ifds[0].value(500), Some(&Value::Short(v.clone()))); + prop_assert_eq!(t.ifds[0].f64s(501).unwrap().len(), v.len()); + } +}