From 89791873f9c6b2c6e008103df1e8293e93964c2a Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Tue, 13 Oct 2026 03:24:57 +1100 Subject: [PATCH 01/23] Codecs: open uncompressed DICOM single-frame images (#1048) Add a pure-Rust DICOM reader: the file meta group's transfer syntax (implicit VR little-endian, explicit VR little/big-endian), MONOCHROME1/2 and RGB/YBR_FULL, 8/16-bit allocated samples, interleaved or planar, BitsStored/HighBit scaling and signed pixel representation. Compressed syntaxes, sequences, multi-frame data and other photometric interpretations are refused with Unsupported/Malformed, never wrong pixels. DICOM is read-only; .dcm/.dicom/.dc3 join the open filters (and .pcx is added to the web filter, where it was missing). --- apps/photocraft-web/src/web.rs | 3 +- apps/photocraft/src/services.rs | 3 +- crates/codecs/README.md | 9 + crates/codecs/src/codecs/dicom.rs | 364 +++++++++++++++++++++++++++++ crates/codecs/src/codecs/mod.rs | 1 + crates/codecs/src/format.rs | 16 +- crates/codecs/src/lib.rs | 4 +- crates/codecs/tests/detect_caps.rs | 17 +- docs/file-format-parity.md | 2 +- 9 files changed, 413 insertions(+), 6 deletions(-) create mode 100644 crates/codecs/src/codecs/dicom.rs diff --git a/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs index 6c9939607d..c4b89dd4ef 100644 --- a/apps/photocraft-web/src/web.rs +++ b/apps/photocraft-web/src/web.rs @@ -18,7 +18,8 @@ type Inbox = Arc)>>>; /// Photoshop brushes (.abr), gradients (.grd) and swatches (.aco, .ase), which go to the preset libraries. const OPEN_EXTS: &[&str] = &[ "pcraft", "pdn", "ora", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm", - "ppm", "pam", "pfm", "fits", "fit", "fts", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", + "ppm", "pam", "pfm", "pcx", "dcm", "dicom", "dc3", "fits", "fit", "fts", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", + "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub", ]; const SVG_EXTS: &[&str] = &["svg", "svgz"]; diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index f6695717be..107b6324f5 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -21,7 +21,8 @@ mod recovery_tests; /// (.kys), which go to the preset libraries and Edit › Keyboard Shortcuts. const OPEN_EXTS: &[&str] = &[ "pcraft", "pdn", "ora", "kra", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", - "pgm", "ppm", "pam", "pfm", "pcx", "fits", "fit", "fts", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", + "pgm", "ppm", "pam", "pfm", "pcx", "dcm", "dicom", "dc3", "fits", "fit", "fts", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", + "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub", ]; const SVG_EXTS: &[&str] = &["svg", "svgz"]; diff --git a/crates/codecs/README.md b/crates/codecs/README.md index e44890a0d9..42edd522db 100644 --- a/crates/codecs/README.md +++ b/crates/codecs/README.md @@ -45,6 +45,7 @@ let out = encode(&img, Format::Tiff, &EncodeOptions::default())?; | OpenEXR | yes | yes | F16, F32 | Y, YA, RGB, RGBA | yes | no | no | no | no | no | no | `exr` | | Radiance HDR | yes | yes | F32 | RGB | no | no | no | no | no | no | yes (RGBE) | `image` | | FITS | yes | yes | U8, U16, F32 | Gray, RGB | no | no | no | no | no | yes (header cards) | no | built in | +| DICOM | yes | **no** | U8, U16 | Gray, RGB | no | no | no | no | no | no | n/a | built in | | AVIF | **no** | only with feature `avif` | U8 | RGB, RGBA | yes | no | no | no | no | no | yes | `image` + `ravif` | | HEIF/HEIC | with feature `heif` | **no** | U8, U16 (10/12-bit decodes to U16) | RGB, RGBA | yes (auxiliary alpha) | yes (`colr` prof) | yes | yes | no | no | n/a | `heic-rs` | @@ -78,6 +79,14 @@ Palette BMP pixels decode as RGB in `Image`; `bmp_palette` exposes the embedded with `CodecError::Unsupported`: they are sensor data, not flat images. `photocraft-raw` decodes and develops them, and `photocraft-io` routes them there. +* **DICOM (read-only).** Single-frame medical images over the uncompressed transfer syntaxes + (implicit VR little-endian, explicit VR little/big-endian): MONOCHROME1/2 gray (with the + 1 = white inversion) and RGB/YBR_FULL (8-bit), 8- and 16-bit allocated samples, interleaved or + planar, scaled from `BitsStored`/`HighBit` and mapped up when `PixelRepresentation` is signed. + Compressed transfer syntaxes, sequences, overlays, multi-frame pixel data and other photometric + interpretations return `Unsupported`/`Malformed`. There is no DICOM writer + (`ASYMMETRIC_EXCEPTIONS`). + * **HEIF/HEIC (read-only, feature `heif`).** Decoding lives in the optional `photocraft-heif` crate behind the non-default `heif` feature (official PhotoCraft builds enable it; distributors choose). Without it, HEIF is still detected and decoding returns `CodecError::Unsupported` ("HEIC/HEIF diff --git a/crates/codecs/src/codecs/dicom.rs b/crates/codecs/src/codecs/dicom.rs new file mode 100644 index 0000000000..e86142b2af --- /dev/null +++ b/crates/codecs/src/codecs/dicom.rs @@ -0,0 +1,364 @@ +//! DICOM (medical imaging) reader, uncompressed transfer syntaxes only. +//! +//! Supports what a viewer needs from a single-frame image: MONOCHROME1/2 (gray) and RGB, +//! 8- and 16-bit samples, interleaved or planar, both byte orders and explicit/implicit VR. +//! Compressed transfer syntaxes, sequences, overlays and multi-frame pixel data are refused +//! with a clear error. Read-only in this crate. + +use std::collections::HashMap; + +use crate::error::CodecError; +use crate::format::Format; +use crate::image::{ChannelLayout, Image, SampleType}; +use crate::options::Limits; + +const F: Format = Format::Dicom; +const PREAMBLE: usize = 132; // 128 zero bytes + "DICM" + +fn bad(msg: impl std::fmt::Display) -> CodecError { + CodecError::malformed(F, msg) +} + +/// A DICOM file begins with a 128-byte preamble and the `DICM` magic. +pub(crate) fn is_dicom(b: &[u8]) -> bool { + b.get(128..132) == Some(b"DICM") +} + +#[derive(Clone, Copy)] +struct Endian { + big: bool, +} + +/// The transfer syntax's byte order and whether value representations are explicit. +struct Syntax { + endian: Endian, + explicit: bool, +} + +/// The value representations whose length field is 2 reserved bytes + `u32` in explicit VR. +fn long_vr(vr: &[u8]) -> bool { + matches!(vr, b"OB" | b"OW" | b"OF" | b"SQ" | b"UT" | b"UN" | b"OD" | b"OL" | b"OV" | b"UC" | b"UR") +} + +/// A cursor over the data set. +struct Cur<'a> { + b: &'a [u8], + pos: usize, + big: bool, +} + +impl<'a> Cur<'a> { + fn take(&mut self, n: usize) -> Option<&'a [u8]> { + let s = self.b.get(self.pos..self.pos.checked_add(n)?)?; + self.pos += n; + Some(s) + } + fn u16(&mut self) -> Option { + let c = self.take(2)?; + Some(if self.big { u16::from_be_bytes([c[0], c[1]]) } else { u16::from_le_bytes([c[0], c[1]]) }) + } + fn u32(&mut self) -> Option { + let c = self.take(4)?; + Some(if self.big { u32::from_be_bytes([c[0], c[1], c[2], c[3]]) } else { u32::from_le_bytes([c[0], c[1], c[2], c[3]]) }) + } +} + +/// A parsed element: its VR and the byte range of its value. +struct Elem { + vr: [u8; 2], + start: usize, + len: usize, +} + +/// Walk the elements from `start`, calling `f` with each `(group, element, value)`. Undefined +/// lengths (sequences) are refused, keeping the decoder single-frame and bounded. +fn elements(b: &[u8], start: usize, syn: &Syntax, mut f: impl FnMut(u16, u16, &Elem) -> Result<(), CodecError>) -> Result { + let mut c = Cur { b, pos: start, big: syn.endian.big }; + loop { + let Some(group) = c.u16() else { return Ok(c.pos - start) }; + let elem = c.u16().ok_or_else(|| bad("truncated element tag"))?; + let (vr, len) = if syn.explicit { + let vr = c.take(2).ok_or_else(|| bad("truncated VR"))?; + let vr2 = [vr[0], vr[1]]; + let len = if long_vr(&vr2) { + c.take(2).ok_or_else(|| bad("truncated VR"))?; // reserved + c.u32().ok_or_else(|| bad("truncated length"))? + } else { + u32::from(c.u16().ok_or_else(|| bad("truncated length"))?) + }; + (vr2, len) + } else { + ([0, 0], c.u32().ok_or_else(|| bad("truncated length"))?) + }; + if len == 0xFFFF_FFFF { + return Err(CodecError::unsupported(F, "undefined-length (sequence) elements")); + } + let len = len as usize; + let start = c.pos; + if b.len() < start.saturating_add(len) { + return Err(bad("element value runs past the end of the file")); + } + f(group, elem, &Elem { vr, start, len })?; + c.pos = start + len; + } +} + +fn u16_val(b: &[u8], e: &Elem, big: bool) -> Option { + let c = b.get(e.start..e.start + 2)?; + Some(if big { u16::from_be_bytes([c[0], c[1]]) } else { u16::from_le_bytes([c[0], c[1]]) }) +} + +/// The transfer syntax (byte order and explicit/implicit VR). The file meta group is always +/// explicit VR little-endian; the data set follows its `(0002,0010)` TransferSyntaxUID. +fn transfer_syntax(b: &[u8]) -> Result<(Syntax, usize), CodecError> { + let meta = Syntax { endian: Endian { big: false }, explicit: true }; + let mut ts: Option = None; + let mut data_start = b.len(); + let mut c = Cur { b, pos: PREAMBLE, big: false }; + loop { + let save = c.pos; + let Some(group) = c.u16() else { break }; + let _ = c.u16().ok_or_else(|| bad("truncated element tag"))?; + if group != 0x0002 { + // A non-meta group begins the data set. + data_start = save; + break; + } + let vr = c.take(2).ok_or_else(|| bad("truncated VR"))?; + let vr2 = [vr[0], vr[1]]; + let len = if long_vr(&vr2) { + c.take(2).ok_or_else(|| bad("truncated VR"))?; + c.u32().ok_or_else(|| bad("truncated length"))? + } else { + u32::from(c.u16().ok_or_else(|| bad("truncated length"))?) + }; + if len == 0xFFFF_FFFF { + return Err(CodecError::unsupported(F, "undefined-length meta element")); + } + let start = c.pos; + if b.len() < start.saturating_add(len as usize) { + return Err(bad("meta element runs past the end of the file")); + } + if (group, 0x0010) == (0x0002, 0x0010) { + ts = Some(String::from_utf8_lossy(&b[start..start + len as usize]).trim_end_matches(['\0', ' ']).to_string()); + } + c.pos = start + len as usize; + } + let _ = elements(b, PREAMBLE, &meta, |_, _, _| Ok(()))?; // validate the meta group parses + let syn = match ts.as_deref() { + Some("1.2.840.10008.1.2") => Syntax { endian: Endian { big: false }, explicit: false }, + Some("1.2.840.10008.1.2.1") => Syntax { endian: Endian { big: false }, explicit: true }, + Some("1.2.840.10008.1.2.2") => Syntax { endian: Endian { big: true }, explicit: true }, + Some(other) => return Err(CodecError::unsupported(F, format!("transfer syntax {other} (only uncompressed is read)"))), + // No meta: the historical default is implicit VR little-endian. + None => Syntax { endian: Endian { big: false }, explicit: false }, + }; + Ok((syn, data_start)) +} + +pub(crate) fn decode(b: &[u8], limits: &Limits) -> Result { + if !is_dicom(b) { + return Err(bad("not a DICOM file (no DICM magic)")); + } + let (syn, start) = transfer_syntax(b)?; + let mut tags: HashMap<(u16, u16), Elem> = HashMap::new(); + elements(b, start, &syn, |g, e, el| { + // Later duplicates must not silently override the first (DICOM forbids duplicates). + tags.entry((g, e)).or_insert_with(|| Elem { vr: el.vr, start: el.start, len: el.len }); + Ok(()) + })?; + let big = syn.endian.big; + let get_u16 = |tag: (u16, u16)| tags.get(&tag).and_then(|e| u16_val(b, e, big)); + let rows = get_u16((0x0028, 0x0010)).ok_or_else(|| bad("missing Rows (0028,0010)"))?; + let cols = get_u16((0x0028, 0x0011)).ok_or_else(|| bad("missing Columns (0028,0011)"))?; + let samples = get_u16((0x0028, 0x0002)).unwrap_or(1); + let bits = get_u16((0x0028, 0x0100)).ok_or_else(|| bad("missing BitsAllocated (0028,0100)"))?; + let bits_stored = get_u16((0x0028, 0x0101)).unwrap_or(bits); + let high_bit = get_u16((0x0028, 0x0102)).unwrap_or(bits_stored.saturating_sub(1)); + let signed = get_u16((0x0028, 0x0103)).unwrap_or(0) == 1; + let planar = get_u16((0x0028, 0x0006)).unwrap_or(0); + let photometric = tags + .get(&(0x0028, 0x0004)) + .and_then(|e| b.get(e.start..e.start + e.len)) + .map(|v| String::from_utf8_lossy(v).trim_end_matches(['\0', ' ']).to_string()) + .ok_or_else(|| bad("missing PhotometricInterpretation (0028,0004)"))?; + let pixel = tags.get(&(0x7FE0, 0x0010)).ok_or_else(|| CodecError::unsupported(F, "no pixel data (0028,0004/7FE0,0010)"))?; + if !matches!(bits, 8 | 16) || !matches!(bits_stored, 1..=16) { + return Err(CodecError::unsupported(F, format!("{bits}-bit allocated / {bits_stored}-bit stored samples"))); + } + let mono = matches!(photometric.as_str(), "MONOCHROME1" | "MONOCHROME2"); + let rgb = photometric == "RGB"; + let ybr = photometric == "YBR_FULL" || photometric == "YBR_FULL_422"; + if !(mono || rgb || ybr) { + return Err(CodecError::unsupported(F, format!("photometric interpretation {photometric}"))); + } + if mono && samples != 1 { + return Err(CodecError::unsupported(F, format!("{samples} samples per pixel with {photometric}"))); + } + if ybr && (bits != 8 || samples != 3) { + return Err(CodecError::unsupported(F, format!("{bits}-bit {photometric} ({samples} samples)"))); + } + let layout = if mono { ChannelLayout::Gray } else { ChannelLayout::Rgb }; + let out_sample = if bits == 8 { SampleType::U8 } else { SampleType::U16 }; + limits.check(u32::from(cols), u32::from(rows), layout, out_sample)?; + let n = usize::from(cols) * usize::from(rows) * usize::from(samples); + let bytes_per = if bits == 16 { 2 } else { 1 }; + let need = n.checked_mul(bytes_per).ok_or_else(|| bad("pixel size overflow"))?; + let data = b.get(pixel.start..pixel.start + need).ok_or_else(|| bad("pixel data is truncated"))?; + let out_channels = layout.channels(); + let mut out = Vec::new(); + out.try_reserve_exact(usize::from(cols) * usize::from(rows) * out_channels * bytes_per) + .map_err(|_| CodecError::LimitExceeded("cannot allocate the image".into()))?; + // Sample `i` of `samples` channels at pixel `p`, honouring the planar configuration. + let sample_at = |p: usize, ch: usize, s: usize| -> u32 { + let idx = if planar == 1 { ch * usize::from(cols) * usize::from(rows) + p } else { s + p * usize::from(samples) + ch }; + if bits == 16 { + let off = idx * 2; + let c = [data[off], data[off + 1]]; + u32::from(if big { u16::from_be_bytes(c) } else { u16::from_le_bytes(c) }) + } else { + u32::from(data[idx]) + } + }; + let stored_max = f64::from((1u32 << bits_stored) - 1); + // Extract the `bits_stored` samples (the least significant one at `high_bit`) and scale to + // [0, 1]; signed values are shifted up by half a range first. `high_bit` is 1-based here. + let shift = (u32::from(high_bit) + 1).saturating_sub(u32::from(bits_stored)); + let mask = (1u32 << bits_stored) - 1; + let half = 1u32 << (bits_stored - 1); + let norm = move |raw: u32| -> f64 { + let v = (raw >> shift) & mask; + // Signed: flip the sign bit to map [-2^(b-1), 2^(b-1)-1] onto [0, 2^b-1]. + let v = if signed { v ^ half } else { v }; + (f64::from(v) / stored_max).clamp(0.0, 1.0) + }; + let q = |x: f64| -> u16 { (x * 65535.0).round() as u16 }; + let push = |out: &mut Vec, x: f64| { + if bits == 8 { + out.push((x * 255.0).round() as u8); + } else { + out.extend_from_slice(&q(x).to_ne_bytes()); + } + }; + let px = usize::from(cols) * usize::from(rows); + for p in 0..px { + if mono { + let x = norm(sample_at(p, 0, 0)); + push(&mut out, if photometric == "MONOCHROME1" { 1.0 - x } else { x }); + } else if rgb { + for ch in 0..3 { + push(&mut out, norm(sample_at(p, ch, ch))); + } + } else { + // YBR_FULL (8-bit): BT.601 full-range to RGB. + let y = sample_at(p, 0, 0) as f64; + let cb = sample_at(p, 1, 1) as f64 - 128.0; + let cr = sample_at(p, 2, 2) as f64 - 128.0; + let r = (y + 1.402 * cr).clamp(0.0, 255.0); + let g = (y - 0.344_136 * cb - 0.714_136 * cr).clamp(0.0, 255.0); + let bl = (y + 1.772 * cb).clamp(0.0, 255.0); + for v in [r, g, bl] { + push(&mut out, v / 255.0); + } + } + } + Image::from_raw(u32::from(cols), u32::from(rows), layout, out_sample, out) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A little-endian explicit-VR DICOM with meta group and one frame. + fn synth(rows: u16, cols: u16, samples: u16, bits: u16, photometric: &str, pixels: &[u8]) -> Vec { + fn us(b: &mut Vec, g: u16, e: u16, v: u16) { + b.extend_from_slice(&g.to_le_bytes()); + b.extend_from_slice(&e.to_le_bytes()); + b.extend_from_slice(b"US"); + b.extend_from_slice(&2u16.to_le_bytes()); + b.extend_from_slice(&v.to_le_bytes()); + } + fn cs(b: &mut Vec, g: u16, e: u16, v: &str) { + let mut s = v.as_bytes().to_vec(); + if s.len() % 2 == 1 { + s.push(0); + } + b.extend_from_slice(&g.to_le_bytes()); + b.extend_from_slice(&e.to_le_bytes()); + b.extend_from_slice(b"CS"); + b.extend_from_slice(&(s.len() as u16).to_le_bytes()); + b.extend_from_slice(&s); + } + let mut b = vec![0u8; 128]; + b.extend_from_slice(b"DICM"); + // File meta group: TransferSyntaxUID explicit VR LE => explicit VR little endian. + let ts = b"1.2.840.10008.1.2.1\0"; + b.extend_from_slice(&0x0002u16.to_le_bytes()); + b.extend_from_slice(&0x0010u16.to_le_bytes()); + b.extend_from_slice(b"UI"); + b.extend_from_slice(&(ts.len() as u16).to_le_bytes()); + b.extend_from_slice(ts); + // Data set. + us(&mut b, 0x0028, 0x0010, rows); + us(&mut b, 0x0028, 0x0011, cols); + us(&mut b, 0x0028, 0x0002, samples); + cs(&mut b, 0x0028, 0x0004, photometric); + us(&mut b, 0x0028, 0x0100, bits); + us(&mut b, 0x0028, 0x0101, bits); + us(&mut b, 0x0028, 0x0102, bits - 1); + us(&mut b, 0x0028, 0x0103, 0); + // Pixel data (OB, long form). + b.extend_from_slice(&0x7FE0u16.to_le_bytes()); + b.extend_from_slice(&0x0010u16.to_le_bytes()); + b.extend_from_slice(b"OB"); + b.extend_from_slice(&0u16.to_le_bytes()); + b.extend_from_slice(&(pixels.len() as u32).to_le_bytes()); + b.extend_from_slice(pixels); + b + } + + #[test] + fn decodes_8bit_monochrome() { + let b = synth(2, 2, 1, 8, "MONOCHROME2", &[0, 51, 102, 255]); + let img = decode(&b, &Limits::default()).unwrap(); + assert_eq!((img.width(), img.height(), img.layout(), img.sample_type()), (2, 2, ChannelLayout::Gray, SampleType::U8)); + assert_eq!(img.data(), &[0, 51, 102, 255]); + } + + #[test] + fn decodes_16bit_monochrome_and_scales() { + let px: Vec = [0u16, 32768, 65535].iter().flat_map(|v| v.to_le_bytes()).collect(); + let b = synth(3, 1, 1, 16, "MONOCHROME2", &px); + let img = decode(&b, &Limits::default()).unwrap(); + let v = img.to_u16_samples().unwrap(); + assert_eq!(v[0], 0); + assert!((i32::from(v[1]) - 32768).abs() <= 1); + assert_eq!(v[2], 65535); + } + + #[test] + fn monochrome1_is_inverted() { + let b = synth(2, 1, 1, 8, "MONOCHROME1", &[0, 255]); + let img = decode(&b, &Limits::default()).unwrap(); + assert_eq!(img.data(), &[255, 0]); + } + + #[test] + fn truncated_and_bad_is_error_not_panic() { + let b = synth(2, 2, 1, 8, "MONOCHROME2", &[0, 1, 2, 3]); + for cut in [0, 4, 128, 132, 140, b.len() - 1] { + assert!(decode(&b[..cut.min(b.len())], &Limits::default()).is_err(), "cut {cut}"); + } + assert!(decode(b"not dicom", &Limits::default()).is_err()); + } + + #[test] + fn rejects_compressed_transfer_syntax() { + let mut b = synth(1, 1, 1, 8, "MONOCHROME2", &[0]); + let uncompressed = b"1.2.840.10008.1.2.1\0"; + let compressed = b"1.2.840.10008.1.2.4\0"; // same length, a compressed family + let at = b.windows(uncompressed.len()).position(|w| w == uncompressed).expect("transfer syntax in the fixture"); + b[at..at + compressed.len()].copy_from_slice(compressed); + assert!(matches!(decode(&b, &Limits::default()), Err(CodecError::Unsupported { .. }))); + } +} diff --git a/crates/codecs/src/codecs/mod.rs b/crates/codecs/src/codecs/mod.rs index 22845d930b..8b9e024d0b 100644 --- a/crates/codecs/src/codecs/mod.rs +++ b/crates/codecs/src/codecs/mod.rs @@ -1,4 +1,5 @@ pub(crate) mod deep_exr; +pub(crate) mod dicom; pub(crate) mod exr; pub(crate) mod exr_cryptomatte; pub(crate) mod fits; diff --git a/crates/codecs/src/format.rs b/crates/codecs/src/format.rs index ddb7587141..9aad8a35d2 100644 --- a/crates/codecs/src/format.rs +++ b/crates/codecs/src/format.rs @@ -27,6 +27,8 @@ pub enum Format { Heif, /// FITS (Flexible Image Transport System), the astronomy format: gray or RGB image data. Fits, + /// DICOM single-frame images (uncompressed): read-only. + Dicom, } /// What a format can hold **and** what this crate reads/writes for it. @@ -74,6 +76,10 @@ pub const ASYMMETRIC_EXCEPTIONS: &[(Format, &str)] = &[ writing needs an HEVC encoder, and the mature ones (x265, libheif) are C, so write stays \ unsupported. Without the feature HEIF is detected but neither read nor written.", ), + ( + Format::Dicom, + "DICOM single-frame images are imported for medical review; this crate has no DICOM writer, so save an imported frame as TIFF or PNG instead.", + ), ]; use ChannelLayout as L; @@ -84,7 +90,7 @@ const ALL_LAYOUTS: &[ChannelLayout] = &[L::Gray, L::GrayA, L::Rgb, L::Rgba, L::C impl Format { /// Every format known to the crate (enabled or not). - pub const ALL: [Format; 16] = [ + pub const ALL: [Format; 17] = [ Format::Png, Format::Jpeg, Format::Tiff, @@ -101,6 +107,7 @@ impl Format { Format::Avif, Format::Heif, Format::Fits, + Format::Dicom, ]; pub fn caps(self) -> FormatCaps { @@ -131,6 +138,7 @@ impl Format { Format::Avif => "AVIF", Format::Heif => "HEIF", Format::Fits => "FITS", + Format::Dicom => "DICOM", } } @@ -154,6 +162,7 @@ impl Format { Format::Avif => &["avif"], Format::Heif => &["heic", "heif", "hif"], Format::Fits => &["fits", "fit", "fts"], + Format::Dicom => &["dcm", "dicom", "dc3"], } } @@ -175,6 +184,7 @@ impl Format { Format::Avif => "image/avif", Format::Heif => "image/heif", Format::Fits => "image/fits", + Format::Dicom => "application/dicom", } } @@ -226,6 +236,7 @@ pub fn caps(format: Format) -> FormatCaps { FormatCaps { read: cfg!(feature = "heif"), write: false, depths: &[S::U8, S::U16], icc: true, exif: true, xmp: true, lossy: true, ..base } } Format::Fits => FormatCaps { depths: &[S::U8, S::U16, S::F32], layouts: &[L::Gray, L::Rgb], alpha: false, text: true, ..base }, + Format::Dicom => FormatCaps { read: true, write: false, depths: &[S::U8, S::U16], layouts: &[L::Gray, L::Rgb], alpha: false, ..base }, } } @@ -277,6 +288,9 @@ pub fn detect(bytes: &[u8]) -> Option { if b.starts_with(b"SIMPLE =") { return Some(Format::Fits); } + if crate::codecs::dicom::is_dicom(b) { + return Some(Format::Dicom); + } if crate::codecs::pcx::looks_like_pcx(b) { return Some(Format::Pcx); } diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index 16025a5a46..2424dcde76 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -43,7 +43,7 @@ pub use crate::resolution::{exif_resolution, export_exif, export_xmp, photoshop_ pub use crate::source_settings::{WebpEncoding, jpeg_quality, webp_encoding}; pub use half::f16; -use crate::codecs::{exr, fits, heif, jpeg, pcx, png, pnm, tiff, via_image, webp}; +use crate::codecs::{dicom, exr, fits, heif, jpeg, pcx, png, pnm, tiff, via_image, webp}; /// Detect the format and decode with default [`Limits`]. pub fn decode(bytes: &[u8]) -> Result { @@ -75,6 +75,7 @@ pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Res Format::WebP => webp::decode(bytes, l), Format::Pnm => pnm::decode(bytes, l), Format::Pcx => pcx::decode(bytes, l), + Format::Dicom => dicom::decode(bytes, l), Format::Fits => fits::decode(bytes, l), Format::OpenExr => exr::decode(bytes, l), Format::Heif => heif::decode(bytes, l, opts.keep_orientation), @@ -223,6 +224,7 @@ pub fn encode(image: &Image, format: Format, opts: &EncodeOptions) -> Result webp::encode(image, plan, opts), Format::Pnm => pnm::encode(image, plan, opts), Format::Pcx => Err(CodecError::unsupported(format, "PCX is import-only; save as PNG instead")), + Format::Dicom => Err(CodecError::unsupported(format, "DICOM is import-only; save as TIFF or PNG instead")), Format::Fits => fits::encode(image, plan, opts), Format::OpenExr => exr::encode(image, plan, opts), Format::Heif => Err(CodecError::unsupported(format, "encoding is not available in this build")), diff --git a/crates/codecs/tests/detect_caps.rs b/crates/codecs/tests/detect_caps.rs index 367a8bd813..049e0c1389 100644 --- a/crates/codecs/tests/detect_caps.rs +++ b/crates/codecs/tests/detect_caps.rs @@ -101,7 +101,9 @@ fn caps_are_internally_consistent() { let c = caps(f); assert!(!c.depths.is_empty() && !c.layouts.is_empty(), "{f:?}"); assert_eq!(c.alpha, c.layouts.iter().any(|l| l.has_alpha()), "{f:?} alpha flag vs layouts"); - assert!(!f.extensions().is_empty() && !f.name().is_empty() && f.mime_type().starts_with("image/")); + // DICOM's registered media type is `application/dicom`; every other format is an image. + let mime_ok = f.mime_type().starts_with("image/") || (f == Format::Dicom && f.mime_type() == "application/dicom"); + assert!(!f.extensions().is_empty() && !f.name().is_empty() && mime_ok, "{f:?}"); } } @@ -192,6 +194,19 @@ fn detect_magic_avif() { assert_eq!(detect(b"\0\0\0\x18ftypmif1\0\0\0\0mif1avif"), Some(Format::Avif), "compatible brand"); assert_eq!(detect(b"\0\0\0\x18ftypisom\0\0\0\0isommp41"), None, "mp4 is not avif"); } +#[test] +fn detect_magic_dicom() { + let mut b = vec![0u8; 128]; + b.extend_from_slice(b"DICM"); + assert_eq!(detect(&b), Some(Format::Dicom)); + assert_eq!(from_extension("scan.DCM"), Some(Format::Dicom)); + assert_eq!(from_extension("scan.dc3"), Some(Format::Dicom)); + assert!(caps(Format::Dicom).read && !caps(Format::Dicom).write); + assert!(ASYMMETRIC_EXCEPTIONS.iter().any(|(f, _)| *f == Format::Dicom)); + // A bare magic is an error, never a panic. + assert!(decode_as(Format::Dicom, &b).is_err()); +} + #[test] fn detect_tga_heuristic() { // Uncompressed true-colour 2x2, 24 bpp. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 024d596f1e..2c8beadbe2 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -44,7 +44,7 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity. | SVG | R (rasterize) | yes, as editable shape layers or a vector smart object | no | Better than Photoshop for editing; gradients approximated (FILE-215-9) | | JPEG 2000 | R/W | no (#1047) | no | | | Cineon / DPX | R (DPX), R/W (CIN) | no (#1049) | no | | -| DICOM | R/W | no | no | | +| DICOM | R/W | yes (#1048) | no | Read-only, uncompressed syntaxes: MONOCHROME1/2, RGB, YBR_FULL | | PCX | R/W | no | no | | | Pixar | R/W | no | no | | | Amiga IFF | R/W | no | no | | From c99df31a13a45536590e15c14208ea10f1f671d1 Mon Sep 17 00:00:00 2001 From: Przemek Date: Mon, 12 Oct 2026 15:20:03 +0200 Subject: [PATCH 02/23] SVG import: set text in an installed font when the named ones are missing usvg's fontdb maps the generic families to Windows fonts (sans-serif to Arial, serif to Times New Roman, monospace to Courier New) and knows nothing of Fontconfig's aliases. On a Linux without those fonts (Fedora 44 ships neither Arial nor DejaVu Sans), every family of a usual font-family="Arial, Helvetica, sans-serif" misses and usvg drops the text, although hundreds of fonts are installed; the import then only says "nothing to draw", because the no-font warning (#3168) fires only for an empty font database. Point each generic family at an installed font, as a browser falls back: the system's choice (fc-match on Linux) when the database has it, then metric-compatible and common stand-ins (Liberation, Croscore, DejaVu, Noto), the bundled craft fonts, the sans-serif choice, and any font at all. A generic whose font is installed is left alone. The tests use the bundled Inter only, so they don't depend on the fonts of the machine; tests/svg.rs's text test now passes on Fedora. --- crates/io/src/svg.rs | 111 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/crates/io/src/svg.rs b/crates/io/src/svg.rs index 04fc4bee3a..4170e2ca59 100644 --- a/crates/io/src/svg.rs +++ b/crates/io/src/svg.rs @@ -61,11 +61,75 @@ fn fonts() -> Arc { } #[cfg(not(target_arch = "wasm32"))] db.load_system_fonts(); + resolve_generic_families(&mut db, system_generic_family); Arc::new(db) }) .clone() } +/// Installed families that stand in for each generic one, best first: the metric-compatible +/// Liberation and Croscore fonts, then common Linux defaults, then the bundled craft fonts. +const SANS_SERIF: &[&str] = &["Liberation Sans", "Arimo", "Helvetica", "DejaVu Sans", "Noto Sans", photocraft_text::fonts::DEFAULT_FAMILY]; +const SERIF: &[&str] = &["Liberation Serif", "Tinos", "Times", "DejaVu Serif", "Noto Serif"]; +const MONOSPACE: &[&str] = &["Liberation Mono", "Cousine", "Courier", "DejaVu Sans Mono", "Noto Sans Mono", photocraft_text::fonts::MONO_FAMILY]; + +/// Points the generic families (`sans-serif`, `serif`, `monospace`, `cursive`, `fantasy`) at +/// fonts the database holds. fontdb names Windows fonts for them (Arial, Times New Roman, +/// Courier New…): on a system without those, every family of a usual +/// `font-family="Arial, Helvetica, sans-serif"` misses, and usvg drops the text although +/// hundreds of fonts are installed, where a browser sets it in a fallback font. A generic whose +/// font is installed is left alone. Otherwise the system's choice (`system`, Fontconfig on Linux) +/// wins when the database has it, then the first installed stand-in, then the sans-serif choice, +/// then any font at all. +fn resolve_generic_families(db: &mut usvg::fontdb::Database, system: impl Fn(&str) -> Option) { + use usvg::fontdb::Family; + let has = |db: &usvg::fontdb::Database, name: &str| db.faces().any(|f| f.families.iter().any(|(n, _)| n.eq_ignore_ascii_case(name))); + let Some(any) = db.faces().find_map(|f| f.families.first()).map(|(n, _)| n.clone()) else { return }; + let mut sans = None; + for (generic, css, stand_ins) in [ + (Family::SansSerif, "sans-serif", SANS_SERIF), + (Family::Serif, "serif", SERIF), + (Family::Monospace, "monospace", MONOSPACE), + (Family::Cursive, "cursive", &[][..]), + (Family::Fantasy, "fantasy", &[][..]), + ] { + let current = db.family_name(&generic).to_string(); + let pick = if has(db, ¤t) { + current + } else { + system(css) + .filter(|n| has(db, n)) + .or_else(|| stand_ins.iter().find(|n| has(db, n)).map(|n| n.to_string())) + .or_else(|| sans.clone()) + .unwrap_or_else(|| any.clone()) + }; + match generic { + Family::SansSerif => db.set_sans_serif_family(pick.clone()), + Family::Serif => db.set_serif_family(pick.clone()), + Family::Monospace => db.set_monospace_family(pick.clone()), + Family::Cursive => db.set_cursive_family(pick.clone()), + _ => db.set_fantasy_family(pick.clone()), + } + if generic == Family::SansSerif { + sans = Some(pick); + } + } +} + +/// The family Fontconfig sets a generic family in (`fc-match sans-serif`), as the rest of the +/// desktop does; `None` without Fontconfig. +#[cfg(all(target_os = "linux", not(target_arch = "wasm32")))] +fn system_generic_family(generic: &str) -> Option { + let out = std::process::Command::new("fc-match").args(["--format=%{family[0]}", generic]).output().ok()?; + let name = String::from_utf8(out.stdout).ok()?; + Some(name.trim().to_string()).filter(|n| out.status.success() && !n.is_empty()) +} + +#[cfg(not(all(target_os = "linux", not(target_arch = "wasm32"))))] +fn system_generic_family(_generic: &str) -> Option { + None +} + /// Parse options for an untrusted drawing. `` resolves only `data:` URLs: a file path /// or relative href is never read, so opening an SVG cannot pull local files (`/etc/...`, /// `~/.ssh/...`) into the document. Embedded SVG images get no `` resolution at all @@ -768,6 +832,53 @@ mod tests { assert!(r.warnings.iter().any(|w| w.contains("clip-path")), "{:?}", r.warnings); } + /// A database with the bundled Inter alone, generics resolved without Fontconfig: none of + /// fontdb's Windows defaults (Arial, Times New Roman, Courier New) is in it. + fn inter_only() -> Arc { + let mut db = usvg::fontdb::Database::new(); + db.load_font_data(include_bytes!("../../../assets/fonts/Inter-Regular.ttf").to_vec()); + resolve_generic_families(&mut db, |_| None); + Arc::new(db) + } + + /// Text asking for fonts that aren't installed (`Arial, Helvetica, sans-serif` on a Linux + /// without them) is set in an installed font, as a browser does, instead of being dropped. + #[test] + fn text_in_missing_fonts_falls_back_to_an_installed_one() { + for family in ["Arial, Helvetica, sans-serif", "Times New Roman", "Courier New, monospace", "cursive", "fantasy"] { + let svg = format!( + "II" + ); + let parsed = parse_with(svg.as_bytes(), inter_only).unwrap(); + assert!(!parsed.fontless_text); + let r = import_parsed("t", parsed, MAX_GROUP_DEPTH).unwrap(); + assert_eq!(r.document.layers.first().map(|l| l.name.as_str()), Some("II"), "{family}: {:?}", r.warnings); + } + } + + #[test] + fn generic_families_point_at_installed_fonts() { + use usvg::fontdb::Family; + let db = inter_only(); + for g in [Family::SansSerif, Family::Serif, Family::Monospace, Family::Cursive, Family::Fantasy] { + assert_eq!(db.family_name(&g), "Inter", "{g:?}"); + } + // The system's choice wins when installed; a generic whose font is installed stays. + let mut db = usvg::fontdb::Database::new(); + db.load_font_data(include_bytes!("../../../assets/fonts/Inter-Regular.ttf").to_vec()); + db.load_font_data(include_bytes!("../../../assets/fonts/JetBrainsMono-Regular.ttf").to_vec()); + db.set_cursive_family("Inter"); + resolve_generic_families(&mut db, |g| Some(if g == "serif" { "JetBrains Mono" } else { "Not Installed" }.to_string())); + assert_eq!(db.family_name(&Family::Serif), "JetBrains Mono"); + assert_eq!(db.family_name(&Family::Monospace), "JetBrains Mono", "the bundled mono stand-in"); + assert_eq!(db.family_name(&Family::SansSerif), "Inter"); + assert_eq!(db.family_name(&Family::Cursive), "Inter"); + // An empty database is left alone. + let mut empty = usvg::fontdb::Database::new(); + resolve_generic_families(&mut empty, |_| Some("Inter".into())); + assert_eq!(empty.family_name(&Family::SansSerif), "Arial"); + } + #[test] fn huge_drawings_scale_to_the_side_limit() { let tree = parse(b"").unwrap().tree; From 6f3db89321f60b4d2188ed634f42804e891a214e Mon Sep 17 00:00:00 2001 From: webdevtodayjason <115739170+webdevtodayjason@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:20:27 -0500 Subject: [PATCH 03/23] Contributors: add Jason Brashear --- contributors/people.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index bbda6719ec..7f0210dee2 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -154,3 +154,6 @@ sync_github_name = true [people.VanTan666] display_name = "VanTan" sync_github_name = false + +[people.webdevtodayjason] +display_name = "Jason Brashear" From 1e6a9f27b9976a7ed1c5fbeca7da6107163bb21e Mon Sep 17 00:00:00 2001 From: webdevtodayjason <115739170+webdevtodayjason@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:59:19 +0000 Subject: [PATCH 04/23] Codecs: PCX runs may cross plane boundaries within a scanline --- crates/codecs/src/codecs/pcx.rs | 6 ++---- crates/codecs/tests/pcx.rs | 20 +++++++++++++++++++- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/crates/codecs/src/codecs/pcx.rs b/crates/codecs/src/codecs/pcx.rs index 43a3e97302..f89a781e9d 100644 --- a/crates/codecs/src/codecs/pcx.rs +++ b/crates/codecs/src/codecs/pcx.rs @@ -34,7 +34,7 @@ pub(crate) fn looks_like_pcx(b: &[u8]) -> bool { stride >= min_stride && stride > 0 && (bits != 8 || matches!(planes, 1 | 3)) && (bits == 8 || u32::from(bits) * u32::from(planes) <= 4) } -/// Read one compressed plane's row. RLE packets never cross a scanline. +/// Read one compressed scanline, including all planes. RLE packets never cross a scanline. fn read_row(b: &[u8], pos: &mut usize, out: &mut [u8]) -> Result<(), CodecError> { let mut n = 0; while n < out.len() { @@ -77,9 +77,7 @@ pub(crate) fn decode(b: &[u8], limits: &Limits) -> Result { let mut p = HEADER; let width = w as usize; for y in 0..h as usize { - for plane in row.chunks_mut(stride) { - read_row(&b[..image_end], &mut p, plane)?; - } + read_row(&b[..image_end], &mut p, &mut row)?; for x in 0..width { let rgb = if bits == 8 && planes == 3 { [row[x], row[stride + x], row[2 * stride + x]] diff --git a/crates/codecs/tests/pcx.rs b/crates/codecs/tests/pcx.rs index 89661cb8eb..a5796473e6 100644 --- a/crates/codecs/tests/pcx.rs +++ b/crates/codecs/tests/pcx.rs @@ -79,7 +79,7 @@ fn decodes_4bit_and_monochrome_palettes() { fn rejects_truncated_and_overshooting_rle_without_panicking() { let mut b = header(8, 3, 2, 1, 2); assert!(decode_as(Format::Pcx, &b).is_err(), "header alone is truncated"); - b.extend([0xc5, 0xff]); // a five-pixel run cannot cross a two-byte row + b.extend([0xc7, 0xff]); // a seven-byte run cannot cross a six-byte scanline assert!(decode_as(Format::Pcx, &b).is_err()); let mut b = header(8, 3, 2, 1, 2); b.extend([0xc1]); // missing run value @@ -98,3 +98,21 @@ fn refuses_headers_that_exceed_allocation_budget() { b[2] = 0; assert_eq!(detect(&b), None, "unsupported uncompressed encoding"); } + +#[test] +fn decodes_rgb_runs_across_planes() { + let mut b = header(8, 3, 2, 1, 2); + b.extend([0xc4, 0xff, 0xc2, 0]); + let im = decode(&b).unwrap(); + assert_eq!(im.data(), &[255, 255, 0, 255, 255, 0]); + let png = encode(&im, Format::Png, &Default::default()).unwrap(); + assert_eq!(decode(&png).unwrap().data(), im.data()); +} + +#[test] +fn decodes_four_plane_runs_across_planes() { + let mut b = header(1, 4, 2, 1, 2); + b[16 + 15 * 3..16 + 16 * 3].copy_from_slice(&[255, 255, 0]); + b.extend([0xc8, 0xff]); + assert_eq!(decode(&b).unwrap().data(), &[255, 255, 0, 255, 255, 0]); +} From ab596db6ae4682054b456dda4a6a30f89e42967d Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Tue, 13 Oct 2026 14:24:03 +1100 Subject: [PATCH 05/23] Codecs: open Amiga IFF/ILBM bitmaps read-only Amiga IFF/ILBM (EA 85) artwork opens read-only: the FORM chunks BMHD, CMAP and BODY, both the interleaved ILBM and planar PBM variants, 1-8 bitplanes through the palette (a grey ramp when there is none), masking 0/1/2, and both stored (0) and ByteRun1/PackBits (1) compression. Chunk lengths and Run lengths are checked, so a malformed file errors rather than panicking; other bit-plane counts return Unsupported. No writer, so .iff/.lbm/.ilbm join the open filters and the format opens in Photoshop parity's Amiga IFF row. --- apps/photocraft-web/src/web.rs | 2 +- apps/photocraft/src/services.rs | 3 +- crates/codecs/README.md | 7 + crates/codecs/src/codecs/iff.rs | 287 +++++++++++++++++++++++++++++ crates/codecs/src/codecs/mod.rs | 1 + crates/codecs/src/format.rs | 13 +- crates/codecs/src/lib.rs | 4 +- crates/codecs/tests/detect_caps.rs | 13 ++ docs/file-format-parity.md | 2 +- docs/gaps.md | 12 +- 10 files changed, 335 insertions(+), 9 deletions(-) create mode 100644 crates/codecs/src/codecs/iff.rs diff --git a/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs index a5c9d8a565..fa2d98d640 100644 --- a/apps/photocraft-web/src/web.rs +++ b/apps/photocraft-web/src/web.rs @@ -18,7 +18,7 @@ type Inbox = Arc)>>>; /// Photoshop brushes (.abr), gradients (.grd) and swatches (.aco, .ase), which go to the preset libraries. const OPEN_EXTS: &[&str] = &[ "pcraft", "pdn", "ora", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", - "pbm", "pgm", "ppm", "pam", "pfm", "dds", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", + "pbm", "pgm", "ppm", "pam", "pfm", "dds", "fits", "fit", "fts", "dpx", "iff", "lbm", "ilbm", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub", ]; const SVG_EXTS: &[&str] = &["svg", "svgz"]; diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index d4a3869f31..9bafe574ef 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -21,7 +21,8 @@ mod recovery_tests; /// (.kys), which go to the preset libraries and Edit › Keyboard Shortcuts. const OPEN_EXTS: &[&str] = &[ "pcraft", "pdn", "ora", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", - "pbm", "pgm", "ppm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", + "pbm", "pgm", "ppm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "iff", "lbm", "ilbm", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", + "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub", ]; const SVG_EXTS: &[&str] = &["svg", "svgz"]; diff --git a/crates/codecs/README.md b/crates/codecs/README.md index 7486d46504..04e3209fba 100644 --- a/crates/codecs/README.md +++ b/crates/codecs/README.md @@ -47,6 +47,7 @@ let out = encode(&img, Format::Tiff, &EncodeOptions::default())?; | Radiance HDR | yes | yes | F32 | RGB | no | no | no | no | no | no | yes (RGBE) | `image` | | FITS | yes | yes | U8, U16, F32 | Gray, RGB | no | no | no | no | no | yes (header cards) | no | built in | | DPX | yes | yes | U8, U16 (10/12-bit code values read and written exactly through U16) | Gray, RGB, RGBA | yes | no | no | no | no | DPX header fields only (creator, project, copyright, creation time, transfer, colorimetric, frame position, frame rate, time code) | no | built in | +| Amiga IFF (ILBM/PBM) | yes | **no** | U8 | RGB | no | no | no | no | no | no | n/a | built in | | AVIF | **no** | only with feature `avif` | U8 | RGB, RGBA | yes | no | no | no | no | no | yes | `image` + `ravif` | | HEIF/HEIC | with feature `heif` | **no** | U8, U16 (10/12-bit decodes to U16) | RGB, RGBA | yes (auxiliary alpha) | yes (`colr` prof) | yes | yes | no | no | n/a | `heic-rs` | @@ -95,6 +96,12 @@ embedded table (and a PNG's transparent entry) so `photocraft-io` can open them Other descriptors, signed or run-length-encoded data and other bit depths return `CodecError::Unsupported` naming the field. Cineon (`.cin`) is not supported. +* **Amiga IFF (read-only).** `FORM ... ILBM` (interleaved) and `FORM ... PBM ` (planar) bitmaps + from the published IFF/ILBM layout: 1–8 bitplanes decoded through the `CMAP` palette (a grey + ramp when there is none), masking 0/1/2, and both stored (0) and ByteRun1/PackBits (1) + compression. Other bit-plane counts or compressions return `CodecError::Unsupported`; a + malformed chunk length or run errors rather than panicking. There is no IFF writer; save as PNG. + * **Camera raw files** (DNG, CR2, NEF, ARW… which are TIFF-structured) are recognised and refused with `CodecError::Unsupported`: they are sensor data, not flat images. `photocraft-raw` decodes and develops them, and `photocraft-io` routes them there. diff --git a/crates/codecs/src/codecs/iff.rs b/crates/codecs/src/codecs/iff.rs new file mode 100644 index 0000000000..421d55c894 --- /dev/null +++ b/crates/codecs/src/codecs/iff.rs @@ -0,0 +1,287 @@ +//! Amiga IFF/ILBM reader (EA "Interchange File Format", "InterLeaved BitMap" and the planar +//! "PBM " variant). Written from the published IFF/ILBM layout: a `FORM` chunk of four-byte +//! chunks (`BMHD` bitmap header, `CMAP` palette, `CAMG` Amiga viewport mode, `BODY` pixel data). +//! Read-only: modern saves should use PNG. +//! +//! Supported: 1–8 bitplanes with a palette (or a grey ramp when there is none), masking 0/1/2, +//! and both stored (0) and ByteRun1/PackBits (1) compression. Anything else is a clear +//! `Unsupported`/`Malformed` error, never a panic. + +use crate::error::CodecError; +use crate::format::Format; +use crate::image::{ChannelLayout, Image, SampleType}; +use crate::options::Limits; + +const F: Format = Format::Iff; + +fn bad(msg: impl std::fmt::Display) -> CodecError { + CodecError::malformed(F, msg) +} + +fn unsupported(msg: impl Into) -> CodecError { + CodecError::unsupported(F, msg) +} + +fn be32(b: &[u8], at: usize) -> Option { + let s = b.get(at..at.checked_add(4)?)?.try_into().ok()?; + Some(u32::from_be_bytes(s)) +} + +fn be16(b: &[u8], at: usize) -> Option { + let s = b.get(at..at.checked_add(2)?)?.try_into().ok()?; + Some(u16::from_be_bytes(s)) +} + +/// `FORM` + a bitmap type (`ILBM` interleaved, `PBM ` planar) at offset 8. +pub(crate) fn looks_like_iff(b: &[u8]) -> bool { + b.get(0..4) == Some(b"FORM") && matches!(b.get(8..12), Some(b"ILBM" | b"PBM ")) +} + +/// The bitmap header fields decoding needs. +struct Header { + width: u32, + height: u32, + planes: u8, + masking: u8, + compression: u8, + /// `ILBM` interleaves a row's planes; `PBM ` stores a whole plane before the next. + interleaved: bool, +} + +/// One four-byte chunk's id and byte range. +struct Chunk<'a> { + id: [u8; 4], + data: &'a [u8], +} + +/// Walk the `FORM`'s chunks from offset 12 to `end` (the form length), stopping at the first +/// malformed length so a hostile file can't run past the buffer. +fn chunks(b: &[u8]) -> impl Iterator> { + let end = be32(b, 4).map_or(b.len(), |n| (n as usize).saturating_add(8).min(b.len())); + let mut pos = 12usize; + std::iter::from_fn(move || { + if pos + 8 > end { + return None; + } + let id = b.get(pos..pos + 4)?.try_into().ok()?; + let len = be32(b, pos + 4)? as usize; + let start = pos + 8; + let data = b.get(start..start.checked_add(len)?)?; + // Chunks are padded to an even length. + pos = start.checked_add(len)?.checked_add(len & 1)?; + Some(Chunk { id, data }) + }) +} + +fn parse_header(b: &[u8], interleaved: bool) -> Result { + let bmhd = chunks(b).find(|c| &c.id == b"BMHD").ok_or_else(|| bad("no BMHD chunk"))?; + let d = bmhd.data; + if d.len() < 20 { + return Err(bad("BMHD chunk is too short")); + } + let width = u32::from(be16(d, 0).ok_or_else(|| bad("BMHD width"))?); + let height = u32::from(be16(d, 2).ok_or_else(|| bad("BMHD height"))?); + let planes = *d.get(8).ok_or_else(|| bad("BMHD planes"))?; + let masking = *d.get(9).ok_or_else(|| bad("BMHD masking"))?; + let compression = *d.get(10).ok_or_else(|| bad("BMHD compression"))?; + if width == 0 || height == 0 { + return Err(bad("zero-sized bitmap")); + } + if !(1..=8).contains(&planes) { + return Err(unsupported(format!("{planes} bitplanes (1–8, with a palette)"))); + } + if masking > 2 { + return Err(unsupported(format!("masking {masking}"))); + } + if compression > 1 { + return Err(unsupported(format!("compression {compression} (only stored and ByteRun1)"))); + } + Ok(Header { width, height, planes, masking, compression, interleaved }) +} + +/// ByteRun1 / PackBits decode of one plane row into `out`; returns the bytes consumed. A `0x80` +/// control byte is a no-op, as the layout specifies. +fn unpack_byte_run1(src: &[u8], out: &mut [u8]) -> Result { + let (mut i, mut o) = (0usize, 0usize); + while o < out.len() { + let n = *src.get(i).ok_or_else(|| bad("truncated ByteRun1 data"))? as i8; + i += 1; + if n >= 0 { + let count = n as usize + 1; + let lit = src.get(i..i.checked_add(count).ok_or_else(|| bad("ByteRun1 overflow"))?).ok_or_else(|| bad("truncated ByteRun1 run"))?; + if count > out.len() - o { + return Err(bad("ByteRun1 run crosses the plane row")); + } + out[o..o + count].copy_from_slice(lit); + i += count; + o += count; + } else if n != -128 { + let count = (1 - n as i32) as usize; + let value = *src.get(i).ok_or_else(|| bad("truncated ByteRun1 repeat"))?; + i += 1; + if count > out.len() - o { + return Err(bad("ByteRun1 repeat crosses the plane row")); + } + out[o..o + count].fill(value); + o += count; + } + } + Ok(i) +} + +pub(crate) fn decode(b: &[u8], limits: &Limits) -> Result { + if !looks_like_iff(b) { + return Err(bad("not an IFF/ILBM file (no FORM ... ILBM)")); + } + let interleaved = b.get(8..12) == Some(b"ILBM"); + let h = parse_header(b, interleaved)?; + limits.check(h.width, h.height, ChannelLayout::Rgb, SampleType::U8)?; + + // Palette: three bytes per colour; missing entries read as black. + let cmap = chunks(b).find(|c| &c.id == b"CMAP").map(|c| c.data); + let colors = 1usize << h.planes; + let palette = |idx: usize| -> [u8; 3] { + cmap.and_then(|p| p.get(idx * 3..idx * 3 + 3)).map(|c| [c[0], c[1], c[2]]).unwrap_or({ + // No palette: a grey ramp across the index range. + let v = ((idx as u32 * 255) / (colors as u32 - 1).max(1)) as u8; + [v, v, v] + }) + }; + + let body = chunks(b).find(|c| &c.id == b"BODY").map(|c| c.data).ok_or_else(|| bad("no BODY chunk"))?; + let (w, hh) = (h.width as usize, h.height as usize); + // Each plane row is stored as a whole number of 16-bit words. + let plane_row = w.div_ceil(16) * 2; + // A masking type 1 bitmap carries one extra (mask) plane per row. + let rows_planes: usize = if h.masking == 1 { usize::from(h.planes) + 1 } else { usize::from(h.planes) }; + + // Decode every stored plane row up front, then recombine. + let total_rows = hh.checked_mul(rows_planes).ok_or_else(|| CodecError::LimitExceeded("bitmap too large".into()))?; + let total_bytes = total_rows.checked_mul(plane_row).ok_or_else(|| CodecError::LimitExceeded("bitmap too large".into()))?; + let mut stored = vec![0u8; total_bytes]; + let mut cursor = 0usize; + for row in stored.chunks_mut(plane_row.max(1)).take(total_rows) { + match h.compression { + 0 => { + let src = body.get(cursor..cursor.checked_add(plane_row).ok_or_else(|| bad("BODY overflow"))?).ok_or_else(|| bad("truncated BODY data"))?; + let n = row.len().min(src.len()); + row[..n].copy_from_slice(&src[..n]); + cursor += plane_row; + } + _ => { + let tail = body.get(cursor..).ok_or_else(|| bad("truncated BODY data"))?; + cursor += unpack_byte_run1(tail, row)?; + } + } + } + + let mut out = Vec::new(); + out.try_reserve_exact(w.saturating_mul(hh).saturating_mul(3)).map_err(|_| CodecError::LimitExceeded("cannot allocate the image".into()))?; + let planes = usize::from(h.planes); + for y in 0..hh { + for x in 0..w { + let index = if h.interleaved { + // Row-major, interleaved: row y, plane p sits at (y * rows_planes + p). + (0..planes).filter(|&p| bit_at(&stored, (y * rows_planes + p) * plane_row, x)).fold(0usize, |acc, p| acc | (1 << p)) + } else { + // Planar: plane p's rows come first, then the next plane. + (0..planes).filter(|&p| bit_at(&stored, (p * hh + y) * plane_row, x)).fold(0usize, |acc, p| acc | (1 << p)) + }; + let c = palette(index); + out.extend_from_slice(&c); + } + } + Image::from_u8(h.width, h.height, ChannelLayout::Rgb, out) +} + +/// The bit for pixel `x` of a stored plane row at `base` (bits run MSB-first). +fn bit_at(stored: &[u8], base: usize, x: usize) -> bool { + stored.get(base + x / 8).is_some_and(|byte| byte & (0x80 >> (x % 8)) != 0) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A `FORM ILBM` with a `BMHD`, `CMAP` and `BODY`. `planes` plane rows per row, + /// `compression` 0 (stored) or 1 (ByteRun1), MSB-first. + fn ilbm(w: u16, h: u16, planes: u8, palette: &[[u8; 3]], compression: u8, body: &[u8]) -> Vec { + let mut b = b"FORM".to_vec(); + b.extend_from_slice(&0u32.to_be_bytes()); // patched below + b.extend_from_slice(b"ILBM"); + // BMHD (20 bytes) + b.extend_from_slice(b"BMHD"); + b.extend_from_slice(&20u32.to_be_bytes()); + b.extend_from_slice(&w.to_be_bytes()); + b.extend_from_slice(&h.to_be_bytes()); + b.extend_from_slice(&[0, 0, 0, 0]); // x/y origin + b.push(planes); + b.push(0); // masking + b.push(compression); + b.push(0); // pad + b.extend_from_slice(&[0, 0]); // transparent colour + b.extend_from_slice(&[1, 1]); // aspect + b.extend_from_slice(&[0, 0, 0, 0]); // page size + // CMAP + b.extend_from_slice(b"CMAP"); + b.extend_from_slice(&((palette.len() * 3) as u32).to_be_bytes()); + for c in palette { + b.extend_from_slice(c); + } + // BODY + b.extend_from_slice(b"BODY"); + b.extend_from_slice(&(body.len() as u32).to_be_bytes()); + b.extend_from_slice(body); + if body.len() % 2 == 1 { + b.push(0); + } + let size = (b.len() - 8) as u32; + b[4..8].copy_from_slice(&size.to_be_bytes()); + b + } + + #[test] + fn reads_a_stored_two_plane_bitmap() { + // 2×1, 2 planes: pixel 0 index 0 (plane bits 0,0), pixel 1 index 3 (bits 1,1). + // Plane 0 = 0b0100_0000, plane 1 = 0b0100_0000, each padded to 2 bytes. + let body = [0b0100_0000, 0, 0b0100_0000, 0]; + let pal = [[10, 20, 30], [40, 50, 60], [70, 80, 90], [100, 110, 120]]; + let b = ilbm(2, 1, 2, &pal, 0, &body); + assert!(looks_like_iff(&b)); + let img = decode(&b, &Limits::default()).unwrap(); + assert_eq!((img.width(), img.height(), img.layout()), (2, 1, ChannelLayout::Rgb)); + assert_eq!(img.data(), &[10, 20, 30, 100, 110, 120]); + } + + #[test] + fn reads_byte_run1_compressed_rows() { + // Same image, ByteRun1: literal run of two bytes (0x01) then the two plane bytes each row. + let body = [0x01, 0b0100_0000, 0, 0x01, 0b0100_0000, 0]; + let pal = [[1, 2, 3], [4, 5, 6], [7, 8, 9], [250, 251, 252]]; + let b = ilbm(2, 1, 2, &pal, 1, &body); + let img = decode(&b, &Limits::default()).unwrap(); + assert_eq!(img.data(), &[1, 2, 3, 250, 251, 252]); + } + + #[test] + fn no_palette_falls_back_to_a_grey_ramp() { + // 2×1, 1 plane, no CMAP: indices 0 and 1 → black and white. + let body = [0b0100_0000, 0]; + let b = ilbm(2, 1, 1, &[], 0, &body); + let img = decode(&b, &Limits::default()).unwrap(); + assert_eq!(img.data(), &[0, 0, 0, 255, 255, 255]); + } + + #[test] + fn hostile_input_errors_not_panics() { + // Truncations of a valid file never panic. + let body = [0b0100_0000, 0, 0b0100_0000, 0]; + let full = ilbm(2, 1, 2, &[[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]], 0, &body); + for cut in [0, 4, 8, 12, 40, full.len() - 1] { + assert!(decode(&full[..cut.min(full.len())], &Limits::default()).is_err(), "cut {cut}"); + } + // Too many planes is unsupported. + let bad = ilbm(1, 1, 9, &[], 0, &[0, 0]); + assert!(matches!(decode(&bad, &Limits::default()), Err(CodecError::Unsupported { .. }))); + } +} diff --git a/crates/codecs/src/codecs/mod.rs b/crates/codecs/src/codecs/mod.rs index afb7f96791..1b1683f4a3 100644 --- a/crates/codecs/src/codecs/mod.rs +++ b/crates/codecs/src/codecs/mod.rs @@ -6,6 +6,7 @@ pub(crate) mod exr_cryptomatte; pub(crate) mod fits; pub(crate) mod gif_frames; pub(crate) mod heif; +pub(crate) mod iff; pub(crate) mod jpeg; pub(crate) mod pcx; pub(crate) mod png; diff --git a/crates/codecs/src/format.rs b/crates/codecs/src/format.rs index d3730c08fb..e5fd1e1199 100644 --- a/crates/codecs/src/format.rs +++ b/crates/codecs/src/format.rs @@ -33,6 +33,8 @@ pub enum Format { Fits, /// DPX (SMPTE 268M Digital Picture Exchange): luma, RGB or RGBA film and video frames. Dpx, + /// Amiga IFF/ILBM (`FORM ... ILBM`): planar bitplanes with a palette, read-only. + Iff, } /// What a format can hold **and** what this crate reads/writes for it. @@ -65,6 +67,7 @@ pub struct FormatCaps { /// The test-suite asserts that every other enabled format is read+write. pub const ASYMMETRIC_EXCEPTIONS: &[(Format, &str)] = &[ (Format::Xpm, "XPM is a legacy Unix icon interchange format. Reading existing XPM icons is supported; writing modern image assets should use PNG instead."), + (Format::Iff, "Amiga IFF/ILBM is supported for opening legacy Amiga artwork; this crate has no IFF writer, so save as PNG instead."), ( Format::Pcx, "PCX is supported for opening legacy paint and clipart assets. New files should use PNG; writing obsolete indexed and planar variants is intentionally not provided.", @@ -95,7 +98,7 @@ const ALL_LAYOUTS: &[ChannelLayout] = &[L::Gray, L::GrayA, L::Rgb, L::Rgba, L::C impl Format { /// Every format known to the crate (enabled or not). - pub const ALL: [Format; 19] = [ + pub const ALL: [Format; 20] = [ Format::Png, Format::Jpeg, Format::Tiff, @@ -115,6 +118,7 @@ impl Format { Format::Heif, Format::Fits, Format::Dpx, + Format::Iff, ]; pub fn caps(self) -> FormatCaps { @@ -148,6 +152,7 @@ impl Format { Format::Heif => "HEIF", Format::Fits => "FITS", Format::Dpx => "DPX", + Format::Iff => "Amiga IFF", } } @@ -174,6 +179,7 @@ impl Format { Format::Heif => &["heic", "heif", "hif"], Format::Fits => &["fits", "fit", "fts"], Format::Dpx => &["dpx"], + Format::Iff => &["iff", "lbm", "ilbm"], } } @@ -198,6 +204,7 @@ impl Format { Format::Heif => "image/heif", Format::Fits => "image/fits", Format::Dpx => "image/x-dpx", + Format::Iff => "image/x-iff", } } @@ -253,6 +260,7 @@ pub fn caps(format: Format) -> FormatCaps { Format::Fits => FormatCaps { depths: &[S::U8, S::U16, S::F32], layouts: &[L::Gray, L::Rgb], alpha: false, text: true, ..base }, // `text`: the DPX header fields only (see `fidelity_warnings_with`). Format::Dpx => FormatCaps { depths: &[S::U8, S::U16], layouts: &[L::Gray, L::Rgb, L::Rgba], text: true, ..base }, + Format::Iff => FormatCaps { read: true, write: false, depths: &[S::U8], layouts: &[L::Rgb], alpha: false, ..base }, } } @@ -307,6 +315,9 @@ pub fn detect(bytes: &[u8]) -> Option { if b.starts_with(b"SDPX") || b.starts_with(b"XPDS") { return Some(Format::Dpx); } + if crate::codecs::iff::looks_like_iff(b) { + return Some(Format::Iff); + } if b.starts_with(b"SIMPLE =") { return Some(Format::Fits); } diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index 438e1857e4..88cc171678 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -43,7 +43,7 @@ pub use crate::resolution::{exif_resolution, export_exif, export_xmp, photoshop_ pub use crate::source_settings::{WebpEncoding, jpeg_quality, webp_encoding}; pub use half::f16; -use crate::codecs::{dds, dpx, exr, fits, heif, jpeg, pcx, png, pnm, tiff, via_image, webp, xpm}; +use crate::codecs::{dds, dpx, exr, fits, heif, iff, jpeg, pcx, png, pnm, tiff, via_image, webp, xpm}; /// Detect the format and decode with default [`Limits`]. pub fn decode(bytes: &[u8]) -> Result { @@ -79,6 +79,7 @@ pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Res Format::Dds => dds::decode(bytes, l), Format::Fits => fits::decode(bytes, l), Format::Dpx => dpx::decode(bytes, l), + Format::Iff => iff::decode(bytes, l), Format::OpenExr => exr::decode(bytes, l), Format::Heif => heif::decode(bytes, l, opts.keep_orientation), Format::Gif | Format::Bmp | Format::Tga | Format::Ico | Format::Qoi | Format::Hdr | Format::Avif => via_image::decode(format, bytes, l), @@ -231,6 +232,7 @@ pub fn encode(image: &Image, format: Format, opts: &EncodeOptions) -> Result Err(CodecError::unsupported(format, "DDS is import-only; save as PNG instead")), Format::Fits => fits::encode(image, plan, opts), Format::Dpx => dpx::encode(image, plan, opts), + Format::Iff => Err(CodecError::unsupported(format, "IFF/ILBM is import-only; save as PNG instead")), Format::OpenExr => exr::encode(image, plan, opts), Format::Heif => Err(CodecError::unsupported(format, "encoding is not available in this build")), Format::Gif | Format::Bmp | Format::Tga | Format::Ico | Format::Qoi | Format::Hdr | Format::Avif => via_image::encode(format, image, plan, opts), diff --git a/crates/codecs/tests/detect_caps.rs b/crates/codecs/tests/detect_caps.rs index 22bf227eb8..12bfdc4c31 100644 --- a/crates/codecs/tests/detect_caps.rs +++ b/crates/codecs/tests/detect_caps.rs @@ -187,6 +187,19 @@ fn detect_magic_hdr() { assert_eq!(detect(b"#?RGBE\n"), Some(Format::Hdr)); } #[test] +fn detect_magic_iff() { + // FORM + a four-byte length + the bitmap type. + assert_eq!(detect(b"FORM\0\0\0\x14ILBM"), Some(Format::Iff)); + assert_eq!(detect(b"FORM\0\0\0\x14PBM "), Some(Format::Iff)); + assert_eq!(from_extension("art.LBM"), Some(Format::Iff)); + assert_eq!(from_extension("art.ilbm"), Some(Format::Iff)); + assert!(caps(Format::Iff).read && !caps(Format::Iff).write, "IFF is read-only"); + assert!(ASYMMETRIC_EXCEPTIONS.iter().any(|(f, _)| *f == Format::Iff)); + // A bare FORM without a bitmap type is not IFF. + assert_eq!(detect(b"FORM\0\0\0\x14AIFF"), None); + assert!(decode_as(Format::Iff, b"FORM\0\0\0\x14ILBM").is_err()); +} +#[test] fn detect_magic_avif() { assert_eq!(detect(b"\0\0\0\x1cftypavif\0\0\0\0avifmif1miaf"), Some(Format::Avif)); assert_eq!(detect(b"\0\0\0\x1cftypavis\0\0\0\0avismsf1miaf"), Some(Format::Avif)); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index cb2ca13acd..d6681d4224 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -47,7 +47,7 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity. | DICOM | R/W | no | no | | | PCX | R/W | no | no | | | Pixar | R/W | no | no | | -| Amiga IFF | R/W | no | no | | +| Amiga IFF | R/W | yes | no | Read-only: 1–8 planes with CMAP, stored and ByteRun1 | | Wireless Bitmap | R/W | no | no | | | MPO / JPS (stereo) | R/W | no | no | | | PICT | R | no | no | Legacy | diff --git a/docs/gaps.md b/docs/gaps.md index 25a286aab8..949eb68328 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-13 · **Change:** minor (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) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (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, and Amiga IFF read; 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) · **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 @@ -220,10 +220,13 @@ what an agent can't finish alone. ### G10. Missing and partial file formats - **Missing:** JPEG Options dialog (FILE-215-19), Photoshop PDF read and write and multi-page PDF (FILE-215-10, #2541), AVIF read (#463) and default-build AVIF write, JPEG XL, JPEG 2000 (#1047), - Cineon (DPX landed, #1049), DICOM, EPS/DCS, PCX, Pixar, IFF, WBMP, MPO/JPS, PICT, Scitex CT, Photoshop - Raw; layered TIFF extra alpha channels (FILE-215-7); spot plates (FILE-215-17); .atn, .asl, - .shc, .csh presets (FILE-215-15); C2PA (#1050). + Cineon (DPX landed, #1049), DICOM, EPS/DCS, PCX, Pixar, IFF write (read landed), WBMP, MPO/JPS, + PICT, Scitex CT, Photoshop Raw; layered TIFF extra alpha channels (FILE-215-7); spot plates + (FILE-215-17); .atn, .asl, .shc, .csh presets (FILE-215-15); C2PA (#1050). - **Detail:** [`file-format-parity.md`](file-format-parity.md). +- **Progress (2026-10-13):** Amiga IFF/ILBM (`FORM ... ILBM`/`PBM `) 1–8-plane paletted bitmaps + open read-only, stored or ByteRun1-compressed (`crates/codecs/src/codecs/iff.rs`); writing is + still open. - **Estimate:** 120–220 h. ## Hardware gaps @@ -263,6 +266,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | G10: Amiga IFF/ILBM read landed (1–8 planes, CMAP, stored and ByteRun1); IFF write remains open | | 2026-10-13 | minor | G4: Hue Jitter amount-track appearance (#3825, partial); no readiness percentage or estimate changed | | 2026-10-13 | minor | G4: minimum inputs in Shape Dynamics and Transfer follow their Control (#3825, partial); no readiness percentage or estimate changed | | 2026-10-12 | minor | G5: Free Transform options bar H/V skew fields done | From 085c29bea76bb9f13a23e897a81d969081ea39a0 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Wed, 14 Oct 2026 05:50:25 +1100 Subject: [PATCH 06/23] Wrap OPEN_EXTS so cargo fmt --check passes. CI failed rustfmt on the DICOM extension lines in the desktop and web open-file lists. --- apps/photocraft-web/src/web.rs | 4 ++-- apps/photocraft/src/services.rs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs index ea3a8b564e..290561ee2e 100644 --- a/apps/photocraft-web/src/web.rs +++ b/apps/photocraft-web/src/web.rs @@ -18,8 +18,8 @@ type Inbox = Arc)>>>; /// Photoshop brushes (.abr), gradients (.grd) and swatches (.aco, .ase), which go to the preset libraries. const OPEN_EXTS: &[&str] = &[ "pcraft", "pdn", "ora", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", - "pbm", "pgm", "ppm", "pam", "pfm", "dds", "fits", "fit", "fts", "dpx", "dcm", "dicom", "dc3", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", - "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub", + "pbm", "pgm", "ppm", "pam", "pfm", "dds", "fits", "fit", "fts", "dpx", "dcm", "dicom", "dc3", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", + "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub", ]; const SVG_EXTS: &[&str] = &["svg", "svgz"]; const CANVAS_ID: &str = "photocraft_canvas"; diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index 5bcf454438..f359aea96d 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -21,8 +21,8 @@ mod recovery_tests; /// (.kys), which go to the preset libraries and Edit › Keyboard Shortcuts. const OPEN_EXTS: &[&str] = &[ "pcraft", "pdn", "ora", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", - "pbm", "pgm", "ppm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "dcm", "dicom", "dc3", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", - "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub", + "pbm", "pgm", "ppm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "dcm", "dicom", "dc3", "heic", "heif", "hif", "dng", "cr2", "cr3", + "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub", ]; const SVG_EXTS: &[&str] = &["svg", "svgz"]; From 54e7287ba669b82003285faef99ad3aa51f1e9a7 Mon Sep 17 00:00:00 2001 From: lloyd Date: Wed, 14 Oct 2026 18:46:26 +0200 Subject: [PATCH 07/23] Animated WebP: every frame loads into video layers (#1198) An animated WebP used to bring only its first frame into a video layer. Now New Video Layer from File, Replace Footage, Reload Frame and Video Frames to Layers take every frame, as they do for an animated GIF (#572): - photocraft-codecs: `decode_webp_frames` returns each frame composited onto the canvas by image-webp (offsets, blending, disposal), with its duration in milliseconds and the file's ICC profile. A still WebP is one frame, read as `decode` reads it. A damaged later frame keeps the frames before it and marks the first `Truncated`; the frame count and total bytes are checked against `Limits`. `webp_is_animated` tells the two apart. New `decode_webp` fuzz target. - photocraft-io: `import_webp_frames`, sharing `import_gif_frames`' loop. - photocraft-engine: `load_frames` takes an animated WebP's frames; the timeline rate comes from the most common duration. A still WebP keeps the existing path and the 30 fps default. Co-Authored-By: Claude Opus 5.5 --- crates/codecs/README.md | 5 +- crates/codecs/fuzz/Cargo.toml | 7 + .../codecs/fuzz/fuzz_targets/decode_webp.rs | 13 ++ crates/codecs/src/codecs/gif_frames.rs | 8 +- crates/codecs/src/codecs/webp.rs | 50 ++++++ crates/codecs/src/lib.rs | 17 ++ crates/codecs/tests/webp_frames.rs | 166 ++++++++++++++++++ crates/engine/src/video_cmds.rs | 23 ++- crates/engine/src/video_cmds/tests.rs | 74 ++++++++ crates/io/src/flat.rs | 12 +- crates/io/src/lib.rs | 4 +- docs/file-format-parity.md | 5 +- 12 files changed, 369 insertions(+), 15 deletions(-) create mode 100644 crates/codecs/fuzz/fuzz_targets/decode_webp.rs create mode 100644 crates/codecs/tests/webp_frames.rs diff --git a/crates/codecs/README.md b/crates/codecs/README.md index 7486d46504..71796c7fdd 100644 --- a/crates/codecs/README.md +++ b/crates/codecs/README.md @@ -128,7 +128,10 @@ embedded table (and a PNG's transparent entry) so `photocraft-io` can open them with `decode_tiff_page` (and `photocraft_io::import_tiff_page`). Every frame of a GIF comes from `decode_gif_frames`: each frame composited onto the logical screen with the disposal methods applied, as RGBA8 with its delay in milliseconds, within `Limits` and - `MAX_GIF_FRAMES`. The video-layer commands use it (`photocraft_io::import_gif_frames`). + `MAX_GIF_FRAMES`. Every frame of an animated WebP comes from `decode_webp_frames` the same + way, composited onto its canvas, with its duration in milliseconds and the file's ICC profile. + The video-layer commands use both (`photocraft_io::import_gif_frames`, + `photocraft_io::import_webp_frames`). * **EXIF in TIFF** is stored as a sub-IFD rather than a blob, so it is not preserved yet. `caps.exif = false` for TIFF, and a warning is raised. * **Orientation** (EXIF tag 274 in JPEG, PNG `eXIf` and WebP; the decoded page's tag in TIFF and diff --git a/crates/codecs/fuzz/Cargo.toml b/crates/codecs/fuzz/Cargo.toml index 62ec96c523..e01d1bd337 100644 --- a/crates/codecs/fuzz/Cargo.toml +++ b/crates/codecs/fuzz/Cargo.toml @@ -69,3 +69,10 @@ path = "fuzz_targets/decode_gif.rs" test = false doc = false bench = false + +[[bin]] +name = "decode_webp" +path = "fuzz_targets/decode_webp.rs" +test = false +doc = false +bench = false diff --git a/crates/codecs/fuzz/fuzz_targets/decode_webp.rs b/crates/codecs/fuzz/fuzz_targets/decode_webp.rs new file mode 100644 index 0000000000..c1539e1600 --- /dev/null +++ b/crates/codecs/fuzz/fuzz_targets/decode_webp.rs @@ -0,0 +1,13 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use photocraft_codecs::{decode_as_with, decode_webp_frames, DecodeOptions, Format, Limits}; + +fuzz_target!(|data: &[u8]| { + let opts = DecodeOptions { + limits: Limits { max_width: 4096, max_height: 4096, max_pixels: 1 << 22, max_alloc: 256 << 20 }, + ..Default::default() + }; + let _ = decode_as_with(Format::WebP, data, &opts); + let _ = decode_webp_frames(data, &opts); +}); diff --git a/crates/codecs/src/codecs/gif_frames.rs b/crates/codecs/src/codecs/gif_frames.rs index a5bed96254..6a99b54f7b 100644 --- a/crates/codecs/src/codecs/gif_frames.rs +++ b/crates/codecs/src/codecs/gif_frames.rs @@ -14,16 +14,16 @@ use crate::error::CodecError; use crate::image::{ChannelLayout, DecodeWarning, Image}; use crate::options::DecodeOptions; -/// Most frames one GIF may yield. Frames are held in memory, and a tiny canvas could otherwise -/// ask for millions of them while staying inside the byte budget. +/// Most frames one GIF (or animated WebP) may yield. Frames are held in memory, and a tiny +/// canvas could otherwise ask for millions of them while staying inside the byte budget. pub const MAX_GIF_FRAMES: usize = 100_000; /// One composited frame of an animation and how long it stays on screen. #[derive(Debug, Clone, PartialEq)] pub struct AnimationFrame { pub image: Image, - /// On-screen time in milliseconds. GIF counts in hundredths of a second, so this is a - /// multiple of 10 (0 when the file gives none). + /// On-screen time in milliseconds. GIF counts in hundredths of a second, so there this is a + /// multiple of 10 (0 when the file gives none); WebP counts in milliseconds. pub delay_ms: u32, } diff --git a/crates/codecs/src/codecs/webp.rs b/crates/codecs/src/codecs/webp.rs index b7b6e2a668..f109f62e96 100644 --- a/crates/codecs/src/codecs/webp.rs +++ b/crates/codecs/src/codecs/webp.rs @@ -56,6 +56,56 @@ pub(crate) fn probe(bytes: &[u8], limits: &Limits) -> Result<(u32, u32, Option bool { + image_webp::WebPDecoder::new(Cursor::new(bytes)).is_ok_and(|d| d.is_animated() && d.num_frames() > 1) +} + +/// Every frame of an animated WebP, or `None` when it is a still one (or an animation of one +/// frame), which [`decode`] reads whole. The frames come composited onto the canvas by +/// `image-webp` (offsets, blending and disposal applied), each a full picture with the file's +/// ICC profile and its duration in milliseconds. +/// +/// The first frame must decode. If a later frame is damaged the frames before it are returned +/// and the first one carries [`DecodeWarning::Truncated`]. The frame count +/// ([`super::gif_frames::MAX_GIF_FRAMES`]) and the total decoded bytes are checked against +/// `limits` before each frame is kept. +pub(crate) fn decode_frames(bytes: &[u8], limits: &Limits) -> Result>, CodecError> { + use super::gif_frames::{AnimationFrame, MAX_GIF_FRAMES}; + if !is_animated(bytes) { + return Ok(None); + } + let (mut dec, w, h, layout) = open(bytes, limits)?; + let size = dec.output_buffer_size().ok_or_else(|| err("image too large"))?; + let icc = dec.icc_profile().ok().flatten(); + let mut buf = alloc_zeroed::(size)?; + let mut out: Vec = Vec::new(); + let mut damaged = false; + for _ in 0..dec.num_frames() { + if out.len() >= MAX_GIF_FRAMES { + return Err(CodecError::LimitExceeded(format!("more than {MAX_GIF_FRAMES} frames"))); + } + if (size as u64).saturating_mul(out.len() as u64 + 1) > limits.max_alloc { + return Err(CodecError::LimitExceeded(format!("{} frames of {w}x{h} exceed max_alloc {}", out.len() + 1, limits.max_alloc))); + } + let delay_ms = match dec.read_frame(&mut buf) { + Ok(d) => d, + Err(e) if out.is_empty() => return Err(err(e)), + Err(_) => { + damaged = true; + break; + } + }; + let mut image = Image::from_u8(w, h, layout, buf.clone())?; + image.icc.clone_from(&icc); + out.push(AnimationFrame { image, delay_ms }); + } + if damaged && let Some(first) = out.first_mut() { + first.image.warnings.push(DecodeWarning::Truncated { format: F }); + } + Ok(Some(out)) +} + pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result, CodecError> { let img = src.converted(plan.layout, plan.sample); if !opts.webp_lossless { diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index 3831cf654f..70ade5c9a4 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -149,6 +149,23 @@ pub fn decode_gif_frames(bytes: &[u8], opts: &DecodeOptions) -> Result Result, CodecError> { + match crate::codecs::webp::decode_frames(bytes, &opts.limits)? { + Some(frames) => Ok(frames), + None => Ok(vec![AnimationFrame { image: decode_as_with(Format::WebP, bytes, opts)?, delay_ms: 0 }]), + } +} + +/// Whether `bytes` is an animated WebP of more than one frame (which [`decode`] opens as its +/// first frame, with a warning, and [`decode_webp_frames`] reads whole). +pub fn webp_is_animated(bytes: &[u8]) -> bool { + crate::codecs::webp::is_animated(bytes) +} + /// The Orientation (1–8) of the page [`decode`] opens from a TIFF or BigTIFF file; 1 when it is /// absent, malformed or out of range. pub fn tiff_orientation(bytes: &[u8]) -> u16 { diff --git a/crates/codecs/tests/webp_frames.rs b/crates/codecs/tests/webp_frames.rs new file mode 100644 index 0000000000..5045633cad --- /dev/null +++ b/crates/codecs/tests/webp_frames.rs @@ -0,0 +1,166 @@ +//! `decode_webp_frames`: every frame of an animated WebP, composited, with its duration (#1198). + +use photocraft_codecs::{ + AnimationFrame, ChannelLayout, CodecError, DecodeOptions, DecodeWarning, EncodeOptions, Format, Image, Limits, decode, decode_webp_frames, encode, + webp_is_animated, +}; + +const RED: [u8; 4] = [255, 0, 0, 255]; +const GREEN: [u8; 4] = [0, 255, 0, 255]; +const BLUE: [u8; 4] = [0, 0, 255, 255]; + +fn frames(bytes: &[u8]) -> Vec { + decode_webp_frames(bytes, &DecodeOptions::default()).unwrap() +} + +fn solid(c: [u8; 4], w: u32, h: u32) -> Image { + Image::from_u8(w, h, ChannelLayout::Rgba, c.repeat((w * h) as usize)).unwrap() +} + +fn chunk(id: &[u8; 4], data: &[u8]) -> Vec { + let mut c = id.to_vec(); + c.extend_from_slice(&(data.len() as u32).to_le_bytes()); + c.extend_from_slice(data); + if data.len() % 2 == 1 { + c.push(0); + } + c +} + +fn u24(v: u32) -> [u8; 3] { + let b = v.to_le_bytes(); + [b[0], b[1], b[2]] +} + +/// One frame of [`animated`]: the picture, where it sits on the canvas (even offsets, as WebP +/// stores them halved), its duration in milliseconds, and whether it is alpha-blended over the +/// canvas rather than replacing it. +struct Part { + image: Image, + at: [u32; 2], + duration_ms: u32, + blend: bool, +} + +fn part(c: [u8; 4], w: u32, h: u32, duration_ms: u32) -> Part { + Part { image: solid(c, w, h), at: [0, 0], duration_ms, blend: false } +} + +/// An animated WebP with a `w`x`h` canvas (transparent background) and one ANMF chunk per part, +/// each holding a lossless still from our own encoder. +fn animated(w: u32, h: u32, parts: &[Part]) -> Vec { + let mut body = b"WEBP".to_vec(); + let mut vp8x = vec![0x02 | 0x10, 0, 0, 0]; // animation and alpha flags + vp8x.extend_from_slice(&u24(w - 1)); + vp8x.extend_from_slice(&u24(h - 1)); + body.extend(chunk(b"VP8X", &vp8x)); + body.extend(chunk(b"ANIM", &[0, 0, 0, 0, 0, 0])); // background 0, loop forever + for p in parts { + let still = encode(&p.image, Format::WebP, &EncodeOptions::default()).unwrap(); + let at = still.windows(4).position(|w| w == b"VP8L").unwrap(); + let len = u32::from_le_bytes(still[at + 4..at + 8].try_into().unwrap()) as usize; + let mut anmf = Vec::new(); + anmf.extend_from_slice(&u24(p.at[0] / 2)); + anmf.extend_from_slice(&u24(p.at[1] / 2)); + anmf.extend_from_slice(&u24(p.image.width() - 1)); + anmf.extend_from_slice(&u24(p.image.height() - 1)); + anmf.extend_from_slice(&u24(p.duration_ms)); + anmf.push(if p.blend { 0 } else { 0x02 }); // bit 1: do not blend + anmf.extend(chunk(b"VP8L", &still[at + 8..at + 8 + len])); + body.extend(chunk(b"ANMF", &anmf)); + } + let mut out = b"RIFF".to_vec(); + out.extend_from_slice(&(body.len() as u32).to_le_bytes()); + out.extend(body); + out +} + +fn pixel(f: &AnimationFrame, x: u32, y: u32) -> [u8; 4] { + let rgba = f.image.to_rgba8(); + let i = ((y * f.image.width() + x) * 4) as usize; + rgba[i..i + 4].try_into().unwrap() +} + +#[test] +fn a_still_webp_gives_one_frame_equal_to_decode() { + let still = encode(&solid(RED, 6, 4), Format::WebP, &EncodeOptions::default()).unwrap(); + assert!(!webp_is_animated(&still)); + let f = frames(&still); + assert_eq!(f.len(), 1); + assert_eq!(f[0].delay_ms, 0); + assert_eq!(f[0].image, decode(&still).unwrap()); +} + +#[test] +fn a_one_frame_animation_is_not_animated() { + let a = animated(6, 4, &[part(RED, 6, 4, 70)]); + assert!(!webp_is_animated(&a)); + assert_eq!(frames(&a).len(), 1); +} + +#[test] +fn every_frame_comes_back_in_order_with_its_duration() { + let a = animated(6, 4, &[part(RED, 6, 4, 40), part(GREEN, 6, 4, 120), part(BLUE, 6, 4, 7)]); + assert!(webp_is_animated(&a)); + let f = frames(&a); + assert_eq!(f.iter().map(|f| f.delay_ms).collect::>(), [40, 120, 7], "WebP durations are milliseconds"); + for (f, c) in f.iter().zip([RED, GREEN, BLUE]) { + assert_eq!((f.image.width(), f.image.height()), (6, 4)); + assert_eq!(pixel(f, 0, 0), c); + assert_eq!(pixel(f, 5, 3), c); + assert_eq!(f.image.warnings, []); + } +} + +#[test] +fn a_smaller_frame_is_drawn_over_the_previous_one() { + let small = Part { image: solid(BLUE, 2, 2), at: [2, 2], duration_ms: 50, blend: true }; + let f = frames(&animated(6, 4, &[part(RED, 6, 4, 50), small])); + assert_eq!(f.len(), 2); + // Alpha blending rounds: within one level. + let near = |a: [u8; 4], b: [u8; 4]| a.iter().zip(b).all(|(x, y)| x.abs_diff(y) <= 1); + assert!(near(pixel(&f[1], 0, 0), RED), "outside the second frame the first shows through"); + assert!(near(pixel(&f[1], 2, 2), BLUE)); + assert!(near(pixel(&f[1], 3, 3), BLUE)); + assert!(near(pixel(&f[1], 4, 2), RED)); +} + +#[test] +fn a_damaged_later_frame_keeps_the_frames_before_it() { + let mut a = animated(6, 4, &[part(RED, 6, 4, 50), part(GREEN, 6, 4, 50), part(BLUE, 6, 4, 50)]); + // Break the last frame's VP8L signature byte; the container around it stays whole. + let last = a.windows(4).rposition(|w| w == b"VP8L").unwrap(); + a[last + 8] = 0; + let f = frames(&a); + assert_eq!(f.len(), 2); + assert_eq!(pixel(&f[0], 0, 0), RED); + assert_eq!(pixel(&f[1], 0, 0), GREEN); + assert_eq!(f[0].image.warnings, [DecodeWarning::Truncated { format: Format::WebP }]); +} + +#[test] +fn every_prefix_of_an_animated_webp_returns() { + let a = animated(5, 4, &[part(RED, 5, 4, 50), part(GREEN, 5, 4, 50), part(BLUE, 5, 4, 50)]); + for n in 0..a.len() { + let _ = decode_webp_frames(&a[..n], &DecodeOptions::default()); + } +} + +#[test] +fn dimension_limits_apply() { + let a = animated(4, 4, &[part(RED, 4, 4, 50), part(GREEN, 4, 4, 50)]); + let opts = DecodeOptions { limits: Limits { max_width: 3, ..Limits::default() }, ..Default::default() }; + assert!(matches!(decode_webp_frames(&a, &opts), Err(CodecError::LimitExceeded(_)))); +} + +#[test] +fn the_byte_budget_covers_all_frames_together() { + // One 4x4 RGBA frame is 64 bytes. The decoder's own canvas counts against the budget too, so + // two frames need more than 128; ten frames of the same size must not fit where two do. + let two = animated(4, 4, &[part(RED, 4, 4, 50), part(GREEN, 4, 4, 50)]); + let ten: Vec = (0..10).map(|i| part(if i % 2 == 0 { RED } else { BLUE }, 4, 4, 50)).collect(); + let ten = animated(4, 4, &ten); + let opts = DecodeOptions { limits: Limits { max_alloc: 256, ..Limits::default() }, ..Default::default() }; + assert_eq!(decode_webp_frames(&two, &opts).unwrap().len(), 2); + assert!(matches!(decode_webp_frames(&ten, &opts), Err(CodecError::LimitExceeded(_)))); +} diff --git a/crates/engine/src/video_cmds.rs b/crates/engine/src/video_cmds.rs index 2a9f8ae8b4..3bc3e97106 100644 --- a/crates/engine/src/video_cmds.rs +++ b/crates/engine/src/video_cmds.rs @@ -212,7 +212,7 @@ fn rasterize(s: &mut Session, _p: &Value) -> Result { /// Frames loaded from a file or an image sequence. struct Footage { frames: Vec, - /// The frame rate the footage itself implies (a single animated GIF's delays); `None` for + /// The frame rate the footage itself implies (a single animated GIF's or WebP's delays); `None` for /// stills and sequences, which carry none. fps: Option, } @@ -237,7 +237,14 @@ fn fps_from_delays(delays: &[u32]) -> Option { best.map(|(d, _)| 1000.0 / d as f32) } -/// Load frames from `path`: a single image (1 frame, or every frame of an animated GIF), or a +/// `photocraft_io::import_gif_frames` / `import_webp_frames`. +type ImportFrames = fn( + &str, + &[u8], + &mut dyn FnMut(photocraft_doc::Document, u32) -> std::result::Result<(), photocraft_io::IoError>, +) -> std::result::Result; + +/// Load frames from `path`: a single image (1 frame, or every frame of an animated GIF or WebP), or a /// directory of images (sorted = an image sequence). Each becomes a full raster frame in the /// document's format. fn load_frames(path: &str, fmt: photocraft_color::PixelFormat) -> Result { @@ -267,8 +274,14 @@ fn load_frames(path: &str, fmt: photocraft_color::PixelFormat) -> Result Some(photocraft_io::import_gif_frames as ImportFrames), + Some(photocraft_codecs::Format::WebP) if photocraft_codecs::webp_is_animated(&bytes) => Some(photocraft_io::import_webp_frames as ImportFrames), + _ => None, + }; + if let Some(import) = animation { + import(&name, &bytes, &mut |doc, delay_ms| { frames.push(crate::file_cmds::flattened(&doc, fmt)); delays.push(delay_ms); Ok(()) @@ -283,7 +296,7 @@ fn load_frames(path: &str, fmt: photocraft_color::PixelFormat) -> Result Result { /// milliseconds (a still GIF has one). One frame at a time becomes a document, so the caller /// can flatten and drop it. Returns the number of frames; stops at the first error `each` gives. pub fn import_gif_frames(name: &str, bytes: &[u8], each: &mut dyn FnMut(Document, u32) -> Result<(), IoError>) -> Result { - let frames = codecs::decode_gif_frames(bytes, &codecs::DecodeOptions::default())?; + import_frames(name, codecs::decode_gif_frames(bytes, &codecs::DecodeOptions::default())?, each) +} + +/// Every frame of a WebP as a single-layer document, as [`import_gif_frames`] does for a GIF: an +/// animation's frames composited onto its canvas, with their durations in milliseconds; a still +/// WebP is one frame. +pub fn import_webp_frames(name: &str, bytes: &[u8], each: &mut dyn FnMut(Document, u32) -> Result<(), IoError>) -> Result { + import_frames(name, codecs::decode_webp_frames(bytes, &codecs::DecodeOptions::default())?, each) +} + +fn import_frames(name: &str, frames: Vec, each: &mut dyn FnMut(Document, u32) -> Result<(), IoError>) -> Result { let n = frames.len(); for f in frames { each(image_to_document(name, &f.image)?.document, f.delay_ms)?; diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 49e4bb60e2..7be03bceeb 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -72,8 +72,8 @@ use photocraft_psd::{PsdError, PsdFile}; pub use adjust_map::ADJUSTMENT_KEYS; pub use flat::{ - DEFAULT_LOSSY_WEBP_QUALITY, apply_file_encoding, document_to_image, file_encoding, import_gif_frames, import_tiff_page, tga_alpha_channel_to_transparency, - tga_default_bits, + DEFAULT_LOSSY_WEBP_QUALITY, apply_file_encoding, document_to_image, file_encoding, import_gif_frames, import_tiff_page, import_webp_frames, + tga_alpha_channel_to_transparency, tga_default_bits, }; pub use psd_export::{PsdExportOptions, document_to_psd, document_to_psd_with}; pub use psd_import::{psd_to_document, psd_to_document_with}; diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 5229842b6e..2c93f6766d 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-13 · **Change:** minor (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-14 · **Change:** minor (animated WebP frames load into video layers, #1198; earlier: 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 @@ -29,7 +29,7 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity. | PNG | R/W | yes | yes | 8/16-bit, ICC/text; APNG first frame only; alpha channels on open (#2225); palette PNGs open in Indexed Color mode with their transparent entry (#4804) | | GIF | R/W | yes | yes | File › Open keeps the first frame; New Video Layer from File and Video Frames to Layers take every frame, with the timeline rate from the delays (#572). Per-frame delays and keeping the frames in `.pcraft` are not done | | BMP | R/W | yes | yes | 8-bit; palette BMPs open in Indexed Color mode | -| WebP | R/W | yes | yes | Lossless and our own lossy VP8 encoder; ICC/EXIF/XMP; Save keeps an opened file lossy (at its quality) or lossless (#2399) | +| WebP | R/W | yes | yes | Lossless and our own lossy VP8 encoder; ICC/EXIF/XMP; Save keeps an opened file lossy (at its quality) or lossless (#2399). File › Open keeps an animation's first frame; New Video Layer from File and Video Frames to Layers take every frame, with the timeline rate from the durations (#1198) | | AVIF | R/W | no (#463) | optional feature, off by default | | | JPEG XL | R/W | inside DNG only | no | | | HEIF/HEIC | R/W(HEIF) | yes (pure-Rust heic-rs, `heif` feature, on in releases) | no | 8/16-bit | @@ -142,6 +142,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Animated WebP: New Video Layer from File, Replace Footage, Reload Frame and Video Frames to Layers take every frame, composited, with the timeline rate from the durations (#1198) | | 2026-10-13 | minor | Gradient Map adjustment layers are driven by the W3C luminosity (0.3, 0.59, 0.11), not the Rec. 601 grey: rgb8 and rgb16 gradient-map.psd pass; Photoshop-oracle floor 145 → 147 of 258 | | 2026-10-13 | minor | PSD: a "Saving Merged Transparency" global block (`Mtrn`, `Mt16`, `Mt32`; Photoshop's 32-bit documents keep a positive layer count) marks the merged image's first extra channel as its transparency: opening such a file no longer adds a "Transparency" alpha channel, and the oracle compares psd-tools transparentbg.psd's alpha (83.7 % → 0.9 % of pixels off; its shape edges remain) | | 2026-10-13 | minor | Palette PNGs open in Indexed Color mode (#4804) | From 5834d960038aa115d69fd3710d1b1b628a7f5049 Mon Sep 17 00:00:00 2001 From: Jason Xiang <77497570+0x6Ason@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:52:14 -0700 Subject: [PATCH 08/23] fix(codecs): respect uncompressed DDS row pitch --- crates/codecs/README.md | 5 +- crates/codecs/src/codecs/dds.rs | 51 +++++++++++----- crates/codecs/tests/dds.rs | 101 ++++++++++++++++++++++++++++++++ 3 files changed, 140 insertions(+), 17 deletions(-) diff --git a/crates/codecs/README.md b/crates/codecs/README.md index 7486d46504..8a0deb7acf 100644 --- a/crates/codecs/README.md +++ b/crates/codecs/README.md @@ -70,8 +70,9 @@ embedded table (and a PNG's transparent entry) so `photocraft-io` can open them * **DDS.** The top mip level of a 2D texture: uncompressed pixels described by channel masks (8, 16, 24 or 32 bits, RGB or luminance, with or without alpha) or BC1/BC2/BC3 (DXT1/DXT3/DXT5), - with the legacy header or the DX10 one. Everything opens as U8; the other mip levels are - ignored. Cubemaps, texture arrays, volume textures, premultiplied DXT2/DXT4 and other formats + with the legacy header or the DX10 one. Uncompressed row padding follows the header's + `DDSD_PITCH` stride; without that flag, rows are tightly packed. Everything opens as U8; + the other mip levels are ignored. Cubemaps, texture arrays, volume textures, premultiplied DXT2/DXT4 and other formats (BC4–BC7, float) are refused with `CodecError::Unsupported`. Writing is not provided. * **FITS.** The image in the primary HDU, or the first `IMAGE` extension, is read; `NAXIS3 = 3` diff --git a/crates/codecs/src/codecs/dds.rs b/crates/codecs/src/codecs/dds.rs index efe93c7f2e..a493d65305 100644 --- a/crates/codecs/src/codecs/dds.rs +++ b/crates/codecs/src/codecs/dds.rs @@ -14,6 +14,7 @@ const HEADER: usize = 128; /// DDS_HEADER_DXT10, present when the FourCC is `DX10`. const DX10_HEADER: usize = 20; +const DDSD_PITCH: u32 = 0x8; const DDSD_DEPTH: u32 = 0x80_0000; const DDPF_ALPHAPIXELS: u32 = 0x1; const DDPF_FOURCC: u32 = 0x4; @@ -106,15 +107,29 @@ impl Encoding { } } - /// Bytes of the top mip level. - fn level_size(&self, width: u32, height: u32) -> Option { - let (w, h) = (usize::try_from(width).ok()?, usize::try_from(height).ok()?); - let block = match self { - Encoding::Masks { bytes, .. } => return w.checked_mul(*bytes)?.checked_mul(h), - Encoding::Bc1 => 8, - Encoding::Bc2 | Encoding::Bc3 => 16, + /// Stored row stride and byte size of the top mip level (block rows for BC). + fn level_layout(&self, width: u32, height: u32, pitch: Option) -> Result<(usize, usize), CodecError> { + let overflow = || bad("pixel data size overflow"); + let (w, h) = (usize::try_from(width).map_err(|_| overflow())?, usize::try_from(height).map_err(|_| overflow())?); + let (row_bytes, rows) = match self { + Encoding::Masks { bytes, .. } => { + let packed = w.checked_mul(*bytes).ok_or_else(overflow)?; + let row_bytes = match pitch { + Some(pitch) => usize::try_from(pitch).map_err(|_| overflow())?, + None => packed, + }; + if row_bytes < packed { + return Err(bad(format!("row pitch {row_bytes} is smaller than the {packed}-byte pixel row"))); + } + (row_bytes, h) + } + Encoding::Bc1 | Encoding::Bc2 | Encoding::Bc3 => { + let block = if matches!(self, Encoding::Bc1) { 8 } else { 16 }; + (w.div_ceil(4).checked_mul(block).ok_or_else(overflow)?, h.div_ceil(4)) + } }; - w.div_ceil(4).checked_mul(h.div_ceil(4))?.checked_mul(block) + let size = row_bytes.checked_mul(rows).ok_or_else(overflow)?; + Ok((row_bytes, size)) } } @@ -184,19 +199,25 @@ pub(crate) fn decode(b: &[u8], limits: &Limits) -> Result { let (encoding, start) = if dx10 { (dx10_encoding(b)?, HEADER + DX10_HEADER) } else { (legacy_encoding(b)?, HEADER) }; let layout = encoding.layout(); limits.check(width, height, layout, SampleType::U8)?; - let size = encoding.level_size(width, height).ok_or_else(|| bad("pixel data size overflow"))?; + // The same header field is a total level size for BC, but a row stride when DDSD_PITCH is set. + let pitch = if flags & DDSD_PITCH != 0 { Some(le32(b, 20)?) } else { None }; + let (row_pitch, size) = encoding.level_layout(width, height, pitch)?; let data = b.get(start..).and_then(|d| d.get(..size)).ok_or_else(|| bad(format!("truncated pixel data: {size} bytes needed")))?; let (w, h) = (width as usize, height as usize); let channels = layout.channels(); - let total = w.checked_mul(h).and_then(|n| n.checked_mul(channels)).ok_or_else(|| bad("pixel size overflow"))?; + let row_size = w.checked_mul(channels).ok_or_else(|| bad("pixel size overflow"))?; + let total = row_size.checked_mul(h).ok_or_else(|| bad("pixel size overflow"))?; let mut pixels = vec![0u8; total]; match encoding { Encoding::Masks { bytes, gray, fields } => { - for (src, dst) in data.chunks_exact(bytes).zip(pixels.chunks_exact_mut(channels)) { - let pixel = src.iter().rev().fold(0u32, |v, &byte| v << 8 | u32::from(byte)); - let channel = |i: usize| fields[i].map_or(0, |f| f.get(pixel)); - let values = if gray { [channel(0), channel(3), 0, 0] } else { [channel(0), channel(1), channel(2), channel(3)] }; - dst.copy_from_slice(&values[..channels]); + for (src_row, dst_row) in data.chunks_exact(row_pitch).zip(pixels.chunks_exact_mut(row_size)) { + // Stop at the output width, leaving any stored row padding unread. + for (src, dst) in src_row.chunks_exact(bytes).zip(dst_row.chunks_exact_mut(channels)) { + let pixel = src.iter().rev().fold(0u32, |v, &byte| v << 8 | u32::from(byte)); + let channel = |i: usize| fields[i].map_or(0, |f| f.get(pixel)); + let values = if gray { [channel(0), channel(3), 0, 0] } else { [channel(0), channel(1), channel(2), channel(3)] }; + dst.copy_from_slice(&values[..channels]); + } } } Encoding::Bc1 | Encoding::Bc2 | Encoding::Bc3 => { diff --git a/crates/codecs/tests/dds.rs b/crates/codecs/tests/dds.rs index 766555f6e5..9519d81331 100644 --- a/crates/codecs/tests/dds.rs +++ b/crates/codecs/tests/dds.rs @@ -6,6 +6,7 @@ const DDPF_ALPHAPIXELS: u32 = 0x1; const DDPF_FOURCC: u32 = 0x4; const DDPF_RGB: u32 = 0x40; const DDPF_LUMINANCE: u32 = 0x2_0000; +const DDSD_PITCH: u32 = 0x8; /// A DDS_HEADER for a single-level 2D texture, after the `DDS ` signature. fn header(width: u32, height: u32, pf_flags: u32, fourcc: &[u8; 4], bits: u32, masks: [u32; 4]) -> Vec { @@ -31,6 +32,11 @@ fn compressed(width: u32, height: u32, fourcc: &[u8; 4]) -> Vec { header(width, height, DDPF_FOURCC, fourcc, 0, [0; 4]) } +fn set_pitch(b: &mut [u8], pitch: u32) { + b[8..12].copy_from_slice(&(0x1007 | DDSD_PITCH).to_le_bytes()); + b[20..24].copy_from_slice(&pitch.to_le_bytes()); +} + /// The DX10 extension header after a `DX10` FourCC: format, dimension, misc flags, array size. fn dx10(width: u32, height: u32, dxgi: u32, dimension: u32, misc: u32, array: u32) -> Vec { let mut b = compressed(width, height, b"DX10"); @@ -169,6 +175,101 @@ fn decodes_packed_masks_and_luminance() { assert_eq!((im.layout(), im.data()), (ChannelLayout::GrayA, &[10u8, 20, 30, 40][..])); } +#[test] +fn decodes_padded_rgb_rows() { + // DWORD-aligned 24-bit rows: the fourth byte is padding, not the next pixel. + let mut b = header(1, 2, DDPF_RGB, &[0; 4], 24, [0xff, 0xff00, 0xff_0000, 0]); + set_pitch(&mut b, 4); + b.extend([255, 0, 0, 99, 0, 255, 0, 88]); + let im = decode(&b).unwrap(); + assert_eq!((im.dimensions(), im.layout()), ((1, 2), ChannelLayout::Rgb)); + assert_eq!(im.data(), &[255, 0, 0, 0, 255, 0]); + + // Padding can contain whole pixels; only the declared width is decoded. + let mut b = header(2, 2, DDPF_RGB, &[0; 4], 16, [0xf800, 0x07e0, 0x001f, 0]); + set_pitch(&mut b, 8); + for v in [RED, GREEN, WHITE, WHITE, BLUE, BLACK, WHITE, WHITE] { + b.extend(v.to_le_bytes()); + } + assert_eq!(decode(&b).unwrap().data(), &[255, 0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0]); +} + +#[test] +fn decodes_padded_luminance_rows() { + let mut b = header(2, 2, DDPF_LUMINANCE, &[0; 4], 8, [0xff, 0, 0, 0]); + set_pitch(&mut b, 4); + b.extend([10, 20, 99, 98, 30, 40, 97, 96]); + let im = decode(&b).unwrap(); + assert_eq!((im.layout(), im.data()), (ChannelLayout::Gray, &[10u8, 20, 30, 40][..])); + + let mut b = header(1, 2, DDPF_LUMINANCE | DDPF_ALPHAPIXELS, &[0; 4], 16, [0xff, 0, 0, 0xff00]); + set_pitch(&mut b, 4); + b.extend([10, 20, 99, 98, 30, 40, 97, 96]); + let im = decode(&b).unwrap(); + assert_eq!((im.layout(), im.data()), (ChannelLayout::GrayA, &[10u8, 20, 30, 40][..])); +} + +#[test] +fn decodes_padded_dx10_rows() { + let mut b = dx10(1, 2, 28, 3, 0, 1); // R8G8B8A8_UNORM + set_pitch(&mut b, 8); + b.extend([1, 2, 3, 4, 99, 98, 97, 96, 5, 6, 7, 8, 95, 94, 93, 92]); + assert_eq!(decode(&b).unwrap().data(), &[1, 2, 3, 4, 5, 6, 7, 8]); +} + +#[test] +fn ignores_pitch_without_the_pitch_flag() { + let mut b = header(1, 2, DDPF_RGB, &[0; 4], 24, [0xff, 0xff00, 0xff_0000, 0]); + b[20..24].copy_from_slice(&u32::MAX.to_le_bytes()); + b.extend([255, 0, 0, 0, 255, 0]); + assert_eq!(decode(&b).unwrap().data(), &[255, 0, 0, 0, 255, 0]); + + set_pitch(&mut b, 3); // An explicitly tightly packed row is also valid. + assert_eq!(decode(&b).unwrap().data(), &[255, 0, 0, 0, 255, 0]); +} + +#[test] +fn compressed_linear_size_is_not_a_row_pitch() { + let mut b = compressed(4, 8, b"DXT1"); + b[8..12].copy_from_slice(&(0x1007u32 | 0x8_0000).to_le_bytes()); // DDSD_LINEARSIZE + b[20..24].copy_from_slice(&16u32.to_le_bytes()); // Two 8-byte block rows. + b.extend(bc1_block(RED, RED, [0; 4])); + b.extend(bc1_block(BLUE, BLUE, [0; 4])); + let im = decode(&b).unwrap(); + assert!(im.data()[..64].as_chunks::<4>().0.iter().all(|p| p == &[255, 0, 0, 255])); + assert!(im.data()[64..].as_chunks::<4>().0.iter().all(|p| p == &[0, 0, 255, 255])); +} + +#[test] +fn rejects_pitch_smaller_than_a_pixel_row() { + for pitch in [0, 1, 2] { + let mut b = header(1, 2, DDPF_RGB, &[0; 4], 24, [0xff, 0xff00, 0xff_0000, 0]); + set_pitch(&mut b, pitch); + b.extend([255, 0, 0, 0, 255, 0]); + assert!(matches!(decode(&b), Err(CodecError::Malformed { .. })), "pitch {pitch}"); + } +} + +#[test] +fn rejects_truncated_padded_rows() { + let mut b = header(1, 2, DDPF_RGB, &[0; 4], 24, [0xff, 0xff00, 0xff_0000, 0]); + set_pitch(&mut b, 4); + b.extend([255, 0, 0, 99, 0, 255, 0, 88]); + assert!(decode(&b).is_ok()); + for n in 128..b.len() { + assert!(matches!(decode(&b[..n]), Err(CodecError::Malformed { .. })), "decoded {n} of {} bytes", b.len()); + } +} + +#[test] +fn rejects_huge_pitch_without_allocating() { + let mut b = header(1, 2, DDPF_LUMINANCE, &[0; 4], 8, [0xff, 0, 0, 0]); + set_pitch(&mut b, u32::MAX); + b.extend([10, 20]); + // On 32-bit targets pitch * height overflows; on 64-bit the payload is too short. + assert!(matches!(decode(&b), Err(CodecError::Malformed { .. }))); +} + #[test] fn decodes_dx10_headers() { let mut b = dx10(1, 1, 28, 3, 0, 1); // R8G8B8A8_UNORM From 3264c2fa6f3118ecbbaf4d9bef13383a7226ad21 Mon Sep 17 00:00:00 2001 From: Jason Xiang <77497570+0x6Ason@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:08:10 -0700 Subject: [PATCH 09/23] fix(format): ignore accidental ZIP directory signatures in comments --- crates/format/src/zip.rs | 67 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 65 insertions(+), 2 deletions(-) diff --git a/crates/format/src/zip.rs b/crates/format/src/zip.rs index 9ec92c2fba..5057e52168 100644 --- a/crates/format/src/zip.rs +++ b/crates/format/src/zip.rs @@ -127,11 +127,24 @@ impl<'a> ZipReader<'a> { if bytes.len() < 22 { return Err(FormatError::corrupt("not a zip archive (too short)")); } - // End of central directory: scan back over a possible comment. + // A comment may itself contain the signature. Only accept a record whose + // declared comment reaches the end of the archive (APPNOTE 4.3.16). let min = bytes.len().saturating_sub(22 + 65535); let eocd = (min..=bytes.len() - 22) .rev() - .find(|&i| u32_at(bytes, i).ok() == Some(EOCD_SIG)) + .find(|&i| { + if u32_at(bytes, i).ok() != Some(EOCD_SIG) { + return false; + } + let comment_end = u16_at(bytes, i + 20).ok().and_then(|length| i.checked_add(22)?.checked_add(usize::from(length))); + let directory_end = u32_at(bytes, i + 16) + .ok() + .zip(u32_at(bytes, i + 12).ok()) + .and_then(|(offset, size)| usize::try_from(offset).ok()?.checked_add(usize::try_from(size).ok()?)); + // In the supported non-ZIP64 layout, the central directory immediately + // precedes this record. A zeroed signature in a comment is not an empty ZIP. + comment_end == Some(bytes.len()) && directory_end == Some(i) + }) .ok_or_else(|| FormatError::corrupt("zip end-of-central-directory not found"))?; let count = u16_at(bytes, eocd + 10)? as usize; let cd_size = u32_at(bytes, eocd + 12)? as usize; @@ -263,6 +276,56 @@ mod tests { assert!(matches!(r.read_by_name("a.txt", 3), Err(FormatError::LimitExceeded(_)))); } + fn archive_with_comment(comment: &[u8]) -> Vec { + let mut writer = ZipWriter::new(); + writer.add("manifest.json", b"project content").unwrap(); + let mut bytes = writer.finish().unwrap(); + let length_offset = bytes.len() - 2; + bytes[length_offset..].copy_from_slice(&u16::try_from(comment.len()).unwrap().to_le_bytes()); + bytes.extend_from_slice(comment); + bytes + } + + #[test] + fn directory_signature_inside_comment_does_not_hide_entries() { + for length in [22, 32, usize::from(u16::MAX)] { + let mut comment = vec![0; length]; + comment[..4].copy_from_slice(&EOCD_SIG.to_le_bytes()); + let bytes = archive_with_comment(&comment); + let reader = ZipReader::new(&bytes).unwrap(); + assert_eq!(reader.entries.len(), 1, "comment length {length}"); + assert_eq!(reader.read_by_name("manifest.json", 100).unwrap(), b"project content"); + } + } + + #[test] + fn archive_comments_support_empty_short_and_maximum_lengths() { + for length in [0, 1, 21, 22, 100, usize::from(u16::MAX)] { + let bytes = archive_with_comment(&vec![b'x'; length]); + let reader = ZipReader::new(&bytes).unwrap(); + assert_eq!(reader.read_by_name("manifest.json", 100).unwrap(), b"project content", "comment length {length}"); + } + } + + #[test] + fn central_directory_digital_signature_remains_supported() { + let mut bytes = archive_with_comment(b"signed archive"); + let eocd = bytes.len() - 22 - b"signed archive".len(); + let signature = [0x50, 0x4b, 0x05, 0x05, 3, 0, 1, 2, 3]; + let size = u32_at(&bytes, eocd + 12).unwrap() + signature.len() as u32; + bytes[eocd + 12..eocd + 16].copy_from_slice(&size.to_le_bytes()); + bytes.splice(eocd..eocd, signature); + let reader = ZipReader::new(&bytes).unwrap(); + assert_eq!(reader.read_by_name("manifest.json", 100).unwrap(), b"project content"); + } + + #[test] + fn truncated_archive_comment_is_rejected() { + let mut bytes = archive_with_comment(b"comment"); + bytes.pop(); + assert!(ZipReader::new(&bytes).is_err()); + } + #[test] fn crc_matches_bitwise_reference() { // Bit-at-a-time IEEE CRC-32 as the oracle, over lengths that cross From f7109db763abc5d494c0ec10903ea6fece55d6e2 Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:52:24 +0300 Subject: [PATCH 10/23] Affinity import: budget decoded pixels across the document (#5599) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An Affinity container with many embedded images decoded (and kept) one surface each with no cumulative cap, so a few-megabyte file could exhaust memory: each image decodes up to 256 MB and resamples into another surface of the same order, and nothing tracked the total. The import now shares a 1 GiB decoded-pixel budget: an embedded image is charged its decoded w×h×4 bytes, resampling is charged its staging buffer, and an image the budget cannot cover is left out with a warning, the same shape as the existing per-image caps. ORA and KRA budget their decoded pixels the same way. Regression tests cover the budget arithmetic and both charge sites (an embedded PNG decode and an RGBA resample) with tiny in-memory fixtures. --- crates/io/src/affinity.rs | 100 +++++++++++++++++++++++++++++++++++++- 1 file changed, 99 insertions(+), 1 deletion(-) diff --git a/crates/io/src/affinity.rs b/crates/io/src/affinity.rs index efba868be0..bd82fc3d8c 100644 --- a/crates/io/src/affinity.rs +++ b/crates/io/src/affinity.rs @@ -28,6 +28,10 @@ pub const EXTENSIONS: &[&str] = &["af", "afphoto", "afdesign", "afpub"]; const MAX_PIXELS: u64 = 64 << 20; /// Canvas side in pixels. const MAX_SIDE: f64 = 300_000.0; +/// Decoded pixel bytes allowed across the whole import: the caps above are per call, so a small +/// container of embedded images could still decode (and keep) a surface each until the process +/// dies. ORA and KRA budget their decoded pixels the same way. +const MAX_PIXEL_BYTES: u64 = 1 << 30; pub fn has_extension(name: &str) -> bool { name.rsplit(['/', '\\']).next().and_then(|n| n.rsplit_once('.')).is_some_and(|(_, ext)| EXTENSIONS.iter().any(|e| ext.eq_ignore_ascii_case(e))) @@ -82,6 +86,20 @@ struct Builder { /// Document pixels (in the canvas) per Affinity document pixel: always 1; spreads are offset. format: PixelFormat, canvas: Rect, + /// Decoded pixel bytes left for embedded images and their resampled surfaces. + pixel_budget: u64, +} + +/// Deduct `cost` bytes from a decoded-pixel budget; `false` (leaving the budget alone) when it +/// cannot cover them. +fn charge(budget: &mut u64, cost: u64) -> bool { + match budget.checked_sub(cost) { + Some(rest) => { + *budget = rest; + true + } + None => false, + } } fn affine(a: af::Affine) -> Affine { @@ -108,7 +126,7 @@ impl Builder { let mut doc = Document::new(name, size, ColorMode::Rgb, SampleType::U8); doc.resolution_dpi = d.dpi as f32; let canvas = Rect::new(0, 0, size.width as i32, size.height as i32); - Ok(Self { doc, warnings: Vec::new(), format: PixelFormat::RGBA8, canvas }) + Ok(Self { doc, warnings: Vec::new(), format: PixelFormat::RGBA8, canvas, pixel_budget: MAX_PIXEL_BYTES }) } fn warn(&mut self, w: &str) { @@ -494,6 +512,10 @@ impl Builder { match photocraft_codecs::decode_with(bytes, &opts) { Ok(d) => { let (w, h) = d.dimensions(); + if !charge(&mut self.pixel_budget, u64::from(w).saturating_mul(u64::from(h)).saturating_mul(4)) { + self.warn("the document's embedded images need more pixel memory than the import allows; the rest were left out"); + return None; + } Some((w, h, d.to_rgba8())) } Err(_) => { @@ -548,6 +570,10 @@ impl Builder { let exact = a == 1.0 && d == 1.0 && b == 0.0 && c == 0.0 && e.fract() == 0.0 && f.fract() == 0.0; let inv = m.inverse()?; let (rw, rh) = (r.width() as usize, r.height() as usize); + if !charge(&mut self.pixel_budget, (rw as u64).saturating_mul(rh as u64).saturating_mul(channels as u64)) { + self.warn("the document's images need more pixel memory than the import allows; the rest were left out"); + return None; + } let mut buf = vec![0u8; rw * rh * channels]; let at = |x: i64, y: i64, ch: usize| -> f64 { if x < 0 || y < 0 || x >= i64::from(w) || y >= i64::from(h) { @@ -619,3 +645,75 @@ fn color(c: ap::Color) -> Color { } } } + +#[cfg(test)] +mod tests { + use super::*; + + /// A transparent 64×64 page holding one image node per entry; the identity transform maps + /// each image onto the page's top-left corner. + fn affinity_document(images: Vec<(u32, u32, Pixels)>) -> af::Document { + let nodes = images + .into_iter() + .enumerate() + .map(|(i, (width, height, pixels))| af::Node { + class: photocraft_affinity::synth::tag(b"ImgN"), + name: format!("Image {i}"), + visible: true, + locked: false, + opacity: 1.0, + blend: Blend::Normal, + kind: Kind::Image(af::Image { width, height, pixels, transform: af::Affine::IDENTITY }), + mask: None, + pixel_mask: None, + children: Vec::new(), + }) + .collect(); + af::Document { + dpi: 72.0, + spreads: vec![af::Spread { bounds: af::Rect { x0: 0.0, y0: 0.0, x1: 64.0, y1: 64.0 }, pages: Vec::new(), transparent: true, nodes }], + saved_by: None, + warnings: Vec::new(), + } + } + + #[test] + fn the_pixel_budget_refuses_what_does_not_fit() { + let mut budget = 100u64; + assert!(charge(&mut budget, 60)); + assert!(!charge(&mut budget, 60), "a later image cannot overdraw the import's budget"); + assert_eq!(budget, 40, "a refused charge leaves the budget alone"); + } + + #[test] + fn images_past_the_pixel_budget_are_left_out() { + // Two 64×64 RGBA images cost 16 KiB each when resampled; a 16 KiB budget admits one and + // the second is dropped with a warning instead of being staged anyway. + let d = affinity_document(vec![(64, 64, Pixels::Rgba8(vec![0; 64 * 64 * 4])), (64, 64, Pixels::Rgba8(vec![0; 64 * 64 * 4]))]); + let mut b = Builder::new("budget", &d).unwrap(); + b.pixel_budget = 64 * 64 * 4; + b.document(&d).unwrap(); + assert_eq!(b.doc.layers.len(), 1, "the image past the budget must not import"); + assert!(b.warnings.iter().any(|w| w.contains("pixel memory")), "{:?}", b.warnings); + } + + #[test] + fn embedded_images_past_the_pixel_budget_are_not_decoded_into_it() { + // An 8×8 embedded PNG costs 256 bytes decoded and 256 more resampled. + let png = { + let img = photocraft_codecs::Image::from_u8(8, 8, photocraft_codecs::ChannelLayout::Rgba, vec![0; 8 * 8 * 4]).unwrap(); + photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &photocraft_codecs::EncodeOptions::default()).unwrap() + }; + let d = affinity_document(vec![(8, 8, Pixels::Encoded(png))]); + let mut b = Builder::new("budget", &d).unwrap(); + b.pixel_budget = 8 * 8 * 4 - 1; + b.document(&d).unwrap(); + assert!(b.doc.layers.is_empty(), "the embedded image must not import past the budget"); + assert!(b.warnings.iter().any(|w| w.contains("pixel memory")), "{:?}", b.warnings); + // The same image imports when the budget covers the decode and the resample. + let mut ok = Builder::new("room", &d).unwrap(); + ok.pixel_budget = 8 * 8 * 4 * 2; + ok.document(&d).unwrap(); + assert_eq!(ok.doc.layers.len(), 1, "within the budget the image imports as a smart object"); + } +} From 4623c3068c5f0511104cec2acc4e2ab2e9eba54b Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Fri, 16 Oct 2026 00:20:51 +1100 Subject: [PATCH 11/23] Fix the detect chain after the main merge. The shared closing brace left the Cineon test doubled when the two magic checks were stitched together. --- crates/codecs/src/format.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/codecs/src/format.rs b/crates/codecs/src/format.rs index 771d7a64e5..6d89bf2414 100644 --- a/crates/codecs/src/format.rs +++ b/crates/codecs/src/format.rs @@ -335,7 +335,6 @@ pub fn detect(bytes: &[u8]) -> Option { if crate::codecs::iff::looks_like_iff(b) { return Some(Format::Iff); } - } if b.starts_with(b"SIMPLE =") { return Some(Format::Fits); } From 1e9c0b43f6e611d34bb46671a769a1a99a703260 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Fri, 16 Oct 2026 00:21:14 +1100 Subject: [PATCH 12/23] Format the merged open-extension lists. --- apps/photocraft-web/src/web.rs | 4 ++-- apps/photocraft/src/services.rs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs index 90e080d2a1..e98d6cfbb7 100644 --- a/apps/photocraft-web/src/web.rs +++ b/apps/photocraft-web/src/web.rs @@ -18,8 +18,8 @@ type Inbox = Arc)>>>; /// Photoshop brushes (.abr), gradients (.grd) and swatches (.aco, .ase), which go to the preset libraries. const OPEN_EXTS: &[&str] = &[ "pcraft", "pdn", "ora", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", - "pbm", "pgm", "ppm", "pam", "pfm", "dds", "fits", "fit", "fts", "dpx", "cin", "iff", "lbm", "ilbm", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", - "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub", + "pbm", "pgm", "ppm", "pam", "pfm", "dds", "fits", "fit", "fts", "dpx", "cin", "iff", "lbm", "ilbm", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", + "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub", ]; const SVG_EXTS: &[&str] = &["svg", "svgz"]; const CANVAS_ID: &str = "photocraft_canvas"; diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index d44748cdbd..1453d550b5 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -21,8 +21,8 @@ mod recovery_tests; /// (.kys), which go to the preset libraries and Edit › Keyboard Shortcuts. const OPEN_EXTS: &[&str] = &[ "pcraft", "pdn", "ora", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", - "pbm", "pgm", "ppm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "cin", "iff", "lbm", "ilbm", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", - "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub", + "pbm", "pgm", "ppm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "cin", "iff", "lbm", "ilbm", "heic", "heif", "hif", "dng", "cr2", + "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub", ]; const SVG_EXTS: &[&str] = &["svg", "svgz"]; From 0c439b66e25a2b45695c3f58591426247705717a Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Fri, 16 Oct 2026 00:25:36 +1100 Subject: [PATCH 13/23] Format and compile the merged DICOM reader. --- apps/photocraft-web/src/web.rs | 4 ++-- apps/photocraft/src/services.rs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs index 83b8c20060..d9c5c7641a 100644 --- a/apps/photocraft-web/src/web.rs +++ b/apps/photocraft-web/src/web.rs @@ -18,8 +18,8 @@ type Inbox = Arc)>>>; /// Photoshop brushes (.abr), gradients (.grd) and swatches (.aco, .ase), which go to the preset libraries. const OPEN_EXTS: &[&str] = &[ "pcraft", "pdn", "ora", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", - "pbm", "pgm", "ppm", "pam", "pfm", "dds", "fits", "fit", "fts", "dpx", "cin", "dcm", "dicom", "dc3", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", - "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub", + "pbm", "pgm", "ppm", "pam", "pfm", "dds", "fits", "fit", "fts", "dpx", "cin", "dcm", "dicom", "dc3", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", + "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub", ]; const SVG_EXTS: &[&str] = &["svg", "svgz"]; const CANVAS_ID: &str = "photocraft_canvas"; diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index 9cffaf2843..24e198fd98 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -21,8 +21,8 @@ mod recovery_tests; /// (.kys), which go to the preset libraries and Edit › Keyboard Shortcuts. const OPEN_EXTS: &[&str] = &[ "pcraft", "pdn", "ora", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", - "pbm", "pgm", "ppm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "cin", "dcm", "dicom", "dc3", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", - "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub", + "pbm", "pgm", "ppm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "cin", "dcm", "dicom", "dc3", "heic", "heif", "hif", "dng", "cr2", + "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub", ]; const SVG_EXTS: &[&str] = &["svg", "svgz"]; From b11223b272f4fb3f860c3d976b291e0eda4ec05b Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Thu, 15 Oct 2026 20:31:20 +0200 Subject: [PATCH 14/23] Codecs: save Cineon (.cin) film frames Write Kodak Cineon 4.5: the 1024-byte generic header (V4.5, one or three channels designated gray or R/G/B, 10 bits, code values 0-1023 over densities 0-2.048) and a 1024-byte film header left undefined, then the samples three to a big-endian 32-bit word, left justified (packing 5), lines on a word boundary. Samples are written as round(v * 1023 / 65535), so a frame opened from Cineon saves back with its code values; other images take the encode plan (alpha dropped, CMYK and Lab to RGB) and are quantized to 10 bits. Cineon leaves ASYMMETRIC_EXCEPTIONS, Save As offers .cin, and the codecs README, file-format parity and G10 are updated. --- apps/photocraft/src/services.rs | 1 + crates/codecs/README.md | 11 +++- crates/codecs/src/codecs/dpx.rs | 79 ++++++++++++++++++++++++- crates/codecs/src/format.rs | 9 +-- crates/codecs/src/lib.rs | 2 +- crates/codecs/tests/detect_caps.rs | 5 +- crates/codecs/tests/dpx.rs | 92 ++++++++++++++++++++++++++++++ crates/io/tests/flat.rs | 3 + docs/file-format-parity.md | 5 +- docs/gaps.md | 10 ++-- 10 files changed, 197 insertions(+), 20 deletions(-) diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index 9de1c475ea..09c1f0bcbc 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -68,6 +68,7 @@ const SAVE_FILTERS: &[(&str, &[&str])] = &[ ("OpenEXR", &["exr"]), ("FITS", &["fits", "fit"]), ("DPX", &["dpx"]), + ("Cineon", &["cin"]), ]; /// Non-document files the shell saves (Swatches panel exports): offered alone, so the dialog diff --git a/crates/codecs/README.md b/crates/codecs/README.md index d4f7094894..f897a5841d 100644 --- a/crates/codecs/README.md +++ b/crates/codecs/README.md @@ -47,7 +47,7 @@ let out = encode(&img, Format::Tiff, &EncodeOptions::default())?; | Radiance HDR | yes | yes | F32 | RGB | no | no | no | no | no | no | yes (RGBE) | `image` | | FITS | yes | yes | U8, U16, F32 | Gray, RGB | no | no | no | no | no | yes (header cards) | no | built in | | DPX | yes | yes | U8, U16 (10/12-bit code values read and written exactly through U16) | Gray, RGB, RGBA | yes | no | no | no | no | DPX header fields only (creator, project, copyright, creation time, transfer, colorimetric, frame position, frame rate, time code) | no | built in | -| Cineon | yes | **no** | U16 (10-bit code values) | Gray, RGB | no | no | no | no | no | no | n/a | built in | +| Cineon | yes | yes | U16 (10-bit code values read and written exactly through U16) | Gray, RGB | no | no | no | no | no | no | no | built in | | AVIF | **no** | only with feature `avif` | U8 | RGB, RGBA | yes | no | no | no | no | no | yes | `image` + `ravif` | | HEIF/HEIC | with feature `heif` | **no** | U8, U16 (10/12-bit decodes to U16) | RGB, RGBA | yes (auxiliary alpha) | yes (`colr` prof) | yes | yes | no | no | n/a | `heic-rs` | @@ -96,13 +96,18 @@ embedded table (and a PNG's transparent entry) so `photocraft-io` can open them Other descriptors, signed or run-length-encoded data and other bit depths return `CodecError::Unsupported` naming the field. -* **Cineon (read-only).** Kodak's 10-bit logarithmic film-scan format (the one DPX grew from), +* **Cineon.** Kodak's 10-bit logarithmic film-scan format (the one DPX grew from), read from the same Kodak header family: the generic 1024-byte header, big-endian, one (Gray) or three (RGB) channels, three 10-bit samples packed into each 32-bit word, lines on a word boundary. The 10-bit code values open as U16 `round(c × 65535 / 1023)`; the file's black/white reference points are a log-conversion choice and are not applied. A depth other than 10 bits or a channel count other than 1/3 returns `CodecError::Unsupported`; truncation errors rather than - panicking. There is no Cineon writer (`ASYMMETRIC_EXCEPTIONS`); save as DPX, TIFF or EXR. + panicking. Writing follows the Cineon 4.5 layout: a 1024-byte generic header (version `V4.5`, + one or three channels designated as universal-metric gray or R, G, B, 10 bits, data 0–1023 over + densities 0–2.048) and a 1024-byte film header left undefined, then the samples three to a + big-endian 32-bit word, left justified (packing 5), lines on a word boundary. Samples are written + as `round(v × 1023 / 65535)`, so a frame opened from Cineon keeps its code values; any other + image takes the encode plan (alpha dropped, CMYK and Lab to RGB) and is quantized to 10 bits. * **Camera raw files** (DNG, CR2, NEF, ARW… which are TIFF-structured) are recognised and refused with `CodecError::Unsupported`: they are sensor data, not flat images. `photocraft-raw` decodes diff --git a/crates/codecs/src/codecs/dpx.rs b/crates/codecs/src/codecs/dpx.rs index 091403d641..f93c79c928 100644 --- a/crates/codecs/src/codecs/dpx.rs +++ b/crates/codecs/src/codecs/dpx.rs @@ -21,6 +21,8 @@ //! Writing: one element, big-endian, unencoded, descriptor 6, 50 or 51; 8 and 16 bits with //! packing 0, 10 and 12 bits with packing 1 (method A). The bit depth follows the image //! (8 → 8, 16 → 16) unless [`EncodeOptions::dpx_bits`] picks one. +//! +//! Kodak Cineon (`.cin`), the format DPX grew from, is read and written here too (see its section). use crate::Format; use crate::error::CodecError; @@ -344,7 +346,7 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result // orientation (1 byte), the channel count (1 byte) and up to eight 28-byte channel descriptors // (the first carries the bits per pixel and the line/image dimensions). Image data is three // 10-bit samples packed into each 32-bit word, lines on a word boundary — the same layout as -// DPX's 10-bit packing method A, so the unpacking is shared. +// DPX's 10-bit packing method A, so the unpacking and packing are shared. /// The Cineon magic number (`802A5FD7` hex), big-endian. pub(crate) fn is_cineon(b: &[u8]) -> bool { @@ -409,6 +411,81 @@ pub(crate) fn decode_cineon(bytes: &[u8], limits: &Limits) -> Result Result, CodecError> { + let enc = |msg: String| CodecError::encode(Format::Cineon, msg); + let img = src.converted(plan.layout, plan.sample); + let (w, h) = img.dimensions(); + // Channel designators: 0 for black and white, 1–3 for red, green and blue. + let designators: &[u8] = match img.layout() { + ChannelLayout::Gray => &[0], + ChannelLayout::Rgb => &[1, 2, 3], + l => return Err(enc(format!("Cineon cannot store {l:?}"))), + }; + let samples = img.to_u16_samples().ok_or_else(|| enc(format!("cannot write {:?} samples at 10 bits", img.sample_type())))?; + let codes: Vec = samples.into_iter().map(|v| narrow(v, 1023)).collect(); + let per_line = (w as usize).checked_mul(designators.len()).ok_or_else(|| enc("image too large".into()))?; + if codes.len() != per_line.checked_mul(h as usize).ok_or_else(|| enc("image too large".into()))? { + return Err(enc("the image has the wrong number of samples".into())); + } + let data_len = line_bytes(per_line, 10, 1).and_then(|l| l.checked_mul(h as usize)).ok_or_else(|| enc("image too large".into()))?; + let total = + CINEON_HEADER_LEN.checked_add(data_len).and_then(|t| u32::try_from(t).ok()).ok_or_else(|| enc("image too large for a Cineon file (4 GiB)".into()))?; + + let mut wr = Writer { h: vec![0; CINEON_HEADER_LEN] }; + // File information. + wr.u32(0, 0x802A_5FD7); + wr.u32(4, CINEON_HEADER_LEN as u32); + wr.u32(8, CINEON_GENERIC_LEN); + wr.u32(12, CINEON_HEADER_LEN as u32 - CINEON_GENERIC_LEN); + wr.u32(16, 0); + wr.u32(20, total); + wr.string((24, 8), "V4.5"); + // Image information: default orientation, then each channel's descriptor; the unused ones, + // the white point and the primaries are undefined. + wr.u8(192, 0); + wr.u8(193, designators.len() as u8); + wr.undefined(CINEON_CHANNEL0, 420); + for (i, &designator) in designators.iter().enumerate() { + let c = CINEON_CHANNEL0 + 28 * i; + wr.u8(c, 0); + wr.u8(c + 1, designator); + wr.u8(c + 2, 10); + wr.u8(c + 3, 0); + wr.u32(c + 4, w); + wr.u32(c + 8, h); + // Code values 0–1023 stand for printing densities 0–2.048, the Cineon convention. All + // four are 32-bit floats here (DPX stores the data values as integers). + wr.u32(c + 12, 0f32.to_bits()); + wr.u32(c + 16, 0f32.to_bits()); + wr.u32(c + 20, 1023f32.to_bits()); + wr.u32(c + 24, 2.048f32.to_bits()); + } + // Pixel-interleaved, unsigned, positive, no line or channel padding. + wr.u8(681, 5); + // Image origination: pixel pitches and gamma unknown. + wr.undefined(972, 984); + // Film header: manufacturer, type, perforation offset, prefix, count, frame position and rate + // unknown; its strings stay empty. + wr.undefined(1024, 1036); + wr.undefined(1068, 1076); + + let mut out = wr.h; + out.try_reserve_exact(data_len).map_err(|_| enc("out of memory".into()))?; + for row in codes.chunks_exact(per_line.max(1)) { + pack_line(&mut out, row, 10); + } + Ok(out) +} + /// The kept header fields as text metadata, in [`KEYS`] order; undefined ones are left out. fn header_text(b: &[u8], o: Order, e: Element) -> Vec<(String, String)> { let mut text = Vec::new(); diff --git a/crates/codecs/src/format.rs b/crates/codecs/src/format.rs index fa2e1e6cb3..c00ad38805 100644 --- a/crates/codecs/src/format.rs +++ b/crates/codecs/src/format.rs @@ -33,7 +33,7 @@ pub enum Format { Fits, /// DPX (SMPTE 268M Digital Picture Exchange): luma, RGB or RGBA film and video frames. Dpx, - /// Kodak Cineon (`.cin`, the 10-bit film-scan format DPX grew from): read-only. + /// Kodak Cineon (`.cin`, the 10-bit film-scan format DPX grew from). Cineon, } @@ -80,11 +80,6 @@ pub const ASYMMETRIC_EXCEPTIONS: &[(Format, &str)] = &[ "AVIF encode uses ravif (pure Rust) but decoding requires dav1d (C); read stays unsupported \ until a pure-Rust AV1 decoder is viable. Only enabled with the non-default `avif` feature.", ), - ( - Format::Cineon, - "Cineon frames are imported for film work; this crate has no Cineon writer, so save an \ - imported frame as DPX, TIFF or EXR instead.", - ), ( Format::Heif, "HEIC decode uses heic-rs (pure Rust, in the optional photocraft-heif crate behind the \ @@ -266,7 +261,7 @@ pub fn caps(format: Format) -> FormatCaps { Format::Fits => FormatCaps { depths: &[S::U8, S::U16, S::F32], layouts: &[L::Gray, L::Rgb], alpha: false, text: true, ..base }, // `text`: the DPX header fields only (see `fidelity_warnings_with`). Format::Dpx => FormatCaps { depths: &[S::U8, S::U16], layouts: &[L::Gray, L::Rgb, L::Rgba], text: true, ..base }, - Format::Cineon => FormatCaps { read: true, write: false, depths: &[S::U16], layouts: &[L::Gray, L::Rgb], alpha: false, ..base }, + Format::Cineon => FormatCaps { depths: &[S::U16], layouts: &[L::Gray, L::Rgb], alpha: false, ..base }, } } diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index 86611599d5..4dccf3418e 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -255,7 +255,7 @@ pub fn encode(image: &Image, format: Format, opts: &EncodeOptions) -> Result Err(CodecError::unsupported(format, "DDS is import-only; save as PNG instead")), Format::Fits => fits::encode(image, plan, opts), Format::Dpx => dpx::encode(image, plan, opts), - Format::Cineon => Err(CodecError::unsupported(format, "Cineon is import-only; save as DPX, TIFF or EXR instead")), + Format::Cineon => dpx::encode_cineon(image, plan), Format::OpenExr => exr::encode(image, plan, opts), Format::Heif => Err(CodecError::unsupported(format, "encoding is not available in this build")), Format::Gif | Format::Bmp | Format::Tga | Format::Ico | Format::Qoi | Format::Hdr | Format::Avif => via_image::encode(format, image, plan, opts), diff --git a/crates/codecs/tests/detect_caps.rs b/crates/codecs/tests/detect_caps.rs index f2191e05f1..312920aebd 100644 --- a/crates/codecs/tests/detect_caps.rs +++ b/crates/codecs/tests/detect_caps.rs @@ -60,6 +60,7 @@ fn default_build_formats_all_symmetric() { Format::Hdr, Format::Fits, Format::Dpx, + Format::Cineon, ]; for f in expected { assert!(caps(f).read && caps(f).write, "{f:?}"); @@ -192,8 +193,8 @@ fn detect_magic_cineon() { b[0..4].copy_from_slice(&[0x80, 0x2A, 0x5F, 0xD7]); assert_eq!(detect(&b), Some(Format::Cineon)); assert_eq!(from_extension("scan.CIN"), Some(Format::Cineon)); - assert!(caps(Format::Cineon).read && !caps(Format::Cineon).write, "Cineon is read-only"); - assert!(ASYMMETRIC_EXCEPTIONS.iter().any(|(f, _)| *f == Format::Cineon)); + assert!(caps(Format::Cineon).read && caps(Format::Cineon).write, "Cineon is read and written"); + assert!(!ASYMMETRIC_EXCEPTIONS.iter().any(|(f, _)| *f == Format::Cineon)); // A bare magic is an error, never a panic. assert!(decode_as(Format::Cineon, &b).is_err()); } diff --git a/crates/codecs/tests/dpx.rs b/crates/codecs/tests/dpx.rs index 8fdeb398c3..6fbfb2db7b 100644 --- a/crates/codecs/tests/dpx.rs +++ b/crates/codecs/tests/dpx.rs @@ -384,3 +384,95 @@ fn unsupported_and_damaged_files_error_instead_of_panicking() { assert!(decode_as(Format::Dpx, &bytes).is_err(), "{what}"); } } + +// ---------------------------------------------------------------- Cineon (Kodak .cin) + +/// A Cineon 4.5 file built field by field: the 1024-byte generic header, a 1024-byte film header, +/// then `codes` (interleaved, top line first) three 10-bit values to a big-endian word, left +/// justified, each line on a word boundary. +fn cineon_bytes(channels: u8, width: u32, height: u32, codes: &[u16]) -> Vec { + let mut b = vec![0u8; 2048]; + b[0..4].copy_from_slice(&0x802A_5FD7u32.to_be_bytes()); + b[4..8].copy_from_slice(&2048u32.to_be_bytes()); + b[193] = channels; + b[198] = 10; + b[200..204].copy_from_slice(&width.to_be_bytes()); + b[204..208].copy_from_slice(&height.to_be_bytes()); + for line in codes.chunks(width as usize * usize::from(channels)) { + for chunk in line.chunks(3) { + let w = chunk.iter().enumerate().fold(0u32, |w, (i, &c)| w | (u32::from(c) << (22 - 10 * i as u32))); + b.extend(w.to_be_bytes()); + } + } + b +} + +/// Code values spread over 0..=1023, ends included. +fn cineon_codes(n: usize) -> Vec { + (0..n).map(|i| if i == 1 { 1023 } else { ((i as u64 * 2654435761) % 1024) as u16 }).collect() +} + +/// Reads back what we wrote: (channels, width, height, code values), from the header's own fields. +fn cineon_written(b: &[u8]) -> (u8, u32, u32, Vec) { + let off = be32_at(b, 4) as usize; + let (nc, w, h) = (b[193], be32_at(b, 200), be32_at(b, 204)); + let per_line = w as usize * usize::from(nc); + let stride = per_line.div_ceil(3) * 4; + let mut codes = Vec::new(); + for y in 0..h as usize { + let line = &b[off + y * stride..]; + codes.extend((0..per_line).map(|i| ((be32_at(line, i / 3 * 4) >> (22 - 10 * (i % 3) as u32)) & 0x3FF) as u16)); + } + (nc, w, h, codes) +} + +#[test] +fn cineon_frames_saved_as_cineon_keep_their_code_values_and_reopen_identically() { + // Widths that leave partly filled words at the end of each line. + for (channels, layout) in [(1u8, ChannelLayout::Gray), (3, ChannelLayout::Rgb)] { + let codes = cineon_codes(5 * 3 * usize::from(channels)); + let img = decode(&cineon_bytes(channels, 5, 3, &codes)).unwrap(); + assert_eq!(img.layout(), layout); + let out = encode(&img, Format::Cineon, &EncodeOptions::default()).unwrap_or_else(|e| panic!("{layout:?}: {e}")); + assert_eq!(detect(&out), Some(Format::Cineon), "{layout:?}"); + assert_eq!(cineon_written(&out), (channels, 5, 3, codes), "{layout:?}"); + assert_eq!(decode(&out).unwrap(), img, "{layout:?}"); + } +} + +#[test] +fn cineon_header_follows_the_kodak_layout() { + let img = decode(&cineon_bytes(3, 4, 2, &cineon_codes(24))).unwrap(); + let out = encode(&img, Format::Cineon, &EncodeOptions::default()).unwrap(); + let f32_at = |at: usize| f32::from_bits(be32_at(&out, at)); + // Generic and film header lengths, user data, total size, version. + assert_eq!((be32_at(&out, 4), be32_at(&out, 8), be32_at(&out, 12), be32_at(&out, 16)), (2048, 1024, 1024, 0)); + assert_eq!(be32_at(&out, 20) as usize, out.len()); + assert_eq!(&out[24..29], b"V4.5\0"); + // Upright, three channels, each the universal metric's red, green, blue at 10 bits. + assert_eq!((out[192], out[193]), (0, 3)); + for (i, colour) in [1u8, 2, 3].into_iter().enumerate() { + let c = 196 + 28 * i; + assert_eq!(&out[c..c + 3], &[0, colour, 10], "channel {i}"); + assert_eq!((be32_at(&out, c + 4), be32_at(&out, c + 8)), (4, 2), "channel {i}"); + // Minimum and maximum data value and quantity, all four 32-bit floats. + assert_eq!((f32_at(c + 12), f32_at(c + 16), f32_at(c + 20), f32_at(c + 24)), (0.0, 0.0, 1023.0, 2.048), "channel {i}"); + } + // Pixel interleave, 32-bit words left justified, unsigned, positive, no padding. + assert_eq!(&out[680..692], &[0, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + // Two lines of twelve samples: four words each. + assert_eq!(out.len(), 2048 + 2 * 16); +} + +#[test] +fn any_image_saves_as_ten_bit_cineon() { + // 16-bit samples land on the nearest 10-bit code; colour with alpha drops it, gray stays gray. + let wide = Image::from_u16(3, 1, ChannelLayout::Rgba, &[0, 32, 65535, 65535, 30000, 40000, 50000, 0, 1, 2, 3, 4]).unwrap(); + let out = encode(&wide, Format::Cineon, &EncodeOptions::default()).unwrap(); + let to_code = |v: u16| (f64::from(v) * 1023.0 / 65535.0).round() as u16; + let want: Vec = [0u16, 32, 65535, 30000, 40000, 50000, 1, 2, 3].into_iter().map(to_code).collect(); + assert_eq!(cineon_written(&out), (3, 3, 1, want)); + assert!(fidelity_warnings(&wide, Format::Cineon).contains(&FidelityWarning::AlphaDiscarded)); + let gray8 = Image::from_u8(2, 1, ChannelLayout::Gray, vec![0, 255]).unwrap(); + assert_eq!(cineon_written(&encode(&gray8, Format::Cineon, &EncodeOptions::default()).unwrap()), (1, 2, 1, vec![0, 1023])); +} diff --git a/crates/io/tests/flat.rs b/crates/io/tests/flat.rs index b82b41ffc3..decaa2498b 100644 --- a/crates/io/tests/flat.rs +++ b/crates/io/tests/flat.rs @@ -56,6 +56,9 @@ codec_rt!(fits_rgb32, "fits", ColorMode::Rgb, SampleType::F32, false, 0.0); codec_rt!(dpx_gray8, "dpx", ColorMode::Grayscale, SampleType::U8, false, 0.0); codec_rt!(dpx_rgb16, "dpx", ColorMode::Rgb, SampleType::U16, false, 0.0); codec_rt!(dpx_rgba16, "dpx", ColorMode::Rgb, SampleType::U16, true, 0.0); +// Cineon stores 10-bit code values: within half a 10-bit step. +codec_rt!(cin_gray16, "cin", ColorMode::Grayscale, SampleType::U16, false, 0.5 / 1023.0); +codec_rt!(cin_rgb16, "cin", ColorMode::Rgb, SampleType::U16, false, 0.5 / 1023.0); fn indexed_bmp_8() -> Vec { const FILE_HEADER_SIZE: usize = 14; diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index ba74623709..8bd047d5a8 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-13 · **Change:** minor (earlier: 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-15 · **Change:** minor (Cineon write: 10-bit gray/RGB; earlier: 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 @@ -43,7 +43,7 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity. | Generic EPS / AI | R (rasterize) | no | no | | | SVG | R (rasterize) | yes, as editable shape layers or a vector smart object | no | Better than Photoshop for editing; gradients approximated (FILE-215-9) | | JPEG 2000 | R/W | no (#1047) | no | | -| Cineon / DPX | R (DPX), R/W (CIN) | DPX: luma, RGB, RGBA at 8/10/12/16 bits, both byte orders (#1049); Cineon: 10-bit gray/RGB, read-only | DPX: 8/10/12/16 bits (`dpxBits`, `--dpx-bits`); Cineon no | Writes DPX, which Photoshop only reads; header fields (creator, project, copyright, transfer, frame rate, time code…) round-trip; Cineon read-only, no RLE | +| Cineon / DPX | R (DPX), R/W (CIN) | DPX: luma, RGB, RGBA at 8/10/12/16 bits, both byte orders (#1049); Cineon: 10-bit gray/RGB | DPX: 8/10/12/16 bits (`dpxBits`, `--dpx-bits`); Cineon: 10-bit log gray/RGB (Save As `.cin`) | Writes DPX, which Photoshop only reads; header fields (creator, project, copyright, transfer, frame rate, time code…) round-trip; a Cineon frame saved as Cineon keeps its 10-bit code values; no RLE | | DICOM | R/W | no | no | | | PCX | R/W | no | no | | | Pixar | R/W | no | no | | @@ -142,6 +142,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | Cineon write: Save As `.cin` stores 10-bit log gray/RGB frames (Cineon 4.5 header, three samples to a 32-bit word); a Cineon frame saved back keeps its code values | | 2026-10-14 | minor | PSD smart filter masks: Photoshop stores a smart object's filtered pixels without its filter mask and mixes the unfiltered ones (`FEid`) back in; import now mixes them and export writes them that way, so sf-filter-mask-hard, -gradient and sf-stack-blend-and-mask pass; Photoshop-oracle floor 185 → 188 of 258 | | 2026-10-13 | minor | Lossy DNG (compression 34892) decodes: 8-bit baseline JPEG tiles and strips, CFA and LinearRaw, partial edge tiles; the stored values take the normal levels and LinearizationTable (FILE-215-11, lossy DNG part) | | 2026-10-14 | minor | Corpus floors raised to the measured results of the CMYK, Lab, 32-bit, Levels, Threshold, Posterize, feather and pass-through fixes landed together: Photoshop-authored 165 → 185 of 258, io 146 → 148 of 170, psd-tools 242 → 249 of 309 | diff --git a/docs/gaps.md b/docs/gaps.md index faf0884c89..71b2317d56 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-13 · **Change:** minor (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-15 · **Change:** minor (G10: Cineon write; 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 @@ -225,12 +225,13 @@ what an agent can't finish alone. - **Missing:** JPEG Options dialog (FILE-215-19), Photoshop PDF read and its vector/text/multi-page write (FILE-215-10, #2541; a single flattened page now saves via Save As / Save a Copy / CLI, 2026-10-11, #3401), AVIF read (#463) and default-build AVIF write, JPEG XL, JPEG 2000 (#1047), - Cineon write (read landed), DICOM, EPS/DCS, Pixar, IFF, WBMP, MPO/JPS, PICT, Scitex CT, Photoshop + DICOM, EPS/DCS, Pixar, IFF, WBMP, MPO/JPS, PICT, Scitex CT, Photoshop Raw; layered TIFF extra alpha channels (FILE-215-7); spot plates (FILE-215-17); .atn, .asl, .shc, .csh presets (FILE-215-15); C2PA (#1050). - **Detail:** [`file-format-parity.md`](file-format-parity.md). -- **Progress (2026-10-13):** Cineon (`.cin`) 10-bit gray/RGB frames open read-only, sharing the - DPX header/unpacking (`crates/codecs/src/codecs/dpx.rs`); writing is still open. +- **Progress (2026-10-15):** Cineon (`.cin`) 10-bit gray/RGB frames open and save (Save As + `.cin`), sharing the DPX header/packing (`crates/codecs/src/codecs/dpx.rs`); a Cineon frame saved + back keeps its code values. - **Estimate:** 120–220 h. ## Hardware gaps @@ -270,6 +271,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | G10: Cineon write landed (10-bit log gray/RGB, Save As `.cin`) | | 2026-10-14 | minor | G1: a PSD smart object's filter mask shows when the file opens (Photoshop stores the filtered pixels unmasked); export stores them that way too; Photoshop-oracle floor 185 → 188 of 258 | | 2026-10-13 | minor | G14: D90 image area measured (4288 × 2848 at (10, 10)); gap remains open | | 2026-10-14 | minor | Corpus floors raised to the measured results of the CMYK, Lab, 32-bit, Levels, Threshold, Posterize, feather and pass-through fixes landed together: Photoshop-authored 165 → 185 of 258, io 146 → 148 of 170, psd-tools 242 → 249 of 309 | From d5535f35174d048861c9c9f21cfeb4b29e1b4142 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sat, 17 Oct 2026 03:08:00 +1100 Subject: [PATCH 15/23] Correct IFF chunky decoding and preserve bitmap transparency. --- crates/codecs/README.md | 13 ++-- crates/codecs/src/codecs/iff.rs | 113 +++++++++++++++++++++++------ crates/codecs/src/format.rs | 2 +- crates/codecs/tests/detect_caps.rs | 2 + 4 files changed, 100 insertions(+), 30 deletions(-) diff --git a/crates/codecs/README.md b/crates/codecs/README.md index 958d71d888..4cb0c0b4f2 100644 --- a/crates/codecs/README.md +++ b/crates/codecs/README.md @@ -48,7 +48,7 @@ let out = encode(&img, Format::Tiff, &EncodeOptions::default())?; | FITS | yes | yes | U8, U16, F32 | Gray, RGB | no | no | no | no | no | yes (header cards) | no | built in | | DPX | yes | yes | U8, U16 (10/12-bit code values read and written exactly through U16) | Gray, RGB, RGBA | yes | no | no | no | no | DPX header fields only (creator, project, copyright, creation time, transfer, colorimetric, frame position, frame rate, time code) | no | built in | | Cineon | yes | **no** | U16 (10-bit code values) | Gray, RGB | no | no | no | no | no | no | n/a | built in | -| Amiga IFF (ILBM/PBM) | yes | **no** | U8 | RGB | no | no | no | no | no | no | n/a | built in | +| Amiga IFF (ILBM/PBM) | yes | **no** | U8 | RGB, RGBA | yes | no | no | no | no | no | n/a | built in | | AVIF | **no** | only with feature `avif` | U8 | RGB, RGBA | yes | no | no | no | no | no | yes | `image` + `ravif` | | HEIF/HEIC | with feature `heif` | **no** | U8, U16 (10/12-bit decodes to U16) | RGB, RGBA | yes (auxiliary alpha) | yes (`colr` prof) | yes | yes | no | no | n/a | `heic-rs` | @@ -105,11 +105,12 @@ embedded table (and a PNG's transparent entry) so `photocraft-io` can open them a channel count other than 1/3 returns `CodecError::Unsupported`; truncation errors rather than panicking. There is no Cineon writer (`ASYMMETRIC_EXCEPTIONS`); save as DPX, TIFF or EXR. -* **Amiga IFF (read-only).** `FORM ... ILBM` (interleaved) and `FORM ... PBM ` (planar) bitmaps - from the published IFF/ILBM layout: 1–8 bitplanes decoded through the `CMAP` palette (a grey - ramp when there is none), masking 0/1/2, and both stored (0) and ByteRun1/PackBits (1) - compression. Other bit-plane counts or compressions return `CodecError::Unsupported`; a - malformed chunk length or run errors rather than panicking. There is no IFF writer; save as PNG. +* **Amiga IFF (read-only).** `FORM ... ILBM` stores interleaved 1–8 bitplanes; + `FORM ... PBM ` stores 8-bit chunky palette indices with even-padded rows. Both support + stored and ByteRun1 compression, a `CMAP` palette (grey ramp when absent, black for missing + entries), and transparent-colour masking. ILBM also supports a separate mask plane. HAM/EHB + viewport modes, PBM mask planes, other bit depths and compressions return + `CodecError::Unsupported`. There is no IFF writer; save as PNG. * **Camera raw files** (DNG, CR2, NEF, ARW… which are TIFF-structured) are recognised and refused with `CodecError::Unsupported`: they are sensor data, not flat images. `photocraft-raw` decodes diff --git a/crates/codecs/src/codecs/iff.rs b/crates/codecs/src/codecs/iff.rs index 421d55c894..cae046f3b7 100644 --- a/crates/codecs/src/codecs/iff.rs +++ b/crates/codecs/src/codecs/iff.rs @@ -1,4 +1,4 @@ -//! Amiga IFF/ILBM reader (EA "Interchange File Format", "InterLeaved BitMap" and the planar +//! Amiga IFF/ILBM reader (EA "Interchange File Format", "InterLeaved BitMap" and the chunky //! "PBM " variant). Written from the published IFF/ILBM layout: a `FORM` chunk of four-byte //! chunks (`BMHD` bitmap header, `CMAP` palette, `CAMG` Amiga viewport mode, `BODY` pixel data). //! Read-only: modern saves should use PNG. @@ -32,7 +32,7 @@ fn be16(b: &[u8], at: usize) -> Option { Some(u16::from_be_bytes(s)) } -/// `FORM` + a bitmap type (`ILBM` interleaved, `PBM ` planar) at offset 8. +/// `FORM` + a bitmap type (`ILBM` interleaved, `PBM ` chunky) at offset 8. pub(crate) fn looks_like_iff(b: &[u8]) -> bool { b.get(0..4) == Some(b"FORM") && matches!(b.get(8..12), Some(b"ILBM" | b"PBM ")) } @@ -44,8 +44,9 @@ struct Header { planes: u8, masking: u8, compression: u8, - /// `ILBM` interleaves a row's planes; `PBM ` stores a whole plane before the next. - interleaved: bool, + transparent: u16, + /// `PBM ` stores one palette index per byte, padded to even row lengths. + chunky: bool, } /// One four-byte chunk's id and byte range. @@ -73,7 +74,7 @@ fn chunks(b: &[u8]) -> impl Iterator> { }) } -fn parse_header(b: &[u8], interleaved: bool) -> Result { +fn parse_header(b: &[u8], chunky: bool) -> Result { let bmhd = chunks(b).find(|c| &c.id == b"BMHD").ok_or_else(|| bad("no BMHD chunk"))?; let d = bmhd.data; if d.len() < 20 { @@ -96,7 +97,11 @@ fn parse_header(b: &[u8], interleaved: bool) -> Result { if compression > 1 { return Err(unsupported(format!("compression {compression} (only stored and ByteRun1)"))); } - Ok(Header { width, height, planes, masking, compression, interleaved }) + if chunky && (planes != 8 || masking == 1) { + return Err(unsupported("PBM requires 8-bit indices without a mask plane")); + } + let transparent = be16(d, 12).ok_or_else(|| bad("BMHD transparent colour"))?; + Ok(Header { width, height, planes, masking, compression, transparent, chunky }) } /// ByteRun1 / PackBits decode of one plane row into `out`; returns the bytes consumed. A `0x80` @@ -133,32 +138,43 @@ pub(crate) fn decode(b: &[u8], limits: &Limits) -> Result { if !looks_like_iff(b) { return Err(bad("not an IFF/ILBM file (no FORM ... ILBM)")); } - let interleaved = b.get(8..12) == Some(b"ILBM"); - let h = parse_header(b, interleaved)?; - limits.check(h.width, h.height, ChannelLayout::Rgb, SampleType::U8)?; + let h = parse_header(b, b.get(8..12) == Some(b"PBM "))?; + if let Some(camg) = chunks(b).find(|c| &c.id == b"CAMG") { + let mode = be32(camg.data, 0).ok_or_else(|| bad("short CAMG chunk"))?; + if mode & (0x80 | 0x800) != 0 { + return Err(unsupported("EHB and HAM viewport modes")); + } + } + let layout = if h.masking == 0 { ChannelLayout::Rgb } else { ChannelLayout::Rgba }; + limits.check(h.width, h.height, layout, SampleType::U8)?; // Palette: three bytes per colour; missing entries read as black. - let cmap = chunks(b).find(|c| &c.id == b"CMAP").map(|c| c.data); + let cmap = chunks(b).find(|c| &c.id == b"CMAP").map(|c| c.data).filter(|p| !p.is_empty()); let colors = 1usize << h.planes; let palette = |idx: usize| -> [u8; 3] { - cmap.and_then(|p| p.get(idx * 3..idx * 3 + 3)).map(|c| [c[0], c[1], c[2]]).unwrap_or({ - // No palette: a grey ramp across the index range. + if let Some(p) = cmap { + p.get(idx * 3..idx * 3 + 3).map(|c| [c[0], c[1], c[2]]).unwrap_or([0; 3]) + } else { let v = ((idx as u32 * 255) / (colors as u32 - 1).max(1)) as u8; [v, v, v] - }) + } }; let body = chunks(b).find(|c| &c.id == b"BODY").map(|c| c.data).ok_or_else(|| bad("no BODY chunk"))?; let (w, hh) = (h.width as usize, h.height as usize); // Each plane row is stored as a whole number of 16-bit words. - let plane_row = w.div_ceil(16) * 2; + let plane_row = if h.chunky { w.div_ceil(2) * 2 } else { w.div_ceil(16) * 2 }; // A masking type 1 bitmap carries one extra (mask) plane per row. - let rows_planes: usize = if h.masking == 1 { usize::from(h.planes) + 1 } else { usize::from(h.planes) }; + let rows_planes: usize = if h.chunky { 1 } else { usize::from(h.planes) + usize::from(h.masking == 1) }; // Decode every stored plane row up front, then recombine. let total_rows = hh.checked_mul(rows_planes).ok_or_else(|| CodecError::LimitExceeded("bitmap too large".into()))?; let total_bytes = total_rows.checked_mul(plane_row).ok_or_else(|| CodecError::LimitExceeded("bitmap too large".into()))?; - let mut stored = vec![0u8; total_bytes]; + if total_bytes as u64 > limits.max_alloc { + return Err(CodecError::LimitExceeded("bitmap storage exceeds max_alloc".into())); + } + Limits::check_plausible(total_bytes as u64, body.len(), if h.compression == 0 { 1 } else { 128 })?; + let mut stored = crate::options::alloc_zeroed::(total_bytes)?; let mut cursor = 0usize; for row in stored.chunks_mut(plane_row.max(1)).take(total_rows) { match h.compression { @@ -176,22 +192,24 @@ pub(crate) fn decode(b: &[u8], limits: &Limits) -> Result { } let mut out = Vec::new(); - out.try_reserve_exact(w.saturating_mul(hh).saturating_mul(3)).map_err(|_| CodecError::LimitExceeded("cannot allocate the image".into()))?; + out.try_reserve_exact(w.saturating_mul(hh).saturating_mul(layout.channels())).map_err(|_| CodecError::LimitExceeded("cannot allocate the image".into()))?; let planes = usize::from(h.planes); for y in 0..hh { for x in 0..w { - let index = if h.interleaved { - // Row-major, interleaved: row y, plane p sits at (y * rows_planes + p). - (0..planes).filter(|&p| bit_at(&stored, (y * rows_planes + p) * plane_row, x)).fold(0usize, |acc, p| acc | (1 << p)) + let index = if h.chunky { + usize::from(*stored.get(y * plane_row + x).ok_or_else(|| bad("PBM pixel outside BODY"))?) } else { - // Planar: plane p's rows come first, then the next plane. - (0..planes).filter(|&p| bit_at(&stored, (p * hh + y) * plane_row, x)).fold(0usize, |acc, p| acc | (1 << p)) + (0..planes).filter(|&p| bit_at(&stored, (y * rows_planes + p) * plane_row, x)).fold(0usize, |acc, p| acc | (1 << p)) }; let c = palette(index); out.extend_from_slice(&c); + if h.masking != 0 { + let opaque = if h.masking == 1 { bit_at(&stored, (y * rows_planes + planes) * plane_row, x) } else { index != usize::from(h.transparent) }; + out.push(if opaque { 255 } else { 0 }); + } } } - Image::from_u8(h.width, h.height, ChannelLayout::Rgb, out) + Image::from_u8(h.width, h.height, layout, out) } /// The bit for pixel `x` of a stored plane row at `base` (bits run MSB-first). @@ -228,6 +246,9 @@ mod tests { for c in palette { b.extend_from_slice(c); } + if palette.len() % 2 == 1 { + b.push(0); + } // BODY b.extend_from_slice(b"BODY"); b.extend_from_slice(&(body.len() as u32).to_be_bytes()); @@ -272,6 +293,52 @@ mod tests { assert_eq!(img.data(), &[0, 0, 0, 255, 255, 255]); } + #[test] + fn pbm_reads_chunky_indices_with_odd_width_padding() { + let pal = [[10, 20, 30], [40, 50, 60], [70, 80, 90]]; + for (compression, body) in [(0, vec![2, 0, 1, 255, 1, 2, 0, 255]), (1, vec![3, 2, 0, 1, 255, 3, 1, 2, 0, 255])] { + let mut b = ilbm(3, 2, 8, &pal, compression, &body); + b[8..12].copy_from_slice(b"PBM "); + let img = decode(&b, &Limits::default()).unwrap(); + assert_eq!(img.data(), &[70, 80, 90, 10, 20, 30, 40, 50, 60, 40, 50, 60, 70, 80, 90, 10, 20, 30]); + } + } + + #[test] + fn transparency_uses_the_mask_plane_or_transparent_index() { + for masking in [1, 2] { + let mut b = ilbm(2, 1, 1, &[[10, 20, 30], [40, 50, 60]], 0, if masking == 1 { &[0x40, 0, 0x40, 0] } else { &[0x40, 0] }); + b[29] = masking; + let img = decode(&b, &Limits::default()).unwrap(); + assert_eq!(img.layout(), ChannelLayout::Rgba); + assert_eq!(img.data(), &[10, 20, 30, 0, 40, 50, 60, 255]); + } + let mut b = ilbm(2, 1, 8, &[[10, 20, 30], [40, 50, 60]], 0, &[0, 1]); + b[8..12].copy_from_slice(b"PBM "); + b[29] = 2; + b[32..34].copy_from_slice(&1u16.to_be_bytes()); + assert_eq!(decode(&b, &Limits::default()).unwrap().data(), &[10, 20, 30, 255, 40, 50, 60, 0]); + } + + #[test] + fn rejects_ham_and_ehb_instead_of_returning_wrong_colours() { + for mode in [0x80u32, 0x800] { + let mut b = ilbm(1, 1, 1, &[], 0, &[0, 0]); + b.extend_from_slice(b"CAMG"); + b.extend_from_slice(&4u32.to_be_bytes()); + b.extend_from_slice(&mode.to_be_bytes()); + let len = (b.len() - 8) as u32; + b[4..8].copy_from_slice(&len.to_be_bytes()); + assert!(matches!(decode(&b, &Limits::default()), Err(CodecError::Unsupported { .. }))); + } + } + + #[test] + fn missing_palette_entries_are_black() { + let b = ilbm(1, 1, 2, &[[10, 20, 30]], 0, &[0x80, 0, 0x80, 0]); + assert_eq!(decode(&b, &Limits::default()).unwrap().data(), &[0, 0, 0]); + } + #[test] fn hostile_input_errors_not_panics() { // Truncations of a valid file never panic. diff --git a/crates/codecs/src/format.rs b/crates/codecs/src/format.rs index 6d89bf2414..1aa5b9e84f 100644 --- a/crates/codecs/src/format.rs +++ b/crates/codecs/src/format.rs @@ -274,7 +274,7 @@ pub fn caps(format: Format) -> FormatCaps { // `text`: the DPX header fields only (see `fidelity_warnings_with`). Format::Dpx => FormatCaps { depths: &[S::U8, S::U16], layouts: &[L::Gray, L::Rgb, L::Rgba], text: true, ..base }, Format::Cineon => FormatCaps { read: true, write: false, depths: &[S::U16], layouts: &[L::Gray, L::Rgb], alpha: false, ..base }, - Format::Iff => FormatCaps { read: true, write: false, depths: &[S::U8], layouts: &[L::Rgb], alpha: false, ..base }, + Format::Iff => FormatCaps { read: true, write: false, depths: &[S::U8], layouts: &[L::Rgb, L::Rgba], alpha: true, ..base }, } } diff --git a/crates/codecs/tests/detect_caps.rs b/crates/codecs/tests/detect_caps.rs index c0cd569e3a..87b2755cc4 100644 --- a/crates/codecs/tests/detect_caps.rs +++ b/crates/codecs/tests/detect_caps.rs @@ -206,6 +206,8 @@ fn detect_magic_iff() { assert_eq!(from_extension("art.LBM"), Some(Format::Iff)); assert_eq!(from_extension("art.ilbm"), Some(Format::Iff)); assert!(caps(Format::Iff).read && !caps(Format::Iff).write, "IFF is read-only"); + assert!(caps(Format::Iff).alpha); + assert!(caps(Format::Iff).layouts.contains(&photocraft_codecs::ChannelLayout::Rgba)); assert!(ASYMMETRIC_EXCEPTIONS.iter().any(|(f, _)| *f == Format::Iff)); // A bare FORM without a bitmap type is not IFF. assert_eq!(detect(b"FORM\0\0\0\x14AIFF"), None); From a78e9f05fca98315732ef18eccd5cd0e6581e496 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 17 Oct 2026 02:33:33 -0700 Subject: [PATCH 16/23] Save: ask before replacing a file that an added or lowercased extension names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The save dialog asks before replacing only the file it answers with. After it closes, pick_save can change that path: it gives a name without an extension the suggested file's extension (#5039), and Lowercase Extension changes CAT.PSD to CAT.psd. The save then wrote to the changed path at once, and a file there was replaced without a question. On Linux this happens when the Swatches panel exports to a folder that holds Swatches.aco (the one-type portal dialog shows and asks about "Swatches"), when CAT.PSD is typed where CAT.psd exists, and in rfd's zenity fallback dialog. When the path written differs from the dialog's answer and a file exists there, the save now asks "“{name}” already exists. Replace it?". Replace writes the file, and ui.dialog.confirm returns the save's result. Cancel writes nothing and ends the save like the dialog's Cancel, and Save in the close prompt of an untitled document returns to that prompt. PhotoCraft counts the file dialog as open until the question is answered. A name that reaches the same file, as CAT.PSD and CAT.psd do on Windows and macOS by default, is not asked about again. The new same_file service decides this: the desktop app compares device and inode on Unix and the canonical paths on Windows. The web has no file stamps and does not ask. The two new strings have rows in all 20 catalogs. --- apps/photocraft/src/services.rs | 37 +++++++++ crates/ui-egui/src/dialogs.rs | 8 +- crates/ui-egui/src/discard_ui.rs | 47 +++++++++++ crates/ui-egui/src/file_dialog.rs | 94 +++++++++++++++++++--- crates/ui-egui/src/file_dialog/tests.rs | 102 ++++++++++++++++++++++++ crates/ui-egui/src/i18n/cs.tsv | 2 + crates/ui-egui/src/i18n/de.tsv | 2 + crates/ui-egui/src/i18n/el.tsv | 2 + crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/fr.tsv | 2 + crates/ui-egui/src/i18n/hu.tsv | 2 + crates/ui-egui/src/i18n/id.tsv | 2 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/ko.tsv | 2 + crates/ui-egui/src/i18n/nl.tsv | 2 + crates/ui-egui/src/i18n/pl.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/sr.tsv | 2 + crates/ui-egui/src/i18n/tr.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/i18n/vi.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + crates/ui-egui/src/lib.rs | 7 ++ crates/ui-egui/src/replace_prompt.rs | 46 +++++++++++ crates/ui-egui/src/swatches_ui.rs | 26 ++++++ docs/control-protocol.md | 1 + docs/ui-dialogs.md | 2 + 30 files changed, 397 insertions(+), 13 deletions(-) create mode 100644 crates/ui-egui/src/replace_prompt.rs diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index d4e7a01553..92ce4840f5 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -298,6 +298,26 @@ fn write_atomic_progress(path: &Path, bytes: &[u8], progress: &dyn Fn(f32)) -> R photocraft_format::atomic_write_progress(path, bytes, progress).map_err(|e| e.to_string()) } +/// Whether `a` and `b` reach the same file: the same device and inode on Unix, the same final path +/// on Windows (where it holds the name's letter case as stored). False when either can't be read. +fn same_file(a: &Path, b: &Path) -> bool { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + match (std::fs::metadata(a), std::fs::metadata(b)) { + (Ok(a), Ok(b)) => a.dev() == b.dev() && a.ino() == b.ino(), + _ => false, + } + } + #[cfg(not(unix))] + { + match (std::fs::canonicalize(a), std::fs::canonicalize(b)) { + (Ok(a), Ok(b)) => a == b, + _ => false, + } + } +} + fn export_options(settings: &photocraft_ui_egui::ExportSettings) -> photocraft_io::ExportOptions { settings.io_options() } @@ -511,6 +531,7 @@ pub fn native(automation: Option) -> let meta = std::fs::metadata(path).ok()?; Some(photocraft_ui_egui::FileStamp { len: meta.len(), modified: meta.modified().ok() }) })), + same_file: Some(Box::new(|a: &str, b: &str| same_file(Path::new(a), Path::new(b)))), automation_read, automation_write, automation_open_pcraft, @@ -888,6 +909,22 @@ mod tests { assert_eq!(dialog_file_name("", true), None); } + /// A name in another letter case reaches the same file where the file system ignores case + /// (Windows, and macOS by default), and only there. Two files with the same contents are two. + #[test] + fn same_file_follows_the_file_system() { + let dir = std::env::temp_dir().join(format!("photocraft-same-file-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let (cat, dog, upper) = (dir.join("CAT.psd"), dir.join("dog.psd"), dir.join("CAT.PSD")); + std::fs::write(&cat, b"same").unwrap(); + std::fs::write(&dog, b"same").unwrap(); + assert!(same_file(&cat, &cat)); + assert!(!same_file(&cat, &dog)); + assert_eq!(same_file(&upper, &cat), upper.exists(), "the same file only where letter case is ignored"); + assert!(!same_file(&dir.join("missing.psd"), &cat)); + std::fs::remove_dir_all(&dir).unwrap(); + } + #[test] fn block_on_waits_for_a_wake_from_another_thread() { let (tx, rx) = std::sync::mpsc::channel::(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 43b4f3d3f9..0d76025828 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -322,6 +322,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { DialogKind::Command if crate::shape_dialog::owns(&fields) => crate::shape_dialog::body(app, ui, &mut fields), DialogKind::Command if crate::delete_layer_prompt::owns(&fields) => crate::delete_layer_prompt::body(ui, &mut fields), DialogKind::Command if crate::disk_change_prompt::owns(&fields) => crate::disk_change_prompt::body(ui, &fields), + DialogKind::Command if crate::replace_prompt::owns(&fields) => crate::replace_prompt::body(ui, &fields), DialogKind::Command if crate::actions::owns_insert_stop(&fields) => crate::actions::insert_stop_body(ui, &mut fields), DialogKind::Command if crate::new_layer_ui::owns(&fields) => crate::new_layer_ui::body(ui, &mut fields), DialogKind::Command if crate::mask_trash_prompt::owns(&fields) => crate::mask_trash_prompt::body(ui, &fields), @@ -405,7 +406,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { tl!("Create") } else if crate::delete_layer_prompt::owns(&d.fields) { tl!("Delete") - } else if crate::disk_change_prompt::owns(&d.fields) { + } else if crate::disk_change_prompt::owns(&d.fields) || crate::replace_prompt::owns(&d.fields) { tl!("Replace") } else if d.fields.contains_key("__export") { tl!("Export") @@ -621,6 +622,7 @@ pub fn confirm(app: &mut PhotocraftApp, id: u64) -> Result { DialogKind::Command if crate::shape_dialog::owns(&d.fields) => crate::shape_dialog::confirm(app, &d.fields), DialogKind::Command if crate::delete_layer_prompt::owns(&d.fields) => crate::delete_layer_prompt::confirm(app, &d.fields), DialogKind::Command if crate::disk_change_prompt::owns(&d.fields) => crate::disk_change_prompt::confirm(app, &d.fields), + DialogKind::Command if crate::replace_prompt::owns(&d.fields) => crate::replace_prompt::confirm(app), DialogKind::Command if crate::actions::owns_insert_stop(&d.fields) => crate::actions::insert_stop_confirm(app, &d.fields), DialogKind::Command if crate::new_layer_ui::owns(&d.fields) => crate::new_layer_ui::confirm(app, &d.fields), DialogKind::Command if crate::mask_trash_prompt::owns(&d.fields) => crate::mask_trash_prompt::confirm(app, &d.fields), @@ -679,6 +681,10 @@ pub fn cancel(app: &mut PhotocraftApp, id: u64) -> Result { if crate::open_prompt::owns(&closed.fields) { crate::open_prompt::answer(app, None)?; } + // Cancel on the question whether to replace a file writes nothing. + if crate::replace_prompt::owns(&closed.fields) { + crate::replace_prompt::cancel(app)?; + } app.ui.dialogs.retain(|d| !crate::layer_style::color_picker::child_of(&d.fields, id)); app.filter_preview = None; app.color_range = None; diff --git a/crates/ui-egui/src/discard_ui.rs b/crates/ui-egui/src/discard_ui.rs index 3dbf33c905..ac9d3b030e 100644 --- a/crates/ui-egui/src/discard_ui.rs +++ b/crates/ui-egui/src/discard_ui.rs @@ -878,6 +878,53 @@ mod tests { assert!(h.state().session.documents().is_empty()); } + /// Save on closing an untitled document, answered with `kept` while `kept.psd` exists: the + /// prompt waits for the question whether to replace it. Cancel brings the prompt back with + /// nothing written; Replace saves and closes the document. + #[test] + fn saving_from_the_prompt_waits_for_the_question_whether_to_replace() { + use crate::{FileDialogAnswer, FileDialogReply}; + use egui_kittest::kittest::Queryable; + use std::{cell::RefCell, rc::Rc}; + let written: Rc>> = Rc::default(); + let (w, waiting): (_, Rc>>) = (written.clone(), Rc::default()); + let held = waiting.clone(); + let mut app = app_with_docs(1); + make_dirty(&mut app, 0); + app.services.export = Some(Box::new(|_: &photocraft_doc::Document, _: &str, _: &crate::ExportSettings| Ok((b"out".to_vec(), Vec::new())))); + app.services.write = Some(Box::new(move |p: &str, _: &[u8]| { + w.borrow_mut().push(p.to_string()); + Ok(()) + })); + app.services.file_stamp = Some(Box::new(|p: &str| (p == "/pics/kept.psd").then_some(crate::FileStamp { len: 1, modified: None }))); + app.services.file_dialog = Some(Box::new(move |_, _, reply| *held.borrow_mut() = Some(reply))); + let mut h = prompt_for(app, "file.close", egui::os::OperatingSystem::Windows); + let save = |h: &mut Prompted| { + h.key_press(Key::Y); + h.run_steps(3); + waiting.borrow_mut().take().expect("the save dialog is open").send(Some(FileDialogAnswer::SaveTo("/pics/kept".into()))); + h.run_steps(2); + h.state().ui.dialogs.iter().find(|d| crate::replace_prompt::owns(&d.fields)).map(|d| d.id).expect("the save asks first") + }; + let id = save(&mut h); + assert!(h.state().file_dialog_open()); + assert!(h.query_by_label("(Y)es").is_none(), "the prompt waits behind the question"); + assert!(written.borrow().is_empty()); + + crate::dialogs::cancel(h.state_mut(), id).unwrap(); + h.run_steps(2); + assert!(written.borrow().is_empty(), "Cancel writes nothing"); + assert_eq!(docs_left(&h), Some(1)); + assert_eq!(focused(&h, &["(Y)es", "(N)o", "(C)ancel"]), Some("(Y)es"), "the prompt comes back"); + + let id = save(&mut h); + crate::dialogs::confirm(h.state_mut(), id).unwrap(); + h.run_steps(2); + assert_eq!(*written.borrow(), ["/pics/kept.psd"]); + assert!(h.state().discard.is_none()); + assert!(h.state().session.documents().is_empty()); + } + #[test] fn saving_from_the_prompt_waits_for_a_background_save_then_closes() { use egui_kittest::kittest::Queryable; diff --git a/crates/ui-egui/src/file_dialog.rs b/crates/ui-egui/src/file_dialog.rs index 7cdff50af9..7c9e83a0bb 100644 --- a/crates/ui-egui/src/file_dialog.rs +++ b/crates/ui-egui/src/file_dialog.rs @@ -73,12 +73,20 @@ pub type FileDialogFn = Box, /// What to do with the answer (`None`: cancelled). type Then = Box) -> Result>; +/// The path a save writes to for the path the save dialog answered (see [`saved_path`]). +type SavedPath = Box String>; + /// The file dialog in progress. pub(crate) struct Pending { /// Not shown yet: it is shown at the end of the frame, which has the window to parent it to. request: Option, answer: Option>>, then: Then, + /// For a save dialog, the path the save writes to for the dialog's answer. + saved_path: Option, + /// The save dialog's answer while the app asks whether to replace the file it writes to + /// (`replace_prompt`). + held: Option, } /// The error of a cancelled dialog: not reported, since backing out is the user's choice. @@ -115,7 +123,7 @@ impl PhotocraftApp { Some(FileDialogAnswer::Unavailable(why)) => Err(why), Some(a) => then(app, a), }); - self.file_dialog = Some(Pending { request: Some(request), answer: None, then }); + self.file_dialog = Some(Pending { request: Some(request), answer: None, then, saved_path: None, held: None }); Ok(json!({ "fileDialog": kind })) } @@ -130,7 +138,9 @@ impl PhotocraftApp { } /// Ask where to save: `then` gets the chosen path, with a lowercase extension when - /// Preferences ▸ File Handling › Lowercase Extension is on (its default). + /// Preferences ▸ File Handling › Lowercase Extension is on (its default). When that, or the + /// extension added to a bare name, changes the path to one where a file exists, the app asks + /// whether to replace it before `then` runs (`replace_prompt`). /// /// A bare file name (export dialogs, an untitled document) starts in the folder the active /// document was last saved or exported to through a dialog, else beside the document @@ -153,13 +163,10 @@ impl PhotocraftApp { // extensions are hidden, #5039) gets the suggested file's extension, as Photoshop's // Save As appends the extension of the chosen file type. let suggested_ext = suggested.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()); - self.ask_file(FileDialogRequest::Save { suggested: suggested.to_string_lossy().into_owned() }, move |app, answer| match answer { + let ext = suggested_ext.clone(); + let asked = self.ask_file(FileDialogRequest::Save { suggested: suggested.to_string_lossy().into_owned() }, move |app, answer| match answer { FileDialogAnswer::SaveTo(path) => { - let path = Self::lowercased_extension(app, path); - let path = match &suggested_ext { - Some(ext) if Path::new(&path).extension().is_none() => Path::new(&path).with_extension(ext).to_string_lossy().into_owned(), - _ => path, - }; + let path = saved_path(app, &path, suggested_ext.as_deref()); // The web answers a download name, which has no folder to remember. if let Some(doc) = doc && let Some(dir) = Path::new(&path).parent().filter(|p| !p.as_os_str().is_empty()) @@ -169,7 +176,32 @@ impl PhotocraftApp { then(app, path) } _ => Err(UNEXPECTED.into()), - }) + })?; + if let Some(p) = self.file_dialog.as_mut() { + p.saved_path = Some(Box::new(move |app, path| saved_path(app, path, ext.as_deref()))); + } + Ok(asked) + } + + /// Whether a save to `path` replaces a file the save dialog didn't ask about: the dialog + /// answered `answered`, and [`saved_path`] changed it. A file at `path` that + /// [`Services::same_file`](crate::Services::same_file) finds is also the file at `answered` (the + /// same name in another letter case on a file system that ignores case, as Windows and macOS do + /// by default) counts as asked about. Never on the web, which has no file stamps. + fn replaces_unasked(&self, answered: &str, path: &str) -> bool { + let Some(stamp) = self.services.file_stamp.as_ref() else { return false }; + answered != path && stamp(path).is_some() && !self.services.same_file.as_ref().is_some_and(|same| same(answered, path)) + } + + /// The question whether to replace a file is answered. Replace writes the save dialog's answer + /// as if the question had not been asked and returns what the save returns, its failure + /// included. Cancel ends the save like the dialog's Cancel. + pub(crate) fn answer_replace(&mut self, replace: bool) -> Result { + let held = self.file_dialog.as_mut().and_then(|p| p.held.take()).ok_or("no save is waiting to replace a file")?; + match self.finish_file_dialog(replace.then_some(held)) { + Err(e) if e == CANCELLED => Ok(json!({ "replaced": false })), + r => r, + } } /// `X.PSD` → `X.psd`, only the extension: the stem and the folders keep the user's spelling. @@ -275,6 +307,14 @@ impl PhotocraftApp { /// continues the action that asked once the answer is in. Its errors go to the status bar. pub fn poll_file_dialog(&mut self, ctx: &egui::Context, parent: Option<&eframe::Frame>) { let Some(p) = self.file_dialog.as_mut() else { return }; + if p.held.is_some() { + // The question whether to replace answers through `answer_replace`. Closed without an + // answer, it counts as Cancel. + if !self.ui.dialogs.iter().any(|d| crate::replace_prompt::owns(&d.fields)) { + let _ = self.answer_replace(false); + } + return; + } if let Some(request) = p.request.take() { let (tx, rx) = mpsc::channel(); p.answer = Some(rx); @@ -288,13 +328,43 @@ impl PhotocraftApp { // The reply was dropped unanswered: the dialog couldn't be shown. Some(Err(TryRecvError::Disconnected)) | None => None, }; - let Some(Pending { then, .. }) = self.file_dialog.take() else { return }; - if let Err(e) = then(self, answer) + if let Some(FileDialogAnswer::SaveTo(answered)) = &answer + && let Some(path) = self.file_dialog.as_ref().and_then(|p| p.saved_path.as_ref()).map(|written| written(self, answered)) + && self.replaces_unasked(answered, &path) + { + crate::replace_prompt::open(self, answered, &path); + if let Some(p) = self.file_dialog.as_mut() { + p.answer = None; + p.held = answer; + } + return; + } + let _ = self.finish_file_dialog(answer); + } + + /// Ends the file dialog in progress: its action continues with `answer`, and its errors go to + /// the status bar. Returns the action's result. + fn finish_file_dialog(&mut self, answer: Option) -> Result { + let Some(Pending { then, .. }) = self.file_dialog.take() else { return Err(CANCELLED.into()) }; + let r = then(self, answer); + if let Err(e) = &r && e != CANCELLED { - self.ui.status = e; + self.ui.status = e.clone(); self.ui.status_error = true; } + r + } +} + +/// The path a save writes to for the path the save dialog answered: its extension lowercased +/// when Preferences ▸ File Handling › Lowercase Extension is on, and a name without one given +/// `suggested_ext`, the suggested file's. +fn saved_path(app: &PhotocraftApp, answered: &str, suggested_ext: Option<&str>) -> String { + let path = PhotocraftApp::lowercased_extension(app, answered.to_string()); + match suggested_ext { + Some(ext) if Path::new(&path).extension().is_none() => Path::new(&path).with_extension(ext).to_string_lossy().into_owned(), + _ => path, } } diff --git a/crates/ui-egui/src/file_dialog/tests.rs b/crates/ui-egui/src/file_dialog/tests.rs index 6a3ccfcf9e..e08d576c01 100644 --- a/crates/ui-egui/src/file_dialog/tests.rs +++ b/crates/ui-egui/src/file_dialog/tests.rs @@ -433,6 +433,108 @@ fn save_paths_without_an_extension_get_the_suggested_one() { assert_eq!(*written.borrow(), ["/pics/cat.psd", "/pics/dog.png"]); } +/// The question whether to replace a file (`replace_prompt`), when one is open. +fn replace_prompt(app: &PhotocraftApp) -> Option { + app.ui.dialogs.iter().find(|d| crate::replace_prompt::owns(&d.fields)).map(|d| d.id) +} + +/// File › Save As, answered with `path`. +fn save_as(app: &mut PhotocraftApp, open: &Open, path: &str) { + let ctx = egui::Context::default(); + menus::invoke(app, &ctx, "file.saveAs", json!({})).unwrap(); + app.poll_file_dialog(&ctx, None); + answer(open, Some(FileDialogAnswer::SaveTo(path.into()))); + app.poll_file_dialog(&ctx, None); +} + +/// The save dialog asked only about `cat`. The extension added to it names a file that exists, +/// so the save asks before replacing that file: Cancel writes nothing, Replace writes it. +#[test] +fn a_save_asks_before_replacing_the_file_its_added_extension_names() { + let ctx = egui::Context::default(); + let (mut app, open, written) = app(); + let disk = on_disk(&mut app); + changed_by_another_app(&disk, "/pics/cat.psd"); + save_as(&mut app, &open, "/pics/cat"); + let id = replace_prompt(&app).expect("the save asks first"); + let fields = &app.ui.dialogs.iter().find(|d| d.id == id).unwrap().fields; + assert!(fields["message"].as_str().unwrap().contains("“cat.psd”"), "{fields:?}"); + assert!(fields["detail"].as_str().unwrap().contains("“cat”"), "{fields:?}"); + assert!(written.borrow().is_empty(), "nothing is written before the answer"); + app.poll_file_dialog(&ctx, None); + assert!(app.file_dialog_open(), "the save waits for the answer"); + assert!(written.borrow().is_empty()); + + crate::dialogs::cancel(&mut app, id).unwrap(); + assert!(written.borrow().is_empty(), "Cancel writes nothing"); + assert!(!app.file_dialog_open() && !app.ui.status_error); + assert_eq!(app.session.active().unwrap().path, None); + + // A question closed without an answer counts as Cancel. + save_as(&mut app, &open, "/pics/cat"); + app.ui.close_dialog(replace_prompt(&app).expect("asked again")); + app.poll_file_dialog(&ctx, None); + assert!(written.borrow().is_empty() && !app.file_dialog_open()); + + // Replace answers with the save's failure. + save_as(&mut app, &open, "/pics/cat"); + let write = app.services.write.replace(Box::new(|_: &str, _: &[u8]| Err("disk full".into()))); + let id = replace_prompt(&app).expect("asked again"); + let e = crate::dialogs::confirm(&mut app, id).unwrap_err(); + assert!(e.contains("disk full") && app.ui.status_error, "{e}"); + app.services.write = write; + + save_as(&mut app, &open, "/pics/cat"); + let id = replace_prompt(&app).expect("asked again"); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert_eq!(*written.borrow(), ["/pics/cat.psd"]); + assert_eq!(app.session.active().unwrap().path.as_deref(), Some("/pics/cat.psd")); + assert!(!app.file_dialog_open() && replace_prompt(&app).is_none()); +} + +/// Lowercase Extension changes `CAT.PSD` to `CAT.psd`. Where letter case tells two files apart +/// (Linux), the dialog asked only about `CAT.PSD`; where it doesn't (Windows and macOS by +/// default), both names reach the same file, which the dialog asked about. +#[test] +fn a_save_asks_before_replacing_the_file_its_lowercased_extension_names() { + { + // Two files, even with the same size and modification time. + let (mut app, open, written) = app(); + let disk = on_disk(&mut app); + for name in ["/pics/CAT.psd", "/pics/CAT.PSD"] { + changed_by_another_app(&disk, name); + } + app.services.same_file = Some(Box::new(|a: &str, b: &str| a == b)); + save_as(&mut app, &open, "/pics/CAT.PSD"); + let id = replace_prompt(&app).expect("the save asks first"); + assert!(written.borrow().is_empty()); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert_eq!(*written.borrow(), ["/pics/CAT.psd"]); + } + let (mut app, open, written) = app(); + let disk = on_disk(&mut app); + for name in ["/pics/CAT.psd", "/pics/CAT.PSD"] { + changed_by_another_app(&disk, name); + } + app.services.same_file = Some(Box::new(|a: &str, b: &str| a.eq_ignore_ascii_case(b))); + save_as(&mut app, &open, "/pics/CAT.PSD"); + assert!(replace_prompt(&app).is_none(), "the dialog asked about this file"); + assert_eq!(*written.borrow(), ["/pics/CAT.psd"]); +} + +/// The save dialog asked about the file it answered: a bare name with no file under the added +/// extension, and a full name, are written at once. +#[test] +fn a_save_writes_at_once_when_the_dialog_asked_about_the_file() { + let (mut app, open, written) = app(); + let disk = on_disk(&mut app); + changed_by_another_app(&disk, "/pics/dog.png"); + save_as(&mut app, &open, "/pics/cat"); + save_as(&mut app, &open, "/pics/dog.png"); + assert!(replace_prompt(&app).is_none()); + assert_eq!(*written.borrow(), ["/pics/cat.psd", "/pics/dog.png"]); +} + #[test] fn save_and_export_dialogs_start_beside_the_document() { let (mut app, open, _) = app(); diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index f50c0249b7..d12c09fb5c 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3818,3 +3818,5 @@ navigator Zoom Zvětšení GPU Memory (MB, 0 = Automatic) Paměť GPU (MB, 0 = Automaticky) {name} was changed by another app after it was opened or saved here. Soubor {name} změnila jiná aplikace poté, co byl zde otevřen nebo uložen. Replace it with this document, or cancel and use File › Save As to keep both. Nahraďte ho tímto dokumentem, nebo zrušte a použijte Soubor › Uložit jako, abyste zachovali obojí. + “{name}” already exists. Replace it? „{name}“ již existuje. Nahradit? + The name in the save dialog was “{name}”. Název v dialogu ukládání byl „{name}“. diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index b1ea8977e2..05c2a8cb7e 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3212,3 +3212,5 @@ curves Smooth Glätten GPU Memory (MB, 0 = Automatic) GPU-Speicher (MB, 0 = Automatisch) {name} was changed by another app after it was opened or saved here. {name} wurde von einer anderen App geändert, nachdem es hier geöffnet oder gespeichert wurde. Replace it with this document, or cancel and use File › Save As to keep both. Ersetze die Datei durch dieses Dokument oder brich ab und nutze Datei › Speichern unter, um beide zu behalten. + “{name}” already exists. Replace it? „{name}“ ist bereits vorhanden. Ersetzen? + The name in the save dialog was “{name}”. Der Name im Speichern-Dialog war „{name}“. diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 8229806307..03d45ed037 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3910,3 +3910,5 @@ curves Smooth Εξομάλυνση GPU Memory (MB, 0 = Automatic) Μνήμη GPU (MB, 0 = Αυτόματο) {name} was changed by another app after it was opened or saved here. Το {name} άλλαξε από άλλη εφαρμογή αφού ανοίχτηκε ή αποθηκεύτηκε εδώ. Replace it with this document, or cancel and use File › Save As to keep both. Αντικαταστήστε το με αυτό το έγγραφο ή ακυρώστε και χρησιμοποιήστε Αρχείο › Αποθήκευση ως για να κρατήσετε και τα δύο. + “{name}” already exists. Replace it? Το «{name}» υπάρχει ήδη. Να αντικατασταθεί; + The name in the save dialog was “{name}”. Το όνομα στο παράθυρο αποθήκευσης ήταν «{name}». diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 6f3d85002b..9e5158bf67 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4172,3 +4172,5 @@ navigator Zoom Zoom GPU Memory (MB, 0 = Automatic) Memoria de GPU (MB, 0 = Automático) {name} was changed by another app after it was opened or saved here. Otra aplicación cambió {name} después de abrirlo o guardarlo aquí. Replace it with this document, or cancel and use File › Save As to keep both. Reemplázalo con este documento, o cancela y usa Archivo › Guardar como para conservar ambos. + “{name}” already exists. Replace it? «{name}» ya existe. ¿Reemplazarlo? + The name in the save dialog was “{name}”. El nombre en el cuadro de diálogo Guardar era «{name}». diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 4a819b0e1b..139db79602 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3216,3 +3216,5 @@ curves Smooth Lisser Replace Remplacer {name} was changed by another app after it was opened or saved here. {name} a été modifié par une autre application après avoir été ouvert ou enregistré ici. Replace it with this document, or cancel and use File › Save As to keep both. Remplacez-le par ce document, ou annulez et utilisez Fichier › Enregistrer sous pour garder les deux. + “{name}” already exists. Replace it? « {name} » existe déjà. Le remplacer ? + The name in the save dialog was “{name}”. Le nom dans la boîte de dialogue d'enregistrement était « {name} ». diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 86a6b30acb..7705a01e67 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4129,3 +4129,5 @@ cameraRaw Yellow Sárga GPU Memory (MB, 0 = Automatic) GPU-memória (MB, 0 = Automatikus) {name} was changed by another app after it was opened or saved here. A(z) {name} fájlt egy másik alkalmazás módosította, miután itt megnyitották vagy mentették. Replace it with this document, or cancel and use File › Save As to keep both. Cserélje le erre a dokumentumra, vagy válassza a Mégse gombot, és a Fájl › Mentés másként paranccsal tartsa meg mindkettőt. + “{name}” already exists. Replace it? „{name}” már létezik. Lecseréli? + The name in the save dialog was “{name}”. A mentési párbeszédpanelen megadott név: „{name}”. diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 49777913c1..1ce1b3ab20 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3203,3 +3203,5 @@ curves Smooth Haluskan Replace Ganti {name} was changed by another app after it was opened or saved here. {name} diubah oleh aplikasi lain setelah dibuka atau disimpan di sini. Replace it with this document, or cancel and use File › Save As to keep both. Ganti dengan dokumen ini, atau batalkan dan gunakan Berkas › Simpan Sebagai untuk menyimpan keduanya. + “{name}” already exists. Replace it? “{name}” sudah ada. Ganti? + The name in the save dialog was “{name}”. Nama di dialog simpan adalah “{name}”. diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index f1144c0fdd..3e4d805f3d 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4331,3 +4331,5 @@ shape Appearance Aspetto GPU Memory (MB, 0 = Automatic) Memoria GPU (MB, 0 = Automatico) {name} was changed by another app after it was opened or saved here. {name} è stato modificato da un'altra app dopo essere stato aperto o salvato qui. Replace it with this document, or cancel and use File › Save As to keep both. Sostituiscilo con questo documento, oppure annulla e usa File › Salva con nome per tenerli entrambi. + “{name}” already exists. Replace it? “{name}” esiste già. Sostituirlo? + The name in the save dialog was “{name}”. Il nome nella finestra di salvataggio era “{name}”. diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 351699ea89..11532fe9e7 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3887,3 +3887,5 @@ curves Smooth スムーズ Replace 置き換え {name} was changed by another app after it was opened or saved here. {name} は、ここで開くか保存した後に別のアプリで変更されました。 Replace it with this document, or cancel and use File › Save As to keep both. この書類で置き換えるか、キャンセルしてファイル › 名前を付けて保存で両方を残してください。 + “{name}” already exists. Replace it? 「{name}」は既に存在します。置き換えますか? + The name in the save dialog was “{name}”. 保存ダイアログで指定した名前は「{name}」でした。 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 3eaf143979..ad6ba85342 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3389,3 +3389,5 @@ curves Smooth 매끄럽게 Replace 대치 {name} was changed by another app after it was opened or saved here. {name}이(가) 여기서 열거나 저장한 뒤 다른 앱에서 변경되었습니다. Replace it with this document, or cancel and use File › Save As to keep both. 이 문서로 대치하거나, 취소하고 파일 › 다른 이름으로 저장을 사용해 둘 다 보관하세요. + “{name}” already exists. Replace it? “{name}”이(가) 이미 있습니다. 대치하시겠습니까? + The name in the save dialog was “{name}”. 저장 대화상자에 입력한 이름: “{name}” diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 09dd568009..4bb345d1e6 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3970,3 +3970,5 @@ curves Smooth Vloeiend maken GPU Memory (MB, 0 = Automatic) GPU-geheugen (MB, 0 = Automatisch) {name} was changed by another app after it was opened or saved here. {name} is door een andere app gewijzigd nadat het hier is geopend of bewaard. Replace it with this document, or cancel and use File › Save As to keep both. Vervang het door dit document, of annuleer en gebruik Bestand › Opslaan als om beide te houden. + “{name}” already exists. Replace it? “{name}” bestaat al. Vervangen? + The name in the save dialog was “{name}”. De naam in het venster Opslaan was “{name}”. diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index c4827aa829..d2446750ee 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3809,3 +3809,5 @@ curves Smooth Wygładź Replace Zastąp {name} was changed by another app after it was opened or saved here. Plik {name} został zmieniony przez inną aplikację po otwarciu lub zapisaniu go tutaj. Replace it with this document, or cancel and use File › Save As to keep both. Zastąp go tym dokumentem albo anuluj i użyj Plik › Zapisz jako, aby zachować oba. + “{name}” already exists. Replace it? „{name}” już istnieje. Zastąpić? + The name in the save dialog was “{name}”. Nazwa w oknie zapisywania: „{name}”. diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index f96542d642..75a9640695 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -4000,3 +4000,5 @@ layerLabel No Color Sem cor GPU Memory (MB, 0 = Automatic) Memória da GPU (MB, 0 = Automático) {name} was changed by another app after it was opened or saved here. {name} foi alterado por outro app depois de ser aberto ou salvo aqui. Replace it with this document, or cancel and use File › Save As to keep both. Substitua-o por este documento, ou cancele e use Arquivo › Salvar como para manter os dois. + “{name}” already exists. Replace it? “{name}” já existe. Substituir? + The name in the save dialog was “{name}”. O nome na caixa de diálogo Salvar era “{name}”. diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index f49200b973..f95a6a6cee 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3181,3 +3181,5 @@ curves Smooth Сгладить Replace Заменить {name} was changed by another app after it was opened or saved here. Файл {name} был изменён другим приложением после того, как его открыли или сохранили здесь. Replace it with this document, or cancel and use File › Save As to keep both. Замените его этим документом или отмените и используйте Файл › Сохранить как, чтобы сохранить оба. + “{name}” already exists. Replace it? «{name}» уже существует. Заменить? + The name in the save dialog was “{name}”. В окне сохранения было указано имя «{name}». diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index c207facb72..a83e28b7ef 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -4283,3 +4283,5 @@ shape Appearance Izgled GPU Memory (MB, 0 = Automatic) GPU memorija (MB, 0 = Automatski) {name} was changed by another app after it was opened or saved here. Datoteku {name} je izmenila druga aplikacija nakon što je ovde otvorena ili sačuvana. Replace it with this document, or cancel and use File › Save As to keep both. Zameni je ovim dokumentom ili otkaži i koristi Datoteka › Sačuvaj kao da zadržiš obe. + “{name}” already exists. Replace it? „{name}“ već postoji. Zameniti? + The name in the save dialog was “{name}”. Ime u dijalogu za čuvanje je bilo „{name}“. diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index a8aa0c3c58..9cf46749b6 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3940,3 +3940,5 @@ cameraData Lens Lens GPU Memory (MB, 0 = Automatic) GPU Belleği (MB, 0 = Otomatik) {name} was changed by another app after it was opened or saved here. {name}, burada açıldıktan veya kaydedildikten sonra başka bir uygulama tarafından değiştirildi. Replace it with this document, or cancel and use File › Save As to keep both. Bu belgeyle değiştirin ya da iptal edip ikisini de korumak için Dosya › Farklı Kaydet'i kullanın. + “{name}” already exists. Replace it? '{name}' zaten var. Değiştirilsin mi? + The name in the save dialog was “{name}”. Kaydetme penceresindeki ad '{name}' idi. diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 48fb60343c..28d4aa961c 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3910,3 +3910,5 @@ curves Smooth Згладити Replace Замінити {name} was changed by another app after it was opened or saved here. Файл {name} змінила інша програма після того, як його тут відкрили або зберегли. Replace it with this document, or cancel and use File › Save As to keep both. Замініть його цим документом або скасуйте й скористайтеся Файл › Зберегти як, щоб зберегти обидва. + “{name}” already exists. Replace it? «{name}» уже існує. Замінити? + The name in the save dialog was “{name}”. У вікні збереження було вказано назву «{name}». diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index f33ef15011..112b44aa1e 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3697,3 +3697,5 @@ curves Smooth Làm mượt Replace Thay thế {name} was changed by another app after it was opened or saved here. {name} đã bị một ứng dụng khác thay đổi sau khi được mở hoặc lưu ở đây. Replace it with this document, or cancel and use File › Save As to keep both. Thay thế bằng tài liệu này, hoặc hủy và dùng Tệp › Lưu thành để giữ cả hai. + “{name}” already exists. Replace it? “{name}” đã tồn tại. Thay thế? + The name in the save dialog was “{name}”. Tên trong hộp thoại lưu là “{name}”. diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 75eacc994d..988d3faad0 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2827,3 +2827,5 @@ curves Smooth 平滑 Replace 替换 {name} was changed by another app after it was opened or saved here. {name} 在此处打开或存储后已被其他应用更改。 Replace it with this document, or cancel and use File › Save As to keep both. 用此文档替换它,或取消并使用“文件 › 另存为”以保留两者。 + “{name}” already exists. Replace it? “{name}”已存在。要替换它吗? + The name in the save dialog was “{name}”. 存储对话框中的名称是“{name}”。 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 95a11dc593..75be2a7289 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2812,3 +2812,5 @@ curves Smooth 平滑 Replace 取代 {name} was changed by another app after it was opened or saved here. {name} 在此處開啟或儲存後已被其他 App 更改。 Replace it with this document, or cancel and use File › Save As to keep both. 以此文件取代它,或取消並使用「檔案 › 另存新檔」以保留兩者。 + “{name}” already exists. Replace it? 「{name}」已存在。要取代它嗎? + The name in the save dialog was “{name}”. 儲存對話框中的名稱是「{name}」。 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 04c37282e6..8bb3bdc586 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -173,6 +173,7 @@ pub mod proxy; pub mod puppet_ui; pub mod quick_pick; pub mod rasterize_prompt; +pub mod replace_prompt; pub mod retouch_ui; mod rgb_histogram; pub mod rotate_view; @@ -360,6 +361,8 @@ pub struct FileStamp { } /// The [`FileStamp`] of the file at a path, `None` when it can't be read. pub type FileStampFn = Box Option>; +/// Whether two paths reach the same file on disk; false when either can't be read. +pub type SameFileFn = Box bool>; /// Encode a document and write it to a path, reporting the stage to the job; returns the export /// warnings. Runs on a worker thread (see [`Services::save_file`]). It must call /// [`jobs_ui::SaveCtl::commit`] right before replacing the file, and write nothing if that fails. @@ -460,6 +463,10 @@ pub struct Services { /// Size and modification time of a file (native); `None` on the web, where Save never /// writes back to a file. pub file_stamp: Option, + /// Whether two paths reach the same file (native): a save dialog's answer and the path the + /// save writes to after Lowercase Extension, on a file system that ignores letter case. A save + /// asks before replacing a file it finds different (`replace_prompt`). + pub same_file: Option, /// Encode and write in one step on a worker thread, so a large save doesn't freeze the /// window (#2017). With background jobs on, saves use it instead of `export` and `write`. pub save_file: Option, diff --git a/crates/ui-egui/src/replace_prompt.rs b/crates/ui-egui/src/replace_prompt.rs new file mode 100644 index 0000000000..7f301db8e7 --- /dev/null +++ b/crates/ui-egui/src/replace_prompt.rs @@ -0,0 +1,46 @@ +//! A save asks before replacing a file that the save dialog did not ask about. The dialog asks +//! only about the name it answers, and the app can change that name afterwards: it gives a name +//! without an extension the suggested file's (#5039), and lowercases the extension when +//! Preferences ▸ File Handling › Lowercase Extension is on. When a file exists under the changed +//! name, Replace writes over it and Cancel ends the save like the save dialog's Cancel. +//! +//! The prompt is an ordinary dialog in the UI state: `ui.dialog.confirm` replaces the file and +//! `ui.dialog.cancel` keeps it. The file dialog stays open until it is answered +//! ([`PhotocraftApp::file_dialog_open`]). + +use serde_json::{Map, Value, json}; + +use crate::PhotocraftApp; +use crate::file_open::display_name; +use crate::state::DialogKind; + +const MARK: &str = "__replaceSaved"; + +pub fn owns(fields: &Map) -> bool { + fields.contains_key(MARK) +} + +/// Opens the prompt about `path`, the file a save writes to for `answered`, the save dialog's +/// answer. +pub(crate) fn open(app: &mut PhotocraftApp, answered: &str, path: &str) { + let message = crate::i18n::fmt(tl!("“{name}” already exists. Replace it?"), &[("name", display_name(path).as_str())]); + let detail = crate::i18n::fmt(tl!("The name in the save dialog was “{name}”."), &[("name", display_name(answered).as_str())]); + let fields = json!({MARK: true, "__label": "Save", "message": message, "detail": detail, "path": path}); + app.ui.open_dialog(DialogKind::Command, fields.as_object().cloned().unwrap_or_default()); +} + +pub fn body(ui: &mut egui::Ui, fields: &Map) { + ui.label(fields.get("message").and_then(Value::as_str).unwrap_or_default()); + ui.add_space(6.0); + ui.label(fields.get("detail").and_then(Value::as_str).unwrap_or_default()); +} + +/// Replace: the save the prompt held back. +pub fn confirm(app: &mut PhotocraftApp) -> Result { + app.answer_replace(true) +} + +/// Cancel: nothing is written. +pub fn cancel(app: &mut PhotocraftApp) -> Result { + app.answer_replace(false) +} diff --git a/crates/ui-egui/src/swatches_ui.rs b/crates/ui-egui/src/swatches_ui.rs index a18ddee640..e73ec1d440 100644 --- a/crates/ui-egui/src/swatches_ui.rs +++ b/crates/ui-egui/src/swatches_ui.rs @@ -442,6 +442,32 @@ mod tests { assert!(out[0].1.starts_with(b"ASEF")); } + /// The Linux save dialog shows the export's name without its extension, `Swatches`, and asks + /// only about that name. The export adds `.aco`, so with `Swatches.aco` in the folder it asks + /// before replacing it. + #[test] + fn export_asks_before_replacing_the_file_its_added_extension_names() { + use crate::file_dialog::FileDialogAnswer; + let written: std::rc::Rc>> = Default::default(); + let w = written.clone(); + let services = crate::Services { + file_dialog: Some(crate::file_dialog::fake(vec![Some(FileDialogAnswer::SaveTo("/swatches/Swatches".into()))]).0), + write: Some(Box::new(move |p: &str, _: &[u8]| { + w.borrow_mut().push(p.to_string()); + Ok(()) + })), + file_stamp: Some(Box::new(|p: &str| (p == "/swatches/Swatches.aco").then_some(crate::FileStamp { len: 1, modified: None }))), + ..Default::default() + }; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), services); + export_dialog(&mut app, "aco").unwrap(); + app.poll_file_dialog(&egui::Context::default(), None); + let id = app.ui.dialogs.iter().find(|d| crate::replace_prompt::owns(&d.fields)).map(|d| d.id).expect("the export asks first"); + assert!(written.borrow().is_empty(), "nothing is written before the answer"); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert_eq!(*written.borrow(), ["/swatches/Swatches.aco"]); + } + /// #3658: dragging a group header reorders the folders, and the new order sticks in the UI /// state (UI state is serde, so it saves and reloads with the rest of the panel state). #[test] diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 15334ac3a2..873dcb5f60 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -66,6 +66,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i - `ui.dialog.open {kind:"colorPicker", textStyle:{kind:"character"|"paragraph", id:u32}}` opens the shared Color Picker for an explicitly enabled Color attribute in Style Options. It opens the matching style panel/editor after validation; inherited colors must first be enabled through the existing style command. The picker reports its bound document/revision/style under `__textStyleColor` in `ui.inspect`. Changes preview in an isolated document; OK runs the existing style `set` command once, Cancel changes nothing. Document edits/switches or closed/changed Style Options discard the picker. `textStyle` cannot be combined with `parent`, `effect`, `field` or `target`. - The shell's own dialogs (Window › Workspace › New Workspace / Delete Workspace, View › Pixel Aspect Ratio › Custom Pixel Aspect Ratio…, View › Show › Show Extras Options…, View › 32-bit Preview Options…) are listed in `ui.inspect`'s `dialogs` with the id `"shell"` (at most one is open). `ui.dialog.set`, `ui.dialog.confirm` and `ui.dialog.cancel` take `{"dialog": "shell"}`; OK runs the dialog's command and keeps the dialog open if it fails - The unsaved-changes prompt (closing a document, Revert, quitting with unsaved work) is listed in `ui.inspect`'s `dialogs` with the id `"discard"` while it is up: `fields` gives the waiting `command` (`file.close`, `file.exit`…), the `document` it asks about, how many are `remaining`, and its `choices`. `ui.dialog.confirm` takes `{"dialog": "discard", "choice": "save" | "dontSave" | "cancel"}` (default `save`; Revert takes `dontSave` or `cancel`) and answers it as the window's buttons do, one document at a time; `ui.dialog.cancel` with `{"dialog": "discard"}` cancels it. Save of an untitled document opens the save dialog first, so the reply's `fileDialog` is true and the prompt stays open until that is answered. + - A save dialog's answer that PhotoCraft changes, by giving a name without an extension the suggested file's or by lowercasing the extension (Preferences › File Handling › Lowercase Extension), opens a replace prompt when a file exists under the changed name: a dialog with `__replaceSaved`, `message`, `detail` and `path` (the file the save would replace). `ui.dialog.confirm` writes the file; `ui.dialog.cancel` ends the save without writing, as Cancel in the save dialog does. The file dialog counts as open until the prompt is answered. A name that differs only in letter case and reaches the same file (Windows and macOS by default) does not open it. - Layer Style (`layerStyle {effect?}` opens on that effect kind): the fields hold the dialog's whole state, so agents read and drive it like a user. `effects` lists every effect instance in the layer's order as `{id, kind, on, params, fx}` (`fx` is the effect snapshot the dialog loaded; OK edits that snapshot with `params`, so anything the dialog doesn't model survives). `selected` picks the page (`blendingOptions` or an instance id), `preview` (default on) gates the live canvas preview, `p:blendingOptions` edits blend mode/opacity/fill opacity, and `patternList` names the usable patterns. OK replaces the layer's effects in one step (`layer.layerStyle.replace`); Cancel discards. "New Style…" saves the pending state via `style.presets.new {effects}`, a swatch click applies a style preset, and Make/Reset Default use `layer.layerStyle.makeDefault` / `layer.layerStyle.defaultFor` - Preferences (`ui.menu.invoke {id: "edit.preferences.interface"}`) exposes the sections in its `values` field. Apply (`ui.dialog.apply`) and OK (`ui.dialog.confirm`) on Preferences are refused over control because automation denies whole-preferences updates and filesystem-bearing preference sections. A refused `ui.dialog.apply` returns an error and keeps the dialog open; a refused `ui.dialog.confirm` returns an error but closes and removes the dialog. `ui.dialog.cancel` closes the dialog without saving. Individual safe preferences can instead be changed with `engine.execute` using `prefs.set` (e.g. `{"command": "prefs.set", "params": {"path": "interface.showTooltips", "value": false}}`). Settings marked for the next launch still require a restart. - Edit › Fill… (`ui.menu.invoke {id: "edit.fill"}`, Shift+F5, Shift+Backspace) opens the Fill dialog; its fields are `edit.fill`'s params (`contents`, `color`, `pattern`, `colorAdaptation`, `mode`, `opacity`, `preserveTransparency`), and OK remembers them in the preferences (`dialogs["edit.fill"]`). diff --git a/docs/ui-dialogs.md b/docs/ui-dialogs.md index 032edfde5d..8767f59714 100644 --- a/docs/ui-dialogs.md +++ b/docs/ui-dialogs.md @@ -50,6 +50,7 @@ Dispatch order matches `dialogs::show` in `dialogs.rs` (check earlier rows befor | `__stroke` | `stroke_ui.rs` | Edit › Stroke… | | `__createShape` | `shape_dialog.rs` | Click-with-shape-tool creation (rectangle, ellipse, …) | | `__deleteLayer` | `delete_layer_prompt.rs` | Layers panel trash confirmation | +| `__replaceSaved` | `replace_prompt.rs` | Replace prompt when a save's added or lowercased extension names an existing file | | `__newLayer` | `new_layer_ui.rs` | Layer › New › Layer… (⇧⌘N; ⌥⇧⌘N makes the layer without it) | | `__rasterize` | `rasterize_prompt.rs` | Rasterize-before-paint prompt | | `__resetAllTools` | `tool_reset.rs` | Reset All Tools confirmation | @@ -101,6 +102,7 @@ actions with their marker already set. | `__stroke` | `stroke_ui` | | `__createShape` | `shape_dialog` | | `__deleteLayer` | `delete_layer_prompt` | +| `__replaceSaved` | `replace_prompt` | | `__newLayer` | `new_layer_ui` | | `__rasterize` | `rasterize_prompt` | | `__resetAllTools` | `tool_reset` | From f94fb6cd595f187bbedf6dc00bc3468457139d77 Mon Sep 17 00:00:00 2001 From: RehanKhatkar Date: Sun, 18 Oct 2026 01:37:26 +0530 Subject: [PATCH 17/23] Open EXR Parts: the menu asks for a file and shows the parts dialog (#7169) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit File › Open EXR Parts… ran the engine command with no params, failing with `missing "path"` and never showing a dialog: the picker and parts form in view_cmds were never reached because the id was missing from `wraps()`. Add it, with a regression test that drives the menu through the file picker, the parts dialog and OK. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/file_dialog/tests.rs | 31 +++++++++++++++++++++++++ crates/ui-egui/src/view_cmds.rs | 1 + 2 files changed, 32 insertions(+) diff --git a/crates/ui-egui/src/file_dialog/tests.rs b/crates/ui-egui/src/file_dialog/tests.rs index bb827274ff..ed7f5ad419 100644 --- a/crates/ui-egui/src/file_dialog/tests.rs +++ b/crates/ui-egui/src/file_dialog/tests.rs @@ -161,6 +161,37 @@ fn answer(open: &Open, answer: Option) { std::thread::spawn(move || reply.send(answer)).join().unwrap(); } +#[test] +fn menu_open_exr_parts_asks_for_a_file_then_shows_the_parts_dialog() { + // Before the fix the menu ran the bare command: `missing "path"`, and no dialog (#7169). + let (mut app, open, _) = app(); + let bytes = photocraft_io::export(&app.session.active().unwrap().doc, "render.exr", &Default::default()).unwrap().bytes; + let unique = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let dir = std::env::temp_dir().join(format!("photocraft-menu-exr-parts-{}-{unique}", std::process::id())); + std::fs::create_dir(&dir).unwrap(); + let path = dir.join("render.exr"); + std::fs::write(&path, &bytes).unwrap(); + let path = path.to_string_lossy().into_owned(); + let ctx = egui::Context::default(); + let r = menus::invoke(&mut app, &ctx, "file.openExrParts", json!({})).unwrap(); + assert_eq!(r["fileDialog"], "open", "{r}"); + app.poll_file_dialog(&ctx, None); + assert!(matches!(open.borrow().as_slice(), [(FileDialogRequest::Open { multiple: false, extensions: Some(e), .. }, _)] if e == &["exr"])); + answer(&open, Some(FileDialogAnswer::Paths(vec![path.clone()]))); + app.poll_file_dialog(&ctx, None); + assert!(!app.ui.status_error, "{}", app.ui.status); + let dialog = app.ui.dialogs.iter().find(|d| d.fields.get("__command") == Some(&json!("file.openExrParts"))).expect("the parts dialog is open"); + assert_eq!(dialog.fields["path"], json!(path)); + let parts: Vec<&String> = dialog.fields.keys().filter(|k| k.starts_with("part/")).collect(); + assert_eq!(parts.len(), 1, "{:?}", dialog.fields); + assert_eq!(dialog.fields[parts[0].as_str()], json!(true), "the first colour part is preselected"); + let id = dialog.id; + let documents = app.session.documents().len(); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert_eq!(app.session.documents().len(), documents + 1); + std::fs::remove_dir_all(&dir).unwrap(); +} + #[test] fn menu_place_starts_transform_for_each_chosen_image() { for command in ["file.placeEmbedded", "file.placeLinked"] { diff --git a/crates/ui-egui/src/view_cmds.rs b/crates/ui-egui/src/view_cmds.rs index 1da1f918d2..117586d060 100644 --- a/crates/ui-egui/src/view_cmds.rs +++ b/crates/ui-egui/src/view_cmds.rs @@ -579,6 +579,7 @@ fn wraps(id: &str) -> bool { matches!( id, "file.openAs" + | "file.openExrParts" | "file.saveACopy" | "file.placeEmbedded" | "file.placeLinked" From 5c48fa987d72b2e08171099eb4ffda6c96bf5ada Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 17 Oct 2026 19:06:54 -0700 Subject: [PATCH 18/23] Save As on Linux: the one file type offered gives the name its extension With a single type offered the portal reports no filter, and the dialog opens on the stem (services::dialog_file_name), so accepting the name as shown returned a bare path: Swatches became a file named "Swatches" instead of "Swatches.aco" for the Swatches and preset-export flows. The sole filter's extensions are now used when the answer names no type this dialog offered; several types with no pick still add nothing, and an extension the user typed stays their own (with_chosen_type keeps one it recognises). Tests: the mock portal answers the bare stem for a single filter and gets back Swatches.aco; chosen_type is also checked directly, so the rule is covered where no dbus-daemon exists to run the portal test. --- apps/photocraft/src/portal_save.rs | 20 ++++++++++++---- apps/photocraft/src/portal_save/tests.rs | 30 ++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/apps/photocraft/src/portal_save.rs b/apps/photocraft/src/portal_save.rs index 42588e2bc4..d16432193b 100644 --- a/apps/photocraft/src/portal_save.rs +++ b/apps/photocraft/src/portal_save.rs @@ -156,6 +156,18 @@ fn file_uri_path(uri: &str) -> Option { Some(PathBuf::from(std::ffi::OsString::from_vec(out))) } +/// Which file type's extensions belong on the name the dialog returned: the one it reported, or, +/// when the dialog offered only one type, that one. It reports no pick then, and the name it was +/// given is the stem (`services::dialog_file_name`), so a bare `Swatches` has to come back as +/// `Swatches.aco`. `None` when the answer names no type this dialog offered. +fn chosen_type(filters: &[Filter], picked: Option) -> Option { + match picked { + Some(i) if i < filters.len() => Some(i), + None if filters.len() == 1 => Some(0), + _ => None, + } +} + /// Save As on Linux: the portal's dialog, with the name given the extension of the file type /// picked. When that changes the name to one that already exists, the dialog opens again under /// the new name, so the portal asks before replacing it (it asked only about the typed name). @@ -163,10 +175,10 @@ pub fn save_as(conn: &zbus::blocking::Connection, mut req: SaveRequest) -> Resul loop { let Some(saved) = save_file(conn, &req)? else { return Ok(None) }; let picked = saved.filter.as_deref().and_then(|label| req.filters.iter().position(|(l, _)| l == label)); - // With one type offered there is nothing to pick, and a typed extension is the user's. - let exts: &[String] = match picked { - Some(i) if req.filters.len() > 1 => &req.filters[i].1, - _ => &[], + // A typed extension is the user's own: `with_chosen_type` keeps one it recognises. + let exts: &[String] = match chosen_type(&req.filters, picked) { + Some(i) => &req.filters[i].1, + None => &[], }; let path = crate::services::with_chosen_type(&saved.path, req.suggested.as_deref(), exts); if path == saved.path || !path.exists() { diff --git a/apps/photocraft/src/portal_save/tests.rs b/apps/photocraft/src/portal_save/tests.rs index ed44a10d9c..76243c32be 100644 --- a/apps/photocraft/src/portal_save/tests.rs +++ b/apps/photocraft/src/portal_save/tests.rs @@ -230,3 +230,33 @@ fn a_single_file_type_keeps_the_typed_extension() { assert_eq!(save_as(&conn, req).unwrap(), Some(PathBuf::from("/tmp/photocraft-none/Swatches.ase"))); daemon.kill().ok(); } + +/// The same single type, with the name left as the dialog shows it: the bare stem comes back, and +/// that one type gives it its extension instead of a file named `Swatches` being written. +#[test] +fn a_single_file_type_gives_the_bare_name_its_extension() { + let typed: &'static str = "file:///tmp/photocraft-none/Swatches"; + let Some((conn, _seen, mut daemon)) = mock_portal(vec![(0, Some((typed, "Color Swatches")))]) else { + eprintln!("no dbus-daemon: skipped"); + return; + }; + let req = SaveRequest { filters: vec![("Color Swatches".into(), vec!["aco".into()])], ..request("Swatches.aco") }; + assert_eq!(save_as(&conn, req).unwrap(), Some(PathBuf::from("/tmp/photocraft-none/Swatches.aco"))); + daemon.kill().ok(); +} + +/// Which type's extensions the returned name gets. The portal cases above need a `dbus-daemon`, +/// so the rule itself is checked here too, which needs none. +#[test] +fn the_type_that_gives_the_extension_is_the_one_picked() { + let one: Vec = vec![("Color Swatches".into(), vec!["aco".into()])]; + // One type offered: the dialog reports no pick, and it was given the stem. + assert_eq!(chosen_type(&one, None), Some(0)); + assert_eq!(chosen_type(&one, Some(0)), Some(0)); + let many: Vec = request("test").filters; + assert_eq!(chosen_type(&many, Some(1)), Some(1)); + // Several types and no pick: nothing to add, as before. + assert_eq!(chosen_type(&many, None), None); + // An answer naming a type this dialog never offered is not a pick either. + assert_eq!(chosen_type(&many, Some(99)), None); +} From 89195d7f16f78b8d6947396d9ef246d89286cb96 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 18 Oct 2026 16:35:59 -0700 Subject: [PATCH 19/23] Save: ask before replacing a hard link to the file the dialog named A save whose added or lowercased extension names another entry in the folder now asks before replacing it, even when that entry is a hard link to the file the save dialog asked about. Two spellings of one name on a file system that ignores letter case still count as the same entry and do not ask. --- apps/photocraft/src/services.rs | 48 ++++++++++++++++++++++++++++++- crates/ui-egui/src/file_dialog.rs | 5 ++-- crates/ui-egui/src/lib.rs | 9 +++--- 3 files changed, 55 insertions(+), 7 deletions(-) diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index 1b2cb20a26..2dddaa89f5 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -346,6 +346,24 @@ fn same_file(a: &Path, b: &Path) -> bool { } } +/// Whether `a` and `b` name one entry in one folder: `same_file` finds they reach the same file +/// in the same folder, and the folder doesn't list both names. Another spelling of a name on a +/// file system that ignores letter case is the same entry; a hard link is another entry, which a +/// save replaces while the first name keeps the old file. False when the folder can't be read. +fn same_entry(a: &Path, b: &Path, same_file: impl Fn(&Path, &Path) -> bool) -> bool { + let folder = |p: &Path| p.parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(Path::new(".")).to_path_buf(); + if !same_file(a, b) || !same_file(&folder(a), &folder(b)) { + return false; + } + let (Some(x), Some(y)) = (a.file_name(), b.file_name()) else { return false }; + if x == y { + return true; + } + let Ok(entries) = std::fs::read_dir(folder(a)) else { return false }; + let listed: Vec<_> = entries.filter_map(|e| e.ok().map(|e| e.file_name())).collect(); + !(listed.iter().any(|n| n == x) && listed.iter().any(|n| n == y)) +} + fn export_options(settings: &photocraft_ui_egui::ExportSettings) -> photocraft_io::ExportOptions { settings.io_options() } @@ -559,7 +577,7 @@ pub fn native(automation: Option) -> let meta = std::fs::metadata(path).ok()?; Some(photocraft_ui_egui::FileStamp { len: meta.len(), modified: meta.modified().ok() }) })), - same_file: Some(Box::new(|a: &str, b: &str| same_file(Path::new(a), Path::new(b)))), + same_file: Some(Box::new(|a: &str, b: &str| same_entry(Path::new(a), Path::new(b), same_file))), automation_read, automation_write, automation_open_pcraft, @@ -973,6 +991,34 @@ mod tests { std::fs::remove_dir_all(&dir).unwrap(); } + /// A hard link is another entry in the folder, though it reaches the same file: a save to it + /// replaces that entry and leaves the other name on the old file. Another spelling of a name, + /// where letter case is ignored, is the same entry. + #[test] + fn a_hard_link_is_another_entry() { + let dir = std::env::temp_dir().join(format!("photocraft-same-entry-{}", std::process::id())); + std::fs::create_dir_all(dir.join("other")).unwrap(); + let (cat, bare, upper, other) = (dir.join("CAT.psd"), dir.join("CAT"), dir.join("CAT.PSD"), dir.join("other").join("CAT.psd")); + std::fs::write(&cat, b"cat").unwrap(); + std::fs::hard_link(&cat, &bare).unwrap(); + std::fs::hard_link(&cat, &other).unwrap(); + // Where both names reach one file (one inode on Unix), the folder still lists both. + let one_file = |_: &Path, _: &Path| true; + assert!(!same_entry(&bare, &cat, one_file)); + assert!(!same_entry(&bare, &cat, same_file)); + assert!(!same_entry(&other, &cat, same_file), "another folder"); + assert!(same_entry(&cat, &cat, one_file)); + assert!(same_entry(&cat, &cat, same_file)); + if upper.exists() { + assert!(same_entry(&upper, &cat, same_file), "one entry where letter case is ignored"); + } else { + std::fs::hard_link(&cat, &upper).unwrap(); + assert!(!same_entry(&upper, &cat, same_file), "two entries where letter case tells names apart"); + } + assert!(!same_entry(&dir.join("missing.psd"), &cat, same_file)); + std::fs::remove_dir_all(&dir).unwrap(); + } + #[test] fn block_on_waits_for_a_wake_from_another_thread() { let (tx, rx) = std::sync::mpsc::channel::(); diff --git a/crates/ui-egui/src/file_dialog.rs b/crates/ui-egui/src/file_dialog.rs index 7c9e83a0bb..c7ac09dbc7 100644 --- a/crates/ui-egui/src/file_dialog.rs +++ b/crates/ui-egui/src/file_dialog.rs @@ -185,9 +185,10 @@ impl PhotocraftApp { /// Whether a save to `path` replaces a file the save dialog didn't ask about: the dialog /// answered `answered`, and [`saved_path`] changed it. A file at `path` that - /// [`Services::same_file`](crate::Services::same_file) finds is also the file at `answered` (the + /// [`Services::same_file`](crate::Services::same_file) finds is the same entry as `answered` (the /// same name in another letter case on a file system that ignores case, as Windows and macOS do - /// by default) counts as asked about. Never on the web, which has no file stamps. + /// by default) counts as asked about; a hard link to it does not. Never on the web, which has no + /// file stamps. fn replaces_unasked(&self, answered: &str, path: &str) -> bool { let Some(stamp) = self.services.file_stamp.as_ref() else { return false }; answered != path && stamp(path).is_some() && !self.services.same_file.as_ref().is_some_and(|same| same(answered, path)) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c4cedac077..3ba415b22c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -367,7 +367,7 @@ pub struct FileStamp { } /// The [`FileStamp`] of the file at a path, `None` when it can't be read. pub type FileStampFn = Box Option>; -/// Whether two paths reach the same file on disk; false when either can't be read. +/// Whether two paths name one entry in one folder on disk; false when either can't be read. pub type SameFileFn = Box bool>; /// Encode a document and write it to a path, reporting the stage to the job; returns the export /// warnings. Runs on a worker thread (see [`Services::save_file`]). It must call @@ -476,9 +476,10 @@ pub struct Services { /// Size and modification time of a file (native); `None` on the web, where Save never /// writes back to a file. pub file_stamp: Option, - /// Whether two paths reach the same file (native): a save dialog's answer and the path the - /// save writes to after Lowercase Extension, on a file system that ignores letter case. A save - /// asks before replacing a file it finds different (`replace_prompt`). + /// Whether two paths name one entry in one folder (native): a save dialog's answer and the + /// path the save writes to after Lowercase Extension, on a file system that ignores letter + /// case. A hard link is another entry. A save asks before replacing an entry it finds + /// different (`replace_prompt`). pub same_file: Option, /// `write` only asks for the bytes to be kept (a browser download, which the user can /// cancel and which reports nothing back), so a save through it names the document but From 562601eb476f492f7245945751cba47a57420a48 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 16 Oct 2026 03:39:15 +0200 Subject: [PATCH 20/23] Save for Web: Width, Height, Percent and Resample in Image Size, linked in proportion (#6388) --- crates/ui-egui/src/export_dialog.rs | 14 +- crates/ui-egui/src/file_ui.rs | 186 ++++++++++++++++++++-- crates/ui-egui/src/file_ui/web_preview.rs | 8 +- docs/control-protocol.md | 2 +- 4 files changed, 192 insertions(+), 18 deletions(-) diff --git a/crates/ui-egui/src/export_dialog.rs b/crates/ui-egui/src/export_dialog.rs index 275221e050..ba9fec4698 100644 --- a/crates/ui-egui/src/export_dialog.rs +++ b/crates/ui-egui/src/export_dialog.rs @@ -271,14 +271,14 @@ fn scaled_size(w: f64, h: f64, scale: f64) -> (f64, f64) { } /// Largest side Export As will ask Image Size for. The same cap `image.imageSize` enforces. -const MAX_PX: f64 = 300_000.0; +pub(crate) const MAX_PX: f64 = 300_000.0; /// Pixel size, percent scale and whether the two sides stay linked. #[derive(Clone, Copy, Debug, PartialEq)] -struct ExportSize { - width: f64, - height: f64, - scale: f64, +pub(crate) struct ExportSize { + pub(crate) width: f64, + pub(crate) height: f64, + pub(crate) scale: f64, } fn finite_px(v: f64) -> Result { @@ -289,7 +289,7 @@ fn finite_px(v: f64) -> Result { } /// Percent scale applied to both sides. `scale` is a percent in 1..=1000. -fn resize_from_scale(src_w: f64, src_h: f64, scale: f64) -> Result { +pub(crate) fn resize_from_scale(src_w: f64, src_h: f64, scale: f64) -> Result { if !scale.is_finite() || !(1.0..=1000.0).contains(&scale) { return Err("scale must be within 1..=1000 percent".into()); } @@ -301,7 +301,7 @@ fn resize_from_scale(src_w: f64, src_h: f64, scale: f64) -> Result Result { +pub(crate) fn resize_from_side(src_w: f64, src_h: f64, width: f64, height: f64, lock: bool, edited_width: bool) -> Result { let src_w = src_w.max(1.0); let src_h = src_h.max(1.0); let (width, height) = if edited_width { diff --git a/crates/ui-egui/src/file_ui.rs b/crates/ui-egui/src/file_ui.rs index 32f97bc4e8..d22c936cc8 100644 --- a/crates/ui-egui/src/file_ui.rs +++ b/crates/ui-egui/src/file_ui.rs @@ -242,6 +242,7 @@ pub fn open_web(app: &mut PhotocraftApp) -> u64 { ("convertToSrgb", json!(true)), ("metadata", json!("copyright")), ("percent", json!(100.0)), + ("resample", json!("bicubic")), ("webSnap", json!(0)), ] { f.entry(k.to_string()).or_insert(v); @@ -284,7 +285,7 @@ fn web_pane(app: &PhotocraftApp, ui: &mut egui::Ui, size: egui::Vec2, p: Option< let rev = st.revision; // A bounded proxy; navigation never recomposites or re-encodes it. let base = p.cloned().unwrap_or_else(|| json!({})); - let size_sig = format!("{}|{}|{}|{}", base["percent"], base["width"], base["height"], base["convertToSrgb"]); + let size_sig = format!("{}|{}|{}|{}|{}", base["percent"], base["width"], base["height"], base["resample"], base["convertToSrgb"]); let wkey = egui::Id::new(("web-proxy", doc.id.0, pane)); let proxy_sig = format!("{rev}|{frame:?}|{size_sig}"); let cached_proxy: Option<(String, WebProxy)> = ui.data(|d| d.get_temp(wkey)); @@ -302,8 +303,9 @@ fn web_pane(app: &PhotocraftApp, ui: &mut egui::Ui, size: egui::Vec2, p: Option< let k = (2048.0 / fw.max(fh)).min(1.0); let st = WebSettings::from_params(&base, "preview").unwrap_or_default(); let Ok(doc) = web_frame_document(&doc, frame) else { return }; + let resample = web_preview::proxy_resample(&base, k); let Ok((wd, _, _)) = - web_cmds::web_document(&doc, &json!({"width": (fw * k).round().max(1.0), "height": (fh * k).round().max(1.0), "resample": "bilinear"}), &st) + web_cmds::web_document(&doc, &json!({"width": (fw * k).round().max(1.0), "height": (fh * k).round().max(1.0), "resample": resample}), &st) else { return; }; @@ -628,13 +630,7 @@ fn web_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map, unit: &str, decimals: u32) -> Option { + let t = Tokens::get(ui.ctx()); + let passed = (shown as f32).clamp(*range.start(), *range.end()); + let mut v = passed; + ui.label(egui::RichText::new(label).color(t.text_dim)); + crate::widgets::value_field_prec(ui, &mut v, range, unit, 90.0, decimals); + ui.end_row(); + (v != passed).then_some(f64::from(v)) +} + +/// Image Size, as in Photoshop's Save for Web: Width and Height linked in proportion (on at +/// each opening), Percent and the resampling method. A side edit sets exact pixels and drops +/// `percent`, which the engine would otherwise apply first; a Percent edit sets both sides. +fn web_size(ui: &mut egui::Ui, f: &mut Map, doc: &photocraft_doc::Document) { + use crate::export_dialog::{MAX_PX, resize_from_scale, resize_from_side}; + let (w0, h0) = (f64::from(doc.size.width).max(1.0), f64::from(doc.size.height).max(1.0)); + let (w, h) = web_preview::dimensions(doc, ¶ms(f)).unwrap_or((w0, h0)); + let lock = b(f, "__constrain", true); + let pct = n(f, "percent", w / w0 * 100.0); + let write = |f: &mut Map, width: f64, height: f64| { + f.insert("width".into(), json!(width)); + f.insert("height".into(), json!(height)); + }; + let max = MAX_PX as f32; + let (width, height, percent) = egui::Grid::new("web-size") + .num_columns(2) + .show(ui, |ui| { + ( + size_field(ui, tl!("Width"), w, 1.0..=max, "px", 0), + size_field(ui, tl!("Height"), h, 1.0..=max, "px", 0), + size_field(ui, tl!("Percent"), pct, 1.0..=1000.0, "%", 2), + ) + }) + .inner; + let side = match (width, height) { + (Some(v), _) => resize_from_side(w0, h0, v.round(), h, lock, true).ok(), + (_, Some(v)) => resize_from_side(w0, h0, w, v.round(), lock, false).ok(), + _ => None, + }; + if let Some(size) = side { + f.remove("percent"); + write(f, size.width, size.height); + } else if let Some(v) = percent + && let Ok(size) = resize_from_scale(w0, h0, v) + { + f.insert("percent".into(), json!(size.scale)); + write(f, size.width, size.height); + } + let mut linked = lock; + if crate::widgets::checkbox(ui, &mut linked, tl!("Constrain proportions")).changed() { + f.insert("__constrain".into(), json!(linked)); + // Linked again, the height follows the width in the document's proportions. + if linked && let Ok(size) = resize_from_side(w0, h0, w, h, true, true) { + f.remove("percent"); + write(f, size.width, size.height); + } + } + ui.horizontal(|ui| { + ui.label(egui::RichText::new(tl!("Resample")).color(Tokens::get(ui.ctx()).text_dim)); + // Photoshop's Save for Web list, in its order. + dropdown_str( + ui, + "web-resample", + f, + "resample", + &[ + ("nearest", tl!("Nearest Neighbor (hard edges)")), + ("bilinear", tl!("Bilinear")), + ("bicubic", tl!("Bicubic (smooth gradients)")), + ("bicubicSmoother", tl!("Bicubic Smoother (enlargement)")), + ("bicubicSharper", tl!("Bicubic Sharper (reduction)")), + ], + 170.0, + ); + }); +} + fn web_confirm(app: &mut PhotocraftApp, f: &Map) -> Result { // Check while the control/input policy is still installed, before an asynchronous picker // can outlive the request. A browser download uses the interactive platform writer. @@ -1309,6 +1385,98 @@ mod tests { assert!(!f.contains_key("height")); } + /// Save for Web's Image Size on a 120 x 80 document, as a PNG-24 (no other number fields). + fn web_size_dialog(app: PhotocraftApp) -> (egui_kittest::Harness<'static, PhotocraftApp>, u64) { + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1600.0, 1100.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("semibold".into()))) { + crate::dialogs::show(app, ui.ctx()); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, Default::default()); + let id = open_web(h.state_mut()); + h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("format".into(), json!("png24")); + h.run_steps(5); + (h, id) + } + + /// Types `text` into the number field now showing `value`, and leaves it with Tab. + fn type_into_field(h: &mut egui_kittest::Harness<'static, PhotocraftApp>, value: f64, text: &str) { + use egui_kittest::kittest::{NodeT, Queryable}; + let field = h + .query_all_by_role(egui::accesskit::Role::SpinButton) + .find(|n| n.accesskit_node().numeric_value() == Some(value)) + .map(|n| n.rect()) + .expect("a field showing the value"); + h.hover_at(field.center()); + h.run_steps(1); + h.drag_at(field.center()); + h.run_steps(1); + h.drop_at(field.center()); + h.run_steps(2); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.event(egui::Event::Text(text.into())); + h.run_steps(1); + h.key_press(egui::Key::Tab); + h.run_steps(3); + } + + /// #6388: Save for Web's Image Size has Width and Height linked in proportion, as in + /// Photoshop. A typed width sets the height, the file comes out at that size, and the + /// document itself is never resized. + #[test] + fn save_for_web_width_keeps_proportions_and_sizes_the_file() { + let (app, _) = app(); + let (mut h, id) = web_size_dialog(app); + let revision = h.state().session.active().unwrap().revision; + type_into_field(&mut h, 120.0, "60"); + let f = h.state().ui.dialogs[0].fields.clone(); + assert_eq!((f["width"].as_f64(), f["height"].as_f64()), (Some(60.0), Some(40.0)), "{f:?}"); + // Unlinked, the height changes alone; linked again, it follows the width. + { + use egui_kittest::kittest::Queryable; + h.get_by_label("Constrain proportions").click(); + h.run_steps(3); + type_into_field(&mut h, 40.0, "50"); + let f = h.state().ui.dialogs[0].fields.clone(); + assert_eq!((f["width"].as_f64(), f["height"].as_f64()), (Some(60.0), Some(50.0)), "{f:?}"); + h.get_by_label("Constrain proportions").click(); + h.run_steps(3); + } + let f = h.state().ui.dialogs[0].fields.clone(); + assert_eq!((f["width"].as_f64(), f["height"].as_f64(), f.get("__constrain")), (Some(60.0), Some(40.0), Some(&json!(true))), "{f:?}"); + let path = std::env::temp_dir().join(format!("pc-webui-wh-{}.png", std::process::id())).to_string_lossy().into_owned(); + h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("path".into(), json!(path)); + let r = crate::dialogs::confirm(h.state_mut(), id).unwrap(); + assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(60), Some(40)), "{r}"); + let st = h.state().session.active().unwrap(); + assert_eq!((st.revision, st.doc.size), (revision, photocraft_doc::Size::new(120, 80))); + } + + /// Reopened on another document, Save for Web starts at that document's full size again + /// (#1857) but keeps the resampling method, like the other encoding choices. + #[test] + fn save_for_web_reopens_at_full_size_with_the_resample_method() { + let (app, _) = app(); + let (mut h, id) = web_size_dialog(app); + type_into_field(&mut h, 120.0, "60"); + let path = std::env::temp_dir().join(format!("pc-webui-reopen-{}.png", std::process::id())).to_string_lossy().into_owned(); + let d = h.state_mut().ui.dialog_mut(id).unwrap(); + d.fields.insert("resample".into(), json!("nearest")); + d.fields.insert("path".into(), json!(path)); + crate::dialogs::confirm(h.state_mut(), id).unwrap(); + h.state_mut().run("file.new", json!({"width": 240, "height": 160, "name": "next"})).unwrap(); + let id = open_web(h.state_mut()); + h.run_steps(3); + let f = h.state().ui.dialogs.iter().find(|d| d.id == id).unwrap().fields.clone(); + assert_eq!(f["resample"], "nearest"); + assert!(!f.contains_key("width") && !f.contains_key("height"), "{f:?}"); + let p = web_preview::dimensions(&h.state().session.active().unwrap().doc, ¶ms(&f)).unwrap(); + assert_eq!(p, (240.0, 160.0)); + } + #[test] fn save_for_web_downloads_images_and_html_through_the_platform_writer() { let (mut app, _) = app(); diff --git a/crates/ui-egui/src/file_ui/web_preview.rs b/crates/ui-egui/src/file_ui/web_preview.rs index b603584573..02cf44cb6d 100644 --- a/crates/ui-egui/src/file_ui/web_preview.rs +++ b/crates/ui-egui/src/file_ui/web_preview.rs @@ -18,6 +18,12 @@ pub fn dimensions(doc: &photocraft_doc::Document, p: &Value) -> Result<(f64, f64 Ok((w.round().max(1.0), h.round().max(1.0))) } +/// The method a preview proxy is resampled with: the export's own when the proxy is at full +/// size, so the preview, palette and size estimate show it; bilinear for a reduced proxy. +pub fn proxy_resample(p: &Value, scale: f64) -> &str { + if scale >= 1.0 { p.get("resample").and_then(Value::as_str).unwrap_or("bicubic") } else { "bilinear" } +} + pub fn prepare_table(app: &PhotocraftApp, f: &mut Map) -> Result<(), String> { if !matches!(s(f, "format", "jpeg").as_str(), "gif" | "png8") { return Ok(()); @@ -55,7 +61,7 @@ pub fn prepare_table(app: &PhotocraftApp, f: &mut Map) -> Result< m.remove("percent"); m.insert("width".into(), json!((w * scale).round().max(1.0))); m.insert("height".into(), json!((h * scale).round().max(1.0))); - m.insert("resample".into(), json!("bilinear")); + m.insert("resample".into(), json!(proxy_resample(&source, scale))); } let (doc, _, _) = web_cmds::web_document(&st.doc, &proxy, &settings).map_err(|e| e.to_string())?; f.insert("__paletteApproximate".into(), json!(scale < 1.0)); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 918a9f811b..88b1a65967 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -69,7 +69,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i - Layer Style (`layerStyle {effect?}` opens on that effect kind): the fields hold the dialog's whole state, so agents read and drive it like a user. `effects` lists every effect instance in the layer's order as `{id, kind, on, params, fx}` (`fx` is the effect snapshot the dialog loaded; OK edits that snapshot with `params`, so anything the dialog doesn't model survives). `selected` picks the page (`blendingOptions` or an instance id), `preview` (default on) gates the live canvas preview, `p:blendingOptions` edits blend mode/opacity/fill opacity, and `patternList` names the usable patterns. OK replaces the layer's effects in one step (`layer.layerStyle.replace`); Cancel discards. "New Style…" saves the pending state via `style.presets.new {effects}`, a swatch click applies a style preset, and Make/Reset Default use `layer.layerStyle.makeDefault` / `layer.layerStyle.defaultFor` - Preferences (`ui.menu.invoke {id: "edit.preferences.interface"}`) exposes the sections in its `values` field. Apply (`ui.dialog.apply`) and OK (`ui.dialog.confirm`) on Preferences are refused over control because automation denies whole-preferences updates and filesystem-bearing preference sections. A refused `ui.dialog.apply` returns an error and keeps the dialog open; a refused `ui.dialog.confirm` returns an error but closes and removes the dialog. `ui.dialog.cancel` closes the dialog without saving. Individual safe preferences can instead be changed with `engine.execute` using `prefs.set` (e.g. `{"command": "prefs.set", "params": {"path": "interface.showTooltips", "value": false}}`). Settings marked for the next launch still require a restart. - Edit › Fill… (`ui.menu.invoke {id: "edit.fill"}`, Shift+F5, Shift+Backspace) opens the Fill dialog; its fields are `edit.fill`'s params (`contents`, `color`, `pattern`, `colorAdaptation`, `mode`, `opacity`, `preserveTransparency`), and OK remembers them in the preferences (`dialogs["edit.fill"]`). - - Save for Web (`ui.menu.invoke {id: "file.export.saveForWebLegacy"}`) is resizable with independently scrolling settings. Dialog fields `__view` (`original|optimized|2up|4up`), `__zoom` (`null` = Fit, otherwise a factor; 1 = 100% in physical display pixels), and `__center: [u,v]` control the shared preview navigation. `__previewRects` reports pane rectangles in screen points. Drag a preview to pan, scroll/pinch to zoom at the pointer, or double-click to fit. GIF/PNG-8 expose `colorTable: ["#rrggbb", …]` (opaque source entries) and `colorTableEdits: {"0":"#rrggbb", …}` (zero-based replacements after quantization). A swatch opens a color picker; edits update the preview and saved file without remapping pixel indices or changing the document. Reset clears replacements. Changing palette/size/matte/transparency settings regenerates the table and clears index-based edits. Previews and generated tables use a bounded 2048-pixel proxy; full-size export uses that explicit table unchanged. `__paletteApproximate` reports proxy-derived palette generation, and enlarged proxy previews are labelled. The engine export command accepts the same table/edit parameters; its no-path estimate returns the source `colorTable` and `transparentIndex` as well as size statistics. Opening a new dialog does not carry a previous image's table across. + - Save for Web (`ui.menu.invoke {id: "file.export.saveForWebLegacy"}`) is resizable with independently scrolling settings. Dialog fields `__view` (`original|optimized|2up|4up`), `__zoom` (`null` = Fit, otherwise a factor; 1 = 100% in physical display pixels), and `__center: [u,v]` control the shared preview navigation. `__previewRects` reports pane rectangles in screen points. Drag a preview to pan, scroll/pinch to zoom at the pointer, or double-click to fit. GIF/PNG-8 expose `colorTable: ["#rrggbb", …]` (opaque source entries) and `colorTableEdits: {"0":"#rrggbb", …}` (zero-based replacements after quantization). A swatch opens a color picker; edits update the preview and saved file without remapping pixel indices or changing the document. Reset clears replacements. Changing palette/size/matte/transparency settings regenerates the table and clears index-based edits. Previews and generated tables use a bounded 2048-pixel proxy; full-size export uses that explicit table unchanged. `__paletteApproximate` reports proxy-derived palette generation, and enlarged proxy previews are labelled. The engine export command accepts the same table/edit parameters; its no-path estimate returns the source `colorTable` and `transparentIndex` as well as size statistics. Opening a new dialog does not carry a previous image's table across. Image Size has `width` and `height` (px), `percent` and `resample` (`nearest|bilinear|bicubic|bicubicSmoother|bicubicSharper`, default `bicubic`); `__constrain` (default true at each opening) links the two sides in proportion. A side edit sets both sides and removes `percent`, which the export command otherwise applies first; a Percent edit sets both sides from it. Each opening starts at the document's full size, while `resample` is remembered with the encoding choices. The document is never resized. - A pixel tool pressed on a type, shape, Smart Object or fill layer (e.g. through `ui.pointer`) opens the "Rasterize?" prompt instead of painting: a dialog with `__rasterize` (`type|shape|smartObject|fill`), `message`, `layer`, `tool` and `at`. `ui.dialog.confirm` runs the `layer.rasterize.*` command and then paints at `at` (two history states, `{"rasterized", "painted"}`); `ui.dialog.cancel` does nothing. - Edit › Transform › Warp on editable type (`ui.menu.invoke {"id":"edit.transform.warp"}`, or the warp toggle of an open transform box) opens Warp mode with no prompt, as in Photoshop: the mesh can't be dragged or split, only the options bar's warp style, Bend, H and V apply, and the commit sets the layer's Warp Text in one step, so the type stays editable. Directly: `engine.execute {"command":"edit.transform.warp","params":{"style":"flag","bend":40}}` on a type layer. Distort and Perspective stay greyed for type. - `ui.window.open {document?}` / `ui.window.close {window}`: extra document windows From c97ffe307e4ba21501dc7cfa6617c5e3cce67485 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 02:22:10 +0000 Subject: [PATCH 21/23] Integration: #4315 refuse a HighBit outside the allocated bits An out-of-range HighBit made the sample shift overflow. Adds a test with HighBit 200. --- crates/codecs/src/codecs/dicom.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/crates/codecs/src/codecs/dicom.rs b/crates/codecs/src/codecs/dicom.rs index ef47177584..ea2674dae7 100644 --- a/crates/codecs/src/codecs/dicom.rs +++ b/crates/codecs/src/codecs/dicom.rs @@ -213,6 +213,9 @@ pub(crate) fn decode(b: &[u8], limits: &Limits) -> Result { let bits = get_u16((0x0028, 0x0100)).ok_or_else(|| bad("missing BitsAllocated (0028,0100)"))?; let bits_stored = get_u16((0x0028, 0x0101)).unwrap_or(bits); let high_bit = get_u16((0x0028, 0x0102)).unwrap_or(bits_stored.saturating_sub(1)); + if u32::from(high_bit) >= u32::from(bits) { + return Err(bad("HighBit is outside the allocated bits")); + } let signed = get_u16((0x0028, 0x0103)).unwrap_or(0) == 1; let planar = get_u16((0x0028, 0x0006)).unwrap_or(0); let photometric = tags @@ -493,6 +496,15 @@ mod tests { assert!(decode(b"not dicom", &Limits::default()).is_err()); } + #[test] + fn high_bit_outside_the_allocated_bits_is_an_error_not_a_panic() { + let mut b = synth("1.2.840.10008.1.2.1", true, false, 0, 1, 1, 1, 8, "MONOCHROME2", &[7]); + let tag = [0x28, 0x00, 0x02, 0x01, b'U', b'S', 0x02, 0x00]; + let at = b.windows(tag.len()).position(|w| w == tag).unwrap() + tag.len(); + b[at..at + 2].copy_from_slice(&200u16.to_le_bytes()); + assert!(decode(&b, &Limits::default()).is_err()); + } + #[test] fn rejects_compressed_transfer_syntax() { let mut b = synth("1.2.840.10008.1.2.1", true, false, 0, 1, 1, 1, 8, "MONOCHROME2", &[0]); From 995927592b24fd616f682d26cc5275e11b227da4 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 02:29:34 +0000 Subject: [PATCH 22/23] Integration: DDS_PITCH is defined once after #5864 met the DDS writer --- crates/codecs/src/codecs/dds.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/codecs/src/codecs/dds.rs b/crates/codecs/src/codecs/dds.rs index 3a23e17296..cadc2b4bc8 100644 --- a/crates/codecs/src/codecs/dds.rs +++ b/crates/codecs/src/codecs/dds.rs @@ -25,7 +25,6 @@ const HEADER: usize = 128; /// DDS_HEADER_DXT10, present when the FourCC is `DX10`. const DX10_HEADER: usize = 20; -const DDSD_PITCH: u32 = 0x8; const DDSD_DEPTH: u32 = 0x80_0000; const DDPF_ALPHAPIXELS: u32 = 0x1; const DDPF_FOURCC: u32 = 0x4; From 1033aab750ff6ba7dec3ee2b8561d24f66ebd44d Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 02:56:39 +0000 Subject: [PATCH 23/23] Integration: cargo fmt --- apps/photocraft-web/src/web.rs | 5 +++-- apps/photocraft/src/services.rs | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs index 76b1e521bf..d7ac6c1ead 100644 --- a/apps/photocraft-web/src/web.rs +++ b/apps/photocraft-web/src/web.rs @@ -20,8 +20,9 @@ type Inbox = Arc)>>>; /// Photoshop brushes (.abr), gradients (.grd) and swatches (.aco, .act, .ase), which go to the preset libraries. const OPEN_EXTS: &[&str] = &[ "pcraft", "pdn", "ora", "xcf", "xcfgz", "gz", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", - "qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm", "dds", "fits", "fit", "fts", "dpx", "cin", "dcm", "dicom", "dc3", "iff", "lbm", "ilbm", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", - "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "act", "ase", "af", "afdesign", "afphoto", "afpub", + "qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm", "dds", "fits", "fit", "fts", "dpx", "cin", "dcm", "dicom", "dc3", "iff", "lbm", "ilbm", "heic", + "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "act", "ase", "af", "afdesign", + "afphoto", "afpub", ]; const SVG_EXTS: &[&str] = &["svg", "svgz"]; const CANVAS_ID: &str = "photocraft_canvas"; diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index babac52f46..e769208a85 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -21,8 +21,9 @@ mod recovery_tests; /// (.kys), which go to the preset libraries and Edit › Keyboard Shortcuts. const OPEN_EXTS: &[&str] = &[ "pcraft", "pdn", "ora", "xcf", "xcfgz", "gz", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", - "qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "cin", "dcm", "dicom", "dc3", "iff", "lbm", "ilbm", "heic", "heif", "hif", "dng", "cr2", - "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "act", "ase", "kys", "af", "afdesign", "afphoto", "afpub", + "qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "cin", "dcm", "dicom", "dc3", "iff", "lbm", + "ilbm", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "act", "ase", + "kys", "af", "afdesign", "afphoto", "afpub", ]; const SVG_EXTS: &[&str] = &["svg", "svgz"];