Merge pull request #7377 from smyrgeorge/fuji-compressed-raf

Fujifilm compressed RAF: decode lossless and lossy files instead of the embedded preview
This commit is contained in:
David Mann authored and GitHub committed 2026-11-10 13:36:29 +01:00
commit 2a950d64dd
11 files changed
+753 -51

No files matched your search

+35 -8
View File
@@ -284,6 +284,25 @@ fn develop_fitted(bytes: &[u8], opts: &DevelopOptions) -> Result<photocraft_raw:
photocraft_raw::develop_sensor(&sensor, &opts)
}
/// The camera's embedded JPEG, upright, for opening in place of sensor data that can't be
/// developed. A preview without an ICC profile of its own is tagged with the colour space the
/// raw's EXIF states (a Sony body set to Adobe RGB writes no profile into the JPEG), as the colour
/// fit reads it ([`preview_profile`]); otherwise it opens as untagged sRGB.
fn preview_image(raw: &[u8], jpeg: &[u8], limits: &photocraft_raw::Limits) -> Result<Image, IoError> {
let mut img = upright_preview(raw, jpeg, limits)?;
if img.icc.is_none() && photocraft_raw::embedded_preview_color_space(raw) == Some(PreviewColorSpace::AdobeRgb) {
img.icc = Some(Builtin::AdobeRgbCompat.profile().to_bytes().to_vec());
}
Ok(img)
}
/// A preview opened in place of the raw, with `note` saying why first among the warnings.
fn preview_document(name: &str, img: &Image, note: String) -> Result<ImportResult, IoError> {
let mut r = image_to_document(name, img)?;
r.warnings.insert(0, note);
Ok(r)
}
/// In the import note of a raw developed from its sensor data with the default settings (not of
/// a preview fallback): what the open-time Camera Raw dialog looks for.
pub const DEVELOPED_NOTE: &str = "developed with default settings";
@@ -318,18 +337,26 @@ pub fn import_raw_with(name: &str, bytes: &[u8], opts: &DevelopOptions) -> Resul
let size = (p.width, p.height);
(jxl_preview_image(bytes, p)?, "JPEG XL", size)
}
(None, Some(p)) => (upright_preview(bytes, p.jpeg, &opts.limits)?, "JPEG", (p.width, p.height)),
(None, Some(p)) => (preview_image(bytes, p.jpeg, &opts.limits)?, "JPEG", (p.width, p.height)),
(None, None) => return Err(IoError::Raw(RawError::Unsupported(reason))),
};
let mut r = image_to_document(name, &img)?;
let depth = if img.sample_type() == SampleType::U8 { "8-bit" } else { "16-bit" };
r.warnings.insert(
0,
format!(
"{format}: {reason} is not supported yet; opened the camera's embedded {w}x{h} {kind} preview instead ({depth}, not the raw sensor data)"
),
let note = format!(
"{format}: {reason} is not supported yet; opened the camera's embedded {w}x{h} {kind} preview instead ({depth}, not the raw sensor data)"
);
Ok(r)
preview_document(name, &img, note)
}
// Damaged sensor data: the camera's JPEG is often still intact, so the photo opens.
Err(RawError::Malformed(reason)) => {
match photocraft_raw::embedded_preview(bytes).map(|p| (p.width, p.height, preview_image(bytes, p.jpeg, &opts.limits))) {
Some((w, h, Ok(img))) => {
let note = format!(
"{format}: the raw sensor data is damaged ({reason}); opened the camera's embedded {w}x{h} JPEG preview instead (8-bit, not the raw sensor data)"
);
preview_document(name, &img, note)
}
_ => Err(IoError::Raw(RawError::Malformed(reason))),
}
}
Err(e) => Err(IoError::Raw(e)),
}
+56 -2
View File
@@ -183,6 +183,12 @@ fn a_developed_raw_keeps_its_camera_when_the_exif_directory_empties() {
/// A NEF-like file with Nikon's (undocumented) compression and a full-size
/// baseline JPEG preview in IFD0.
fn nef_with_preview() -> Vec<u8> {
nef_with_preview_exif(false)
}
/// The same; `adobe_rgb`: the EXIF says the camera was set to Adobe RGB (ColorSpace
/// Uncalibrated, InteropIndex "R03"), as on a Sony body, and the JPEG carries no profile.
fn nef_with_preview_exif(adobe_rgb: bool) -> Vec<u8> {
let img = Image::from_u8(32, 20, ChannelLayout::Rgb, vec![180; 32 * 20 * 3]).unwrap();
let jpeg = photocraft_codecs::encode(&img, Format::Jpeg, &EncodeOptions::default()).unwrap();
let mut t = TiffBuilder::default();
@@ -199,16 +205,32 @@ fn nef_with_preview() -> Vec<u8> {
(33421, Val::Short(vec![2, 2])),
(33422, Val::Byte(vec![0, 1, 1, 2])),
]);
let ifd0 = t.ifd(vec![
let mut tags = vec![
(271, Val::Ascii("NIKON CORPORATION".into())),
(330, Val::Ifds(vec![raw])),
(513, Val::Blobs(vec![preview])),
(514, Val::Long(vec![jpeg.len() as u32])),
]);
];
if adobe_rgb {
let interop = t.ifd(vec![(0x0001, Val::Ascii("R03".into()))]);
let exif = t.ifd(vec![(0xA001, Val::Short(vec![0xFFFF])), (0xA005, Val::Ifds(vec![interop]))]);
tags.push((34665, Val::Ifds(vec![exif])));
}
let ifd0 = t.ifd(tags);
t.chain = vec![ifd0];
t.build()
}
#[test]
fn a_preview_fallback_keeps_the_colour_space_the_raw_states() {
let tagged = import("DSC_0001.NEF", &nef_with_preview_exif(true)).unwrap();
let icc = tagged.document.icc_profile.as_deref().expect("an Adobe RGB preview opens tagged");
let profile = photocraft_cms::Profile::parse(icc).unwrap();
assert!(profile.same_colors(photocraft_cms::Builtin::AdobeRgbCompat.profile()));
// Unstated: untagged sRGB, as before.
assert!(import("DSC_0001.NEF", &nef_with_preview()).unwrap().document.icc_profile.is_none());
}
#[test]
fn unsupported_raw_falls_back_to_the_embedded_preview() {
let r = import("DSC_0001.NEF", &nef_with_preview()).unwrap();
@@ -313,6 +335,38 @@ fn cr3_opens_its_upright_camera_jpeg_with_explicit_preview_warning() {
assert!(r.warnings[0].contains("CR3"));
}
#[test]
fn damaged_raw_opens_its_camera_jpeg_with_a_warning() {
use photocraft_raw::testgen::{RafPacking, RafSpec, XTRANS};
let img = Image::from_u8(32, 20, ChannelLayout::Rgb, vec![120; 32 * 20 * 3]).unwrap();
let jpeg = photocraft_codecs::encode(&img, Format::Jpeg, &EncodeOptions::default()).unwrap();
// A lossless compressed RAF (all-zero samples) with a real camera JPEG.
let mut spec = RafSpec::new(780, 18, vec![0; 780 * 18], XTRANS.to_vec());
spec.packing = RafPacking::Compressed { lossy: false };
spec.black = vec![0];
spec.preview_jpeg = Some(jpeg);
let mut b = spec.build();
assert_eq!(import("DSCF0001.RAF", &b).unwrap().document.depth, SampleType::U16, "intact: developed");
// Zero bits inside the first stripe overrun the residual escape: malformed sensor data.
let at = b.windows(5).position(|w| w == b"IS\x01\x10\x0e").unwrap();
b[at + 96..at + 160].fill(0);
let r = import("DSCF0001.RAF", &b).unwrap();
assert_eq!((r.document.size.width, r.document.size.height, r.document.depth), (32, 20, SampleType::U8));
assert!(r.warnings[0].contains("damaged") && r.warnings[0].contains("8-bit, not the raw sensor data"), "{:?}", r.warnings);
// Without a usable preview it stays an error.
spec.preview_jpeg = None;
let mut b = spec.build();
let at = b.windows(5).position(|w| w == b"IS\x01\x10\x0e").unwrap();
b[at + 96..at + 160].fill(0);
match import("DSCF0001.RAF", &b) {
Err(e @ IoError::Raw(_)) => assert!(e.to_string().contains("malformed"), "{e}"),
Err(e) => panic!("expected a raw error, got {e}"),
Ok(_) => panic!("damaged sensor data without a preview must not open"),
}
}
#[test]
fn truncated_raws_do_not_panic() {
let b = nef_with_preview();
+2 -1
View File
@@ -84,7 +84,8 @@ camera colour tables were copied.
| Olympus ORF, uncompressed 16-bit (E-1, E-400…) | Decoded, with ImageProcessing black / WB / ValidBits / crop |
| Sony "Compressed RAW 2", Pentax compressed PEF, RW2 RawFormat 6 and newer, Olympus compressed ORF | Unsupported: no public description of these codes was found apart from GPL decoder source, which this crate may not use (clean-room). `photocraft-io` opens the embedded JPEG preview instead |
| Fujifilm RAF, uncompressed (X-Trans I–V, GFX and Bayer bodies since about 2010) | Decoded: 16-bit containers (either byte order), 12-bit LSB-first packing, 14-bit packing in 32-bit words; X-Trans 6×6 or Bayer 2×2 layout, black, as-shot WB, crop, RawExposureBias, orientation from the preview's EXIF |
| Fujifilm compressed RAF, early FinePix / SuperCCD RAF (no CFA TIFF) | Unsupported (no public description of the compression); preview fallback |
| Fujifilm compressed RAF, lossless and lossy (X-Trans and Bayer, 12/14/16-bit) | Decoded (`rafc.rs`): ported from LightCraft's clean-room decoder (storytold/lightcraft `crates/raw/src/vendor/rafc.rs`, MIT OR Apache-2.0, same organisation), verified there sample for sample on 24 files from 13 bodies; here on an X-T30 lossless file |
| Early FinePix / SuperCCD RAF (no CFA TIFF) | Unsupported; preview fallback |
| CR3 | Native CRX unsupported; bounded JPEG-track/PRVW preview fallback with CMT1 orientation. I/O labels the result as an 8-bit camera preview |
| X-Trans and other periodic non-Bayer CFAs (up to 16×16) | Demosaiced by one pattern-agnostic edge-directed method, whatever `Demosaic` is chosen |
+1
View File
@@ -62,6 +62,7 @@ mod orf;
mod par;
mod preview;
mod raf;
mod rafc;
mod rw2;
mod saturation;
mod sensor;
+51 -21
View File
@@ -28,9 +28,10 @@
//! least significant bit first (X-Trans I, X100, XF1, X-A1…); 14-bit
//! samples packed into little-endian 32-bit words, most significant bit
//! first (X-A3/5/7, X-T100/200, XF10). A shorter strip is Fujifilm's own
//! lossless compression (it starts with `IS`), which has no public
//! description and is reported as unsupported, as are the pre-2010
//! FinePix / SuperCCD files without the CFA TIFF.
//! striped compression (it starts with `IS`; header byte 108 holds the
//! mode: 0 uncompressed, 2 lossless, 3 lossy), decoded by `rafc`. The
//! pre-2010 FinePix / SuperCCD files without the CFA TIFF are reported as
//! unsupported.
//! * XTransLayout lists the 6×6 X-Trans pattern (0 = R, 1 = G, 2 = B) in
//! reverse order: its last byte is the colour at data (0, 0), its first
//! the colour at (5, 5).
@@ -47,7 +48,7 @@ use crate::cr2::clip_level;
use crate::error::{RawError, Result};
use crate::sensor::{BlackLevels, Cfa, Rect, Sensor};
use crate::tiff::{Tiff, tag};
use crate::{Limits, RawFormat, par};
use crate::{Limits, RawFormat, par, rafc};
pub(crate) const MAGIC: &[u8] = b"FUJIFILMCCD-RAW";
@@ -57,6 +58,8 @@ const FUJI_LAYOUT: u16 = 0x0130;
const XTRANS_LAYOUT: u16 = 0x0131;
const WB_GRGB_LEVELS: u16 = 0x2FF0;
const RAW_EXPOSURE_BIAS: u16 = 0x9650;
/// Header byte offset of the big-endian compression mode: 0 uncompressed, 2 lossless, 3 lossy.
const COMPRESSION: usize = 108;
const FUJI_IFD: u16 = 0xF000;
const RAW_WIDTH: u16 = 0xF001;
@@ -195,6 +198,31 @@ fn header_model(bytes: &[u8]) -> Option<String> {
(!s.is_empty()).then_some(s)
}
/// A strip shorter than the samples it holds: Fujifilm's striped compression (`rafc`).
fn decode_compressed(bytes: &[u8], src: &[u8], width: usize, height: usize, bits: u32, cfa: &Cfa) -> Result<Vec<u16>> {
let flag =
rafc::stream_flag(src).ok_or_else(|| RawError::unsupported("Fujifilm RAF with an unknown sample storage (short strip without the IS signature)"))?;
// The header's compression mode (0 uncompressed, 2 lossless, 3 lossy) must agree with the stream.
match be32(bytes, COMPRESSION) {
Some(2) if flag == 1 => {}
Some(3) if flag == 0 => {}
Some(m @ (2 | 3)) => return Err(RawError::malformed(format!("RAF compression mode {m} disagrees with the compressed stream"))),
_ => {}
}
// The stream assumes a fixed arrangement: the X-Trans pattern of `rafc::XTRANS`, or
// greens off the diagonal of a 2×2 Bayer tile.
let xtrans = cfa.width == 6;
let fits = if xtrans {
(0..36).all(|i| rafc::XTRANS.get(i / 6).and_then(|r| r.get(i % 6)).is_some_and(|&c| usize::from(cfa.color(i % 6, i / 6)) == c))
} else {
cfa.color(1, 0) == 1 && cfa.color(0, 1) == 1 && cfa.color(0, 0) != 1
};
if !fits {
return Err(RawError::unsupported("Fujifilm compressed RAF with an unknown CFA layout"));
}
rafc::decode(src, width, height, bits, xtrans)
}
pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Sensor> {
let cfa_at = be32(bytes, 100).ok_or_else(|| RawError::malformed("RAF header is truncated"))?;
let cfa_len = be32(bytes, 104).unwrap_or(0);
@@ -220,25 +248,27 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Sensor> {
let src = if count > 0 { src.get(..count).unwrap_or(src) } else { src };
let pixels = width * height;
let packed = pixels.saturating_mul(bits as usize).div_ceil(8);
let packing = if count >= pixels * 2 {
Packing::U16 { le: t.le }
} else if count >= packed && bits == 12 {
Packing::LsbFirst
} else if count >= packed && bits == 14 {
Packing::Words32MsbFirst
} else if count >= packed {
return Err(RawError::unsupported(format!("Fujifilm RAF with packed {bits}-bit samples")));
} else {
return Err(RawError::unsupported("Fujifilm compressed RAF is not decoded (no public description of the format)"));
};
let need = if matches!(packing, Packing::U16 { .. }) { pixels * 2 } else { packed };
if src.len() < need {
return Err(RawError::malformed("RAF raw data is truncated"));
}
let data = unpack(src, width, height, bits, packing);
let recs = records(bytes);
let cfa = layout_cfa(&recs).ok_or_else(|| RawError::unsupported("Fujifilm RAF without a recognised CFA layout"))?;
let data = if count >= pixels * 2 || count >= packed {
let packing = if count >= pixels * 2 {
Packing::U16 { le: t.le }
} else if bits == 12 {
Packing::LsbFirst
} else if bits == 14 {
Packing::Words32MsbFirst
} else {
return Err(RawError::unsupported(format!("Fujifilm RAF with packed {bits}-bit samples")));
};
let need = if matches!(packing, Packing::U16 { .. }) { pixels * 2 } else { packed };
if src.len() < need {
return Err(RawError::malformed("RAF raw data is truncated"));
}
unpack(src, width, height, bits, packing)
} else {
decode_compressed(bytes, src, width, height, bits, &cfa)?
};
let full = Rect::new(0, 0, width, height);
let mut warnings = Vec::new();
+507
View File
@@ -0,0 +1,507 @@
//! Fujifilm's striped predictive raw compression (lossless and lossy RAF).
//!
//! Ported from LightCraft's clean-room decoder (`crates/raw/src/vendor/rafc.rs`,
//! storytold/lightcraft, MIT OR Apache-2.0, same organisation). LightCraft took the broad
//! structure from Fabian's prose description of lossless RAF compression
//! (<https://capnfabs.net/posts/fuji-raf-compression-algorithm/>) and recovered the headers,
//! colour-vector placement, predictors, contexts and adaptation from sample bytes and
//! black-box comparisons of decoded sensor arrays (rawpy as an oracle only); no other raw
//! decoder's source was consulted. LightCraft's `docs/raf-compression.md` records the
//! verification: 24 files from 13 bodies (X-Trans II–V, GFX Bayer, 14- and 16-bit, lossless
//! and lossy) matched the reference sensor arrays sample for sample.
//!
//! The stream:
//!
//! * A 16-byte `IS` header: compression flag (0 lossy, 1 lossless), sensor type (0 Bayer, 16
//! X-Trans), bit depth, height, padded width, width, 768-pixel stripe width, stripe count and
//! six-row group count. It must agree with the raw IFD.
//! * A big-endian stripe size table, padded to 16 bytes; lossy files then carry one quantizer
//! byte per six-row group for each stripe, each table padded to 16 bytes.
//! * Independent stripes 768 pixels wide. Each six-row group is collated into three red, six
//! green and three blue vectors (384 samples on Bayer; 512 slots on X-Trans, some of them
//! interpolated without consuming bits). Vector pairs alternate R/G and G/B; even columns
//! lead odd ones by seven positions. Prediction errors use adaptive Rice coding with a
//! bounded escape, in 41 folded gradient contexts per pair phase and column parity.
use crate::error::{RawError, Result};
use crate::par;
const BLOCK: usize = 768;
/// The CFA the X-Trans stream assumes at the data origin (0 = R, 1 = G, 2 = B).
pub(crate) const XTRANS: [[usize; 6]; 6] =
[[1, 1, 0, 1, 1, 2], [1, 1, 2, 1, 1, 0], [2, 0, 1, 0, 2, 1], [1, 1, 2, 1, 1, 0], [1, 1, 0, 1, 1, 2], [0, 2, 1, 2, 0, 1]];
/// Which X-Trans vector slots hold real samples, per pair and colour, by column mod 4.
const MASKS: [[u8; 2]; 6] = [[10, 15], [15, 10], [14, 10], [15, 11], [11, 15], [10, 14]];
/// A malformed-stream error.
fn corrupt(why: &str) -> RawError {
RawError::malformed(format!("Fujifilm compressed RAF: {why}"))
}
/// Big-endian 16-bit value at `at`.
fn be16(src: &[u8], at: usize) -> Result<usize> {
let s = src.get(at..at.saturating_add(2)).ok_or_else(|| corrupt("truncated header"))?;
let [a, b] = s else { return Err(corrupt("truncated header")) };
Ok(u16::from_be_bytes([*a, *b]) as usize)
}
/// The compression flag (byte 2 of the stream): 1 lossless, 0 lossy.
pub(crate) fn stream_flag(src: &[u8]) -> Option<u8> {
src.starts_with(b"IS").then(|| src.get(2).copied()).flatten()
}
struct Stripe<'a> {
bytes: &'a [u8],
quantizers: Option<&'a [u8]>,
width: usize,
}
/// Decodes a `width × height` compressed strip. The caller has checked the size against the
/// [`crate::Limits`].
pub(crate) fn decode(src: &[u8], width: usize, height: usize, bits: u32, xtrans: bool) -> Result<Vec<u16>> {
let h = src.get(..16).ok_or_else(|| corrupt("truncated header"))?;
if !h.starts_with(b"IS") {
return Err(corrupt("missing compression signature"));
}
let flag = h.get(2).copied().ok_or_else(|| corrupt("truncated header"))?;
if flag > 1 {
return Err(corrupt("invalid compression flag"));
}
let sensor = h.get(3).copied().ok_or_else(|| corrupt("truncated header"))?;
if sensor != if xtrans { 16 } else { 0 } {
return Err(corrupt("compression sensor type disagrees with the CFA"));
}
if !matches!(bits, 12 | 14 | 16) {
return Err(RawError::unsupported(format!("Fujifilm compressed RAF at {bits} bits")));
}
let count = h.get(13).copied().ok_or_else(|| corrupt("truncated header"))? as usize;
if h.get(4).copied() != Some(bits as u8)
|| be16(h, 5)? != height
|| be16(h, 9)? != width
|| count == 0
|| count != width.div_ceil(BLOCK)
|| be16(h, 7)? != count * BLOCK
|| be16(h, 11)? != BLOCK
|| height == 0
|| !height.is_multiple_of(6)
|| be16(h, 14)? != height / 6
{
return Err(corrupt("inconsistent compression dimensions"));
}
let n = width.checked_mul(height).ok_or_else(|| RawError::LimitExceeded("image too large".into()))?;
let table_end = 16 + count * 4;
let mut offset = table_end.div_ceil(16) * 16;
let quantizer_start = offset;
let quantizer_stride = (height / 6).div_ceil(16) * 16;
if flag == 0 {
offset += quantizer_stride * count;
if src.get(quantizer_start..offset).is_none() {
return Err(corrupt("truncated quantizer tables"));
}
}
let mut stripes = Vec::with_capacity(count);
for i in 0..count {
let entry = src.get(16 + i * 4..20 + i * 4).ok_or_else(|| corrupt("truncated stripe size table"))?;
let [a, b, c, d] = entry else { return Err(corrupt("truncated stripe size table")) };
let size = u32::from_be_bytes([*a, *b, *c, *d]) as usize;
let end = offset.checked_add(size).ok_or_else(|| corrupt("stripe size overflow"))?;
let bytes = src.get(offset..end).filter(|s| !s.is_empty()).ok_or_else(|| corrupt("stripe outside the file"))?;
let quantizers = if flag == 0 {
let q = quantizer_start + i * quantizer_stride;
Some(src.get(q..q + height / 6).ok_or_else(|| corrupt("truncated quantizer table"))?)
} else {
None
};
stripes.push(Stripe { bytes, quantizers, width: (width - i * BLOCK).min(BLOCK) });
offset = end;
}
let decoded = par::map(stripes.len(), |i| match stripes.get(i) {
Some(s) => decode_stripe(s, height, bits, xtrans),
None => Err(corrupt("invalid stripe")),
});
let mut output = vec![0; n];
for (i, (stripe, data)) in stripes.iter().zip(decoded).enumerate() {
let data = data?;
for (row, samples) in output.chunks_exact_mut(width).zip(data.chunks_exact(stripe.width)) {
row.get_mut(i * BLOCK..i * BLOCK + stripe.width).ok_or_else(|| corrupt("invalid stripe placement"))?.copy_from_slice(samples);
}
}
Ok(output)
}
/// Big-endian bit reader.
struct Bits<'a> {
src: &'a [u8],
pos: usize,
acc: u64,
available: u32,
}
impl<'a> Bits<'a> {
fn new(src: &'a [u8]) -> Self {
Self { src, pos: 0, acc: 0, available: 0 }
}
#[inline]
fn get(&mut self, n: u32) -> Result<u32> {
while self.available < n {
let b = self.src.get(self.pos).copied().ok_or_else(|| corrupt("truncated entropy stream"))?;
self.pos += 1;
self.acc = (self.acc << 8) | u64::from(b);
self.available += 8;
}
self.available -= n;
Ok(((self.acc >> self.available) & ((1u64 << n) - 1)) as u32)
}
/// One Rice-coded residual: `zeros` unary prefix then `k` bits, or after `limit` zeros
/// an escaped `bits`-bit code.
fn residual(&mut self, k: u32, bits: u32, range: u32, limit: u32) -> Result<i32> {
let mut zeros = 0;
while self.get(1)? == 0 {
zeros += 1;
if zeros > limit {
return Err(corrupt("invalid residual escape"));
}
}
let code = if zeros == limit { self.get(bits)? + 1 } else { (zeros << k) | self.get(k)? };
if code > range {
return Err(corrupt("residual outside the sample range"));
}
Ok(if code & 1 == 0 { (code / 2) as i32 } else { -((code / 2) as i32) - 1 })
}
}
/// Adaptive Rice parameter statistics of one context.
#[derive(Clone, Copy)]
struct State {
sum: u32,
count: u32,
}
impl State {
fn k(self) -> u32 {
let mut k = 0;
while k < 31 && self.count << k < self.sum {
k += 1;
}
k
}
fn update(&mut self, delta: i32) {
self.sum = self.sum.saturating_add(delta.unsigned_abs());
if self.count == 64 {
self.sum >>= 1;
self.count >>= 1;
}
self.count += 1;
}
}
/// Gradient bucket (−4..=4) for the context of quantizer `near`.
#[inline]
fn quant(v: i32, near: u32) -> i32 {
let t = [18 + 3 * near, 67 + 5 * near, 276 + 7 * near];
let a = v.unsigned_abs();
let bucket = if a <= near {
0
} else if a < t[0] {
1
} else if a < t[1] {
2
} else if a < t[2] {
3
} else {
4
};
if v < 0 { -bucket } else { bucket }
}
/// Prediction for an even column from its north-west, north, north-east and north-north
/// neighbours: the pair across the smallest gradient, averaged with the north sample.
#[inline]
fn even_prediction(nw: i32, n: i32, ne: i32, nn: i32) -> i32 {
let (a, b, c) = ((nw - n).abs(), (ne - n).abs(), (nn - n).abs());
let pair = if a > b && a > c {
ne + nn
} else if b > a && b > c {
nw + nn
} else {
nw + ne
};
(pair + 2 * n) >> 2
}
/// Sample `x` of line `y` of a colour vector.
#[inline]
fn sample(rows: &[Vec<i32>], y: usize, x: usize) -> Result<i32> {
rows.get(y).and_then(|r| r.get(x)).copied().ok_or_else(|| corrupt("invalid predictor position"))
}
/// Decodes one stripe, six rows at a time, into `height × stripe.width` samples.
fn decode_stripe(stripe: &Stripe<'_>, height: usize, bits: u32, xtrans: bool) -> Result<Vec<u16>> {
let size = if xtrans { BLOCK * 2 / 3 } else { BLOCK / 2 };
let mut previous = vec![vec![vec![0; size + 2]; 2]; 3];
let max = (1u32 << bits) - 1;
let range = |near: u32| (max + 2 * near) / (2 * near + 1) + 1;
let seed = |near: u32| State { sum: ((range(near) + 32) / 64).max(2), count: 1 };
let mut states = [[[seed(0); 41]; 2]; 3];
// Smooth regions keep finer precision and their own state across block quantizer changes.
let mut fine = [[[[seed(0); 5]; 2]; 3], [[[seed(1); 5]; 2]; 3], [[[seed(2); 5]; 2]; 3]];
let mut last_near = None;
let mut reader = Bits::new(stripe.bytes);
let mut output = Vec::with_capacity(height.saturating_mul(stripe.width));
for group in 0..height / 6 {
let near = match stripe.quantizers {
Some(q) => u32::from(*q.get(group).ok_or_else(|| corrupt("truncated quantizer table"))?),
None => 0,
};
if last_near != Some(near) {
states = [[[seed(near); 41]; 2]; 3];
last_near = Some(near);
}
let mut rows = previous;
for pair in 0..6 {
let colours = if pair % 2 == 0 { [0, 1] } else { [1, 2] };
for &colour in &colours {
let lines = rows.get_mut(colour).ok_or_else(|| corrupt("invalid colour vector"))?;
let mut row = vec![0; size + 2];
let prev = lines.len() - 1;
let left = sample(lines, prev, 1)?;
let right = sample(lines, prev, size)?;
if let Some(v) = row.first_mut() {
*v = left;
}
if let Some(v) = row.last_mut() {
*v = right;
}
lines.push(row);
}
for even in (0..size + 8).step_by(2) {
for (odd, position) in [(0, Some(even)), (1, even.checked_sub(7))] {
let Some(pos) = position.filter(|&x| x < size) else { continue };
let x = pos + 1;
for (which, &colour) in colours.iter().enumerate() {
let lines = rows.get_mut(colour).ok_or_else(|| corrupt("invalid colour vector"))?;
let y = lines.len() - 1;
let (nw, north, ne, nn) =
(sample(lines, y - 1, x - 1)?, sample(lines, y - 1, x)?, sample(lines, y - 1, x + 1)?, sample(lines, y - 2, x)?);
let west = sample(lines, y, x - 1)?;
let east = sample(lines, y, x + 1)?;
let (predict, d1, d2) = if odd == 0 {
(even_prediction(nw, north, ne, nn), north - nn, nw - north)
} else {
let p = if (nw.min(ne)..=nw.max(ne)).contains(&north) { (west + east) >> 1 } else { (west + east + 2 * north) >> 2 };
(p, north - nw, nw - west)
};
let real = !xtrans || MASKS.get(pair).and_then(|m| m.get(which)).is_some_and(|m| (m >> (pos % 4)) & 1 != 0);
let value = if real {
let contrast = d1.unsigned_abs() + d2.unsigned_abs();
let precision = match contrast {
0..=5 => 0,
6 => near.min(1),
7 => near.min(2),
_ => near,
};
let (context, state) = if precision < near {
let sign = |v: i32| if v.unsigned_abs() <= precision { 0 } else { v.signum() };
let context = 3 * sign(d1) + sign(d2);
let state = fine
.get_mut(precision as usize)
.and_then(|f| f.get_mut(pair % 3))
.and_then(|s| s.get_mut(odd))
.and_then(|s| s.get_mut(context.unsigned_abs() as usize))
.ok_or_else(|| corrupt("invalid fine coding context"))?;
(context, state)
} else {
let context = 9 * quant(d1, near) + quant(d2, near);
let state = states
.get_mut(pair % 3)
.and_then(|s| s.get_mut(odd))
.and_then(|s| s.get_mut(context.unsigned_abs() as usize))
.ok_or_else(|| corrupt("invalid coding context"))?;
(context, state)
};
let coded_range = range(precision);
let coded_bits = 32 - (coded_range - 1).leading_zeros();
let mut delta = reader.residual(state.k(), coded_bits, coded_range, 4 * bits - coded_bits - 1)?;
state.update(delta);
if context < 0 {
delta = -delta;
}
let step = (2 * precision + 1) as i32;
let mut value = predict + delta * step;
if value < -(precision as i32) {
value += coded_range as i32 * step;
} else if value > (max + precision) as i32 {
value -= coded_range as i32 * step;
}
value.clamp(0, max as i32)
} else {
predict
};
*lines.get_mut(y).and_then(|r| r.get_mut(x)).ok_or_else(|| corrupt("invalid predictor position"))? = value;
}
}
}
}
for y in 0..6 {
for x in 0..stripe.width {
let colour = if xtrans {
XTRANS.get(y).and_then(|r| r.get(x % 6)).copied().ok_or_else(|| corrupt("invalid CFA position"))?
} else {
match (y % 2, x % 2) {
(0, 0) => 0,
(1, 1) => 2,
_ => 1,
}
};
let row = 2 + if colour == 1 { y } else { y / 2 };
let col = 1 + if xtrans { 2 * (x / 3) + (x % 3).min(1) } else { x / 2 };
output.push(sample(rows.get(colour).ok_or_else(|| corrupt("invalid colour vector"))?, row, col)? as u16);
}
}
previous = rows.into_iter().map(|mut r| r.split_off(r.len() - 2)).collect();
}
Ok(output)
}
/// A compressed stream whose residuals are all zero (every sample decodes to 0), for tests
/// and fuzz seeds: two stripes over a 780-pixel-wide image, `6 × groups` rows high. Lossy
/// streams take one quantizer per group.
#[cfg(any(test, feature = "testgen"))]
pub(crate) fn zero_stream(bits: u32, xtrans: bool, groups: usize, quantizers: Option<&[u8]>) -> Vec<u8> {
assert!(quantizers.is_none_or(|q| q.len() == groups), "one quantizer per six-row group");
let mut bitstream = Vec::new();
let mut main = [[(1u32 << (bits - 6), 1u32); 2]; 3];
let mut fine = main;
let mut previous = None;
for group in 0..groups {
let q = quantizers.and_then(|q| q.get(group)).copied().unwrap_or(0);
if previous != Some(q) {
main = [[(1u32 << (bits - 6), 1u32); 2]; 3];
previous = Some(q);
}
for pair in 0..6 {
let width = if xtrans { 512 } else { 384 };
for even in (0usize..width + 8).step_by(2) {
for (parity, column) in [(0, Some(even)), (1, even.checked_sub(7))] {
let Some(column) = column.filter(|&x| x < width) else { continue };
for &mask in &MASKS[pair % 6] {
if xtrans && (mask >> (column % 4)) & 1 == 0 {
continue;
}
let (sum, count) = &mut if q == 0 { &mut main } else { &mut fine }[pair % 3][parity];
let k = 32 - (sum.saturating_sub(1) / *count).leading_zeros();
bitstream.push(true);
bitstream.extend(std::iter::repeat_n(false, k as usize));
if *count == 64 {
*sum /= 2;
*count /= 2;
}
*count += 1;
}
}
}
}
}
let stream: Vec<u8> = bitstream.chunks(8).map(|chunk| chunk.iter().enumerate().fold(0, |b, (i, bit)| b | (u8::from(*bit) << (7 - i)))).collect();
// Two stripes; the second is mostly padding, which must never enter the output.
let mut out = b"IS".to_vec();
out.extend_from_slice(&[u8::from(quantizers.is_none()), if xtrans { 16 } else { 0 }, bits as u8]);
for v in [groups as u16 * 6, 1536, 780, 768] {
out.extend_from_slice(&v.to_be_bytes());
}
out.push(2);
out.extend_from_slice(&(groups as u16).to_be_bytes());
for _ in 0..2 {
out.extend_from_slice(&(stream.len() as u32).to_be_bytes());
}
out.resize(32, 0);
if let Some(q) = quantizers {
for _ in 0..2 {
out.extend_from_slice(q);
out.resize(out.len() + q.len().div_ceil(16) * 16 - q.len(), 0);
}
}
out.extend_from_slice(&stream);
out.extend_from_slice(&stream);
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lossless_and_lossy_bayer_and_xtrans_stripes() {
for bits in [12, 14, 16] {
for xtrans in [false, true] {
for q in [None, Some([2, 0, 5].as_slice())] {
let src = zero_stream(bits, xtrans, 3, q);
assert_eq!(decode(&src, 780, 18, bits, xtrans).unwrap(), vec![0; 780 * 18], "{bits} {xtrans} {q:?}");
}
}
}
}
#[test]
fn malformed_headers_and_truncated_entropy_are_errors() {
let valid = zero_stream(14, true, 3, Some(&[1, 2, 0]));
for len in (0..96).chain([valid.len() / 2, valid.len() - 1]) {
assert!(decode(&valid[..len], 780, 18, 14, true).is_err(), "length {len}");
}
for at in [2, 3, 4, 5, 7, 9, 11, 13, 14, 16] {
let mut src = valid.clone();
src[at] ^= 0xff;
assert!(decode(&src, 780, 18, 14, true).is_err(), "header byte {at}");
}
assert!(decode(&valid, usize::MAX, usize::MAX, 14, true).is_err());
assert!(decode(&valid, 780, 18, 14, false).is_err(), "sensor type disagrees with the CFA");
let mut reader = Bits::new(&[0; 64]);
assert!(reader.residual(0, 14, 16384, 41).is_err());
}
#[test]
fn residual_sign_wrap_and_escape() {
// Ordinary codes 0, 1, 2, 3 with k=2; then the escape for -8192.
let mut stream = vec![true, false, false, true, false, true, true, true, false, true, true, true];
stream.extend(std::iter::repeat_n(false, 41));
stream.push(true);
let escaped = 16382u32;
stream.extend((0..14).rev().map(|i| (escaped >> i) & 1 != 0));
let bytes: Vec<u8> = stream.chunks(8).map(|chunk| chunk.iter().enumerate().fold(0, |b, (i, bit)| b | (u8::from(*bit) << (7 - i)))).collect();
let mut reader = Bits::new(&bytes);
for delta in [0, -1, 1, -2, -8192] {
assert_eq!(reader.residual(2, 14, 16384, 41).unwrap(), delta);
}
assert_eq!(even_prediction(1112, 858, 1104, 1152), 983);
}
#[test]
fn random_streams_never_panic() {
// xorshift: deterministic hostile entropy behind a valid header.
let mut x = 0x9E37_79B9_7F4A_7C15u64;
let mut next = || {
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
x
};
for case in 0..256 {
let bits = [12, 14, 16][case % 3];
let xtrans = case % 2 == 0;
let quantizer = (next() & 0xFF) as u8;
let len = (next() % 2048) as usize;
let data: Vec<u8> = (0..len).map(|_| next() as u8).collect();
let mut src = zero_stream(bits, xtrans, 1, Some(&[quantizer]));
src.truncate(64);
for i in [16, 20] {
src[i..i + 4].copy_from_slice(&(data.len() as u32).to_be_bytes());
}
src.extend_from_slice(&data);
src.extend_from_slice(&data);
let _ = decode(&src, 780, 6, bits, xtrans);
}
}
}
+17 -6
View File
@@ -11,8 +11,9 @@ pub enum RafPacking {
Lsb12,
/// 14-bit, most significant bit first in little-endian 32-bit words.
Words14,
/// A short `IS…` strip standing in for Fujifilm's compressed data.
Compressed,
/// Fujifilm's striped compression, lossless or lossy: a stream whose residuals are all
/// zero, so it needs a 780-pixel-wide, all-zero image whose height is a multiple of 6.
Compressed { lossy: bool },
}
/// The X-Trans repeat at the data origin, as observed (row major, 0 = R, 1 = G, 2 = B).
@@ -46,6 +47,8 @@ pub struct RafSpec {
pub orientation: u16,
/// `false` writes an early-FinePix-style file without the CFA TIFF.
pub cfa_tiff: bool,
/// A real JPEG to embed as the preview; `None` writes a tiny header-only stand-in.
pub preview_jpeg: Option<Vec<u8>>,
}
impl RafSpec {
@@ -63,6 +66,7 @@ impl RafSpec {
exposure_bias: None,
orientation: 1,
cfa_tiff: true,
preview_jpeg: None,
}
}
@@ -102,10 +106,10 @@ impl RafSpec {
logical.resize(logical.len().next_multiple_of(4), 0);
logical.chunks(4).flat_map(|w| w.iter().rev().copied().collect::<Vec<u8>>()).collect()
}
RafPacking::Compressed => {
let mut v = b"IS\x01\x00".to_vec();
v.resize(self.width * self.height / 2, 0x5A);
v
RafPacking::Compressed { lossy } => {
assert!(self.width == 780 && self.height.is_multiple_of(6) && self.data.iter().all(|&v| v == 0), "see RafPacking::Compressed");
let quantizers: Vec<u8> = (0..self.height / 6).map(|g| [2, 0, 5][g % 3]).collect();
crate::rafc::zero_stream(self.bits, self.cfa.len() == 36, self.height / 6, lossy.then_some(quantizers.as_slice()))
}
}
}
@@ -166,6 +170,9 @@ impl RafSpec {
/// A tiny baseline-JPEG preview (APP1 EXIF with make, model and
/// orientation; SOF0 32×24; EOI).
fn preview(&self) -> Vec<u8> {
if let Some(jpeg) = &self.preview_jpeg {
return jpeg.clone();
}
let mut t = TiffBuilder::default();
let ifd0 = t.ifd(vec![(271, Val::Ascii("FUJIFILM".into())), (272, Val::Ascii("X-Synthetic".into())), (274, Val::Short(vec![self.orientation]))]);
t.chain = vec![ifd0];
@@ -223,6 +230,10 @@ impl RafSpec {
b.resize(60, 0);
b.extend_from_slice(b"0100");
b.resize(160, 0);
// Compression mode: 0 uncompressed, 2 lossless, 3 lossy.
if let RafPacking::Compressed { lossy } = self.packing {
b[108..112].copy_from_slice(&(if lossy { 3u32 } else { 2 }).to_be_bytes());
}
let preview = self.preview();
let meta = self.meta();
let region = if self.cfa_tiff { self.cfa_region() } else { self.strip() };
+79 -9
View File
@@ -98,23 +98,93 @@ fn bayer_layouts() {
}
}
/// A 780×18 all-zero RAF in Fujifilm's compressed storage (see `RafPacking::Compressed`).
fn compressed(cfa: &[u8], bits: u32, lossy: bool) -> RafSpec {
let mut s = RafSpec::new(780, 18, vec![0; 780 * 18], cfa.to_vec());
s.bits = bits;
s.packing = RafPacking::Compressed { lossy };
s.black = vec![0];
s
}
#[test]
fn compressed_lossless_and_lossy_decode() {
for cfa in [XTRANS.as_slice(), &RGGB, &[2, 1, 1, 0]] {
for bits in [12, 14, 16] {
for lossy in [false, true] {
let mut spec = compressed(cfa, bits, lossy);
spec.crop = Some([0, 2, 18, 776]);
let b = spec.build();
let s = decode(&b, &Limits::default()).unwrap_or_else(|e| panic!("{cfa:?} {bits} {lossy}: {e}"));
assert_eq!((s.width, s.height), (780, 18));
assert_eq!(s.data, spec.data);
assert_eq!(s.crop, Rect::new(2, 0, 776, 18));
assert_eq!(s.camera_wb, Some([2.0, 1.0, 1.5]));
assert_eq!(s.cfa.as_ref().map(|c| c.width), Some(if cfa.len() == 36 { 6 } else { 2 }));
develop_sensor(&s, &DevelopOptions::default()).unwrap();
}
}
}
}
#[test]
fn compressed_images_of_any_group_count_decode() {
for h in [6, 24] {
for lossy in [false, true] {
let mut spec = compressed(&XTRANS, 14, lossy);
spec.height = h;
spec.data = vec![0; 780 * h];
let s = decode(&spec.build(), &Limits::default()).unwrap_or_else(|e| panic!("{h} rows, lossy {lossy}: {e}"));
assert_eq!((s.height, s.data.len()), (h, 780 * h));
}
}
}
#[test]
fn compressed_variants_that_cannot_be_decoded_fail_cleanly() {
// The header's compression mode disagrees with the stream (lossy header, lossless stream).
let mut b = compressed(&XTRANS, 14, false).build();
b[108..112].copy_from_slice(&3u32.to_be_bytes());
assert!(matches!(decode(&b, &Limits::default()), Err(RawError::Malformed(_))));
// The stream assumes a fixed CFA arrangement.
let mut other = XTRANS;
other.swap(0, 2);
for cfa in [other.as_slice(), &[1, 0, 2, 1]] {
let b = compressed(cfa, 14, false).build();
match decode(&b, &Limits::default()) {
Err(RawError::Unsupported(m)) => assert!(m.contains("CFA"), "{m}"),
other => panic!("expected unsupported, got {other:?}"),
}
}
// A short strip without the IS signature.
let mut b = compressed(&XTRANS, 14, false).build();
let at = b.windows(5).position(|w| w == b"IS\x01\x10\x0e").unwrap();
b[at..at + 2].copy_from_slice(b"XX");
assert!(matches!(decode(&b, &Limits::default()), Err(RawError::Unsupported(_))));
// A damaged entropy stream (sizes intact, a run of zero bits inside the first stripe that
// overruns the residual escape) is malformed, never a panic.
let mut b = compressed(&XTRANS, 14, false).build();
let at = b.windows(5).position(|w| w == b"IS\x01\x10\x0e").unwrap();
// Lossless: the 16-byte header and the two-entry size table, padded to 32 bytes.
let stream = at + 32;
b[stream + 64..stream + 128].fill(0);
match decode(&b, &Limits::default()) {
Err(RawError::Malformed(m)) => assert!(m.contains("residual"), "{m}"),
other => panic!("expected malformed, got {other:?}"),
}
}
#[test]
fn unsupported_variants_report_and_keep_the_preview() {
let mut spec = xtrans(RafPacking::Compressed, 14);
let mut spec = xtrans(RafPacking::U16 { le: true }, 14);
spec.cfa_tiff = false;
let b = spec.build();
match decode(&b, &Limits::default()) {
Err(RawError::Unsupported(m)) => assert!(m.contains("compressed"), "{m}"),
Err(RawError::Unsupported(m)) => assert!(m.contains("FinePix"), "{m}"),
other => panic!("expected unsupported, got {other:?}"),
}
let p = embedded_preview(&b).unwrap();
assert_eq!((p.width, p.height), (32, 24));
spec.packing = RafPacking::U16 { le: true };
spec.cfa_tiff = false;
match decode(&spec.build(), &Limits::default()) {
Err(RawError::Unsupported(m)) => assert!(m.contains("FinePix"), "{m}"),
other => panic!("expected unsupported, got {other:?}"),
}
}
#[test]
+3 -2
View File
@@ -1,6 +1,6 @@
# File-format parity
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-11-08 · **Change:** minor (Camera Raw: an `.xmp` sidecar's crop crops the opened raw, and `crs:RawFileName` is no longer named as unsupported, #9985; CLI `--metadata none|all` for `convert`, `run --out` and `batch`: `none` leaves the document XMP and free-form text out of flat exports, as Export As's Metadata: None, `all` the default, #9466; Hasselblad 3FR/FFF, the X2D II 100C, decodes; scripted PSD/PSB saves take `maximizeCompatibility`, and the CLI `--no-maximize-compatibility`, #8064; TIFF Options on Save As and Save a Copy: Image Compression None/LZW/ZIP, Save Image Pyramid, Save Transparency and Discard Layers, also `tiffTransparency`/`tiffPyramid` for scripts, the CLI and MCP, #8827; `.atn`: 145 supported events including Shear after Actions batch 12 (Groups A–I, command options, version 12 files, reference forms); the supported table and measured reference are in [atn.md](atn.md); 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, #1049Line truncated
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-11-08 · **Change:** minor (Fujifilm compressed RAF (lossless and lossy, X-Trans and Bayer) decodes instead of falling back to the embedded JPEG preview, #7377; Camera Raw: an `.xmp` sidecar's crop crops the opened raw, and `crs:RawFileName` is no longer named as unsupported, #9985; CLI `--metadata none|all` for `convert`, `run --out` and `batch`: `none` leaves the document XMP and free-form text out of flat exports, as Export As's Metadata: None, `all` the default, #9466; Hasselblad 3FR/FFF, the X2D II 100C, decodes; scripted PSD/PSB saves take `maximizeCompatibility`, and the CLI `--no-maximize-compatibility`, #8064; TIFF Options on Save As and Save a Copy: Image Compression None/LZW/ZIP, Save Image Pyramid, Save Transparency and Discard Layers, also `tiffTransparency`/`tiffPyramid` for scripts, the CLI and MCP, #8827; `.atn`: 145 supported events including Shear after Actions batch 12 (Groups A–I, command options, version 12 files, reference forms); the supported table and measured reference are in [atn.md](atn.md); 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; Line truncated
**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.
@@ -72,7 +72,7 @@ supports many more, including CR3).
| Sony ARW/SR2/SRF (incl. compressed) | yes | |
| Panasonic RW2, Pentax PEF | yes | |
| Olympus ORF | uncompressed only | |
| Fujifilm RAF (Bayer and X-Trans) | uncompressed only; no Fuji colour calibration | |
| Fujifilm RAF (Bayer and X-Trans) | uncompressed, lossless and lossy compressed; no Fuji colour calibration | `crates/raw/tests/it/raf.rs` |
| Hasselblad 3FR/FFF | uncompressed only; compressed (lossless-JPEG) files: embedded preview only | X2D II 100C uses a measured Bayer phase; other bodies assume it with a warning (`crates/raw/src/hasselblad.rs`) |
| Others (CRW, IIQ, X3F, SRW, MRW, ERF…) | embedded preview only | |
| .xmp sidecars, highlight modes, raw preferences | partial (sidecars and preferences; no highlight modes) | 2026-10-28: a sidecar's crop (`crs:HasCrop` with `CropTop` / `CropLeft` / `CropBottom` / `CropRight`, fractions of the developed frame as stored, mapped through its Orientation) crops the opened raw: Open re-develops and crops before the Camera Raw step, so the post-crop vignette centres on the crop; the dialog previews the whole frame. A straightened crop (`CropAngle` not 0) isn't applied and is named, as are unreadable or empty ones; `HasCrop` missing or `False` means no crop; `RawFileName` is bookkeeping (#9985; `crates/io/src/camera_raw_xmp.rs` tests, `crates/io/tests/it/raw.rs` `camera_raw_tuning_crops_in_the_stored_orientation`, `camera_raw_ui/tests.rs` `a_sidecar_crop_crops_the_opened_raw`). 2026-10-13: sidecars fill the open-time dialog and Open writes them back; Camera Raw Defaults wired. FILE-215-11 |
@@ -152,6 +152,7 @@ The original reference entry retains its source date and historical totals; curr
| Date | Change | Summary |
|---|---|---|
| 2026-11-07 | minor | Fujifilm compressed RAF (lossless and lossy, X-Trans and Bayer) decodes instead of falling back to the embedded JPEG preview (#7377) |
| 2026-11-07 | minor | Photoshop PDF read: pages import as raster artboards through the shared craft-pdf (Hayro) renderer, up to 16 pages, 8192 px per side and 64M px per page; editable text/vectors and PDFium are gone. Write is unchanged. |
| 2026-11-06 | minor | Scripted PSD/PSB saves take `maximizeCompatibility` (Save, Save As, Save a Copy, `app.save`, serve `doc.save`, MCP `doc_save`/`doc_export`, Batch, Image Processor, Layers to Files) over Preferences › File Handling for one save, and the CLI `--no-maximize-compatibility` (#8064). Layer channels and the merged image were already RLE (ZIP with prediction at 32-bit), as Photoshop writes them (#8645) |
| 2026-10-24 | minor | Hasselblad 3FR/FFF: the X2D II 100C's 16-bit uncompressed CFA decodes; compressed 3FR still opens as the embedded preview |
+1 -1
View File
@@ -1,6 +1,6 @@
# Where PhotoCraft falls short of Photoshop
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (G5: Warp mode edits Photoshop's Cylinder warp, #4112: the Warp menu lists it, five handles set its seven values as in the reference, and a smart object's stored cylinder opens in Warp mode as one; Average executes through shared filters on Smart Objects and imported PSD records; Average preserves per-pixel alpha and includes hidden RGB in its mean; RGB8 observations against Photoshop 26.6.1, see average-filter.md; G4: Both Axes on Scatter headers, #3825 partial; G4: combined Spacing checkbox and percentage header, #3825 partial; G14: Hasselblad 3FR/FFF, the X2D II 100C's uncompressed CFA, decodes; G4: Angle/Roundness fields left of the Brush Tip Shape widget, #3825 partial; G12: the eraser end of flipped pens selects the Eraser on Windows with Windows Ink on, #2285; no estimate changed; RGB and CMYK documents preview their spot inks as an overprint on the canvas and in the PSD thumbnail, FILE-215-17 narrows to PDF Separation, #10249; G2: compositor compilation moved off the first document refresh, with startup measurements pending, #9157; G11: Playback Options dialog with Step by Step and Pause For, #5212; G11: Stop step messages with Continue/Stop and nested resume, #8386; adjustment-layer Make keeps name, blend mode and clipping, #8384; tile-sparse Select and Mask outputs and preview coverage, #8822; classical Select › Sky can seed substantial high-confidence components beneath an obstructed top band; learned segmentation remains open; Art History Brush Opacity on the options bar, #10263; no estimate changed; G8: Type › OpenType › Stylistic Sets submenu, Set 1–20, #10285; G8: PhotoCraft-native PSDs keep stylistic sets and other Txt2-only OpenType flags through save/reopen, #10308; G10: TIFF Options dialog on Save As and Save a Copy, compression, Save Image Pyramid, Save Transparency, #8827; G11: Batch Esc Continue/Stop and read-only input previews; full live tabs remain open, #4579; G11: action recording folds Move-tool nudges, records relative keyboard document switches and scales point text placement; paragraph boxes remain open, #4514; G11: Batch measured output naming and UTF-16 log corrections; remaining progress and cancel prompt parity tracked, #4579; G11: Render ATN events mapped; Stylize/Pixelate ATN events mapped, current counts 128 supported/89 unmapped; G9: 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; selected colour sets edit drawing, tone and supported filters; geometry remains open; G11: `.atn` document offsets preserve opening order after tab reordering, #7103; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; Smooth and Feather command masks cropped to selection bounds plus blur reach; G1: Halftone Pattern Filter Gallery settings survive PSD/PSB saves (#8940); cropped selection morphology and command mask conversion performance evidence added; G6: Art History Brush landed as partial, TOOL-213-20, #1610, so no toolbar tool is missing; G4: five hard-round built-in presets use 5% spacing to remove stacked-tip outlines and reduce pressure-opacity bands, #8842; G9: Crop tool canvas menu, #6561; G2: Content-Aware Scale seam carving 1.65–3.6× faster with unchanged output, P28 row 3.3 → 1.6 s on a 4-core VM, #5236; G9: UI scale presets done, 105–120% steps, #3222; G14: lossy DNG, raw sidecars, Camera Raw Defaults live, Calibration and Auto, CR3 assessed as not clean-room feasible; G8: PSD type on a path opens on its path and saves back into the file's `Txt2`, #7962; G11: 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; adjustment clipping stays visible in compact Properties, #5231; G9: Color panel modes, ramp and copy commands, the same in every theme, UI-217-17, #6000; G17: Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; G10: `.asl` style libraries landed, #1051; G8: area type (TYPE-218-10); G1: smart objects keep Photoshop's Cylinder warp, #4112; G8: paragraph direction buttons in the Paragraph panel and options bar, TYPE-218-2 partial; G1: 32-bit Hue/Saturation and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; G11: compound layer properties export as separate Photoshop Set events, rename last, #8304; G11: Batch and droplets take the result at the action's Save step, so actions ending in Save As and Close work; G11: `.atn` steps that switch to another open document import, play and save back, #7103; G14: the Nikon D4 white level follows the sample depth, so 12-bit D4 NEFs no longer open 2.6 stops dark, #5978; G14: a Nikon NEF's Adobe RGB preview is read as Adobe RGB (maker-note ColorSpace 0x001E), #5987; Apply Layer Comp action mappLine truncated
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (G14: Fujifilm compressed RAF decodes, #7377; G5: Warp mode edits Photoshop's Cylinder warp, #4112: the Warp menu lists it, five handles set its seven values as in the reference, and a smart object's stored cylinder opens in Warp mode as one; Average executes through shared filters on Smart Objects and imported PSD records; Average preserves per-pixel alpha and includes hidden RGB in its mean; RGB8 observations against Photoshop 26.6.1, see average-filter.md; G4: Both Axes on Scatter headers, #3825 partial; G4: combined Spacing checkbox and percentage header, #3825 partial; G14: Hasselblad 3FR/FFF, the X2D II 100C's uncompressed CFA, decodes; G4: Angle/Roundness fields left of the Brush Tip Shape widget, #3825 partial; G12: the eraser end of flipped pens selects the Eraser on Windows with Windows Ink on, #2285; no estimate changed; RGB and CMYK documents preview their spot inks as an overprint on the canvas and in the PSD thumbnail, FILE-215-17 narrows to PDF Separation, #10249; G2: compositor compilation moved off the first document refresh, with startup measurements pending, #9157; G11: Playback Options dialog with Step by Step and Pause For, #5212; G11: Stop step messages with Continue/Stop and nested resume, #8386; adjustment-layer Make keeps name, blend mode and clipping, #8384; tile-sparse Select and Mask outputs and preview coverage, #8822; classical Select › Sky can seed substantial high-confidence components beneath an obstructed top band; learned segmentation remains open; Art History Brush Opacity on the options bar, #10263; no estimate changed; G8: Type › OpenType › Stylistic Sets submenu, Set 1–20, #10285; G8: PhotoCraft-native PSDs keep stylistic sets and other Txt2-only OpenType flags through save/reopen, #10308; G10: TIFF Options dialog on Save As and Save a Copy, compression, Save Image Pyramid, Save Transparency, #8827; G11: Batch Esc Continue/Stop and read-only input previews; full live tabs remain open, #4579; G11: action recording folds Move-tool nudges, records relative keyboard document switches and scales point text placement; paragraph boxes remain open, #4514; G11: Batch measured output naming and UTF-16 log corrections; remaining progress and cancel prompt parity tracked, #4579; G11: Render ATN events mapped; Stylize/Pixelate ATN events mapped, current counts 128 supported/89 unmapped; G9: 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; selected colour sets edit drawing, tone and supported filters; geometry remains open; G11: `.atn` document offsets preserve opening order after tab reordering, #7103; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; Smooth and Feather command masks cropped to selection bounds plus blur reach; G1: Halftone Pattern Filter Gallery settings survive PSD/PSB saves (#8940); cropped selection morphology and command mask conversion performance evidence added; G6: Art History Brush landed as partial, TOOL-213-20, #1610, so no toolbar tool is missing; G4: five hard-round built-in presets use 5% spacing to remove stacked-tip outlines and reduce pressure-opacity bands, #8842; G9: Crop tool canvas menu, #6561; G2: Content-Aware Scale seam carving 1.65–3.6× faster with unchanged output, P28 row 3.3 → 1.6 s on a 4-core VM, #5236; G9: UI scale presets done, 105–120% steps, #3222; G14: lossy DNG, raw sidecars, Camera Raw Defaults live, Calibration and Auto, CR3 assessed as not clean-room feasible; G8: PSD type on a path opens on its path and saves back into the file's `Txt2`, #7962; G11: 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; adjustment clipping stays visible in compact Properties, #5231; G9: Color panel modes, ramp and copy commands, the same in every theme, UI-217-17, #6000; G17: Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; G10: `.asl` style libraries landed, #1051; G8: area type (TYPE-218-10); G1: smart objects keep Photoshop's Cylinder warp, #4112; G8: paragraph direction buttons in the Paragraph panel and options bar, TYPE-218-2 partial; G1: 32-bit Hue/Saturation and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; G11: compound layer properties export as separate Photoshop Set events, rename last, #8304; G11: Batch and droplets take the result at the action's Save step, so actions ending in Save As and Close work; G11: `.atn` steps that switch to another open document import, play and save back, #7103; G14: the Nikon D4 white level follows the sample depth, so 12-bit D4 NEFs no longer open 2.6 stops dark, #5978; G14: a Nikon NEF's Adobe RGB preview is read as Adobe RGB (maker-note ColorSpaceLine truncated
Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact
on the way to beta. This is the work list agents pick from: take the highest-ranked entry that
+1 -1
View File
@@ -1,6 +1,6 @@
# PhotoCraft vs Adobe Photoshop: target-app parity
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-11-02 · **Change:** minor (Average executes through shared filters on Smart Objects and imported PSD records; Average preserves per-pixel alpha and includes hidden RGB in its mean; RGB8 observations against Photoshop 26.6.1, see average-filter.md; RGB and CMYK documents preview their spot inks as an overprint, FILE-215-17 narrows to PDF Separation, #10249; tile-sparse Select and Mask outputs and preview coverage, #8822; classical Select › Sky can seed substantial high-confidence components beneath an obstructed top band; learned segmentation remains open; Art History Brush Opacity on the options bar, #10263; no estimate changed; Automation Batch Esc Continue/Stop and read-only input previews now follow the measured cancellation flow; full live tabs remain partial, AUTO-219-5, #4579; Automation recording folds Move-tool nudge runs, records keyboard document switches by opening-order offset and scales point text placement; paragraph boxes remain open, #4514; Automation Batch output naming and UTF-16 logs now follow measured Photoshop behavior; progress and cancel prompt remain partial, AUTO-219-5, #4579; 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; selected colour sets edit drawing, tone and supported filters; geometry remains open; Smooth and Feather command masks cropped to selection bounds plus blur reach; Equalize with a selection asks Selected Area Only or Entire Image Based On Selected Area; cropped selection morphology and command mask conversion performance evidence added; the Camera Raw row names per-camera colour for non-DNG cameras as missing, not the Calibration panel, which landed 2026-10-13; Content-Aware Scale measurements added, #5236; Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; area type, TYPE-218-10 partial, Type scorecard 5 / 5 / 0; compound layer properties export as separate Photoshop Set events, #8304; JPEG Options dialog landed, FILE-215-19 partial; Print › Functions › Bleed through `file.print`; Content-Aware Move live preview for every selection size, TOOL-213-4 done, #6680; retouching evidence: Pattern Stamp and Background Eraser draw live, only the Mixer Brush is left of TOOL-213-7; fill and adjustment layers copy and paste whole, UI-217-23 partial, #2344; shape layers take Density and Feather, TOOL-214-12 done, #5846; on-canvas corner radius widgets, TOOL-214-12; `.atn` import maps 112 events; Free Transform H/V skew fields; stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment; stale missing-tool rows corrected: Color Replacement and Red Eye are live toolbar tools, Frame landed; percentages unchanged; PSD spot plates in Photoshop's polarity, #7415 split; TIFF spot plates, FILE-215-17's TIFF part, #7415) · **Target:** Adobe Photoshop 2026; Print: Japanese paper sizes — はがき, 往復はがき, JIS B5/B4, A6, L判, 2L判, KG判, 六切, 四切;
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-11-07 · **Change:** minor (Fujifilm compressed RAF decodes, #7377; Average executes through shared filters on Smart Objects and imported PSD records; Average preserves per-pixel alpha and includes hidden RGB in its mean; RGB8 observations against Photoshop 26.6.1, see average-filter.md; RGB and CMYK documents preview their spot inks as an overprint, FILE-215-17 narrows to PDF Separation, #10249; tile-sparse Select and Mask outputs and preview coverage, #8822; classical Select › Sky can seed substantial high-confidence components beneath an obstructed top band; learned segmentation remains open; Art History Brush Opacity on the options bar, #10263; no estimate changed; Automation Batch Esc Continue/Stop and read-only input previews now follow the measured cancellation flow; full live tabs remain partial, AUTO-219-5, #4579; Automation recording folds Move-tool nudge runs, records keyboard document switches by opening-order offset and scales point text placement; paragraph boxes remain open, #4514; Automation Batch output naming and UTF-16 logs now follow measured Photoshop behavior; progress and cancel prompt remain partial, AUTO-219-5, #4579; 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; selected colour sets edit drawing, tone and supported filters; geometry remains open; Smooth and Feather command masks cropped to selection bounds plus blur reach; Equalize with a selection asks Selected Area Only or Entire Image Based On Selected Area; cropped selection morphology and command mask conversion performance evidence added; the Camera Raw row names per-camera colour for non-DNG cameras as missing, not the Calibration panel, which landed 2026-10-13; Content-Aware Scale measurements added, #5236; Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; area type, TYPE-218-10 partial, Type scorecard 5 / 5 / 0; compound layer properties export as separate Photoshop Set events, #8304; JPEG Options dialog landed, FILE-215-19 partial; Print › Functions › Bleed through `file.print`; Content-Aware Move live preview for every selection size, TOOL-213-4 done, #6680; retouching evidence: Pattern Stamp and Background Eraser draw live, only the Mixer Brush is left of TOOL-213-7; fill and adjustment layers copy and paste whole, UI-217-23 partial, #2344; shape layers take Density and Feather, TOOL-214-12 done, #5846; on-canvas corner radius widgets, TOOL-214-12; `.atn` import maps 112 events; Free Transform H/V skew fields; stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment; stale missing-tool rows corrected: Color Replacement and Red Eye are live toolbar tools, Frame landed; percentages unchanged; PSD spot plates in Photoshop's polarity, #7415 split; TIFF spot plates, FILE-215-17's TIFF part, #7415) · **Target:** Adobe Photoshop 2026; Print: Japanese paper sizes — はがき, 往復はがき, JIS B5/B4, A6, L判, 2L判, KG判, 六切, 四切;
The authoritative answer to "how close is PhotoCraft to Photoshop, and how much work is left". The
one-page summary is [`ROADMAP.md`](../ROADMAP.md); the ranked work list is [`gaps.md`](gaps.md);