mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-02 18:13:39 +00:00
* TIFF: BigTIFF, every IFD and SubIFD, banded parallel decode (FILE-215-8) Large and multi-page TIFFs now open the way Photoshop opens them, quickly and without a second full-size buffer, and any directory of the file can be opened on request. * photocraft-codecs `tiff_ifd`: a bounds-checked directory walker for classic TIFF and BigTIFF (LONG8 / IFD8, both byte orders). It follows the whole IFD chain and the SubIFD trees (tag 330) with a seen-set, a depth limit and a 10 000-directory cap, and checks every value range against the file. `tiff_info` lists the pages (size, kind: page / reduced-resolution / mask, parent, storage); `default_page` is the first full-resolution page of the main chain, so a leading thumbnail is skipped. `decode_tiff_page`, `tiff_orientation`, `tiff_page_orientation`. * Banded decode: the `tiff` crate reads the chosen directory through a patched byte view (header -> that IFD, its next pointer -> 0, WhiteIsZero / palette -> BlackIsZero), so SubIFDs and cyclic chains decode. The image is allocated once (fallibly, zeroed in parallel) and each strip or row of tiles, across every plane, is decoded straight into it, in parallel on native targets, with one capped chunk-sized scratch buffer per worker. New: planar configuration 2 (was an error), palette images (as RGB), 64-bit integers, associated alpha made straight, non-alpha extra samples dropped. Image data cut off by the end of the file decodes with a `Truncated` warning. Before the crate sees a file: structural tag values must lie inside it, the chunk tables must match the geometry (checked in 64 bits; the crate overflowed and panicked on `(h / rows + 1) * planes`), and a decompression-bomb guard refuses files far too small for their declared size. * WhiteIsZero and BigTIFF Orientation keep upstream #746's behaviour (#691, #693) through the new decode path; regression tests cover both. * Encode: `EncodeOptions::tiff_bigtiff` writes BigTIFF; without it, BigTIFF is chosen when the file could pass 4 GiB (and a classic write that overflows 32-bit offsets is redone as BigTIFF). * photocraft-io: `import_tiff_page(name, bytes, page)`; the TIFF branch of import goes through it. * Tests: codecs `tiff_pages.rs` on hand-built files (`tests/common/tiffgen.rs`): BigTIFF LE/BE at 8/16/32f, padded tiles and planar strips/tiles, pages and SubIFDs, cycles, the IFD cap, truncated and past-EOF strips, huge dimensions / counts / tiles, WhiteIsZero, palette, wide formats, BigTIFF write, and a cut / bit-flip / random-mutation zoo (an ignored long run did 200 000 mutations without a panic); io `tiff_pages.rs`. The `decode_tiff` fuzz target walks every page. `tiff_bench.rs` (ignored) times a 30 MP decode against the previous path. * 30 MP RGB (release, 16 threads, same run): Deflate 344 -> 32 ms, LZW 428 -> 43 ms, 16-bit Deflate 497 -> 46 ms, uncompressed 15 -> 10 ms, 256 px tiles 36 -> 10 ms, planar error -> 12 ms. * Scorecard FILE-215-8 done; README and roadmap updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * rustfmt the TIFF reader and tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: leon <claude.udder842@passmail.net> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
+24
-6
@@ -88,10 +88,12 @@ the same.
|
||||
containers that can hold more frames. The decoded image then carries a
|
||||
`DecodeWarning::MoreFrames` / `MorePages` in `Image::warnings`, with the total when the file
|
||||
states it (frames and TIFF directories are counted without decoding them; reduced-resolution
|
||||
TIFF directories and transparency masks are not pages).
|
||||
TIFF directories and transparency masks are not pages). Any TIFF page can still be opened
|
||||
with `decode_tiff_page` (and `photocraft_io::import_tiff_page`).
|
||||
* **EXIF in TIFF** is stored as a sub-IFD rather than a blob, so it is not preserved yet.
|
||||
`caps.exif = false` for TIFF, and a warning is raised.
|
||||
* **Orientation** (EXIF tag 274 in JPEG, PNG `eXIf` and WebP; the IFD0 tag in TIFF) is applied on
|
||||
* **Orientation** (EXIF tag 274 in JPEG, PNG `eXIf` and WebP; the decoded page's tag in TIFF and
|
||||
BigTIFF) 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`, which change only the tag's value, kept in its own type), so upright pixels
|
||||
@@ -112,10 +114,26 @@ the same.
|
||||
* EXIF and XMP must each fit in one 64 KiB APP1 segment, otherwise the encoder returns an error.
|
||||
Extended XMP is not written.
|
||||
* **TIFF decode**
|
||||
* Supports 1, 2 and 4-bit gray (best effort, not yet covered by tests), 8/16/32-bit integers (32-bit is reduced to U16), F16, F32 and
|
||||
F64 (converted to F32), and WhiteIsZero.
|
||||
* Palette and YCbCr TIFFs, and compressions that the `tiff` crate lacks (CCITT, JPEG-in-TIFF),
|
||||
return `Unsupported`.
|
||||
* Classic TIFF and BigTIFF (version 43, 8-byte offsets), both byte orders, strips or tiles,
|
||||
planar configuration 1 or 2.
|
||||
* Supports 1, 2 and 4-bit gray, 8/16/32/64-bit integers (32 and 64-bit are reduced to U16),
|
||||
F16, F32 and F64 (converted to F32), WhiteIsZero (#691: the tones are no longer inverted),
|
||||
palette images (opened as RGB), associated alpha (made straight) and extra samples (dropped
|
||||
unless the first is alpha).
|
||||
* Every directory is reachable: `tiff_info` lists the main IFD chain and its SubIFDs (tag 330)
|
||||
with each page's size, kind (page, reduced-resolution copy, mask) and storage, walked with a
|
||||
seen-set, a depth limit and a 10 000-directory cap; `decode_tiff_page` opens any of them.
|
||||
`decode` opens the first full-resolution page, like Photoshop (thumbnails are skipped).
|
||||
* Banded decoding: the image is allocated once at its final size and each strip or row of
|
||||
tiles is decoded straight into it, in parallel on native targets, with at most one
|
||||
strip/tile-sized scratch buffer per worker (30 MP RGB8: Deflate 345 → 32 ms, LZW 428 → 43
|
||||
ms in release). Image data cut off by the end of the file decodes with
|
||||
`DecodeWarning::Truncated`; a file far too small for its declared size (more than any
|
||||
compression could expand) is refused before allocating.
|
||||
* YCbCr, CIE Lab, signed integers and compressions that the `tiff` crate lacks (CCITT,
|
||||
JPEG-in-TIFF) return `Unsupported`.
|
||||
* **TIFF encode** writes classic TIFF, or BigTIFF when `EncodeOptions::tiff_bigtiff` is set or
|
||||
the file could pass 4 GiB.
|
||||
* **EXR**
|
||||
* Reads the first valid layer at full resolution, from its data window.
|
||||
* Channel names are matched by suffix, so `layer.R` counts as `R`.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#![no_main]
|
||||
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use photocraft_codecs::{decode_as_with, DecodeOptions, Format, Limits};
|
||||
use photocraft_codecs::{decode_as_with, decode_tiff_page, exif_orientation, tiff_info, tiff_orientation, DecodeOptions, Format, Limits};
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
let opts = DecodeOptions {
|
||||
@@ -9,4 +9,12 @@ fuzz_target!(|data: &[u8]| {
|
||||
..Default::default()
|
||||
};
|
||||
let _ = decode_as_with(Format::Tiff, data, &opts);
|
||||
let _ = exif_orientation(data);
|
||||
let _ = tiff_orientation(data);
|
||||
// Every directory of the chain and its SubIFDs (BigTIFF too), not only the default page.
|
||||
if let Ok(info) = tiff_info(data) {
|
||||
for page in 0..info.pages.len().min(16) {
|
||||
let _ = decode_tiff_page(data, page, &opts);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ pub(crate) mod jpeg;
|
||||
pub(crate) mod png;
|
||||
pub(crate) mod pnm;
|
||||
pub(crate) mod tiff;
|
||||
pub(crate) mod tiff_ifd;
|
||||
pub(crate) mod via_image;
|
||||
pub(crate) mod vp8;
|
||||
pub(crate) mod webp;
|
||||
|
||||
+766
-171
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,400 @@
|
||||
//! A bounds-checked reader for the TIFF directory structure: classic TIFF (version 42, 4-byte
|
||||
//! offsets) and BigTIFF (version 43, 8-byte offsets and the LONG8 / SLONG8 / IFD8 types), in
|
||||
//! either byte order.
|
||||
//!
|
||||
//! Every offset and count comes from the file and is untrusted: reads go through `get` with
|
||||
//! checked arithmetic, directory tables must lie wholly inside the file, the IFD chain and the
|
||||
//! SubIFD trees (tag 330) are walked with a seen-set, a depth limit and a cap on the number of
|
||||
//! directories, and nothing is allocated in proportion to a declared count before the bytes
|
||||
//! behind it are known to exist.
|
||||
//!
|
||||
//! This module lists the pages of a file ([`TiffInfo`]) and reads the metadata of a directory
|
||||
//! without decoding pixels; `tiff.rs` decodes the pixels of one directory.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::error::CodecError;
|
||||
use crate::format::Format;
|
||||
|
||||
/// At most this many directories are listed (main chain and SubIFDs together).
|
||||
pub(crate) const MAX_IFDS: usize = 10_000;
|
||||
/// Entries per directory: the classic format's own limit (a `u16` count), applied to BigTIFF too.
|
||||
const MAX_ENTRIES: u64 = u16::MAX as u64;
|
||||
/// SubIFD nesting depth that is followed (real files use one level).
|
||||
const MAX_SUB_DEPTH: usize = 4;
|
||||
/// SubIFD offsets followed per directory.
|
||||
const MAX_SUBIFDS: u64 = 256;
|
||||
|
||||
pub(crate) const TAG_NEW_SUBFILE_TYPE: u16 = 254;
|
||||
pub(crate) const TAG_SUBFILE_TYPE: u16 = 255;
|
||||
pub(crate) const TAG_WIDTH: u16 = 256;
|
||||
pub(crate) const TAG_HEIGHT: u16 = 257;
|
||||
pub(crate) const TAG_BITS: u16 = 258;
|
||||
pub(crate) const TAG_COMPRESSION: u16 = 259;
|
||||
pub(crate) const TAG_PHOTOMETRIC: u16 = 262;
|
||||
pub(crate) const TAG_ORIENTATION: u16 = 274;
|
||||
pub(crate) const TAG_SAMPLES: u16 = 277;
|
||||
pub(crate) const TAG_ROWS_PER_STRIP: u16 = 278;
|
||||
pub(crate) const TAG_PLANAR: u16 = 284;
|
||||
pub(crate) const TAG_COLOR_MAP: u16 = 320;
|
||||
pub(crate) const TAG_TILE_WIDTH: u16 = 322;
|
||||
pub(crate) const TAG_TILE_LENGTH: u16 = 323;
|
||||
pub(crate) const TAG_TILE_OFFSETS: u16 = 324;
|
||||
pub(crate) const TAG_SUB_IFDS: u16 = 330;
|
||||
pub(crate) const TAG_EXTRA_SAMPLES: u16 = 338;
|
||||
pub(crate) const TAG_SAMPLE_FORMAT: u16 = 339;
|
||||
|
||||
/// Tags the `tiff` crate reads to set up decoding. Their values must lie inside the file, so a
|
||||
/// hostile count can neither make it allocate nor read past the end.
|
||||
pub(crate) const STRUCTURAL_TAGS: &[u16] = &[256, 257, 258, 259, 262, 273, 277, 278, 279, 284, 317, 322, 323, 324, 325, 338, 339, 347, 530];
|
||||
|
||||
/// Bytes per value of a TIFF field type; `None` for unknown types (whose entries are skipped).
|
||||
fn type_size(ty: u16) -> Option<u64> {
|
||||
Some(match ty {
|
||||
1 | 2 | 6 | 7 => 1, // BYTE, ASCII, SBYTE, UNDEFINED
|
||||
3 | 8 => 2, // SHORT, SSHORT
|
||||
4 | 9 | 11 | 13 => 4, // LONG, SLONG, FLOAT, IFD
|
||||
5 | 10 | 12 | 16 | 17 | 18 => 8, // RATIONAL, SRATIONAL, DOUBLE, LONG8, SLONG8, IFD8
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// One directory entry, located but not read.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct Entry {
|
||||
pub tag: u16,
|
||||
pub ty: u16,
|
||||
pub count: u64,
|
||||
/// Position of the value: inside the entry when it fits, else the stored offset.
|
||||
pub value_at: u64,
|
||||
}
|
||||
|
||||
/// One directory: its entries in file order and the next-IFD offset (0 at the end of a chain).
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Ifd {
|
||||
pub at: u64,
|
||||
pub entries: Vec<Entry>,
|
||||
pub next: u64,
|
||||
/// Position of the next-IFD pointer field.
|
||||
pub next_at: u64,
|
||||
}
|
||||
|
||||
impl Ifd {
|
||||
/// The first entry with this tag.
|
||||
pub fn get(&self, tag: u16) -> Option<&Entry> {
|
||||
self.entries.iter().find(|e| e.tag == tag)
|
||||
}
|
||||
}
|
||||
|
||||
/// A TIFF or BigTIFF file's header and bytes.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct File<'a> {
|
||||
pub b: &'a [u8],
|
||||
pub le: bool,
|
||||
pub big: bool,
|
||||
pub first_ifd: u64,
|
||||
}
|
||||
|
||||
impl<'a> File<'a> {
|
||||
/// The header, or `None` when this is not a (Big)TIFF.
|
||||
pub fn parse(b: &'a [u8]) -> Option<Self> {
|
||||
let le = match b.get(0..2)? {
|
||||
b"II" => true,
|
||||
b"MM" => false,
|
||||
_ => return None,
|
||||
};
|
||||
let mut f = File { b, le, big: false, first_ifd: 0 };
|
||||
match f.u16_at(2)? {
|
||||
42 => f.first_ifd = u64::from(f.u32_at(4)?),
|
||||
43 => {
|
||||
// Bytesize of offsets (always 8) and a reserved zero.
|
||||
if f.u16_at(4)? != 8 || f.u16_at(6)? != 0 {
|
||||
return None;
|
||||
}
|
||||
f.big = true;
|
||||
f.first_ifd = f.u64_at(8)?;
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
Some(f)
|
||||
}
|
||||
|
||||
/// Length of the header; no directory can start inside it.
|
||||
pub fn header_len(&self) -> u64 {
|
||||
if self.big { 16 } else { 8 }
|
||||
}
|
||||
|
||||
fn slice(&self, at: u64, len: u64) -> Option<&'a [u8]> {
|
||||
let at = usize::try_from(at).ok()?;
|
||||
let len = usize::try_from(len).ok()?;
|
||||
self.b.get(at..at.checked_add(len)?)
|
||||
}
|
||||
|
||||
pub fn u16_at(&self, at: u64) -> Option<u16> {
|
||||
let s: [u8; 2] = self.slice(at, 2)?.try_into().ok()?;
|
||||
Some(if self.le { u16::from_le_bytes(s) } else { u16::from_be_bytes(s) })
|
||||
}
|
||||
|
||||
pub fn u32_at(&self, at: u64) -> Option<u32> {
|
||||
let s: [u8; 4] = self.slice(at, 4)?.try_into().ok()?;
|
||||
Some(if self.le { u32::from_le_bytes(s) } else { u32::from_be_bytes(s) })
|
||||
}
|
||||
|
||||
pub fn u64_at(&self, at: u64) -> Option<u64> {
|
||||
let s: [u8; 8] = self.slice(at, 8)?.try_into().ok()?;
|
||||
Some(if self.le { u64::from_le_bytes(s) } else { u64::from_be_bytes(s) })
|
||||
}
|
||||
|
||||
/// The directory at `at`: its whole table, up to and including the next-IFD pointer, must
|
||||
/// be inside the file and after the header.
|
||||
pub fn ifd(&self, at: u64) -> Option<Ifd> {
|
||||
if at < self.header_len() {
|
||||
return None;
|
||||
}
|
||||
let (n, first, entry, inline) =
|
||||
if self.big { (self.u64_at(at)?, at.checked_add(8)?, 20u64, 8u64) } else { (u64::from(self.u16_at(at)?), at.checked_add(2)?, 12, 4) };
|
||||
if n > MAX_ENTRIES {
|
||||
return None;
|
||||
}
|
||||
let next_at = first.checked_add(n.checked_mul(entry)?)?;
|
||||
let next = if self.big { self.u64_at(next_at)? } else { u64::from(self.u32_at(next_at)?) };
|
||||
let mut entries = Vec::with_capacity(usize::try_from(n).ok()?);
|
||||
for i in 0..n {
|
||||
let e = first.checked_add(i.checked_mul(entry)?)?;
|
||||
let tag = self.u16_at(e)?;
|
||||
let ty = self.u16_at(e.checked_add(2)?)?;
|
||||
let count = if self.big { self.u64_at(e.checked_add(4)?)? } else { u64::from(self.u32_at(e.checked_add(4)?)?) };
|
||||
let field = e.checked_add(if self.big { 12 } else { 8 })?;
|
||||
// Unknown types are skipped, as the specification says.
|
||||
let Some(size) = type_size(ty) else { continue };
|
||||
let fits = count.checked_mul(size).is_some_and(|len| len <= inline);
|
||||
let value_at = if fits {
|
||||
field
|
||||
} else if self.big {
|
||||
self.u64_at(field)?
|
||||
} else {
|
||||
u64::from(self.u32_at(field)?)
|
||||
};
|
||||
entries.push(Entry { tag, ty, count, value_at });
|
||||
}
|
||||
Some(Ifd { at, entries, next, next_at })
|
||||
}
|
||||
|
||||
/// The value bytes of an entry, when they are all inside the file.
|
||||
pub fn data(&self, e: &Entry) -> Option<&'a [u8]> {
|
||||
self.slice(e.value_at, e.count.checked_mul(type_size(e.ty)?)?)
|
||||
}
|
||||
|
||||
/// Value `i` of an unsigned integer entry (BYTE, SHORT, LONG, LONG8, IFD, IFD8).
|
||||
pub fn uint(&self, e: &Entry, i: u64) -> Option<u64> {
|
||||
if i >= e.count {
|
||||
return None;
|
||||
}
|
||||
let size = type_size(e.ty)?;
|
||||
let at = e.value_at.checked_add(i.checked_mul(size)?)?;
|
||||
match e.ty {
|
||||
1 => self.slice(at, 1)?.first().map(|&v| u64::from(v)),
|
||||
3 => self.u16_at(at).map(u64::from),
|
||||
4 | 13 => self.u32_at(at).map(u64::from),
|
||||
16 | 18 => self.u64_at(at),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The first value of an unsigned integer tag.
|
||||
pub fn tag_uint(&self, ifd: &Ifd, tag: u16) -> Option<u64> {
|
||||
self.uint(ifd.get(tag)?, 0)
|
||||
}
|
||||
|
||||
/// Up to `max` values of an unsigned integer tag (`None` if any of them is unreadable).
|
||||
pub fn tag_uints(&self, ifd: &Ifd, tag: u16, max: u64) -> Option<Vec<u64>> {
|
||||
let e = ifd.get(tag)?;
|
||||
(0..e.count.min(max)).map(|i| self.uint(e, i)).collect()
|
||||
}
|
||||
|
||||
/// The first value of a numeric tag as a float: RATIONAL, FLOAT, DOUBLE or an integer.
|
||||
pub fn tag_f64(&self, ifd: &Ifd, tag: u16) -> Option<f64> {
|
||||
let e = ifd.get(tag)?;
|
||||
if e.count == 0 {
|
||||
return None;
|
||||
}
|
||||
match e.ty {
|
||||
5 => {
|
||||
let (n, d) = (self.u32_at(e.value_at)?, self.u32_at(e.value_at.checked_add(4)?)?);
|
||||
(d != 0).then(|| f64::from(n) / f64::from(d))
|
||||
}
|
||||
11 => Some(f64::from(f32::from_bits(self.u32_at(e.value_at)?))),
|
||||
12 => Some(f64::from_bits(self.u64_at(e.value_at)?)),
|
||||
_ => self.uint(e, 0).map(|v| v as f64),
|
||||
}
|
||||
}
|
||||
|
||||
/// The bytes of a one-byte-per-value tag (BYTE, ASCII, SBYTE or UNDEFINED).
|
||||
pub fn tag_bytes(&self, ifd: &Ifd, tag: u16) -> Option<&'a [u8]> {
|
||||
let e = ifd.get(tag)?;
|
||||
matches!(e.ty, 1 | 2 | 6 | 7).then(|| self.data(e)).flatten()
|
||||
}
|
||||
|
||||
/// SubIFD offsets of a directory (tag 330 as LONG, IFD, LONG8 or IFD8).
|
||||
fn sub_ifds(&self, ifd: &Ifd) -> Vec<u64> {
|
||||
let Some(e) = ifd.get(TAG_SUB_IFDS) else { return Vec::new() };
|
||||
if !matches!(e.ty, 4 | 13 | 16 | 18) {
|
||||
return Vec::new();
|
||||
}
|
||||
(0..e.count.min(MAX_SUBIFDS)).map_while(|i| self.uint(e, i)).filter(|&o| o != 0).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// A directory's Orientation (1–8), with the rules of [`crate::exif_orientation`]: the first
|
||||
/// Orientation entry wins, it must be exactly one SHORT or LONG, and anything else reads as 1.
|
||||
pub(crate) fn orientation_of(f: &File<'_>, ifd: &Ifd) -> u16 {
|
||||
ifd.get(TAG_ORIENTATION)
|
||||
.filter(|e| e.count == 1 && matches!(e.ty, 3 | 4))
|
||||
.and_then(|e| f.uint(e, 0))
|
||||
.and_then(|v| u16::try_from(v).ok())
|
||||
.filter(|v| (1..=8).contains(v))
|
||||
.unwrap_or(1)
|
||||
}
|
||||
|
||||
/// What a TIFF directory holds, from `NewSubfileType` (254) or the older `SubfileType` (255).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TiffPageKind {
|
||||
/// A full-resolution image: a page of the document.
|
||||
Page,
|
||||
/// A reduced-resolution copy of another image (a thumbnail or a pyramid level).
|
||||
ReducedResolution,
|
||||
/// A transparency mask for another image.
|
||||
Mask,
|
||||
}
|
||||
|
||||
/// One image directory of a TIFF file, described without decoding its pixels.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TiffPage {
|
||||
/// Byte offset of the directory in the file.
|
||||
pub ifd_offset: u64,
|
||||
/// For a SubIFD (tag 330): the index (in [`TiffInfo::pages`]) of the directory listing it.
|
||||
/// `None` for the directories of the main IFD chain.
|
||||
pub parent: Option<usize>,
|
||||
pub kind: TiffPageKind,
|
||||
/// 0 when the directory has no readable dimensions.
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub samples_per_pixel: u16,
|
||||
pub bits_per_sample: u16,
|
||||
/// TIFF `SampleFormat`: 1 unsigned integer, 2 signed integer, 3 IEEE float.
|
||||
pub sample_format: u16,
|
||||
/// TIFF `PhotometricInterpretation`, when present.
|
||||
pub photometric: Option<u16>,
|
||||
/// TIFF `Compression` (1 = none).
|
||||
pub compression: u16,
|
||||
/// Tiles rather than strips.
|
||||
pub tiled: bool,
|
||||
/// `PlanarConfiguration` 2: each sample in its own plane.
|
||||
pub planar: bool,
|
||||
}
|
||||
|
||||
/// The directory structure of a TIFF file: its byte order, BigTIFF or not, and every image
|
||||
/// directory of the main chain and its SubIFDs.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TiffInfo {
|
||||
pub big_tiff: bool,
|
||||
pub little_endian: bool,
|
||||
/// Main-chain directories in file order, each followed by its SubIFDs (depth first).
|
||||
pub pages: Vec<TiffPage>,
|
||||
/// `false` when the walk stopped early: an unreadable directory, a cycle, or more than
|
||||
/// 10 000 directories. The pages listed up to that point are still valid.
|
||||
pub complete: bool,
|
||||
}
|
||||
|
||||
impl TiffInfo {
|
||||
/// The page opened by default, like Photoshop: the first full-resolution directory of the
|
||||
/// main chain (reduced-resolution copies and masks are skipped), else the first directory.
|
||||
pub fn default_page(&self) -> Option<usize> {
|
||||
self.pages
|
||||
.iter()
|
||||
.position(|p| p.parent.is_none() && p.kind == TiffPageKind::Page && p.width > 0 && p.height > 0)
|
||||
.or_else(|| (!self.pages.is_empty()).then_some(0))
|
||||
}
|
||||
|
||||
/// Full-resolution pages in the main chain (what Photoshop calls the pages of the file).
|
||||
pub fn page_count(&self) -> usize {
|
||||
self.pages.iter().filter(|p| p.parent.is_none() && p.kind == TiffPageKind::Page).count()
|
||||
}
|
||||
}
|
||||
|
||||
fn describe(f: &File<'_>, ifd: &Ifd, parent: Option<usize>) -> TiffPage {
|
||||
let new_kind = f.tag_uint(ifd, TAG_NEW_SUBFILE_TYPE).unwrap_or(0);
|
||||
let old_kind = f.tag_uint(ifd, TAG_SUBFILE_TYPE).unwrap_or(1);
|
||||
let kind = if new_kind & 4 != 0 {
|
||||
TiffPageKind::Mask
|
||||
} else if new_kind & 1 != 0 || old_kind == 2 {
|
||||
TiffPageKind::ReducedResolution
|
||||
} else {
|
||||
TiffPageKind::Page
|
||||
};
|
||||
let u32_of = |tag| f.tag_uint(ifd, tag).and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
|
||||
let u16_of = |tag, default| f.tag_uint(ifd, tag).and_then(|v| u16::try_from(v).ok()).unwrap_or(default);
|
||||
TiffPage {
|
||||
ifd_offset: ifd.at,
|
||||
parent,
|
||||
kind,
|
||||
width: u32_of(TAG_WIDTH),
|
||||
height: u32_of(TAG_HEIGHT),
|
||||
samples_per_pixel: u16_of(TAG_SAMPLES, 1),
|
||||
bits_per_sample: u16_of(TAG_BITS, 1),
|
||||
sample_format: u16_of(TAG_SAMPLE_FORMAT, 1),
|
||||
photometric: f.tag_uint(ifd, TAG_PHOTOMETRIC).and_then(|v| u16::try_from(v).ok()),
|
||||
compression: u16_of(TAG_COMPRESSION, 1),
|
||||
tiled: ifd.get(TAG_TILE_OFFSETS).is_some(),
|
||||
planar: f.tag_uint(ifd, TAG_PLANAR) == Some(2),
|
||||
}
|
||||
}
|
||||
|
||||
struct Walk<'a, 'f> {
|
||||
f: &'f File<'a>,
|
||||
seen: HashSet<u64>,
|
||||
pages: Vec<TiffPage>,
|
||||
complete: bool,
|
||||
}
|
||||
|
||||
impl Walk<'_, '_> {
|
||||
/// Follows a chain of directories starting at `at`, listing each with `parent` and then
|
||||
/// its SubIFDs.
|
||||
fn chain(&mut self, mut at: u64, parent: Option<usize>, depth: usize) {
|
||||
while at != 0 {
|
||||
if self.pages.len() >= MAX_IFDS || !self.seen.insert(at) {
|
||||
self.complete = false;
|
||||
return;
|
||||
}
|
||||
let Some(ifd) = self.f.ifd(at) else {
|
||||
self.complete = false;
|
||||
return;
|
||||
};
|
||||
let index = self.pages.len();
|
||||
self.pages.push(describe(self.f, &ifd, parent));
|
||||
if depth < MAX_SUB_DEPTH {
|
||||
for sub in self.f.sub_ifds(&ifd) {
|
||||
self.chain(sub, Some(index), depth + 1);
|
||||
}
|
||||
}
|
||||
at = ifd.next;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Lists the directories of a (Big)TIFF file. An error only when the bytes are not a TIFF or
|
||||
/// the first directory cannot be read; a damaged chain after it ends the list early
|
||||
/// (`complete == false`).
|
||||
pub fn tiff_info(bytes: &[u8]) -> Result<TiffInfo, CodecError> {
|
||||
let f = File::parse(bytes).ok_or_else(|| CodecError::malformed(Format::Tiff, "not a TIFF or BigTIFF header"))?;
|
||||
info_of(&f)
|
||||
}
|
||||
|
||||
pub(crate) fn info_of(f: &File<'_>) -> Result<TiffInfo, CodecError> {
|
||||
let mut w = Walk { f, seen: HashSet::new(), pages: Vec::new(), complete: true };
|
||||
w.chain(f.first_ifd, None, 0);
|
||||
if w.pages.is_empty() {
|
||||
return Err(CodecError::malformed(Format::Tiff, "the first image directory is missing or cut off"));
|
||||
}
|
||||
Ok(TiffInfo { big_tiff: f.big, little_endian: f.le, pages: w.pages, complete: w.complete })
|
||||
}
|
||||
@@ -24,6 +24,7 @@ pub mod web;
|
||||
|
||||
pub use crate::codecs::png::encode_indexed as encode_png_indexed;
|
||||
pub use crate::codecs::tiff::{PhotoshopTags as TiffPhotoshopTags, photoshop_tags as tiff_photoshop_tags, writes_little_endian as tiff_writes_little_endian};
|
||||
pub use crate::codecs::tiff_ifd::{TiffInfo, TiffPage, TiffPageKind};
|
||||
pub use crate::error::CodecError;
|
||||
pub use crate::fidelity::{FidelityWarning, fidelity_warnings, fidelity_warnings_with};
|
||||
pub use crate::format::{ASYMMETRIC_EXCEPTIONS, Format, FormatCaps, caps, detect, from_extension};
|
||||
@@ -67,19 +68,51 @@ pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Res
|
||||
Format::Heif => heif::decode(bytes, l, opts.keep_orientation),
|
||||
Format::Gif | Format::Bmp | Format::Tga | Format::Ico | Format::Qoi | Format::Hdr | Format::Avif => via_image::decode(format, bytes, l),
|
||||
}?;
|
||||
// Turn the pixels upright, like Photoshop: a TIFF records it in the decoded page's own
|
||||
// directory, the others in their EXIF block. The metadata is rewritten to Orientation = 1
|
||||
// on the way. HEIF keeps it in its container, and its decoder has already applied it.
|
||||
let o = match format {
|
||||
Format::Tiff => tiff::orientation(bytes, None),
|
||||
Format::Heif => 1,
|
||||
_ => img.meta.exif.as_deref().map_or(1, exif_orientation),
|
||||
};
|
||||
finish_decode(img, o, opts)
|
||||
}
|
||||
|
||||
/// Lists the pages (image directories) of a TIFF or BigTIFF file without decoding pixels: the
|
||||
/// main IFD chain and its SubIFDs, each with its size, kind (page, reduced-resolution copy or
|
||||
/// mask) and storage. [`TiffInfo::default_page`] is the page [`decode`] opens.
|
||||
pub fn tiff_info(bytes: &[u8]) -> Result<TiffInfo, CodecError> {
|
||||
crate::codecs::tiff_ifd::tiff_info(bytes)
|
||||
}
|
||||
|
||||
/// Decodes page `page` (an index into [`tiff_info`]'s `pages`) of a TIFF or BigTIFF file,
|
||||
/// applying that page's orientation unless `opts.keep_orientation`.
|
||||
pub fn decode_tiff_page(bytes: &[u8], page: usize, opts: &DecodeOptions) -> Result<Image, CodecError> {
|
||||
let img = tiff::decode_page(bytes, Some(page), &opts.limits)?;
|
||||
finish_decode(img, tiff::orientation(bytes, Some(page)), opts)
|
||||
}
|
||||
|
||||
/// The Orientation (1–8) of the page [`decode`] opens from a TIFF or BigTIFF file; 1 when it is
|
||||
/// absent, malformed or out of range.
|
||||
pub fn tiff_orientation(bytes: &[u8]) -> u16 {
|
||||
tiff::orientation(bytes, None)
|
||||
}
|
||||
|
||||
/// The Orientation (1–8) of page `page` (an index into [`tiff_info`]'s `pages`); 1 when it is
|
||||
/// absent, malformed or out of range, or the page does not exist.
|
||||
pub fn tiff_page_orientation(bytes: &[u8], page: usize) -> u16 {
|
||||
tiff::orientation(bytes, Some(page))
|
||||
}
|
||||
|
||||
/// The limits check and the orientation, shared by every decoder.
|
||||
fn finish_decode(img: Image, o: u16, opts: &DecodeOptions) -> Result<Image, CodecError> {
|
||||
let l = &opts.limits;
|
||||
// Final guard for decoders whose header we could not pre-inspect.
|
||||
l.check(img.width(), img.height(), img.layout(), img.sample_type())?;
|
||||
if opts.keep_orientation {
|
||||
return Ok(img);
|
||||
}
|
||||
// Turn the pixels upright, like Photoshop: a TIFF records it in its own IFD0, the others
|
||||
// in their EXIF block. The metadata is rewritten to Orientation = 1 on the way. HEIF keeps it
|
||||
// in its container, and its decoder has already applied it.
|
||||
let o = match format {
|
||||
Format::Tiff => exif_orientation(bytes),
|
||||
Format::Heif => 1,
|
||||
_ => 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) {
|
||||
|
||||
@@ -120,6 +120,9 @@ pub struct EncodeOptions {
|
||||
/// Lossy WebP quality 0..=100 (the source application's scale; 75–85 is typical for photos).
|
||||
pub webp_quality: u8,
|
||||
pub tiff_compression: TiffCompression,
|
||||
/// Always write TIFF as BigTIFF (8-byte offsets). Without it a TIFF is written as BigTIFF
|
||||
/// only when it could pass the 4 GiB a classic TIFF can address.
|
||||
pub tiff_bigtiff: bool,
|
||||
pub exr_compression: ExrCompression,
|
||||
/// Embed the ICC profile when the format supports it.
|
||||
pub embed_icc: bool,
|
||||
@@ -137,6 +140,7 @@ impl Default for EncodeOptions {
|
||||
webp_lossless: true,
|
||||
webp_quality: 80,
|
||||
tiff_compression: TiffCompression::Deflate,
|
||||
tiff_bigtiff: false,
|
||||
exr_compression: ExrCompression::Zip16,
|
||||
embed_icc: true,
|
||||
embed_metadata: true,
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
//! image generators, comparison metrics.
|
||||
#![allow(dead_code)]
|
||||
|
||||
pub mod tiffgen;
|
||||
|
||||
use photocraft_codecs::*;
|
||||
|
||||
/// SplitMix64 — tiny deterministic PRNG.
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
//! A tiny hand-rolled TIFF / BigTIFF writer for tests: any byte order, strips or tiles, any
|
||||
//! planar configuration, several IFDs in a chain plus free-standing ones (SubIFDs), and access
|
||||
//! to every offset so tests can corrupt the structure precisely.
|
||||
#![allow(dead_code, clippy::too_many_arguments)]
|
||||
|
||||
/// A tag value: a number or a reference to another directory's offset (for SubIFDs).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum V {
|
||||
N(u64),
|
||||
Ifd(usize),
|
||||
}
|
||||
|
||||
/// TIFF field types used by the tests.
|
||||
pub const BYTE: u16 = 1;
|
||||
pub const ASCII: u16 = 2;
|
||||
pub const SHORT: u16 = 3;
|
||||
pub const LONG: u16 = 4;
|
||||
pub const RATIONAL: u16 = 5;
|
||||
pub const UNDEFINED: u16 = 7;
|
||||
pub const IFD: u16 = 13;
|
||||
pub const LONG8: u16 = 16;
|
||||
pub const IFD8: u16 = 18;
|
||||
|
||||
fn type_size(ty: u16) -> usize {
|
||||
match ty {
|
||||
BYTE | ASCII | UNDEFINED | 6 => 1,
|
||||
SHORT | 8 => 2,
|
||||
LONG | IFD | 9 | 11 => 4,
|
||||
RATIONAL | 10 | 12 | LONG8 | 17 | IFD8 => 8,
|
||||
_ => 1,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Dir {
|
||||
/// `(tag, type, values)`; strip / tile offsets and byte counts are added from `chunks`.
|
||||
pub entries: Vec<(u16, u16, Vec<V>)>,
|
||||
/// Pixel data, one blob per strip or tile.
|
||||
pub chunks: Vec<Vec<u8>>,
|
||||
/// Tiles (322/323 must be in `entries`) instead of strips.
|
||||
pub tiled: bool,
|
||||
}
|
||||
|
||||
impl Dir {
|
||||
pub fn tag(mut self, tag: u16, ty: u16, values: &[u64]) -> Self {
|
||||
self.entries.push((tag, ty, values.iter().map(|&v| V::N(v)).collect()));
|
||||
self
|
||||
}
|
||||
pub fn bytes(mut self, tag: u16, ty: u16, data: &[u8]) -> Self {
|
||||
self.entries.push((tag, ty, data.iter().map(|&v| V::N(u64::from(v))).collect()));
|
||||
self
|
||||
}
|
||||
pub fn sub_ifds(mut self, ty: u16, dirs: &[usize]) -> Self {
|
||||
self.entries.push((330, ty, dirs.iter().map(|&d| V::Ifd(d)).collect()));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// A basic directory: `w`×`h`, `spp` samples of `bits` bits, the given photometric, chunky,
|
||||
/// uncompressed, one strip per `rows` rows holding `data` (already in file byte order).
|
||||
pub fn strips(w: u32, h: u32, bits: u16, spp: u16, photometric: u16, rows: u32, data: &[u8]) -> Dir {
|
||||
let row = (w as usize * bits as usize * spp as usize).div_ceil(8);
|
||||
let chunks = data.chunks((row * rows as usize).max(1)).map(<[u8]>::to_vec).collect();
|
||||
Dir { entries: base(w, h, bits, spp, photometric), chunks, tiled: false }.tag(278, LONG, &[u64::from(rows)])
|
||||
}
|
||||
|
||||
pub fn base(w: u32, h: u32, bits: u16, spp: u16, photometric: u16) -> Vec<(u16, u16, Vec<V>)> {
|
||||
vec![
|
||||
(256, LONG, vec![V::N(u64::from(w))]),
|
||||
(257, LONG, vec![V::N(u64::from(h))]),
|
||||
(258, SHORT, vec![V::N(u64::from(bits)); spp as usize]),
|
||||
(259, SHORT, vec![V::N(1)]),
|
||||
(262, SHORT, vec![V::N(u64::from(photometric))]),
|
||||
(277, SHORT, vec![V::N(u64::from(spp))]),
|
||||
]
|
||||
}
|
||||
|
||||
/// Where things ended up in a built file.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Built {
|
||||
pub bytes: Vec<u8>,
|
||||
/// Offset of each directory (`dirs` order).
|
||||
pub ifd_at: Vec<u64>,
|
||||
/// Offset of each directory's next-IFD pointer field.
|
||||
pub next_at: Vec<u64>,
|
||||
/// Offset of each chunk's data, per directory.
|
||||
pub chunk_at: Vec<Vec<u64>>,
|
||||
}
|
||||
|
||||
/// Builds a file. `chain` lists the directories of the main IFD chain in order; directories not
|
||||
/// in it are only reachable through SubIFD references.
|
||||
pub fn build(little: bool, big: bool, dirs: &[Dir], chain: &[usize]) -> Built {
|
||||
let e16 = |v: u16| if little { v.to_le_bytes().to_vec() } else { v.to_be_bytes().to_vec() };
|
||||
let e32 = |v: u32| if little { v.to_le_bytes().to_vec() } else { v.to_be_bytes().to_vec() };
|
||||
let e64 = |v: u64| if little { v.to_le_bytes().to_vec() } else { v.to_be_bytes().to_vec() };
|
||||
let enc = |ty: u16, v: u64| -> Vec<u8> {
|
||||
match type_size(ty) {
|
||||
1 => vec![v as u8],
|
||||
2 => e16(v as u16),
|
||||
4 => e32(v as u32),
|
||||
_ => e64(v),
|
||||
}
|
||||
};
|
||||
let header = if big { 16 } else { 8 };
|
||||
let entry_len = if big { 20 } else { 12 };
|
||||
let inline = if big { 8 } else { 4 };
|
||||
let off_ty = if big { LONG8 } else { LONG };
|
||||
|
||||
// Complete entry lists (with chunk offsets / byte counts), sorted by tag.
|
||||
let mut full: Vec<Vec<(u16, u16, Vec<V>)>> = Vec::new();
|
||||
for d in dirs {
|
||||
let mut e = d.entries.clone();
|
||||
let (ot, ct) = if d.tiled { (324, 325) } else { (273, 279) };
|
||||
if !d.chunks.is_empty() || !e.iter().any(|x| x.0 == ot) {
|
||||
e.retain(|x| x.0 != ot && x.0 != ct);
|
||||
e.push((ot, off_ty, vec![V::N(0); d.chunks.len()]));
|
||||
e.push((ct, off_ty, d.chunks.iter().map(|c| V::N(c.len() as u64)).collect()));
|
||||
}
|
||||
e.sort_by_key(|x| x.0);
|
||||
full.push(e);
|
||||
}
|
||||
|
||||
// Pass 1: positions. Layout: header, then per directory its chunks, its out-of-line values
|
||||
// and its table.
|
||||
let mut pos = header as u64;
|
||||
let mut ifd_at = Vec::new();
|
||||
let mut chunk_at = Vec::new();
|
||||
for (d, e) in dirs.iter().zip(&full) {
|
||||
let mut ca = Vec::new();
|
||||
for c in &d.chunks {
|
||||
ca.push(pos);
|
||||
pos += c.len() as u64;
|
||||
}
|
||||
chunk_at.push(ca);
|
||||
for (_, ty, vals) in e {
|
||||
let n = type_size(*ty) * vals.len();
|
||||
if n > inline {
|
||||
pos += n as u64;
|
||||
}
|
||||
}
|
||||
pos += pos % 2; // word alignment
|
||||
ifd_at.push(pos);
|
||||
pos += (if big { 8 } else { 2 }) + (e.len() * entry_len) as u64 + if big { 8 } else { 4 };
|
||||
}
|
||||
|
||||
// Pass 2: bytes.
|
||||
let mut f = Vec::new();
|
||||
f.extend_from_slice(if little { b"II" } else { b"MM" });
|
||||
let first = chain.first().map_or(0, |&i| ifd_at[i]);
|
||||
if big {
|
||||
f.extend(e16(43));
|
||||
f.extend(e16(8));
|
||||
f.extend(e16(0));
|
||||
f.extend(e64(first));
|
||||
} else {
|
||||
f.extend(e16(42));
|
||||
f.extend(e32(first as u32));
|
||||
}
|
||||
let mut next_at = vec![0; dirs.len()];
|
||||
for (k, (d, e)) in dirs.iter().zip(&full).enumerate() {
|
||||
for c in &d.chunks {
|
||||
f.extend_from_slice(c);
|
||||
}
|
||||
let (ot, _) = if d.tiled { (324, 325) } else { (273, 279) };
|
||||
let resolve = |tag: u16, i: usize, v: V| -> u64 {
|
||||
if tag == ot && !d.chunks.is_empty() {
|
||||
return chunk_at[k][i];
|
||||
}
|
||||
match v {
|
||||
V::N(n) => n,
|
||||
V::Ifd(j) => ifd_at[j],
|
||||
}
|
||||
};
|
||||
let mut table = Vec::new();
|
||||
table.extend(if big { e64(e.len() as u64) } else { e16(e.len() as u16) });
|
||||
for (tag, ty, vals) in e {
|
||||
let mut data = Vec::new();
|
||||
for (i, v) in vals.iter().enumerate() {
|
||||
data.extend(enc(*ty, resolve(*tag, i, *v)));
|
||||
}
|
||||
table.extend(e16(*tag));
|
||||
table.extend(e16(*ty));
|
||||
table.extend(if big { e64(vals.len() as u64) } else { e32(vals.len() as u32) });
|
||||
if data.len() <= inline {
|
||||
data.resize(inline, 0);
|
||||
table.extend(data);
|
||||
} else {
|
||||
let at = f.len() as u64;
|
||||
f.extend(data);
|
||||
table.extend(if big { e64(at) } else { e32(at as u32) });
|
||||
}
|
||||
}
|
||||
if f.len() % 2 == 1 {
|
||||
f.push(0);
|
||||
}
|
||||
assert_eq!(f.len() as u64, ifd_at[k]);
|
||||
let next = chain.iter().position(|&c| c == k).and_then(|p| chain.get(p + 1)).map_or(0, |&n| ifd_at[n]);
|
||||
f.extend(table);
|
||||
next_at[k] = f.len() as u64;
|
||||
f.extend(if big { e64(next) } else { e32(next as u32) });
|
||||
}
|
||||
Built { bytes: f, ifd_at, next_at, chunk_at }
|
||||
}
|
||||
|
||||
/// Overwrites the next-IFD pointer of directory `k` (for cycles and dangling pointers).
|
||||
pub fn set_next(b: &mut Built, little: bool, big: bool, k: usize, to: u64) {
|
||||
let at = b.next_at[k] as usize;
|
||||
let v = if big {
|
||||
if little { to.to_le_bytes().to_vec() } else { to.to_be_bytes().to_vec() }
|
||||
} else if little {
|
||||
(to as u32).to_le_bytes().to_vec()
|
||||
} else {
|
||||
(to as u32).to_be_bytes().to_vec()
|
||||
};
|
||||
b.bytes[at..at + v.len()].copy_from_slice(&v);
|
||||
}
|
||||
|
||||
/// Locates entry `tag` of directory `k`: `(entry position, type, count, value position)`.
|
||||
pub fn find_entry(b: &Built, little: bool, big: bool, k: usize, tag: u16) -> Option<(usize, u16, u64, usize)> {
|
||||
let f = &b.bytes;
|
||||
let rd = |at: usize, n: usize| -> u64 {
|
||||
let s = &f[at..at + n];
|
||||
let mut v = 0u64;
|
||||
for i in 0..n {
|
||||
let byte = if little { s[n - 1 - i] } else { s[i] };
|
||||
v = (v << 8) | u64::from(byte);
|
||||
}
|
||||
v
|
||||
};
|
||||
let at = b.ifd_at[k] as usize;
|
||||
let (n, first, entry, inline) = if big { (rd(at, 8), at + 8, 20, 8) } else { (rd(at, 2), at + 2, 12, 4) };
|
||||
for i in 0..n as usize {
|
||||
let e = first + i * entry;
|
||||
if rd(e, 2) as u16 != tag {
|
||||
continue;
|
||||
}
|
||||
let ty = rd(e + 2, 2) as u16;
|
||||
let count = if big { rd(e + 4, 8) } else { rd(e + 4, 4) };
|
||||
let field = e + if big { 12 } else { 8 };
|
||||
let value_at = if count as usize * type_size(ty) <= inline { field } else { rd(field, inline) as usize };
|
||||
return Some((e, ty, count, value_at));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Overwrites value `i` of entry `tag` in directory `k` (in its own type and width).
|
||||
pub fn patch_value(b: &mut Built, little: bool, big: bool, k: usize, tag: u16, i: usize, value: u64) {
|
||||
let (_, ty, _, at) = find_entry(b, little, big, k, tag).expect("tag present");
|
||||
let n = type_size(ty);
|
||||
let bytes = if little { value.to_le_bytes() } else { value.to_be_bytes() };
|
||||
let v = if little { bytes[..n].to_vec() } else { bytes[8 - n..].to_vec() };
|
||||
let at = at + i * n;
|
||||
b.bytes[at..at + n].copy_from_slice(&v);
|
||||
}
|
||||
|
||||
/// Overwrites the count field of entry `tag` in directory `k`.
|
||||
pub fn patch_count(b: &mut Built, little: bool, big: bool, k: usize, tag: u16, count: u64) {
|
||||
let (e, ..) = find_entry(b, little, big, k, tag).expect("tag present");
|
||||
let v = if big {
|
||||
if little { count.to_le_bytes().to_vec() } else { count.to_be_bytes().to_vec() }
|
||||
} else if little {
|
||||
(count as u32).to_le_bytes().to_vec()
|
||||
} else {
|
||||
(count as u32).to_be_bytes().to_vec()
|
||||
};
|
||||
b.bytes[e + 4..e + 4 + v.len()].copy_from_slice(&v);
|
||||
}
|
||||
|
||||
/// Samples to file bytes in the given order.
|
||||
pub fn u16s(v: &[u16], little: bool) -> Vec<u8> {
|
||||
v.iter().flat_map(|x| if little { x.to_le_bytes() } else { x.to_be_bytes() }).collect()
|
||||
}
|
||||
pub fn f32s(v: &[f32], little: bool) -> Vec<u8> {
|
||||
v.iter().flat_map(|x| if little { x.to_le_bytes() } else { x.to_be_bytes() }).collect()
|
||||
}
|
||||
|
||||
/// Splits an interleaved `w`×`h` image of `spp` samples (`bps` bytes each) into tiles of
|
||||
/// `tw`×`th`, padded with zeros; chunky (one blob per tile) or planar (all tiles of plane 0,
|
||||
/// then plane 1, …).
|
||||
pub fn tile_split(px: &[u8], w: usize, h: usize, spp: usize, bps: usize, tw: usize, th: usize, planar: bool) -> Vec<Vec<u8>> {
|
||||
let across = w.div_ceil(tw);
|
||||
let down = h.div_ceil(th);
|
||||
let planes = if planar { spp } else { 1 };
|
||||
let per = if planar { 1 } else { spp };
|
||||
let mut out = Vec::new();
|
||||
for p in 0..planes {
|
||||
for ty in 0..down {
|
||||
for tx in 0..across {
|
||||
let mut t = vec![0u8; tw * th * per * bps];
|
||||
for y in 0..th {
|
||||
for x in 0..tw {
|
||||
let (ix, iy) = (tx * tw + x, ty * th + y);
|
||||
if ix >= w || iy >= h {
|
||||
continue;
|
||||
}
|
||||
for s in 0..per {
|
||||
let src = ((iy * w + ix) * spp + if planar { p } else { s }) * bps;
|
||||
let dst = ((y * tw + x) * per + s) * bps;
|
||||
t[dst..dst + bps].copy_from_slice(&px[src..src + bps]);
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Splits an interleaved image into planar strips of `rows` rows: plane 0's strips, then
|
||||
/// plane 1's, ….
|
||||
pub fn planar_strips(px: &[u8], w: usize, h: usize, spp: usize, bps: usize, rows: usize) -> Vec<Vec<u8>> {
|
||||
let mut out = Vec::new();
|
||||
for p in 0..spp {
|
||||
for y0 in (0..h).step_by(rows) {
|
||||
let mut s = Vec::new();
|
||||
for y in y0..(y0 + rows).min(h) {
|
||||
for x in 0..w {
|
||||
let src = ((y * w + x) * spp + p) * bps;
|
||||
s.extend_from_slice(&px[src..src + bps]);
|
||||
}
|
||||
}
|
||||
out.push(s);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
//! TIFF decode timings on a synthetic ~30 MP image (6336×4752). Ignored by default; run in
|
||||
//! release:
|
||||
//!
|
||||
//! ```sh
|
||||
//! cargo test --release -p photocraft-codecs --test tiff_bench -- --ignored --nocapture
|
||||
//! ```
|
||||
|
||||
mod common;
|
||||
use common::tiffgen::*;
|
||||
use photocraft_codecs::*;
|
||||
use std::time::Instant;
|
||||
|
||||
const W: u32 = 6336;
|
||||
const H: u32 = 4752;
|
||||
|
||||
fn pixels(spp: usize) -> Vec<u8> {
|
||||
// A smooth gradient with a little texture: compresses like a photo, not like noise.
|
||||
let mut v = Vec::with_capacity(W as usize * H as usize * spp);
|
||||
let mut s = 0x1234_5678u32;
|
||||
for y in 0..H {
|
||||
for x in 0..W {
|
||||
s ^= s << 13;
|
||||
s ^= s >> 17;
|
||||
s ^= s << 5;
|
||||
for c in 0..spp {
|
||||
let g = (x * (c as u32 + 1) / 25 + y / 19) as u8;
|
||||
v.push(g.wrapping_add((s >> (8 * c)) as u8 & 3));
|
||||
}
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// The decoder before FILE-215-8, for a same-run comparison: the `tiff` crate decodes the whole
|
||||
/// image into one buffer (`read_image`, first plane only), which is then copied into an `Image`.
|
||||
fn legacy(bytes: &[u8]) -> Result<Image, String> {
|
||||
use tiff::decoder::{Decoder, DecodingResult, Limits as TLimits};
|
||||
let mut l = TLimits::default();
|
||||
l.decoding_buffer_size = 8 << 30;
|
||||
l.intermediate_buffer_size = 1 << 30;
|
||||
let mut dec = Decoder::new(std::io::Cursor::new(bytes)).map_err(|e| e.to_string())?.with_limits(l);
|
||||
let (w, h) = dec.dimensions().map_err(|e| e.to_string())?;
|
||||
let layout = match dec.colortype().map_err(|e| e.to_string())? {
|
||||
tiff::ColorType::RGB(_) => ChannelLayout::Rgb,
|
||||
tiff::ColorType::Gray(_) => ChannelLayout::Gray,
|
||||
c => return Err(format!("{c:?}")),
|
||||
};
|
||||
match dec.read_image().map_err(|e| e.to_string())? {
|
||||
DecodingResult::U8(v) => Image::from_u8(w, h, layout, v),
|
||||
DecodingResult::U16(v) => Image::from_u16(w, h, layout, &v),
|
||||
_ => return Err("sample type".into()),
|
||||
}
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn best_of(f: impl Fn() -> Result<Image, String>) -> Result<f64, String> {
|
||||
let mut best = f64::MAX;
|
||||
for _ in 0..7 {
|
||||
let t = Instant::now();
|
||||
f()?;
|
||||
best = best.min(t.elapsed().as_secs_f64() * 1000.0);
|
||||
}
|
||||
Ok(best)
|
||||
}
|
||||
|
||||
fn time(name: &str, bytes: &[u8]) {
|
||||
let new = best_of(|| decode(bytes).map_err(|e| e.to_string()));
|
||||
let old = best_of(|| legacy(bytes));
|
||||
let show = |r: &Result<f64, String>| match r {
|
||||
Ok(ms) => format!("{ms:>7.1} ms"),
|
||||
Err(e) => format!("error ({})", e.chars().take(48).collect::<String>()),
|
||||
};
|
||||
println!("{name:<34} new {} before {} ({:.1} MB file)", show(&new), show(&old), bytes.len() as f64 / 1e6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "benchmark: run in release with --ignored --nocapture"]
|
||||
fn decode_30mp() {
|
||||
let rgb = pixels(3);
|
||||
let img = Image::from_u8(W, H, ChannelLayout::Rgb, rgb.clone()).unwrap();
|
||||
for (name, c) in
|
||||
[("strips, none", TiffCompression::None), ("strips, LZW + predictor", TiffCompression::Lzw), ("strips, Deflate + predictor", TiffCompression::Deflate)]
|
||||
{
|
||||
let bytes = encode(&img, Format::Tiff, &EncodeOptions { tiff_compression: c, ..Default::default() }).unwrap();
|
||||
time(&format!("RGB 8 {name}"), &bytes);
|
||||
}
|
||||
let img16 = img.convert(ChannelLayout::Rgb, SampleType::U16);
|
||||
let bytes = encode(&img16, Format::Tiff, &EncodeOptions { tiff_compression: TiffCompression::Deflate, ..Default::default() }).unwrap();
|
||||
time("RGB 16 strips, Deflate + predictor", &bytes);
|
||||
let bytes = encode(&img16, Format::Tiff, &EncodeOptions { tiff_compression: TiffCompression::None, ..Default::default() }).unwrap();
|
||||
time("RGB 16 strips, none", &bytes);
|
||||
|
||||
// Hand-built layouts the encoder does not write: one big strip, tiles, planar.
|
||||
let le = cfg!(target_endian = "little");
|
||||
let one = build(le, false, &[strips(W, H, 8, 3, 2, H, &rgb)], &[0]);
|
||||
time("RGB 8 one strip, none", &one.bytes);
|
||||
let tiles = tile_split(&rgb, W as usize, H as usize, 3, 1, 256, 256, false);
|
||||
let mut d = Dir { entries: base(W, H, 8, 3, 2), chunks: tiles, tiled: true };
|
||||
d = d.tag(322, LONG, &[256]).tag(323, LONG, &[256]);
|
||||
time("RGB 8 tiles 256, none", &build(le, false, &[d], &[0]).bytes);
|
||||
let planes = planar_strips(&rgb, W as usize, H as usize, 3, 1, 64);
|
||||
let mut d = Dir { entries: base(W, H, 8, 3, 2), chunks: planes, tiled: false };
|
||||
d = d.tag(278, LONG, &[64]).tag(284, SHORT, &[2]);
|
||||
time("RGB 8 planar strips, none", &build(le, false, &[d], &[0]).bytes);
|
||||
let d = strips(W, H, 8, 3, 2, 64, &rgb);
|
||||
time("RGB 8 BigTIFF strips, none", &build(le, true, &[d], &[0]).bytes);
|
||||
}
|
||||
@@ -0,0 +1,585 @@
|
||||
//! Large-file TIFF support (FILE-215-8): BigTIFF in both byte orders, every IFD of a multi-page
|
||||
//! file and its SubIFDs, banded decoding of strips and tiles in either planar configuration, the
|
||||
//! sample formats and interpretations around them, and hostile structures (cycles, counts and
|
||||
//! offsets past the end, absurd sizes) that must give `Ok` or a clean `Err`, never a panic.
|
||||
//! Every file here is hand-built by `common::tiffgen`.
|
||||
|
||||
mod common;
|
||||
use common::Rng;
|
||||
use common::tiffgen::*;
|
||||
use photocraft_codecs::*;
|
||||
|
||||
const ORDERS: [(bool, bool); 4] = [(true, false), (false, false), (true, true), (false, true)];
|
||||
|
||||
fn ne16(v: &[u16]) -> Vec<u8> {
|
||||
v.iter().flat_map(|x| x.to_ne_bytes()).collect()
|
||||
}
|
||||
fn ne32f(v: &[f32]) -> Vec<u8> {
|
||||
v.iter().flat_map(|x| x.to_ne_bytes()).collect()
|
||||
}
|
||||
|
||||
/// A test image's samples in each storage: 8-bit, 16-bit and 32-bit float.
|
||||
struct Truth {
|
||||
u8s: Vec<u8>,
|
||||
u16s: Vec<u16>,
|
||||
f32s: Vec<f32>,
|
||||
}
|
||||
|
||||
fn truth(n: usize, seed: u64) -> Truth {
|
||||
let mut r = Rng::new(seed);
|
||||
let u16s: Vec<u16> = (0..n).map(|_| r.next_u64() as u16).collect();
|
||||
Truth { u8s: u16s.iter().map(|v| (v >> 8) as u8).collect(), f32s: u16s.iter().map(|&v| f32::from(v) / 65535.0).collect(), u16s }
|
||||
}
|
||||
|
||||
/// `(bits, sample format, file bytes in this order, expected native bytes, sample type)`.
|
||||
type Depth = (u16, u16, Vec<u8>, Vec<u8>, SampleType);
|
||||
|
||||
fn depths(t: &Truth, little: bool) -> Vec<Depth> {
|
||||
vec![
|
||||
(8, 1, t.u8s.clone(), t.u8s.clone(), SampleType::U8),
|
||||
(16, 1, u16s(&t.u16s, little), ne16(&t.u16s), SampleType::U16),
|
||||
(32, 3, f32s(&t.f32s, little), ne32f(&t.f32s), SampleType::F32),
|
||||
]
|
||||
}
|
||||
|
||||
fn decode_ok(bytes: &[u8]) -> Image {
|
||||
decode(bytes).unwrap_or_else(|e| panic!("decode failed: {e}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bigtiff_and_classic_in_both_byte_orders_at_every_depth() {
|
||||
let (w, h) = (7u32, 5u32);
|
||||
for spp in [1u16, 3, 4] {
|
||||
let t = truth((w * h) as usize * spp as usize, u64::from(spp));
|
||||
for (little, big) in ORDERS {
|
||||
for (bits, format, file, want, sample) in depths(&t, little) {
|
||||
let photometric = if spp == 1 { 1 } else { 2 };
|
||||
let mut d = strips(w, h, bits, spp, photometric, 2, &file).tag(339, SHORT, &vec![u64::from(format); spp as usize]);
|
||||
if spp == 4 {
|
||||
d = d.tag(338, SHORT, &[2]);
|
||||
}
|
||||
let b = build(little, big, &[d], &[0]);
|
||||
let img = decode_ok(&b.bytes);
|
||||
assert_eq!((img.dimensions(), img.sample_type()), ((w, h), sample), "little {little} big {big} bits {bits} spp {spp}");
|
||||
assert_eq!(img.layout().channels(), spp as usize);
|
||||
assert_eq!(img.data(), &want[..], "little {little} big {big} bits {bits} spp {spp}");
|
||||
assert_eq!(img.warnings, []);
|
||||
let info = tiff_info(&b.bytes).unwrap();
|
||||
assert_eq!((info.big_tiff, info.little_endian, info.pages.len(), info.complete), (big, little, 1, true));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiles_with_padding_in_both_planar_configurations() {
|
||||
let (w, h) = (37usize, 29usize);
|
||||
for spp in [1usize, 3, 4] {
|
||||
let t = truth(w * h * spp, 9 + spp as u64);
|
||||
for (little, big) in ORDERS {
|
||||
for (bits, format, file, want, _) in depths(&t, little) {
|
||||
let bps = bits as usize / 8;
|
||||
for planar in [false, true] {
|
||||
let mut d = Dir {
|
||||
entries: base(w as u32, h as u32, bits, spp as u16, if spp == 1 { 1 } else { 2 }),
|
||||
chunks: tile_split(&file, w, h, spp, bps, 16, 16, planar),
|
||||
tiled: true,
|
||||
};
|
||||
d = d.tag(322, SHORT, &[16]).tag(323, SHORT, &[16]).tag(339, SHORT, &[u64::from(format)]);
|
||||
if planar {
|
||||
d = d.tag(284, SHORT, &[2]);
|
||||
}
|
||||
if spp == 4 {
|
||||
d = d.tag(338, SHORT, &[2]);
|
||||
}
|
||||
let b = build(little, big, &[d], &[0]);
|
||||
let img = decode_ok(&b.bytes);
|
||||
assert_eq!(img.data(), &want[..], "tiles: little {little} big {big} bits {bits} spp {spp} planar {planar}");
|
||||
let info = tiff_info(&b.bytes).unwrap();
|
||||
assert!(info.pages[0].tiled && info.pages[0].planar == planar);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn planar_strips_at_every_depth() {
|
||||
let (w, h) = (11usize, 9usize);
|
||||
for spp in [3usize, 4] {
|
||||
let t = truth(w * h * spp, 3);
|
||||
for (little, big) in ORDERS {
|
||||
for (bits, format, file, want, _) in depths(&t, little) {
|
||||
for rows in [1usize, 4, 9] {
|
||||
let mut d = Dir {
|
||||
entries: base(w as u32, h as u32, bits, spp as u16, 2),
|
||||
chunks: planar_strips(&file, w, h, spp, bits as usize / 8, rows),
|
||||
tiled: false,
|
||||
};
|
||||
d = d.tag(278, LONG, &[rows as u64]).tag(284, SHORT, &[2]).tag(339, SHORT, &[u64::from(format)]);
|
||||
if spp == 4 {
|
||||
d = d.tag(338, SHORT, &[2]);
|
||||
}
|
||||
let img = decode_ok(&build(little, big, &[d], &[0]).bytes);
|
||||
assert_eq!(img.data(), &want[..], "planar strips: little {little} big {big} bits {bits} spp {spp} rows {rows}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_samples_that_are_not_alpha_are_dropped() {
|
||||
let (w, h) = (5usize, 3usize);
|
||||
let t = truth(w * h * 5, 4);
|
||||
let rgb_of = |spp: usize, keep: usize| -> Vec<u8> { t.u8s[..w * h * spp].chunks(spp).flat_map(|p| p[..keep].to_vec()).collect() };
|
||||
for planar in [false, true] {
|
||||
// RGB + one unspecified extra sample: RGB.
|
||||
let px = &t.u8s[..w * h * 4];
|
||||
let chunks = if planar { planar_strips(px, w, h, 4, 1, 2) } else { px.chunks(w * 4 * 2).map(<[u8]>::to_vec).collect() };
|
||||
let mut d = Dir { entries: base(w as u32, h as u32, 8, 4, 2), chunks, tiled: false }.tag(278, LONG, &[2]).tag(338, SHORT, &[0]);
|
||||
if planar {
|
||||
d = d.tag(284, SHORT, &[2]);
|
||||
}
|
||||
let img = decode_ok(&build(true, false, &[d], &[0]).bytes);
|
||||
assert_eq!(img.layout(), ChannelLayout::Rgb);
|
||||
assert_eq!(img.data(), &rgb_of(4, 3)[..], "planar {planar}");
|
||||
// RGB + alpha + an unspecified extra sample: RGBA.
|
||||
let px = &t.u8s[..w * h * 5];
|
||||
let chunks = if planar { planar_strips(px, w, h, 5, 1, 2) } else { px.chunks(w * 5 * 2).map(<[u8]>::to_vec).collect() };
|
||||
let mut d = Dir { entries: base(w as u32, h as u32, 8, 5, 2), chunks, tiled: false }.tag(278, LONG, &[2]).tag(338, SHORT, &[2, 0]);
|
||||
if planar {
|
||||
d = d.tag(284, SHORT, &[2]);
|
||||
}
|
||||
let img = decode_ok(&build(false, true, &[d], &[0]).bytes);
|
||||
assert_eq!(img.layout(), ChannelLayout::Rgba);
|
||||
assert_eq!(img.data(), &rgb_of(5, 4)[..], "planar {planar}");
|
||||
}
|
||||
// Gray + alpha + extra: gray and alpha; gray + an unspecified extra: gray.
|
||||
let px = &t.u8s[..w * h * 3];
|
||||
let d = strips(w as u32, h as u32, 8, 3, 1, 3, px).tag(338, SHORT, &[2, 0]);
|
||||
let img = decode_ok(&build(true, false, &[d], &[0]).bytes);
|
||||
assert_eq!(img.layout(), ChannelLayout::GrayA);
|
||||
assert_eq!(img.data(), &rgb_of(3, 2)[..]);
|
||||
let px = &t.u8s[..w * h * 2];
|
||||
let d = strips(w as u32, h as u32, 8, 2, 1, 3, px).tag(338, SHORT, &[0]);
|
||||
let img = decode_ok(&build(true, false, &[d], &[0]).bytes);
|
||||
assert_eq!(img.layout(), ChannelLayout::Gray);
|
||||
assert_eq!(img.data(), &rgb_of(2, 1)[..]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn associated_alpha_is_made_straight() {
|
||||
// Premultiplied: half-transparent mid gray is stored as 64 with alpha 128.
|
||||
let px = [64u8, 128, 200, 255, 0, 0];
|
||||
let d = strips(3, 1, 8, 2, 1, 1, &px).tag(338, SHORT, &[1]);
|
||||
let img = decode_ok(&build(true, false, &[d], &[0]).bytes);
|
||||
assert_eq!(img.layout(), ChannelLayout::GrayA);
|
||||
assert_eq!(img.data(), &[128, 128, 200, 255, 0, 0]);
|
||||
let px16 = u16s(&[16384, 32768], false);
|
||||
let d = strips(1, 1, 16, 2, 1, 1, &px16).tag(338, SHORT, &[1]);
|
||||
let img = decode_ok(&build(false, false, &[d], &[0]).bytes);
|
||||
assert_eq!(img.to_u16_samples().unwrap(), [32768, 32768]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn white_is_zero_opens_with_the_right_tones() {
|
||||
// #691: 00 FF 00 in a WhiteIsZero file is white, black, white.
|
||||
let b = build(true, false, &[strips(3, 1, 8, 1, 0, 1, &[0x00, 0xff, 0x00])], &[0]);
|
||||
let ours = decode_ok(&b.bytes);
|
||||
let oracle = image::load_from_memory_with_format(&b.bytes, image::ImageFormat::Tiff).unwrap().into_luma8();
|
||||
assert_eq!(oracle.as_raw(), &[255u8, 0, 255]);
|
||||
assert_eq!(ours.data(), &oracle.as_raw()[..]);
|
||||
// The BlackIsZero control keeps the stored bytes.
|
||||
let b = build(true, false, &[strips(3, 1, 8, 1, 1, 1, &[0x00, 0xff, 0x00])], &[0]);
|
||||
assert_eq!(decode_ok(&b.bytes).data(), &[0, 255, 0]);
|
||||
// 16-bit, in both byte orders and BigTIFF.
|
||||
for (little, big) in ORDERS {
|
||||
let b = build(little, big, &[strips(2, 1, 16, 1, 0, 1, &u16s(&[0, 65535], little))], &[0]);
|
||||
assert_eq!(decode_ok(&b.bytes).to_u16_samples().unwrap(), [65535, 0]);
|
||||
}
|
||||
// 1-bit (a fax-style scan): bit set = black.
|
||||
let b = build(true, false, &[strips(8, 1, 1, 1, 0, 1, &[0b1010_0000])], &[0]);
|
||||
assert_eq!(decode_ok(&b.bytes).data(), &[0, 255, 0, 255, 255, 255, 255, 255]);
|
||||
// With alpha: the gray inverts, the alpha does not.
|
||||
let b = build(true, false, &[strips(2, 1, 8, 2, 0, 1, &[0, 255, 255, 10]).tag(338, SHORT, &[2])], &[0]);
|
||||
let img = decode_ok(&b.bytes);
|
||||
assert_eq!(img.layout(), ChannelLayout::GrayA);
|
||||
assert_eq!(img.data(), &[255, 255, 0, 10]);
|
||||
// 32-bit float.
|
||||
let b = build(true, false, &[strips(2, 1, 32, 1, 0, 1, &f32s(&[0.0, 0.25], true)).tag(339, SHORT, &[3])], &[0]);
|
||||
assert_eq!(decode_ok(&b.bytes).to_f32_samples().unwrap(), [1.0, 0.75]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bigtiff_orientation_is_applied_like_classic_tiff() {
|
||||
// #693: the same 2×1 gray image with Orientation 6, classic and BigTIFF, both byte orders.
|
||||
for (little, big) in ORDERS {
|
||||
let d = strips(2, 1, 8, 1, 1, 1, &[10, 200]).tag(274, SHORT, &[6]);
|
||||
let b = build(little, big, &[d], &[0]);
|
||||
assert_eq!(exif_orientation(&b.bytes), 6, "little {little} big {big}");
|
||||
assert_eq!(tiff_orientation(&b.bytes), 6);
|
||||
let img = decode_ok(&b.bytes);
|
||||
assert_eq!(img.dimensions(), (1, 2), "little {little} big {big}");
|
||||
assert_eq!(img.data(), &[10, 200]);
|
||||
let kept = decode_with(&b.bytes, &DecodeOptions { keep_orientation: true, ..Default::default() }).unwrap();
|
||||
assert_eq!(kept.dimensions(), (2, 1));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn palette_images_open_as_rgb() {
|
||||
// Two 8-bit entries used: 0 = red, 1 = (16, 32, 48); the map has 256 entries per channel.
|
||||
let mut map = vec![0u64; 768];
|
||||
map[0] = 0xffff;
|
||||
map[1] = 16 * 257;
|
||||
map[256 + 1] = 32 * 257;
|
||||
map[512 + 1] = 48 * 257;
|
||||
for (little, big) in ORDERS {
|
||||
let d = strips(3, 1, 8, 1, 3, 1, &[0, 1, 0]).tag(320, SHORT, &map);
|
||||
let img = decode_ok(&build(little, big, &[d], &[0]).bytes);
|
||||
assert_eq!((img.layout(), img.sample_type()), (ChannelLayout::Rgb, SampleType::U8));
|
||||
assert_eq!(img.data(), &[255, 0, 0, 16, 32, 48, 255, 0, 0]);
|
||||
}
|
||||
// 4-bit indices.
|
||||
let mut map = vec![0u64; 48];
|
||||
map[15] = 0xffff;
|
||||
map[16 + 15] = 0x8000;
|
||||
let d = strips(2, 1, 4, 1, 3, 1, &[0xf0]).tag(320, SHORT, &map);
|
||||
assert_eq!(decode_ok(&build(true, false, &[d], &[0]).bytes).data(), &[255, 128, 0, 0, 0, 0]);
|
||||
// A missing or short colour map is an error.
|
||||
let d = strips(2, 1, 8, 1, 3, 1, &[0, 1]);
|
||||
assert!(decode(&build(true, false, &[d], &[0]).bytes).is_err());
|
||||
let d = strips(2, 1, 8, 1, 3, 1, &[0, 1]).tag(320, SHORT, &[1, 2, 3]);
|
||||
assert!(decode(&build(true, false, &[d], &[0]).bytes).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wide_and_odd_sample_formats() {
|
||||
for (little, big) in ORDERS {
|
||||
let e = |v: u32| if little { v.to_le_bytes() } else { v.to_be_bytes() };
|
||||
// 32-bit unsigned keeps its top 16 bits.
|
||||
let px: Vec<u8> = [0x1234_5678u32, 0xffff_0000].iter().flat_map(|&v| e(v)).collect();
|
||||
let img = decode_ok(&build(little, big, &[strips(2, 1, 32, 1, 1, 1, &px)], &[0]).bytes);
|
||||
assert_eq!(img.to_u16_samples().unwrap(), [0x1234, 0xffff]);
|
||||
// 64-bit float becomes 32-bit float.
|
||||
let px: Vec<u8> = [0.5f64, 2.0].iter().flat_map(|v| if little { v.to_le_bytes() } else { v.to_be_bytes() }).collect();
|
||||
let img = decode_ok(&build(little, big, &[strips(2, 1, 64, 1, 1, 1, &px).tag(339, SHORT, &[3])], &[0]).bytes);
|
||||
assert_eq!(img.to_f32_samples().unwrap(), [0.5, 2.0]);
|
||||
// 16-bit float stays half.
|
||||
let px: Vec<u8> = [f16::from_f32(0.25), f16::ONE].iter().flat_map(|v| if little { v.to_le_bytes() } else { v.to_be_bytes() }).collect();
|
||||
let img = decode_ok(&build(little, big, &[strips(2, 1, 16, 1, 1, 1, &px).tag(339, SHORT, &[3])], &[0]).bytes);
|
||||
assert_eq!(img.sample_type(), SampleType::F16);
|
||||
assert_eq!(img.to_normalized(), [0.25, 1.0]);
|
||||
}
|
||||
// 2- and 4-bit gray, rows padded to a byte, in several strips.
|
||||
let img = decode_ok(&build(true, false, &[strips(3, 2, 2, 1, 1, 1, &[0b0001_1011, 0b1110_0100])], &[0]).bytes);
|
||||
assert_eq!(img.data(), &[0, 85, 170, 255, 170, 85]);
|
||||
let img = decode_ok(&build(false, true, &[strips(3, 2, 4, 1, 1, 1, &[0x0f, 0x80, 0x12, 0x30])], &[0]).bytes);
|
||||
assert_eq!(img.data(), &[0, 255, 136, 17, 34, 51]);
|
||||
// Signed integers are unsupported, not a crash.
|
||||
let d = strips(2, 1, 16, 1, 1, 1, &[0, 0, 0, 0]).tag(339, SHORT, &[2]);
|
||||
assert!(matches!(decode(&build(true, false, &[d], &[0]).bytes), Err(CodecError::Unsupported { .. })));
|
||||
}
|
||||
|
||||
/// A flat gray page of `w`×`h` at `level`, with an optional NewSubfileType.
|
||||
fn gray_page(w: u32, h: u32, level: u8, kind: Option<u64>) -> Dir {
|
||||
let d = strips(w, h, 8, 1, 1, h, &vec![level; (w * h) as usize]);
|
||||
match kind {
|
||||
Some(k) => d.tag(254, LONG, &[k]),
|
||||
None => d,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_ifd_and_subifd_is_listed_and_decodable() {
|
||||
for (little, big) in ORDERS {
|
||||
let sub_ty = if big { IFD8 } else { IFD };
|
||||
let dirs = vec![
|
||||
gray_page(4, 3, 1, Some(1)), // 0: thumbnail first
|
||||
gray_page(9, 7, 2, None).sub_ifds(sub_ty, &[4, 5]), // 1: page A, two SubIFDs
|
||||
gray_page(9, 7, 3, Some(4)), // 2: its transparency mask
|
||||
gray_page(5, 5, 4, Some(2)), // 3: page B (multi-page bit)
|
||||
gray_page(3, 2, 5, Some(1)), // 4: SubIFD: reduced copy of A
|
||||
gray_page(2, 1, 6, Some(1)), // 5: SubIFD: smaller copy
|
||||
];
|
||||
let b = build(little, big, &dirs, &[0, 1, 2, 3]);
|
||||
let info = tiff_info(&b.bytes).unwrap();
|
||||
assert!(info.complete);
|
||||
let summary: Vec<(u32, Option<usize>, TiffPageKind)> = info.pages.iter().map(|p| (p.width, p.parent, p.kind)).collect();
|
||||
use TiffPageKind::*;
|
||||
assert_eq!(
|
||||
summary,
|
||||
[(4, None, ReducedResolution), (9, None, Page), (3, Some(1), ReducedResolution), (2, Some(1), ReducedResolution), (9, None, Mask), (5, None, Page)]
|
||||
);
|
||||
assert_eq!(info.pages[1].ifd_offset, b.ifd_at[1]);
|
||||
assert_eq!((info.default_page(), info.page_count()), (Some(1), 2));
|
||||
// Like Photoshop: the first full-resolution page opens; the thumbnail is skipped.
|
||||
let img = decode_ok(&b.bytes);
|
||||
assert_eq!((img.dimensions(), img.data()[0]), ((9, 7), 2));
|
||||
assert_eq!(img.warnings, [DecodeWarning::MorePages { total: Some(2) }]);
|
||||
// Every directory decodes on request.
|
||||
for (i, (w, level)) in [(4, 1u8), (9, 2), (3, 5), (2, 6), (9, 3), (5, 4)].into_iter().enumerate() {
|
||||
let img = decode_tiff_page(&b.bytes, i, &DecodeOptions::default()).unwrap();
|
||||
assert_eq!((img.width(), img.data()[0]), (w, level), "page {i} little {little} big {big}");
|
||||
}
|
||||
assert!(matches!(decode_tiff_page(&b.bytes, 6, &DecodeOptions::default()), Err(CodecError::InvalidImage(_))));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_of_only_thumbnails_still_opens() {
|
||||
let b = build(true, false, &[gray_page(4, 3, 7, Some(1))], &[0]);
|
||||
assert_eq!(tiff_info(&b.bytes).unwrap().default_page(), Some(0));
|
||||
assert_eq!(decode_ok(&b.bytes).data()[0], 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cyclic_chains_and_subifds_terminate() {
|
||||
for (little, big) in ORDERS {
|
||||
// IFD1 points back to IFD0.
|
||||
let mut b = build(little, big, &[gray_page(2, 2, 1, None), gray_page(2, 2, 2, None)], &[0, 1]);
|
||||
let first = b.ifd_at[0];
|
||||
set_next(&mut b, little, big, 1, first);
|
||||
let info = tiff_info(&b.bytes).unwrap();
|
||||
assert_eq!((info.pages.len(), info.complete), (2, false));
|
||||
let img = decode_ok(&b.bytes);
|
||||
assert_eq!(img.warnings, [DecodeWarning::MorePages { total: None }]);
|
||||
// A directory pointing at itself.
|
||||
let mut b = build(little, big, &[gray_page(2, 2, 1, None)], &[0]);
|
||||
let first = b.ifd_at[0];
|
||||
set_next(&mut b, little, big, 0, first);
|
||||
assert_eq!(tiff_info(&b.bytes).unwrap().pages.len(), 1);
|
||||
assert_eq!(decode_ok(&b.bytes).warnings, []);
|
||||
// A SubIFD listing its own parent, and one listing itself.
|
||||
let sub_ty = if big { IFD8 } else { IFD };
|
||||
let b = build(little, big, &[gray_page(2, 2, 1, None).sub_ifds(sub_ty, &[1]), gray_page(1, 1, 2, Some(1)).sub_ifds(sub_ty, &[0, 1])], &[0]);
|
||||
let info = tiff_info(&b.bytes).unwrap();
|
||||
assert_eq!((info.pages.len(), info.complete), (2, false));
|
||||
decode_ok(&b.bytes);
|
||||
// A next pointer past the end, or into the header.
|
||||
for to in [b.bytes.len() as u64 + 100, 3, u64::MAX] {
|
||||
let mut b = build(little, big, &[gray_page(2, 2, 1, None), gray_page(2, 2, 2, None)], &[0, 1]);
|
||||
set_next(&mut b, little, big, 1, to);
|
||||
let info = tiff_info(&b.bytes).unwrap();
|
||||
assert_eq!((info.pages.len(), info.complete), (2, false));
|
||||
decode_ok(&b.bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_chain_walk_is_capped() {
|
||||
// 10 001 tiny directories: the walk stops at 10 000 and says the list is incomplete.
|
||||
let dirs: Vec<Dir> = (0..10_001).map(|_| gray_page(1, 1, 9, None)).collect();
|
||||
let chain: Vec<usize> = (0..dirs.len()).collect();
|
||||
let b = build(true, false, &dirs, &chain);
|
||||
let info = tiff_info(&b.bytes).unwrap();
|
||||
assert_eq!((info.pages.len(), info.complete), (10_000, false));
|
||||
assert_eq!(decode_ok(&b.bytes).warnings, [DecodeWarning::MorePages { total: None }]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_data_cut_off_decodes_with_a_warning() {
|
||||
let (w, h) = (4u32, 8u32);
|
||||
let px: Vec<u8> = (0..32).collect();
|
||||
for (little, big) in ORDERS {
|
||||
// The last strip starts past the end of the file: its rows stay empty.
|
||||
let mut b = build(little, big, &[strips(w, h, 8, 1, 1, 2, &px)], &[0]);
|
||||
let len = b.bytes.len() as u64;
|
||||
patch_value(&mut b, little, big, 0, 273, 3, len + 1000);
|
||||
let img = decode_ok(&b.bytes);
|
||||
assert_eq!(img.warnings, [DecodeWarning::Truncated { format: Format::Tiff }]);
|
||||
assert_eq!(&img.data()[..24], &px[..24]);
|
||||
assert_eq!(&img.data()[24..], &[0; 8]);
|
||||
// A strip running into the end of the file.
|
||||
let mut b = build(little, big, &[strips(w, h, 8, 1, 1, 2, &px)], &[0]);
|
||||
let len = b.bytes.len() as u64;
|
||||
patch_value(&mut b, little, big, 0, 273, 1, len - 3);
|
||||
assert_eq!(decode_ok(&b.bytes).warnings, [DecodeWarning::Truncated { format: Format::Tiff }]);
|
||||
// Every strip missing: nothing to show, an error.
|
||||
let mut b = build(little, big, &[strips(w, h, 8, 1, 1, 2, &px)], &[0]);
|
||||
let len = b.bytes.len() as u64;
|
||||
for i in 0..4 {
|
||||
patch_value(&mut b, little, big, 0, 273, i, len + 7);
|
||||
}
|
||||
assert!(decode(&b.bytes).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_sizes_and_counts_are_refused_without_allocating() {
|
||||
let tight = DecodeOptions { limits: Limits { max_alloc: 64 << 20, ..Limits::default() }, ..Default::default() };
|
||||
for (little, big) in ORDERS {
|
||||
let px = vec![7u8; 16];
|
||||
let fresh = || build(little, big, &[strips(4, 4, 8, 1, 1, 1, &px)], &[0]);
|
||||
// Huge declared dimensions: over the limits, or far more than the file can hold.
|
||||
for (wv, hv) in [(1u64 << 31, 4u64), (4, 1 << 31), (u32::MAX as u64, u32::MAX as u64), (200_000, 200_000), (30_000, 30_000)] {
|
||||
let mut b = fresh();
|
||||
patch_value(&mut b, little, big, 0, 256, 0, wv);
|
||||
patch_value(&mut b, little, big, 0, 257, 0, hv);
|
||||
assert!(decode(&b.bytes).is_err(), "{wv}x{hv}");
|
||||
assert!(decode_with(&b.bytes, &DecodeOptions { limits: Limits::none(), ..Default::default() }).is_err(), "{wv}x{hv} without limits");
|
||||
}
|
||||
// A strip-offset count far past the end of the file.
|
||||
let mut b = fresh();
|
||||
patch_count(&mut b, little, big, 0, 273, 1 << 30);
|
||||
assert!(matches!(decode(&b.bytes), Err(CodecError::Malformed { .. })));
|
||||
let mut b = fresh();
|
||||
patch_count(&mut b, little, big, 0, 258, u32::MAX as u64);
|
||||
assert!(decode(&b.bytes).is_err());
|
||||
// Zero rows per strip, zero samples, absurd samples per pixel.
|
||||
for (tag, v) in [(278u16, 0u64), (277, 0), (277, 60_000)] {
|
||||
let mut b = fresh();
|
||||
patch_value(&mut b, little, big, 0, tag, 0, v);
|
||||
assert!(decode(&b.bytes).is_err(), "tag {tag} = {v}");
|
||||
}
|
||||
// Absurd tiles over a tiny image, chunky and planar.
|
||||
for planar in [false, true] {
|
||||
for (tw, th) in [(1u64 << 30, 16u64), (16, 1 << 30), (u32::MAX as u64, u32::MAX as u64)] {
|
||||
let spp = if planar { 3 } else { 1 };
|
||||
let mut d = Dir { entries: base(4, 4, 8, spp, if planar { 2 } else { 1 }), chunks: vec![vec![1u8; 64]; spp as usize], tiled: true };
|
||||
d = d.tag(322, LONG, &[tw]).tag(323, LONG, &[th]);
|
||||
if planar {
|
||||
d = d.tag(284, SHORT, &[2]);
|
||||
}
|
||||
let b = build(little, big, &[d], &[0]);
|
||||
let _ = decode_with(&b.bytes, &tight);
|
||||
let _ = decode(&b.bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
// A BigTIFF directory claiming 2^60 entries.
|
||||
let mut b = build(true, true, &[gray_page(2, 2, 1, None)], &[0]);
|
||||
let at = b.ifd_at[0] as usize;
|
||||
b.bytes[at..at + 8].copy_from_slice(&(1u64 << 60).to_le_bytes());
|
||||
assert!(tiff_info(&b.bytes).is_err());
|
||||
assert!(decode(&b.bytes).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn banded_decode_matches_the_encoder_on_larger_images() {
|
||||
// Many strips decoded in parallel bands, at every depth and compression.
|
||||
for (layout, sample) in [
|
||||
(ChannelLayout::Rgb, SampleType::U8),
|
||||
(ChannelLayout::Rgba, SampleType::U16),
|
||||
(ChannelLayout::Gray, SampleType::F32),
|
||||
(ChannelLayout::Cmyk, SampleType::U8),
|
||||
] {
|
||||
let img = common::synth(613, 411, layout, sample, 3, 0.2);
|
||||
for c in [TiffCompression::None, TiffCompression::Lzw, TiffCompression::Deflate, TiffCompression::PackBits] {
|
||||
let bytes = encode(&img, Format::Tiff, &EncodeOptions { tiff_compression: c, ..Default::default() }).unwrap();
|
||||
let back = decode_ok(&bytes);
|
||||
assert_eq!(back.data(), img.data(), "{layout:?} {sample:?} {c:?}");
|
||||
}
|
||||
}
|
||||
// Tiles across many bands.
|
||||
let t = truth(300 * 260 * 3, 77);
|
||||
let d = Dir { entries: base(300, 260, 16, 3, 2), chunks: tile_split(&u16s(&t.u16s, false), 300, 260, 3, 2, 64, 32, false), tiled: true }
|
||||
.tag(322, SHORT, &[64])
|
||||
.tag(323, SHORT, &[32]);
|
||||
assert_eq!(decode_ok(&build(false, true, &[d], &[0]).bytes).data(), &ne16(&t.u16s)[..]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bigtiff_is_written_on_request_and_reads_back() {
|
||||
let mut img = common::synth(23, 17, ChannelLayout::Rgba, SampleType::U16, 2, 0.3);
|
||||
img.meta.photoshop_layers = Some(b"Adobe Photoshop Document Data Block\0MIB8ryaL".to_vec());
|
||||
img.meta.photoshop_resources = Some(b"8BIM\x04\x04\0\0\0\0\0\0".to_vec());
|
||||
for c in [TiffCompression::None, TiffCompression::Deflate, TiffCompression::Lzw] {
|
||||
let classic = encode(&img, Format::Tiff, &EncodeOptions { tiff_compression: c, ..Default::default() }).unwrap();
|
||||
let big = encode(&img, Format::Tiff, &EncodeOptions { tiff_compression: c, tiff_bigtiff: true, ..Default::default() }).unwrap();
|
||||
let version = |b: &[u8]| if b.starts_with(b"II") { u16::from_le_bytes([b[2], b[3]]) } else { u16::from_be_bytes([b[2], b[3]]) };
|
||||
assert_eq!((version(&classic), version(&big)), (42, 43));
|
||||
assert!(tiff_info(&big).unwrap().big_tiff);
|
||||
let back = decode_ok(&big);
|
||||
assert_eq!(back.data(), img.data(), "{c:?}");
|
||||
assert_eq!(back.meta.photoshop_layers, img.meta.photoshop_layers);
|
||||
assert_eq!(back.meta.photoshop_resources, img.meta.photoshop_resources);
|
||||
assert_eq!(tiff_photoshop_tags(&big), (img.meta.photoshop_resources.as_deref(), img.meta.photoshop_layers.as_deref()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Small files covering every structure: multi-IFD, SubIFDs, BigTIFF both orders, tiles, planar.
|
||||
fn zoo() -> Vec<Vec<u8>> {
|
||||
let mut v = Vec::new();
|
||||
let t = truth(6 * 5 * 4, 5);
|
||||
for (little, big) in ORDERS {
|
||||
let sub_ty = if big { IFD8 } else { IFD };
|
||||
let tiles = Dir { entries: base(6, 5, 8, 3, 2), chunks: tile_split(&t.u8s, 6, 5, 3, 1, 16, 16, true), tiled: true }
|
||||
.tag(322, SHORT, &[16])
|
||||
.tag(323, SHORT, &[16])
|
||||
.tag(284, SHORT, &[2]);
|
||||
let planar = Dir { entries: base(6, 5, 16, 4, 2), chunks: planar_strips(&u16s(&t.u16s, little), 6, 5, 4, 2, 2), tiled: false }
|
||||
.tag(278, SHORT, &[2])
|
||||
.tag(284, SHORT, &[2])
|
||||
.tag(338, SHORT, &[1]);
|
||||
let dirs = vec![gray_page(3, 2, 1, Some(1)), tiles.sub_ifds(sub_ty, &[3]), planar, gray_page(2, 2, 4, Some(1)).tag(274, SHORT, &[6])];
|
||||
v.push(build(little, big, &dirs, &[0, 1, 2]).bytes);
|
||||
v.push(build(little, big, &[strips(5, 3, 1, 1, 0, 2, &[0xa5; 3])], &[0]).bytes);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn poke(bytes: &[u8]) {
|
||||
let _ = decode(bytes);
|
||||
let _ = decode_with(bytes, &DecodeOptions { limits: Limits { max_alloc: 16 << 20, ..Limits::default() }, ..Default::default() });
|
||||
let _ = exif_orientation(bytes);
|
||||
let _ = tiff_orientation(bytes);
|
||||
if let Ok(info) = tiff_info(bytes) {
|
||||
for i in 0..info.pages.len().min(8) {
|
||||
let _ = decode_tiff_page(bytes, i, &DecodeOptions::default());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoo_decodes_and_never_panics_when_cut_or_corrupted() {
|
||||
for f in zoo() {
|
||||
let info = tiff_info(&f).unwrap();
|
||||
for i in 0..info.pages.len() {
|
||||
decode_tiff_page(&f, i, &DecodeOptions::default()).unwrap();
|
||||
}
|
||||
for cut in 0..f.len() {
|
||||
poke(&f[..cut]);
|
||||
}
|
||||
for i in 0..f.len() {
|
||||
for flip in [0xffu8, 0x80, 0x01] {
|
||||
let mut g = f.clone();
|
||||
g[i] ^= flip;
|
||||
poke(&g);
|
||||
}
|
||||
}
|
||||
let mut r = Rng::new(f.len() as u64);
|
||||
for _ in 0..300 {
|
||||
let mut g = f.clone();
|
||||
for _ in 0..4 {
|
||||
let i = (r.next_u64() as usize) % g.len();
|
||||
g[i] = r.next_u64() as u8;
|
||||
}
|
||||
poke(&g);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A longer random-mutation run over the zoo (ignored by default; `-- --ignored`, with
|
||||
/// `TIFF_FUZZ_ITERS` to change the count).
|
||||
#[test]
|
||||
#[ignore = "long fuzz run"]
|
||||
fn zoo_long_random_mutations() {
|
||||
let iters: usize = std::env::var("TIFF_FUZZ_ITERS").ok().and_then(|v| v.parse().ok()).unwrap_or(20_000);
|
||||
let mut r = Rng::new(0x5eed);
|
||||
let zoo = zoo();
|
||||
for k in 0..iters {
|
||||
let mut g = zoo[k % zoo.len()].clone();
|
||||
for _ in 0..1 + r.next_u64() % 8 {
|
||||
let i = (r.next_u64() as usize) % g.len();
|
||||
g[i] = match r.next_u64() % 4 {
|
||||
0 => 0,
|
||||
1 => 0xff,
|
||||
_ => r.next_u64() as u8,
|
||||
};
|
||||
}
|
||||
poke(&g);
|
||||
}
|
||||
}
|
||||
+22
-12
@@ -17,18 +17,7 @@ const BAND_BYTES: usize = 32 << 20;
|
||||
/// layered (see [`crate::tiff_layers`]).
|
||||
pub fn import_flat(name: &str, bytes: &[u8]) -> Result<ImportResult, IoError> {
|
||||
if codecs::detect(bytes) == Some(Format::Tiff) {
|
||||
// The orientation is applied after the layer check: rotating the composite but not the
|
||||
// layers would misalign them, so a layered TIFF keeps its stored orientation.
|
||||
let orientation = codecs::exif_orientation(bytes);
|
||||
let img = codecs::decode_with(bytes, &codecs::DecodeOptions { keep_orientation: true, ..Default::default() })?;
|
||||
if let Some(layers) = img.meta.photoshop_layers.as_deref().filter(|l| !l.is_empty()) {
|
||||
let mut r = crate::tiff_layers::import_layered(name, &img, layers)?;
|
||||
if orientation != 1 {
|
||||
r.warnings.push("the TIFF's orientation tag was ignored so the layers stay aligned with the image".to_string());
|
||||
}
|
||||
return Ok(r);
|
||||
}
|
||||
return image_to_document(name, &img.oriented(orientation)?);
|
||||
return import_tiff_page(name, bytes, None);
|
||||
}
|
||||
let img = codecs::decode(bytes)?;
|
||||
let mut r = image_to_document(name, &img)?;
|
||||
@@ -41,6 +30,27 @@ pub fn import_flat(name: &str, bytes: &[u8]) -> Result<ImportResult, IoError> {
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Opens one page of a TIFF or BigTIFF file: `None` is the page Photoshop opens (the first
|
||||
/// full-resolution one), `Some(i)` an index into [`codecs::tiff_info`]'s pages (every IFD and
|
||||
/// SubIFD). A page with Photoshop layer data opens layered.
|
||||
pub fn import_tiff_page(name: &str, bytes: &[u8], page: Option<usize>) -> Result<ImportResult, IoError> {
|
||||
// The orientation is applied after the layer check: rotating the composite but not the
|
||||
// layers would misalign them, so a layered TIFF keeps its stored orientation.
|
||||
let keep = codecs::DecodeOptions { keep_orientation: true, ..Default::default() };
|
||||
let (img, orientation) = match page {
|
||||
None => (codecs::decode_with(bytes, &keep)?, codecs::tiff_orientation(bytes)),
|
||||
Some(p) => (codecs::decode_tiff_page(bytes, p, &keep)?, codecs::tiff_page_orientation(bytes, p)),
|
||||
};
|
||||
if let Some(layers) = img.meta.photoshop_layers.as_deref().filter(|l| !l.is_empty()) {
|
||||
let mut r = crate::tiff_layers::import_layered(name, &img, layers)?;
|
||||
if orientation != 1 {
|
||||
r.warnings.push("the TIFF's orientation tag was ignored so the layers stay aligned with the image".to_string());
|
||||
}
|
||||
return Ok(r);
|
||||
}
|
||||
image_to_document(name, &img.oriented(orientation)?)
|
||||
}
|
||||
|
||||
/// A decoded flat image as a single-layer document.
|
||||
pub(crate) fn image_to_document(name: &str, img: &Image) -> Result<ImportResult, IoError> {
|
||||
// What the decoder noticed (frames or pages left out, data ending early) comes first.
|
||||
|
||||
@@ -50,7 +50,7 @@ use photocraft_doc::Document;
|
||||
use photocraft_psd::{PsdError, PsdFile};
|
||||
|
||||
pub use adjust_map::ADJUSTMENT_KEYS;
|
||||
pub use flat::document_to_image;
|
||||
pub use flat::{document_to_image, import_tiff_page};
|
||||
pub use psd_export::{PsdExportOptions, document_to_psd, document_to_psd_with};
|
||||
pub use psd_import::{psd_to_document, psd_to_document_with};
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
//! Multi-page TIFF and BigTIFF open through `photocraft-io`: the default page is the first
|
||||
//! full-resolution one (like Photoshop), any page can be opened by index, and a BigTIFF's
|
||||
//! orientation is applied (#693).
|
||||
|
||||
/// A TIFF (classic or BigTIFF, little-endian) with one 8-bit gray page per `(w, h, level,
|
||||
/// NewSubfileType, orientation)`, chained in order.
|
||||
fn tiff(big: bool, pages: &[(u32, u32, u8, u32, u16)]) -> Vec<u8> {
|
||||
let mut f = Vec::new();
|
||||
f.extend_from_slice(b"II");
|
||||
if big {
|
||||
f.extend_from_slice(&43u16.to_le_bytes());
|
||||
f.extend_from_slice(&8u16.to_le_bytes());
|
||||
f.extend_from_slice(&0u16.to_le_bytes());
|
||||
f.extend_from_slice(&0u64.to_le_bytes());
|
||||
} else {
|
||||
f.extend_from_slice(&42u16.to_le_bytes());
|
||||
f.extend_from_slice(&0u32.to_le_bytes());
|
||||
}
|
||||
let mut next_field = if big { 8 } else { 4 };
|
||||
for &(w, h, level, kind, orientation) in pages {
|
||||
let pixels_at = f.len() as u64;
|
||||
f.extend(std::iter::repeat_n(level, (w * h) as usize));
|
||||
if f.len() % 2 == 1 {
|
||||
f.push(0);
|
||||
}
|
||||
let ifd_at = f.len() as u64;
|
||||
let width = if big { 8 } else { 4 };
|
||||
let put = |f: &mut Vec<u8>, at: usize, v: u64| f[at..at + width].copy_from_slice(&v.to_le_bytes()[..width]);
|
||||
put(&mut f, next_field, ifd_at);
|
||||
let entries: [(u16, u16, u64); 11] = [
|
||||
(254, 4, u64::from(kind)),
|
||||
(256, 4, u64::from(w)),
|
||||
(257, 4, u64::from(h)),
|
||||
(258, 3, 8),
|
||||
(259, 3, 1),
|
||||
(262, 3, 1),
|
||||
(273, 4, pixels_at),
|
||||
(274, 3, u64::from(orientation)),
|
||||
(277, 3, 1),
|
||||
(278, 4, u64::from(h)),
|
||||
(279, 4, u64::from(w * h)),
|
||||
];
|
||||
if big {
|
||||
f.extend_from_slice(&(entries.len() as u64).to_le_bytes());
|
||||
} else {
|
||||
f.extend_from_slice(&(entries.len() as u16).to_le_bytes());
|
||||
}
|
||||
for (tag, ty, v) in entries {
|
||||
f.extend_from_slice(&tag.to_le_bytes());
|
||||
f.extend_from_slice(&ty.to_le_bytes());
|
||||
if big {
|
||||
f.extend_from_slice(&1u64.to_le_bytes());
|
||||
f.extend_from_slice(&v.to_le_bytes());
|
||||
} else {
|
||||
f.extend_from_slice(&1u32.to_le_bytes());
|
||||
f.extend_from_slice(&(v as u32).to_le_bytes());
|
||||
}
|
||||
}
|
||||
next_field = f.len();
|
||||
f.extend(std::iter::repeat_n(0, width));
|
||||
}
|
||||
f
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_full_resolution_page_opens_and_any_page_on_request() {
|
||||
for big in [false, true] {
|
||||
// A thumbnail, then two pages.
|
||||
let bytes = tiff(big, &[(2, 2, 9, 1, 1), (6, 4, 50, 0, 1), (3, 5, 90, 2, 1)]);
|
||||
let r = photocraft_io::import("pages.tif", &bytes).unwrap();
|
||||
assert_eq!((r.document.size.width, r.document.size.height), (6, 4), "big {big}");
|
||||
assert!(r.warnings.iter().any(|w| w == "only the first of 2 pages was imported"), "{:?}", r.warnings);
|
||||
let info = photocraft_codecs::tiff_info(&bytes).unwrap();
|
||||
assert_eq!((info.pages.len(), info.default_page(), info.big_tiff), (3, Some(1), big));
|
||||
for (page, size) in [(0, (2, 2)), (1, (6, 4)), (2, (3, 5))] {
|
||||
let r = photocraft_io::import_tiff_page("pages.tif", &bytes, Some(page)).unwrap();
|
||||
assert_eq!((r.document.size.width, r.document.size.height), size, "page {page} big {big}");
|
||||
}
|
||||
assert!(photocraft_io::import_tiff_page("pages.tif", &bytes, Some(3)).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bigtiff_orientation_is_applied_on_open() {
|
||||
// #693: Orientation 6 turns the stored 6×4 landscape into a 4×6 portrait, in both containers.
|
||||
for big in [false, true] {
|
||||
let bytes = tiff(big, &[(6, 4, 50, 0, 6)]);
|
||||
let r = photocraft_io::import("turned.tif", &bytes).unwrap();
|
||||
assert_eq!((r.document.size.width, r.document.size.height), (4, 6), "big {big}");
|
||||
let r = photocraft_io::import_tiff_page("turned.tif", &bytes, Some(0)).unwrap();
|
||||
assert_eq!((r.document.size.width, r.document.size.height), (4, 6), "big {big}");
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -74,7 +74,7 @@ Confidence: moderate — the next users of 0.2.x will move these numbers either
|
||||
| Painting | Brush model and Brush Settings panel near Photoshop; .abr/.grd import; persistent presets; pen pressure/tilt on Windows, web, macOS and X11 | medium | Wayland pen input open (#79); macOS/X11 pressure not yet verified on tablet hardware. |
|
||||
| Text / typography | Engine works; caret placement and size editing fixed; OpenType features, text-on-path editing, composer parity partial | medium-low | Measure with the Photoshop-authored set. |
|
||||
| Colour management | Colour-managed canvas (document → monitor), embedded CMYK profiles, linear EXR/HDR, 16-bit float canvas | medium-high | Monitor profile follows only at launch. |
|
||||
| Performance | 14k+ px on the GPU at ~⅓ the memory; adjustment preview 285 ms → 4–9 ms; font-size edits 297 ms → 4.6 ms | medium-high on rasters | Complex layout documents still laggy (#125/#128); >16384 px GPU tiling in progress (#49). |
|
||||
| Performance | 14k+ px on the GPU at ~⅓ the memory; adjustment preview 285 ms → 4–9 ms; font-size edits 297 ms → 4.6 ms; 2026-10-07: 30 MP TIFF open (banded, parallel strip/tile decode) Deflate 345 → 32 ms, LZW 428 → 43 ms, BigTIFF and every IFD readable | medium-high on rasters | Complex layout documents still laggy (#125/#128); >16384 px GPU tiling in progress (#49). |
|
||||
| Stability | Never-crash lint series, crash guard, `panic_hunt` fuzzing in the gate | medium-high | No field crash data yet. |
|
||||
| Camera RAW | DNG, CR2, Sony ARW (lossless + compressed), RW2, uncompressed ORF | medium | Nikon compressed NEF, CR3, RAF blocked by clean-room limits (#50). |
|
||||
| AI / generative | none | ~0% | Deferred by decision (#41). |
|
||||
|
||||
+2
-2
@@ -7,7 +7,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js
|
||||
| Area | Done | Partial | Missing | Key numbers |
|
||||
|---|---:|---:|---:|---|
|
||||
| [Performance](#performance) | 3 | 22 | 13 | 47 scenarios: 3 within budget, 22 over budget or failing, 15 tracked only, 6 not run, 7 not measurable yet (baseline 2026-10-05) |
|
||||
| [File compatibility](#file-compatibility) | 2 | 9 | 16 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 229/309, round trip ≥ 307/309; photoshop oracles: oracle ≥ 132/256, round trip ≥ 258/256 |
|
||||
| [File compatibility](#file-compatibility) | 3 | 9 | 15 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 229/309, round trip ≥ 307/309; photoshop oracles: oracle ≥ 132/256, round trip ≥ 258/256 |
|
||||
| [Tools](#tools) | 6 | 14 | 10 | |
|
||||
| [Workspace and UI](#workspace-and-ui) | 0 | 5 | 18 | settings that do nothing: 57 of 135 (target 0) |
|
||||
| [Type](#type) | 0 | 3 | 6 | |
|
||||
@@ -96,7 +96,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t
|
||||
| FILE-215-5 | Smart Filter rows: collapse, mask thumb, blend icon, drag reorder, context menu, live preview | partial | [#215](https://github.com/storytold/photocraft/issues/215) | smart_ui.rs:31 rows + visibility + editor; no collapse, drag or context menu |
|
||||
| FILE-215-6 | Smart Object corner skew and perspective (quad or homography transform) | missing | [#215](https://github.com/storytold/photocraft/issues/215) | doc lib.rs:312 SmartObject.transform is an Affine |
|
||||
| FILE-215-7 | Layered TIFF read and write (tag 37724), honouring the ask-before-saving pref | done | [#215](https://github.com/storytold/photocraft/issues/215) | psd tiff.rs (ImageSourceData, both byte orders), io tiff_layers.rs, ui tiff_options_ui.rs (Discard Layers and Save a Copy); extra alpha channels are not written (one transparency sample) |
|
||||
| FILE-215-8 | Large and BigTIFF import with banded decode across all IFDs | missing | [#215](https://github.com/storytold/photocraft/issues/215) | codecs tiff.rs:3 'Only the first IFD is read' |
|
||||
| FILE-215-8 | Large and BigTIFF import with banded decode across all IFDs | done | [#215](https://github.com/storytold/photocraft/issues/215) | codecs tiff_ifd.rs (bounds-checked IFD chain + SubIFD walk, classic and BigTIFF, cycle-safe; tiff_info / decode_tiff_page), tiff.rs banded parallel strip/tile decode, planar 1 and 2, BigTIFF write (tiff_bigtiff or > 4 GiB); io import_tiff_page; 30 MP RGB8 Deflate 345 -> 32 ms; no page picker in the UI yet |
|
||||
| FILE-215-9 | SVG opens and places as shape layers or a vector Smart Object | missing | [#215](https://github.com/storytold/photocraft/issues/215) | No SVG codec or import |
|
||||
| FILE-215-10 | Multi-page PDF save, PDF presentation, layer comps to PDF, vector/text output, PDF import | missing | [#215](https://github.com/storytold/photocraft/issues/215) | Only print_cmds.rs:133 print_pdf (raster print page) |
|
||||
| FILE-215-11 | RAW: lossy DNG, highlight modes, .xmp sidecars, RAW prefs wired, per-camera corpus | missing | [#215](https://github.com/storytold/photocraft/issues/215) | raw sensor.rs:210 lossy DNG unsupported; raw_defaults unread; no xmp |
|
||||
|
||||
@@ -57,9 +57,9 @@ note = "psd tiff.rs (ImageSourceData, both byte orders), io tiff_layers.rs, ui t
|
||||
[[item]]
|
||||
id = "FILE-215-8"
|
||||
target = "Large and BigTIFF import with banded decode across all IFDs"
|
||||
status = "missing"
|
||||
status = "done"
|
||||
issue = 215
|
||||
note = "codecs tiff.rs:3 'Only the first IFD is read'"
|
||||
note = "codecs tiff_ifd.rs (bounds-checked IFD chain + SubIFD walk, classic and BigTIFF, cycle-safe; tiff_info / decode_tiff_page), tiff.rs banded parallel strip/tile decode, planar 1 and 2, BigTIFF write (tiff_bigtiff or > 4 GiB); io import_tiff_page; 30 MP RGB8 Deflate 345 -> 32 ms; no page picker in the UI yet"
|
||||
|
||||
[[item]]
|
||||
id = "FILE-215-9"
|
||||
|
||||
Reference in New Issue
Block a user