From 49a755605db80ab419813a12c59e53af54014461 Mon Sep 17 00:00:00 2001 From: "Yorgos S." Date: Sun, 18 Oct 2026 14:57:06 +0200 Subject: [PATCH 1/6] Fujifilm compressed RAF: decode lossless and lossy files instead of the embedded preview Opening a RAF shot with lossless (or lossy) compression fell back to the camera's embedded JPEG ("Fujifilm compressed RAF is not decoded ... opened the camera's embedded 4416x2944 JPEG preview instead"). Port LightCraft's clean-room decoder for Fujifilm's striped predictive compression (storytold/lightcraft crates/raw/src/vendor/rafc.rs, MIT OR Apache-2.0, same organisation), verified there sample for sample against a reference decoder on 24 files from 13 bodies. New `rafc` module: - 16-byte `IS` header checked against the raw IFD; stripe size table; per-stripe quantizer tables for lossy files - 768-pixel stripes decoded in parallel (`par::map`: rayon natively, sequential on wasm); X-Trans and Bayer, 12/14/16-bit - every read bounds-checked: damaged streams return errors, never panic `raf.rs` sends short strips to it, after checking that header byte 108 (0 uncompressed, 2 lossless, 3 lossy) agrees with the stream and that the CFA layout is one the stream can encode (else unsupported, preview fallback as before). `RafPacking::Compressed { lossy }` in testgen now writes real compressed streams; tests cover lossless and lossy, X-Trans, RGGB and BGGR, 12/14/16 bits, mode mismatches, unknown layouts, a missing signature, truncated and random entropy. Verified on an X-T30 lossless file: decodes in 155 ms and develops to 6240x4160. --- crates/raw/README.md | 3 +- crates/raw/src/lib.rs | 1 + crates/raw/src/raf.rs | 71 ++++-- crates/raw/src/rafc.rs | 502 +++++++++++++++++++++++++++++++++++++ crates/raw/src/testgen.rs | 17 +- crates/raw/tests/raf.rs | 71 +++++- docs/file-format-parity.md | 5 +- docs/gaps.md | 5 +- docs/target-app-parity.md | 7 +- 9 files changed, 638 insertions(+), 44 deletions(-) create mode 100644 crates/raw/src/rafc.rs diff --git a/crates/raw/README.md b/crates/raw/README.md index bb4815a4fc..3ed83c36bf 100644 --- a/crates/raw/README.md +++ b/crates/raw/README.md @@ -79,7 +79,8 @@ camera colour tables were copied. | Olympus ORF, uncompressed 16-bit (E-1, E-400…) | Decoded, with ImageProcessing black / WB / ValidBits / crop | | Nikon "lossy after split" NEF, 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 | diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index 0d1a99e796..c75d0a12a9 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -60,6 +60,7 @@ mod orf; mod par; mod preview; mod raf; +mod rafc; mod rw2; mod saturation; mod sensor; diff --git a/crates/raw/src/raf.rs b/crates/raw/src/raf.rs index cb794889d4..89bf4ca68e 100644 --- a/crates/raw/src/raf.rs +++ b/crates/raw/src/raf.rs @@ -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,30 @@ fn header_model(bytes: &[u8]) -> Option { (!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> { + 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 { 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 +247,27 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result { 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(); diff --git a/crates/raw/src/rafc.rs b/crates/raw/src/rafc.rs new file mode 100644 index 0000000000..5cbc21a9e3 --- /dev/null +++ b/crates/raw/src/rafc.rs @@ -0,0 +1,502 @@ +//! 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 +//! () 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]]; + +fn corrupt(why: &str) -> RawError { + RawError::malformed(format!("Fujifilm compressed RAF: {why}")) +} + +fn be16(src: &[u8], at: usize) -> Result { + 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 { + 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> { + 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 { + 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 { + 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 } +} + +#[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 +} + +#[inline] +fn sample(rows: &[Vec], y: usize, x: usize) -> Result { + rows.get(y).and_then(|r| r.get(x)).copied().ok_or_else(|| corrupt("invalid predictor position")) +} + +fn decode_stripe(stripe: &Stripe<'_>, height: usize, bits: u32, xtrans: bool) -> Result> { + 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 +/// (`groups` = the quantizer count, or 3 when lossless). +#[cfg(any(test, feature = "testgen"))] +pub(crate) fn zero_stream(bits: u32, xtrans: bool, quantizers: Option<&[u8]>) -> Vec { + let groups = quantizers.map_or(3, <[u8]>::len); + 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 = + 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, 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, 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 = 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 = (0..len).map(|_| next() as u8).collect(); + let mut src = zero_stream(bits, xtrans, 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); + } + } +} diff --git a/crates/raw/src/testgen.rs b/crates/raw/src/testgen.rs index 9d8b8fb94c..5016224f67 100644 --- a/crates/raw/src/testgen.rs +++ b/crates/raw/src/testgen.rs @@ -1387,8 +1387,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). @@ -1478,10 +1479,10 @@ impl RafSpec { logical.resize(logical.len().next_multiple_of(4), 0); logical.chunks(4).flat_map(|w| w.iter().rev().copied().collect::>()).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 = (0..self.height / 6).map(|g| [2, 0, 5][g % 3]).collect(); + crate::rafc::zero_stream(self.bits, self.cfa.len() == 36, lossy.then_some(quantizers.as_slice())) } } } @@ -1599,6 +1600,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() }; diff --git a/crates/raw/tests/raf.rs b/crates/raw/tests/raf.rs index 027ae65bd7..15708cedd9 100644 --- a/crates/raw/tests/raf.rs +++ b/crates/raw/tests/raf.rs @@ -98,23 +98,76 @@ 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_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 is an error, never a panic. + let mut b = compressed(&XTRANS, 14, true).build(); + let len = b.len(); + b.truncate(len - 200); + let b104 = (b.len() - u32::from_be_bytes(b[100..104].try_into().unwrap()) as usize) as u32; + b[104..108].copy_from_slice(&b104.to_be_bytes()); + assert!(decode(&b, &Limits::default()).is_err()); +} + #[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] diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 0a80894db1..b4268222d3 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-15 · **Change:** minor (DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 41 filters (27 more: Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint 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: 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; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-18 · **Change:** minor (Fujifilm compressed RAF decodes; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 41 filters (27 more: Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint 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: 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; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 Every format Photoshop 2026 (27.11.0) reads or writes, with PhotoCraft's support, fidelity and tests. Photoshop's list comes from its `Info.plist` `CFBundleDocumentTypes`, the PiPL read/write @@ -70,7 +70,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/raf.rs` | | Others (CRW, 3FR, IIQ, X3F, SRW, MRW, ERF…) | embedded preview only | | | .xmp sidecars, highlight modes, raw preferences | missing | FILE-215-11 | | Camera Raw as a PSD smart filter | partial | 2026-10-07: WB, Light/Presence, curves, HSL, grading, detail, grain, vignette mapped (FILE-215-12) | @@ -142,6 +142,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | Date | Change | Summary | |---|---|---| +| 2026-10-18 | minor | Fujifilm compressed RAF (lossless and lossy, X-Trans and Bayer) decodes instead of falling back to the embedded JPEG preview | | 2026-10-15 | minor | DDS textures save (#1327): uncompressed or BC1/BC2/BC3 (DXT1/3/5) from a clean-room block encoder, optional box-filtered mip chain, legacy header; DDS Options in Save As and Save a Copy, `ddsCompression`/`ddsMipmaps` for scripts, the CLI and MCP | | 2026-10-16 | minor | PSD smart filters: Perspective Warp (`perspectiveWarpTransform`: `vertices`, `warpedVertices`, `quads` of vertex indices, document pixels) opens as `layer.smartObjects.perspectiveWarp`, one plane per quad, and re-renders warped; save writes the original item with only the geometry rewritten, or a new one in Photoshop's layout; inconsistent descriptors stay verbatim (FILE-215-2, #6268) | | 2026-10-16 | minor | PSD smart filters: Hue/Saturation (`HStr`: `Clrz`, `Adjs` = master `Hst2` plus one per edited range with `LclR` and its four range sliders, all longs, measured on a Photoshop-saved file) opens as `image.adjustments.hueSaturation`, re-renders with the smart object and saves in Photoshop's layout; layouts outside it stay verbatim (FILE-215-2, #6267) | diff --git a/docs/gaps.md b/docs/gaps.md index 3c56d6d35a..b9e2e86619 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (G9: Preferences › File Handling › Save in Background takes effect, #6207, 24 of 169 preferences do nothing; G7: Content-Aware Move previews every selection the command takes, coarse then exact, TOOL-213-4 done, #6680; G4: Pattern Stamp and Background Eraser draw live, TOOL-213-7; G9: Animated Zoom pref read: holding the Zoom tool zooms continuously, #6210, prefs that do nothing 25 of 166; G2: a cancelled 24 MP Gaussian Blur job stops in at most 44 ms, P25 under its 200 ms budget, #6205; G1: generated large-file fixtures, a 4.38 GB PSB opens on Windows through io, FILE-216-2 done, #5107; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (G14: Fujifilm compressed RAF decodes; G9: Preferences › File Handling › Save in Background takes effect, #6207, 24 of 169 preferences do nothing; G7: Content-Aware Move previews every selection the command takes, coarse then exact, TOOL-213-4 done, #6680; G4: Pattern Stamp and Background Eraser draw live, TOOL-213-7; G9: Animated Zoom pref read: holding the Zoom tool zooms continuously, #6210, prefs that do nothing 25 of 166; G2: a cancelled 24 MP Gaussian Blur job stops in at most 44 ms, P25 under its 200 ms budget, #6205; G1: generated large-file fixtures, a 4.38 GB PSB opens on Windows through io, FILE-216-2 done, #5107; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 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 @@ -129,7 +129,7 @@ what an agent can't finish alone. (AGENTS.md forbids JS in the app). ### G14. Camera Raw depth -- **Missing:** CR3 (detected, unsupported), compressed RAF, lossy DNG (FILE-215-11), .xmp +- **Missing:** CR3 (detected, unsupported), lossy DNG (FILE-215-11), .xmp sidecars, masking and calibration in the Camera Raw dialog, per-camera colour for non-DNG cameras, lens profiles. - **Evidence:** `crates/raw/src/lib.rs:228` (CR3), scorecard FILE-215-11/12, #50. @@ -287,6 +287,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-18 | minor | G14: Fujifilm compressed RAF (lossless and lossy, X-Trans and Bayer) decodes; ported from LightCraft's clean-room decoder | | 2026-10-15 | minor | G1 / #5972 A1: render only provably constant-RGB `GdFl` noise fills; variable noise synthesis remains open | | 2026-10-18 | minor | G9: with no selection, Copy takes fill and adjustment layers whole and Paste recreates them (#2344); UI-217-23 stays partial (a single pixel layer copies its pixels only); estimate unchanged | | 2026-10-15 | minor | G2: a cancelled 24 MP Gaussian Blur job stops within 44 ms max (P25, budget 200 ms; was about 400 ms), the uncancelled job about 40% faster; #6205 | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index a2581c4b46..17ecbdefc1 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # PhotoCraft vs Adobe Photoshop: target-app parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-18 · **Change:** minor (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) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-18 · **Change:** minor (Fujifilm compressed RAF decodes; 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) · **Target:** Adobe Photoshop 2026 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); @@ -176,7 +176,7 @@ parity for the area and are already contained in the dimension rows above. | Type | 40% | 120–200 | Engine works; manual/optical kerning, a live Warp Text preview and variable-font axis sliders (2026-10-11); type on a path only places glyphs rigidly (TYPE-218-1 partial); no RTL paragraphs, OpenType panel UI; Type tool freeze on macOS (#2518) | | Vector shapes and paths | 55% | 80–140 | Pen, Direct/Path Selection, shapes, shape strokes. Freeform and Curvature Pen (#1054). Add/Delete Anchor Point and Convert Point tools exist (`Tool` enum); on-canvas corner radius widgets on live rectangles (TOOL-214-12, 2026-10-13); shape Density and Feather, the path as the fill's soft vector mask, matching Photoshop's 10 px feather ramp within 1 level (#5846, 2026-10-15); missing path-point nudge; shape parity tracking issue #2496 | | Colour management and modes | 70% | 60–100 | Own ICC engine, soft proof, CMYK/Lab/Indexed/Duotone/Multichannel; no Adobe CMYK profiles, no spot plates (FILE-215-17), monitor profile only at launch | -| Camera Raw and computational photo (Photomerge, HDR, lens) | 45% | 120–200 | Raw for DNG/CR2/NEF/ARW/RW2/ORF/RAF(uncompressed)/PEF; no CR3; ACR dialog partial (no masking, calibration); generic lens profiles only | +| Camera Raw and computational photo (Photomerge, HDR, lens) | 45% | 120–200 | Raw for DNG/CR2/NEF/ARW/RW2/ORF/RAF/PEF; no CR3; ACR dialog partial (no masking, calibration); generic lens profiles only | | Automation, actions, batch, scripting | 45% | 120–200 | CLI/MCP/control channel ahead of Photoshop; Actions panel and recording not document-independent (AUTO-219-1/2); `.atn` load/save since 2026-10-12 (112 events mapped, the rest kept verbatim); no ExtendScript/UXP | | File I/O | 60% | 300–500 | See [file-format-parity.md](file-format-parity.md) | | Print, export, Save for Web | 50% | 60–100 | JPEG Options dialog missing (FILE-215-19), no Photoshop PDF, no separations, Save for Web settings not remembered | @@ -450,7 +450,7 @@ filled bar. This extends UI reachability and does not claim Photoshop-authored s | Colour management | Colour-managed canvas (document → monitor), embedded CMYK profiles, linear EXR/HDR, 16-bit float canvas | medium-high | Monitor profile follows only at launch. | | Performance | 14k+ px on the GPU at ~⅓ the memory; adjustment preview 285 ms → 4–9 ms; font-size edits 297 ms → 4.6 ms; 2026-10-07: 30 MP TIFF open (banded, parallel strip/tile decode) Deflate 345 → 32 ms, LZW 428 → 43 ms, BigTIFF and every IFD readable; 2026-10-09: 4.2 MP Indexed Color, 256 colours, full-resolution CPU preview p50 3018 → 1061 ms (Ryzen AI 7 350, three measured runs; GPU upload excluded); 2026-10-10: Liquify 3500×2200, 800 px brush, visible 1:1 crop 3.20 → 3.63 ms p50 (5 runs) while refreshing ~9× as many pixels | medium-high on rasters | Complex layout documents still laggy (#125/#128); >16384 px GPU tiling in progress (#49). Liquify now displays source-resolution detail above 100% zoom; its ≤1 ms update target is not met (3.73 ms p95 on a Windows run; GPU upload excluded). Native Indexed Color previews run on one background worker with stale-result rejection; large palettes can still take about a second to compute. Web previews remain synchronous. | | Stability | Never-crash lint series, crash guard, `panic_hunt` fuzzing in the gate | medium-high | No field crash data yet. | -| Camera RAW | DNG, CR2, Sony ARW (lossless + compressed), Nikon NEF (lossless + lossy compressed), RW2, uncompressed ORF; 2026-10-09: uncompressed Fujifilm RAF, Bayer and X-Trans (edge-directed X-Trans demosaic; 26 MP X-Trans develop 306 ms on 12 cores) | medium | CR3, compressed RAF and Nikon "lossy after split" still open (#50). Non-DNG cameras have no calibration: their colour (matrix + tone curve) is fitted per file to the camera's embedded JPEG, with the neutral sRGB-primaries fallback when the fit doesn't clearly help (2026-10-08: 8 D3200 NEFs, mean ΔE vs the camera JPEG 11.6–26.0 → 1.0–7.5); measured per-model calibration still open. | +| Camera RAW | DNG, CR2, Sony ARW (lossless + compressed), Nikon NEF (lossless + lossy compressed), RW2, uncompressed ORF; 2026-10-09: uncompressed Fujifilm RAF, Bayer and X-Trans (edge-directed X-Trans demosaic; 26 MP X-Trans develop 306 ms on 12 cores) | medium | 2026-10-18: compressed RAF (lossless and lossy) decodes. CR3 and Nikon "lossy after split" still open (#50). Non-DNG cameras have no calibration: their colour (matrix + tone curve) is fitted per file to the camera's embedded JPEG, with the neutral sRGB-primaries fallback when the fit doesn't clearly help (2026-10-08: 8 D3200 NEFs, mean ΔE vs the camera JPEG 11.6–26.0 → 1.0–7.5); measured per-model calibration still open. | | AI / generative | none | ~0% | Deferred by decision (#41). | | Ecosystem | Sandboxed WebAssembly plug-ins instead of .8BF; `.atn` actions load and save (2026-10-12); no ExtendScript/UXP; no Adobe Fonts/Libraries/cloud docs | low | By design for 8BF; scripting compatibility open. | | Platforms | macOS (notarized), Windows, Linux (AppImage/deb/rpm/Flatpak bundle), web | medium-high | Flathub later (#173); Windows signing material pending. | @@ -565,6 +565,7 @@ See [method, raw measurements and reproduction](five-algorithm-performance.md). | Date | Change | Summary | |---|---|---| +| 2026-10-18 | minor | Camera Raw: Fujifilm compressed RAF decodes; estimates unchanged | | 2026-10-18 | minor | Layers: with no selection, Copy takes fill and adjustment layers whole and Paste recreates them (#2344); UI-217-23 stays partial; percentages unchanged | | 2026-10-16 | minor | Content-Aware Move: live result preview for every selection the command takes (TOOL-213-4 done, #6680); percentages unchanged | | 2026-10-15 | minor | Vector shapes: shape layers take Density and Feather (Properties › Masks), PSD and .pcraft round trip; TOOL-214-12 done (#5846); estimates unchanged | From ade8d0e8990ade22189bd80510e7027491e8e77e Mon Sep 17 00:00:00 2001 From: "Yorgos S." Date: Sun, 18 Oct 2026 15:16:18 +0200 Subject: [PATCH 2/6] Address review: zero_stream group count, entropy test, stale G14 entry - rafc::zero_stream takes the six-row group count explicitly; a lossless stream always declared 18 rows, so lossless fixtures of any other height were rejected as inconsistent. New test decodes 6- and 24-row lossless and lossy files (fails with the old behaviour). - The damaged-entropy case keeps the file and the stripe sizes intact and zeroes bytes inside the first stripe, so it reaches the residual decoder and must be RawError::Malformed (truncating the file only hit the "stripe outside the file" check). - Doc comments on the remaining rafc helpers. - docs/gaps.md G14: lossy DNG decodes since 2026-10-13; no longer missing. --- crates/raw/src/rafc.rs | 20 +++++++++++++------- crates/raw/src/testgen.rs | 2 +- crates/raw/tests/raf.rs | 31 ++++++++++++++++++++++++------- docs/gaps.md | 2 +- 4 files changed, 39 insertions(+), 16 deletions(-) diff --git a/crates/raw/src/rafc.rs b/crates/raw/src/rafc.rs index 5cbc21a9e3..34c5266881 100644 --- a/crates/raw/src/rafc.rs +++ b/crates/raw/src/rafc.rs @@ -33,10 +33,12 @@ pub(crate) const XTRANS: [[usize; 6]; 6] = /// 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 { 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")) }; @@ -215,6 +217,8 @@ fn quant(v: i32, near: u32) -> i32 { 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()); @@ -228,11 +232,13 @@ fn even_prediction(nw: i32, n: i32, ne: i32, nn: i32) -> i32 { (pair + 2 * n) >> 2 } +/// Sample `x` of line `y` of a colour vector. #[inline] fn sample(rows: &[Vec], y: usize, x: usize) -> Result { 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> { let size = if xtrans { BLOCK * 2 / 3 } else { BLOCK / 2 }; let mut previous = vec![vec![vec![0; size + 2]; 2]; 3]; @@ -361,11 +367,11 @@ fn decode_stripe(stripe: &Stripe<'_>, height: usize, bits: u32, xtrans: bool) -> } /// 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 -/// (`groups` = the quantizer count, or 3 when lossless). +/// 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, quantizers: Option<&[u8]>) -> Vec { - let groups = quantizers.map_or(3, <[u8]>::len); +pub(crate) fn zero_stream(bits: u32, xtrans: bool, groups: usize, quantizers: Option<&[u8]>) -> Vec { + 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; @@ -433,7 +439,7 @@ mod tests { 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, q); + let src = zero_stream(bits, xtrans, 3, q); assert_eq!(decode(&src, 780, 18, bits, xtrans).unwrap(), vec![0; 780 * 18], "{bits} {xtrans} {q:?}"); } } @@ -442,7 +448,7 @@ mod tests { #[test] fn malformed_headers_and_truncated_entropy_are_errors() { - let valid = zero_stream(14, true, Some(&[1, 2, 0])); + 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}"); } @@ -489,7 +495,7 @@ mod tests { let quantizer = (next() & 0xFF) as u8; let len = (next() % 2048) as usize; let data: Vec = (0..len).map(|_| next() as u8).collect(); - let mut src = zero_stream(bits, xtrans, Some(&[quantizer])); + 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()); diff --git a/crates/raw/src/testgen.rs b/crates/raw/src/testgen.rs index 5016224f67..c5ef22bfa1 100644 --- a/crates/raw/src/testgen.rs +++ b/crates/raw/src/testgen.rs @@ -1482,7 +1482,7 @@ impl RafSpec { 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 = (0..self.height / 6).map(|g| [2, 0, 5][g % 3]).collect(); - crate::rafc::zero_stream(self.bits, self.cfa.len() == 36, lossy.then_some(quantizers.as_slice())) + crate::rafc::zero_stream(self.bits, self.cfa.len() == 36, self.height / 6, lossy.then_some(quantizers.as_slice())) } } } diff --git a/crates/raw/tests/raf.rs b/crates/raw/tests/raf.rs index 15708cedd9..91691be4ae 100644 --- a/crates/raw/tests/raf.rs +++ b/crates/raw/tests/raf.rs @@ -127,6 +127,19 @@ fn compressed_lossless_and_lossy_decode() { } } +#[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). @@ -148,13 +161,17 @@ fn compressed_variants_that_cannot_be_decoded_fail_cleanly() { 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 is an error, never a panic. - let mut b = compressed(&XTRANS, 14, true).build(); - let len = b.len(); - b.truncate(len - 200); - let b104 = (b.len() - u32::from_be_bytes(b[100..104].try_into().unwrap()) as usize) as u32; - b[104..108].copy_from_slice(&b104.to_be_bytes()); - assert!(decode(&b, &Limits::default()).is_err()); + // 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] diff --git a/docs/gaps.md b/docs/gaps.md index b9e2e86619..2ad75f830e 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -129,7 +129,7 @@ what an agent can't finish alone. (AGENTS.md forbids JS in the app). ### G14. Camera Raw depth -- **Missing:** CR3 (detected, unsupported), lossy DNG (FILE-215-11), .xmp +- **Missing:** CR3 (detected, unsupported), .xmp sidecars, masking and calibration in the Camera Raw dialog, per-camera colour for non-DNG cameras, lens profiles. - **Evidence:** `crates/raw/src/lib.rs:228` (CR3), scorecard FILE-215-11/12, #50. From d6024423dbc66cb9e591fbd15339ba4dd0a90b2a Mon Sep 17 00:00:00 2001 From: "Yorgos S." Date: Mon, 19 Oct 2026 22:26:41 +0200 Subject: [PATCH 3/6] docs: qualify the RAF entry in target-app parity (early FinePix/SuperCCD still open their preview) --- docs/target-app-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 17ecbdefc1..e57bdb6ff9 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -176,7 +176,7 @@ parity for the area and are already contained in the dimension rows above. | Type | 40% | 120–200 | Engine works; manual/optical kerning, a live Warp Text preview and variable-font axis sliders (2026-10-11); type on a path only places glyphs rigidly (TYPE-218-1 partial); no RTL paragraphs, OpenType panel UI; Type tool freeze on macOS (#2518) | | Vector shapes and paths | 55% | 80–140 | Pen, Direct/Path Selection, shapes, shape strokes. Freeform and Curvature Pen (#1054). Add/Delete Anchor Point and Convert Point tools exist (`Tool` enum); on-canvas corner radius widgets on live rectangles (TOOL-214-12, 2026-10-13); shape Density and Feather, the path as the fill's soft vector mask, matching Photoshop's 10 px feather ramp within 1 level (#5846, 2026-10-15); missing path-point nudge; shape parity tracking issue #2496 | | Colour management and modes | 70% | 60–100 | Own ICC engine, soft proof, CMYK/Lab/Indexed/Duotone/Multichannel; no Adobe CMYK profiles, no spot plates (FILE-215-17), monitor profile only at launch | -| Camera Raw and computational photo (Photomerge, HDR, lens) | 45% | 120–200 | Raw for DNG/CR2/NEF/ARW/RW2/ORF/RAF/PEF; no CR3; ACR dialog partial (no masking, calibration); generic lens profiles only | +| Camera Raw and computational photo (Photomerge, HDR, lens) | 45% | 120–200 | Raw for DNG/CR2/NEF/ARW/RW2/ORF/RAF/PEF (RAF: not the early FinePix/SuperCCD files without a CFA TIFF, which open their preview); no CR3; ACR dialog partial (no masking, calibration); generic lens profiles only | | Automation, actions, batch, scripting | 45% | 120–200 | CLI/MCP/control channel ahead of Photoshop; Actions panel and recording not document-independent (AUTO-219-1/2); `.atn` load/save since 2026-10-12 (112 events mapped, the rest kept verbatim); no ExtendScript/UXP | | File I/O | 60% | 300–500 | See [file-format-parity.md](file-format-parity.md) | | Print, export, Save for Web | 50% | 60–100 | JPEG Options dialog missing (FILE-215-19), no Photoshop PDF, no separations, Save for Web settings not remembered | From 7000e76156bbfad4da76346ba0e54e5ccc6869af Mon Sep 17 00:00:00 2001 From: "Yorgos S." Date: Mon, 19 Oct 2026 23:53:26 +0200 Subject: [PATCH 4/6] cargo fmt on raf.rs and rafc.rs --- crates/raw/src/raf.rs | 3 ++- crates/raw/src/rafc.rs | 3 +-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/raw/src/raf.rs b/crates/raw/src/raf.rs index 89bf4ca68e..452f51ad50 100644 --- a/crates/raw/src/raf.rs +++ b/crates/raw/src/raf.rs @@ -200,7 +200,8 @@ fn header_model(bytes: &[u8]) -> Option { /// 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> { - let flag = rafc::stream_flag(src).ok_or_else(|| RawError::unsupported("Fujifilm RAF with an unknown sample storage (short strip without the IS signature)"))?; + 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 => {} diff --git a/crates/raw/src/rafc.rs b/crates/raw/src/rafc.rs index 34c5266881..40efd8e13e 100644 --- a/crates/raw/src/rafc.rs +++ b/crates/raw/src/rafc.rs @@ -405,8 +405,7 @@ pub(crate) fn zero_stream(bits: u32, xtrans: bool, groups: usize, quantizers: Op } } } - let stream: Vec = - bitstream.chunks(8).map(|chunk| chunk.iter().enumerate().fold(0, |b, (i, bit)| b | (u8::from(*bit) << (7 - i)))).collect(); + let stream: Vec = 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]); From 0133e139bb6857af2d4a2749ffe0ba99a7697e8d Mon Sep 17 00:00:00 2001 From: "Yorgos S." Date: Mon, 19 Oct 2026 23:53:26 +0200 Subject: [PATCH 5/6] A raw with damaged sensor data opens its camera JPEG instead of failing On main every compressed RAF opened its embedded JPEG (as unsupported). With the decoder, a damaged compressed RAF returns RawError::Malformed, and photocraft-io fell back to the preview only for Unsupported, so the file no longer opened at all. import_raw_with now opens the embedded preview for Malformed too, with the warning ": the raw sensor data is damaged (); opened the camera's embedded WxH JPEG preview instead (8-bit, not the raw sensor data)". Without a usable preview it stays the same error. This applies to every raw format. Checked on three damaged copies of a real X-T30 lossless RAF (64 zeroed bytes in the first stripe, truncated to 60%, 1 MB of random bytes): each opens its 4416x2944 preview with the reason; none panic. testgen's RafSpec gains `preview_jpeg` so an io test can embed a real JPEG. --- crates/io/src/raw.rs | 12 ++++++++++++ crates/io/tests/raw.rs | 32 ++++++++++++++++++++++++++++++++ crates/raw/src/testgen.rs | 6 ++++++ 3 files changed, 50 insertions(+) diff --git a/crates/io/src/raw.rs b/crates/io/src/raw.rs index aba70c21e7..6c4ba62a8f 100644 --- a/crates/io/src/raw.rs +++ b/crates/io/src/raw.rs @@ -270,6 +270,18 @@ pub fn import_raw_with(name: &str, bytes: &[u8], opts: &DevelopOptions) -> Resul } None => Err(IoError::Raw(RawError::Unsupported(reason))), }, + // 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, upright_preview(bytes, p.jpeg))) { + Some((w, h, Ok(img))) => { + let mut r = image_to_document(name, &img)?; + r.warnings.insert( + 0, + 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)"), + ); + Ok(r) + } + _ => Err(IoError::Raw(RawError::Malformed(reason))), + }, Err(e) => Err(IoError::Raw(e)), } } diff --git a/crates/io/tests/raw.rs b/crates/io/tests/raw.rs index d9e31a87c1..7923e1ddae 100644 --- a/crates/io/tests/raw.rs +++ b/crates/io/tests/raw.rs @@ -106,6 +106,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(); diff --git a/crates/raw/src/testgen.rs b/crates/raw/src/testgen.rs index c5ef22bfa1..8b4a8237b3 100644 --- a/crates/raw/src/testgen.rs +++ b/crates/raw/src/testgen.rs @@ -1423,6 +1423,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>, } impl RafSpec { @@ -1440,6 +1442,7 @@ impl RafSpec { exposure_bias: None, orientation: 1, cfa_tiff: true, + preview_jpeg: None, } } @@ -1543,6 +1546,9 @@ impl RafSpec { /// A tiny baseline-JPEG preview (APP1 EXIF with make, model and /// orientation; SOF0 32×24; EOI). fn preview(&self) -> Vec { + 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]; From 17248a205dd71c6ae12931b53028e4ac0eb918df Mon Sep 17 00:00:00 2001 From: "Yorgos S." Date: Tue, 20 Oct 2026 00:10:39 +0200 Subject: [PATCH 6/6] A preview fallback keeps the colour space the raw's EXIF states When a raw opens its embedded JPEG instead of the sensor data (unsupported or damaged), a JPEG without an ICC profile of its own went straight into the document, which then had no profile. A Sony body set to Adobe RGB states that only in the raw's EXIF (ColorSpace Uncalibrated, InteropIndex "R03"), so its preview opened as sRGB, with wrong colours. Both fallback branches now go through preview_image, which tags an unprofiled preview with Adobe RGB when the raw's EXIF says so, the same reading the colour fit uses (preview_profile). Previews with their own profile, or with no stated colour space, are unchanged. New io test: an Adobe RGB fallback opens tagged with the Adobe RGB profile, an unstated one untagged (fails without the fix). --- crates/io/src/raw.rs | 43 +++++++++++++++++++++++++++--------------- crates/io/tests/raw.rs | 26 +++++++++++++++++++++++-- 2 files changed, 52 insertions(+), 17 deletions(-) diff --git a/crates/io/src/raw.rs b/crates/io/src/raw.rs index 6c4ba62a8f..52ac27fb73 100644 --- a/crates/io/src/raw.rs +++ b/crates/io/src/raw.rs @@ -242,6 +242,25 @@ fn develop_fitted(bytes: &[u8], opts: &DevelopOptions) -> Result Result { + let mut img = upright_preview(raw, jpeg)?; + 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 { + 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"; @@ -257,28 +276,22 @@ pub fn import_raw_with(name: &str, bytes: &[u8], opts: &DevelopOptions) -> Resul } Err(RawError::Unsupported(reason)) => match photocraft_raw::embedded_preview(bytes) { Some(p) => { - let img = upright_preview(bytes, p.jpeg)?; - let mut r = image_to_document(name, &img)?; - r.warnings.insert( - 0, - format!( - "{format}: {reason} is not supported yet; opened the camera's embedded {}x{} JPEG preview instead (8-bit, not the raw sensor data)", - p.width, p.height - ), + let img = preview_image(bytes, p.jpeg)?; + let note = format!( + "{format}: {reason} is not supported yet; opened the camera's embedded {}x{} JPEG preview instead (8-bit, not the raw sensor data)", + p.width, p.height ); - Ok(r) + preview_document(name, &img, note) } None => Err(IoError::Raw(RawError::Unsupported(reason))), }, // 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, upright_preview(bytes, p.jpeg))) { + Err(RawError::Malformed(reason)) => match photocraft_raw::embedded_preview(bytes).map(|p| (p.width, p.height, preview_image(bytes, p.jpeg))) { Some((w, h, Ok(img))) => { - let mut r = image_to_document(name, &img)?; - r.warnings.insert( - 0, - 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)"), + 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)" ); - Ok(r) + preview_document(name, &img, note) } _ => Err(IoError::Raw(RawError::Malformed(reason))), }, diff --git a/crates/io/tests/raw.rs b/crates/io/tests/raw.rs index 7923e1ddae..8f9b338c94 100644 --- a/crates/io/tests/raw.rs +++ b/crates/io/tests/raw.rs @@ -49,6 +49,12 @@ fn cr2_opens() { /// A NEF-like file with Nikon's (undocumented) compression and a full-size /// baseline JPEG preview in IFD0. fn nef_with_preview() -> Vec { + 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 { 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(); @@ -65,16 +71,32 @@ fn nef_with_preview() -> Vec { (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();