diff --git a/crates/images/src/lib.rs b/crates/images/src/lib.rs index 7ce48c0a..d59f4741 100644 --- a/crates/images/src/lib.rs +++ b/crates/images/src/lib.rs @@ -150,6 +150,9 @@ pub fn natural_size(bytes: &[u8]) -> Option<(f64, f64)> { /// Pixel dimensions of a raster. pub fn pixel_size(bytes: &[u8]) -> Option<(u32, u32)> { + if let Some(p) = palette_tiff(bytes) { + return Some((p.width, p.height)); + } if is_psd(bytes) && bytes.len() >= 26 { // Header: signature, version, 6 reserved, channels, height, width (big-endian). let be = |i: usize| u32::from_be_bytes([bytes[i], bytes[i + 1], bytes[i + 2], bytes[i + 3]]); @@ -163,6 +166,10 @@ pub fn decode_rgba(bytes: &[u8]) -> Option { if is_psd(bytes) { return psd::decode(bytes); } + // `image` rejects indexed-colour TIFFs (an old GIF logo saved from Photoshop). + if let Some(img) = decode_palette_tiff(bytes) { + return Some(img); + } // An EPS is drawn as its proxy (preview, simple PostScript or placeholder); the document // keeps the EPS itself. if is_eps(bytes) { @@ -228,6 +235,98 @@ fn tiff_decoder(bytes: &[u8]) -> Option, +} + +/// The header of a palette TIFF with 1-, 2-, 4- or 8-bit indices and a full ColorMap, which +/// neither `image` nor `tiff` decodes. `None` for anything else. +fn palette_tiff(bytes: &[u8]) -> Option { + use tiff::tags::Tag; + let mut d = tiff_decoder(bytes)?; + if d.find_tag_unsigned::(Tag::PhotometricInterpretation).ok()?? != 3 { + return None; + } + if d.find_tag_unsigned::(Tag::SamplesPerPixel).ok()?.unwrap_or(1) != 1 { + return None; + } + let bits = d.find_tag_unsigned::(Tag::BitsPerSample).ok()?.unwrap_or(1); + if !matches!(bits, 1 | 2 | 4 | 8) { + return None; + } + // 2^bits reds, then greens, then blues; 16-bit entries whose high byte is the 8-bit value. + let map = d.find_tag_unsigned_vec::(Tag::ColorMap).ok()??; + let n = 1usize << bits; + let (r, gb) = map.split_at_checked(n)?; + let (g, b) = gb.split_at_checked(n)?; + if b.len() != n { + return None; + } + let colours = r.iter().zip(g).zip(b).map(|((&r, &g), &b)| [r, g, b].map(|v| (v >> 8) as u8)).collect(); + let (width, height) = d.dimensions().ok()?; + Some(PaletteTiff { width, height, bits, colours }) +} + +/// A palette TIFF as RGBA, each index looked up in the ColorMap. +fn decode_palette_tiff(bytes: &[u8]) -> Option { + let p = palette_tiff(bytes)?; + // Read as gray, `tiff` hands back the indices themselves, whatever the compression or layout. + let gray = with_photometric(bytes, 1)?; + let tiff::decoder::DecodingResult::U8(px) = tiff_decoder(&gray)?.read_image().ok()? else { return None }; + let (w, h) = (usize::try_from(p.width).ok()?, usize::try_from(p.height).ok()?); + let bits = usize::from(p.bits); + // Indices below 8 bits are packed high bits first, each row starting on a byte. + let row = w.checked_mul(bits)?.div_ceil(8); + if row == 0 || px.len() < row.checked_mul(h)? { + return None; + } + let per_byte = 8 / bits; + let mask = ((1u16 << bits) - 1) as u8; + let mut out = Vec::new(); + out.try_reserve_exact(w.checked_mul(h)?.checked_mul(4)?).ok()?; + for line in px.chunks_exact(row).take(h) { + for x in 0..w { + let shift = 8 - bits * (x % per_byte + 1); + let i = (line.get(x / per_byte)? >> shift) & mask; + let [r, g, b] = *p.colours.get(usize::from(i))?; + out.extend([r, g, b, 255]); + } + } + image::RgbaImage::from_raw(p.width, p.height, out) +} + +/// A copy of a TIFF with its first image's PhotometricInterpretation (tag 262, one SHORT) set +/// to `value`. Classic and BigTIFF, either byte order. +fn with_photometric(bytes: &[u8], value: u16) -> Option> { + let le = bytes.starts_with(b"II"); + let int = |at: usize, len: usize| -> Option { + let b = bytes.get(at..at.checked_add(len)?)?; + let v = |acc: u64, &x: &u8| acc << 8 | u64::from(x); + usize::try_from(if le { b.iter().rev().fold(0, v) } else { b.iter().fold(0, v) }).ok() + }; + // Classic: 4-byte offsets and value counts, 2-byte entry count, 12-byte entries. BigTIFF: + // 8, 8, 8 and 20. + let (ifd, word, count_len, entry_len) = if int(2, 2)? == 43 { (int(8, 8)?, 8, 8, 20) } else { (int(4, 4)?, 4, 2, 12) }; + let count = int(ifd, count_len)?; + for k in 0..count { + let at = ifd.checked_add(count_len)?.checked_add(k.checked_mul(entry_len)?)?; + // Tag, type (3 = SHORT), value count, then the value itself. + if int(at, 2)? == 262 && int(at + 2, 2)? == 3 && int(at + 4, word)? == 1 { + let mut out = bytes.to_vec(); + let v = if le { value.to_le_bytes() } else { value.to_be_bytes() }; + let value_at = at + 4 + word; + out.get_mut(value_at..value_at + 2)?.copy_from_slice(&v); + return Some(out); + } + } + None +} + /// A TIFF whose pixels are CMYK (PhotometricInterpretation Separated, four inks). pub fn is_cmyk_tiff(bytes: &[u8]) -> bool { tiff_decoder(bytes).and_then(|mut d| d.colortype().ok()).is_some_and(|c| matches!(c, tiff::ColorType::CMYK(_) | tiff::ColorType::CMYKA(_))) @@ -435,6 +534,59 @@ mod tests { assert_eq!(decode_cmyk_tiff(&b[..b.len() / 2]), None, "truncated"); } + /// Indexed colour (PhotometricInterpretation 3), which the `tiff` encoder has no type for. + struct Palette; + impl tiff::encoder::colortype::ColorType for Palette { + type Inner = u8; + const TIFF_VALUE: tiff::tags::PhotometricInterpretation = tiff::tags::PhotometricInterpretation::RGBPalette; + const BITS_PER_SAMPLE: &'static [u16] = &[BITS]; + const SAMPLE_FORMAT: &'static [tiff::tags::SampleFormat] = &[tiff::tags::SampleFormat::Uint]; + fn horizontal_predict(row: &[u8], result: &mut Vec) { + result.extend_from_slice(row); + } + } + + /// A palette TIFF as Photoshop and Pillow write it: 16-bit ColorMap entries (reds, then + /// greens, then blues), each 8-bit value times 256. + fn palette_tiff(w: u32, h: u32, px: &[u8], colours: &[[u8; 3]], lzw: bool) -> Vec { + let n = 1usize << BITS; + let mut map = vec![0u16; 3 * n]; + for (i, c) in colours.iter().enumerate() { + for (ch, &v) in c.iter().enumerate() { + map[ch * n + i] = u16::from(v) << 8; + } + } + let mut b = Vec::new(); + let mut enc = tiff::encoder::TiffEncoder::new(std::io::Cursor::new(&mut b)).unwrap(); + if lzw { + enc = enc.with_compression(tiff::encoder::Compression::Lzw); + } + let mut img = enc.new_image::>(w, h).unwrap(); + img.encoder().write_tag(tiff::tags::Tag::ColorMap, &map[..]).unwrap(); + img.write_data(px).unwrap(); + b + } + + /// Indexed-colour TIFFs (an old GIF logo saved from Photoshop) are sized and drawn through + /// their palette, uncompressed or LZW, 8- or 4-bit. + #[test] + fn palette_tiff_decodes_through_its_colormap() { + let colours = [[0, 128, 255], [255, 0, 0], [10, 20, 30]]; + for lzw in [false, true] { + let b = palette_tiff::<8>(3, 1, &[2, 0, 1], &colours, lzw); + assert_eq!(pixel_size(&b), Some((3, 1)), "lzw {lzw}"); + let img = decode_rgba(&b).expect("decodes"); + let px: Vec<_> = img.pixels().map(|p| p.0).collect(); + assert_eq!(px, [[10, 20, 30, 255], [0, 128, 255, 255], [255, 0, 0, 255]], "lzw {lzw}"); + } + // Two indices per byte, high nibble first: 2, 1 | 0, 2 (the encoder counts samples, not + // bytes, so the strip carries two spare bytes the decoder doesn't read). + let b = palette_tiff::<4>(2, 2, &[0x21, 0x02, 0, 0], &colours, false); + assert_eq!(pixel_size(&b), Some((2, 2))); + let px: Vec<_> = decode_rgba(&b).expect("decodes").pixels().map(|p| p.0).collect(); + assert_eq!(px, [[10, 20, 30, 255], [255, 0, 0, 255], [0, 128, 255, 255], [10, 20, 30, 255]]); + } + #[test] fn bmp_decodes() { let mut bmp = Vec::new(); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 06f59de9..0d5a61d5 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -55,7 +55,7 @@ score below). |---|---|---|---|---|---| | PDF / AI (PDF-compatible) | `.pdf`, `.ai` | ✓ page boxes, layers, pages | ✓ rendered with hayro, embedded as vector in PDF export, crop to page boxes | layer visibility depth; editable vectors not offered (#227) | 3% | | JPEG, PNG, GIF, BMP, WebP | | ✓ | ✓ | CMYK JPEG/TIFF keep inks in PDF (#35) | 2% | -| TIFF | `.tif` | ✓ incl. CMYK, alpha, paths | ✓ incl. CMYK | clipping paths from 8BIM resources unverified | 1% | +| TIFF | `.tif` | ✓ incl. CMYK, alpha, indexed colour, paths | ✓ incl. CMYK | clipping paths from 8BIM resources unverified | 1% | | PSD | `.psd` | ✓ layers, comps, alpha, paths | ~ composite + Object Layer Options | layer comps partial | 1% | | EPS | `.eps` | ✓ (PostScript) | ~ preview only (no interpreter) | | <1% | | SVG | `.svg` | ✓ | ✓ (usvg) | | <1% |