diff --git a/NOTICE b/NOTICE index d3adbafbb..6729833e3 100644 --- a/NOTICE +++ b/NOTICE @@ -92,8 +92,8 @@ Source provenance crates/raw (craft-raw) is moved from LightCraft, with behaviour ported from two other readers: - LightCraft, crates/raw (src/lib.rs, dng.rs, dngwrite.rs, tiffraw.rs, unpack.rs, ljpeg.rs, jxl.rs, opcodes.rs, profile.rs, gaintable.rs, semantic.rs, preview.rs, vendor/crw.rs, vendor/hasselblad.rs, - vendor.rs (shared level helpers), vendor/mrw.rs, vendor/erf.rs, vendor/mef.rs, vendor/kodak.rs and their - tests) and crates/meta/src/mrw.rs (the MRW block container), + vendor.rs (shared level helpers), vendor/mrw.rs, vendor/erf.rs, vendor/mef.rs, vendor/kodak.rs, vendor/pef.rs, + vendor/srw.rs, tests/it/vendor_robust.rs and their tests) and crates/meta/src/mrw.rs (the MRW block container), revision 5ff567a4a2e067bef1b6bce152c11e7f727ccbaf. https://github.com/storytold/lightcraft Copyright (c) 2026 ArtCraft Team and the LightCraft contributors. diff --git a/README.md b/README.md index 89f8f6cdb..ad38249e0 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,7 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = "` form on craft-xml elements, value codecs and the package writer; and InDesign Tagged Text, read and written. diff --git a/crates/raw/Cargo.toml b/crates/raw/Cargo.toml index a82189bac..2bae18366 100644 --- a/crates/raw/Cargo.toml +++ b/crates/raw/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "craft-raw" -description = "Camera raw files, the file-format layer: container parsing, decompression and the sensor data out as a neutral raw image with its levels, crop, orientation, colour tags and embedded previews. DNG (read and write), Hasselblad 3FR/FFF, MRW, ERF, MEF, Kodak DCS and Canon CRW so far." +description = "Camera raw files, the file-format layer: container parsing, decompression and the sensor data out as a neutral raw image with its levels, crop, orientation, colour tags and embedded previews. DNG (read and write), Hasselblad 3FR/FFF, MRW, ERF, MEF, Kodak DCS, Pentax PEF, Samsung SRW and Canon CRW so far." version.workspace = true edition.workspace = true license.workspace = true @@ -11,7 +11,7 @@ repository.workspace = true workspace = true [features] -default = ["dng", "jxl", "crw", "hasselblad", "mrw", "erf", "mef", "kodak"] +default = ["dng", "jxl", "crw", "hasselblad", "mrw", "erf", "mef", "kodak", "pef", "srw"] ## Adobe DNG, reading and writing: uncompressed, lossless JPEG, lossy JPEG, Deflate. dng = ["dep:miniz_oxide", "dep:zune-jpeg", "dep:zune-core"] ## JPEG XL compressed DNGs (DNG 1.7, compression 52546) via jxl-oxide (pure Rust, MIT OR Apache-2.0). @@ -26,6 +26,10 @@ erf = [] mef = [] ## Kodak DCS520C raw TIFF. kodak = [] +## Pentax PEF: uncompressed and Pentax's Huffman coding. +pef = [] +## Samsung SRW: the uncompressed variants. +srw = [] ## Canon CRW (CIFF) through craft-crw, the one CRW decoder. crw = ["dep:craft-crw", "dep:chrono"] diff --git a/crates/raw/README.md b/crates/raw/README.md index b51f62865..356dd6874 100644 --- a/crates/raw/README.md +++ b/crates/raw/README.md @@ -57,11 +57,13 @@ One module and one Cargo feature per maker or container family. All are on by de | `erf` | Epson ERF (R-D1, R-D1s, R-D1x) | 12-bit samples in 16-byte blocks, black levels from the maker note, the measured picture origin. No white balance (the file states none) | | `mef` | Mamiya MEF (ZD) | Uncompressed 12-bit packed; default crop from the RGB preview's measured window. No white balance (the file states none) | | `kodak` | Kodak DCS520C TIFF | Two-component lossless JPEG CFA with the `GrayResponseCurve`, white balance from the `KodakIFD` | +| `pef` | Pentax PEF | Uncompressed (packed 12-bit or 16-bit words, also under the PackBits tag some bodies write) and Pentax's Huffman coding (compression 65535, the table from the maker note); image area from the maker note's crop tags, else the dark borders trimmed from the samples; black from `BlackPoint` or the masked columns; as-shot white balance from the maker note; the layout from the Exif `CFAPattern` | +| `srw` | Samsung SRW | The uncompressed variants: 16-bit words (compression 32769) and 12 bits packed (32770 with exactly the packed size; the bit order is decided from the samples); levels and white balance from the keyed maker-note vectors; framing from `SensorAreas`. The compressed codings (32770 with fewer bytes, 32772, 32773) are reported as unsupported | | `hasselblad` | Hasselblad 3FR and FFF, and other CFA TIFFs in the DNG layout without a DNG version tag (`RawFormat::CfaTiff`) | Uncompressed and Hasselblad's lossless JPEG (predictor selection value 8); RGGB from the sensor origin when the file states no `CFAPattern`; an FFF's colour matrix from the 3FR header it embeds; the 8-bit RGB preview handed on as a baseline TIFF. Implies `dng` | Not decoded: GoPro VC-5 DNG (GPR), CFA with more than three colours, non-rectangular CFA layouts. `probe` names every container it can tell apart whatever the features (CR2, CR3, NEF, NRW, ARW, -RAF, ORF, RW2, PEF, SRW, MRW, X3F, other TIFF-based raws); +RAF, ORF, RW2, X3F, other TIFF-based raws); their decoders are still in the apps and move here one maker family at a time. `RawFormat::is_supported` says what this build decodes. @@ -116,6 +118,9 @@ Left as LightCraft has it, with the reason: estimated one from the embedded preview's neutral pixels; that is an estimate from picture content, so it stays with the developer and `wb_multipliers` is `None` here. The inputs it needs (samples, crop, CFA, levels, preview) are all in `RawImage` and `embedded_preview`. +- Pentax PEF and Samsung SRW are LightCraft's readers unchanged. PhotoCraft has no reader for either: it sends + them down its generic TIFF/EP path, which reads an uncompressed CFA IFD and refuses Pentax's Huffman coding + and Samsung's private compressions, so there was nothing of PhotoCraft's to fold in. - Lossy JPEG is accepted for CFA data (one grey component) as in PhotoCraft; FilmCraft refuses it because DNG 1.4 allows the coding for LinearRaw only. diff --git a/crates/raw/src/huffman.rs b/crates/raw/src/huffman.rs new file mode 100644 index 000000000..cce27237b --- /dev/null +++ b/crates/raw/src/huffman.rs @@ -0,0 +1,105 @@ +//! The table-driven Huffman decoder and MSB-first bit reader that the makers' difference codings share (Pentax PEF, +//! Nikon NEF, Panasonic RW2, Sony ARW6): a code names a difference category, the category's additional bits follow +//! as in ITU-T T.81 F.1.2.1. + +use crate::{RawError, Result}; + +/// A canonical-by-table Huffman decoder with a 12-bit lookup. +pub(crate) struct Huffman { + /// Indexed by the next 12 bits: (symbol, length); length 0 = invalid. + lut: Vec<(u8, u8)>, +} + +impl Huffman { + /// From `(left-aligned 12-bit code, length)` per symbol. + pub fn new(codes: &[(u16, u8)]) -> Result { + let mut lut = vec![(0u8, 0u8); 4096]; + for (sym, &(code, len)) in codes.iter().enumerate() { + if len == 0 { + continue; + } + if len > 12 || sym > 16 { + return Err(RawError::Unsupported(format!("PEF Huffman code of length {len}"))); + } + let first = (code >> (12 - len) << (12 - len)) as usize; + for e in &mut lut[first..first + (1 << (12 - len))] { + if e.1 == 0 { + *e = (sym as u8, len); + } + } + } + Ok(Huffman { lut }) + } +} + +/// MSB-first bit reader that yields zeros past the end. +pub(crate) struct Bits<'a> { + src: &'a [u8], + pos: usize, + acc: u64, + n: u32, +} + +impl<'a> Bits<'a> { + pub fn new(src: &'a [u8]) -> Self { + Bits { src, pos: 0, acc: 0, n: 0 } + } + #[inline] + fn fill(&mut self) { + while self.n <= 56 { + let b = self.src.get(self.pos).copied().unwrap_or(0); + self.pos += 1; + self.acc |= (b as u64) << (56 - self.n); + self.n += 8; + } + } + #[inline] + pub fn peek(&mut self, k: u32) -> u32 { + if self.n < k { + self.fill(); + } + (self.acc >> (64 - k)) as u32 + } + #[inline] + pub fn skip(&mut self, k: u32) { + self.acc <<= k; + self.n -= k; + } + #[inline] + pub fn get(&mut self, k: u32) -> u32 { + if k == 0 { + return 0; + } + let v = self.peek(k); + self.skip(k); + v + } + /// Bits handed out so far (may exceed the source length: zeros are read past its end). + pub fn consumed_bits(&self) -> usize { + self.pos * 8 - self.n as usize + } + /// Whether more bits were handed out than the source holds (the zeros read past its end are lookahead padding, + /// not data; using them would fabricate samples). + pub fn overrun(&self) -> bool { + self.consumed_bits() > self.src.len() * 8 + } +} + +/// Decode one difference: Huffman category then T.81 additional bits. +#[inline] +pub(crate) fn diff(bits: &mut Bits, h: &Huffman) -> Option { + let (sym, len) = h.lut[bits.peek(12) as usize]; + if len == 0 { + return None; + } + bits.skip(len as u32); + let s = sym as u32; + if s == 0 { + return Some(0); + } + if s > 16 { + return None; + } + let v = bits.get(s) as i32; + Some(if v < 1 << (s - 1) { v - (1 << s) + 1 } else { v }) +} diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index 176997840..a3ac3b594 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -29,6 +29,8 @@ //! | `erf` | Epson ERF (R-D1 family): 12-bit samples in 16-byte blocks | //! | `mef` | Mamiya MEF (ZD): uncompressed 12-bit packed | //! | `kodak` | Kodak DCS520C TIFF: two-component lossless JPEG with a response curve | +//! | `pef` | Pentax PEF: uncompressed (packed or 16-bit words) and Pentax's Huffman coding (compression 65535) | +//! | `srw` | Samsung SRW: the uncompressed variants (compression 32769 words, 32770 at 12 bits packed) | //! | `hasselblad` | Hasselblad 3FR and FFF (and other CFA TIFFs in the DNG layout without a DNG version): uncompressed and Hasselblad's lossless JPEG; implies `dng` | //! //! [`probe`] names every container it can tell apart whatever the features; [`RawFormat::is_supported`] says @@ -58,6 +60,9 @@ mod erf; pub mod gaintable; #[cfg(feature = "hasselblad")] mod hasselblad; +// (the Huffman difference decoder Pentax, Nikon, Panasonic and Sony codings share) +#[allow(dead_code)] +mod huffman; #[cfg(feature = "jxl")] mod jxl; #[cfg(feature = "kodak")] @@ -68,6 +73,8 @@ mod mef; #[cfg(feature = "mrw")] mod mrw; pub mod opcodes; +#[cfg(feature = "pef")] +mod pef; mod preview; pub mod profile; #[cfg(feature = "dng")] @@ -75,6 +82,8 @@ pub mod semantic; // (the TIFF pixel plumbing every TIFF-based maker shares; DNG is its only user so far) #[allow(dead_code)] // each maker uses some of these helpers; which are live depends on the features mod shared; +#[cfg(feature = "srw")] +mod srw; #[cfg_attr(not(feature = "dng"), allow(dead_code))] mod tiffraw; #[cfg_attr(not(feature = "dng"), allow(dead_code))] @@ -314,6 +323,8 @@ impl RawFormat { || (self == RawFormat::Mrw && cfg!(feature = "mrw")) || (self == RawFormat::Erf && cfg!(feature = "erf")) || (self == RawFormat::Mef && cfg!(feature = "mef")) + || (self == RawFormat::Pef && cfg!(feature = "pef")) + || (self == RawFormat::Srw && cfg!(feature = "srw")) } } @@ -594,6 +605,10 @@ fn decode_in(bytes: &[u8], mode: Mode, limits: &Limits) -> Result { RawFormat::Erf => erf::decode(bytes, mode, limits), #[cfg(feature = "mef")] RawFormat::Mef => mef::decode(bytes, mode, limits), + #[cfg(feature = "pef")] + RawFormat::Pef => pef::decode(bytes, mode, limits), + #[cfg(feature = "srw")] + RawFormat::Srw => srw::decode(bytes, mode, limits), RawFormat::OtherTiff => Err(RawError::Unsupported(other_tiff_reason(bytes))), other => Err(RawError::Unsupported(format!("{other:?} files are not decoded by this build"))), } diff --git a/crates/raw/src/pef.rs b/crates/raw/src/pef.rs new file mode 100644 index 000000000..5339b7945 --- /dev/null +++ b/crates/raw/src/pef.rs @@ -0,0 +1,619 @@ +//! Pentax PEF — uncompressed and Pentax Huffman-compressed (compression 65535). +//! +//! Sources: TIFF 6.0 (container), ITU-T T.81 (JPEG) Annex H / F.1.2.1 for the lossless "difference category + +//! additional bits" coding, the ExifTool Pentax tag-name documentation (maker-note `0x0200` BlackPoint, `0x0201` +//! WhitePoint i.e. WB levels, `0x0220` HuffmanTable) and black-box analysis of CC0 samples from +//! raw.pixls.us (K10D, K-5 II s, K-3): +//! +//! - The Huffman table is stored in the file (maker note `0x0220`, in the maker note's byte order): a `u16` `d`, +//! 12 further bytes, then `n = d + 12` `u16` codes left-aligned in 12 bits, then `n` code lengths (bytes). +//! Symbol `i` is the difference category `i` (T.81 Table H.2: the number of additional bits). +//! - The bit stream (MSB-first, no byte stuffing, continuous across rows) codes one difference per pixel; the +//! additional bits follow T.81 F.1.2.1 (a leading 0 means a negative value). +//! - Prediction: each pixel is predicted from the previous same-colour pixel in its row (two to the left); the +//! first two pixels of a row are predicted from the first two pixels of the previous row of the same parity +//! (starting at 0). Found by trying candidate predictors and checking the decoded rows are continuous. +//! - Maker note `0x0038`/`0x0039`: the image area's left/top and width/height (verified against the data). + +use crate::huffman::{Bits, Huffman, diff}; +use crate::shared::{black_from_columns, cfa_from_exif, white_from_data}; + +use crate::tiffraw::{Packing, read_image_in}; +use crate::{BlackLevel, Camera, Cfa, ColorData, Limits, Mode, OpcodeLists, Orientation, RawData, RawError, RawFormat, RawImage, Rect, Result}; +use craft_tiff::image::{ImageInfo, chunk_bytes}; +use craft_tiff::{ByteOrder, Tiff, makernote, tags as t}; + +const CROP_ORIGIN: u16 = 0x0038; +const CROP_SIZE: u16 = 0x0039; +const BLACK_POINT: u16 = 0x0200; +const WB_LEVELS: u16 = 0x0201; +const HUFFMAN: u16 = 0x0220; + +/// Parse the maker-note Huffman table. +pub(crate) fn table(b: &[u8], order: ByteOrder) -> Result { + let u16_at = |i: usize| b.get(i..i + 2).map(|s| order.u16([s[0], s[1]])); + let d = u16_at(0).ok_or_else(|| RawError::Corrupt("PEF Huffman table too short".into()))? as usize; + let n = d + 12; + if n > 17 || b.len() < 14 + 3 * n { + return Err(RawError::Corrupt(format!("PEF Huffman table: {n} symbols in {} bytes", b.len()))); + } + let codes: Vec<(u16, u8)> = (0..n).map(|i| (u16_at(14 + 2 * i).unwrap_or(0) & 0x0fff, b[14 + 2 * n + i])).collect(); + Huffman::new(&codes) +} + +/// Decode a `w × h` Huffman-coded image. +pub(crate) fn decode_huffman(src: &[u8], h: &Huffman, w: usize, hgt: usize, bits_per: u32) -> Result> { + // every sample takes at least one bit: a stream too short for the declared size is refused before its picture + // is allocated, so a few bytes can't ask for gigabytes (storytold/lightcraft#1512) + let samples = w.checked_mul(hgt).ok_or(RawError::Limit("image too large"))?; + if (src.len() as u64).saturating_mul(8) < samples as u64 { + return Err(RawError::Corrupt(format!("PEF: {w} x {hgt} samples need more than the {} bytes of data there", src.len()))); + } + let mut out = vec![0u16; samples]; + let mut bits = Bits::new(src); + let mut vpred = [[0i32; 2]; 2]; + let max = (1i32 << bits_per.min(16)) - 1; + for y in 0..hgt { + let mut hpred = [0i32; 2]; + let row = &mut out[y * w..(y + 1) * w]; + for (x, o) in row.iter_mut().enumerate() { + let d = diff(&mut bits, h).ok_or_else(|| RawError::Corrupt(format!("PEF: invalid Huffman code at row {y}")))?; + // a run of large differences in a corrupt stream can drive a predictor past i32 (storytold/lightcraft#1515) + let overflow = || RawError::Corrupt(format!("PEF: predictor out of range at row {y}")); + let p = if x < 2 { + let v = vpred[y & 1][x].checked_add(d).ok_or_else(overflow)?; + vpred[y & 1][x] = v; + hpred[x] = v; + v + } else { + let v = hpred[x & 1].checked_add(d).ok_or_else(overflow)?; + hpred[x & 1] = v; + v + }; + *o = p.clamp(0, max) as u16; + } + if bits.overrun() { + return Err(RawError::Corrupt(format!("PEF: data ends at row {y} of {hgt}"))); + } + } + Ok(out) +} + +/// The image area without leading/trailing lines that hold no picture (masked or empty), kept at even offsets. With +/// the maker-note `black` level, a line is dark when its mean is at most 1/256 of the remaining range (at least 16) +/// above it, the tolerance `cr2::masked_columns` uses: masked lines sit at the black level plus noise, while dark +/// real content (a night sky) is well above. Without it, below 5% of the interior's mean. +fn dark_trimmed(d: &[u16], w: usize, h: usize, black: Option, white: f32) -> Rect { + if w < 256 || h < 256 { + return Rect::new(0, 0, w, h); + } + let (xstep, ystep) = (w / 256, h / 256); + let at = |x: usize, y: usize| d.get(y * w + x).map_or(0.0, |&v| f64::from(v)); + let col_mean = |x: usize| (0..h).step_by(ystep).map(|y| at(x, y)).sum::() / h.div_ceil(ystep) as f64; + let row_mean = |y: usize| (0..w).step_by(xstep).map(|x| at(x, y)).sum::() / w.div_ceil(xstep) as f64; + let interior = (w / 4..w * 3 / 4).step_by(w / 64).map(col_mean).sum::() / 32.0; + let (base, tol) = match black { + Some(b) => (b, ((f64::from(white) - b) / 256.0).max(16.0)), + None => (0.0, interior * 0.05), + }; + // a frame no brighter than masked lines (a lens-cap shot) has nothing to tell them from picture + if interior - base <= tol { + return Rect::new(0, 0, w, h); + } + let range = f64::from(white) - base; + // lines from one edge that hold no picture: within `tol` of the outermost line's level when that sits a little + // above black (K-70, K-1: 65 over BlackPoint), and saturated lines when dark ones follow (645Z) + let run = |mean: &dyn Fn(usize) -> f64| { + let saturated = (0..64).take_while(|&k| mean(k) - base >= range * 0.98).count(); + let outer = mean(saturated) - base; + let level = if outer > 0.0 && outer < range * 0.01 { outer } else { 0.0 }; + match (saturated..64).take_while(|&k| mean(k) - base < level + tol).count() { + 0 => 0, + dark => saturated + dark, + } + }; + let edges = |n: usize, mean: &dyn Fn(usize) -> f64| { + let lo = run(mean).next_multiple_of(2); + let hi = n - run(&|k| mean(n - 1 - k)); + (lo, (hi - lo) & !1) + }; + let (left, width) = edges(w, &col_mean); + let (top, height) = edges(h, &row_mean); + Rect::new(left, top, width, height) +} + +/// Whether every strip or tile of a one-sample-per-pixel image holds exactly the bytes that its rows need when +/// the samples are packed MSB-first at the declared bit depth, rows starting on byte boundaries (TIFF 6.0 +/// section 7). A real PackBits stream of such an image has a different length (almost always shorter), so the +/// exact match is what tells plain samples under a compression tag that says otherwise. +fn is_exactly_packed(info: &ImageInfo, file_len: u64) -> bool { + let bits = u64::from(info.bits()); + if info.samples_per_pixel != 1 || info.planar != 1 || !(1..=16).contains(&bits) || info.byte_counts.len() != info.offsets.len() { + return false; + } + let chunks = info.chunks(file_len); + !chunks.is_empty() && chunks.iter().all(|c| c.len == (u64::from(c.width) * bits).div_ceil(8) * u64::from(c.height)) +} + +pub(crate) fn decode(bytes: &[u8], mode: Mode, limits: &Limits) -> Result { + let tiff = Tiff::parse(bytes)?; + let ifd0 = tiff.ifds.first().ok_or_else(|| RawError::Corrupt("TIFF without IFDs".into()))?; + let info = ifd0.image()?; + let (w, hgt) = (info.width as usize, info.height as usize); + let bits = info.bits() as u32; + if w == 0 || hgt == 0 || w.saturating_mul(hgt) > limits.samples() { + return Err(RawError::Limit("image too large")); + } + let make = ifd0.string(t::MAKE).unwrap_or_default(); + let mn = + tiff.exif().and_then(|e| e.get(t::MAKER_NOTE)).and_then(|e| makernote::parse_makernote(bytes, e.offset, e.count() as u64, tiff.order, &make)); + let pair = |tag: u16| mn.as_ref().and_then(|m| m.ifd.u64s(tag)).filter(|v| v.len() == 2).map(|v| (v[0] as usize, v[1] as usize)); + let tagged = match (pair(CROP_ORIGIN), pair(CROP_SIZE)) { + // checked: the tags are file data; `x + cw` on a hostile value would wrap past the bounds + (Some((x, y)), Some((cw, ch))) + if cw > 0 && ch > 0 && x.checked_add(cw).is_some_and(|end| end <= w) && y.checked_add(ch).is_some_and(|end| end <= hgt) => + { + Some(Rect::new(x, y, cw, ch)) + } + _ => None, + }; + // without crop tags the image area is found from the samples (dark borders) + let read = if tagged.is_some() { mode } else { Mode::Full }; + let data = match info.compression { + 65535 => { + let mn = mn.as_ref().ok_or_else(|| RawError::Corrupt("compressed PEF without maker note".into()))?; + let tb = mn.ifd.bytes(HUFFMAN).ok_or_else(|| RawError::Unsupported("compressed PEF without a Huffman table".into()))?; + let huff = table(tb, mn.order)?; + let chunks = info.chunks(bytes.len() as u64); + let first = chunks.first().ok_or_else(|| RawError::Corrupt("PEF without image data".into()))?; + let mut c = *first; + c.len = chunks.iter().fold(0u64, |a, c| a.saturating_add(c.len)).max(first.len); + let src = chunk_bytes(bytes, &c) + .or_else(|| bytes.get(first.offset as usize..)) + .ok_or_else(|| RawError::Corrupt("PEF data outside file".into()))?; + if read == Mode::Full { RawData::U16(decode_huffman(src, &huff, w, hgt, bits)?) } else { RawData::U16(Vec::new()) } + } + 1 => { + let strip = info.chunks(bytes.len() as u64).iter().fold(0u64, |a, c| a.saturating_add(c.len)); + let packing = if strip >= (w * hgt * 2) as u64 { Packing::Word16 } else { Packing::Msb }; + read_image_in(read, bytes, &info, tiff.order, packing, limits)? + } + // Some bodies tag plain packed samples as PackBits (32773). Only a strip that holds exactly the packed + // size of its rows is read, as the uncompressed MSB-first samples it is; any other size stays unsupported. + 32773 if is_exactly_packed(&info, bytes.len() as u64) => { + let packed = ImageInfo { compression: 1, ..info.clone() }; + read_image_in(read, bytes, &packed, tiff.order, Packing::Msb, limits)? + } + c => return Err(RawError::Unsupported(format!("PEF compression {c}"))), + }; + let RawData::U16(ref samples) = data else { return Err(RawError::Unsupported("float PEF".into())) }; + + let white = white_from_data(samples, bits); + let black_point = mn.as_ref().and_then(|m| m.ifd.f64s(BLACK_POINT)).filter(|v| v.len() == 4); + let active = match tagged { + Some(r) => r, + None => dark_trimmed(samples, w, hgt, black_point.as_deref().map(|v| v.iter().sum::() / 4.0), white), + }; + let cfa = match (ifd0.u64s(t::CFA_REPEAT_PATTERN_DIM).as_deref(), ifd0.bytes(t::CFA_PATTERN_EP)) { + (Some([2, 2]), Some(p)) if p.len() == 4 && p.iter().all(|&c| c <= 2) => Cfa { width: 2, height: 2, pattern: p.to_vec() }, + // Every body states its layout in the Exif `CFAPattern`, read from the first sample of the stored array, not + // of the image area: the *ist D (origin 19, 11) and the 645D (80, 59) only fit unshifted. The 12-bit 10 MP + // bodies and everything from the K-3 on say RGGB; the K-7 / K-5 / 645D generation says BGGR. Files without the + // tag keep BGGR. + _ => cfa_from_exif(&tiff).unwrap_or_else(|| Cfa::bayer_static("BGGR")), + }; + let black = match black_point.as_deref() { + Some(v @ [_, _, _, _]) => { + // RGGB-ordered levels → per CFA position at the active area origin + let a = cfa.shifted(active.x, active.y); + let values = a.pattern.iter().map(|&c| [v[0], (v[1] + v[2]) / 2.0, v[3]][c as usize] as f32).collect(); + BlackLevel { repeat_rows: 2, repeat_cols: 2, values, ..Default::default() } + } + _ if active.x >= 4 => black_from_columns(samples, w, 0..active.x.saturating_sub(2), 0..hgt, active), + _ => BlackLevel::uniform(0.0), + }; + let wb = mn.as_ref().and_then(|m| m.ifd.f64s(WB_LEVELS)).filter(|v| v.len() == 4 && v.iter().all(|x| *x > 0.0)).map(|v| { + let g = (v[1] + v[2]) / 2.0; + [(v[0] / g) as f32, 1.0, (v[3] / g) as f32] + }); + let img = RawImage { + format: RawFormat::Pef, + width: w, + height: hgt, + cpp: 1, + data, + cfa: Some(cfa), + bits, + black, + white: vec![white], + active_area: active, + crop: Rect::new(0, 0, active.width, active.height), + orientation: Orientation::from_exif(ifd0.u16(t::ORIENTATION).unwrap_or(1)), + color: ColorData::default(), + wb_multipliers: wb, + linearized: false, + pixel_aspect: 1.0, + opcodes: OpcodeLists::default(), + camera: tiff.ifds.first().map(Camera::from_ifd0).unwrap_or_default(), + cinema: Default::default(), + }; + img.validate_for(mode)?; + Ok(img) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Our own prefix code for categories 0..=14: lengths 2,2,3,3,4,5,..., codes assigned canonically. + fn test_table() -> (Vec, Vec<(u16, u8)>) { + let lens: [u8; 15] = [3, 2, 2, 3, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 12]; + let mut order: Vec = (0..15).collect(); + order.sort_by_key(|&i| (lens[i], i)); + let mut codes = vec![(0u16, 0u8); 15]; + let (mut code, mut prev) = (0u32, lens[order[0]]); + for (k, &i) in order.iter().enumerate() { + if k > 0 { + code = (code + 1) << (lens[i] - prev); + } + prev = lens[i]; + codes[i] = ((code << (12 - lens[i])) as u16, lens[i]); + } + let mut b = vec![0u8, 3]; + b.extend_from_slice(&[0; 12]); + for c in &codes { + b.extend_from_slice(&c.0.to_be_bytes()); + } + b.extend(codes.iter().map(|c| c.1)); + (b, codes) + } + + /// A compressed PEF declaring far more samples than its data could hold (one bit each at least) is refused + /// before its picture is allocated (storytold/lightcraft#1512). + #[test] + fn a_stream_too_short_for_its_size_is_refused_before_allocating() { + let (tb, _) = test_table(); + let h = table(&tb, ByteOrder::Big).unwrap(); + let t = std::time::Instant::now(); + let r = decode_huffman(&[0x55; 4096], &h, 30_000, 30_000, 12); + assert!(matches!(r, Err(RawError::Corrupt(ref m)) if m.contains("need more")), "{r:?}"); + assert!(t.elapsed() < std::time::Duration::from_secs(1)); + } + + fn encode(px: &[u16], w: usize, codes: &[(u16, u8)]) -> Vec { + let mut bits: Vec = Vec::new(); + let put = |v: u32, n: u8, bits: &mut Vec| (0..n).rev().for_each(|i| bits.push(v >> i & 1 == 1)); + let mut vpred = [[0i32; 2]; 2]; + for (y, row) in px.chunks(w).enumerate() { + let mut hpred = [0i32; 2]; + for (x, &v) in row.iter().enumerate() { + let pred = if x < 2 { vpred[y & 1][x] } else { hpred[x & 1] }; + let d = v as i32 - pred; + if x < 2 { + vpred[y & 1][x] = v as i32; + } + hpred[x & 1] = v as i32; + let s = if d == 0 { 0 } else { 32 - d.unsigned_abs().leading_zeros() } as u8; + let (c, l) = codes[s as usize]; + put((c >> (12 - l)) as u32, l, &mut bits); + if s > 0 { + let extra = if d < 0 { (d - 1) as u32 & ((1 << s) - 1) } else { d as u32 }; + put(extra, s, &mut bits); + } + } + } + bits.chunks(8).map(|c| c.iter().enumerate().fold(0u8, |a, (i, &b)| a | (b as u8) << (7 - i))).collect() + } + + #[test] + fn huffman_round_trip() { + let (tb, codes) = test_table(); + let h = table(&tb, ByteOrder::Big).unwrap(); + let (w, hgt) = (37usize, 9usize); + let px: Vec = (0..w * hgt).map(|i| ((i * 7919) % 16384) as u16).collect(); + let src = encode(&px, w, &codes); + assert_eq!(decode_huffman(&src, &h, w, hgt, 14).unwrap(), px); + // truncated data is reported, not a panic + assert!(decode_huffman(&src[..src.len() / 3], &h, w, hgt, 14).is_err()); + assert!(table(&tb[..20], ByteOrder::Big).is_err()); + } + + /// Zero-padded lookahead past the end must not be decoded as samples: with no entropy at all, the zero bits + /// are a valid code (category 1) in this table, so a reader that only limits the refill position accepts them. + #[test] + fn symbols_decoded_from_padding_past_the_end_are_rejected() { + let (tb, codes) = test_table(); + let h = table(&tb, ByteOrder::Big).unwrap(); + assert!(decode_huffman(&[], &h, 2, 1, 14).is_err()); + // the same image with its real entropy still decodes, including the last byte's padding bits + let px = [5u16, 9]; + let src = encode(&px, 2, &codes); + assert_eq!(decode_huffman(&src, &h, 2, 1, 14).unwrap(), px); + // one byte short of the real entropy is rejected + assert!(decode_huffman(&src[..src.len() - 1], &h, 2, 1, 14).is_err()); + } + + /// A long run of the largest difference drove a predictor past i32: a panic in builds with overflow checks, + /// wrapped values otherwise (storytold/lightcraft#1515). Both the row and the column predictors now stop with an error. + #[test] + fn predictor_overflow_is_an_error_not_a_panic() { + let (tb, codes) = test_table(); + let h = table(&tb, ByteOrder::Big).unwrap(); + let (c, l) = codes[14]; + let d = (1u32 << 14) - 1; + let stream = |n: usize| { + let mut bits: Vec = Vec::with_capacity(n * (l as usize + 14)); + for _ in 0..n { + (0..l).rev().for_each(|i| bits.push((c >> (12 - l)) >> i & 1 == 1)); + (0..14).rev().for_each(|i| bits.push(d >> i & 1 == 1)); + } + bits.chunks(8).map(|c| c.iter().enumerate().fold(0u8, |a, (i, &b)| a | (b as u8) << (7 - i))).collect::>() + }; + // differences one predictor needs to pass i32::MAX + let steps = (i32::MAX as u32 / d) as usize + 2; + // along a row: every other sample adds to the same predictor + let w = 2 * steps + 2; + let err = decode_huffman(&stream(w), &h, w, 1, 14).unwrap_err(); + assert!(matches!(err, RawError::Corrupt(ref m) if m.contains("predictor")), "{err}"); + // down two columns: each row adds to the predictor of its parity + let rows = 2 * steps + 2; + let err = decode_huffman(&stream(2 * rows), &h, 2, rows, 14).unwrap_err(); + assert!(matches!(err, RawError::Corrupt(ref m) if m.contains("predictor")), "{err}"); + } + + /// A big-endian one-strip PEF-style TIFF of `w × h` 12-bit samples with the given compression tag and strip + /// byte count, around `payload`. + fn tiny_pef(w: u16, h: u16, compression: u16, strip_len: u32, payload: &[u8]) -> Vec { + let entries: [(u16, u16, u32); 9] = [ + (0x0100, 3, w as u32 * 0x1_0000), // ImageWidth + (0x0101, 3, h as u32 * 0x1_0000), // ImageLength + (0x0102, 3, 12 * 0x1_0000), // BitsPerSample + (0x0103, 3, compression as u32 * 0x1_0000), // Compression + (0x0106, 3, 32803 * 0x1_0000), // Photometric: CFA + (0x0111, 4, 8 + 2 + 9 * 12 + 4), // StripOffsets + (0x0115, 3, 0x1_0000), // SamplesPerPixel + (0x0116, 3, h as u32 * 0x1_0000), // RowsPerStrip + (0x0117, 4, strip_len), // StripByteCounts + ]; + let mut f = b"MM\0*\0\0\0\x08".to_vec(); + f.extend_from_slice(&9u16.to_be_bytes()); + for (tag, ty, val) in entries { + f.extend_from_slice(&tag.to_be_bytes()); + f.extend_from_slice(&ty.to_be_bytes()); + f.extend_from_slice(&1u32.to_be_bytes()); + f.extend_from_slice(&val.to_be_bytes()); + } + f.extend_from_slice(&0u32.to_be_bytes()); // no next IFD + f.extend_from_slice(payload); + f + } + + /// Eight 12-bit samples, two rows of four, packed MSB-first: 0x123 0x456 0x789 0xabc / 0xdef 0x012 0x345 0x678. + const PACKED: [u8; 12] = [0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0, 0x12, 0x34, 0x56, 0x78]; + + #[test] + fn packed_samples_under_the_packbits_tag_are_read_when_the_strip_has_exactly_the_packed_size() { + let file = tiny_pef(4, 2, 32773, 12, &PACKED); + let img = decode(&file, Mode::Full, &Limits::default()).unwrap(); + let RawData::U16(v) = &img.data else { panic!("integer samples") }; + assert_eq!(v, &[0x123, 0x456, 0x789, 0xabc, 0xdef, 0x012, 0x345, 0x678]); + assert_eq!((img.width, img.height, img.bits), (4, 2, 12)); + // the same bytes tagged as plain uncompressed give the same samples + let plain = decode(&tiny_pef(4, 2, 1, 12, &PACKED), Mode::Full, &Limits::default()).unwrap(); + assert_eq!(plain.data, img.data); + // header-only mode accepts it too + assert!(decode(&file, Mode::Header, &Limits::default()).is_ok()); + } + + #[test] + fn a_packbits_tagged_strip_of_any_other_size_stays_unsupported() { + for len in [11u32, 13, 6, 24] { + let mut payload = PACKED.to_vec(); + payload.resize(24, 0x55); + let file = tiny_pef(4, 2, 32773, len, &payload); + let err = decode(&file, Mode::Full, &Limits::default()).unwrap_err(); + assert!(matches!(&err, RawError::Unsupported(m) if m.contains("32773")), "strip of {len} bytes: {err:?}"); + } + } + + /// A 16 x 8 big-endian 12-bit PEF-style file with a 12 x 6 image area at the odd origin (3, 1), the Exif + /// `CFAPattern` `pattern` and an AOC maker note with the area tags (and, for 65535, a Huffman table). The samples + /// are plain 16-bit words (1), packed 12-bit (32773) or Huffman coded (65535). + fn pef_with_pattern(compression: u16, pattern: [u8; 4]) -> Vec { + use craft_tiff::{IfdBuilder, ImageData, TiffWriter, Value}; + let (w, h) = (16usize, 8usize); + let px: Vec = (0..w * h).map(|i| ((i * 37) % 900 + 100) as u16).collect(); + let (tb, codes) = test_table(); + let strip: Vec = match compression { + 1 => px.iter().flat_map(|v| v.to_be_bytes()).collect(), + 32773 => px.chunks(2).flat_map(|p| [(p[0] >> 4) as u8, ((p[0] & 15) << 4 | p[1] >> 8) as u8, p[1] as u8]).collect(), + _ => encode(&px, w, &codes), + }; + let build = |table_at: u32| { + let mut ifd = IfdBuilder::new(); + ifd.set(0x0100, Value::Long(vec![w as u32])); + ifd.set(0x0101, Value::Long(vec![h as u32])); + ifd.set(0x0102, Value::Short(vec![12])); + ifd.set(0x0103, Value::Short(vec![compression])); + ifd.set(0x0106, Value::Short(vec![32803])); + ifd.set(0x010f, Value::Ascii("PENTAX Corporation".into())); + ifd.set_image(ImageData::Strips { rows_per_strip: h as u32, strips: vec![strip.clone()] }); + // AOC + "MM", then the IFD (area origin, area size and, for Huffman data, the table), next-IFD 0, the table + let mut note = b"AOCMM".to_vec(); + let mut entries = vec![(0x0038u16, 3u16, 2u32, [0u8, 3, 0, 1]), (0x0039, 3, 2, [0, 12, 0, 6])]; + if compression == 65535 { + entries.push((0x0220, 7, tb.len() as u32, table_at.to_be_bytes())); + } + note.extend_from_slice(&(entries.len() as u16).to_be_bytes()); + for (tag, ty, cnt, val) in entries { + note.extend_from_slice(&tag.to_be_bytes()); + note.extend_from_slice(&ty.to_be_bytes()); + note.extend_from_slice(&cnt.to_be_bytes()); + note.extend_from_slice(&val); + } + note.extend_from_slice(&0u32.to_be_bytes()); + if compression == 65535 { + note.extend_from_slice(&tb); + } + let mut exif = IfdBuilder::new(); + exif.set(0xa302, Value::Undefined([&[0, 2, 0, 2][..], &pattern[..]].concat())); + exif.set(0x927c, Value::Undefined(note)); + ifd.set_child(0x8769, exif); + TiffWriter::new(ByteOrder::Big, false).write(&[ifd]).unwrap() + }; + let first = build(0); + if compression != 65535 { + return first; + } + let at = Tiff::parse(&first).unwrap().exif().unwrap().get(0x927c).unwrap().offset; + build((at + 6 + 2 + 3 * 12 + 4) as u32) + } + + #[test] + fn the_pattern_is_the_exif_cfapattern_from_the_array_origin_for_every_compression() { + for compression in [1u16, 32773, 65535] { + for (p, name) in [([0u8, 1, 1, 2], "RGGB"), ([2, 1, 1, 0], "BGGR"), ([1, 0, 2, 1], "GRBG"), ([1, 2, 0, 1], "GBRG")] { + let img = decode(&pef_with_pattern(compression, p), Mode::Full, &Limits::default()).unwrap(); + assert_eq!(img.active_area, Rect::new(3, 1, 12, 6), "compression {compression}"); + assert_eq!(img.cfa, Some(Cfa::bayer_static(name)), "compression {compression}, Exif {name}"); + } + } + // no tag: the assumed layout stays + let img = decode(&tiny_pef(4, 2, 1, 16, &[0; 16]), Mode::Full, &Limits::default()).unwrap(); + assert_eq!(img.cfa, Some(Cfa::bayer_static("BGGR"))); + } + + /// A `w x h` big-endian 14-bit PEF of plain 16-bit words without area tags, whose maker note has only a + /// BlackPoint of `black`, with `px(x, y)` as the samples. + fn pef_with_black_point(w: usize, h: usize, black: u16, px: impl Fn(usize, usize) -> u16) -> Vec { + use craft_tiff::{IfdBuilder, ImageData, TiffWriter, Value}; + let strip: Vec = (0..w * h).flat_map(|i| px(i % w, i / w).to_be_bytes()).collect(); + let build = |levels_at: u32| { + let mut ifd = IfdBuilder::new(); + ifd.set(0x0100, Value::Long(vec![w as u32])); + ifd.set(0x0101, Value::Long(vec![h as u32])); + ifd.set(0x0102, Value::Short(vec![14])); + ifd.set(0x0103, Value::Short(vec![1])); + ifd.set(0x0106, Value::Short(vec![32803])); + ifd.set(0x010f, Value::Ascii("PENTAX Corporation".into())); + ifd.set_image(ImageData::Strips { rows_per_strip: h as u32, strips: vec![strip.clone()] }); + // AOC\0 + "MM", one entry (BlackPoint, four shorts stored after the IFD), next-IFD 0, the four levels + let mut note = b"AOC\0MM\0\x01".to_vec(); + note.extend_from_slice(&[0x02, 0x00, 0, 3, 0, 0, 0, 4]); + note.extend_from_slice(&levels_at.to_be_bytes()); + note.extend_from_slice(&0u32.to_be_bytes()); + (0..4).for_each(|_| note.extend_from_slice(&black.to_be_bytes())); + let mut exif = IfdBuilder::new(); + exif.set(0x927c, Value::Undefined(note)); + ifd.set_child(0x8769, exif); + TiffWriter::new(ByteOrder::Big, false).write(&[ifd]).unwrap() + }; + let at = Tiff::parse(&build(0)).unwrap().exif().unwrap().get(0x927c).unwrap().offset; + build((at + 6 + 2 + 12 + 4) as u32) + } + + /// The K-3 Mark III / KP / KF layout (storytold/lightcraft#1271): masked columns and rows at the black level, an empty band + /// at zero, around an interior less than 20 times as bright as the black level. + #[test] + fn masked_rows_and_columns_at_the_black_level_are_trimmed_without_crop_tags() { + let (w, h) = (512, 384); + let file = pef_with_black_point(w, h, 256, |x, y| { + if x >= w - 8 { + 0 + } else if y >= h - 14 { + 15 + } else if x < 24 || x >= w - 16 || y < 28 { + 256 + } else { + 1256 + ((x * 7 + y * 13) % 64) as u16 + } + }); + let img = decode(&file, Mode::Full, &Limits::default()).unwrap(); + assert_eq!(img.black.values, vec![256.0; 4]); + assert_eq!(img.active_area, Rect::new(24, 28, w - 40, h - 42)); + } + + /// A night frame (bright centre, dark sky): 20 lines of real content 100 DN above the black level inside the + /// masked border stay in the picture, whatever the interior's brightness. + #[test] + fn dark_real_content_next_to_the_masked_border_is_kept() { + let (w, h) = (512, 384); + let file = pef_with_black_point(w, h, 256, |x, y| { + if x >= w - 8 { + 0 + } else if y >= h - 14 { + 15 + } else if x < 24 || x >= w - 16 || y < 28 { + 256 + ((x + y) % 3) as u16 + } else if x < 44 || x >= w - 36 || y < 48 || y >= h - 34 { + 356 + ((x * 7 + y * 13) % 16) as u16 + } else { + 3256 + ((x * 7 + y * 13) % 64) as u16 + } + }); + let img = decode(&file, Mode::Full, &Limits::default()).unwrap(); + assert_eq!(img.active_area, Rect::new(24, 28, w - 40, h - 42)); + } + + /// The K-70 (and K-1) layout: masked lines 65 DN over BlackPoint 64, just above the tolerance over black, and + /// empty rows at the bottom. + #[test] + fn masked_lines_a_little_above_the_black_level_are_trimmed() { + let (w, h) = (320, 288); + let file = pef_with_black_point(w, h, 64, |x, y| { + if y >= h - 12 { + 0 + } else if x < 56 || y < 28 { + 128 + ((x + y) % 3) as u16 + } else { + 2064 + ((x * 7 + y * 13) % 64) as u16 + } + }); + assert_eq!(decode(&file, Mode::Full, &Limits::default()).unwrap().active_area, Rect::new(56, 28, 264, 248)); + } + + /// The 645Z layout: 12 saturated columns outside 46 masked ones on the right; they hold no picture either. + #[test] + fn saturated_lines_outside_masked_ones_are_trimmed() { + let (w, h) = (400, 288); + let file = pef_with_black_point(w, h, 64, |x, y| { + if x >= w - 12 { + 16316 + } else if x >= w - 58 { + 64 + ((x + y) % 3) as u16 + } else { + 2064 + ((x * 7 + y * 13) % 64) as u16 + } + }); + assert_eq!(decode(&file, Mode::Full, &Limits::default()).unwrap().active_area, Rect::new(0, 0, w - 58, h)); + } + + /// A blown-out edge with the picture right behind it is picture: nothing is trimmed. + #[test] + fn a_saturated_edge_followed_by_picture_stays() { + let (w, h) = (400, 288); + let file = pef_with_black_point(w, h, 64, |x, y| if x >= w - 12 { 16316 } else { 2064 + ((x * 7 + y * 13) % 64) as u16 }); + assert_eq!(decode(&file, Mode::Full, &Limits::default()).unwrap().active_area, Rect::new(0, 0, w, h)); + } + + /// A frame at the black level everywhere (a lens-cap shot) has no picture to tell masked lines from: it keeps + /// its whole area. + #[test] + fn a_frame_at_the_black_level_keeps_its_whole_area() { + let (w, h) = (320, 288); + let file = pef_with_black_point(w, h, 64, |x, y| 64 + ((x * 7 + y * 13) % 5) as u16); + assert_eq!(decode(&file, Mode::Full, &Limits::default()).unwrap().active_area, Rect::new(0, 0, w, h)); + } + + /// Strip byte counts that add up past `u64::MAX` (LONG8 `[u64::MAX, 1]`) are an error or a picture, not an overflow, + /// for the Huffman-coded and the uncompressed samples. + #[test] + fn strip_byte_counts_past_u64_max_do_not_overflow() { + for compression in [65535u16, 1] { + let file = crate::shared::tests::huge_strip_counts(pef_with_pattern(compression, [0, 1, 1, 2])); + for mode in [Mode::Header, Mode::Full] { + let _ = decode(&file, mode, &Limits::default()); + } + } + } +} diff --git a/crates/raw/src/shared.rs b/crates/raw/src/shared.rs index cf7eed107..65cb306af 100644 --- a/crates/raw/src/shared.rs +++ b/crates/raw/src/shared.rs @@ -104,13 +104,13 @@ pub(crate) fn declare_strip_len(file: &mut [u8], len: u32, declared: u32) { } #[cfg(test)] -mod tests { +pub(crate) mod tests { use super::*; use craft_tiff::tags as t; /// `file`, whose image IFD holds one strip, as two strips at the same offset whose StripByteCounts are LONG8 (which /// classic TIFF readers accept) `[u64::MAX, 1]`: their sum is past `u64::MAX`. - pub(super) fn huge_strip_counts(mut file: Vec) -> Vec { + pub(crate) fn huge_strip_counts(mut file: Vec) -> Vec { let tiff = Tiff::parse(&file).unwrap(); let o = tiff.order; let ifd = tiff.all_ifds().into_iter().find(|i| i.contains(t::STRIP_OFFSETS)).unwrap(); diff --git a/crates/raw/src/srw.rs b/crates/raw/src/srw.rs new file mode 100644 index 000000000..690459832 --- /dev/null +++ b/crates/raw/src/srw.rs @@ -0,0 +1,599 @@ +//! Samsung SRW: the variants that store the sensor samples uncompressed. +//! +//! Sources: TIFF 6.0 (container: a big- or little-endian TIFF whose IFD0 points at sub-IFDs, one of them the raw +//! image), Exif 2.3 (`CFAPattern`, `PixelXDimension`, `PixelYDimension`; maker note `0x927c`), the ExifTool Samsung +//! tag-name documentation (`SensorAreas` `0xa010`, `EncryptionKey` `0xa020`, `WB_RGGBLevelsUncorrected` `0xa021`, +//! `WB_RGGBLevelsBlack` `0xa028`, `RawData` `0xa048`) and black-box analysis of CC0 samples from raw.pixls.us +//! (NX5, NX10, NX11, NX20, NX200, NX210, NX1000, NX1100, EX1, WB2000; every number below was measured on them): +//! +//! - The raw image is the IFD whose `Compression` is a private value (32769..=32773); its `CFAPattern` (`0x828e`) +//! names the 2x2 layout, anchored at the first sample of the stored array. Checked on the files above: the colours +//! of the decoded mosaic follow the camera's JPEG best with exactly that layout (`corpus_samsung_srw`). +//! - Compression 32769 (EX1, WB2000): one 16-bit little-endian word per sample (the TIFF is big-endian, the words are +//! not); the strip holds exactly `width * height * 2` bytes. +//! - Compression 32770 with exactly `width * height * 12 / 8` strip bytes (NX5, NX10, NX11, NX20, NX200, NX210, NX1000, +//! NX1100): 12-bit samples, two in three bytes, rows without padding. The NX5/NX10/NX11 files read them MSB-first, +//! the others LSB-first; the file doesn't say which, the image does: in the wrong order neighbouring same-colour +//! samples differ 5 to 11 times more (10 files, every one decided by that margin), see [`lsb_is_smoother`]. +//! - Every other shape of those and of the other private values (32770 with fewer bytes: NX30, NX300, NX2000, +//! EK-GN120; 32772: NX mini, NX3000, NX3300; 32773: NX1, NX500) is a compressed coding: reported as unsupported. +//! - The maker note is a plain IFD whose value offsets count from the start of the note, not of the TIFF. Several +//! vectors are stored offset by `EncryptionKey`, 11 integers in every file (element `i` of a vector uses key +//! `i mod 11`): `WB_RGGBLevelsUncorrected` and `WB_RGGBLevelsBlack` hold `value + key`, `RawData` holds +//! `value - key`, found by trying both signs and keeping the one that gives plausible numbers (green levels +//! 4096 in all but one file, saturation levels 4095 or 16383, black levels 0 to 3, the dark-column level). +//! - `SensorAreas`: the second rectangle (left, top, right, bottom) is the camera JPEG's framing (its size equals the +//! JPEG's in every file but WB2000's), used as the active area. Registered against the JPEG its centre agrees to +//! within 2 pixels (NX10 family; the NX20 family's JPEG is lens-corrected, edges differ). +//! - The Exif picture size is smaller than the framing in one direction only on the WB2000 (16:9 from a 4:3 sensor): +//! the JPEG is the centre of the framing (registration: top offset 366 = framing top 22 + (2736 - 2048) / 2). +//! - `RawData`: four black levels, then saturation levels (R, G1, G2, B twice). The EX1 saturates at 15881, not at its +//! stated 16383 (85786 samples sit on the plateau), so a plateau below the stated level wins. +//! - Black: `WB_RGGBLevelsBlack` when it states one (NX5, NX10, NX11: 2 to 3, the right-hand dark columns measure +//! 3.5 to 4.5); otherwise the dark rows or columns outside the framing (EX1: eight rows at 2.3); otherwise 0. +//! - `WB_RGGBLevelsUncorrected` divided by its mean green is the as-shot gain: the renders of the 12 files match the +//! camera JPEG at median ΔE76 2.2 to 7.6 (10.7 on the backlit WB2000 sample, 5.7 after registration). +use crate::shared::{black_from_columns, white_from_data}; +use crate::unpack::{read_u16s, unpack_lsb, unpack_msb}; +use crate::{BlackLevel, Camera, Cfa, ColorData, Limits, Mode, OpcodeLists, Orientation, RawData, RawError, RawFormat, RawImage, Rect, Result}; +use craft_tiff::image::chunk_bytes; +use craft_tiff::{ByteOrder, Ifd, Tiff, tags as t}; +use rayon::prelude::*; + +const SENSOR_AREAS: u16 = 0xa010; +const ENCRYPTION_KEY: u16 = 0xa020; +const WB_UNCORRECTED: u16 = 0xa021; +const WB_BLACK: u16 = 0xa028; +const RAW_DATA: u16 = 0xa048; + +/// The private `Compression` values of the raw image IFD. +const COMPRESSIONS: std::ops::RangeInclusive = 32769..=32773; + +/// The maker-note IFD (offsets relative to the note's own start). +fn maker_note(bytes: &[u8], tiff: &Tiff) -> Option { + let e = tiff.exif()?.get(t::MAKER_NOTE)?; + let opts = craft_tiff::ParseOptions { max_ifds: 4, max_depth: 1, follow_children: false, ..Default::default() }; + craft_tiff::parse_ifd_at(bytes, e.offset, tiff.order, e.offset, false, &opts).ok().map(|(i, _)| i) +} + +/// A key-offset vector: the entries of `tag` shifted by the maker note's key (`sign` is +1 or -1 times the key). +fn keyed(mn: &Ifd, tag: u16, sign: i64) -> Option> { + let key = mn.u64s(ENCRYPTION_KEY).filter(|k| k.len() == 11)?; + let vals = mn.f64s(tag)?; + // 32-bit entries: the stored value wraps modulo 2^32 (a negative number stored as a LONG) + // (a key value past i64::MAX must not overflow the multiply: only the low 32 bits are kept) + Some(vals.iter().enumerate().map(|(i, &v)| i64::from((v as i64).wrapping_add((key[i % 11] as i64).wrapping_mul(sign)) as i32)).collect()) +} + +/// The raw image's IFD: the one with a private compression value. +fn raw_ifd(tiff: &Tiff) -> Option<&Ifd> { + tiff.all_ifds().into_iter().find(|i| i.u16(t::COMPRESSION).is_some_and(|c| COMPRESSIONS.contains(&c)) && i.contains(t::IMAGE_WIDTH)) +} + +/// How the samples of an IFD are stored, when uncompressed. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Coding { + /// One little-endian 16-bit word per sample. + Words, + /// 12-bit samples, two in three bytes: read MSB-first (the NX5, NX10 and NX11 files) or LSB-first (the later + /// bodies' files); the file does not say which, the smoother image does ([`lsb_is_smoother`]). + Packed12, +} + +fn coding(compression: u16, w: usize, h: usize, strip: u64) -> Option { + let n = (w as u64).checked_mul(h as u64)?; + match compression { + 32769 if strip == n.checked_mul(2)? => Some(Coding::Words), + 32770 if w.is_multiple_of(2) && strip == n.checked_mul(12)? / 8 => Some(Coding::Packed12), + _ => None, + } +} + +pub(crate) fn decode(bytes: &[u8], mode: Mode, limits: &Limits) -> Result { + let tiff = Tiff::parse(bytes)?; + let ifd0 = tiff.ifds.first().ok_or_else(|| RawError::Corrupt("SRW without IFDs".into()))?; + let raw = raw_ifd(&tiff).ok_or_else(|| RawError::Unsupported("Samsung raw image not found".into()))?; + let info = raw.image()?; + let (w, h) = (info.width as usize, info.height as usize); + let n = w.checked_mul(h).filter(|n| *n > 0 && *n <= limits.samples()).ok_or(RawError::Limit("image too large"))?; + let chunks = info.chunks(bytes.len() as u64); + let strip = chunks.iter().fold(0u64, |a, c| a.saturating_add(c.len)); + let Some(coding) = coding(info.compression, w, h, strip).filter(|_| chunks.len() == 1) else { + return Err(RawError::Unsupported(format!( + "Samsung compressed raw (compression {}, {:.2} stored bits per sample)", + info.compression, + strip as f64 * 8.0 / n as f64 + ))); + }; + let data = if mode == Mode::Full { + let src = chunk_bytes(bytes, &chunks[0]).ok_or_else(|| RawError::Corrupt("SRW strip outside file".into()))?; + if src.len() as u64 != strip { + return Err(RawError::Corrupt("SRW strip shorter than its rows".into())); + } + let mut d = vec![0u16; n]; + match coding { + Coding::Words => read_u16s(src, ByteOrder::Little, &mut d), + Coding::Packed12 => { + let stride = w / 2 * 3; + let unpack: fn(&[u8], u32, &mut [u16]) = if lsb_is_smoother(src, w, h) { unpack_lsb } else { unpack_msb }; + d.par_chunks_mut(w).zip(src.par_chunks(stride)).for_each(|(row, s)| unpack(s, 12, row)); + } + } + RawData::U16(d) + } else { + RawData::U16(Vec::new()) + }; + + let mn = maker_note(bytes, &tiff); + // the camera JPEG's framing (left, top, right, bottom), when it lies inside the stored array + let active = mn + .as_ref() + .and_then(|m| m.u64s(SENSOR_AREAS)) + .filter(|v| v.len() == 8) + .map(|v| (v[4] as usize, v[5] as usize, v[6] as usize, v[7] as usize)) + .filter(|&(l, tp, r, b)| l < r && tp < b && r <= w && b <= h) + .map_or(Rect::new(0, 0, w, h), |(l, tp, r, b)| Rect::new(l, tp, r - l, b - tp)); + let cfa = match (raw.u64s(t::CFA_REPEAT_PATTERN_DIM).as_deref(), raw.bytes(t::CFA_PATTERN_EP)) { + (Some([2, 2]), Some(p)) if p.len() == 4 && p.iter().all(|&c| c <= 2) => Cfa { width: 2, height: 2, pattern: p.to_vec() }, + _ => return Err(RawError::Unsupported("Samsung raw without a 2x2 CFAPattern".into())), + }; + // saturation levels and black levels (R, G1, G2, B), in the file's own words + let levels = mn.as_ref().and_then(|m| keyed(m, RAW_DATA, 1)).filter(|v| v.len() == 12); + let RawData::U16(samples) = &data else { return Err(RawError::Unsupported("float SRW".into())) }; + // saturation: the file's level, or lower where the samples pile up (EX1 clips at 15882 of its stated 16383) + let stated = levels.as_ref().filter(|v| v[4..].iter().all(|&x| x == v[4]) && (255..=65535).contains(&v[4])).map(|v| v[4] as f32); + let bits = stated.map_or((info.bits() as u32).clamp(8, 16), |s| 32 - (s as u32).leading_zeros()); + let plateau = (!samples.is_empty()).then(|| white_from_data(&active_rows(samples, w, active), bits)); + let white = match (stated, plateau) { + (Some(s), Some(p)) => s.min(p), + (Some(s), None) => s, + (None, Some(p)) => p, + (None, None) => 4095.0, + }; + let tagged_black = + mn.as_ref().and_then(|m| keyed(m, WB_BLACK, -1)).filter(|v| v.len() == 4 && v.iter().all(|&x| (0..=u16::MAX as i64).contains(&x))); + let black = match tagged_black { + Some(v) if v.iter().any(|&x| x > 0) => { + // RGGB-ordered levels -> per CFA position at the active area origin + let a = cfa.shifted(active.x, active.y); + let values = a.pattern.iter().map(|&c| [v[0], (v[1] + v[2]) / 2, v[3]][c as usize] as f32).collect(); + BlackLevel { repeat_rows: 2, repeat_cols: 2, values, ..Default::default() } + } + // the file states no black level: measure the masked band around the active area, when there is one + _ => masked_black(samples, w, h, active, white), + }; + // as-shot white balance: gains with the greens near 4096 (unity) + let wb = mn.as_ref().and_then(|m| keyed(m, WB_UNCORRECTED, -1)).filter(|v| v.len() == 4 && v.iter().all(|&x| x > 0)).map(|v| { + let g = (v[1] + v[2]) as f64 / 2.0; + [(v[0] as f64 / g) as f32, 1.0, (v[3] as f64 / g) as f32] + }); + let crop = picture_crop(&tiff, active); + let img = RawImage { + format: RawFormat::Srw, + width: w, + height: h, + cpp: 1, + data, + cfa: Some(cfa), + bits, + black, + white: vec![white], + active_area: active, + crop, + orientation: Orientation::from_exif(ifd0.u16(t::ORIENTATION).unwrap_or(1)), + color: ColorData::default(), + wb_multipliers: wb, + linearized: false, + pixel_aspect: 1.0, + opcodes: OpcodeLists::default(), + camera: tiff.ifds.first().map(Camera::from_ifd0).unwrap_or_default(), + cinema: Default::default(), + }; + img.validate_for(mode)?; + Ok(img) +} + +/// Which bit order unpacks 12-bit samples of `w` x `h` (in `src`, `w / 2 * 3` bytes per row) into the image: in +/// the right order neighbouring samples of one colour differ by little, in the wrong one by about the whole range. +/// Compares the summed absolute differences of samples two apart over 64 rows from the middle of the frame. +fn lsb_is_smoother(src: &[u8], w: usize, h: usize) -> bool { + let stride = w / 2 * 3; + let (mut msb, mut lsb) = (0u64, 0u64); + let (mut a, mut b) = (vec![0u16; w], vec![0u16; w]); + for y in (h / 5..h * 4 / 5).step_by((h * 3 / 5 / 64).max(1)) { + let Some(row) = y.checked_mul(stride).and_then(|s| src.get(s..s + stride)) else { break }; + unpack_msb(row, 12, &mut a); + unpack_lsb(row, 12, &mut b); + let spread = |v: &[u16]| v.windows(3).map(|p| u64::from(p[0].abs_diff(p[2]))).sum::(); + msb += spread(&a); + lsb += spread(&b); + } + lsb < msb +} + +/// The default crop: the whole framing, or, when the Exif picture size is the framing cut in one direction only +/// (a 16:9 picture from a 4:3 sensor), the centred part of that size. +fn picture_crop(tiff: &Tiff, active: Rect) -> Rect { + let whole = Rect::new(0, 0, active.width, active.height); + let Some(exif) = tiff.exif() else { return whole }; + let (Some(pw), Some(ph)) = (exif.u32(t::PIXEL_X_DIMENSION), exif.u32(t::PIXEL_Y_DIMENSION)) else { return whole }; + let (pw, ph) = (pw as usize, ph as usize); + if pw == 0 || ph == 0 || pw > active.width || ph > active.height || (pw < active.width && ph < active.height) { + return whole; + } + Rect::new((active.width - pw) / 2, (active.height - ph) / 2, pw, ph) +} + +/// Every third row of the active area (the saturation statistics need no more). +fn active_rows(d: &[u16], w: usize, a: Rect) -> Vec { + (a.y..a.y + a.height).step_by(3).filter_map(|y| d.get(y * w + a.x..y * w + a.x + a.width)).flatten().copied().collect() +} + +/// Black level from rows or columns outside the active area that are dark (mean below 1.5% of `white`): the +/// longest run of dark lines on the right, else the left, below, above; at least four lines, the two outermost +/// rows and columns of the array left out. Uniform 0 when there is no such run. +fn masked_black(d: &[u16], w: usize, h: usize, a: Rect, white: f32) -> BlackLevel { + if d.is_empty() { + return BlackLevel::uniform(0.0); + } + let limit = f64::from(white) * 0.015; + let col_mean = |x: usize| { + (a.y..a.y + a.height).step_by(7).filter_map(|y| d.get(y * w + x)).map(|&v| f64::from(v)).sum::() / a.height.div_ceil(7) as f64 + }; + let row_mean = + |y: usize| (a.x..a.x + a.width).step_by(7).filter_map(|x| d.get(y * w + x)).map(|&v| f64::from(v)).sum::() / a.width.div_ceil(7) as f64; + // the longest run of consecutive dark lines within `lines` + let longest = |lines: std::ops::Range, mean: &dyn Fn(usize) -> f64| { + let (mut best, mut start) = (0..0, lines.start); + for i in lines.clone() { + if mean(i) >= limit { + start = i + 1; + } else if i + 1 - start > best.len() { + best = start..i + 1; + } + } + best + }; + let cols = a.y..a.y + a.height; + let rows = a.x..a.x + a.width; + let right = longest(a.x + a.width..w.saturating_sub(2), &col_mean); + let left = longest(2..a.x, &col_mean); + let below = longest(a.y + a.height..h.saturating_sub(2), &row_mean); + let above = longest(2..a.y, &row_mean); + if right.len() >= 4 { + black_from_columns(d, w, right, cols, a) + } else if left.len() >= 4 { + black_from_columns(d, w, left, cols, a) + } else if below.len() >= 4 { + black_from_columns(d, w, rows, below, a) + } else if above.len() >= 4 { + black_from_columns(d, w, rows, above, a) + } else { + BlackLevel::uniform(0.0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{decode, probe, probe_info}; + use craft_tiff::{IfdBuilder, ImageData, TiffWriter, Value}; + + const KEY: [u32; 11] = [305, 72, 737, 456, 282, 307, 519, 724, 13, 505, 193]; + + /// A maker note in the Samsung layout: a big-endian IFD of LONG vectors, offsets relative to the note's start. + fn note(entries: &[(u16, Vec)]) -> Vec { + let typed: Vec<_> = entries.iter().map(|(tag, v)| (*tag, 4, v.len() as u32, v.iter().flat_map(|x| x.to_be_bytes()).collect())).collect(); + note_typed(&typed) + } + + /// [`note`] with any entry types: `(tag, type, count, big-endian value bytes)`. + fn note_typed(entries: &[(u16, u16, u32, Vec)]) -> Vec { + let mut head = Vec::new(); + let mut tail = Vec::new(); + head.extend_from_slice(&(entries.len() as u16).to_be_bytes()); + let values_at = 2 + 12 * entries.len() + 4; + for (tag, kind, count, v) in entries { + head.extend_from_slice(&tag.to_be_bytes()); + head.extend_from_slice(&kind.to_be_bytes()); + head.extend_from_slice(&count.to_be_bytes()); + if v.len() <= 4 { + head.extend(v.iter().copied().chain(std::iter::repeat(0)).take(4)); + } else { + head.extend_from_slice(&((values_at + tail.len()) as u32).to_be_bytes()); + tail.extend_from_slice(v); + } + } + head.extend_from_slice(&[0; 4]); + head.extend(tail); + head + } + + /// Stored form of a vector the way the file keeps it: `plain + key` (`sign` 1) or `plain - key` (`sign` -1). + fn stored(plain: &[i64], sign: i64) -> Vec { + plain.iter().enumerate().map(|(i, &p)| (p + sign * i64::from(KEY[i % 11])) as u32).collect() + } + + fn pack12(v: &[u16]) -> Vec { + v.chunks(2).flat_map(|p| [(p[0] >> 4) as u8, ((p[0] & 15) << 4) as u8 | (p[1] >> 8) as u8, p[1] as u8]).collect() + } + + fn le_words(v: &[u16]) -> Vec { + v.iter().flat_map(|x| x.to_le_bytes()).collect() + } + + struct Spec { + w: u32, + h: u32, + compression: u16, + strip: Vec, + pattern: Option<[u8; 4]>, + note: Option>, + picture: Option<(u32, u32)>, + } + + fn srw(s: Spec) -> Vec { + let mut raw = IfdBuilder::new(); + raw.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0])); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![s.w])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![s.h])); + raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![12])); + raw.set(t::COMPRESSION, Value::Short(vec![s.compression])); + raw.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1])); + if let Some(p) = s.pattern { + raw.set(t::CFA_REPEAT_PATTERN_DIM, Value::Short(vec![2, 2])); + raw.set(t::CFA_PATTERN_EP, Value::Byte(p.to_vec())); + } + raw.set_image(ImageData::Strips { rows_per_strip: s.h, strips: vec![s.strip] }); + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::MAKE, Value::Ascii("SAMSUNG".into())); + ifd0.set(t::MODEL, Value::Ascii("NX0".into())); + let mut exif = IfdBuilder::new(); + if let Some(n) = s.note { + exif.set(t::MAKER_NOTE, Value::Undefined(n)); + } + if let Some((pw, ph)) = s.picture { + exif.set(t::PIXEL_X_DIMENSION, Value::Long(vec![pw])); + exif.set(t::PIXEL_Y_DIMENSION, Value::Long(vec![ph])); + } + ifd0.set_child(t::EXIF_IFD, exif); + ifd0.add_sub_ifd(raw); + TiffWriter::new(craft_tiff::ByteOrder::Big, false).write(&[ifd0]).unwrap() + } + + /// Strip byte counts that add up past `u64::MAX` (LONG8 `[u64::MAX, 1]`) are an error or a picture, not an overflow. + #[test] + fn strip_byte_counts_past_u64_max_do_not_overflow() { + let file = crate::shared::tests::huge_strip_counts(srw(Spec { + w: 4, + h: 2, + compression: 32769, + strip: vec![0; 16], + pattern: None, + note: None, + picture: None, + })); + for mode in [Mode::Header, Mode::Full] { + let _ = super::decode(&file, mode, &Limits::default()); + } + } + + /// The camera-stated description every test file carries: JPEG framing, white balance, levels. + fn full_note(area: [u32; 4], white: i64, black: i64) -> Vec { + let mut levels = vec![0i64; 4]; + levels.extend([white; 8]); + note(&[ + (SENSOR_AREAS, vec![0, 0, 64, 32, area[0], area[1], area[2], area[3]]), + (ENCRYPTION_KEY, KEY.to_vec()), + (WB_UNCORRECTED, stored(&[8192, 4096, 4096, 6144], 1)), + (WB_BLACK, stored(&[black; 4], 1)), + (RAW_DATA, stored(&levels, -1)), + ]) + } + + /// A smooth gradient with a little texture, `max` the first value that is not reached. + fn ramp(w: u32, h: u32, max: u32) -> Vec { + (0..w * h).map(|i| ((1000 + (i % w) * 7 + (i / w) * 5 + (i * 13) % 9) % max) as u16).collect() + } + + fn pack12_lsb(v: &[u16]) -> Vec { + v.chunks(2).flat_map(|p| [p[0] as u8, ((p[0] >> 8) as u8) | ((p[1] & 15) << 4) as u8, (p[1] >> 4) as u8]).collect() + } + + /// Known answer: the samples, layout, framing, levels and white balance all come from the file's own words. + #[test] + fn packed_12_bit_samples_decode_with_the_maker_note() { + let v = ramp(64, 32, 4096); + let bytes = srw(Spec { + w: 64, + h: 32, + compression: 32770, + strip: pack12(&v), + pattern: Some([2, 1, 1, 0]), + note: Some(full_note([6, 4, 58, 30], 4095, 3)), + picture: None, + }); + assert_eq!(probe(&bytes), Some(RawFormat::Srw)); + let r = decode(&bytes).unwrap(); + assert_eq!((r.format, r.width, r.height, r.bits), (RawFormat::Srw, 64, 32, 12)); + assert_eq!(r.data, RawData::U16(v)); + assert_eq!(r.cfa.as_ref().unwrap().name(), "BGGR"); + assert_eq!(r.active_area, Rect::new(6, 4, 52, 26)); + assert_eq!(r.crop, Rect::new(0, 0, 52, 26)); + assert_eq!((r.black.mean(), r.white.clone()), (3.0, vec![4095.0])); + assert_eq!(r.wb_multipliers, Some([2.0, 1.0, 1.5])); + // headers only: everything but the samples + let i = probe_info(&bytes).unwrap(); + assert_eq!((i.active_area, i.wb_multipliers, i.bits), (Rect::new(6, 4, 52, 26), Some([2.0, 1.0, 1.5]), 12)); + } + + /// The later bodies pack the same 12-bit samples LSB-first; which of the two orders a file uses is found from + /// the samples (the wrong order makes a noisy image), not from the model. + #[test] + fn lsb_first_packing_is_found_from_the_samples() { + let v = ramp(64, 32, 4096); + for (strip, name) in [(pack12(&v), "msb"), (pack12_lsb(&v), "lsb")] { + let bytes = srw(Spec { w: 64, h: 32, compression: 32770, strip, pattern: Some([1, 0, 2, 1]), note: None, picture: None }); + assert_eq!(decode(&bytes).unwrap().data, RawData::U16(v.clone()), "{name}"); + } + } + + /// A picture size narrower than the framing in one direction only (16:9 from a 4:3 sensor) is the framing's + /// centre; any other size is ignored. + #[test] + fn the_exif_picture_size_centres_a_one_direction_crop() { + let v = ramp(64, 32, 4096); + let file = |picture| { + srw(Spec { + w: 64, + h: 32, + compression: 32770, + strip: pack12(&v), + pattern: Some([2, 1, 1, 0]), + note: Some(full_note([6, 4, 58, 30], 4095, 0)), + picture, + }) + }; + // framing 52 x 26 + assert_eq!(decode(&file(Some((52, 18)))).unwrap().crop, Rect::new(0, 4, 52, 18)); + assert_eq!(decode(&file(Some((40, 26)))).unwrap().crop, Rect::new(6, 0, 40, 26)); + let r = decode(&file(Some((52, 18)))).unwrap(); + assert_eq!(r.info().developed_size(), (52, 18)); + for other in [None, Some((52, 26)), Some((40, 18)), Some((60, 18)), Some((52, 30)), Some((0, 18))] { + assert_eq!(decode(&file(other)).unwrap().crop, Rect::new(0, 0, 52, 26), "{other:?}"); + } + } + + /// 16-bit words are little-endian although the file is big-endian; a saturation plateau below the stated + /// level wins; dark rows below the framing give the black level when the file states none. + #[test] + fn word_samples_decode_and_measure_what_the_file_does_not_say() { + let (w, h) = (64u32, 40u32); + let mut v: Vec = (0..w * h).map(|i| 3000 + (i * 91 % 5000) as u16).collect(); + // saturation: a plateau at 15882 inside the framing (rows 4..28), two rows of picture beyond it, dark rows 30..40 + for i in 0..150usize { + v[(4 + i % 24) * 64 + 8 + i / 24] = 15882; + } + for x in 0..64 * 10 { + v[30 * 64 + x] = 80; + } + let bytes = srw(Spec { + w, + h, + compression: 32769, + strip: le_words(&v), + pattern: Some([0, 1, 1, 2]), + note: Some(full_note([0, 4, 64, 28], 16383, 0)), + picture: None, + }); + let r = decode(&bytes).unwrap(); + assert_eq!(r.data, RawData::U16(v)); + assert_eq!(r.bits, 14); + assert!((15800.0..=15882.0).contains(&r.white[0]), "{:?}", r.white); + assert_eq!(r.black.mean(), 80.0); + assert_eq!(r.cfa.unwrap().name(), "RGGB"); + } + + /// Files whose strip is not exactly the uncompressed size (or that use another private value) are compressed + /// codings: reported as such, still recognised as SRW. + #[test] + fn compressed_codings_are_reported_not_misread() { + let plain = |compression, strip: Vec| { + srw(Spec { w: 64, h: 32, compression, strip, pattern: Some([2, 1, 1, 0]), note: Some(full_note([0, 0, 64, 32], 4095, 0)), picture: None }) + }; + for bytes in [ + plain(32770, vec![7; 64 * 32 * 12 / 8 - 3]), + plain(32772, vec![7; 4000]), + plain(32773, pack12(&ramp(64, 32, 4096))), + plain(32769, vec![0; 64 * 32]), + ] { + assert_eq!(probe(&bytes), Some(RawFormat::Srw)); + let Err(RawError::Unsupported(why)) = decode(&bytes) else { panic!("expected Unsupported") }; + assert!(why.contains("compressed"), "{why}"); + assert!(probe_info(&bytes).is_err()); + } + } + + /// Without a maker note the samples still decode: whole array, no white balance, depth from the TIFF tags. + #[test] + fn a_file_without_a_maker_note_decodes_with_defaults() { + let v = ramp(64, 32, 4096); + let bytes = srw(Spec { w: 64, h: 32, compression: 32770, strip: pack12(&v), pattern: Some([1, 0, 2, 1]), note: None, picture: None }); + let r = decode(&bytes).unwrap(); + assert_eq!(r.data, RawData::U16(v)); + assert_eq!((r.active_area, r.wb_multipliers, r.black.mean()), (Rect::new(0, 0, 64, 32), None, 0.0)); + assert_eq!(r.cfa.unwrap().name(), "GRBG"); + assert_eq!(r.bits, 12); + } + + /// A raw IFD that does not say how its mosaic is laid out is not guessed at. + #[test] + fn a_file_without_a_cfa_pattern_is_unsupported() { + let bytes = srw(Spec { w: 64, h: 32, compression: 32770, strip: pack12(&ramp(64, 32, 4096)), pattern: None, note: None, picture: None }); + assert!(matches!(decode(&bytes), Err(RawError::Unsupported(w)) if w.contains("CFAPattern"))); + } + + /// An encryption key value past i64::MAX (LONG8) overflowed `-1 * key` in debug builds. + #[test] + fn a_huge_encryption_key_does_not_panic() { + let key: Vec = std::iter::repeat_n((1u64 << 63).to_be_bytes(), 11).flatten().collect(); + let note = note_typed(&[(ENCRYPTION_KEY, 16, 11, key), (WB_UNCORRECTED, 4, 4, [0u8; 16].to_vec()), (WB_BLACK, 4, 4, [0u8; 16].to_vec())]); + let bytes = srw(Spec { + w: 64, + h: 32, + compression: 32770, + strip: pack12(&ramp(64, 32, 4096)), + pattern: Some([2, 1, 1, 0]), + note: Some(note), + picture: None, + }); + let mn = maker_note(&bytes, &Tiff::parse(&bytes).unwrap()).unwrap(); + assert_eq!(mn.u64s(ENCRYPTION_KEY).map(|k| k[0]), Some(1 << 63), "the key reaches keyed()"); + assert_eq!(keyed(&mn, WB_BLACK, -1), Some(vec![0; 4]), "(2^63 mod 2^32 is 0)"); + assert!(decode(&bytes).is_ok()); + assert!(probe_info(&bytes).is_ok()); + } + + /// A framing rectangle outside the array, or reversed, is ignored; truncated files fail without panicking. + #[test] + fn hostile_framing_and_truncation_do_not_panic() { + let v = ramp(64, 32, 4096); + for area in [[0, 0, 65, 32], [10, 0, 10, 32], [0, 40, 64, 20]] { + let bytes = srw(Spec { + w: 64, + h: 32, + compression: 32770, + strip: pack12(&v), + pattern: Some([2, 1, 1, 0]), + note: Some(full_note(area, 4095, 0)), + picture: None, + }); + assert_eq!(decode(&bytes).unwrap().active_area, Rect::new(0, 0, 64, 32)); + } + let bytes = srw(Spec { + w: 64, + h: 32, + compression: 32770, + strip: pack12(&v), + pattern: Some([2, 1, 1, 0]), + note: Some(full_note([0, 0, 64, 32], 4095, 0)), + picture: None, + }); + for cut in [10, bytes.len() / 2, bytes.len() - 20] { + let _ = decode(&bytes[..cut]); + let _ = probe_info(&bytes[..cut]); + } + } + + /// storytold/lightcraft#2561: a small file whose strip declares a large frame is refused before the full decode allocates the frame. + #[test] + fn a_strip_cut_short_is_refused_before_allocating() { + let (w, h) = (3000u32, 2000u32); + for (compression, declared) in [(32769, w * h * 2), (32770, w * h / 2 * 3)] { + let mut f = srw(Spec { w, h, compression, strip: vec![0u8; 12], pattern: Some([0, 1, 1, 2]), note: None, picture: None }); + crate::shared::declare_strip_len(&mut f, 12, declared); + assert_eq!(probe(&f), Some(crate::RawFormat::Srw)); + assert!(matches!(decode(&f), Err(RawError::Corrupt(_))), "compression {compression}"); + } + } +} diff --git a/crates/raw/tests/it/main.rs b/crates/raw/tests/it/main.rs index b01dbc0ed..3ed7a0b82 100644 --- a/crates/raw/tests/it/main.rs +++ b/crates/raw/tests/it/main.rs @@ -4,3 +4,4 @@ mod dng; #[cfg(feature = "dng")] mod robust; +mod vendor_robust; diff --git a/crates/raw/tests/it/vendor_robust.rs b/crates/raw/tests/it/vendor_robust.rs new file mode 100644 index 000000000..7e348f07f --- /dev/null +++ b/crates/raw/tests/it/vendor_robust.rs @@ -0,0 +1,90 @@ +//! Malformed maker raw files must decode to an error, never panic: every truncation and random mutations of one +//! small synthetic file per maker layout. + +use craft_tiff::tags as t; +use craft_tiff::{ByteOrder, IfdBuilder, ImageData, TiffWriter, Value}; +use proptest::prelude::*; + +const W: u32 = 32; +const H: u32 = 8; + +/// A CFA TIFF the way most makers lay it out: IFD0 (make) + a SubIFD holding the raw strip. +fn cfa_tiff(make: &str, compression: u16, bits: u16, order: ByteOrder) -> Vec { + let px = (W * H) as usize; + let strip: Vec = (0..px * 2).map(|i| (i * 37 % 251) as u8).collect(); + let mut raw = IfdBuilder::new(); + raw.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0])); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![W])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![H])); + raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![bits])); + raw.set(t::COMPRESSION, Value::Short(vec![compression])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA])); + raw.set(t::CFA_REPEAT_PATTERN_DIM, Value::Short(vec![2, 2])); + raw.set(t::CFA_PATTERN_EP, Value::Byte(vec![0, 1, 1, 2])); + raw.set_image(ImageData::Strips { rows_per_strip: H, strips: vec![strip] }); + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::MAKE, Value::Ascii(make.into())); + ifd0.set(t::MODEL, Value::Ascii("TEST".into())); + ifd0.add_sub_ifd(raw); + TiffWriter::new(order, false).write(&[ifd0]).unwrap() +} + +/// A Pentax PEF: the raw image in IFD0, wide enough for the dark-border trim. +fn pef(compression: u16) -> Vec { + let (w, h) = (264u32, 4u32); + let mut ifd = IfdBuilder::new(); + ifd.set(t::MAKE, Value::Ascii("PENTAX".into())); + ifd.set(t::IMAGE_WIDTH, Value::Long(vec![w])); + ifd.set(t::IMAGE_LENGTH, Value::Long(vec![h])); + ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![16])); + ifd.set(t::COMPRESSION, Value::Short(vec![compression])); + ifd.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA])); + let strip: Vec = (0..(w * h * 2) as usize).map(|i| if i % 528 < 8 { 0 } else { (i * 7 % 200) as u8 }).collect(); + ifd.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![strip] }); + TiffWriter::new(ByteOrder::Big, false).write(&[ifd]).unwrap() +} + +fn samples() -> Vec> { + vec![ + pef(1), + pef(65535), + cfa_tiff("SAMSUNG", 1, 12, ByteOrder::Little), + cfa_tiff("SAMSUNG", 32769, 14, ByteOrder::Big), + cfa_tiff("ACME", 1, 16, ByteOrder::Little), + ] +} + +fn exercise(bytes: &[u8]) { + let _ = craft_raw::probe(bytes); + let _ = craft_raw::probe_info(bytes); + let _ = craft_raw::embedded_preview(bytes); + if let Ok(img) = craft_raw::decode(bytes) { + assert!(img.validate().is_ok()); + } +} + +#[test] +fn vendor_samples_truncated_at_every_length() { + for s in samples() { + exercise(&s); + for n in (0..s.len()).step_by(3) { + exercise(&s[..n]); + } + } +} + +proptest! { + #![proptest_config(ProptestConfig { cases: 2000, .. ProptestConfig::default() })] + + #[test] + fn mutated_vendor_files_never_panic(kind in any::(), flips in proptest::collection::vec((any::(), any::()), 1..16), cut in any::()) { + let all = samples(); + let mut data = all[kind.index(all.len())].clone(); + let n = data.len(); + for (i, v) in flips { + data[i % n] = v; + } + exercise(&data); + exercise(&data[..cut % (n + 1)]); + } +}