From 51b82caf0e6e406d0cf20a6ccab116d18ffc711b Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 4 Oct 2026 14:25:00 -0400 Subject: [PATCH] PSD round trip: read/write Vibrance, Color Balance, B&W, Photo Filter, Channel Mixer; corpus floors (#60) * PSD: read and write vibA, blnc, blwh, phfl, mixr adjustment layers Vibrance, Color Balance, Black & White, Photo Filter and Channel Mixer layers made in PhotoCraft were dropped on PSD save (Photoshop saw an empty pixel layer), and the same blocks rendered as no-ops on import. They are now parsed into their Adjustment variants and written per the Adobe spec (descriptor blocks with version 16; phfl version 2/3; mixr version 1). A Color Lookup without a table is written as an identity cube and a Gradient Map with fewer than two stops as the equivalent two-stop gradient, so every evaluated kind has an encoding. Tests: golden hand-built blocks for each new key, malformed and fuzzed input, and a document -> PSD -> document round trip of every adjustment kind (8 and 16 bit, plain / masked + blended / clipped) and every blend mode (pixel, clipped, group, pass-through, adjustment layers), asserting model equality where lossless and composite equality within 1/255. Co-Authored-By: Claude Opus 5.5 * Corpus: export round-trip step and pass floors; fill pixels in the mask frame corpus.rs now re-imports every exported file and compares its flatten with the first import (169/169 render the same). With PHOTOCRAFT_CORPUS set (corpus dir, or 1 for the default location) it asserts the oracle pass floor (113) and the round-trip floor (169). The new step found gradient fills without Photoshop pixels exported in the canvas frame instead of the frame their masks give them, so they re-imported shifted (gradient-fill.psd/.psb, 0.26 max error). Export now renders the fill like the compositor (render_fill_content). Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/compose/src/lib.rs | 6 + crates/io/src/adjust_map.rs | 396 +++++++++++++++++++++- crates/io/src/channel_map.rs | 2 +- crates/io/src/psd_export.rs | 5 +- crates/io/tests/adjust_blend_roundtrip.rs | 239 +++++++++++++ crates/io/tests/common/mod.rs | 12 +- crates/io/tests/corpus.rs | 53 ++- 7 files changed, 694 insertions(+), 19 deletions(-) create mode 100644 crates/io/tests/adjust_blend_roundtrip.rs diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index c7eb9bac..6a942d4e 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -389,6 +389,12 @@ pub fn fill_frame(layer: &Layer, canvas: Rect) -> Rect { frame } +/// A fill layer's content over `canvas`, laid out in the frame its masks give it (as the +/// compositor does) but without applying the masks: the pixels a PSD fill layer stores. +pub fn render_fill_content(layer: &Layer, f: &Fill, canvas: Rect, patterns: &[Pattern]) -> Buffer { + render_fill(f, canvas, fill_frame(layer, canvas), patterns) +} + fn render_fill(f: &Fill, rect: Rect, canvas: Rect, patterns: &[Pattern]) -> Buffer { match f { Fill::Solid(c) => { diff --git a/crates/io/src/adjust_map.rs b/crates/io/src/adjust_map.rs index 31243dc6..901aa1c8 100644 --- a/crates/io/src/adjust_map.rs +++ b/crates/io/src/adjust_map.rs @@ -3,6 +3,12 @@ //! Binary layouts follow the Adobe spec ("Adjustment layer" section). The //! mapped subset keeps the master/composite parameters; other channels of //! Levels/Curves/Hue-Sat are written back with defaults. +//! +//! Every [`Adjustment`] kind is written (`vibA`, `blnc`, `blwh`, `phfl` and `mixr` included); +//! a Color Lookup without a table is written as an identity cube and a Gradient Map with fewer +//! than two stops as the equivalent two-stop gradient, so nothing is dropped on save. Blocks +//! that cannot be read (noise gradients, profile lookups, CMYK channel mixers, unknown versions) +//! stay [`Adjustment::Unsupported`] and are written back verbatim. use photocraft_doc::Adjustment; use photocraft_doc::adjust::{CurvePoint, LevelsChannel}; @@ -100,6 +106,11 @@ pub enum Channels { /// (modern brightness/contrast parameters). Levels/Curves store the /// composite record first, then one per document channel (see [`Channels`]). pub fn parse(key: &[u8; 4], data: &[u8], cged: Option<&[u8]>, channels: Channels) -> Adjustment { + // A CMYK Channel Mixer (any record using the fourth, black, source) has no RGB meaning: + // keep it raw. Mixers written by `write` leave that source at 0 and map in every mode. + if key == b"mixr" && channels == Channels::Other && (0..).map_while(|r| bei16(data, 4 + r * 10 + 6)).any(|k| k != 0) { + return unsupported(key, data); + } let mut a = parse_any(key, data, cged); match (&mut a, channels) { (_, Channels::Rgb) => {} @@ -160,6 +171,11 @@ fn parse_any(key: &[u8; 4], data: &[u8], cged: Option<&[u8]>) -> Adjustment { b"selc" => parse_selective(data), b"clrL" => parse_lookup(data), b"grdm" => parse_gradient_map(data), + b"vibA" => parse_vibrance(data), + b"blnc" => parse_color_balance(data), + b"blwh" => parse_black_white(data), + b"phfl" => parse_photo_filter(data), + b"mixr" => parse_channel_mixer(data), _ => None, }; parsed.unwrap_or_else(|| unsupported(key, data)) @@ -318,6 +334,119 @@ fn parse_lookup(d: &[u8]) -> Option { }) } +/// The descriptor of a `vibA` / `blwh` block: a 4-byte descriptor version (16), then the descriptor. +fn versioned(d: &[u8]) -> Option { + VersionedDescriptor::parse_prefix(d).ok().map(|(v, _)| v.descriptor) +} + +/// `vibA` (Adobe spec, "Vibrance"): a versioned descriptor with `vibrance` and `Strt` +/// (saturation), both integers in -100..=100. Photoshop omits a key whose value is 0. +fn parse_vibrance(d: &[u8]) -> Option { + let desc = versioned(d)?; + let get = |k: &str| desc_num(&desc, k).unwrap_or(0.0).clamp(-100.0, 100.0); + Some(Adjustment::Vibrance { vibrance: get("vibrance"), saturation: get("Strt") }) +} + +/// `blnc` (Adobe spec, "Color Balance"): shadows, midtones and highlights as three i16 each +/// (cyan-red, magenta-green, yellow-blue, -100..=100), then preserve luminosity (1 byte). +fn parse_color_balance(d: &[u8]) -> Option { + let tone = |k: usize| -> Option<[f32; 3]> { + let mut out = [0.0; 3]; + for (i, v) in out.iter_mut().enumerate() { + *v = f32::from(bei16(d, (k * 3 + i) * 2)?).clamp(-100.0, 100.0); + } + Some(out) + }; + Some(Adjustment::ColorBalance { shadows: tone(0)?, midtones: tone(1)?, highlights: tone(2)?, preserve_luminosity: *d.get(18)? != 0 }) +} + +/// Photoshop's default Black & White mix (reds, yellows, greens, cyans, blues, magentas) and tint. +const BW_DEFAULT: [f32; 6] = [40.0, 60.0, 40.0, 60.0, 20.0, 80.0]; +const BW_KEYS: [&str; 6] = ["Rd ", "Yllw", "Grn ", "Cyn ", "Bl ", "Mgnt"]; +const BW_TINT: [f32; 3] = [225.0 / 255.0, 211.0 / 255.0, 179.0 / 255.0]; + +/// An `RGBC` colour (0..255 doubles) read in f64 so `write`'s encoding comes back bit-exact; +/// other colour classes go through [`crate::blocks::color_from_desc`]. +fn desc_rgb(d: &Descriptor) -> Option<[f32; 3]> { + let g = |k: &str| match d.get(k)? { + Value::Double(v) => Some(*v), + Value::Integer(v) => Some(f64::from(*v)), + Value::UnitFloat { value, .. } => Some(*value), + _ => None, + }; + if d.class_id.as_bytes() == b"RGBC" && d.get("redFloat").is_none() { + let c = [g("Rd ")?, g("Grn ")?, g("Bl ")?]; + return Some(c.map(|v| (v / 255.0).clamp(0.0, 1.0) as f32)); + } + crate::blocks::color_from_desc(d).map(|c| c.to_rgb().map(|v| v.clamp(0.0, 1.0))) +} + +fn rgb_desc(c: [f32; 3]) -> Descriptor { + let d = |v: f32| Value::Double(f64::from(v.clamp(0.0, 1.0)) * 255.0); + Descriptor::new("RGBC").with("Rd ", d(c[0])).with("Grn ", d(c[1])).with("Bl ", d(c[2])) +} + +/// `blwh` (Adobe spec, "Black and White"): a versioned descriptor with the six colour weights +/// (`Rd `, `Yllw`, `Grn `, `Cyn `, `Bl `, `Mgnt`, percent), `useTint` and `tintColor`. +fn parse_black_white(d: &[u8]) -> Option { + let desc = versioned(d)?; + let mut weights = BW_DEFAULT; + for (w, k) in weights.iter_mut().zip(BW_KEYS) { + if let Some(v) = desc_num(&desc, k) { + *w = v.clamp(-200.0, 300.0); + } + } + let tint = desc_bool(&desc, "useTint").unwrap_or(false).then(|| crate::blocks::get_desc(&desc, "tintColor").and_then(desc_rgb).unwrap_or(BW_TINT)); + Some(Adjustment::BlackWhite { weights, tint }) +} + +/// `phfl` (Adobe spec, "Photo Filter"): version (2 or 3); version 2 has a colour structure +/// (colour space + four u16), version 3 three i32 holding the Lab colour ×100 (as documented by +/// ag-psd, MIT); then density (u32 percent) and preserve luminosity (1 byte). +fn parse_photo_filter(d: &[u8]) -> Option { + let (color, at) = match be16(d, 0)? { + 2 => { + let space = be16(d, 2)?; + if !matches!(space, 0 | 2 | 7 | 8) { + return None; + } + let c = [be16(d, 4)?, be16(d, 6)?, be16(d, 8)?, be16(d, 10)?]; + (crate::channel_map::decode_color(space, c).to_rgb(), 12) + } + 3 => { + let lab = [be32(d, 2)? as i32, be32(d, 6)? as i32, be32(d, 10)? as i32].map(|v| v as f32 / 100.0); + if !(0.0..=100.0).contains(&lab[0]) || lab[1].abs() > 128.0 || lab[2].abs() > 128.0 { + return None; + } + (photocraft_color::convert::lab_to_srgb(lab), 14) + } + _ => return None, + }; + let density = (be32(d, at)? as f32 / 100.0).clamp(0.0, 1.0); + let preserve_luminosity = *d.get(at + 4)? != 0; + Some(Adjustment::PhotoFilter { color: color.map(|v| v.clamp(0.0, 1.0)), density, preserve_luminosity }) +} + +/// `mixr` (Adobe spec, "Channel Mixer"): version (1), monochrome (u16), then one 10-byte record +/// per output channel: four i16 source percentages (red, green, blue, and a fourth used only by +/// CMYK documents) and an i16 constant (percent). With monochrome on, the first record is the +/// gray mix (as ag-psd, MIT, documents). +fn parse_channel_mixer(d: &[u8]) -> Option { + if be16(d, 0)? != 1 { + return None; + } + let monochrome = be16(d, 2)? != 0; + let rec = |r: usize| -> Option<[f32; 4]> { + let at = 4 + r * 10; + let v = |k: usize| bei16(d, at + k * 2).map(|x| f32::from(x).clamp(-200.0, 200.0) / 100.0); + Some([v(0)?, v(1)?, v(2)?, v(4)?]) + }; + let first = rec(0)?; + // A monochrome block may carry only the gray record. + let (g, b) = if monochrome { (rec(1).unwrap_or([0.0; 4]), rec(2).unwrap_or([0.0; 4])) } else { (rec(1)?, rec(2)?) }; + Some(Adjustment::ChannelMixer { matrix: [first, g, b], monochrome }) +} + fn put16(v: &mut Vec, x: u16) { v.extend_from_slice(&x.to_be_bytes()); } @@ -337,7 +466,8 @@ fn levels_write(v: &mut Vec, c: &LevelsChannel) { } /// Serializes an adjustment into its PSD blocks: `(key, data)` pairs -/// (Brightness/Contrast also writes a `CgEd` descriptor). +/// (Brightness/Contrast also writes a `CgEd` descriptor). Every evaluated kind has an +/// encoding; the result is empty only for an [`Adjustment::Unsupported`] with a malformed key. pub fn write(adj: &Adjustment) -> Vec<([u8; 4], Vec)> { let mut v = Vec::new(); match adj { @@ -430,15 +560,23 @@ pub fn write(adj: &Adjustment) -> Vec<([u8; 4], Vec)> { } return vec![(*b"selc", v)]; } - Adjustment::ColorLookup { name, lut: Some(table), size, dither, .. } => { - let file = photocraft_cms::lutfile::LutFile { title: String::new(), size: *size as usize, data: table.to_vec() }; + Adjustment::ColorLookup { name, lut, size, dither, .. } => { + // A lookup without a usable table (none chosen yet, or a bad size) renders as the + // identity, so it is written as a 2³ identity cube rather than dropped. + let n = *size as usize; + let (file, dither) = match lut { + Some(table) if (2..=256).contains(&n) && table.len() >= n * n * n * 3 => { + (photocraft_cms::lutfile::LutFile { title: String::new(), size: n, data: table[..n * n * n * 3].to_vec() }, *dither) + } + _ => (photocraft_cms::lutfile::LutFile { title: String::new(), ..photocraft_cms::lutfile::LutFile::identity(2) }, false), + }; let en = |t: &str, val: &str| Value::Enumerated { type_id: photocraft_psd::descriptor::Id::new(t), value: photocraft_psd::descriptor::Id::new(val) }; let text = |t: &str| Value::Text(photocraft_psd::descriptor::UnicodeString::new_nul(t)); let d = Descriptor::new("null") .with("lookupType", en("colorLookupType", "3DLUT")) .with("NM ", text(name)) - .with("Dthr", Value::Boolean(*dither)) + .with("Dthr", Value::Boolean(dither)) .with("profile", Value::RawData(Vec::new())) .with("LUTFormat", en("LUTFormatType", "LUTFormatCUBE")) .with("dataOrder", en("colorLookupOrder", "rgbOrder")) @@ -449,15 +587,77 @@ pub fn write(adj: &Adjustment) -> Vec<([u8; 4], Vec)> { v.extend_from_slice(&VersionedDescriptor::new(d).to_bytes()); return vec![(*b"clrL", v)]; } - Adjustment::GradientMap { stops, reverse } if stops.len() >= 2 => return vec![(*b"grdm", write_gradient_map(stops, *reverse))], + Adjustment::GradientMap { stops, reverse } => { + // Photoshop needs two stops; fewer are written as the gradient they render as + // (none: black to white, i.e. the gray value itself; one: that colour throughout). + let stops = match stops.as_slice() { + [] => vec![(0.0, [0.0; 3]), (1.0, [1.0; 3])], + [(_, c)] => vec![(0.0, *c), (1.0, *c)], + s => s.to_vec(), + }; + return vec![(*b"grdm", write_gradient_map(&stops, *reverse))]; + } + Adjustment::Vibrance { vibrance, saturation } => { + let d = Descriptor::new("null") + .with("vibrance", Value::Integer(i32::from(clamp_i16(vibrance.clamp(-100.0, 100.0))))) + .with("Strt", Value::Integer(i32::from(clamp_i16(saturation.clamp(-100.0, 100.0))))); + return vec![(*b"vibA", VersionedDescriptor::new(d).to_bytes())]; + } + Adjustment::ColorBalance { shadows, midtones, highlights, preserve_luminosity } => { + for x in shadows.iter().chain(midtones).chain(highlights) { + v.extend_from_slice(&clamp_i16(x.clamp(-100.0, 100.0)).to_be_bytes()); + } + v.push(u8::from(*preserve_luminosity)); + v.push(0); + return vec![(*b"blnc", v)]; + } + Adjustment::BlackWhite { weights, tint } => { + let mut d = Descriptor::new("null"); + for (w, k) in weights.iter().zip(BW_KEYS) { + d = d.with(k, Value::Integer(i32::from(clamp_i16(w.clamp(-200.0, 300.0))))); + } + let d = d + .with("useTint", Value::Boolean(tint.is_some())) + .with("tintColor", Value::Descriptor(rgb_desc(tint.unwrap_or(BW_TINT)))) + .with("bwPresetKind", Value::Integer(1)) + .with("blackAndWhitePresetFileName", Value::Text(photocraft_psd::descriptor::UnicodeString::new_nul(""))); + return vec![(*b"blwh", VersionedDescriptor::new(d).to_bytes())]; + } + Adjustment::PhotoFilter { color, density, preserve_luminosity } => { + // Version 2: an RGB colour structure keeps the colour at 16 bits. + put16(&mut v, 2); + put16(&mut v, 0); + for x in color { + put16(&mut v, (x.clamp(0.0, 1.0) * 65535.0).round() as u16); + } + put16(&mut v, 0); + v.extend_from_slice(&((density.clamp(0.0, 1.0) * 100.0).round() as u32).to_be_bytes()); + v.push(u8::from(*preserve_luminosity)); + v.push(0); + return vec![(*b"phfl", v)]; + } + Adjustment::ChannelMixer { matrix, monochrome } => { + put16(&mut v, 1); + put16(&mut v, u16::from(*monochrome)); + // Red, green, blue, then the gray record (the first row, which monochrome uses). + for row in matrix.iter().chain(std::iter::once(&matrix[0])) { + let pct = |x: f32| clamp_i16((x * 100.0).clamp(-200.0, 200.0)).to_be_bytes(); + for x in &row[..3] { + v.extend_from_slice(&pct(*x)); + } + v.extend_from_slice(&[0, 0]); + v.extend_from_slice(&pct(row[3])); + } + return vec![(*b"mixr", v)]; + } Adjustment::Unsupported { psd_key, raw } => { let k = psd_key.as_bytes(); if k.len() == 4 { return vec![([k[0], k[1], k[2], k[3]], raw.clone())]; } } - _ => {} } + // Only an `Unsupported` block whose key is not four bytes has no encoding. Vec::new() } @@ -530,6 +730,17 @@ mod tests { rt(Adjustment::GradientMap { stops: vec![(0.0, [0.0, 0.0, 0.0]), (0.5, [1.0, 0.0, 0.0]), (1.0, [1.0, 1.0, 1.0])], reverse: true }); rt(Adjustment::SelectiveColor { relative: true, adjustments: std::array::from_fn(|r| [r as f32 * 10.0 - 40.0, 5.0, -100.0, 100.0]) }); rt(Adjustment::SelectiveColor { relative: false, adjustments: [[0.0; 4]; 9] }); + rt(Adjustment::Vibrance { vibrance: 35.0, saturation: -12.0 }); + rt(Adjustment::Vibrance { vibrance: 0.0, saturation: 0.0 }); + rt(Adjustment::ColorBalance { shadows: [20.0, -10.0, 5.0], midtones: [-15.0, 10.0, 30.0], highlights: [0.0, 5.0, -100.0], preserve_luminosity: true }); + rt(Adjustment::ColorBalance { shadows: [0.0; 3], midtones: [100.0, 0.0, 0.0], highlights: [0.0; 3], preserve_luminosity: false }); + rt(Adjustment::BlackWhite { weights: [40.0, 60.0, 40.0, 60.0, 20.0, 80.0], tint: None }); + rt(Adjustment::BlackWhite { weights: [-200.0, 300.0, 0.0, 10.0, 90.0, 30.0], tint: Some([0.9, 0.7, 0.5]) }); + let q = |v: u16| f32::from(v) / 65535.0; + rt(Adjustment::PhotoFilter { color: [q(60000), q(30000), q(0)], density: 0.25, preserve_luminosity: true }); + rt(Adjustment::PhotoFilter { color: [q(0), q(40000), q(65535)], density: 1.0, preserve_luminosity: false }); + rt(Adjustment::ChannelMixer { matrix: [[0.5, 0.3, 0.2, 0.0], [0.1, 0.8, 0.1, 0.05], [0.0, 0.2, 0.9, -0.05]], monochrome: false }); + rt(Adjustment::ChannelMixer { matrix: [[0.4, 0.4, 0.2, 0.1], [0.0, 1.0, 0.0, 0.0], [-2.0, 0.0, 2.0, 0.0]], monochrome: true }); let id = photocraft_cms::lutfile::LutFile::identity(5); rt(Adjustment::ColorLookup { name: "Look.cube".into(), lut: Some(std::sync::Arc::new(id.data)), size: 5, tetrahedral: false, dither: true }); } @@ -587,7 +798,178 @@ mod tests { assert!(matches!(parse(b"levl", &[0, 2, 1], None, Channels::Rgb), Adjustment::Unsupported { .. })); assert!(matches!(parse(b"curv", &[0, 0, 9], None, Channels::Rgb), Adjustment::Unsupported { .. })); assert!(matches!(parse(b"thrs", &[], None, Channels::Rgb), Adjustment::Unsupported { .. })); - assert!(matches!(parse(b"blnc", &[0; 40], None, Channels::Rgb), Adjustment::Unsupported { .. })); + assert!(matches!(parse(b"blnc", &[0; 18], None, Channels::Rgb), Adjustment::Unsupported { .. })); + assert!(matches!(parse(b"vibA", &[0, 0, 0, 15, 0], None, Channels::Rgb), Adjustment::Unsupported { .. })); + assert!(matches!(parse(b"blwh", &[], None, Channels::Rgb), Adjustment::Unsupported { .. })); + assert!(matches!(parse(b"mixr", &[0, 2, 0, 0], None, Channels::Rgb), Adjustment::Unsupported { .. })); + // A CMYK mixer (black source used) stays raw in CMYK documents; our own RGB layout maps. + let mut cmyk = i16s(&[1, 0]); + for r in 0..4 { + cmyk.extend(i16s(&std::array::from_fn::(|k| if k == r { 100 } else { 0 }))); + } + assert!(matches!(parse(b"mixr", &cmyk, None, Channels::Other), Adjustment::Unsupported { .. })); + let ours = write(&Adjustment::ChannelMixer { matrix: [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]], monochrome: false }); + assert!(matches!(parse(b"mixr", &ours[0].1, None, Channels::Other), Adjustment::ChannelMixer { .. })); + assert!(matches!(parse(b"mixr", &[0, 1, 0, 0, 0, 100], None, Channels::Rgb), Adjustment::Unsupported { .. })); + // Photo Filter: unknown version, HSB colour space, out-of-range version-3 Lab, truncated. + assert!(matches!(parse(b"phfl", &[0, 4, 0, 0], None, Channels::Rgb), Adjustment::Unsupported { .. })); + assert!(matches!(parse(b"phfl", &[0, 2, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 25, 1], None, Channels::Rgb), Adjustment::Unsupported { .. })); + let mut lab = vec![0, 3]; + for x in [20000i32, 0, 0] { + lab.extend(x.to_be_bytes()); + } + lab.extend([0, 0, 0, 25, 1]); + assert!(matches!(parse(b"phfl", &lab, None, Channels::Rgb), Adjustment::Unsupported { .. })); + assert!(matches!(parse(b"phfl", &[0, 2, 0, 0, 0, 0], None, Channels::Rgb), Adjustment::Unsupported { .. })); + // Arbitrary bytes never panic, whatever the key. + for key in ADJUSTMENT_KEYS { + for len in 0..64usize { + let d: Vec = (0..len).map(|i| (i * 37 + len) as u8).collect(); + let _ = parse(key, &d, Some(&d), Channels::Rgb); + let _ = parse(key, &d, None, Channels::Other); + } + } + } + + fn i16s(v: &[i16]) -> Vec { + v.iter().flat_map(|x| x.to_be_bytes()).collect() + } + + /// Hand-built blocks laid out per the Adobe spec (no file written by Photoshop involved). + #[test] + fn color_balance_golden_block() { + let mut d = i16s(&[-30, 20, 0, 10, -5, 100, 0, 0, -100]); + d.extend([1, 0]); + let a = parse(b"blnc", &d, None, Channels::Rgb); + assert_eq!( + a, + Adjustment::ColorBalance { shadows: [-30.0, 20.0, 0.0], midtones: [10.0, -5.0, 100.0], highlights: [0.0, 0.0, -100.0], preserve_luminosity: true } + ); + assert_eq!(write(&a), vec![(*b"blnc", d.clone())]); + // The 19-byte form (no trailing pad) parses the same. + assert_eq!(parse(b"blnc", &d[..19], None, Channels::Rgb), a); + } + + #[test] + fn photo_filter_golden_blocks() { + // Version 2, RGB colour structure (65535, 32768, 0), density 40 %, preserve luminosity. + let mut v2 = i16s(&[2, 0]); + v2.extend([0xff, 0xff, 0x80, 0x00, 0, 0, 0, 0]); + v2.extend(40u32.to_be_bytes()); + v2.extend([1, 0]); + let a = parse(b"phfl", &v2, None, Channels::Rgb); + let Adjustment::PhotoFilter { color, density, preserve_luminosity: true } = a else { panic!("{a:?}") }; + assert_eq!(color, [1.0, 32768.0 / 65535.0, 0.0]); + assert_eq!(density, 0.4); + assert_eq!(write(&a)[0].1, v2, "version 2 RGB is written back byte-exact"); + // Version 2, Lab colour structure: L 50, a 0, b 0 is mid gray. + let mut lab = i16s(&[2, 7, 5000, 0, 0, 0]); + lab.extend(25u32.to_be_bytes()); + lab.push(0); + let Adjustment::PhotoFilter { color, density, preserve_luminosity: false } = parse(b"phfl", &lab, None, Channels::Rgb) else { panic!() }; + assert!(color.iter().all(|c| (c - 0.466).abs() < 0.01), "{color:?}"); + assert_eq!(density, 0.25); + // Version 3: Lab ×100 as i32 (L 100, a 0, b 0 = white), density 100 %. + let mut v3 = vec![0, 3]; + for x in [10000i32, 0, 0] { + v3.extend(x.to_be_bytes()); + } + v3.extend(100u32.to_be_bytes()); + v3.push(1); + let Adjustment::PhotoFilter { color, density, preserve_luminosity: true } = parse(b"phfl", &v3, None, Channels::Rgb) else { panic!() }; + assert!(color.iter().all(|c| (c - 1.0).abs() < 0.01), "{color:?}"); + assert_eq!(density, 1.0); + // A negative a* (green) in version 3. + let mut green = vec![0, 3]; + for x in [5000i32, -6000, 0] { + green.extend(x.to_be_bytes()); + } + green.extend(30u32.to_be_bytes()); + green.push(0); + let Adjustment::PhotoFilter { color, .. } = parse(b"phfl", &green, None, Channels::Rgb) else { panic!() }; + assert!(color[1] > color[0] && color[1] > color[2], "{color:?}"); + } + + #[test] + fn channel_mixer_golden_blocks() { + // RGB: red = 100 % R + 20 % const; green = 50 % R + 50 % G; blue = -50 % G + 150 % B - 10 % const. + let mut d = i16s(&[1, 0]); + d.extend(i16s(&[100, 0, 0, 0, 20, 50, 50, 0, 0, 0, 0, -50, 150, 0, -10])); + let a = parse(b"mixr", &d, None, Channels::Rgb); + assert_eq!(a, Adjustment::ChannelMixer { matrix: [[1.0, 0.0, 0.0, 0.2], [0.5, 0.5, 0.0, 0.0], [0.0, -0.5, 1.5, -0.1]], monochrome: false }); + // Written with the trailing gray record (a copy of the first row). + let w = write(&a); + assert_eq!(w[0].1[..d.len()], d[..]); + assert_eq!(w[0].1[d.len()..], i16s(&[100, 0, 0, 0, 20])[..]); + // Monochrome with only the gray record: 30/59/11. + let mut m = i16s(&[1, 1]); + m.extend(i16s(&[30, 59, 11, 0, 0])); + let Adjustment::ChannelMixer { matrix, monochrome: true } = parse(b"mixr", &m, None, Channels::Rgb) else { panic!() }; + assert_eq!(matrix[0], [0.3, 0.59, 0.11, 0.0]); + } + + fn vdesc(d: Descriptor) -> Vec { + VersionedDescriptor::new(d).to_bytes() + } + + #[test] + fn vibrance_golden_block() { + let d = vdesc(Descriptor::new("null").with("vibrance", Value::Integer(-40)).with("Strt", Value::Integer(25))); + assert_eq!(&d[..4], &[0, 0, 0, 16], "descriptor version 16"); + assert_eq!(parse(b"vibA", &d, None, Channels::Rgb), Adjustment::Vibrance { vibrance: -40.0, saturation: 25.0 }); + // Photoshop omits zero values. + let only = vdesc(Descriptor::new("null").with("Strt", Value::Integer(10))); + assert_eq!(parse(b"vibA", &only, None, Channels::Rgb), Adjustment::Vibrance { vibrance: 0.0, saturation: 10.0 }); + // Other descriptor versions are rejected (kept raw). + let mut v = d.clone(); + v[3] = 17; + assert!(matches!(parse(b"vibA", &v, None, Channels::Rgb), Adjustment::Unsupported { .. })); + } + + #[test] + fn black_white_golden_block() { + let tint = Descriptor::new("RGBC").with("Rd ", Value::Double(255.0)).with("Grn ", Value::Double(127.5)).with("Bl ", Value::Double(0.0)); + let mut desc = Descriptor::new("null"); + for (k, w) in BW_KEYS.iter().zip([10, 20, 30, 40, 50, 60]) { + desc = desc.with(k, Value::Integer(w)); + } + let d = vdesc( + desc.clone().with("useTint", Value::Boolean(true)).with("tintColor", Value::Descriptor(tint.clone())).with("bwPresetKind", Value::Integer(1)), + ); + let a = parse(b"blwh", &d, None, Channels::Rgb); + assert_eq!(a, Adjustment::BlackWhite { weights: [10.0, 20.0, 30.0, 40.0, 50.0, 60.0], tint: Some([1.0, 0.5, 0.0]) }); + // useTint off: no tint, even with a stored colour. + let off = vdesc(desc.with("useTint", Value::Boolean(false)).with("tintColor", Value::Descriptor(tint))); + assert!(matches!(parse(b"blwh", &off, None, Channels::Rgb), Adjustment::BlackWhite { tint: None, .. })); + // Missing weights take Photoshop's defaults. + let empty = vdesc(Descriptor::new("null")); + assert_eq!(parse(b"blwh", &empty, None, Channels::Rgb), Adjustment::BlackWhite { weights: BW_DEFAULT, tint: None }); + } + + #[test] + fn degenerate_adjustments_still_get_an_encoding() { + // Gradient maps with fewer than two stops render as gray (none) or one colour. + for (stops, want) in + [(vec![], vec![(0.0, [0.0; 3]), (1.0, [1.0; 3])]), (vec![(0.3, [0.2, 0.4, 0.6])], vec![(0.0, [0.2, 0.4, 0.6]), (1.0, [0.2, 0.4, 0.6])])] + { + let b = write(&Adjustment::GradientMap { stops, reverse: true }); + let back = parse(&b[0].0, &b[0].1, None, Channels::Rgb); + let Adjustment::GradientMap { stops, reverse: true } = back else { panic!("{back:?}") }; + assert_eq!(stops.len(), want.len()); + for (s, w) in stops.iter().zip(&want) { + assert!((s.0 - w.0).abs() < 1e-6 && (0..3).all(|k| (s.1[k] - w.1[k]).abs() < 1e-4), "{s:?} vs {w:?}"); + } + } + // A colour lookup without a (usable) table becomes an identity cube, without dither. + for lut in [None, Some(std::sync::Arc::new(vec![0.5; 7]))] { + let b = write(&Adjustment::ColorLookup { name: "L".into(), lut, size: 3, tetrahedral: false, dither: true }); + let back = parse(&b[0].0, &b[0].1, None, Channels::Rgb); + let Adjustment::ColorLookup { lut: Some(t), size: 2, dither: false, name, .. } = back else { panic!("{back:?}") }; + assert_eq!(name, "L"); + assert_eq!(*t, photocraft_cms::lutfile::LutFile::identity(2).data); + } + // Every kind except a malformed Unsupported key has an encoding. + assert!(write(&Adjustment::Unsupported { psd_key: "abc".into(), raw: vec![] }).is_empty()); } #[test] diff --git a/crates/io/src/channel_map.rs b/crates/io/src/channel_map.rs index 9374ad1d..f8dada8b 100644 --- a/crates/io/src/channel_map.rs +++ b/crates/io/src/channel_map.rs @@ -58,7 +58,7 @@ pub(crate) fn apply_display_info(data: &[u8], versioned: bool, channels: &mut [A } } -fn decode_color(space: u16, c: [u16; 4]) -> Color { +pub(crate) fn decode_color(space: u16, c: [u16; 4]) -> Color { let n = |v: u16| f32::from(v) / 65535.0; match space { // Photoshop colour structures store CMYK inverted (65535 = no ink). diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index cac48fbf..57cb2752 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -333,9 +333,8 @@ impl Ex { { return c.surface.clone(); } - let mut plain = Layer::new("fill", LayerContent::Fill(f.clone())); - plain.fill_cache = None; - let buf = photocraft_compose::render_layer(&plain, self.canvas); + // In the frame the layer's masks give it, like the compositor (masks are stored apart). + let buf = photocraft_compose::render_fill_content(l, f, self.canvas, &[]); let mut s = Surface::new(self.fmt); let vals: Vec = buf.px.iter().flat_map(|p| photocraft_raster::from_rgba(&self.fmt, *p)).collect(); s.write_region(self.canvas, &vals); diff --git a/crates/io/tests/adjust_blend_roundtrip.rs b/crates/io/tests/adjust_blend_roundtrip.rs new file mode 100644 index 00000000..b4ea261a --- /dev/null +++ b/crates/io/tests/adjust_blend_roundtrip.rs @@ -0,0 +1,239 @@ +//! Every adjustment kind and every blend mode through document → PSD → document. +//! +//! Asserts doc-model equality where the PSD encoding is lossless, and that the re-imported +//! document flattens to the same composite (within 1/255) in every case, so nothing a user made +//! in PhotoCraft silently disappears or changes when Photoshop opens the file. + +mod common; + +use std::sync::Arc; + +use common::*; +use photocraft_color::{BlendMode, ColorMode, SampleType}; +use photocraft_doc::adjust::{CurvePoint, LevelsChannel}; +use photocraft_doc::*; +use photocraft_geom::Rect; +use photocraft_io::*; +use photocraft_psd::PsdFile; + +const TOL: f32 = 1.0 / 255.0 + 1e-5; + +fn export_import(doc: &Document) -> (Document, Vec, PsdFile) { + let r = export(doc, "x.psd", &ExportOptions::default()).expect("export"); + let file = PsdFile::from_bytes(&r.bytes).expect("parse"); + let back = import("x.psd", &r.bytes).expect("import").document; + (back, r.warnings, file) +} + +fn assert_composite_eq(a: &Document, b: &Document, ctx: &str) { + let (x, y) = (photocraft_compose::flatten(a), photocraft_compose::flatten(b)); + assert_eq!(x.rect, y.rect, "{ctx}: composite rect"); + let m = max_diff(&x.px, &y.px); + assert!(m <= TOL, "{ctx}: composite differs by {m} (> 1/255)"); +} + +fn q16(v: u16) -> f32 { + f32::from(v) / 65535.0 +} + +/// Every [`Adjustment`] kind with representative settings, and whether its PSD encoding is +/// lossless (the document model comes back equal) or only renders the same. +fn adjustments() -> Vec<(Adjustment, bool)> { + let pts = |v: &[(u8, u8)]| v.iter().map(|&(i, o)| CurvePoint { input: g(i), output: g(o) }).collect::>(); + let lc = |a: u8, b: u8, gamma: f32| LevelsChannel { in_black: g(a), in_white: g(b), gamma, out_black: g(5), out_white: g(250) }; + let lut = photocraft_cms::lutfile::LutFile::from_fn("t", 5, |c| [c[1], c[2] * 0.5, 1.0 - c[0]]); + let all = vec![ + (Adjustment::BrightnessContrast { brightness: 30.0, contrast: -20.0, legacy: false }, true), + (Adjustment::BrightnessContrast { brightness: -40.0, contrast: 50.0, legacy: true }, true), + (Adjustment::Levels { master: lc(10, 240, 1.2), per_channel: [lc(0, 255, 1.0), lc(5, 250, 0.8), lc(20, 200, 1.5)] }, true), + ( + Adjustment::Curves { + master: pts(&[(0, 0), (128, 150), (255, 255)]), + per_channel: [pts(&[(0, 10), (255, 255)]), pts(&[(0, 0), (255, 245)]), pts(&[(0, 0), (64, 32), (255, 255)])], + }, + true, + ), + (Adjustment::Exposure { exposure: 0.75, offset: -0.0125, gamma: 0.9 }, true), + (Adjustment::Vibrance { vibrance: 60.0, saturation: -25.0 }, true), + (Adjustment::HueSaturation { hue: 40.0, saturation: -30.0, lightness: 10.0, colorize: false }, true), + (Adjustment::HueSaturation { hue: 200.0, saturation: 50.0, lightness: -10.0, colorize: true }, true), + ( + Adjustment::ColorBalance { shadows: [20.0, -10.0, 5.0], midtones: [-15.0, 10.0, 30.0], highlights: [0.0, 5.0, -20.0], preserve_luminosity: true }, + true, + ), + (Adjustment::ColorBalance { shadows: [60.0, 0.0, 0.0], midtones: [0.0; 3], highlights: [0.0, 0.0, 40.0], preserve_luminosity: false }, true), + (Adjustment::BlackWhite { weights: [40.0, 60.0, 40.0, 60.0, 20.0, 80.0], tint: None }, true), + (Adjustment::BlackWhite { weights: [70.0, 20.0, 50.0, -10.0, 90.0, 130.0], tint: Some([0.9, 0.7, 0.5]) }, true), + (Adjustment::PhotoFilter { color: [q16(60620), q16(35455), q16(0)], density: 0.4, preserve_luminosity: true }, true), + (Adjustment::PhotoFilter { color: [q16(0), q16(39321), q16(65535)], density: 0.3, preserve_luminosity: false }, true), + (Adjustment::ChannelMixer { matrix: [[0.5, 0.3, 0.2, 0.0], [0.1, 0.8, 0.1, 0.05], [0.0, 0.2, 0.9, -0.05]], monochrome: false }, true), + (Adjustment::ChannelMixer { matrix: [[0.4, 0.4, 0.2, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]], monochrome: true }, true), + (Adjustment::ColorLookup { name: "Look.cube".into(), lut: Some(Arc::new(lut.data)), size: 5, tetrahedral: false, dither: false }, true), + // No table chosen: written as an identity cube (renders the same, comes back with a table). + (Adjustment::ColorLookup { name: String::new(), lut: None, size: 0, tetrahedral: false, dither: false }, false), + (Adjustment::Invert, true), + (Adjustment::Posterize { levels: 4 }, true), + (Adjustment::Threshold { level: g(140) }, true), + (Adjustment::GradientMap { stops: vec![(0.0, [q16(6554), 0.0, q16(19661)]), (0.5, [1.0, 0.2, 0.0]), (1.0, [1.0, 1.0, 0.8])], reverse: false }, true), + (Adjustment::GradientMap { stops: vec![(0.0, [0.0, 0.0, 1.0]), (1.0, [1.0, 1.0, 0.0])], reverse: true }, true), + // Fewer than two stops: written as the equivalent two-stop gradient. + (Adjustment::GradientMap { stops: vec![], reverse: false }, false), + (Adjustment::GradientMap { stops: vec![(0.4, [0.2, 0.6, 0.4])], reverse: false }, false), + (Adjustment::SelectiveColor { relative: true, adjustments: std::array::from_fn(|r| [r as f32 * 10.0 - 40.0, 5.0, -30.0, 20.0]) }, true), + (Adjustment::SelectiveColor { relative: false, adjustments: std::array::from_fn(|r| [0.0, r as f32 * 5.0, 0.0, -10.0]) }, true), + // A block this version cannot read (a truncated Selective Color) is kept verbatim. + (Adjustment::Unsupported { psd_key: "selc".into(), raw: vec![0, 1, 0, 0] }, true), + ]; + // Compile-time guard: a new Adjustment kind must be added to the list above. + for (a, _) in &all { + match a { + Adjustment::BrightnessContrast { .. } + | Adjustment::Levels { .. } + | Adjustment::Curves { .. } + | Adjustment::Exposure { .. } + | Adjustment::Vibrance { .. } + | Adjustment::HueSaturation { .. } + | Adjustment::ColorBalance { .. } + | Adjustment::BlackWhite { .. } + | Adjustment::PhotoFilter { .. } + | Adjustment::ChannelMixer { .. } + | Adjustment::ColorLookup { .. } + | Adjustment::Invert + | Adjustment::Posterize { .. } + | Adjustment::Threshold { .. } + | Adjustment::GradientMap { .. } + | Adjustment::SelectiveColor { .. } + | Adjustment::Unsupported { .. } => {} + } + } + all +} + +/// A colourful base, a clipping base, and `adj` three times: plain; at half opacity with a mask +/// and a blend mode; clipped to a pixel layer. +fn adjustment_doc(adj: &Adjustment, depth: SampleType) -> Document { + let mut d = Document::new("adj", photocraft_geom::Size::new(32, 20), ColorMode::Rgb, depth); + let fmt = d.pixel_format(); + d.layers.push(raster("Background", fmt, d.bounds(), 1, false)); + let mut plain = Layer::new("plain", LayerContent::Adjustment(adj.clone())); + plain.opacity = 1.0; + d.layers.push(plain); + let mut masked = Layer::new("masked", LayerContent::Adjustment(adj.clone())); + masked.opacity = g(128); + masked.blend = BlendMode::Multiply; + masked.mask = Some(LayerMask { surface: mask_surface(depth, Rect::new(4, 3, 20, 15), 1.0, 3), enabled: true, linked: true, density: 1.0, feather: 0.0 }); + d.layers.push(masked); + d.layers.push(raster("clip base", fmt, Rect::new(10, 2, 28, 18), 9, true)); + let mut clipped = Layer::new("clipped", LayerContent::Adjustment(adj.clone())); + clipped.clipped = true; + clipped.fill_opacity = g(200); + d.layers.push(clipped); + d +} + +fn check_adjustment(adj: &Adjustment, lossless: bool, depth: SampleType) { + let ctx = format!("{adj:?} @ {depth:?}"); + let d = adjustment_doc(adj, depth); + let (back, warnings, file) = export_import(&d); + assert!(!warnings.iter().any(|w| w.contains("adjustment")), "{ctx}: export warned {warnings:?}"); + // The adjustment layers carry an adjustment block, never an empty pixel layer. + let with_adj = file.layers().iter().filter(|l| l.blocks.iter().any(|b| photocraft_io::ADJUSTMENT_KEYS.contains(&&b.key))).count(); + assert_eq!(with_adj, 3, "{ctx}: adjustment blocks written"); + assert_eq!(back.layers.len(), d.layers.len(), "{ctx}: layer count"); + for (x, y) in d.layers.iter().zip(&back.layers) { + match (&x.content, &y.content) { + (LayerContent::Adjustment(a), LayerContent::Adjustment(b)) => { + if lossless { + assert_eq!(a, b, "{ctx}: adjustment"); + } else { + assert_eq!(std::mem::discriminant(a), std::mem::discriminant(b), "{ctx}: kind"); + } + assert_eq!((x.blend, x.opacity, x.fill_opacity, x.clipped), (y.blend, y.opacity, y.fill_opacity, y.clipped), "{ctx}: {}", x.name); + assert_eq!(x.mask.is_some(), y.mask.is_some(), "{ctx}: {} mask", x.name); + } + (LayerContent::Raster(_), LayerContent::Raster(_)) => {} + (p, q) => panic!("{ctx}: {} became {}", p.kind_name(), q.kind_name()), + } + } + if lossless { + assert_docs_eq(&d, &back); + } + assert_composite_eq(&d, &back, &ctx); + // A second generation is a fixed point for the document model. + let (again, _, _) = export_import(&back); + for (x, y) in back.layers.iter().zip(&again.layers) { + if let (LayerContent::Adjustment(a), LayerContent::Adjustment(b)) = (&x.content, &y.content) { + assert_eq!(a, b, "{ctx}: second generation"); + } + } +} + +#[test] +fn every_adjustment_kind_round_trips_8bit() { + for (adj, lossless) in adjustments() { + check_adjustment(&adj, lossless, SampleType::U8); + } +} + +#[test] +fn every_adjustment_kind_round_trips_16bit() { + for (adj, lossless) in adjustments() { + check_adjustment(&adj, lossless, SampleType::U16); + } +} + +/// One document per blend mode: the mode on a pixel layer (with fill opacity and a mask), on a +/// clipped layer, on a group, inside a pass-through group, and on an adjustment layer. +fn blend_doc(mode: BlendMode) -> Document { + let mut d = Document::new("blend", photocraft_geom::Size::new(24, 16), ColorMode::Rgb, SampleType::U8); + let fmt = d.pixel_format(); + d.layers.push(raster("Background", fmt, d.bounds(), 1, false)); + let mut l = raster("layer", fmt, Rect::new(2, 1, 20, 12), 2, true); + l.blend = mode; + l.fill_opacity = g(150); + l.opacity = g(220); + l.mask = + Some(LayerMask { surface: mask_surface(SampleType::U8, Rect::new(3, 2, 15, 10), 1.0, 4), enabled: true, linked: true, density: 1.0, feather: 0.0 }); + d.layers.push(l); + let mut clipped = raster("clipped", fmt, Rect::new(0, 0, 12, 16), 3, true); + clipped.clipped = true; + clipped.blend = mode; + d.layers.push(clipped); + let mut inner = raster("in pass-through", fmt, Rect::new(8, 4, 24, 16), 4, true); + inner.blend = mode; + d.layers.push(Layer::group("pass-through", vec![inner])); + let mut grp = Layer::group("group", vec![raster("group child", fmt, Rect::new(4, 6, 18, 14), 5, true)]); + grp.blend = mode; + grp.opacity = g(200); + d.layers.push(grp); + let mut adj = Layer::new("adjustment", LayerContent::Adjustment(Adjustment::Invert)); + adj.blend = mode; + adj.opacity = g(100); + d.layers.push(adj); + d +} + +#[test] +fn every_blend_mode_round_trips() { + let mut modes = BlendMode::LAYER_MODES.to_vec(); + modes.push(BlendMode::PassThrough); + for mode in modes { + if mode == BlendMode::PassThrough { + // Pass-through is a group-only mode; blend_doc's "pass-through" group covers it, + // and a pass-through group with an adjustment inside checks it affects the backdrop. + let mut d = blend_doc(BlendMode::Normal); + let fmt = d.pixel_format(); + let adj = Layer::new("inside", LayerContent::Adjustment(Adjustment::Invert)); + d.layers.push(Layer::group("pass-through adj", vec![raster("px", fmt, Rect::new(0, 0, 6, 6), 6, true), adj])); + let (back, _, file) = export_import(&d); + assert!(file.layers().iter().any(|l| l.blend_mode == photocraft_psd::BlendMode::PassThrough), "pass key written"); + assert_docs_eq(&d, &back); + assert_composite_eq(&d, &back, "pass-through"); + continue; + } + let d = blend_doc(mode); + let (back, _, _) = export_import(&d); + assert_docs_eq(&d, &back); + assert_composite_eq(&d, &back, mode.label()); + } +} diff --git a/crates/io/tests/common/mod.rs b/crates/io/tests/common/mod.rs index 433e4e4f..154ef85a 100644 --- a/crates/io/tests/common/mod.rs +++ b/crates/io/tests/common/mod.rs @@ -161,7 +161,17 @@ pub fn gen_doc(mode: ColorMode, depth: SampleType, f: Features) -> Document { }, Adjustment::Unsupported { psd_key: "selc".into(), raw: vec![0, 1, 0, 0] }, ]; - for (i, a) in adjs.into_iter().enumerate() { + // Colour adjustments (Photoshop offers them for colour documents only: they would tint + // a grayscale composite). + let colour = vec![ + Adjustment::Vibrance { vibrance: 40.0, saturation: -10.0 }, + Adjustment::ColorBalance { shadows: [10.0, 0.0, -5.0], midtones: [0.0, 20.0, 0.0], highlights: [-30.0, 0.0, 15.0], preserve_luminosity: true }, + Adjustment::BlackWhite { weights: [50.0, 60.0, 40.0, 60.0, 20.0, 70.0], tint: Some([1.0, 0.8, 0.6]) }, + Adjustment::PhotoFilter { color: [1.0, f32::from(32768u16) / 65535.0, 0.0], density: 0.25, preserve_luminosity: false }, + Adjustment::ChannelMixer { matrix: [[0.8, 0.2, 0.0, 0.0], [0.0, 1.0, 0.0, 0.1], [0.1, 0.0, 0.9, 0.0]], monochrome: false }, + ]; + let adjs = adjs.into_iter().chain(colour.into_iter().filter(|_| mode != ColorMode::Grayscale)); + for (i, a) in adjs.enumerate() { let mut l = Layer::new(format!("adj {i}"), LayerContent::Adjustment(a)); l.opacity = g(if i % 2 == 0 { 255 } else { 128 }); d.layers.push(l); diff --git a/crates/io/tests/corpus.rs b/crates/io/tests/corpus.rs index 162bb1a3..6c26f73e 100644 --- a/crates/io/tests/corpus.rs +++ b/crates/io/tests/corpus.rs @@ -3,9 +3,15 @@ //! //! For each file: parse → document → flatten, compared with the file's own //! merged composite (Photoshop's rendering) as the oracle; then document → -//! PSD → parse. Prints a per-file table. The oracle comparison is reported, -//! not asserted: differences are expected where features are not yet -//! rendered (effects, text engine, smart filters, some adjustments). +//! PSD → document → flatten, compared with the first flatten (our own export +//! must not change what the document looks like). Prints a per-file table. +//! +//! Without `PHOTOCRAFT_CORPUS` the comparisons are only reported: differences +//! are expected where features are not yet rendered (effects, text engine, +//! smart filters). With `PHOTOCRAFT_CORPUS` set (to the corpus directory, or +//! to `1` for the default location) the run asserts that the oracle pass +//! count and the export round-trip count do not fall below their floors. +//! Raise the floors when they improve; never lower them. //! Set `PHOTOCRAFT_CORPUS_STRICT=1` to fail on import/export errors. mod common; @@ -28,17 +34,29 @@ fn collect(dir: &Path, out: &mut Vec) { } const PASS_TOL: f32 = 2.0 / 255.0; +/// Export → re-import must render within one 8-bit step of the imported document. +const ROUNDTRIP_TOL: f32 = 1.0 / 255.0 + 1e-5; +/// Files whose flatten matches Photoshop's merged image (`corpus/psd`, 170 files). +const PASS_FLOOR: usize = 113; +/// Files whose export → re-import renders the same as the import. +const ROUNDTRIP_FLOOR: usize = 169; #[test] fn corpus_import_flatten_oracle() { - let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/psd"); + let env = std::env::var_os("PHOTOCRAFT_CORPUS").filter(|v| !v.is_empty()); + let root = match &env { + Some(v) if v != "1" => PathBuf::from(v), + _ => Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/psd"), + }; if !root.is_dir() { + assert!(env.is_none(), "PHOTOCRAFT_CORPUS is set but {} is not a directory", root.display()); return; } let mut files = Vec::new(); collect(&root, &mut files); files.sort(); let (mut pass, mut diff, mut skipped, mut errors) = (0, 0, 0, 0); + let (mut rt_same, mut rt_diff) = (0, Vec::new()); eprintln!("{:<60} {:>6} {:>9} {:>8} status", "file", "layers", "max_err", "bad_px%"); for p in &files { let name = p.strip_prefix(&root).unwrap_or(p).display().to_string(); @@ -60,20 +78,37 @@ fn corpus_import_flatten_oracle() { } }; let doc = &imp.document; - // Export must always succeed and re-parse. - match export(doc, "x.psd", &ExportOptions::default()) { + // Export must always succeed, re-parse and re-import. + let reimported = match export(doc, "x.psd", &ExportOptions::default()) { Ok(r) => { if let Err(e) = PsdFile::from_bytes(&r.bytes) { eprintln!("{name:<60} REEXPORT-PARSE-ERROR {e}"); errors += 1; continue; } + match import(&name, &r.bytes) { + Ok(i) => i.document, + Err(e) => { + eprintln!("{name:<60} REIMPORT-ERROR {e}"); + errors += 1; + continue; + } + } } Err(e) => { eprintln!("{name:<60} EXPORT-ERROR {e}"); errors += 1; continue; } + }; + let ours = photocraft_compose::flatten(doc).px; + // Our own export must not change how the document renders. + let again = photocraft_compose::flatten(&reimported).px; + let rt = if again.len() == ours.len() { common::max_diff(&ours, &again) } else { f32::INFINITY }; + if rt <= ROUNDTRIP_TOL { + rt_same += 1; + } else { + rt_diff.push(format!("{name} ({rt:.4})")); } let layers = doc.layer_count(); if file.has_real_merged_data() == Some(false) || file.layers().is_empty() { @@ -86,7 +121,6 @@ fn corpus_import_flatten_oracle() { skipped += 1; continue; }; - let ours = photocraft_compose::flatten(doc).px; let m = common::max_diff(&ours, &merged); let bad = ours.iter().zip(&merged).filter(|(a, b)| (0..4).any(|c| (a[c] * a[3] - b[c] * b[3]).abs() > PASS_TOL)).count(); let pct = 100.0 * bad as f32 / ours.len().max(1) as f32; @@ -101,7 +135,12 @@ fn corpus_import_flatten_oracle() { eprintln!("{name:<60} {layers:>6} {:>9.4} {:>7.2}% {status} {}", m, pct, notes.join(" | ")); } eprintln!("io corpus: {} files: {pass} pass (<= 2/255), {diff} differ, {skipped} skipped, {errors} errors", files.len()); + eprintln!("io corpus: export -> re-import renders the same for {rt_same} files; differs for {}: {}", rt_diff.len(), rt_diff.join(", ")); if std::env::var_os("PHOTOCRAFT_CORPUS_STRICT").is_some() { assert_eq!(errors, 0); } + if env.is_some() { + assert!(pass >= PASS_FLOOR, "oracle pass count {pass} fell below the floor {PASS_FLOOR}"); + assert!(rt_same >= ROUNDTRIP_FLOOR, "export round trip {rt_same} fell below the floor {ROUNDTRIP_FLOOR}: {rt_diff:?}"); + } }