TIFF: open YCbCr images as RGB (#10316) (#10319)

* TIFF: open YCbCr images as RGB (#10316)

Scanners often write JPEG-compressed YCbCr TIFFs with 2x2 chroma
subsampling. The tiff crate decodes and upsamples them but returns raw
YCbCr samples, which the decoder rejected with "colour type YCbCr(8)".

The new tiff/ycbcr.rs converts them to RGB after decoding: JPEG data
uses the JFIF full-range BT.601 conversion (as libtiff does), other
compressions honour ReferenceBlackWhite and YCbCrCoefficients with the
TIFF defaults as fallback. 8 and 16-bit. YCbCr with extra samples, or
in other sample formats, fails with a clear unsupported error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Contributors: add Yuki-init's display name

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* TIFF YCbCr: test that YCbCrCoefficients set the luma weights

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* TIFF: YCbCr tags with the wrong value count use the defaults

tag_f64s now returns None unless the tag holds exactly n values, so a
YCbCrCoefficients or ReferenceBlackWhite tag with extra values falls
back to the TIFF defaults instead of being truncated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lrgj5pnShYtsSnrsZq5iRn

* TIFF YCbCr: tests for tiled JPEG with ICC, big-endian 16-bit and planar

Covers the layouts the review asked for: a tiled 4:2:0 JPEG file with an
embedded profile (checked against an independent JPEG decoder), a
big-endian 16-bit file, and planar data in both byte orders.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lrgj5pnShYtsSnrsZq5iRn

* TIFF YCbCr: cite the formula sources and pin the green-channel choice

Documents where the conversion comes from: TIFF 6.0 Section 21
(YCbCrCoefficients, pp. 90-91) and libtiff's tif_color.c
(TIFFYCbCrToRGBInit, TIFFYCbCrtoRGB). Red and blue follow the spec;
green follows libtiff and adds Y unscaled, which matches the spec only
when the luma weights sum to 1. The module doc records this as a
deliberate compatibility choice, and a new test pins it: with weights
0.25/0.25/0.25, mid gray stays (128, 128, 128).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lrgj5pnShYtsSnrsZq5iRn

* Codecs README: YCbCr TIFFs with three 8- or 16-bit samples decode to RGB

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lrgj5pnShYtsSnrsZq5iRn

* Format tiff_ycbcr tests with rustfmt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(tiff): compare grouped YCbCr decoding and update support notes

* style(tiff): align grouped decode call with test formatting

* style(tiff): apply repository rustfmt to grouped YCbCr regression

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
Co-authored-by: FredApps <FredApps@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-11-02 19:31:22 +01:00
1 parent 289dd0a874
commit 9034223c4b
9 files changed
+547 -14

No files matched your search

+4
View File
@@ -291,6 +291,10 @@ sync_github_name = true
[people.dylanmtaylor]
sync_github_name = true
[people.Yuki-init]
display_name = "DC@yuki.himejima"
sync_github_name = false
[people.sn-o-w]
real_name = "Cristian Silaghi"
sync_github_name = false
+4 -2
View File
@@ -254,8 +254,10 @@ embedded table (and a PNG's transparent entry) so `photocraft-io` can open them
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`.
* YCbCr with three 8- or 16-bit samples decodes to RGB. YCbCr with other sample counts or
bit depths returns `Unsupported`. JPEG-compressed YCbCr uses the full-range JFIF conversion.
* 8/16-bit CIELab and ICCLab decode to the Lab channel layout.
* Signed integer samples and unsupported compression methods return `Unsupported`.
* **TIFF encode** writes classic TIFF, or BigTIFF when `EncodeOptions::tiff_bigtiff` is set or
the file could pass 4 GiB. `Metadata::extra_samples` (up to 16) are written after the colour
and alpha samples, with their `kind` in the ExtraSamples tag (a reserved `kind` above 2 is
+1
View File
@@ -168,6 +168,7 @@ mod bands;
mod decode;
mod encode;
mod tags;
mod ycbcr;
pub use decode::TiffBands;
pub(crate) use decode::{decode, decode_page, orientation};
+33 -5
View File
@@ -2,6 +2,7 @@ use std::borrow::Cow;
use super::bands::{Tally, convert_ab, decode_bands, extra_sample_indices, refill_zeroed, split_extra_samples, zeroed};
use super::tags::{F, TAG_IMAGE_SOURCE_DATA, TAG_PHOTOSHOP, TAG_XMP, TEXT_TAGS, photoshop_tags};
use super::ycbcr::YCbCr;
use super::{
ChannelLayout, ChunkType, CodecError, DecodeWarning, Decoder, ExifDirs, File, Ifd, Image, Limits, Metadata, Read, SampleType, Seek, SeekFrom, Tag,
camera_raw, err, ifd, io, map_err, tiff_exif,
@@ -185,6 +186,9 @@ impl<'a> TiffBands<'a> {
// Missing chunks leave their rows as they are: they must start empty in every group.
buf = refill_zeroed(buf, len)?;
tally = tally + decode_bands(p, self.b, &mut buf, band, first, &mut [], 0)?;
if let Some(c) = p.ycbcr {
c.convert(&mut buf, p.lay.out_channels(), p.lay.out);
}
if let Some(signed) = p.lab {
convert_ab(&mut buf, p.lay.out_channels(), p.lay.out, signed, false);
}
@@ -520,6 +524,20 @@ fn header_patches(
Ok(patches)
}
/// Lab and YCbCr are converted after decoding, from 8 or 16-bit samples only: Lab needs at least
/// its three colour samples, YCbCr exactly three.
fn check_converted_model(photometric: u64, spp: u64, bits: u64, src: Src) -> Result<(), CodecError> {
let (name, samples_ok) = match photometric {
8 | 9 => ("Lab", spp >= 3),
6 => ("YCbCr", spp == 3),
_ => return Ok(()),
};
if samples_ok && matches!(src, Src::U8 | Src::U16) {
return Ok(());
}
Err(CodecError::unsupported(F, format!("{name} image with {spp} samples of {bits} bits")))
}
/// A directory checked and laid out for decoding, before any pixel is read.
pub(super) struct Prepared {
w: u32,
@@ -531,6 +549,8 @@ pub(super) struct Prepared {
tl: tiff::decoder::Limits,
/// Lab samples: `Some(true)` for CIELab (a* and b* stored signed), `Some(false)` for ICCLab.
lab: Option<bool>,
/// YCbCr samples: the conversion to RGB (#10316).
ycbcr: Option<YCbCr>,
/// The colour samples of a pixel in the file's colour model.
color: usize,
/// The ExtraSamples tag (0 unspecified, 1 associated alpha, 2 unassociated alpha).
@@ -567,6 +587,9 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result<Image, CodecEr
if tally.decoded == 0 && tally.missing > 0 {
return Err(err("the image data is missing (cut off or past the end of the file)"));
}
if let Some(c) = p.ycbcr {
c.convert(&mut data, p.lay.out_channels(), p.lay.out);
}
if let Some(signed) = p.lab {
convert_ab(&mut data, p.lay.out_channels(), p.lay.out, signed, false);
}
@@ -642,15 +665,17 @@ fn prepare(f: &File<'_>, dir: &Ifd, limits: &Limits, whole: bool) -> Result<Prep
// `tiff` crate as RGB, then labelled Lab with a* and b* in PhotoCraft's encoding
// (`convert_ab`).
let lab = matches!(photometric, 8 | 9);
if lab && (spp < 3 || !matches!(src, Src::U8 | Src::U16)) {
return Err(CodecError::unsupported(F, format!("Lab image with {spp} samples of {bits} bits")));
}
// YCbCr (6), as scanners and cameras write it (usually JPEG-compressed with subsampled
// chroma): the `tiff` crate decodes and upsamples the samples, and they are turned into RGB
// after decoding (#10316).
let ycbcr = photometric == 6;
check_converted_model(photometric, spp, bits, src)?;
// TIFF 6.0 lets any extra sample be the alpha (#1349). For RGB and CMYK the `tiff` crate
// returns the alpha only when it is the first extra sample: otherwise it is asked for every
// sample (as multiband gray) and the colour and alpha samples are picked here. An alpha
// position past the samples the file has is ignored.
let color: usize = match photometric {
2 | 8 | 9 => 3,
2 | 6 | 8 | 9 => 3,
5 => 4,
_ => 1,
};
@@ -686,6 +711,7 @@ fn prepare(f: &File<'_>, dir: &Ifd, limits: &Limits, whole: bool) -> Result<Prep
(if gray_alpha { ChannelLayout::GrayA } else { ChannelLayout::Gray }, usize::from(num_samples))
}
tiff::ColorType::RGB(_) => (ChannelLayout::Rgb, 3),
tiff::ColorType::YCbCr(_) if ycbcr => (ChannelLayout::Rgb, 3),
tiff::ColorType::RGBA(_) => (ChannelLayout::Rgba, 4),
tiff::ColorType::CMYK(_) => (ChannelLayout::Cmyk, 4),
tiff::ColorType::CMYKA(_) => (ChannelLayout::CmykA, 5),
@@ -729,8 +755,9 @@ fn prepare(f: &File<'_>, dir: &Ifd, limits: &Limits, whole: bool) -> Result<Prep
// strip or tile tables point at inside the file count (and never more than the file), so
// padding the file while pointing the chunks past its end does not raise the bound: the
// banded decoder has no whole-image `max_alloc` to fall back on.
let compression = f.tag_uint(dir, ifd::TAG_COMPRESSION).unwrap_or(1);
let stored = u64::from(w).saturating_mul(u64::from(h)).saturating_mul(spp).saturating_mul(bits).div_ceil(8);
let ratio: u64 = match f.tag_uint(dir, ifd::TAG_COMPRESSION).unwrap_or(1) {
let ratio: u64 = match compression {
1 => 4,
32773 => 130,
4 => cw.clamp(4096, 65_535),
@@ -814,6 +841,7 @@ fn prepare(f: &File<'_>, dir: &Ifd, limits: &Limits, whole: bool) -> Result<Prep
patches,
tl,
lab: lab.then_some(photometric == 8),
ycbcr: ycbcr.then(|| YCbCr::of(f, dir, compression, if out == SampleType::U16 { 65535.0 } else { 255.0 })),
color,
extra_kinds: extra,
extras_over_limit,
+222
View File
@@ -0,0 +1,222 @@
//! YCbCr (PhotometricInterpretation 6) to RGB, after the `tiff` crate has decoded the samples.
//!
//! The crate decompresses JPEG data and upsamples subsampled chroma, but hands back the stored
//! Y, Cb and Cr values. This module turns them into RGB in place: ReferenceBlackWhite scales the
//! codes and YCbCrCoefficients gives the luma weights. JPEG-compressed data (compression 7) uses
//! the JPEG (JFIF) conversion, full range with the BT.601 weights, and ignores both tags, as
//! libtiff does when it asks the JPEG library for RGB.
//!
//! # Sources
//!
//! - TIFF Revision 6.0 (Final, June 3, 1992), Section 21 "YCbCr Images", pp. 89–92; the
//! YCbCrCoefficients field and its inverse equations are on pp. 90–91:
//! `R = Cr·(2 - 2·LumaRed) + Y`, `B = Cb·(2 - 2·LumaBlue) + Y`,
//! `G = (Y - LumaBlue·B - LumaRed·R) / LumaGreen`.
//! <https://www.itu.int/itudoc/itu-t/com16/tiff-fx/docs/tiff6.pdf>
//! - libtiff, `libtiff/tif_color.c`: `TIFFYCbCrToRGBInit` (tables) and `TIFFYCbCrtoRGB` (per pixel).
//! <https://gitlab.com/libtiff/libtiff/-/blob/master/libtiff/tif_color.c>
//!
//! # Green: a compatibility choice
//!
//! Red and blue follow the spec. Green follows libtiff, which rewrites the spec's equation as
//! `G = Y - (LumaRed·(2 - 2·LumaRed) / LumaGreen)·Cr - (LumaBlue·(2 - 2·LumaBlue) / LumaGreen)·Cb`,
//! adding Y unscaled. The two agree only when the weights sum to 1. Every standard set does
//! (the spec's own examples, BT.601 and BT.709), but the spec only calls the weights "the
//! proportions of red, green, and blue respectively in luminance" and does not require the sum.
//! For other weights the spec's equation scales Y in green by `(1 - LumaRed - LumaBlue) / LumaGreen`,
//! which tints neutral grays.
//!
//! PhotoCraft deliberately follows libtiff here (decided in PR #10319, for issue #10316), so such
//! files open as they do in libtiff-based tools (ImageMagick, GIMP) and grays stay neutral. This is
//! a contributor choice, not a requirement of the spec; the test
//! `weights_that_do_not_sum_to_one_keep_grays_neutral` in `tests/it/tiff_ycbcr.rs` pins it.
use super::{File, Ifd, SampleType};
const TAG_YCBCR_COEFFICIENTS: u16 = 529;
const TAG_REFERENCE_BLACK_WHITE: u16 = 532;
/// BT.601 luma weights: the TIFF default for YCbCrCoefficients.
const BT601: [f64; 3] = [0.299, 0.587, 0.114];
/// How one file's YCbCr codes map to RGB.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(super) struct YCbCr {
/// Luma weights of red, green and blue.
luma: [f64; 3],
/// Black and white codes of Y, Cb and Cr.
ref_bw: [f64; 6],
}
impl YCbCr {
/// The conversion for directory `dir`, whose samples have `max` as their largest code
/// (255 or 65535). Missing or unusable tags fall back to the TIFF defaults.
pub(super) fn of(f: &File<'_>, dir: &Ifd, compression: u64, max: f64) -> Self {
let defaults = Self::defaults(max);
if compression == 7 {
return defaults;
}
let luma = f
.tag_f64s(dir, TAG_YCBCR_COEFFICIENTS, 3)
.and_then(|v| <[f64; 3]>::try_from(v).ok())
.filter(|l| l.iter().all(|c| c.is_finite() && *c > 0.0 && *c < 1.0))
.unwrap_or(defaults.luma);
let ref_bw = f
.tag_f64s(dir, TAG_REFERENCE_BLACK_WHITE, 6)
.and_then(|v| <[f64; 6]>::try_from(v).ok())
.filter(|r| r.iter().all(|v| v.is_finite()) && r.as_chunks::<2>().0.iter().all(|[black, white]| (white - black).abs() > f64::EPSILON))
.unwrap_or(defaults.ref_bw);
YCbCr { luma, ref_bw }
}
/// TIFF's defaults: BT.601 weights, full-range luma, chroma centred on the middle code.
fn defaults(max: f64) -> Self {
let mid = (max + 1.0) / 2.0;
YCbCr { luma: BT601, ref_bw: [0.0, max, mid, max, mid, max] }
}
/// Converts interleaved Y, Cb, Cr pixels of `channels` samples (the first three are the
/// colour) to R, G, B in place. Other sample types are left as they are.
pub(super) fn convert(self, data: &mut [u8], channels: usize, sample: SampleType) {
match sample {
SampleType::U8 => {
let t = Tables::new(&self, 255);
for_each_pixel(data, channels, |px| {
if let [y, cb, cr, ..] = px {
let [r, g, b] = t.rgb(usize::from(*y), usize::from(*cb), usize::from(*cr));
(*y, *cb, *cr) = (r as u8, g as u8, b as u8);
}
});
}
SampleType::U16 => {
let t = Tables::new(&self, 65535);
for_each_pixel(data, channels * 2, |px| {
if let [y0, y1, b0, b1, r0, r1, ..] = px {
let get = |a: u8, b: u8| usize::from(u16::from_ne_bytes([a, b]));
let [r, g, b] = t.rgb(get(*y0, *y1), get(*b0, *b1), get(*r0, *r1));
[*y0, *y1] = (r as u16).to_ne_bytes();
[*b0, *b1] = (g as u16).to_ne_bytes();
[*r0, *r1] = (b as u16).to_ne_bytes();
}
});
}
_ => {}
}
}
}
/// Runs `f` on every `stride`-byte pixel, in parallel on native targets.
fn for_each_pixel(data: &mut [u8], stride: usize, f: impl Fn(&mut [u8]) + Sync + Send) {
if stride == 0 {
return;
}
#[cfg(not(target_arch = "wasm32"))]
{
use rayon::prelude::*;
// Bands of whole pixels, so each task has a useful amount of work.
let band = stride.saturating_mul(1 << 14);
data.par_chunks_mut(band).for_each(|b| b.chunks_exact_mut(stride).for_each(&f));
}
#[cfg(target_arch = "wasm32")]
data.chunks_exact_mut(stride).for_each(f);
}
/// Per-code contributions, so a pixel costs a few additions.
struct Tables {
max: f64,
/// Scaled luma of each Y code.
y: Vec<f64>,
/// Red and green contributions of each Cr code.
cr_r: Vec<f64>,
cr_g: Vec<f64>,
/// Green and blue contributions of each Cb code.
cb_g: Vec<f64>,
cb_b: Vec<f64>,
}
impl Tables {
fn new(c: &YCbCr, max: u32) -> Self {
let m = f64::from(max);
let [lr, lg, lb] = c.luma;
let [y_black, y_white, cb_black, cb_white, cr_black, cr_white] = c.ref_bw;
// TIFF 6.0: chroma codes span ±(max + 1) / 2 - 1 around the black code; with the
// default ReferenceBlackWhite that is the code minus the middle code.
let half = (m + 1.0) / 2.0 - 1.0;
let codes = || (0..=max).map(f64::from);
let y = codes().map(|v| (v - y_black) * m / (y_white - y_black)).collect();
let cb: Vec<f64> = codes().map(|v| (v - cb_black) * half / (cb_white - cb_black)).collect();
let cr: Vec<f64> = codes().map(|v| (v - cr_black) * half / (cr_white - cr_black)).collect();
// R = Y + (2 - 2·Lr)·Cr and B = Y + (2 - 2·Lb)·Cb (TIFF 6.0 p. 91); green as libtiff:
// G = Y - (Lr·(2 - 2·Lr) / Lg)·Cr - (Lb·(2 - 2·Lb) / Lg)·Cb (see the module notes).
let (kr, kb) = (2.0 - 2.0 * lr, 2.0 - 2.0 * lb);
Tables {
max: m,
y,
cr_r: cr.iter().map(|v| kr * v).collect(),
cr_g: cr.iter().map(|v| -lr * kr * v / lg).collect(),
cb_g: cb.iter().map(|v| -lb * kb * v / lg).collect(),
cb_b: cb.iter().map(|v| kb * v).collect(),
}
}
/// R, G and B of one pixel, rounded and clamped to the code range.
fn rgb(&self, y: usize, cb: usize, cr: usize) -> [u32; 3] {
let at = |t: &[f64], i: usize| t.get(i).copied().unwrap_or(0.0);
let yv = at(&self.y, y);
// Green adds Y unscaled, as libtiff does (see "Green: a compatibility choice" above).
let r = yv + at(&self.cr_r, cr);
let g = yv + at(&self.cr_g, cr) + at(&self.cb_g, cb);
let b = yv + at(&self.cb_b, cb);
[r, g, b].map(|v| v.round().clamp(0.0, self.max) as u32)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn convert(c: &YCbCr, px: [u8; 3]) -> [u8; 3] {
let mut d = px.to_vec();
c.convert(&mut d, 3, SampleType::U8);
[d[0], d[1], d[2]]
}
#[test]
fn jfif_conversion_matches_known_colours() {
let c = YCbCr::defaults(255.0);
assert_eq!(convert(&c, [0, 128, 128]), [0, 0, 0]);
assert_eq!(convert(&c, [255, 128, 128]), [255, 255, 255]);
assert_eq!(convert(&c, [128, 128, 128]), [128, 128, 128]);
// Pure red, green and blue as JFIF encodes them.
assert_eq!(convert(&c, [76, 85, 255]), [254, 0, 0]);
assert_eq!(convert(&c, [150, 44, 21]), [0, 255, 1]);
assert_eq!(convert(&c, [29, 255, 107]), [0, 0, 254]);
}
#[test]
fn sixteen_bit_and_extra_samples() {
let c = YCbCr::defaults(65535.0);
let mut d: Vec<u8> = [65535u16, 32768, 32768, 1234].iter().flat_map(|v| v.to_ne_bytes()).collect();
c.convert(&mut d, 4, SampleType::U16);
let v: Vec<u16> = d.as_chunks::<2>().0.iter().map(|b| u16::from_ne_bytes(*b)).collect();
assert_eq!(v, [65535, 65535, 65535, 1234]);
}
#[test]
fn studio_range_reference_black_white() {
// Y 16–235, chroma 128 ± 112 (ITU-R BT.601 studio range).
let c = YCbCr { luma: BT601, ref_bw: [16.0, 235.0, 128.0, 240.0, 128.0, 240.0] };
assert_eq!(convert(&c, [16, 128, 128]), [0, 0, 0]);
assert_eq!(convert(&c, [235, 128, 128]), [255, 255, 255]);
}
#[test]
fn odd_buffers_do_not_panic() {
let c = YCbCr::defaults(255.0);
let mut d = vec![1u8, 2];
c.convert(&mut d, 3, SampleType::U8);
c.convert(&mut d, 0, SampleType::U8);
c.convert(&mut d, 0, SampleType::U16);
assert_eq!(d, [1, 2]);
}
}
+20 -5
View File
@@ -236,18 +236,33 @@ impl<'a> File<'a> {
/// 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> {
self.float(ifd.get(tag)?, 0)
}
/// The values of a numeric tag as floats, when it holds exactly `n` (`None` for any other
/// count or an unreadable value).
pub fn tag_f64s(&self, ifd: &Ifd, tag: u16, n: u64) -> Option<Vec<f64>> {
let e = ifd.get(tag)?;
if e.count == 0 {
if e.count != n {
return None;
}
(0..n).map(|i| self.float(e, i)).collect()
}
/// Value `i` of a numeric entry as a float: RATIONAL, FLOAT, DOUBLE or an integer.
fn float(&self, e: &Entry, i: u64) -> Option<f64> {
if i >= e.count {
return None;
}
let at = e.value_at.checked_add(i.checked_mul(type_size(e.ty)?)?)?;
match e.ty {
5 => {
let (n, d) = (self.u32_at(e.value_at)?, self.u32_at(e.value_at.checked_add(4)?)?);
let (n, d) = (self.u32_at(at)?, self.u32_at(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),
11 => Some(f64::from(f32::from_bits(self.u32_at(at)?))),
12 => Some(f64::from_bits(self.u64_at(at)?)),
_ => self.uint(e, i).map(|v| v as f64),
}
}
+1
View File
@@ -31,6 +31,7 @@ mod tiff_lab;
mod tiff_pages;
mod tiff_photoshop;
mod tiff_regressions;
mod tiff_ycbcr;
mod webp_frames;
mod webp_lossy;
mod xpm;
+259
View File
@@ -0,0 +1,259 @@
//! YCbCr TIFFs (PhotometricInterpretation 6), as scanners write them: JPEG-compressed with 2×2
//! chroma subsampling, and uncompressed with ReferenceBlackWhite and YCbCrCoefficients (#10316).
//! Every file here is hand-built by `common::tiffgen`.
use crate::common::sample_icc;
use crate::common::tiffgen::*;
use photocraft_codecs::*;
const COMPRESSION: u16 = 259;
const SUBSAMPLING: u16 = 530;
const YCBCR_COEFFICIENTS: u16 = 529;
const REFERENCE_BLACK_WHITE: u16 = 532;
const ICC_PROFILE: u16 = 34675;
/// A directory whose Compression is `c`.
fn compressed(mut d: Dir, c: u64) -> Dir {
d.entries.retain(|e| e.0 != COMPRESSION);
d.tag(COMPRESSION, SHORT, &[c])
}
/// A little-endian RATIONAL value for `tiffgen`.
fn rational(num: u32, den: u32) -> u64 {
u64::from(num) | (u64::from(den) << 32)
}
/// The image's pixels as RGB bytes.
fn rgb(img: &Image) -> Vec<u8> {
img.to_rgba8().as_chunks::<4>().0.iter().flat_map(|[r, g, b, _]| [*r, *g, *b]).collect()
}
/// A smooth colour test card, `w`×`h` RGB.
fn card(w: u32, h: u32) -> Vec<u8> {
let mut v = Vec::new();
for y in 0..h {
for x in 0..w {
v.extend([(x * 255 / (w - 1)) as u8, (y * 255 / (h - 1)) as u8, (255 - x * 255 / (w - 1)) as u8]);
}
}
v
}
/// A 4:2:0 baseline JPEG stream of `rgb`.
fn jpeg(rgb: &[u8], w: u32, h: u32) -> Vec<u8> {
let img = Image::from_raw(w, h, ChannelLayout::Rgb, SampleType::U8, rgb.to_vec()).unwrap();
encode(&img, Format::Jpeg, &EncodeOptions { jpeg_chroma_subsampling: true, ..Default::default() }).unwrap()
}
#[test]
fn jpeg_compressed_ycbcr_with_subsampled_chroma_reads_as_rgb() {
let (w, h) = (32, 24);
let stream = jpeg(&card(w, h), w, h);
// The stream really is subsampled: the luma component samples 2×2 in the frame header.
let sof = stream.windows(2).position(|m| m == [0xFF, 0xC0]).expect("baseline frame header");
assert_eq!(stream[sof + 11], 0x22, "luma sampling factors");
let dir = compressed(strips(w, h, 8, 3, 6, h, &stream), 7).tag(SUBSAMPLING, SHORT, &[2, 2]);
let file = build(true, false, &[dir], &[0]).bytes;
let img = decode(&file).unwrap();
assert_eq!((img.width(), img.height(), img.layout(), img.sample_type()), (w, h, ChannelLayout::Rgb, SampleType::U8));
// The oracle: the same JPEG stream decoded to RGB by an independent decoder.
let want = image::load_from_memory_with_format(&stream, image::ImageFormat::Jpeg).unwrap().to_rgb8().into_raw();
let worst = rgb(&img).iter().zip(&want).map(|(g, o)| g.abs_diff(*o)).max().unwrap();
assert!(worst <= 2, "largest difference from the oracle: {worst}");
}
#[test]
fn uncompressed_ycbcr_uses_the_default_conversion() {
// Black, white, mid gray and JFIF's pure red, without subsampling.
let px = [0, 128, 128, 255, 128, 128, 128, 128, 128, 76, 85, 255];
let dir = strips(4, 1, 8, 3, 6, 1, &px).tag(SUBSAMPLING, SHORT, &[1, 1]);
let img = decode(&build(true, false, &[dir], &[0]).bytes).unwrap();
assert_eq!(img.layout(), ChannelLayout::Rgb);
assert_eq!(rgb(&img), [0, 0, 0, 255, 255, 255, 128, 128, 128, 254, 0, 0]);
}
#[test]
fn reference_black_white_sets_the_code_range() {
// Studio range: Y 16–235, chroma 128 ± 112.
let rbw = [rational(16, 1), rational(235, 1), rational(128, 1), rational(240, 1), rational(128, 1), rational(240, 1)];
let dir = strips(2, 1, 8, 3, 6, 1, &[16, 128, 128, 235, 128, 128]).tag(SUBSAMPLING, SHORT, &[1, 1]).tag(REFERENCE_BLACK_WHITE, RATIONAL, &rbw);
let img = decode(&build(true, false, &[dir], &[0]).bytes).unwrap();
assert_eq!(rgb(&img), [0, 0, 0, 255, 255, 255]);
}
#[test]
fn weights_that_do_not_sum_to_one_keep_grays_neutral() {
// 0.25 each (sum 0.75): TIFF 6.0's full inverse would scale green's Y by (1 − Lr − Lb) / Lg
// = 2 and give mid gray G 255 (clamped). Like libtiff, green keeps Y, so gray stays gray, and
// the weights still set the chroma terms: R = Y + 1.5·Cr, B = Y + 1.5·Cb.
let weights = [rational(1, 4), rational(1, 4), rational(1, 4)];
let dir = strips(2, 1, 8, 3, 6, 1, &[128, 128, 128, 128, 64, 192]).tag(SUBSAMPLING, SHORT, &[1, 1]).tag(YCBCR_COEFFICIENTS, RATIONAL, &weights);
let img = decode(&build(true, false, &[dir], &[0]).bytes).unwrap();
// Green for the second pixel: 128 − (Lb·1.5/Lg)·Cb − (Lr·1.5/Lg)·Cr = 128 + 96 − 96.
assert_eq!(rgb(&img), [128, 128, 128, 224, 128, 32]);
}
#[test]
fn ycbcr_coefficients_set_the_luma_weights() {
// Weights 0.25, 0.5, 0.25 with Cb −64 and Cr +64: R = Y + 1.5·Cr, B = Y + 1.5·Cb, and G keeps Y
// (BT.601 would give R 218 and B 15).
let weights = [rational(1, 4), rational(1, 2), rational(1, 4)];
let dir = strips(1, 1, 8, 3, 6, 1, &[128, 64, 192]).tag(SUBSAMPLING, SHORT, &[1, 1]).tag(YCBCR_COEFFICIENTS, RATIONAL, &weights);
let img = decode(&build(true, false, &[dir], &[0]).bytes).unwrap();
assert_eq!(rgb(&img), [224, 128, 32]);
}
#[test]
fn ycbcr_tags_with_the_wrong_count_use_the_defaults() {
// Four weights and seven black/white codes: both tags are malformed, so BT.601 and full range
// apply (the weights alone would give 224, 128, 32).
let weights = [rational(1, 4), rational(1, 2), rational(1, 4), rational(1, 4)];
let rbw = [rational(16, 1), rational(235, 1), rational(128, 1), rational(240, 1), rational(128, 1), rational(240, 1), rational(0, 1)];
let dir = strips(1, 1, 8, 3, 6, 1, &[128, 64, 192]).tag(SUBSAMPLING, SHORT, &[1, 1]).tag(YCBCR_COEFFICIENTS, RATIONAL, &weights).tag(
REFERENCE_BLACK_WHITE,
RATIONAL,
&rbw,
);
let img = decode(&build(true, false, &[dir], &[0]).bytes).unwrap();
assert_eq!(rgb(&img), [218, 104, 15]);
}
#[test]
fn sixteen_bit_ycbcr_reads_as_rgb() {
let px: Vec<u8> = [65535u16, 32768, 32768, 0, 32768, 32768].iter().flat_map(|v| v.to_le_bytes()).collect();
let dir = strips(2, 1, 16, 3, 6, 1, &px).tag(SUBSAMPLING, SHORT, &[1, 1]);
let img = decode(&build(true, false, &[dir], &[0]).bytes).unwrap();
assert_eq!((img.layout(), img.sample_type()), (ChannelLayout::Rgb, SampleType::U16));
let n = img.to_normalized();
assert!(n[..3].iter().all(|&v| v == 1.0) && n[3..].iter().all(|&v| v == 0.0), "{n:?}");
}
#[test]
#[cfg(target_pointer_width = "64")]
fn sixteen_bit_ycbcr_bands_match_whole_decode_with_a_missing_last_strip() {
// On 32-bit targets max_alloc also bounds the whole image, so a small allocation cap
// cannot force these tiny strips into separate output groups there.
let (w, h, rows) = (4u32, 5u32, 2u32);
let pixels = [[0u16, 32768, 32768], [65535, 32768, 32768], [19595, 21710, 65535], [32768, 40960, 24576]];
let samples: Vec<u16> = (0..w * h).flat_map(|i| pixels[i as usize % pixels.len()]).collect();
let weights = [rational(1, 4), rational(1, 2), rational(1, 4)];
let icc = sample_icc(512);
for little in [true, false] {
for missing in [false, true] {
let dir = strips(w, h, 16, 3, 6, rows, &u16s(&samples, little)).tag(SUBSAMPLING, SHORT, &[1, 1]).tag(YCBCR_COEFFICIENTS, RATIONAL, &weights).bytes(
ICC_PROFILE,
UNDEFINED,
&icc,
);
let mut file = build(little, false, &[dir], &[0]);
if missing {
let past = file.bytes.len() as u64 + 4096;
patch_value(&mut file, little, false, 0, 273, 2, past);
}
let want = decode_with(&file.bytes, &DecodeOptions { keep_orientation: true, ..Default::default() }).unwrap();
let limits = Limits { max_alloc: u64::from(w * rows * 3 * 2), ..Limits::banded() };
let mut bands = TiffBands::open(&file.bytes, None, &limits).unwrap();
assert_eq!((bands.dimensions(), bands.layout(), bands.sample_type()), ((w, h), ChannelLayout::Rgb, SampleType::U16));
let mut got = Image::new(w, h, bands.layout(), bands.sample_type()).unwrap();
let mut positions = Vec::new();
bands
.decode(|x, y, group: &Image| -> Result<(), CodecError> {
positions.push((x, y, group.height()));
assert_eq!((x, group.width(), group.layout(), group.sample_type()), (0, w, ChannelLayout::Rgb, SampleType::U16));
let at = y as usize * w as usize * 3 * 2;
got.data_mut()[at..at + group.data().len()].copy_from_slice(group.data());
Ok(())
})
.unwrap();
assert_eq!(positions, [(0, 0, 2), (0, 2, 2), (0, 4, 1)], "one output group per strip, including the short last strip");
assert_eq!(got.data(), want.data(), "little endian: {little}, missing last strip: {missing}");
assert_eq!(bands.icc, want.icc);
assert_eq!(bands.meta, want.meta);
assert_eq!(bands.warnings, want.warnings);
assert_eq!(bands.warnings.iter().any(|w| matches!(w, DecodeWarning::Truncated { .. })), missing);
}
}
}
#[test]
fn unsupported_ycbcr_files_fail_cleanly() {
// Four samples (an extra sample next to YCbCr).
let four = strips(1, 1, 8, 4, 6, 1, &[128, 128, 128, 255]).tag(SUBSAMPLING, SHORT, &[1, 1]);
assert!(decode(&build(true, false, &[four], &[0]).bytes).is_err());
// Subsampled chroma without JPEG compression (the default subsampling is 2×2).
let sub = strips(2, 2, 8, 3, 6, 2, &[128; 12]);
assert!(decode(&build(true, false, &[sub], &[0]).bytes).is_err());
// A JPEG strip that is not JPEG data.
let junk = compressed(strips(16, 16, 8, 3, 6, 16, &[0x5A; 64]), 7).tag(SUBSAMPLING, SHORT, &[2, 2]);
assert!(decode(&build(true, false, &[junk], &[0]).bytes).is_err());
// Coefficients and ReferenceBlackWhite that would divide by zero fall back to the defaults.
let bad = strips(1, 1, 8, 3, 6, 1, &[255, 128, 128])
.tag(SUBSAMPLING, SHORT, &[1, 1])
.tag(YCBCR_COEFFICIENTS, RATIONAL, &[rational(0, 1), rational(0, 0), rational(5, 1)])
.tag(REFERENCE_BLACK_WHITE, RATIONAL, &[rational(7, 1); 6]);
let img = decode(&build(true, false, &[bad], &[0]).bytes).unwrap();
assert_eq!(&img.to_rgba8()[..3], &[255, 255, 255]);
}
#[test]
fn tiled_jpeg_ycbcr_keeps_its_icc_profile() {
// Two 16×16 tiles, each its own 4:2:0 JPEG stream, and an embedded profile, like a scanner's
// tiled output.
let (w, h, t) = (32u32, 16u32, 16u32);
let px = card(w, h);
let tile = |tx: u32| -> Vec<u8> {
(0..t)
.flat_map(|y| {
let at = ((y * w + tx * t) * 3) as usize;
px[at..at + (t * 3) as usize].to_vec()
})
.collect()
};
let streams = [jpeg(&tile(0), t, t), jpeg(&tile(1), t, t)];
let icc = sample_icc(512);
let dir = Dir { entries: base(w, h, 8, 3, 6), chunks: streams.to_vec(), tiled: true };
let dir = compressed(dir, 7).tag(322, SHORT, &[u64::from(t)]).tag(323, SHORT, &[u64::from(t)]).tag(SUBSAMPLING, SHORT, &[2, 2]).bytes(
ICC_PROFILE,
UNDEFINED,
&icc,
);
let img = decode(&build(true, false, &[dir], &[0]).bytes).unwrap();
assert_eq!((img.width(), img.height(), img.layout()), (w, h, ChannelLayout::Rgb));
assert_eq!(img.icc.as_deref(), Some(icc.as_slice()), "the embedded profile is kept");
// The oracle: each tile's stream decoded by an independent decoder, placed side by side.
let tiles: Vec<Vec<u8>> = streams.iter().map(|s| image::load_from_memory_with_format(s, image::ImageFormat::Jpeg).unwrap().to_rgb8().into_raw()).collect();
let row = (t * 3) as usize;
let want: Vec<u8> = (0..t as usize).flat_map(|y| [&tiles[0][y * row..(y + 1) * row], &tiles[1][y * row..(y + 1) * row]].concat()).collect();
let worst = rgb(&img).iter().zip(&want).map(|(g, o)| g.abs_diff(*o)).max().unwrap();
assert!(worst <= 2, "largest difference from the oracle: {worst}");
}
#[test]
fn big_endian_sixteen_bit_ycbcr_reads_as_rgb() {
// White, black, and red as full-range 16-bit YCbCr (Y 0.299·65535, Cb 32768 − 0.1687·65535, Cr max).
let px = [65535u16, 32768, 32768, 0, 32768, 32768, 19595, 21710, 65535];
let dir = strips(3, 1, 16, 3, 6, 1, &u16s(&px, false)).tag(SUBSAMPLING, SHORT, &[1, 1]);
let file = build(false, false, &[dir], &[0]).bytes;
assert_eq!(&file[..2], b"MM");
let img = decode(&file).unwrap();
assert_eq!((img.layout(), img.sample_type()), (ChannelLayout::Rgb, SampleType::U16));
let n = img.to_normalized();
let want = [1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0];
assert!(n.iter().zip(want).all(|(g, w)| (g - w).abs() < 2.0 / 255.0), "{n:?}");
}
#[test]
fn planar_ycbcr_reads_as_rgb_in_both_byte_orders() {
// The pixels of the default-conversion test, one plane per component.
let px = [0u8, 128, 128, 255, 128, 128, 128, 128, 128, 76, 85, 255];
for little in [true, false] {
let dir = Dir { entries: base(4, 1, 8, 3, 6), chunks: planar_strips(&px, 4, 1, 3, 1, 1), tiled: false }.tag(278, LONG, &[1]).tag(284, SHORT, &[2]).tag(
SUBSAMPLING,
SHORT,
&[1, 1],
);
let img = decode(&build(little, false, &[dir], &[0]).bytes).unwrap();
assert_eq!(rgb(&img), [0, 0, 0, 255, 255, 255, 128, 128, 128, 254, 0, 0], "little endian: {little}");
}
}
+3 -2
View File
@@ -1,6 +1,6 @@
# File-format parity
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-26 · **Change:** minor (TIFFs up to 300,000 px a side open, decoded a group of rows at a time into the layer, #8169; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; PSD Filter Gallery: Halftone Pattern dot/circle/line settings survive PSD/PSB saves (#8940); Save for Web writes WebP, lossy or lossless, #6391; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency, #8877; layered TIFF writes the TySh text version and descriptor in PSD big-endian order while the header, matrix, warp and bounds keep container order, #9098; layered TIFF writes Photoshop's 14-byte global layer mask, so Photoshop opens the layers instead of only the composite, #9095; legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; `.asl` layer style libraries read and written, #1051; JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding; Amiga IFF write: 24-plane ILBM, Save As `.iff`; PSD stores spot channels as Photoshop does, ink 0 = solid, and numbers them in 1053, #7415 split; TIFF writes and reads alpha and spot channels as extra samples with Photoshop's channel resources, PSD output unchanged, FILE-215-7, FILE-215-17's TIFF part, #7415; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency) · **Target:** Adobe Photoshop 2026
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-30 · **Change:** patch (TIFF: YCbCr images open as RGB, including JPEG-compressed scans with subsampled chroma, #10316; TIFFs up to 300,000 px a side open, decoded a group of rows at a time into the layer, #8169; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; PSD Filter Gallery: Halftone Pattern dot/circle/line settings survive PSD/PSB saves (#8940); Save for Web writes WebP, lossy or lossless, #6391; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency, #8877; layered TIFF writes the TySh text version and descriptor in PSD big-endian order while the header, matrix, warp and bounds keep container order, #9098; layered TIFF writes Photoshop's 14-byte global layer mask, so Photoshop opens the layers instead of only the composite, #9095; legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; `.asl` layer style libraries read and written, #1051; JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding; Amiga IFF write: 24-plane ILBM, Save As `.iff`; PSD stores spot channels as Photoshop does, ink 0 = solid, and numbers them in 1053, #7415 split; TIFF writes and reads alpha and spot channels as extra samples with Photoshop's channel resources, PSD output unchanged, FILE-215-7, FILE-215-17's TIFF part, #7415; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency) · **Target:** Adobe Photoshop 2026
**PSD Filter Gallery (#8940, 2026-10-23):** Halftone Pattern maps its size, contrast and dot/circle/line pattern as editable settings on import and export, with the reference application's descriptor keys (`HlSz`, `Cntr`, `ScrT`: `ScrD`/`ScrC`/`ScrL`). PSD/PSB round trips pass at 8/16/32 bits. This does not claim pixel equivalence for the existing renderer. Unknown or malformed settings remain verbatim on import.
@@ -26,7 +26,7 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity.
| PSD | R/W | yes | yes | 1/8/16/32-bit; Bitmap, Gray, Indexed, RGB, CMYK, Multichannel, Lab, Duotone; layers, groups, masks, vector masks, adjustment and fill layers, effects, text, shapes, smart objects (SoLd/lnk2 with their warp: the bend presets, a custom mesh and, since #4112, Cylinder; CS2 PlLd/lnkD read with their warp, kept as is or re-saved as SoLd once edited), smart filters (53 mapped, among them Invert, the Distort filters but Shear, Clouds, Difference Clouds, Fibers and Lens Flare, plus Hue/Saturation, Perspective Warp, Filter Gallery with Chrome and a Camera Raw subset, rest verbatim), slices, comps, Advanced Blending, each layer's XMP packet (`layer.xmpMetadata`); alpha and spot channels as Photoshop stores them (spot ink 0 = solid, channel identifiers in 1053), and PSDs with spot channels from PhotoCraft 0.1.0–0.16.0, which stored the ink the other way round, keep their ink. Oracle floors: Photoshop-authored 190/258, io 148/170, psd-tools 249/309; round trip ≥ 169/170, 308/309, 258/258. Known failures: #1281, #2469 (#2374, text layers, closed 2026-10-10; #2465, CS2 `PlLd`-only smart objects, fixed 2026-10-12) |
| PSB | R/W | yes | yes | Auto-switches above 2 GB (REL-212-2); generated 20 MP, 150-layer, 2 GB PSD and 4.4 GB PSB fixtures opened, saved and re-opened by io tests/it/large_files.rs (FILE-216-2, 2026-10-13; the 4.4 GB PSB opens on Windows); > 4 GB from the app's File › Open on Windows: #375 |
| Cloud PSD (.psdc) | R/W | no | no | Out of scope (Adobe cloud) |
| TIFF | R/W | yes | yes | 8/16/32f, Gray/RGB/CMYK, BigTIFF, every IFD, banded parallel decode; flat pages up to 300,000 px a side open a group of rows at a time into the layer, with no full-size copy, and are refused with an error when the machine lacks the memory (Linux: free RAM and swap, cgroup limits; #8169); layered TIFF both byte orders, including Photoshop mixed-order type blocks on read (#4758/#4771) and TySh text descriptors written in PSD order (#9098) and Photoshop's 14-byte `LMsk` block (#9095); LZW/ZIP for scripted saves by `tiffCompression`; undecodable type keeps saved pixels (FILE-215-7); alpha and spot channels as extra samples (at most 16) with Photoshop's names, ink colours and solidity, spot ink stored 0 = solid (#7415); no Save Transparency choice yet (channels are written as Photoshop does with it off) |
| TIFF | R/W | yes | yes | 8/16/32f, Gray/RGB/CMYK, YCbCr read as RGB (JPEG-compressed with subsampled chroma, or uncompressed without subsampling; #10316), BigTIFF, every IFD, banded parallel decode; flat pages up to 300,000 px a side open a group of rows at a time into the layer, with no full-size copy, and are refused with an error when the machine lacks the memory (Linux: free RAM and swap, cgroup limits; #8169); layered TIFF both byte orders, including Photoshop mixed-order type blocks on read (#4758/#4771) and TySh text descriptors written in PSD order (#9098) and Photoshop's 14-byte `LMsk` block (#9095); LZW/ZIP for scripted saves by `tiffCompression`; undecodable type keeps saved pixels (FILE-215-7); alpha and spot channels as extra samples (at most 16) with Photoshop's names, ink colours and solidity, spot ink stored 0 = solid (#7415); no Save Transparency choice yet (channels are written as Photoshop does with it off) |
| JPEG | R/W | yes | yes | 8-bit Gray/RGB/CMYK, ICC/EXIF/XMP; Save keeps the opened file's quality (estimated from its quantisation tables, #2399); File › Save, Save As and Save a Copy ask JPEG Options: Quality 0–12, starting at the opened file's quality or else the Export preference, and Baseline ("Standard") / Baseline Optimized / Progressive; scripts pass `quality`/`jpegFormat`, the CLI `--jpeg-format` (FILE-215-19). Baseline Optimized fits the Huffman tables only at 4:4:4 (Gray, CMYK) or progressive: RGB is written 4:2:0, which zune-jpeg 0.5.15 decodes wrongly with fitted tables, as in Save for Web. No Matte, progressive Scans (3–5) or size preview |
| PNG | R/W | yes | yes | 8/16-bit, ICC/text; APNG first frame only; alpha channels on open (#2225); palette PNGs open in Indexed Color mode with their transparent entry (#4804); Save keeps a stereo PNG's `sTER` pairing (#6063) |
| GIF | R/W | yes | yes | File › Open keeps the first frame; New Video Layer from File and Video Frames to Layers take every frame, with the timeline rate from the delays (#572). Per-frame delays and keeping the frames in `.pcraft` are not done |
@@ -148,6 +148,7 @@ Embedded SVG and PDF-compatible AI/PDF/EPS payloads can use an explicit VectorCr
| Date | Change | Summary |
|---|---|---|
| 2026-10-30 | patch | TIFF: YCbCr (PhotometricInterpretation 6) opens as RGB instead of failing with "colour type YCbCr(8)": JPEG-compressed with subsampled chroma (as scanners write it) through the JPEG conversion, uncompressed with ReferenceBlackWhite and YCbCrCoefficients; 8 and 16-bit. Uncompressed subsampled YCbCr and YCbCr with extra samples still fail with a clear error (#10316) |
| 2026-10-26 | minor | Embedded AI/PDF/EPS save-back retains native format and a separate source-bound display preview; original sources remain undoable. Native Adobe interoperability and standalone EPS/DCS remain unverified; no corpus floor or percentage changed. |
| 2026-10-22 | minor | Embedded PSD vectors gain explicit bounded SVG editor/save-back; standalone EPS/DCS and native-format fidelity remain open. |
| 2026-10-22 | minor | TIFF: flat pages up to 300,000 px a side open (was 2^30 px, about 32,768²): `codecs::TiffBands` decodes a group of rows at a time into the layer's tiles. The 117,504 × 22,464 px banner from #8456 opens in 3.3 s at 11.45 GB peak for a 10.56 GB layer; 30,000² 6.8 s / 6.43 GB → 1.1 s / 3.86 GB; 36 MP 220 → 52 ms. Layered TIFFs still decode whole (#8169) |