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:
parent
8b1cbb741b
commit
51b82caf0e
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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:?}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue