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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-04 14:25:00 -04:00 committed by GitHub
parent 8b1cbb741b
commit 51b82caf0e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 694 additions and 19 deletions

View File

@ -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) => {

View File

@ -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<Adjustment> {
})
}
/// The descriptor of a `vibA` / `blwh` block: a 4-byte descriptor version (16), then the descriptor.
fn versioned(d: &[u8]) -> Option<Descriptor> {
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<Adjustment> {
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<Adjustment> {
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<Adjustment> {
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<Adjustment> {
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<Adjustment> {
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<u8>, x: u16) {
v.extend_from_slice(&x.to_be_bytes());
}
@ -337,7 +466,8 @@ fn levels_write(v: &mut Vec<u8>, 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<u8>)> {
let mut v = Vec::new();
match adj {
@ -430,15 +560,23 @@ pub fn write(adj: &Adjustment) -> Vec<([u8; 4], Vec<u8>)> {
}
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<u8>)> {
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::<i16, 5, _>(|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<u8> = (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<u8> {
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<u8> {
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]

View File

@ -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).

View File

@ -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<f32> = buf.px.iter().flat_map(|p| photocraft_raster::from_rgba(&self.fmt, *p)).collect();
s.write_region(self.canvas, &vals);

View File

@ -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<String>, 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::<Vec<_>>();
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());
}
}

View File

@ -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);

View File

@ -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<PathBuf>) {
}
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:?}");
}
}