mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-11 00:11:46 +00:00
* 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:
9 files changed
+547
-14
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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) |
|
||||
|
||||
Reference in new issue
Block a user