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) 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 /// 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. /// a preview fallback): what the open-time Camera Raw dialog looks for.
pub const DEVELOPED_NOTE: &str = "developed with default settings"; 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); let size = (p.width, p.height);
(jxl_preview_image(bytes, p)?, "JPEG XL", size) (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))), (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" }; let depth = if img.sample_type() == SampleType::U8 { "8-bit" } else { "16-bit" };
r.warnings.insert( let note = format!(
0, "{format}: {reason} is not supported yet; opened the camera's embedded {w}x{h} {kind} preview instead ({depth}, not the raw sensor data)"
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)), 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 /// A NEF-like file with Nikon's (undocumented) compression and a full-size
/// baseline JPEG preview in IFD0. /// baseline JPEG preview in IFD0.
fn nef_with_preview() -> Vec<u8> { 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 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 jpeg = photocraft_codecs::encode(&img, Format::Jpeg, &EncodeOptions::default()).unwrap();
let mut t = TiffBuilder::default(); let mut t = TiffBuilder::default();
@@ -199,16 +205,32 @@ fn nef_with_preview() -> Vec<u8> {
(33421, Val::Short(vec![2, 2])), (33421, Val::Short(vec![2, 2])),
(33422, Val::Byte(vec![0, 1, 1, 2])), (33422, Val::Byte(vec![0, 1, 1, 2])),
]); ]);
let ifd0 = t.ifd(vec![ let mut tags = vec![
(271, Val::Ascii("NIKON CORPORATION".into())), (271, Val::Ascii("NIKON CORPORATION".into())),
(330, Val::Ifds(vec![raw])), (330, Val::Ifds(vec![raw])),
(513, Val::Blobs(vec![preview])), (513, Val::Blobs(vec![preview])),
(514, Val::Long(vec![jpeg.len() as u32])), (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.chain = vec![ifd0];
t.build() 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] #[test]
fn unsupported_raw_falls_back_to_the_embedded_preview() { fn unsupported_raw_falls_back_to_the_embedded_preview() {
let r = import("DSC_0001.NEF", &nef_with_preview()).unwrap(); 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")); 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] #[test]
fn truncated_raws_do_not_panic() { fn truncated_raws_do_not_panic() {
let b = nef_with_preview(); 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 | | 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 | | 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 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 | | 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 | | 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 par;
mod preview; mod preview;
mod raf; mod raf;
mod rafc;
mod rw2; mod rw2;
mod saturation; mod saturation;
mod sensor; mod sensor;
+51 -21
View File
@@ -28,9 +28,10 @@
//! least significant bit first (X-Trans I, X100, XF1, X-A1…); 14-bit //! least significant bit first (X-Trans I, X100, XF1, X-A1…); 14-bit
//! samples packed into little-endian 32-bit words, most significant 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 //! 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 //! striped compression (it starts with `IS`; header byte 108 holds the
//! description and is reported as unsupported, as are the pre-2010 //! mode: 0 uncompressed, 2 lossless, 3 lossy), decoded by `rafc`. The
//! FinePix / SuperCCD files without the CFA TIFF. //! 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 //! * 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 //! reverse order: its last byte is the colour at data (0, 0), its first
//! the colour at (5, 5). //! the colour at (5, 5).
@@ -47,7 +48,7 @@ use crate::cr2::clip_level;
use crate::error::{RawError, Result}; use crate::error::{RawError, Result};
use crate::sensor::{BlackLevels, Cfa, Rect, Sensor}; use crate::sensor::{BlackLevels, Cfa, Rect, Sensor};
use crate::tiff::{Tiff, tag}; use crate::tiff::{Tiff, tag};
use crate::{Limits, RawFormat, par}; use crate::{Limits, RawFormat, par, rafc};
pub(crate) const MAGIC: &[u8] = b"FUJIFILMCCD-RAW"; pub(crate) const MAGIC: &[u8] = b"FUJIFILMCCD-RAW";
@@ -57,6 +58,8 @@ const FUJI_LAYOUT: u16 = 0x0130;
const XTRANS_LAYOUT: u16 = 0x0131; const XTRANS_LAYOUT: u16 = 0x0131;
const WB_GRGB_LEVELS: u16 = 0x2FF0; const WB_GRGB_LEVELS: u16 = 0x2FF0;
const RAW_EXPOSURE_BIAS: u16 = 0x9650; 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 FUJI_IFD: u16 = 0xF000;
const RAW_WIDTH: u16 = 0xF001; const RAW_WIDTH: u16 = 0xF001;
@@ -195,6 +198,31 @@ fn header_model(bytes: &[u8]) -> Option<String> {
(!s.is_empty()).then_some(s) (!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> { 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_at = be32(bytes, 100).ok_or_else(|| RawError::malformed("RAF header is truncated"))?;
let cfa_len = be32(bytes, 104).unwrap_or(0); 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 src = if count > 0 { src.get(..count).unwrap_or(src) } else { src };
let pixels = width * height; let pixels = width * height;
let packed = pixels.saturating_mul(bits as usize).div_ceil(8); 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 recs = records(bytes);
let cfa = layout_cfa(&recs).ok_or_else(|| RawError::unsupported("Fujifilm RAF without a recognised CFA layout"))?; 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 full = Rect::new(0, 0, width, height);
let mut warnings = Vec::new(); 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, Lsb12,
/// 14-bit, most significant bit first in little-endian 32-bit words. /// 14-bit, most significant bit first in little-endian 32-bit words.
Words14, Words14,
/// A short `IS…` strip standing in for Fujifilm's compressed data. /// Fujifilm's striped compression, lossless or lossy: a stream whose residuals are all
Compressed, /// 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). /// 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, pub orientation: u16,
/// `false` writes an early-FinePix-style file without the CFA TIFF. /// `false` writes an early-FinePix-style file without the CFA TIFF.
pub cfa_tiff: bool, 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 { impl RafSpec {
@@ -63,6 +66,7 @@ impl RafSpec {
exposure_bias: None, exposure_bias: None,
orientation: 1, orientation: 1,
cfa_tiff: true, cfa_tiff: true,
preview_jpeg: None,
} }
} }
@@ -102,10 +106,10 @@ impl RafSpec {
logical.resize(logical.len().next_multiple_of(4), 0); logical.resize(logical.len().next_multiple_of(4), 0);
logical.chunks(4).flat_map(|w| w.iter().rev().copied().collect::<Vec<u8>>()).collect() logical.chunks(4).flat_map(|w| w.iter().rev().copied().collect::<Vec<u8>>()).collect()
} }
RafPacking::Compressed => { RafPacking::Compressed { lossy } => {
let mut v = b"IS\x01\x00".to_vec(); assert!(self.width == 780 && self.height.is_multiple_of(6) && self.data.iter().all(|&v| v == 0), "see RafPacking::Compressed");
v.resize(self.width * self.height / 2, 0x5A); let quantizers: Vec<u8> = (0..self.height / 6).map(|g| [2, 0, 5][g % 3]).collect();
v 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 /// A tiny baseline-JPEG preview (APP1 EXIF with make, model and
/// orientation; SOF0 32×24; EOI). /// orientation; SOF0 32×24; EOI).
fn preview(&self) -> Vec<u8> { fn preview(&self) -> Vec<u8> {
if let Some(jpeg) = &self.preview_jpeg {
return jpeg.clone();
}
let mut t = TiffBuilder::default(); 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]))]); 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]; t.chain = vec![ifd0];
@@ -223,6 +230,10 @@ impl RafSpec {
b.resize(60, 0); b.resize(60, 0);
b.extend_from_slice(b"0100"); b.extend_from_slice(b"0100");
b.resize(160, 0); 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 preview = self.preview();
let meta = self.meta(); let meta = self.meta();
let region = if self.cfa_tiff { self.cfa_region() } else { self.strip() }; 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] #[test]
fn unsupported_variants_report_and_keep_the_preview() { 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(); let b = spec.build();
match decode(&b, &Limits::default()) { 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:?}"), other => panic!("expected unsupported, got {other:?}"),
} }
let p = embedded_preview(&b).unwrap(); let p = embedded_preview(&b).unwrap();
assert_eq!((p.width, p.height), (32, 24)); 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] #[test]
+3 -2
View File
@@ -1,6 +1,6 @@
# File-format parity # 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. **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 | | | Sony ARW/SR2/SRF (incl. compressed) | yes | |
| Panasonic RW2, Pentax PEF | yes | | | Panasonic RW2, Pentax PEF | yes | |
| Olympus ORF | uncompressed only | | | 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`) | | 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 | | | 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 | | .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 | | 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-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-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 | | 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 # 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 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 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 # 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 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); one-page summary is [`ROADMAP.md`](../ROADMAP.md); the ranked work list is [`gaps.md`](gaps.md);