From df4e42eecce148e3257690519dbf95768523e524 Mon Sep 17 00:00:00 2001 From: Aurelia Molzer <5550310+197g@users.noreply.github.com> Date: Tue, 8 Jul 2025 04:59:37 +0200 Subject: [PATCH] Add a dedicated Directory type (#277) --- src/decoder/ifd.rs | 6 ++ src/decoder/image.rs | 18 +++-- src/decoder/mod.rs | 68 ++++++++-------- src/decoder/tag_reader.rs | 6 +- src/directory.rs | 161 ++++++++++++++++++++++++++++++++++++++ src/lib.rs | 2 + src/tags.rs | 21 ++++- 7 files changed, 239 insertions(+), 43 deletions(-) create mode 100644 src/directory.rs diff --git a/src/decoder/ifd.rs b/src/decoder/ifd.rs index 6aefabc..455a3e8 100644 --- a/src/decoder/ifd.rs +++ b/src/decoder/ifd.rs @@ -333,6 +333,10 @@ impl Entry { } } + pub fn count(&self) -> u64 { + self.count + } + /// Returns a mem_reader for the offset/value field fn r(&self, byte_order: ByteOrder) -> EndianReader>> { EndianReader::new(io::Cursor::new(self.offset.to_vec()), byte_order) @@ -668,4 +672,6 @@ fn offset_to_sbytes(n: usize, entry: &Entry) -> TiffResult { } /// Type representing an Image File Directory +#[doc(hidden)] +#[deprecated = "Use struct `tiff::Directory` instead which contains all fields relevant to an Image File Directory, including the offset to the next directory"] pub type Directory = HashMap; diff --git a/src/decoder/image.rs b/src/decoder/image.rs index 4484642..777d5eb 100644 --- a/src/decoder/image.rs +++ b/src/decoder/image.rs @@ -1,4 +1,4 @@ -use super::ifd::{Directory, Value}; +use super::ifd::Value; use super::stream::{ByteOrder, PackBitsReader}; use super::tag_reader::TagReader; use super::{predict_f16, predict_f32, predict_f64, Limits}; @@ -6,7 +6,9 @@ use super::{stream::EndianReader, ChunkType}; use crate::tags::{ CompressionMethod, PhotometricInterpretation, PlanarConfiguration, Predictor, SampleFormat, Tag, }; -use crate::{ColorType, TiffError, TiffFormatError, TiffResult, TiffUnsupportedError, UsageError}; +use crate::{ + ColorType, Directory, TiffError, TiffFormatError, TiffResult, TiffUnsupportedError, UsageError, +}; use std::io::{self, Cursor, Read, Seek}; use std::sync::Arc; @@ -115,7 +117,7 @@ impl Image { }; let jpeg_tables = if compression_method == CompressionMethod::ModernJPEG - && ifd.contains_key(&Tag::JPEGTables) + && ifd.contains(Tag::JPEGTables) { let vec = tag_reader .find_tag(Tag::JPEGTables)? @@ -210,10 +212,10 @@ impl Image { let strip_decoder; let tile_attributes; match ( - ifd.contains_key(&Tag::StripByteCounts), - ifd.contains_key(&Tag::StripOffsets), - ifd.contains_key(&Tag::TileByteCounts), - ifd.contains_key(&Tag::TileOffsets), + ifd.contains(Tag::StripByteCounts), + ifd.contains(Tag::StripOffsets), + ifd.contains(Tag::TileByteCounts), + ifd.contains(Tag::TileOffsets), ) { (true, true, false, false) => { chunk_type = ChunkType::Strip; @@ -676,7 +678,7 @@ impl Image { } } } else { - for (i, row) in buf + for (_i, row) in buf .chunks_mut(output_row_stride) .take(data_dims.1 as usize) .enumerate() diff --git a/src/decoder/mod.rs b/src/decoder/mod.rs index 8e5fcdc..f6f4fa3 100644 --- a/src/decoder/mod.rs +++ b/src/decoder/mod.rs @@ -1,16 +1,16 @@ -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeMap, HashSet}; use std::io::{self, Read, Seek}; use crate::tags::{ - CompressionMethod, PhotometricInterpretation, PlanarConfiguration, Predictor, SampleFormat, - Tag, Type, + CompressionMethod, IfdPointer, PhotometricInterpretation, PlanarConfiguration, Predictor, + SampleFormat, Tag, Type, }; use crate::{ - bytecast, ColorType, TiffError, TiffFormatError, TiffResult, TiffUnsupportedError, UsageError, + bytecast, ColorType, Directory, TiffError, TiffFormatError, TiffResult, TiffUnsupportedError, + UsageError, }; use half::f16; -use self::ifd::Directory; use self::image::Image; use self::stream::{ByteOrder, EndianReader}; @@ -263,9 +263,9 @@ where reader: EndianReader, bigtiff: bool, limits: Limits, - next_ifd: Option, - ifd_offsets: Vec, - seen_ifds: HashSet, + next_ifd: Option, + ifd_offsets: Vec, + seen_ifds: HashSet, image: Image, } @@ -487,11 +487,13 @@ impl Decoder { )) } }; + let next_ifd = if bigtiff { Some(reader.read_u64()?) } else { Some(u64::from(reader.read_u32()?)) - }; + } + .map(IfdPointer); let mut seen_ifds = HashSet::new(); seen_ifds.insert(*next_ifd.as_ref().unwrap()); @@ -557,9 +559,9 @@ impl Decoder { loop { // Follow the list until we find the one we want, or we reach the end, whichever happens first - let (_ifd, next_ifd) = self.next_ifd()?; + let ifd = self.next_ifd()?; - if next_ifd.is_none() { + if ifd.next().is_none() { break; } @@ -571,8 +573,7 @@ impl Decoder { // If the index is within the list of ifds then we can load the selected image/IFD if let Some(ifd_offset) = self.ifd_offsets.get(ifd_index) { - let (ifd, _next_ifd) = Self::read_ifd(&mut self.reader, self.bigtiff, *ifd_offset)?; - + let ifd = Self::read_ifd(&mut self.reader, self.bigtiff, *ifd_offset)?; self.image = Image::from_reader(&mut self.reader, ifd, &self.limits, self.bigtiff)?; Ok(()) @@ -583,20 +584,20 @@ impl Decoder { } } - fn next_ifd(&mut self) -> TiffResult<(Directory, Option)> { + fn next_ifd(&mut self) -> TiffResult { if self.next_ifd.is_none() { return Err(TiffError::FormatError( TiffFormatError::ImageFileDirectoryNotFound, )); } - let (ifd, next_ifd) = Self::read_ifd( + let ifd = Self::read_ifd( &mut self.reader, self.bigtiff, self.next_ifd.take().unwrap(), )?; - if let Some(next) = next_ifd { + if let Some(next) = ifd.next() { if !self.seen_ifds.insert(next) { return Err(TiffError::FormatError(TiffFormatError::CycleInOffsets)); } @@ -604,15 +605,14 @@ impl Decoder { self.ifd_offsets.push(next); } - Ok((ifd, next_ifd)) + Ok(ifd) } /// Reads in the next image. /// If there is no further image in the TIFF file a format error is returned. /// To determine whether there are more images call `TIFFDecoder::more_images` instead. pub fn next_image(&mut self) -> TiffResult<()> { - let (ifd, _next_ifd) = self.next_ifd()?; - + let ifd = self.next_ifd()?; self.image = Image::from_reader(&mut self.reader, ifd, &self.limits, self.bigtiff)?; Ok(()) } @@ -734,6 +734,13 @@ impl Decoder { self.reader.goto_offset(offset) } + /// Read a directory from a known offset. + /// + /// This may always modify the position of the reader. + pub fn read_directory(&mut self, ptr: IfdPointer) -> TiffResult { + Self::read_ifd(&mut self.reader, self.bigtiff, ptr) + } + /// Reads a IFD entry. // An IFD entry has four fields: // @@ -775,17 +782,18 @@ impl Decoder { fn read_ifd( reader: &mut EndianReader, bigtiff: bool, - ifd_location: u64, - ) -> TiffResult<(Directory, Option)> { - reader.goto_offset(ifd_location)?; + ifd_location: IfdPointer, + ) -> TiffResult { + reader.goto_offset(ifd_location.0)?; - let mut dir: Directory = HashMap::new(); + let mut entries: BTreeMap<_, _> = BTreeMap::new(); let num_tags = if bigtiff { reader.read_u64()? } else { reader.read_u16()?.into() }; + for _ in 0..num_tags { let (tag, entry) = match Self::read_entry(reader, bigtiff)? { Some(val) => val, @@ -793,7 +801,8 @@ impl Decoder { continue; } // Unknown data type in tag, skip }; - dir.insert(tag, entry); + + entries.insert(tag.to_u16(), entry); } let next_ifd = if bigtiff { @@ -802,18 +811,15 @@ impl Decoder { reader.read_u32()?.into() }; - let next_ifd = match next_ifd { - 0 => None, - _ => Some(next_ifd), - }; + let next_ifd = core::num::NonZeroU64::new(next_ifd); - Ok((dir, next_ifd)) + Ok(Directory { entries, next_ifd }) } /// Tries to retrieve a tag. /// Return `Ok(None)` if the tag is not present. pub fn find_tag(&mut self, tag: Tag) -> TiffResult> { - let entry = match self.image().ifd.as_ref().unwrap().get(&tag) { + let entry = match self.image().ifd.as_ref().unwrap().get(tag) { None => return Ok(None), Some(entry) => entry.clone(), }; @@ -928,7 +934,7 @@ impl Decoder { self.image.ifd.as_ref().unwrap().iter().map(|(tag, entry)| { entry .val(&self.limits, self.bigtiff, &mut self.reader) - .map(|value| (*tag, value)) + .map(|value| (tag, value)) }) } diff --git a/src/decoder/tag_reader.rs b/src/decoder/tag_reader.rs index 549fa1d..c6d340f 100644 --- a/src/decoder/tag_reader.rs +++ b/src/decoder/tag_reader.rs @@ -1,9 +1,9 @@ use std::io::{Read, Seek}; -use crate::tags::Tag; +use crate::{tags::Tag, Directory}; use crate::{TiffError, TiffFormatError, TiffResult}; -use super::ifd::{Directory, Value}; +use super::ifd::Value; use super::stream::EndianReader; use super::Limits; @@ -15,7 +15,7 @@ pub(crate) struct TagReader<'a, R: Read + Seek> { } impl<'a, R: Read + Seek> TagReader<'a, R> { pub(crate) fn find_tag(&mut self, tag: Tag) -> TiffResult> { - Ok(match self.ifd.get(&tag) { + Ok(match self.ifd.get(tag) { Some(entry) => Some(entry.clone().val(self.limits, self.bigtiff, self.reader)?), None => None, }) diff --git a/src/directory.rs b/src/directory.rs new file mode 100644 index 0000000..1cfbcc8 --- /dev/null +++ b/src/directory.rs @@ -0,0 +1,161 @@ +use core::fmt; +use std::{collections::BTreeMap, num::NonZeroU64}; + +use crate::{ + decoder::ifd::Entry, + tags::{IfdPointer, Tag}, +}; + +/// An Image File Directory (IFD). +#[doc(alias = "IFD")] +pub struct Directory { + /// There are at most `u16::MAX` entries in any single directory, the count is stored as a + /// 2-byte value. The order in the file is implied to be ascending by tag value (the decoder + /// does not mind unordered entries). + pub(crate) entries: BTreeMap, + pub(crate) next_ifd: Option, +} + +impl Directory { + pub fn empty(next: IfdPointer) -> Self { + Directory { + entries: BTreeMap::new(), + next_ifd: NonZeroU64::new(next.0), + } + } + + /// Retrieve the value associated with a tag. + pub fn get(&self, tag: Tag) -> Option<&Entry> { + self.entries.get(&tag.to_u16()) + } + + /// Check if the directory contains a specified tag. + pub fn contains(&self, tag: Tag) -> bool { + self.entries.contains_key(&tag.to_u16()) + } + + /// Iterate over all known and unknown tags in this directory. + pub fn iter(&self) -> impl Iterator + '_ { + self.entries + .iter() + .map(|(k, v)| (Tag::from_u16_exhaustive(*k), v)) + } + + /// Insert additional entries into the directory. + /// + /// Note that a directory can contain at most `u16::MAX` values. There may be one entry that + /// does not fit into the directory. This entry is silently ignored (please check [`Self::len`] + /// to detect the condition). Providing a tag multiple times or a tag that already exists + /// within this directory overwrites the entry. + pub fn extend(&mut self, iter: impl IntoIterator) { + // Code size conscious extension, avoid monomorphic extensions with the assumption of these + // not being performance sensitive in practice. (Maybe we have a polymorphic interface for + // the crate usage in the future. + self.extend_inner(iter.into_iter().by_ref()) + } + + /// Get the length as a 2-byte integer. + /// + /// This is *almost* naturally bounded by the tag type being a `u16` itself. The + /// bounds are upheld when extending the iteration, i.e. this always corresponds to the + /// underlying data. + pub fn len(&self) -> u16 { + // The keys are `u16`. Since IFDs are required to have at least one entry this would have + // been a trivial thing to do in the specification by storing it minus one but alas. + self.entries.len() as u16 + } + + /// Get the pointer to the next IFD, if it was defined. + pub fn next(&self) -> Option { + self.next_ifd.map(|n| IfdPointer(n.get())) + } + + fn extend_inner(&mut self, iter: &mut dyn Iterator) { + for (tag, entry) in iter { + let is_full = self.entries.len() == u16::MAX as usize; + // If the tag is already present, it will be overwritten. + let map_entry = self.entries.entry(tag.to_u16()); + + if is_full { + // Only allowed to modify if the entry is already present. + map_entry.and_modify(|place| *place = entry); + } else { + match map_entry { + std::collections::btree_map::Entry::Vacant(vacant_entry) => { + vacant_entry.insert(entry); + } + std::collections::btree_map::Entry::Occupied(mut occupied_entry) => { + occupied_entry.insert(entry); + } + } + } + } + } +} + +impl fmt::Debug for Directory { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Directory") + .field( + "entries", + &self.entries.iter().map(|(k, v)| (Tag::from_u16(*k), v)), + ) + .field("next_ifd", &self.next_ifd) + .finish() + } +} + +#[cfg(test)] +mod tests { + use super::{Directory, IfdPointer}; + use crate::{decoder::ifd::Entry, tags::Tag}; + + #[test] + fn directory_upholds_len_invariant() { + let mut dir = Directory::empty(IfdPointer(0)); + assert_eq!(dir.len(), 0); + + let bogus_entry = Entry::new_u64(crate::tags::Type::BYTE, 0x0, [0; 8]); + dir.extend((0..=u16::MAX).map(|i| { + let tag = Tag::Unknown(i); + let entry = bogus_entry.clone(); + (tag, entry) + })); + + assert_eq!(dir.len(), u16::MAX); + + // Ensure the other tags are still writable. + dir.extend([( + Tag::Unknown(0), + Entry::new_u64(crate::tags::Type::BYTE, 0x42, [0; 8]), + )]); + + assert_eq!( + dir.get(Tag::Unknown(0)) + .expect("tag 0 should be present after overwriting") + .count(), + 0x42 + ); + } + + #[test] + fn directory_multiple_entries() { + let mut dir = Directory::empty(IfdPointer(0)); + assert_eq!(dir.len(), 0); + + dir.extend((0..=u16::MAX).map(|i| { + let tag = Tag::Unknown(1); + let entry = Entry::new_u64(crate::tags::Type::BYTE, i.into(), [0; 8]); + (tag, entry) + })); + + assert_eq!(dir.len(), 1, "Only one tag was ever modified"); + + assert_eq!( + dir.get(Tag::Unknown(1)) + .expect("tag 1 should be present after this chain") + .count(), + u16::MAX.into() + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index d199461..0e0c5bc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,10 +8,12 @@ mod bytecast; pub mod decoder; +mod directory; pub mod encoder; mod error; pub mod tags; +pub use self::directory::Directory; pub use self::error::{TiffError, TiffFormatError, TiffResult, TiffUnsupportedError, UsageError}; /// An enumeration over supported color types and their bit depths diff --git a/src/tags.rs b/src/tags.rs index 81169ca..05ca1f7 100644 --- a/src/tags.rs +++ b/src/tags.rs @@ -78,7 +78,6 @@ pub enum Tag(u16) unknown("A private or extension tag") { // palette-color images (PhotometricInterpretation 3) ColorMap = 320, // TODO add support Compression = 259, // TODO add support for 2 and 32773 - Copyright = 33_432, DateTime = 306, ExtraSamples = 338, // TODO add support FillOrder = 266, // TODO add support @@ -124,13 +123,33 @@ pub enum Tag(u16) unknown("A private or extension tag") { ModelPixelScaleTag = 33550, // (SoftDesk) ModelTransformationTag = 34264, // (JPL Carto Group) ModelTiepointTag = 33922, // (Intergraph) + // + // *Do note its typo in the Decimal id* + Copyright = 33_432, + // + ExifDirectory = 0x8769, + // + GpsDirectory = 0x8825, GeoKeyDirectoryTag = 34735, // (SPOT) GeoDoubleParamsTag = 34736, // (SPOT) GeoAsciiParamsTag = 34737, // (SPOT) + ExifVersion = 0x9000, GdalNodata = 42113, // Contains areas with missing data } } +/// Identifies the offset of an IFD. +/// +/// This is represented as a 64-bit integer but only BigTIFF can utilize the bits. It is encoded +/// as 32-bit unsigned value ([`Type::LONG`]) in regular TIFF files and as 64-bit unsigned value +/// ([`Type::IFD8`]) in BigTIFF files. +#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)] +// We could be using `NonZeroU64` here but I find that this complicates semantics. This type +// represents the integer value stored as a value in a tag. The semantics of treating `0` as an end +// marker are imposed by the IFD. (It's unclear if Pointer tags such as Exif would allow `0` but in +// practice it just returns garbage and the validity does not matter greatly to us). +pub struct IfdPointer(pub u64); + tags! { /// The type of an IFD entry (a 2 byte field). pub enum Type(u16) {