TIFF: planar grayscale files with extra channels, and planar CMYK files, open and place

TIFFs that store each channel as its own plane (PlanarConfiguration 2)
did not open, place or relink when they were CMYK or grayscale with
extra channels: the image decoder refuses planar CMYK, and the readers
for CMYK inks and for extra samples returned nothing for planar files.

The two readers now read every plane through the tiff crate and put
each pixel's samples together. A planar CMYK TIFF keeps its ink
amounts and is stored as a CMYK TIFF with each pixel's inks together,
at the file's resolution. Grayscale TIFFs with an alpha channel or
other extra channels, and CMYK TIFFs with alpha, read as their chunky
versions do.

A TIFF whose header claims more strips or tiles than the tiff decoder
can count is refused as too large to draw. The decoder multiplies
those counts without a check, which panics in builds with overflow
checks.

Part of #1466.

Docs: docs/file-format-parity.md, docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-10-17 13:49:49 +07:00
committed by bflatastic
parent 5a3e833ec8
commit 3fc78187ec
8 changed files with 451 additions and 43 deletions
+203 -34
View File
@@ -2,10 +2,12 @@
//!
//! Two encodings hold them: a four-component JPEG, whose samples are stored inverted with Adobe's
//! marker (the convention JPEG readers and PDF writers take a CMYK JPEG to follow), and a CMYK
//! TIFF (8 or 16 bits, chunky, ink amounts as they are). [`ImageBlob::cmyk_tiff`] writes the
//! latter, Deflate-compressed. Every other image decodes to RGB(A) only.
//! TIFF (8 or 16 bits, ink amounts as they are, each pixel's inks together or one plane per ink).
//! [`ImageBlob::cmyk_tiff`] writes the latter, inks together, Deflate-compressed. Every other image
//! decodes to RGB(A) only.
use std::io::Cursor;
use std::sync::Arc;
use zune_jpeg::zune_core::bytestream::ZCursor;
use zune_jpeg::zune_core::colorspace::ColorSpace;
@@ -54,25 +56,136 @@ fn is_tiff(bytes: &[u8]) -> bool {
bytes.starts_with(b"II*\0") || bytes.starts_with(b"MM\0*")
}
/// The colour type of TIFF `bytes` and its decoder.
/// The width and height of the first image of TIFF `bytes` when it has more strips or tiles than
/// the `tiff` decoder can count: strips per plane times planes past `u32::MAX`, or tiles per plane
/// times planes past `usize::MAX`. A file that stores one plane per channel (PlanarConfiguration 2)
/// has a plane for each channel; other files have one. The decoder multiplies these counts without
/// a check, which panics where overflow checks are on. Such an image, when it is at least a pixel
/// wide, claims at least 2^32 samples.
/// Where a tag repeats, the value giving the most strips or tiles counts. `None` for other images
/// and for bytes that aren't a TIFF.
pub fn oversized_tiff(bytes: &[u8]) -> Option<(u32, u32)> {
let le = bytes.starts_with(b"II*\0");
if !le && !bytes.starts_with(b"MM\0*") {
return None;
}
let u16_at = |at: usize| -> Option<u16> {
let b: [u8; 2] = bytes.get(at..at.checked_add(2)?)?.try_into().ok()?;
Some(if le { u16::from_le_bytes(b) } else { u16::from_be_bytes(b) })
};
let u32_at = |at: usize| -> Option<u32> {
let b: [u8; 4] = bytes.get(at..at.checked_add(4)?)?.try_into().ok()?;
Some(if le { u32::from_le_bytes(b) } else { u32::from_be_bytes(b) })
};
let ifd = usize::try_from(u32_at(4)?).ok()?;
// (tag, the entry's single BYTE, SHORT, LONG or IFD value, which sits in the entry, or its
// single LONG8 or IFD8 value that fits in 32 bits, which sits where the entry points)
let entry = |i: usize| -> Option<(u16, Option<u32>)> {
let e = ifd.checked_add(i.checked_mul(12)?.checked_add(2)?)?;
let (ty, count, at) = (u16_at(e.checked_add(2)?)?, u32_at(e.checked_add(4)?)?, e.checked_add(8)?);
let value = match (ty, count) {
(1, 1) => bytes.get(at).map(|&b| u32::from(b)),
(3, 1) => u16_at(at).map(u32::from),
(4 | 13, 1) => u32_at(at),
(16 | 18, 1) => {
let at = usize::try_from(u32_at(at)?).ok()?;
let (lo, hi) = (u32_at(at)?, u32_at(at.checked_add(4)?)?);
let wide = if le { u64::from(hi) << 32 | u64::from(lo) } else { u64::from(lo) << 32 | u64::from(hi) };
u32::try_from(wide).ok()
}
_ => None,
};
Some((u16_at(e)?, value))
};
let (mut width, mut height, mut planes, mut planar) = (0u32, 0u32, 1u32, false);
let (mut rows, mut tile_w, mut tile_h) = (None::<u32>, None::<u32>, None::<u32>);
let least = |was: Option<u32>, v: u32| Some(was.map_or(v, |w| w.min(v)));
for i in 0..usize::from(u16_at(ifd)?) {
let Some((tag, value)) = entry(i) else { break };
let Some(v) = value else { continue };
match tag {
256 => width = width.max(v),
257 => height = height.max(v),
277 => planes = planes.max(v),
278 => rows = least(rows, v),
284 => planar |= v == 2,
322 => tile_w = least(tile_w, v),
323 => tile_h = least(tile_h, v),
_ => {}
}
}
let planes = if planar { u128::from(planes) } else { 1 };
let tiles = match (tile_w, tile_h) {
(Some(tw @ 1..), Some(th @ 1..)) => u128::from(width.div_ceil(tw)) * u128::from(height.div_ceil(th)) * planes > usize::MAX as u128,
_ => false,
};
let rows = rows.unwrap_or(height);
let strips = rows > 0 && u128::from(height.saturating_sub(1) / rows + 1) * planes > u128::from(u32::MAX);
(tiles || strips).then_some((width, height))
}
/// The color type of TIFF `bytes` and its decoder; `None` for [`oversized_tiff`] images too.
fn tiff(bytes: &[u8]) -> Option<(tiff::ColorType, tiff::decoder::Decoder<Cursor<&[u8]>>)> {
if !is_tiff(bytes) {
if !is_tiff(bytes) || oversized_tiff(bytes).is_some() {
return None;
}
let mut d = tiff::decoder::Decoder::new(Cursor::new(bytes)).ok()?;
Some((d.colortype().ok()?, d))
}
/// Does the TIFF `d` reads store one plane per channel (PlanarConfiguration 2)?
fn planar(d: &mut tiff::decoder::Decoder<Cursor<&[u8]>>) -> bool {
d.find_tag_unsigned::<u16>(tiff::tags::Tag::PlanarConfiguration).ok().flatten().is_some_and(|p| p != 1)
}
/// The samples of the `n` pixels of the TIFF `d` reads, `per` to a pixel (the number its color
/// type has), pixel by pixel and 8 bits each (16-bit samples to their high byte), whether the file
/// stores each pixel's samples together or one plane per channel. `None` when they don't decode to
/// that many samples, which includes images whose planes together need more than the decoder's
/// limit (256 MB of samples).
fn read_samples(d: &mut tiff::decoder::Decoder<Cursor<&[u8]>>, n: usize, per: usize) -> Option<Vec<u8>> {
let mut buf = tiff::decoder::DecodingResult::U8(Vec::new());
let layout = d.read_image_to_buffer(&mut buf).ok()?;
let data = match buf {
tiff::decoder::DecodingResult::U8(v) => v,
tiff::decoder::DecodingResult::U16(v) => v.iter().map(|s| (s >> 8) as u8).collect(),
_ => return None,
};
if Some(data.len()) != n.checked_mul(per) {
return None;
}
if layout.planes <= 1 {
return Some(data);
}
if layout.planes != per {
return None;
}
let planes: Vec<&[u8]> = data.chunks_exact(n).collect();
Some((0..n).flat_map(|i| planes.iter().map(move |p| p.get(i).copied().unwrap_or(0))).collect())
}
impl ImageBlob {
/// A CMYK TIFF of ink amounts `inks` (Deflate-compressed); `None` when it can't be written.
pub fn cmyk_tiff(inks: &Inks) -> Option<Self> {
use tiff::encoder::{Compression, TiffEncoder, colortype::CMYK8, compression::DeflateLevel};
Self::cmyk_tiff_at(inks, None)
}
/// [`ImageBlob::cmyk_tiff`], declaring the resolution `ppi` (horizontal, vertical pixels per
/// inch) when it is given and both are finite and above 0.
pub fn cmyk_tiff_at(inks: &Inks, ppi: Option<(f64, f64)>) -> Option<Self> {
use tiff::encoder::{Compression, Rational, TiffEncoder, colortype::CMYK8, compression::DeflateLevel};
use tiff::tags::{ResolutionUnit, Tag};
let mut out = Cursor::new(Vec::new());
TiffEncoder::new(&mut out)
.ok()?
.with_compression(Compression::Deflate(DeflateLevel::Balanced))
.write_image::<CMYK8>(inks.width, inks.height, &inks.data)
.ok()?;
let mut enc = TiffEncoder::new(&mut out).ok()?.with_compression(Compression::Deflate(DeflateLevel::Balanced));
let mut img = enc.new_image::<CMYK8>(inks.width, inks.height).ok()?;
if let Some((x, y)) = ppi.filter(|(x, y)| [x, y].iter().all(|v| v.is_finite() && **v > 0.0)) {
// Ten-thousandths of a pixel per inch.
let r = |v: f64| Rational { n: (v * 10_000.0).round() as u32, d: 10_000 };
img.encoder().write_tag(Tag::XResolution, r(x)).ok()?;
img.encoder().write_tag(Tag::YResolution, r(y)).ok()?;
img.encoder().write_tag(Tag::ResolutionUnit, ResolutionUnit::Inch).ok()?;
}
img.write_data(&inks.data).ok()?;
Some(Self::new("image/tiff", out.into_inner()))
}
@@ -119,34 +232,42 @@ fn jpeg_inks(bytes: &[u8]) -> Option<Inks> {
Inks::new(w, h, data)
}
/// The ink amounts of a chunky CMYK TIFF (16-bit samples to 8 bits).
/// The ink amounts of a CMYK TIFF (16-bit samples to 8 bits), its inks stored together or one plane
/// per ink.
fn tiff_inks(bytes: &[u8]) -> Option<Inks> {
let (kind, mut d) = tiff(bytes)?;
let (w, h) = d.dimensions().ok()?;
// Planar samples (one plane per ink) would read as interleaved.
let planar = d.find_tag_unsigned::<u16>(tiff::tags::Tag::PlanarConfiguration).ok().flatten().is_some_and(|p| p != 1);
if !matches!(kind, tiff::ColorType::CMYK(8 | 16)) || planar || pixels(w, h).is_none() {
if !matches!(kind, tiff::ColorType::CMYK(8 | 16)) {
return None;
}
let data = match d.read_image().ok()? {
tiff::decoder::DecodingResult::U8(v) => v,
tiff::decoder::DecodingResult::U16(v) => v.iter().map(|s| (s >> 8) as u8).collect(),
_ => return None,
};
Inks::new(w, h, data)
let n = usize::try_from(pixels(w, h)?).ok()?;
Inks::new(w, h, read_samples(&mut d, n, 4)?)
}
/// CMYK TIFF `bytes` as Open and Place store them: as they are, or, when they store one plane per
/// ink, which the image decoder doesn't read, their ink amounts written again by
/// [`ImageBlob::cmyk_tiff_at`] with the resolution `ppi`. `None` for other images and for CMYK
/// TIFFs whose inks [`ImageBlob::cmyk`] doesn't read.
pub fn stored_cmyk_tiff(bytes: &[u8], ppi: Option<(f64, f64)>) -> Option<Vec<u8>> {
let inks = tiff_inks(bytes)?;
let (_, mut d) = tiff(bytes)?;
if !planar(&mut d) {
return Some(bytes.to_vec());
}
ImageBlob::cmyk_tiff_at(&inks, ppi).map(|b| Arc::unwrap_or_clone(b.bytes))
}
/// A TIFF whose pixels have samples after their colour, which the image decoder doesn't read at
/// all, as RGBA pixels: CMYK with an alpha channel (5 samples), each colour converted by `rgb` (ink
/// amounts 0..1 → sRGB 0..1; the inks can't be kept with the transparency, so they show as RGB),
/// and grayscale with extra samples, of which the first is the alpha when the file says so (other
/// channels are left out). Associated (premultiplied) alpha is divided back out. `None` for other
/// images, planar ones and ones larger than [`MAX_CMYK_PIXELS`].
/// channels are left out). Associated (premultiplied) alpha is divided back out. The samples may be
/// stored together or one plane per channel. `None` for other images and ones larger than
/// [`MAX_CMYK_PIXELS`].
pub fn extra_samples_tiff_rgba(bytes: &[u8], rgb: impl Fn([f32; 4]) -> [f32; 3]) -> Option<image::RgbaImage> {
use tiff::tags::Tag;
let (kind, mut d) = tiff(bytes)?;
let (w, h) = d.dimensions().ok()?;
let planar = d.find_tag_unsigned::<u16>(Tag::PlanarConfiguration).ok().flatten().is_some_and(|p| p != 1);
// 0: white is zero, 1: black is zero (the two grayscales).
let photometric = d.find_tag_unsigned::<u16>(Tag::PhotometricInterpretation).ok().flatten();
// ExtraSamples 1: associated alpha (premultiplied colour), 2: unassociated alpha, 0: not alpha.
@@ -159,18 +280,8 @@ pub fn extra_samples_tiff_rgba(bytes: &[u8], rgb: impl Fn([f32; 4]) -> [f32; 3])
}
_ => return None,
};
if planar {
return None;
}
let n = usize::try_from(pixels(w, h)?).ok()?;
let data: Vec<u8> = match d.read_image().ok()? {
tiff::decoder::DecodingResult::U8(v) => v,
tiff::decoder::DecodingResult::U16(v) => v.iter().map(|s| (s >> 8) as u8).collect(),
_ => return None,
};
if Some(data.len()) != n.checked_mul(samples) {
return None;
}
let data = read_samples(&mut d, n, samples)?;
let mut out = Vec::with_capacity(n * 4);
for p in data.chunks_exact(samples) {
let opacity = if alpha { p.get(colour).copied().unwrap_or(u8::MAX) } else { u8::MAX };
@@ -246,6 +357,64 @@ mod tests {
assert_eq!(gray(0), [255, 255, 255, 255, 51, 51, 51, 255, 0, 0, 0, 255], "a channel that isn't alpha: opaque");
}
/// A header claiming more tiles than can be counted: (2^32 − 1)² pixels in tiles of one pixel,
/// times 2 planes.
#[test]
fn tiffs_with_more_tiles_than_can_be_counted_are_oversized() {
let header = |planar: u32| {
let mut f = b"II*\0\x08\0\0\0".to_vec();
let entries = [(256u16, u32::MAX), (257, u32::MAX), (258, 8), (262, 1), (277, 2), (284, planar), (322, 1), (323, 1), (324, 0), (325, 0)];
f.extend(u16::try_from(entries.len()).unwrap().to_le_bytes());
for (tag, v) in entries {
f.extend([tag.to_le_bytes(), 4u16.to_le_bytes()].concat());
f.extend([1u32.to_le_bytes(), v.to_le_bytes()].concat());
}
f.extend([0; 4]);
f
};
assert_eq!(oversized_tiff(&header(2)), Some((u32::MAX, u32::MAX)));
assert_eq!(oversized_tiff(&header(1)), None, "one plane: (2^32 − 1)² tiles can be counted in 64 bits");
assert!(tiff(&header(2)).is_none(), "not decoded");
}
/// A header claiming more strips than can be counted: 2^31 + 1 rows, a strip each, times 2
/// planes. The decoder counts strips when the file has strip offsets, tile sizes or not, and
/// reads a LONG8 value from where the entry points.
#[test]
fn tiffs_with_more_strips_than_can_be_counted_are_oversized() {
// Each entry holds one value of type 3 (SHORT), 4 (LONG) or 16 (LONG8, after the directory).
let header = |entries: &[(u16, u16, u32)]| {
let after = 8 + 2 + 12 * entries.len() + 4;
let mut f = b"II*\0\x08\0\0\0".to_vec();
f.extend(u16::try_from(entries.len()).unwrap().to_le_bytes());
let mut wide = vec![];
for &(tag, ty, v) in entries {
f.extend([tag.to_le_bytes(), ty.to_le_bytes()].concat());
f.extend(1u32.to_le_bytes());
if ty == 16 {
f.extend(u32::try_from(after + wide.len()).unwrap().to_le_bytes());
wide.extend(u64::from(v).to_le_bytes());
} else {
f.extend(v.to_le_bytes());
}
}
f.extend([0; 4]);
f.extend(wide);
f
};
let tall = 1u32 << 31 | 1;
let strips = [(256, 4, 1), (257, 4, tall), (258, 3, 8), (262, 3, 1), (273, 4, 0), (277, 3, 2), (278, 4, 1), (279, 4, 0), (284, 3, 2)];
assert_eq!(oversized_tiff(&header(&strips)), Some((1, tall)));
let tile_sizes = [&strips[..], &[(322, 4, u32::MAX), (323, 4, u32::MAX)]].concat();
assert_eq!(oversized_tiff(&header(&tile_sizes)), Some((1, tall)), "tile sizes without tile offsets");
let mut long8 = strips;
long8[1] = (257, 16, tall);
assert_eq!(oversized_tiff(&header(&long8)), Some((1, tall)), "ImageLength as a LONG8");
for h in [header(&tile_sizes), header(&long8)] {
assert!(tiff(&h).is_none(), "not decoded");
}
}
fn inks() -> Inks {
Inks::new(3, 2, vec![0, 40, 100, 0, 255, 0, 0, 0, 0, 255, 0, 0, 0, 0, 255, 0, 0, 0, 0, 255, 12, 34, 56, 78]).unwrap()
}