mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-13 23:08:09 +00:00
PSD corpus: psd-tools set (pinned, verified), an oracle for every file, and the fixes it found (#195) (#195)
This commit is contained in:
1 parent
a4380b2953
commit
574d0d3d53
22 files changed
+1580
-157
No files matched your search
@@ -51,3 +51,8 @@ Artwork shown in the screenshots (all public domain, via Wikimedia Commons; deta
|
||||
`corpus/` is gitignored. `corpus/psd` holds MIT-licensed test PSDs (ag-psd, psd-tools), listed in
|
||||
`corpus/psd/SOURCES.md`; `cargo xtask corpus --download` fetches PngSuite (public domain). Files
|
||||
copied in by hand must be MIT, BSD or CC0. No test fixtures are committed to the repository.
|
||||
|
||||
`cargo xtask corpus --psd-tools` fetches every PSD/PSB of the psd-tools test set
|
||||
([`tests/psd_files`](https://github.com/psd-tools/psd-tools/tree/main/tests/psd_files), MIT,
|
||||
Copyright (c) 2019 Kota Yamaguchi) at a pinned commit into `corpus/psd-tools/`, verified against
|
||||
the sha256 list in `xtask/psd-tools-corpus.sha256`, with the upstream `LICENSE` next to them.
|
||||
@@ -528,6 +528,14 @@ pub fn paint_bounds(layer: &Layer) -> Option<Rect> {
|
||||
/// The layer's effective mask over `rect` (row-major), read once per tile: the pixel mask
|
||||
/// times the rasterized vector mask.
|
||||
fn mask_vals(layer: &Layer, rect: Rect, cx: &Ctx) -> Option<Vec<f32>> {
|
||||
// Feathers blur across tiles: read the cached, canvas-wide combined mask.
|
||||
if masks::has_feather(layer)
|
||||
&& let Some(s) = masks::combined_mask(layer, cx.canvas)
|
||||
{
|
||||
let mut v = Vec::new();
|
||||
s.read_region_into(rect, &mut v);
|
||||
return Some(v);
|
||||
}
|
||||
let vector = layer.vector_mask.as_ref().map(|vm| cx.vector_masks.values(vm, rect));
|
||||
let Some(m) = layer.mask.as_ref() else { return vector };
|
||||
let mut v = Vec::new();
|
||||
@@ -865,9 +873,12 @@ fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer
|
||||
}
|
||||
let opacity = layer.opacity * layer.fill_opacity;
|
||||
|
||||
// Pass-through groups composite their children straight into the backdrop.
|
||||
// Pass-through groups composite their children straight into the backdrop. Below 100% fill
|
||||
// Photoshop renders the group isolated, like Normal (psd-tools passthrough_fill_*: an
|
||||
// adjustment inside no longer reaches the layers beneath).
|
||||
if let LayerContent::Group(g) = &layer.content
|
||||
&& layer.blend == BlendMode::PassThrough
|
||||
&& layer.fill_opacity >= 1.0
|
||||
&& !effects::has_effects(layer)
|
||||
{
|
||||
let needs_mix = opacity < 1.0 || layer.mask.is_some() || layer.vector_mask.is_some();
|
||||
|
||||
+82
-17
@@ -1,7 +1,8 @@
|
||||
//! A layer's effective mask (pixel mask × vector mask) as one surface, for backends that sample
|
||||
//! masks from textures (the GPU compositor). Rasterising a vector mask costs a path coverage
|
||||
//! pass, so results are cached per mask state; unchanged masks return the same tiles (cheap to
|
||||
//! clone, and the GPU sees no change).
|
||||
//! A layer's effective mask (pixel mask × vector mask, each feathered) as one surface, for
|
||||
//! backends that sample masks from textures (the GPU compositor) and for feathered masks on the
|
||||
//! CPU. Rasterising a vector mask costs a path coverage pass and feathering a blur, so results
|
||||
//! are cached per mask state; unchanged masks return the same tiles (cheap to clone, and the
|
||||
//! GPU sees no change).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::hash::{Hash, Hasher};
|
||||
@@ -33,7 +34,7 @@ fn key(layer: &Layer, canvas: Rect) -> u64 {
|
||||
(canvas.x0, canvas.y0, canvas.x1, canvas.y1).hash(&mut h);
|
||||
format!("{:?}", layer.vector_mask).hash(&mut h);
|
||||
if let Some(m) = &layer.mask {
|
||||
(m.enabled, m.density.to_bits()).hash(&mut h);
|
||||
(m.enabled, m.density.to_bits(), m.feather.to_bits()).hash(&mut h);
|
||||
format!("{:?}", m.surface.default_pixel()).hash(&mut h);
|
||||
for (c, t) in m.surface.tiles() {
|
||||
(c.tx, c.ty, Arc::as_ptr(t) as usize).hash(&mut h);
|
||||
@@ -42,12 +43,66 @@ fn key(layer: &Layer, canvas: Rect) -> u64 {
|
||||
h.finish()
|
||||
}
|
||||
|
||||
/// The layer's mask values (pixel mask with density × vector mask, exactly as the CPU
|
||||
/// compositor applies them) as a single-channel f32 surface over `canvas`, or `None` when the
|
||||
/// layer has no enabled vector mask (use the pixel mask directly). Values outside the computed
|
||||
/// area are the surface's default.
|
||||
/// Gaussian sigma of a mask feather of `px` pixels (Properties › Feather). Fitted on the
|
||||
/// psd-tools corpus (layer_mask_data.psd, mask-parameters-no-real-channel.psd: sigma = feather
|
||||
/// beats 0.33, 0.5, 0.7, 1.3 and 1.6 times it).
|
||||
pub fn feather_sigma(px: f32) -> f32 {
|
||||
if px.is_finite() { (px * FEATHER_SIGMA).clamp(0.0, MAX_FEATHER_SIGMA) } else { 0.0 }
|
||||
}
|
||||
|
||||
const FEATHER_SIGMA: f32 = 1.0;
|
||||
/// Photoshop's feather tops out at 1000 px.
|
||||
const MAX_FEATHER_SIGMA: f32 = 1000.0 * FEATHER_SIGMA;
|
||||
|
||||
/// Whether `layer` has an enabled mask with a feather (rendered through [`combined_mask`]).
|
||||
pub fn has_feather(layer: &Layer) -> bool {
|
||||
layer.mask.as_ref().is_some_and(|m| m.enabled && feather_sigma(m.feather) > 0.0)
|
||||
|| layer.vector_mask.as_ref().is_some_and(|v| v.enabled && feather_sigma(v.feather) > 0.0)
|
||||
}
|
||||
|
||||
/// Approximate Gaussian blur of a `w`×`h` plane: three box passes per axis (edges clamp, which
|
||||
/// is exact here since the planes extend into constant mask regions).
|
||||
fn gaussian(v: &mut [f32], w: usize, h: usize, sigma: f32) {
|
||||
if sigma <= 0.0 || w == 0 || h == 0 {
|
||||
return;
|
||||
}
|
||||
// Box widths whose three-pass variance matches sigma² (Wells 1986 / Kovesi).
|
||||
let ideal = (12.0 * sigma * sigma / 3.0 + 1.0).sqrt();
|
||||
let r = (((ideal.floor() as usize) | 1).max(1) - 1) / 2;
|
||||
if r == 0 {
|
||||
return;
|
||||
}
|
||||
let mut line = Vec::new();
|
||||
let mut pass = |v: &mut [f32], len: usize, count: usize, at: &dyn Fn(usize, usize) -> usize| {
|
||||
for k in 0..count {
|
||||
for _ in 0..3 {
|
||||
line.clear();
|
||||
line.extend((0..len).map(|i| v[at(k, i)]));
|
||||
let get = |i: isize| line[i.clamp(0, len as isize - 1) as usize];
|
||||
let mut acc: f32 = (-(r as isize)..=r as isize).map(get).sum();
|
||||
let n = (2 * r + 1) as f32;
|
||||
for i in 0..len {
|
||||
v[at(k, i)] = acc / n;
|
||||
acc += get(i as isize + r as isize + 1) - get(i as isize - r as isize);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
pass(v, w, h, &|row, i| row * w + i);
|
||||
pass(v, h, w, &|col, i| i * w + col);
|
||||
}
|
||||
|
||||
/// The layer's mask values (pixel mask with density × vector mask, each blurred by its feather,
|
||||
/// exactly as the CPU compositor applies them) as a single-channel f32 surface over `canvas`, or
|
||||
/// `None` when the layer has no enabled vector mask and no feathered pixel mask (use the pixel
|
||||
/// mask directly). Values outside the computed area are the surface's default.
|
||||
pub fn combined_mask(layer: &Layer, canvas: Rect) -> Option<Surface> {
|
||||
let vm = layer.vector_mask.as_ref().filter(|v| v.enabled)?;
|
||||
let vm = layer.vector_mask.as_ref().filter(|v| v.enabled);
|
||||
let pixel = layer.mask.as_ref().filter(|m| m.enabled);
|
||||
let (sv, sp) = (vm.map_or(0.0, |v| feather_sigma(v.feather)), pixel.map_or(0.0, |m| feather_sigma(m.feather)));
|
||||
if vm.is_none() && sp <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let k = key(layer, canvas);
|
||||
{
|
||||
let mut c = cache().lock().unwrap_or_else(|e| e.into_inner());
|
||||
@@ -60,22 +115,26 @@ pub fn combined_mask(layer: &Layer, canvas: Rect) -> Option<Surface> {
|
||||
}
|
||||
// Far outside the path the vector mask is constant.
|
||||
let far = Rect::from_xywh(canvas.x0 - 1_000_000, canvas.y0 - 1_000_000, 1, 1);
|
||||
let v_out = photocraft_vector::vector_mask_values(vm, far)[0];
|
||||
let pixel = layer.mask.as_ref().filter(|m| m.enabled);
|
||||
let v_out = vm.map_or(1.0, |vm| photocraft_vector::vector_mask_values(vm, far)[0]);
|
||||
let p_def = pixel.map_or(1.0, |m| {
|
||||
let d = m.surface.default_pixel().first().copied().unwrap_or(1.0);
|
||||
1.0 - m.density * (1.0 - d)
|
||||
});
|
||||
let grow = |r: Rect, sigma: f32| {
|
||||
let m = (sigma * 3.0).ceil() as i32 + 2;
|
||||
if r.is_empty() { r } else { Rect::new(r.x0.saturating_sub(m), r.y0.saturating_sub(m), r.x1.saturating_add(m), r.y1.saturating_add(m)) }
|
||||
};
|
||||
// Where the product can differ from `p_def × v_out`: the path's bounds, plus the pixel
|
||||
// mask's painted tiles when the vector mask lets them through outside the path.
|
||||
let mut area = match vm.path.control_bounds() {
|
||||
Some((x0, y0, x1, y1)) => Rect::new(x0.floor() as i32 - 2, y0.floor() as i32 - 2, x1.ceil() as i32 + 2, y1.ceil() as i32 + 2),
|
||||
// mask's painted tiles when the vector mask lets them through outside the path, each grown
|
||||
// by its feather.
|
||||
let mut area = match vm.and_then(|vm| vm.path.control_bounds()) {
|
||||
Some((x0, y0, x1, y1)) => grow(Rect::new(x0.floor() as i32 - 2, y0.floor() as i32 - 2, x1.ceil() as i32 + 2, y1.ceil() as i32 + 2), sv),
|
||||
None => Rect::EMPTY,
|
||||
};
|
||||
if let Some(m) = pixel
|
||||
&& v_out > 0.0
|
||||
{
|
||||
let b = m.surface.content_bounds();
|
||||
let b = grow(m.surface.content_bounds(), sp);
|
||||
area = if area.is_empty() {
|
||||
b
|
||||
} else if b.is_empty() {
|
||||
@@ -87,10 +146,16 @@ pub fn combined_mask(layer: &Layer, canvas: Rect) -> Option<Surface> {
|
||||
let area = area.intersect(&canvas);
|
||||
let mut s = Surface::with_default(FORMAT, &[p_def * v_out]);
|
||||
if !area.is_empty() {
|
||||
let mut v = photocraft_vector::vector_mask_values(vm, area);
|
||||
let (w, h) = (area.width() as usize, area.height() as usize);
|
||||
let mut v = match vm {
|
||||
Some(vm) => photocraft_vector::vector_mask_values(vm, area),
|
||||
None => vec![1.0; w * h],
|
||||
};
|
||||
gaussian(&mut v, w, h, sv);
|
||||
if let Some(m) = pixel {
|
||||
let mut pm = Vec::new();
|
||||
m.values_into(area, &mut pm);
|
||||
gaussian(&mut pm, w, h, sp);
|
||||
for (a, b) in v.iter_mut().zip(pm) {
|
||||
*a *= b;
|
||||
}
|
||||
|
||||
@@ -153,6 +153,41 @@ fn isolated_group_differs_from_pass_through() {
|
||||
assert!((iso[0] - 0.5).abs() <= E, "{iso:?}");
|
||||
}
|
||||
|
||||
/// psd-tools passthrough_fill_adjustment.psd: below 100% fill a pass-through group is isolated,
|
||||
/// so an adjustment inside it no longer changes the layers beneath.
|
||||
#[test]
|
||||
fn pass_through_group_below_full_fill_is_isolated() {
|
||||
let make = |fill: f32| {
|
||||
let mut d = doc_white(2, 2);
|
||||
let mut g = Layer::group("g", vec![Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert))]);
|
||||
g.blend = BlendMode::PassThrough;
|
||||
g.fill_opacity = fill;
|
||||
d.layers.push(g);
|
||||
px(&d, 0, 0)
|
||||
};
|
||||
assert!(close4(make(1.0), [0.0, 0.0, 0.0, 1.0]), "full fill: the invert reaches the background");
|
||||
assert!(close4(make(0.4), [1.0; 4]), "isolated: nothing beneath to invert");
|
||||
}
|
||||
|
||||
/// psd-tools layer_mask_data.psd: a mask feather blurs the mask edge (CPU and the GPU's
|
||||
/// combined mask share `masks::combined_mask`).
|
||||
#[test]
|
||||
fn mask_feather_blurs_the_edge() {
|
||||
let mut d = doc_white(64, 8);
|
||||
let mut l = solid_layer("k", Rect::new(0, 0, 64, 8), [0.0, 0.0, 0.0, 1.0]);
|
||||
let mut m = LayerMask::hide_all();
|
||||
m.surface.fill_rect(Rect::new(0, 0, 32, 8), &[1.0]);
|
||||
l.mask = Some(m);
|
||||
d.layers.push(l.clone());
|
||||
assert!(px(&d, 31, 4)[0] < 0.01 && px(&d, 32, 4)[0] > 0.99, "sharp without feather");
|
||||
if let Some(m) = &mut d.layers[1].mask {
|
||||
m.feather = 8.0;
|
||||
}
|
||||
let (a, b) = (px(&d, 31, 4)[0], px(&d, 32, 4)[0]);
|
||||
assert!(a > 0.3 && a < 0.5 && b > 0.5 && b < 0.7, "soft edge around 50%: {a} {b}");
|
||||
assert!(px(&d, 4, 4)[0] < 0.01 && px(&d, 60, 4)[0] > 0.99, "far from the edge unchanged");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_opacity_applies_once() {
|
||||
let mut d = doc_white(2, 2);
|
||||
|
||||
@@ -539,8 +539,10 @@ impl<'a> Planner<'a> {
|
||||
let opacity = layer.opacity * layer.fill_opacity;
|
||||
let visible_clipped: Vec<&'a Layer> = clipped.iter().filter(|c| c.visible).collect();
|
||||
|
||||
// Below 100% fill a pass-through group renders isolated (as the CPU compositor does).
|
||||
if let LayerContent::Group(g) = &layer.content
|
||||
&& layer.blend == BlendMode::PassThrough
|
||||
&& layer.fill_opacity >= 1.0
|
||||
&& !has_effects(layer)
|
||||
{
|
||||
let before = self.retain(backdrop);
|
||||
|
||||
@@ -21,6 +21,7 @@ photocraft-codecs = { workspace = true }
|
||||
photocraft-raw = { workspace = true }
|
||||
photocraft-text = { workspace = true }
|
||||
photocraft-paint = { workspace = true }
|
||||
photocraft-vector = { workspace = true }
|
||||
photocraft-format = { path = "../format" }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
@@ -28,4 +29,3 @@ thiserror = { workspace = true }
|
||||
photocraft-psd = { workspace = true, features = ["testgen"] }
|
||||
photocraft-raw = { workspace = true, features = ["testgen"] }
|
||||
proptest = { workspace = true }
|
||||
photocraft-vector = { workspace = true }
|
||||
@@ -244,6 +244,70 @@ pub(crate) fn gradient_desc(stops: &[(f32, Color)], opacity: &[(f32, f32)]) -> D
|
||||
.with("Trns", Value::List(trns))
|
||||
}
|
||||
|
||||
/// Opacity stops of a `Grdn` descriptor with Photoshop's smoothness and midpoints applied (the
|
||||
/// same curve as the colour stops, Classic interpolation), densely sampled.
|
||||
fn smoothed_opacity_stops(grad: &Descriptor) -> Vec<(f32, f32)> {
|
||||
let mut stops = Vec::new();
|
||||
let mut mids = Vec::new();
|
||||
if let Some(Value::List(items)) = grad.get("Trns") {
|
||||
for it in items {
|
||||
if let Value::Descriptor(s) = it {
|
||||
let a = num(s.get("Opct")).unwrap_or(100.0) as f32 / 100.0;
|
||||
stops.push((num(s.get("Lctn")).unwrap_or(0.0) as f32 / 4096.0, Color::rgba(a, a, a, 1.0)));
|
||||
mids.push(num(s.get("Mdpn")).unwrap_or(50.0) as f32 / 100.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut order: Vec<usize> = (0..stops.len()).collect();
|
||||
order.sort_by(|a, b| stops[*a].0.total_cmp(&stops[*b].0));
|
||||
let mids: Vec<f32> = order.iter().skip(1).filter_map(|i| mids.get(*i).copied()).collect();
|
||||
let smooth = num(grad.get("Intr")).map_or(0.0, |v| (v / 4096.0) as f32);
|
||||
let baked = if stops.len() >= 2 { crate::gradient_bake::bake(stops, &mids, smooth, crate::gradient_bake::Method::Classic) } else { stops };
|
||||
baked.into_iter().map(|(t, c)| (t, c.to_rgb()[0])).collect()
|
||||
}
|
||||
|
||||
/// Piecewise-linear value of sorted `(location, v)` stops at `t` (clamped at the ends).
|
||||
fn lerp_stops<T: Copy>(stops: &[(f32, T)], t: f32, mix: impl Fn(T, T, f32) -> T) -> Option<T> {
|
||||
let first = stops.first()?;
|
||||
if t <= first.0 {
|
||||
return Some(first.1);
|
||||
}
|
||||
for w in stops.windows(2) {
|
||||
let (a, b) = (w[0], w[1]);
|
||||
if t <= b.0 {
|
||||
let k = if b.0 > a.0 { (t - a.0) / (b.0 - a.0) } else { 1.0 };
|
||||
return Some(mix(a.1, b.1, k));
|
||||
}
|
||||
}
|
||||
stops.last().map(|s| s.1)
|
||||
}
|
||||
|
||||
/// Folds a gradient's opacity stops into its colour stops' alpha (the model keeps one stop
|
||||
/// list): stops at every colour and opacity location, colours and opacities each interpolated
|
||||
/// linearly. Without opacity stops (or all opaque) the colour stops are returned unchanged.
|
||||
/// psd-tools layers-minimal/gradient-fill.psd ("Color to Transparent", no stored pixels).
|
||||
pub(crate) fn with_opacity_stops(stops: Vec<(f32, Color)>, opacity: &[(f32, f32)]) -> Vec<(f32, Color)> {
|
||||
let mut op: Vec<(f32, f32)> = opacity.iter().copied().filter(|(t, a)| t.is_finite() && a.is_finite()).collect();
|
||||
if op.iter().all(|(_, a)| *a >= 1.0) || stops.is_empty() {
|
||||
return stops;
|
||||
}
|
||||
op.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let mut sorted = stops;
|
||||
sorted.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let mut locs: Vec<f32> = sorted.iter().map(|s| s.0).chain(op.iter().map(|s| s.0)).collect();
|
||||
locs.sort_by(f32::total_cmp);
|
||||
locs.dedup();
|
||||
let mix_color =
|
||||
|a: Color, b: Color, k: f32| Color { c: std::array::from_fn(|i| a.c[i] + (b.c[i] - a.c[i]) * k), alpha: a.alpha + (b.alpha - a.alpha) * k, ..a };
|
||||
locs.into_iter()
|
||||
.filter_map(|t| {
|
||||
let c = lerp_stops(&sorted, t, mix_color)?;
|
||||
let a = lerp_stops(&op, t, |a, b, k| a + (b - a) * k)?;
|
||||
Some((t, Color { alpha: (c.alpha * a).clamp(0.0, 1.0), ..c }))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Parses a fill block (`SoCo`, `GdFl`, `PtFl`).
|
||||
pub fn parse_fill(key: &[u8; 4], data: &[u8]) -> Option<Fill> {
|
||||
// Block data may carry trailing padding after the descriptor.
|
||||
@@ -260,9 +324,11 @@ pub fn fill_from_desc(key: &[u8; 4], d: &Descriptor) -> Option<Fill> {
|
||||
let angle = num(d.get("Angl")).unwrap_or(90.0) as f32;
|
||||
let scale = num(d.get("Scl ")).map_or(1.0, |v| v as f32 / 100.0);
|
||||
let (mut stops, _) = get_desc(d, "Grad").map(|g| gradient_stops_with(g, enum_of(d, "gs99"))).unwrap_or_default();
|
||||
let opacity = get_desc(d, "Grad").map(smoothed_opacity_stops).unwrap_or_default();
|
||||
if stops.is_empty() {
|
||||
stops = vec![(0.0, Color::BLACK), (1.0, Color::WHITE)];
|
||||
}
|
||||
let stops = with_opacity_stops(stops, &opacity);
|
||||
Some(Fill::Gradient { stops, angle, scale, style: gradient_style(d), reverse: bool_of(d, "Rvrs") })
|
||||
}
|
||||
b"PtFl" => {
|
||||
@@ -431,6 +497,31 @@ pub fn effects_enabled(lfx2: &[u8]) -> bool {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// psd-tools layers-minimal/gradient-fill.psd ("Color to Transparent", no stored pixels): a
|
||||
/// gradient fill's opacity stops reach the model (as stop alpha) and survive a write.
|
||||
#[test]
|
||||
fn gradient_fill_opacity_stops_become_alpha() {
|
||||
use photocraft_color::Color;
|
||||
use photocraft_doc::{Fill, GradientStyle};
|
||||
let red = Color::rgba(1.0, 0.0, 0.0, 1.0);
|
||||
let f = Fill::Gradient {
|
||||
stops: vec![(0.0, red), (1.0, Color { alpha: 0.0, ..red })],
|
||||
angle: 90.0,
|
||||
scale: 1.0,
|
||||
style: GradientStyle::Linear,
|
||||
reverse: false,
|
||||
};
|
||||
let (k, data) = super::write_fill(&f);
|
||||
let Some(Fill::Gradient { stops, .. }) = super::parse_fill(&k, &data) else { panic!("gradient") };
|
||||
let alpha_at = |t: f32| stops.iter().min_by(|a, b| (a.0 - t).abs().total_cmp(&(b.0 - t).abs())).map(|s| s.1.alpha).unwrap();
|
||||
assert_eq!(alpha_at(0.0), 1.0);
|
||||
assert!(alpha_at(1.0) < 0.01, "{stops:?}");
|
||||
assert!(stops.iter().all(|s| s.1.c[0] == 1.0 && s.1.c[1] == 0.0));
|
||||
// Opaque gradients keep their colour stops untouched.
|
||||
let opaque = super::with_opacity_stops(vec![(0.0, red), (1.0, Color::WHITE)], &[(0.0, 1.0), (1.0, 1.0)]);
|
||||
assert_eq!(opaque, vec![(0.0, red), (1.0, Color::WHITE)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blending_ranges_map_to_blend_if() {
|
||||
use photocraft_doc::{BlendIf, BlendRange};
|
||||
|
||||
@@ -181,6 +181,10 @@ pub fn merged_composite(file: &PsdFile) -> Result<Vec<[f32; 4]>, IoError> {
|
||||
}
|
||||
// Generic path (Lab, CMYK and others) via the raster model conversion.
|
||||
let (doc, _) = psd_to_document(&PsdFile { layer_info: None, ..file.clone() });
|
||||
// Multichannel documents keep their channels apart (no layer): composite them.
|
||||
if doc.layers.is_empty() && doc.mode == photocraft_color::ColorMode::Multichannel {
|
||||
return Ok(photocraft_compose::flatten(&doc).px);
|
||||
}
|
||||
let l = doc.layers.first().ok_or_else(|| IoError::Unsupported("no merged image".into()))?;
|
||||
let s = l.surface().ok_or_else(|| IoError::Unsupported("no merged image".into()))?;
|
||||
Ok(photocraft_compose::surface_to_buffer(s, doc.bounds()).px)
|
||||
|
||||
+33
-18
@@ -101,7 +101,7 @@ impl Ex {
|
||||
(to_psd_rect(r), ch)
|
||||
}
|
||||
|
||||
fn mask(&self, m: &LayerMask) -> (MaskData, Option<ChannelData>) {
|
||||
fn mask(&self, m: &LayerMask, vector: Option<(f32, f32)>) -> (MaskData, Option<ChannelData>) {
|
||||
let s = if m.surface.format() != self.mask_fmt { m.surface.convert(self.mask_fmt) } else { m.surface.clone() };
|
||||
let default = s.default_pixel().first().copied().unwrap_or(0.0);
|
||||
let r = s.content_bounds();
|
||||
@@ -114,18 +114,10 @@ impl Ex {
|
||||
if !m.enabled {
|
||||
flags |= PsdMask::FLAG_DISABLED;
|
||||
}
|
||||
let density = (m.density < 1.0).then(|| q255(m.density));
|
||||
let feather = (m.feather != 0.0).then_some(f64::from(m.feather));
|
||||
let parameters = (density.is_some() || feather.is_some()).then(|| {
|
||||
let parameters = mask_parameters(Some((m.density, m.feather)), vector);
|
||||
if parameters.is_some() {
|
||||
flags |= PsdMask::FLAG_PARAMETERS;
|
||||
MaskParameters {
|
||||
flags: u8::from(density.is_some()) | u8::from(feather.is_some()) << 1,
|
||||
user_density: density,
|
||||
user_feather: feather,
|
||||
vector_density: None,
|
||||
vector_feather: None,
|
||||
}
|
||||
});
|
||||
}
|
||||
let pm = PsdMask {
|
||||
rect: if r.is_empty() { PsdRect::default() } else { to_psd_rect(r) },
|
||||
default_color: q255(default),
|
||||
@@ -133,6 +125,7 @@ impl Ex {
|
||||
trailing: if parameters.is_none() { vec![0, 0] } else { Vec::new() },
|
||||
parameters,
|
||||
real: None,
|
||||
real_first: true,
|
||||
};
|
||||
(MaskData::Mask(pm), Some(self.encode(-2, &plane, w, h)))
|
||||
}
|
||||
@@ -157,10 +150,23 @@ impl Ex {
|
||||
None => (PsdRect::default(), self.empty_channels()),
|
||||
};
|
||||
let mut mask = MaskData::None;
|
||||
// Vector mask density/feather live in the mask parameters, with or without a user mask.
|
||||
let vector = l.vector_mask.as_ref().map(|vm| (vm.density, vm.feather));
|
||||
if let Some(m) = &l.mask {
|
||||
let (md, ch) = self.mask(m);
|
||||
let (md, ch) = self.mask(m, vector);
|
||||
mask = md;
|
||||
channels.extend(ch);
|
||||
} else if let Some(parameters) = mask_parameters(None, vector) {
|
||||
// Parameters only: no user mask channel, so nothing hides on re-import.
|
||||
mask = MaskData::Mask(PsdMask {
|
||||
rect: PsdRect::default(),
|
||||
default_color: 255,
|
||||
flags: PsdMask::FLAG_PARAMETERS,
|
||||
parameters: Some(parameters),
|
||||
real: None,
|
||||
trailing: Vec::new(),
|
||||
real_first: true,
|
||||
});
|
||||
}
|
||||
let mut flags = LayerFlags(0);
|
||||
flags.set_hidden(!l.visible);
|
||||
@@ -321,11 +327,6 @@ impl Ex {
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(vm) = &l.vector_mask
|
||||
&& (vm.density < 1.0 || vm.feather != 0.0)
|
||||
{
|
||||
self.warnings.push(format!("layer \"{}\": vector mask density/feather are not written to PSD", l.name));
|
||||
}
|
||||
}
|
||||
|
||||
/// Pixels for a fill layer: Photoshop's cached rendering while valid,
|
||||
@@ -779,3 +780,17 @@ pub fn document_to_psd_with(doc: &Document, opts: &PsdExportOptions) -> (PsdFile
|
||||
};
|
||||
(file, ex.warnings)
|
||||
}
|
||||
|
||||
/// PSD mask parameters for a user mask's and a vector mask's (density, feather); `None` when
|
||||
/// every value is the default (density 1, feather 0).
|
||||
fn mask_parameters(user: Option<(f32, f32)>, vector: Option<(f32, f32)>) -> Option<MaskParameters> {
|
||||
let density = |v: Option<(f32, f32)>| v.and_then(|(d, _)| (d < 1.0).then(|| q255(d)));
|
||||
let feather = |v: Option<(f32, f32)>| v.and_then(|(_, f)| (f != 0.0).then_some(f64::from(f)));
|
||||
let p =
|
||||
MaskParameters { flags: 0, user_density: density(user), user_feather: feather(user), vector_density: density(vector), vector_feather: feather(vector) };
|
||||
let flags = u8::from(p.user_density.is_some())
|
||||
| u8::from(p.user_feather.is_some()) << 1
|
||||
| u8::from(p.vector_density.is_some()) << 2
|
||||
| u8::from(p.vector_feather.is_some()) << 3;
|
||||
(flags != 0).then_some(MaskParameters { flags, ..p })
|
||||
}
|
||||
@@ -73,6 +73,23 @@ fn selected_real_mask(rec: &LayerRecord) -> Option<photocraft_psd::RealMask> {
|
||||
rec.layer_mask()?.real
|
||||
}
|
||||
|
||||
/// The fill of a plain shape layer (fill block + vector path, no stroke) to import as a fill layer
|
||||
/// with a vector mask: when its mask parameters give the vector mask a density below 100 % or a
|
||||
/// feather, or when it stores no pixels and fills with a pattern.
|
||||
fn soft_shape_fill(rec: &LayerRecord, has_vector: bool, fill_key: Option<&[u8; 4]>) -> Option<photocraft_doc::Fill> {
|
||||
if !has_vector || rec.block(b"vstk").is_some() || rec.block(b"vscg").is_some() {
|
||||
return None;
|
||||
}
|
||||
let k = fill_key?;
|
||||
let fill = rec.block(k).and_then(|b| blocks::parse_fill(k, &b.data))?;
|
||||
let p = rec.layer_mask().and_then(|m| m.parameters);
|
||||
let soft = p.is_some_and(|p| p.vector_density.is_some_and(|d| d < 255) || p.vector_feather.is_some_and(|f| f > 0.0));
|
||||
// Without stored pixels a pattern-filled shape cannot be rasterized on its own (the
|
||||
// compositor resolves the document's patterns for fill layers).
|
||||
let unrendered_pattern = (rec.rect.is_empty() || rec.rect.size().is_err()) && matches!(fill, photocraft_doc::Fill::Pattern { .. });
|
||||
(soft || unrendered_pattern).then_some(fill)
|
||||
}
|
||||
|
||||
impl Ctx<'_> {
|
||||
fn warn(&mut self, s: impl Into<String>) {
|
||||
self.warnings.push(s.into());
|
||||
@@ -247,11 +264,22 @@ impl Ctx<'_> {
|
||||
warp: rec.block(k).and_then(|b| blocks::parse_placed_warp(k, &b.data)),
|
||||
stack_mode: None,
|
||||
})
|
||||
} else if let Some(f) = soft_shape_fill(rec, vector_key.is_some(), fill_key) {
|
||||
// A shape whose vector mask has a density or feather is a fill layer seen through a
|
||||
// soft vector mask: the fill shows beyond the path, which the stored pixels (the
|
||||
// shape alone) lack. Import it as exactly that.
|
||||
LayerContent::Fill(f)
|
||||
} else if (vector_key.is_some() && (fill_key.is_some() || rec.block(b"vstk").is_some())) || rec.block(b"vscg").is_some() {
|
||||
let fill = fill_key.and_then(|k| rec.block(k).and_then(|b| blocks::parse_fill(k, &b.data)));
|
||||
let mut sh = ShapeLayer { fill, cache: Some(self.record_surface(rec, &name)), psd_raw: vector_key.and_then(principal), ..Default::default() };
|
||||
let lookup = |k: &[u8; 4]| rec.block(k).map(|b| b.data.clone());
|
||||
crate::vector_map::shape_from_blocks(&mut sh, &lookup, self.file.header.width, self.file.header.height, self.dpi);
|
||||
// No stored pixels (32-bit documents, files saved without layer pixels): render the
|
||||
// shape from its path and fill, as Photoshop does when it opens the file.
|
||||
if (rec.rect.is_empty() || rec.rect.size().is_err()) && (!sh.path.subpaths.is_empty() || sh.path.inverted) {
|
||||
let canvas = Rect::new(0, 0, self.file.header.width as i32, self.file.header.height as i32);
|
||||
sh.cache = Some(photocraft_vector::render_shape(&sh, self.fmt, canvas));
|
||||
}
|
||||
LayerContent::Shape(sh)
|
||||
} else if let Some(k) = fill_key {
|
||||
match rec.block(k).and_then(|b| blocks::parse_fill(k, &b.data)) {
|
||||
|
||||
+107
-7
@@ -75,30 +75,84 @@ pub fn path_from_resource(data: &[u8], w: u32, h: u32) -> Option<Path> {
|
||||
PathData::from_bytes(data).ok().map(|p| path_from_records(&p, w, h))
|
||||
}
|
||||
|
||||
/// A `vmsk`/`vsms` block → (path with the invert flag applied, flags).
|
||||
/// Layer vector data (`vmsk`/`vsms`) → model path, with Photoshop's reading of the records
|
||||
/// (matching its rendering of the psd-tools corpus):
|
||||
/// - without subpaths, the initial-fill record alone decides coverage (1 = everything);
|
||||
/// - with subpaths the initial-fill record is ignored; a first component that subtracts starts
|
||||
/// from a full canvas (the shape is inverted);
|
||||
/// - a subpath with operation -1 continues the previous component (same operation).
|
||||
///
|
||||
/// The model applies the first subpath as "combine" and inverts the final result, so a leading
|
||||
/// subtract becomes `inverted` with every later operation replaced by its complement dual
|
||||
/// (combine ↔ subtract; exclude is self-dual; intersect has no dual and is kept as is).
|
||||
fn layer_path_from_records(p: &PathData, w: u32, h: u32) -> Path {
|
||||
let mut path = path_from_records(p, w, h);
|
||||
if path.subpaths.is_empty() {
|
||||
return path;
|
||||
}
|
||||
let ops: Vec<i16> = p.subpaths().iter().map(|s| s.operation).collect();
|
||||
let mut prev = PathOp::Combine;
|
||||
for (s, &raw) in path.subpaths.iter_mut().zip(&ops) {
|
||||
if raw == -1 {
|
||||
s.op = prev;
|
||||
}
|
||||
prev = s.op;
|
||||
}
|
||||
path.inverted = path.subpaths.first().is_some_and(|s| s.op == PathOp::Subtract);
|
||||
if path.inverted {
|
||||
for s in path.subpaths.iter_mut().skip(1) {
|
||||
s.op = match s.op {
|
||||
PathOp::Combine => PathOp::Subtract,
|
||||
PathOp::Subtract => PathOp::Combine,
|
||||
op => op,
|
||||
};
|
||||
}
|
||||
if let Some(first) = path.subpaths.first_mut() {
|
||||
first.op = PathOp::Combine;
|
||||
}
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
/// A `vmsk`/`vsms` block → (path with the invert flag applied, flags). Coverage semantics: an
|
||||
/// empty path covers everything when `inverted` (shape layers; vector masks flip this, see
|
||||
/// [`vector_mask_from_block`]).
|
||||
pub fn path_from_vmsk(data: &[u8], w: u32, h: u32) -> Option<(Path, u32)> {
|
||||
let b = VectorMaskBlock::from_bytes(data).ok()?;
|
||||
let mut p = path_from_records(&b.path, w, h);
|
||||
let mut p = layer_path_from_records(&b.path, w, h);
|
||||
if b.flags & VectorMaskBlock::FLAG_INVERT != 0 {
|
||||
p.inverted = !p.inverted;
|
||||
}
|
||||
Some((p, b.flags))
|
||||
}
|
||||
|
||||
/// A `vmsk` block for `path` with extra flags (not linked / disabled).
|
||||
/// A `vmsk` block for `path` with extra flags (not linked / disabled). An empty path is written
|
||||
/// as the initial-fill record (Photoshop's form); otherwise inversion uses the invert flag.
|
||||
pub fn vmsk_bytes(path: &Path, flags: u32, w: u32, h: u32) -> Vec<u8> {
|
||||
let mut p = path.clone();
|
||||
let mut flags = flags & !VectorMaskBlock::FLAG_INVERT;
|
||||
if p.inverted {
|
||||
if p.inverted && !p.subpaths.is_empty() {
|
||||
flags |= VectorMaskBlock::FLAG_INVERT;
|
||||
p.inverted = false;
|
||||
}
|
||||
// The model draws the first subpath as "combine"; a stored leading subtract would read back
|
||||
// as an inverted shape.
|
||||
if let Some(first) = p.subpaths.first_mut()
|
||||
&& first.op == PathOp::Subtract
|
||||
{
|
||||
first.op = PathOp::Combine;
|
||||
}
|
||||
VectorMaskBlock { version: 3, flags, path: path_to_records(&p, w, h) }.to_bytes()
|
||||
}
|
||||
|
||||
/// A layer's vector mask from its `vmsk`/`vsms` block.
|
||||
/// A layer's vector mask from its `vmsk`/`vsms` block. A vector mask without subpaths reveals
|
||||
/// everything unless inverted (see `photocraft_vector::vector_mask_values`), the opposite of a
|
||||
/// shape's coverage, hence the flip.
|
||||
pub fn vector_mask_from_block(data: &[u8], w: u32, h: u32) -> Option<VectorMask> {
|
||||
let (path, flags) = path_from_vmsk(data, w, h)?;
|
||||
let (mut path, flags) = path_from_vmsk(data, w, h)?;
|
||||
if path.subpaths.is_empty() {
|
||||
path.inverted = !path.inverted;
|
||||
}
|
||||
Some(VectorMask {
|
||||
path,
|
||||
enabled: flags & VectorMaskBlock::FLAG_DISABLED == 0,
|
||||
@@ -117,7 +171,11 @@ pub fn vector_mask_bytes(m: &VectorMask, w: u32, h: u32) -> Vec<u8> {
|
||||
if !m.linked {
|
||||
flags |= VectorMaskBlock::FLAG_NOT_LINKED;
|
||||
}
|
||||
vmsk_bytes(&m.path, flags, w, h)
|
||||
let mut path = m.path.clone();
|
||||
if path.subpaths.is_empty() {
|
||||
path.inverted = !path.inverted;
|
||||
}
|
||||
vmsk_bytes(&path, flags, w, h)
|
||||
}
|
||||
|
||||
/// Compares the parts of a vector mask stored in the block (density/feather live elsewhere).
|
||||
@@ -515,6 +573,48 @@ mod tests {
|
||||
assert_eq!(op_from_psd(-1), PathOp::Combine);
|
||||
}
|
||||
|
||||
fn vmsk_with(initial_fill: bool, ops: &[i16]) -> Vec<u8> {
|
||||
let square = |x: f64| PsdSubpath {
|
||||
closed: true,
|
||||
operation: 0,
|
||||
knots: [(x, 0.25), (x + 0.25, 0.25), (x + 0.25, 0.5), (x, 0.5)].iter().map(|&p| PsdKnot { linked: false, pre: p, anchor: p, post: p }).collect(),
|
||||
};
|
||||
let subs: Vec<PsdSubpath> = ops.iter().enumerate().map(|(i, &op)| PsdSubpath { operation: op, ..square(0.1 * i as f64) }).collect();
|
||||
VectorMaskBlock { version: 3, flags: 0, path: PathData::from_subpaths(&subs, initial_fill) }.to_bytes()
|
||||
}
|
||||
|
||||
/// psd-tools vector-mask.psd / vector-mask2.psd / vector-mask3.psd: Photoshop's initial-fill
|
||||
/// record only matters without subpaths; a leading subtract inverts; -1 continues the
|
||||
/// previous component.
|
||||
#[test]
|
||||
fn layer_path_semantics_follow_photoshop() {
|
||||
// Initial fill with a combine subpath: a plain (not inverted) shape.
|
||||
let (p, _) = path_from_vmsk(&vmsk_with(true, &[1]), 100, 100).unwrap();
|
||||
assert!(!p.inverted);
|
||||
// Leading subtract: everything but the shape; later ops become their complements.
|
||||
let (p, _) = path_from_vmsk(&vmsk_with(true, &[2, 1, 2, 0]), 100, 100).unwrap();
|
||||
assert!(p.inverted);
|
||||
assert_eq!(p.subpaths.iter().map(|s| s.op).collect::<Vec<_>>(), [PathOp::Combine, PathOp::Subtract, PathOp::Combine, PathOp::Exclude]);
|
||||
// -1 continues the previous component's operation.
|
||||
let (p, _) = path_from_vmsk(&vmsk_with(false, &[1, 2, -1]), 100, 100).unwrap();
|
||||
assert_eq!(p.subpaths[2].op, PathOp::Subtract);
|
||||
// No subpaths: a vector mask with initial fill reveals all, without hides all; a shape
|
||||
// with initial fill covers everything.
|
||||
let reveal = vector_mask_from_block(&vmsk_with(true, &[]), 100, 100).unwrap();
|
||||
assert_eq!(photocraft_vector::vector_mask_values(&reveal, photocraft_geom::Rect::new(0, 0, 2, 2)), vec![1.0; 4]);
|
||||
let hide = vector_mask_from_block(&vmsk_with(false, &[]), 100, 100).unwrap();
|
||||
assert_eq!(photocraft_vector::vector_mask_values(&hide, photocraft_geom::Rect::new(0, 0, 2, 2)), vec![0.0; 4]);
|
||||
assert!(path_from_vmsk(&vmsk_with(true, &[]), 100, 100).unwrap().0.inverted);
|
||||
// Our own writer round-trips each case.
|
||||
for m in [reveal, hide] {
|
||||
assert_eq!(vector_mask_from_block(&vector_mask_bytes(&m, 100, 100), 100, 100), Some(m));
|
||||
}
|
||||
let mut lead = sample_path();
|
||||
lead.subpaths[0].op = PathOp::Subtract;
|
||||
let back = path_from_vmsk(&vmsk_bytes(&lead, 0, 64, 64), 64, 64).unwrap().0;
|
||||
assert!(!back.inverted, "a stored leading subtract is drawn as combine by the model");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vector_mask_roundtrip_with_flags() {
|
||||
let mut m = VectorMask::new(sample_path());
|
||||
|
||||
+374
-93
@@ -1,22 +1,49 @@
|
||||
//! Optional real-file corpus: `corpus/psd/**/*.{psd,psb}` at the workspace
|
||||
//! root (gitignored; see `corpus/psd/SOURCES.md`). Skips silently if absent.
|
||||
//! Optional real-file corpora (gitignored, never committed; each test skips
|
||||
//! silently when its corpus is absent):
|
||||
//!
|
||||
//! - `corpus/psd/**/*.{psd,psb}`: a hand-picked mix of small ag-psd and
|
||||
//! psd-tools samples (see `corpus/psd/SOURCES.md`). Enforced with
|
||||
//! `PHOTOCRAFT_CORPUS` (a directory, or `1` for the default location).
|
||||
//! - `corpus/psd-tools/**/*.{psd,psb}`: the complete psd-tools test set
|
||||
//! (MIT) at a pinned commit, fetched and sha256-verified by
|
||||
//! `cargo xtask corpus --psd-tools`. Enforced with
|
||||
//! `PHOTOCRAFT_PSDTOOLS_CORPUS` (a directory, or `1`).
|
||||
//!
|
||||
//! For each file: parse → document → flatten, compared with the file's own
|
||||
//! merged composite (Photoshop's rendering) as the oracle; then document →
|
||||
//! PSD → document → flatten, compared with the first flatten (our own export
|
||||
//! must not change what the document looks like). Prints a per-file table.
|
||||
//! A panic anywhere is caught and reported as CRASH (Rule 9).
|
||||
//!
|
||||
//! 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.
|
||||
//! Oracles: files without layers (Bitmap, Indexed, plain flat files) compare
|
||||
//! our import with the merged image as decoded by the psd crate. When
|
||||
//! Photoshop wrote no real merged image (Maximize Compatibility off: blank
|
||||
//! image data) or the merged image would decode through our own model code
|
||||
//! (Multichannel), the embedded thumbnail (resource 1036, Photoshop's own
|
||||
//! render, JPEG, ≤ 160 px) is the oracle: our composite is box-downsampled to
|
||||
//! its size and both are lightly blurred; PASS (thumbnail) needs a mean error
|
||||
//! ≤ 3/255 with ≤ 2 % of pixels off by more than 24/255. That bar is strict
|
||||
//! (calibrated on files that match their merged image, many thumbnails being
|
||||
//! stale or resampled differently), so a thumbnail PASS is real evidence while
|
||||
//! a thumbnail DIFF may be the thumbnail's fault. A file SKIPs only when no
|
||||
//! oracle exists (no real composite and no usable thumbnail).
|
||||
//!
|
||||
//! Without the env var the comparisons are only reported: differences are
|
||||
//! expected where features are not yet rendered (effects, text engine, smart
|
||||
//! filters, knockout). With it set the run asserts no crashes, and that the
|
||||
//! oracle pass count and the export round-trip count do not fall below the
|
||||
//! source's floors. Raise the floors when they improve; never lower them.
|
||||
//! Set `PHOTOCRAFT_CORPUS_STRICT=1` to also fail on import/export errors
|
||||
//! (files listed in `KNOWN_BAD` excepted).
|
||||
//!
|
||||
//! `*_mutations_never_panic` truncates and corrupts every corpus file and
|
||||
//! asserts import + flatten return (Ok or Err) without panicking.
|
||||
|
||||
mod common;
|
||||
|
||||
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Instant;
|
||||
|
||||
use photocraft_io::*;
|
||||
use photocraft_psd::PsdFile;
|
||||
@@ -36,111 +63,365 @@ 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 env = std::env::var_os("PHOTOCRAFT_CORPUS").filter(|v| !v.is_empty());
|
||||
/// Files known to be invalid upstream: their parse error is expected.
|
||||
const KNOWN_BAD: &[(&str, &str)] = &[("group-divider-blend-mode.psd", "psd-tools fixture stripped to 1906 bytes: merged image data missing")];
|
||||
|
||||
/// One corpus directory with its own floors.
|
||||
struct Source {
|
||||
label: &'static str,
|
||||
/// Enforcing env var: a directory, or `1` for `default_dir`.
|
||||
env: &'static str,
|
||||
/// Default location, relative to the workspace root.
|
||||
default_dir: &'static str,
|
||||
/// Files whose flatten matches their oracle (merged image, or thumbnail when there is none).
|
||||
pass_floor: usize,
|
||||
/// Files whose export → re-import renders the same as the import.
|
||||
roundtrip_floor: usize,
|
||||
}
|
||||
|
||||
/// Hand-picked mix in `corpus/psd` (170 files: 121 vs the merged image + 12 vs the thumbnail).
|
||||
const MIXED: Source = Source { label: "io corpus", env: "PHOTOCRAFT_CORPUS", default_dir: "corpus/psd", pass_floor: 133, roundtrip_floor: 169 };
|
||||
|
||||
/// The full psd-tools test set (309 files at the pinned commit; see `xtask/psd-tools-corpus.sha256`):
|
||||
/// 202 vs the merged image + 10 vs the thumbnail.
|
||||
const PSD_TOOLS: Source =
|
||||
Source { label: "psd-tools corpus", env: "PHOTOCRAFT_PSDTOOLS_CORPUS", default_dir: "corpus/psd-tools", pass_floor: 212, roundtrip_floor: 307 };
|
||||
|
||||
/// The corpus directory and whether floors are enforced; `None` when absent.
|
||||
fn locate(src: &Source) -> Option<(PathBuf, bool)> {
|
||||
let env = std::env::var_os(src.env).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"),
|
||||
_ => Path::new(env!("CARGO_MANIFEST_DIR")).join("../..").join(src.default_dir),
|
||||
};
|
||||
if !root.is_dir() {
|
||||
assert!(env.is_none(), "PHOTOCRAFT_CORPUS is set but {} is not a directory", root.display());
|
||||
return;
|
||||
assert!(env.is_none(), "{} is set but {} is not a directory", src.env, root.display());
|
||||
return None;
|
||||
}
|
||||
Some((root, env.is_some()))
|
||||
}
|
||||
|
||||
fn files_in(root: &Path) -> Vec<PathBuf> {
|
||||
let mut files = Vec::new();
|
||||
collect(&root, &mut files);
|
||||
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());
|
||||
files
|
||||
}
|
||||
|
||||
fn panic_message(e: &(dyn std::any::Any + Send)) -> String {
|
||||
e.downcast_ref::<&str>().map(|s| (*s).to_string()).or_else(|| e.downcast_ref::<String>().cloned()).unwrap_or_else(|| "panic".into())
|
||||
}
|
||||
|
||||
/// Thumbnail oracle: mean absolute error and the share of pixels off by more than
|
||||
/// `THUMB_BAD`, after box-downsampling our composite (over white) to the JPEG thumbnail's size.
|
||||
const THUMB_MEAN_TOL: f32 = 3.0 / 255.0;
|
||||
const THUMB_BAD: f32 = 24.0 / 255.0;
|
||||
const THUMB_BAD_FRAC: f32 = 0.02;
|
||||
|
||||
/// Photoshop's embedded thumbnail (resource 1036: a 28-byte header, then JFIF) as RGB floats.
|
||||
fn thumbnail(file: &PsdFile) -> Option<(u32, u32, Vec<[f32; 3]>)> {
|
||||
let r = file.resource(photocraft_psd::resources::ids::THUMBNAIL)?;
|
||||
let format = u32::from_be_bytes(r.data.get(..4)?.try_into().ok()?);
|
||||
if format != 1 {
|
||||
return None;
|
||||
}
|
||||
let img = photocraft_codecs::decode(r.data.get(28..)?).ok()?;
|
||||
let rgba = img.to_rgba8();
|
||||
Some((img.width(), img.height(), rgba.as_chunks::<4>().0.iter().map(|p| [0, 1, 2].map(|c| f32::from(p[c]) / 255.0)).collect()))
|
||||
}
|
||||
|
||||
/// 3×3 box blur of an RGB plane (edges clamp): absorbs the sub-pixel offsets between our box
|
||||
/// downsampling and Photoshop's thumbnail resampling, and JPEG ringing.
|
||||
fn blur3(v: &[[f32; 3]], w: usize, h: usize) -> Vec<[f32; 3]> {
|
||||
let mut out = vec![[0.0; 3]; v.len()];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let mut acc = [0.0f32; 3];
|
||||
for dy in [-1i64, 0, 1] {
|
||||
for dx in [-1i64, 0, 1] {
|
||||
let (sx, sy) = ((x as i64 + dx).clamp(0, w as i64 - 1) as usize, (y as i64 + dy).clamp(0, h as i64 - 1) as usize);
|
||||
for c in 0..3 {
|
||||
acc[c] += v[sy * w + sx][c];
|
||||
}
|
||||
}
|
||||
}
|
||||
out[y * w + x] = acc.map(|a| a / 9.0);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Compares `ours` (canvas-size straight RGBA) with the thumbnail: our composite over white is
|
||||
/// box-downsampled to the thumbnail's size, both are lightly blurred, and the result is the mean
|
||||
/// absolute error and the share of pixels off by more than `THUMB_BAD`. `None` without a
|
||||
/// thumbnail, or when it is not smaller than the canvas (tiny documents: not a real reduction).
|
||||
fn thumbnail_oracle(file: &PsdFile, ours: &[[f32; 4]]) -> Option<(f32, f32)> {
|
||||
let (tw, th, thumb) = thumbnail(file)?;
|
||||
let (w, h) = (file.header.width as usize, file.header.height as usize);
|
||||
let (tw, th) = (tw as usize, th as usize);
|
||||
if tw < 8 || th < 8 || tw > w || th > h || ours.len() != w * h || thumb.len() != tw * th {
|
||||
return None;
|
||||
}
|
||||
let mut small = Vec::with_capacity(tw * th);
|
||||
for ty in 0..th {
|
||||
for tx in 0..tw {
|
||||
let (x0, x1) = (tx * w / tw, ((tx + 1) * w / tw).max(tx * w / tw + 1).min(w));
|
||||
let (y0, y1) = (ty * h / th, ((ty + 1) * h / th).max(ty * h / th + 1).min(h));
|
||||
let mut acc = [0.0f32; 3];
|
||||
for y in y0..y1 {
|
||||
for p in &ours[y * w + x0..y * w + x1] {
|
||||
for c in 0..3 {
|
||||
acc[c] += p[c] * p[3] + 1.0 - p[3];
|
||||
}
|
||||
}
|
||||
}
|
||||
let n = ((x1 - x0) * (y1 - y0)) as f32;
|
||||
small.push(acc.map(|a| a / n));
|
||||
}
|
||||
}
|
||||
let (a, b) = (blur3(&small, tw, th), blur3(&thumb, tw, th));
|
||||
let (mut sum, mut bad) = (0.0f32, 0usize);
|
||||
for (p, t) in a.iter().zip(&b) {
|
||||
let d: [f32; 3] = std::array::from_fn(|c| (p[c] - t[c]).abs());
|
||||
sum += (d[0] + d[1] + d[2]) / 3.0;
|
||||
bad += usize::from(d.iter().any(|v| *v > THUMB_BAD));
|
||||
}
|
||||
let n = (tw * th) as f32;
|
||||
Some((sum / n, bad as f32 / n))
|
||||
}
|
||||
|
||||
enum Outcome {
|
||||
Pass,
|
||||
/// Matched the embedded thumbnail (no real merged image to compare with).
|
||||
ThumbPass,
|
||||
Diff,
|
||||
Skip,
|
||||
Error,
|
||||
}
|
||||
|
||||
/// Oracle + round trip for one file. Returns the oracle outcome and whether the export round trip
|
||||
/// rendered the same (`None` when it could not run).
|
||||
fn check_file(name: &str, bytes: &[u8]) -> (Outcome, Option<f32>) {
|
||||
let known_bad = KNOWN_BAD.iter().any(|(f, _)| name.ends_with(f));
|
||||
let file = match PsdFile::from_bytes(bytes) {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
let tag = if known_bad { "KNOWN-BAD" } else { "PARSE-ERROR" };
|
||||
eprintln!("{name:<60} {:>6} {:>9} {:>8} {tag} {e}", "-", "-", "-");
|
||||
return (if known_bad { Outcome::Skip } else { Outcome::Error }, None);
|
||||
}
|
||||
};
|
||||
let imp = match import(name, bytes) {
|
||||
Ok(i) => i,
|
||||
Err(e) => {
|
||||
eprintln!("{name:<60} IMPORT-ERROR {e}");
|
||||
return (Outcome::Error, None);
|
||||
}
|
||||
};
|
||||
let doc = &imp.document;
|
||||
// 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}");
|
||||
return (Outcome::Error, None);
|
||||
}
|
||||
match import(name, &r.bytes) {
|
||||
Ok(i) => i.document,
|
||||
Err(e) => {
|
||||
eprintln!("{name:<60} REIMPORT-ERROR {e}");
|
||||
return (Outcome::Error, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("{name:<60} EXPORT-ERROR {e}");
|
||||
return (Outcome::Error, None);
|
||||
}
|
||||
};
|
||||
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 };
|
||||
let layers = doc.layer_count();
|
||||
// Oracle choice: the merged image (Photoshop's composite; for files without layers it is the
|
||||
// whole image, decoded independently by the psd crate). When Photoshop wrote no real merged
|
||||
// image (Maximize Compatibility off) or it decodes through our own model code (Multichannel),
|
||||
// its embedded thumbnail is the oracle at thumbnail size.
|
||||
let use_thumb = file.has_real_merged_data() == Some(false) || file.header.color_mode == photocraft_psd::ColorMode::Multichannel;
|
||||
let merged = if use_thumb { None } else { merged_composite(&file).ok() };
|
||||
let Some(merged) = merged else {
|
||||
let notes: Vec<&str> = imp.warnings.iter().map(String::as_str).take(2).collect();
|
||||
return match thumbnail_oracle(&file, &ours) {
|
||||
Some((mean, frac)) => {
|
||||
let ok = mean <= THUMB_MEAN_TOL && frac <= THUMB_BAD_FRAC;
|
||||
let status = if ok { "PASS (thumbnail)" } else { "DIFF (thumbnail)" };
|
||||
eprintln!("{name:<60} {layers:>6} {mean:>9.4} {:>7.2}% {status} {}", 100.0 * frac, notes.join(" | "));
|
||||
(if ok { Outcome::ThumbPass } else { Outcome::Diff }, Some(rt))
|
||||
}
|
||||
None => {
|
||||
eprintln!("{name:<60} {layers:>6} {:>9} {:>8} SKIP (no real composite and no thumbnail)", "-", "-");
|
||||
(Outcome::Skip, Some(rt))
|
||||
}
|
||||
};
|
||||
};
|
||||
// Without an alpha channel in the merged image, Photoshop flattened the document over white.
|
||||
let ours = if file.merged_has_alpha() {
|
||||
ours
|
||||
} else {
|
||||
ours.iter()
|
||||
.map(|p| {
|
||||
let w = 1.0 - p[3];
|
||||
[p[0] * p[3] + w, p[1] * p[3] + w, p[2] * p[3] + w, 1.0]
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
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;
|
||||
let (outcome, status) = if m <= PASS_TOL { (Outcome::Pass, "PASS") } else { (Outcome::Diff, "DIFF") };
|
||||
let notes: Vec<&str> = imp.warnings.iter().map(String::as_str).take(2).collect();
|
||||
eprintln!("{name:<60} {layers:>6} {:>9.4} {:>7.2}% {status} {}", m, pct, notes.join(" | "));
|
||||
if std::env::var_os("CORPUS_THUMB_CALIBRATE").is_some()
|
||||
&& let Some((mean, frac)) = thumbnail_oracle(&file, &ours)
|
||||
{
|
||||
eprintln!("THUMBCAL {status} {mean:.4} {frac:.4} {name}");
|
||||
}
|
||||
(outcome, Some(rt))
|
||||
}
|
||||
|
||||
fn run_oracle(src: &Source) {
|
||||
let Some((root, enforce)) = locate(src) else { return };
|
||||
let files = files_in(&root);
|
||||
let (mut pass, mut thumb_pass, mut diff, mut skipped, mut errors) = (0, 0, 0, 0, 0);
|
||||
let (mut rt_same, mut rt_diff, mut crashes) = (0, Vec::new(), 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();
|
||||
let bytes = std::fs::read(p).unwrap_or_default();
|
||||
let file = match PsdFile::from_bytes(&bytes) {
|
||||
Ok(f) => f,
|
||||
let (outcome, rt) = match catch_unwind(AssertUnwindSafe(|| check_file(&name, &bytes))) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
eprintln!("{name:<60} {:>6} {:>9} {:>8} PARSE-ERROR {e}", "-", "-", "-");
|
||||
errors += 1;
|
||||
let msg = panic_message(e.as_ref());
|
||||
eprintln!("{name:<60} CRASH {msg}");
|
||||
crashes.push(format!("{name}: {msg}"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let imp = match import(&name, &bytes) {
|
||||
Ok(i) => i,
|
||||
Err(e) => {
|
||||
eprintln!("{name:<60} IMPORT-ERROR {e}");
|
||||
errors += 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let doc = &imp.document;
|
||||
// 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})"));
|
||||
match outcome {
|
||||
Outcome::Pass => pass += 1,
|
||||
Outcome::ThumbPass => thumb_pass += 1,
|
||||
Outcome::Diff => diff += 1,
|
||||
Outcome::Skip => skipped += 1,
|
||||
Outcome::Error => errors += 1,
|
||||
}
|
||||
let layers = doc.layer_count();
|
||||
if file.has_real_merged_data() == Some(false) || file.layers().is_empty() {
|
||||
eprintln!("{name:<60} {layers:>6} {:>9} {:>8} SKIP (no layers or no real composite)", "-", "-");
|
||||
skipped += 1;
|
||||
continue;
|
||||
match rt {
|
||||
Some(rt) if rt <= ROUNDTRIP_TOL => rt_same += 1,
|
||||
Some(rt) => rt_diff.push(format!("{name} ({rt:.4})")),
|
||||
None => {}
|
||||
}
|
||||
let Ok(merged) = merged_composite(&file) else {
|
||||
eprintln!("{name:<60} {layers:>6} {:>9} {:>8} SKIP (merged not decodable)", "-", "-");
|
||||
skipped += 1;
|
||||
continue;
|
||||
};
|
||||
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;
|
||||
let status = if m <= PASS_TOL {
|
||||
pass += 1;
|
||||
"PASS"
|
||||
} else {
|
||||
diff += 1;
|
||||
"DIFF"
|
||||
};
|
||||
let notes: Vec<&str> = imp.warnings.iter().map(String::as_str).take(2).collect();
|
||||
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(", "));
|
||||
let label = src.label;
|
||||
eprintln!(
|
||||
"{label}: {} files: {} pass ({pass} vs the merged image <= 2/255, {thumb_pass} vs the thumbnail), {diff} differ, {skipped} skipped, {errors} errors, {} crashes",
|
||||
files.len(),
|
||||
pass + thumb_pass,
|
||||
crashes.len()
|
||||
);
|
||||
let pass = pass + thumb_pass;
|
||||
eprintln!("{label}: 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:?}");
|
||||
if enforce {
|
||||
assert!(crashes.is_empty(), "{label}: panics (Rule 9): {crashes:?}");
|
||||
assert!(pass >= src.pass_floor, "{label}: oracle pass count {pass} fell below the floor {}", src.pass_floor);
|
||||
assert!(rt_same >= src.roundtrip_floor, "{label}: export round trip {rt_same} fell below the floor {}: {rt_diff:?}", src.roundtrip_floor);
|
||||
}
|
||||
}
|
||||
|
||||
/// Deterministic xorshift for the mutation sweep.
|
||||
fn next(state: &mut u64) -> u64 {
|
||||
*state ^= *state << 13;
|
||||
*state ^= *state >> 7;
|
||||
*state ^= *state << 17;
|
||||
*state
|
||||
}
|
||||
|
||||
/// Truncations and byte corruptions of every corpus file: import (and flatten when the document
|
||||
/// is small) must return without panicking. Files are mutated in their first 64 KB, where the
|
||||
/// header, resources and layer records live.
|
||||
fn run_mutations(src: &Source) {
|
||||
// Minutes of work: only when the corpus is enforced.
|
||||
let Some((root, true)) = locate(src) else { return };
|
||||
let enforce = true;
|
||||
let files = files_in(&root);
|
||||
let (mut runs, mut panics, mut slow) = (0usize, Vec::new(), Vec::new());
|
||||
for p in &files {
|
||||
let name = p.strip_prefix(&root).unwrap_or(p).display().to_string();
|
||||
let Ok(bytes) = std::fs::read(p) else { continue };
|
||||
let mut seed = 0x9E37_79B9_7F4A_7C15u64 ^ bytes.len() as u64;
|
||||
let span = bytes.len().clamp(1, 64 * 1024);
|
||||
let mut variants: Vec<(String, Vec<u8>)> = Vec::new();
|
||||
for k in 1..8 {
|
||||
let cut = bytes.len() * k / 8;
|
||||
variants.push((format!("truncate@{cut}"), bytes[..cut].to_vec()));
|
||||
}
|
||||
for _ in 0..24 {
|
||||
let mut m = bytes.clone();
|
||||
let flips = 1 + (next(&mut seed) % 4) as usize;
|
||||
for _ in 0..flips {
|
||||
let at = (next(&mut seed) as usize) % span;
|
||||
if let Some(b) = m.get_mut(at) {
|
||||
*b = match next(&mut seed) % 3 {
|
||||
0 => 0xFF,
|
||||
1 => 0x00,
|
||||
_ => *b ^ (1 << (next(&mut seed) % 8)),
|
||||
};
|
||||
}
|
||||
}
|
||||
variants.push(("mutate".into(), m));
|
||||
}
|
||||
for (what, data) in variants {
|
||||
runs += 1;
|
||||
let t = Instant::now();
|
||||
let r = catch_unwind(AssertUnwindSafe(|| {
|
||||
if let Ok(imp) = import(&name, &data) {
|
||||
let b = imp.document.bounds();
|
||||
if i64::from(b.width()) * i64::from(b.height()) <= 4_000_000 {
|
||||
let _ = photocraft_compose::flatten(&imp.document);
|
||||
}
|
||||
}
|
||||
}));
|
||||
let secs = t.elapsed().as_secs_f32();
|
||||
if secs > 20.0 {
|
||||
slow.push(format!("{name} {what}: {secs:.1}s"));
|
||||
}
|
||||
if let Err(e) = r {
|
||||
let msg = panic_message(e.as_ref());
|
||||
eprintln!("CRASH {name} {what}: {msg}");
|
||||
panics.push(format!("{name} {what}: {msg}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
eprintln!("{}: {runs} mutated imports, {} panics, {} slow (> 20 s): {slow:?}", src.label, panics.len(), slow.len());
|
||||
if enforce {
|
||||
assert!(panics.is_empty(), "{}: mutated files panicked (Rule 9): {panics:?}", src.label);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corpus_import_flatten_oracle() {
|
||||
run_oracle(&MIXED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn psd_tools_import_flatten_oracle() {
|
||||
run_oracle(&PSD_TOOLS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn psd_tools_mutations_never_panic() {
|
||||
run_mutations(&PSD_TOOLS);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
//! Synthetic reproductions of failures found by the psd-tools corpus
|
||||
//! (`cargo xtask corpus --psd-tools`; see `tests/corpus.rs`). The corpus
|
||||
//! files themselves are never committed: each test rebuilds the relevant
|
||||
//! structure from our own documents.
|
||||
|
||||
use photocraft_color::{Color, ColorMode, SampleType};
|
||||
use photocraft_doc::{Document, Fill, Layer, LayerContent, LayerMask, Path, ShapeLayer, Size, Subpath, VectorMask};
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_io::*;
|
||||
use photocraft_psd::layer::CHANNEL_REAL_USER_MASK;
|
||||
use photocraft_psd::{ChannelData, Compression, MaskData, PsdFile, RealMask, Version};
|
||||
|
||||
fn doc(depth: SampleType) -> Document {
|
||||
Document::with_background("t", Size::new(32, 32), ColorMode::Rgb, depth, Color::WHITE)
|
||||
}
|
||||
|
||||
fn square(x0: f64, y0: f64, x1: f64, y1: f64) -> Path {
|
||||
Path::new(vec![Subpath::polygon(&[(x0, y0), (x1, y0), (x1, y1), (x0, y1)])])
|
||||
}
|
||||
|
||||
fn reimport(file: &PsdFile) -> ImportResult {
|
||||
import("x.psd", &file.to_bytes().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
/// psd-tools 32bit.psd, 300dpi.psd, transparentbg.psd, vector-mask2.psd: shape layers whose
|
||||
/// record stores no pixels (empty rect; every 32-bit shape layer) imported as invisible. They
|
||||
/// are now rendered from their path and fill.
|
||||
#[test]
|
||||
fn shape_layer_without_stored_pixels_renders_from_its_path() {
|
||||
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
let mut d = doc(depth);
|
||||
let sh = ShapeLayer { path: square(8.0, 8.0, 24.0, 24.0), fill: Some(Fill::Solid(Color::BLACK)), ..Default::default() };
|
||||
let mut sh2 = sh.clone();
|
||||
sh2.cache = Some(photocraft_vector::render_shape(&sh, d.pixel_format(), d.bounds()));
|
||||
d.layers.push(Layer::new("Shape 1", LayerContent::Shape(sh2)));
|
||||
let mut file = PsdFile::from_bytes(&export(&d, "x.psd", &ExportOptions::default()).unwrap().bytes).unwrap();
|
||||
// Drop the shape record's pixels, as Photoshop does for 32-bit documents.
|
||||
let bits = file.header.depth;
|
||||
let rec = file.layers_mut().last_mut().unwrap();
|
||||
assert!(rec.block(b"vmsk").is_some() || rec.block(b"vsms").is_some());
|
||||
rec.rect = Default::default();
|
||||
for ch in &mut rec.channels {
|
||||
*ch = ChannelData::encode(ch.id, Compression::Raw, &[], 0, 0, bits, Version::Psd).unwrap();
|
||||
}
|
||||
let imp = reimport(&file);
|
||||
let f = photocraft_compose::flatten(&imp.document);
|
||||
assert!(f.get(16, 16)[0] < 0.01, "{depth:?}: shape interior renders black: {:?}", f.get(16, 16));
|
||||
assert!(f.get(2, 2)[0] > 0.99, "{depth:?}: outside stays white");
|
||||
}
|
||||
}
|
||||
|
||||
/// psd-tools mask-density-vectormask.psd, mask_parameters.psd: vector mask density and feather
|
||||
/// were dropped on export (only the user mask's parameters were written).
|
||||
#[test]
|
||||
fn vector_mask_density_and_feather_survive_export() {
|
||||
for with_user_mask in [false, true] {
|
||||
let mut d = doc(SampleType::U8);
|
||||
let mut l = Layer::raster("pix", d.pixel_format());
|
||||
l.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 32, 32), &[0.0, 0.0, 0.0, 1.0]);
|
||||
let mut vm = VectorMask::new(square(0.0, 0.0, 16.0, 32.0));
|
||||
vm.density = 128.0 / 255.0;
|
||||
vm.feather = 1.5;
|
||||
l.vector_mask = Some(vm);
|
||||
if with_user_mask {
|
||||
let mut m = LayerMask::reveal_all();
|
||||
m.density = 204.0 / 255.0;
|
||||
m.feather = 2.5;
|
||||
l.mask = Some(m);
|
||||
}
|
||||
d.layers.push(l);
|
||||
let r = export(&d, "x.psd", &ExportOptions::default()).unwrap();
|
||||
assert!(!r.warnings.iter().any(|w| w.contains("density")), "{:?}", r.warnings);
|
||||
let b = import("x.psd", &r.bytes).unwrap().document;
|
||||
let vm = b.layers[1].vector_mask.as_ref().unwrap();
|
||||
assert_eq!((vm.density, vm.feather), (128.0 / 255.0, 1.5), "user mask: {with_user_mask}");
|
||||
assert_eq!(b.layers[1].mask.is_some(), with_user_mask);
|
||||
if let Some(m) = &b.layers[1].mask {
|
||||
assert_eq!((m.density, m.feather), (204.0 / 255.0, 2.5));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// psd-tools layer_mask_data.psd, mask-density-layervectormask.psd: a layer with both a user
|
||||
/// mask and a vector mask stores the "real" user mask (channel -3) and mask parameters; the
|
||||
/// real-mask fields precede the parameters, so the -3 channel failed to decode.
|
||||
#[test]
|
||||
fn real_user_mask_with_parameters_is_decoded() {
|
||||
let mut d = doc(SampleType::U8);
|
||||
let mut l = Layer::raster("pix", d.pixel_format());
|
||||
l.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 32, 32), &[0.0, 0.0, 0.0, 1.0]);
|
||||
let mut m = LayerMask::reveal_all();
|
||||
m.density = 0.5;
|
||||
l.mask = Some(m);
|
||||
let mut vm = VectorMask::new(square(0.0, 0.0, 32.0, 32.0));
|
||||
vm.density = 0.5;
|
||||
l.vector_mask = Some(vm);
|
||||
d.layers.push(l);
|
||||
let mut file = PsdFile::from_bytes(&export(&d, "x.psd", &ExportOptions::default()).unwrap().bytes).unwrap();
|
||||
// Add a real user mask hiding the left half (0) inside its rect, revealing elsewhere.
|
||||
let rec = file.layers_mut().last_mut().unwrap();
|
||||
let MaskData::Mask(pm) = &mut rec.mask else { panic!("mask record") };
|
||||
assert!(pm.parameters.is_some());
|
||||
let real_rect = photocraft_psd::Rect { top: 0, left: 0, bottom: 32, right: 16 };
|
||||
pm.real = Some(RealMask { flags: 0, background: 255, rect: real_rect });
|
||||
pm.trailing.clear();
|
||||
rec.channels.push(ChannelData::encode(CHANNEL_REAL_USER_MASK, Compression::Rle, &[0u8; 16 * 32], 16, 32, 8, Version::Psd).unwrap());
|
||||
let imp = reimport(&file);
|
||||
assert!(imp.warnings.iter().all(|w| !w.contains("could not be decoded")), "{:?}", imp.warnings);
|
||||
let mask = imp.document.layers[1].mask.as_ref().unwrap();
|
||||
// Hidden at density 0.5: 1 - 0.5 · (1 - 0).
|
||||
assert!((mask.value(4, 4) - 0.5).abs() < 0.01, "real mask hides the left half: {}", mask.value(4, 4));
|
||||
assert_eq!(mask.value(24, 4), 1.0, "and reveals outside its rect");
|
||||
assert_eq!(mask.density, 128.0 / 255.0);
|
||||
assert_eq!(imp.document.layers[1].vector_mask.as_ref().map(|v| v.density), Some(128.0 / 255.0));
|
||||
}
|
||||
|
||||
/// psd-tools layer_mask_data.psd, mask-parameters-no-real-channel.psd: a shape layer whose
|
||||
/// vector mask has a density (or feather) shows its fill beyond the path at 1 - density; the
|
||||
/// stored pixels hold only the shape. It imports as a fill layer with a soft vector mask.
|
||||
#[test]
|
||||
fn shape_with_vector_mask_density_imports_as_fill_with_soft_mask() {
|
||||
let mut d = doc(SampleType::U8);
|
||||
let mut l = Layer::new("Ellipse", LayerContent::Fill(Fill::Solid(Color::BLACK)));
|
||||
let mut vm = VectorMask::new(square(8.0, 8.0, 24.0, 24.0));
|
||||
vm.density = 0.8;
|
||||
l.vector_mask = Some(vm);
|
||||
d.layers.push(l);
|
||||
let mut file = PsdFile::from_bytes(&export(&d, "x.psd", &ExportOptions::default()).unwrap().bytes).unwrap();
|
||||
// Photoshop stores the shape alone: replace the record's pixels with the path's coverage.
|
||||
let rec = file.layers_mut().last_mut().unwrap();
|
||||
assert!(rec.block(b"SoCo").is_some() && rec.block(b"vmsk").is_some());
|
||||
rec.rect = photocraft_psd::Rect { top: 8, left: 8, bottom: 24, right: 24 };
|
||||
for ch in &mut rec.channels {
|
||||
if ch.id >= -1 {
|
||||
let v = if ch.id == -1 { 255 } else { 0 };
|
||||
*ch = ChannelData::encode(ch.id, Compression::Raw, &[v; 16 * 16], 16, 16, 8, Version::Psd).unwrap();
|
||||
}
|
||||
}
|
||||
let imp = reimport(&file);
|
||||
let l = &imp.document.layers[1];
|
||||
assert!(matches!(l.content, LayerContent::Fill(_)), "{}", l.content.kind_name());
|
||||
assert_eq!(l.vector_mask.as_ref().map(|v| v.density), Some(204.0 / 255.0));
|
||||
let f = photocraft_compose::flatten(&imp.document);
|
||||
assert!(f.get(16, 16)[0] < 0.01, "inside the path: black");
|
||||
assert!((f.get(2, 2)[0] - 0.8).abs() < 0.01, "outside: 20% of the fill shows: {:?}", f.get(2, 2));
|
||||
}
|
||||
|
||||
/// psd-tools adjustment-fillers.psd: a solid-colour shape whose vector path is empty with the
|
||||
/// initial-fill record set (it covers the canvas) and no stored pixels rendered nothing.
|
||||
#[test]
|
||||
fn shape_without_pixels_and_full_initial_fill_covers_the_canvas() {
|
||||
let mut d = doc(SampleType::U8);
|
||||
let mut path = Path::new(Vec::new());
|
||||
path.inverted = true;
|
||||
let sh = ShapeLayer { path, fill: Some(Fill::Solid(Color::BLACK)), ..Default::default() };
|
||||
let mut sh2 = sh.clone();
|
||||
sh2.cache = Some(photocraft_vector::render_shape(&sh, d.pixel_format(), d.bounds()));
|
||||
d.layers.push(Layer::new("Color Fill 1", LayerContent::Shape(sh2)));
|
||||
let mut file = PsdFile::from_bytes(&export(&d, "x.psd", &ExportOptions::default()).unwrap().bytes).unwrap();
|
||||
let rec = file.layers_mut().last_mut().unwrap();
|
||||
rec.rect = Default::default();
|
||||
for ch in &mut rec.channels {
|
||||
*ch = ChannelData::encode(ch.id, Compression::Raw, &[], 0, 0, 8, Version::Psd).unwrap();
|
||||
}
|
||||
let f = photocraft_compose::flatten(&reimport(&file).document);
|
||||
assert!(f.get(0, 0)[0] < 0.01 && f.get(31, 31)[0] < 0.01, "covers everything: {:?}", f.get(0, 0));
|
||||
}
|
||||
@@ -48,7 +48,7 @@ ChannelData { id, compression: Option<Compression>, data } // encoded bytes ke
|
||||
ChannelData::encode(id, compression, decoded, w, h, depth, version)
|
||||
ChannelData::decode(w, h, depth, version); set_decoded(...)
|
||||
MaskData::{None, Mask(LayerMask), Raw(Vec<u8>)}
|
||||
LayerMask { rect, default_color, flags, parameters: Option<MaskParameters>, real: Option<RealMask>, trailing }
|
||||
LayerMask { rect, default_color, flags, parameters: Option<MaskParameters>, real: Option<RealMask>, trailing, real_first }
|
||||
BlendMode (27 modes + PassThrough + Unknown([u8;4])); BlendMode::key()/from_key()
|
||||
Layer<'_>::rgba8() -> Result<RgbaImage>; user_mask() -> Option<Result<GrayImage>>; channel_bytes(id)
|
||||
|
||||
|
||||
+101
-8
@@ -176,6 +176,21 @@ pub struct LayerMask {
|
||||
pub real: Option<RealMask>,
|
||||
/// Remaining bytes (the 2 padding bytes of the 20-byte form, etc.).
|
||||
pub trailing: Vec<u8>,
|
||||
/// When both are present, the real-mask fields come before the mask parameters. Photoshop
|
||||
/// writes this order (the published spec lists the parameters first); `false` keeps a file
|
||||
/// that uses the spec's order byte-stable.
|
||||
#[cfg_attr(feature = "serde", serde(default = "default_true"))]
|
||||
pub real_first: bool,
|
||||
}
|
||||
|
||||
#[cfg(feature = "serde")]
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Byte length of the mask parameter values announced by the parameter flags byte `pf`.
|
||||
fn mask_parameter_bytes(pf: u8) -> usize {
|
||||
usize::from(pf & 1 != 0) + 8 * usize::from(pf & 2 != 0) + usize::from(pf & 4 != 0) + 8 * usize::from(pf & 8 != 0)
|
||||
}
|
||||
|
||||
impl LayerMask {
|
||||
@@ -190,19 +205,34 @@ impl LayerMask {
|
||||
|
||||
/// The simple 20-byte form.
|
||||
pub fn new(rect: Rect, default_color: u8, flags: u8) -> Self {
|
||||
LayerMask { rect, default_color, flags: flags & !Self::FLAG_PARAMETERS, parameters: None, real: None, trailing: vec![0, 0] }
|
||||
LayerMask { rect, default_color, flags: flags & !Self::FLAG_PARAMETERS, parameters: None, real: None, trailing: vec![0, 0], real_first: true }
|
||||
}
|
||||
/// `true` if the disabled flag is set.
|
||||
pub fn disabled(&self) -> bool {
|
||||
self.flags & Self::FLAG_DISABLED != 0
|
||||
}
|
||||
|
||||
/// Whether `d` (a whole mask record with mask parameters) stores the real-mask fields before
|
||||
/// the parameters, as Photoshop does. The spec's order wins when it reads as parameters
|
||||
/// followed by padding alone (no real mask), the one layout the two orders can share.
|
||||
fn real_before_parameters(d: &[u8]) -> bool {
|
||||
let fits =
|
||||
|pf_at: usize| d.get(pf_at).is_some_and(|&pf| (pf_at + 1 + mask_parameter_bytes(pf)..=pf_at + 4 + mask_parameter_bytes(pf)).contains(&d.len()));
|
||||
!fits(18) && fits(36)
|
||||
}
|
||||
|
||||
fn parse(d: &[u8]) -> Result<Self> {
|
||||
let mut r = Reader::new(d);
|
||||
let rect = Rect::read(&mut r)?;
|
||||
let default_color = r.u8()?;
|
||||
let flags = r.u8()?;
|
||||
let parameters = if flags & Self::FLAG_PARAMETERS != 0 {
|
||||
let read_real = |r: &mut Reader<'_>| -> Result<Option<RealMask>> {
|
||||
Ok(if r.remaining() >= 18 { Some(RealMask { flags: r.u8()?, background: r.u8()?, rect: Rect::read(r)? }) } else { None })
|
||||
};
|
||||
let has_parameters = flags & Self::FLAG_PARAMETERS != 0;
|
||||
let real_first = has_parameters && Self::real_before_parameters(d);
|
||||
let mut real = if real_first { read_real(&mut r)? } else { None };
|
||||
let parameters = if has_parameters {
|
||||
let pf = r.u8()?;
|
||||
Some(MaskParameters {
|
||||
flags: pf,
|
||||
@@ -214,14 +244,28 @@ impl LayerMask {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let real = if r.remaining() >= 18 { Some(RealMask { flags: r.u8()?, background: r.u8()?, rect: Rect::read(&mut r)? }) } else { None };
|
||||
Ok(LayerMask { rect, default_color, flags, parameters, real, trailing: r.peek_rest().to_vec() })
|
||||
if !real_first {
|
||||
real = read_real(&mut r)?;
|
||||
}
|
||||
// The order only matters when both are present; otherwise default to Photoshop's.
|
||||
let real_first = real_first || real.is_none() || parameters.is_none();
|
||||
Ok(LayerMask { rect, default_color, flags, parameters, real, trailing: r.peek_rest().to_vec(), real_first })
|
||||
}
|
||||
|
||||
fn write(&self, out: &mut Vec<u8>) {
|
||||
self.rect.write(out);
|
||||
out.put_u8(self.default_color);
|
||||
out.put_u8(self.flags);
|
||||
let write_real = |out: &mut Vec<u8>| {
|
||||
if let Some(real) = &self.real {
|
||||
out.put_u8(real.flags);
|
||||
out.put_u8(real.background);
|
||||
real.rect.write(out);
|
||||
}
|
||||
};
|
||||
if self.real_first {
|
||||
write_real(out);
|
||||
}
|
||||
if let Some(p) = &self.parameters {
|
||||
out.put_u8(p.flags);
|
||||
if let Some(v) = p.user_density {
|
||||
@@ -237,10 +281,8 @@ impl LayerMask {
|
||||
out.put_f64(v);
|
||||
}
|
||||
}
|
||||
if let Some(real) = &self.real {
|
||||
out.put_u8(real.flags);
|
||||
out.put_u8(real.background);
|
||||
real.rect.write(out);
|
||||
if !self.real_first {
|
||||
write_real(out);
|
||||
}
|
||||
out.put(&self.trailing);
|
||||
}
|
||||
@@ -755,6 +797,57 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Photoshop stores the real-mask fields before the mask parameters (psd-tools
|
||||
/// mask-density-layervectormask.psd): user + vector mask with both densities set.
|
||||
#[test]
|
||||
fn real_mask_before_parameters_photoshop_layout() {
|
||||
let mut d = Vec::new();
|
||||
Rect::from_xywh(15, -1, 18, 10).write(&mut d);
|
||||
d.extend([0, LayerMask::FLAG_PARAMETERS | 8]);
|
||||
d.extend([0, 255]);
|
||||
Rect::from_xywh(0, 0, 32, 8).write(&mut d);
|
||||
d.extend([0b0101, 64, 64, 0]);
|
||||
let m = LayerMask::parse(&d).unwrap();
|
||||
assert_eq!(m.real.map(|r| r.rect), Some(Rect::from_xywh(0, 0, 32, 8)));
|
||||
assert_eq!(m.real.map(|r| r.background), Some(255));
|
||||
let p = m.parameters.unwrap();
|
||||
assert_eq!((p.user_density, p.vector_density), (Some(64), Some(64)));
|
||||
assert_eq!(m.trailing, vec![0]);
|
||||
assert!(m.real_first);
|
||||
let mut out = Vec::new();
|
||||
m.write(&mut out);
|
||||
assert_eq!(out, d);
|
||||
}
|
||||
|
||||
/// The spec's order (parameters, then real fields) still parses and stays byte-stable.
|
||||
#[test]
|
||||
fn real_mask_after_parameters_spec_layout() {
|
||||
let mut d = Vec::new();
|
||||
Rect::from_xywh(1, 2, 3, 4).write(&mut d);
|
||||
d.extend([255, LayerMask::FLAG_PARAMETERS]);
|
||||
d.extend([0b0011, 200]);
|
||||
d.extend(1.5f64.to_be_bytes());
|
||||
d.extend([0, 255]);
|
||||
Rect::from_xywh(0, 0, 3, 3).write(&mut d);
|
||||
let m = LayerMask::parse(&d).unwrap();
|
||||
assert!(!m.real_first);
|
||||
assert_eq!(m.real.map(|r| r.rect), Some(Rect::from_xywh(0, 0, 3, 3)));
|
||||
assert_eq!(m.parameters.unwrap().user_feather, Some(1.5));
|
||||
let mut out = Vec::new();
|
||||
m.write(&mut out);
|
||||
assert_eq!(out, d);
|
||||
// Parameters alone (no real mask) keep reading in the spec's order.
|
||||
let mut d = Vec::new();
|
||||
Rect::from_xywh(1, 2, 3, 4).write(&mut d);
|
||||
d.extend([255, LayerMask::FLAG_PARAMETERS, 0b1010]);
|
||||
d.extend(1.0f64.to_be_bytes());
|
||||
d.extend(2.0f64.to_be_bytes());
|
||||
d.extend([0, 0]);
|
||||
let m = LayerMask::parse(&d).unwrap();
|
||||
assert_eq!(m.real, None);
|
||||
assert_eq!(m.parameters.map(|p| (p.user_feather, p.vector_feather)), Some((Some(1.0), Some(2.0))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_sizes() {
|
||||
let mut out = Vec::new();
|
||||
|
||||
@@ -281,6 +281,7 @@ pub fn layered(version: Version, mode: ColorMode, depth: u16, compression: Compr
|
||||
parameters: Some(MaskParameters { flags: 0b0011, user_density: Some(200), user_feather: Some(1.5), vector_density: None, vector_feather: None }),
|
||||
real: Some(RealMask { flags: 0, background: 255, rect: Rect::from_xywh(0, 0, 3, 3) }),
|
||||
trailing: Vec::new(),
|
||||
real_first: true,
|
||||
};
|
||||
next(&mut layers, Rect::from_xywh(3, 3, 6, 4), "Grüße", BlendMode::ColorDodge, Some(real_mask));
|
||||
layers.push(group_record(&header, "Inner", SectionType::ClosedFolder, BlendMode::Normal, id0 + 2));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Optional corpus test: iterates `corpus/psd/**/*.{psd,psb}` at the
|
||||
//! workspace root when present (fetched by `xtask corpus`; never committed).
|
||||
//! Skips silently when the directory does not exist.
|
||||
//! Optional corpus test: iterates `corpus/psd/**/*.{psd,psb}` and
|
||||
//! `corpus/psd-tools/**/*.{psd,psb}` (the full psd-tools set, fetched by
|
||||
//! `cargo xtask corpus --psd-tools`) at the workspace root when present
|
||||
//! (never committed). Skips silently when neither directory exists.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
@@ -24,16 +25,21 @@ const KNOWN_BAD: &[(&str, &str)] = &[("group-divider-blend-mode.psd", "psd-tools
|
||||
|
||||
#[test]
|
||||
fn corpus_parse_and_byte_stable() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/psd");
|
||||
for dir in ["corpus/psd", "corpus/psd-tools"] {
|
||||
parse_and_byte_stable(&Path::new(env!("CARGO_MANIFEST_DIR")).join("../..").join(dir));
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_and_byte_stable(root: &Path) {
|
||||
if !root.is_dir() {
|
||||
return;
|
||||
}
|
||||
let mut files = Vec::new();
|
||||
collect(&root, &mut files);
|
||||
collect(root, &mut files);
|
||||
files.sort();
|
||||
let mut failures = Vec::new();
|
||||
for p in &files {
|
||||
let name = p.strip_prefix(&root).unwrap_or(p).display().to_string();
|
||||
let name = p.strip_prefix(root).unwrap_or(p).display().to_string();
|
||||
let bytes = match std::fs::read(p) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
@@ -61,6 +67,6 @@ fn corpus_parse_and_byte_stable() {
|
||||
},
|
||||
}
|
||||
}
|
||||
eprintln!("corpus: {} files, {} failures", files.len(), failures.len());
|
||||
eprintln!("corpus {}: {} files, {} failures", root.display(), files.len(), failures.len());
|
||||
assert!(failures.is_empty(), "corpus failures:\n{}", failures.join("\n"));
|
||||
}
|
||||
+10
-2
@@ -169,7 +169,7 @@ the live command registry (`menus::is_live`) and rewrites [`docs/parity.md`](par
|
||||
- byte-exact round trips
|
||||
- malformed-input sweeps (truncate at every offset)
|
||||
- fuzz targets (`crates/*/fuzz`)
|
||||
- **Real-file corpus** in `corpus/` (gitignored). `corpus/psd` holds MIT-licensed test PSDs, listed with their sources in `corpus/psd/SOURCES.md`, and `cargo xtask corpus --download` fetches PngSuite. Tests skip silently when a corpus is absent.
|
||||
- **Real-file corpus** in `corpus/` (gitignored). `corpus/psd` holds MIT-licensed test PSDs, listed with their sources in `corpus/psd/SOURCES.md`, `cargo xtask corpus --download` fetches PngSuite, and `cargo xtask corpus --psd-tools` fetches the complete psd-tools test set (MIT, 309 PSD/PSB files at a pinned commit, sha256-verified) into `corpus/psd-tools`. Tests skip silently when a corpus is absent.
|
||||
- **Composite oracle:** a PSD's embedded merged image is compared with our compositor's output. The pass rate is tracked in the roadmap.
|
||||
- **UI:** unit tests for widgets and state, plus screenshot checks through the control channel.
|
||||
|
||||
@@ -229,7 +229,15 @@ so a following `ui.inspect` observes their effect.
|
||||
## Rendering fidelity (PSD oracle)
|
||||
|
||||
`cargo test --release -p photocraft-io --test corpus -- --nocapture` compares our composite of every
|
||||
corpus PSD with Photoshop's own merged image (PASS ≤ 2/255). To dig into one file:
|
||||
corpus PSD with Photoshop's own merged image (PASS ≤ 2/255) and checks that export → re-import renders
|
||||
the same, per source with its own floors: `corpus/psd` (enforced with `PHOTOCRAFT_CORPUS=1`) and the
|
||||
psd-tools set (`cargo xtask corpus --psd-tools`, enforced with `PHOTOCRAFT_PSDTOOLS_CORPUS=1`, which
|
||||
also runs a truncation/corruption sweep over every file that must never panic). A panic is reported
|
||||
as `CRASH` and fails an enforced run. Files without a real merged image (Maximize Compatibility off)
|
||||
are judged against their embedded thumbnail instead (`PASS (thumbnail)`, a strict low-resolution
|
||||
check); a file only SKIPs when it has no oracle at all. Raise the floors in `crates/io/tests/corpus.rs` when they
|
||||
improve; never lower them. Synthetic reproductions of corpus findings live in
|
||||
`crates/io/tests/corpus_regressions.rs` (corpus files are never committed). To dig into one file:
|
||||
|
||||
```sh
|
||||
cargo run --release -p photocraft-io --example oracle_diff -- corpus/psd/<file>.psd 0 png /tmp/diff.png
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
3ae85219dadb3028dab6f4f8f72e6e1a6ca4b5d40aeede8abf87ec24020150fa 0layers.psb
|
||||
0711306e56002f193e33aefe4a1e9ce89ceafaea04da243593bc0c1e697c7d64 0layers.psd
|
||||
4f40d8f359571c86e485af6c711a52af90c4b8aac2ad84db6655cca75a61ce24 0layers_tblocks.psb
|
||||
407c1b0659fd6283f2833b9cf95c7ca3538c5cc61d226e63cebf90a4c1c9f665 0layers_tblocks.psd
|
||||
e0c9b37ffb5981aaedbeec2b2dfa3ad561b57e86e5e04758ed859ca819cd427c 16bit5x5.psb
|
||||
c78689ea7b576f23bbd8b6b4f4993a365266c3ffc9aaa02cc20bef4a36a9d7a0 16bit5x5.psd
|
||||
85317ccdb7ec11a51f1983ad2055544a490839c01f8b313db8070c1b0f0b83f4 1layer.psb
|
||||
c2b457581d549f4bea2e5c34a04b68b2917f640bce0e958b6bca9c65be75174b 1layer.psd
|
||||
ee06e0307b1d5966eccc84768c245183c9ad90dc8e0ab3a8113f75d407615d4d 1layer2.psd
|
||||
1434880f98b92d60193e6759859bc5ebe589e5deeed22246d0e37c85db58840f 2layer_8ele_tblocks.psd
|
||||
863c51eb4d4e72214a3cbdf06a0594c485e4262d899318b1ba914d4c848382ab 2layers.psb
|
||||
406ddf7cbf5a1065b992b9c0d2a59be6290a371b90f23b9a04c0e6bbc94beb0c 2layers.psd
|
||||
a31ee6a32a1e51ef2cae6e4c829ac328c560436306a5e7038c42c562ec241594 300dpi.psb
|
||||
613a5c22e0afdaed8fc3e2c11ab0f30267c515cecb27165833b0ed3833d0f51f 300dpi.psd
|
||||
232bde4537492a671d22b7ff7025a786bc8110677ce5add180103f977be4f5ee 32bit.psb
|
||||
78dd96ec74960a392db0a3bf4193478d62adfa5d93634ab7ce8a40e4468df5e1 32bit.psd
|
||||
cd506872a4b1dc48bf7f64e10d321b99ea950c557cd3a83e303288e8ef6bf481 32bit5x5.psb
|
||||
66a4260e6bb8a60deff94031a66fa502c74870d75a723b4180aef02c35a97d0e 32bit5x5.psd
|
||||
72ab9e417042a9258350326814ebbdeef84442ac3f453b5462f3d339adc29768 adjustment-fillers.psd
|
||||
6c866b95c1458b5a639249f379fad009e3dd6f8cfa2d3dfbbb431ee637c30390 adjustment-mask.psd
|
||||
39f55d8391a80bb768012738a37971a4c02ecffc5519ecab67ec89781e0a0af7 adjustments/adjustment_backdrop_test.psd
|
||||
7dc81242cc78a22cf11a9b587e335299235de37e29b869a7bbb37c6ad7bf29d8 adjustments/adjustment_clipping.psd
|
||||
55a3b828b486c77e1ba1c6ed5442ba2c1a157e0fc1020c5bf50d82e920221306 adjustments/adjustment_nested_composition_1.psd
|
||||
7e120732f1520095f5a0f112e2995d6da69f824013d10f800b630f13d1ccf018 adjustments/adjustment_nested_composition_2.psd
|
||||
0e9b3b8f029a9cdcea9e3e974fd45ad9c4eb78fd4617220f806fca72cc05e24c adjustments/adjustment_nested_composition_3.psd
|
||||
1577dded7ceed05fb786b775948501571cad06d90b20c61a5a0923067c501000 adjustments/adjustment_nested_composition_4.psd
|
||||
f150b3200c829edd0613e9be83c22c37c2d993a8ee9927be3400ed2c2b14e2aa adjustments/adjustment_nested_composition_5.psd
|
||||
12cbc4ca9a18cfae4a01dd207c5f44b135606e55354f162636be796ea0329992 adjustments/brightnesscontrast_cmyk.psd
|
||||
c21289d955a9b24418f26ae423dc69ab1d7431c734510391f99cfa563ddf4167 adjustments/brightnesscontrast_grayscale.psd
|
||||
b1844e3919ba6858361b696cb1d9232ef3a5b702f00b55c1c973f7f1c5433487 adjustments/brightnesscontrast_legacy_cmyk.psd
|
||||
d79c5b778d2b6e023c9b17fba58b0efa05d90c071f623d5b52a4bd8ad9d9fa82 adjustments/brightnesscontrast_legacy_grayscale.psd
|
||||
6c36993b8429316aeaa3b67af97d9517eec01f09e548bf5770a6d1643548aa68 adjustments/brightnesscontrast_legacy_rgb.psd
|
||||
bc2e1771b28268b101b6f50fe4f71444f54d170266de4b5bfb0244e47a9a8df2 adjustments/brightnesscontrast_rgb.psd
|
||||
3508445b94eddadd1f74245ccb316889cbbc3b37e40b983a3b2b5d53b5f56c4c adjustments/curves_cmyk.psd
|
||||
f716cdf162c2c49e41aab1393f0b6f15336a8f66e664936b0294dc997ce272d0 adjustments/curves_grayscale.psd
|
||||
ca20254c47a9fcc0029caa81f7ac7e197ff3657808332c22b75e23920fd040bb adjustments/curves_rgb.psd
|
||||
01147b29385829f34cb5c746d7d779fc82cff89fb92fc47b9652300526271f5a adjustments/exposure_grayscale.psd
|
||||
3e188d416aa2608fc22e34ef274787d58dd4172510760dcef3cbc8c470607379 adjustments/exposure_rgb.psd
|
||||
8e64b22972a81439c97ea2ec94d535e4c947c276c54ab701d7eee042de02efa0 adjustments/huesaturation_colorize_rgb.psd
|
||||
bacb269f9b1f75e54bc64f10cf33e5acf947389900b7ee6b6576b7cb332e4cdb adjustments/huesaturation_lightness_rgb.psd
|
||||
2c81966b5f43f48ea0596ee02b41a18eff3db92e3a9a54c83769e561f018f55d adjustments/huesaturation_rgb.psd
|
||||
389c6fe7d8dd6f73e4ea4039b8ee4b94cc9c20023ace6727aa76799a7897f409 adjustments/huesaturation_saturation_rgb.psd
|
||||
cf0b1d08cfc2f6f1c222009df6f15cad0f52fcb7b2bb3f461229a2b4349929b1 adjustments/invert_cmyk.psd
|
||||
b4660b0e3af58db0e593232f31c63b46c74557b8bcc4bf7cc8835d9930f1f26c adjustments/invert_grayscale.psd
|
||||
6a48998f4d0c970b973eab4d465997f8fe7183f1d757013089a3144c124152a4 adjustments/invert_rgb.psd
|
||||
b566d8e2db943fe666083d0765f9e31cfbc5cc33ac2793d5b94bf4d37caa944f adjustments/levels_cmyk.psd
|
||||
d5fa40edf2973be124d27cc485151d70a8c017d90ddcfd8b87c6d55b0ec17fb5 adjustments/levels_grayscale.psd
|
||||
7c6a8e94a97b66d554748ad09be068a6dcbe3cd8d56ea3809eb231a8168905df adjustments/levels_rgb.psd
|
||||
3f737af36486974475e28cc2fa747dcef9a993b20d357a48e14bb77a3e2d3b60 adjustments/posterize_16bits_cmyk.psd
|
||||
05f9af8ead05b0febbaf3286b6d8bf82d2e39a31fa0fdeac2885a22886156a26 adjustments/posterize_16bits_grayscale.psd
|
||||
f349d0e2e253e1d7b3dcd8645d555a1605b7e2d6bba1b99de164ab7853e4871c adjustments/posterize_16bits_rgb.psd
|
||||
24846e6fbd76b928cdf6833807345043261e3967ee58331d93e46ca79c1616d6 adjustments/posterize_cmyk.psd
|
||||
5c3219ce794da2c4c5d7dcf3e00f3152c07d0a2064d4104ba05767238889dd8a adjustments/posterize_grayscale.psd
|
||||
7e97af29a928e43e0c16baeb3b60483574774e2d7f23667ab07a3affeb83048a adjustments/posterize_rgb.psd
|
||||
b9edb0f14ee579357df5bb887bc10077c99b29febf132feb1897e9dd0a47ce8b adjustments/threshold_16bits_grayscale.psd
|
||||
db8191826e0ca11fdf0961abd9acfa9fbb31b3edca5fc7492f5c0b6766d13559 adjustments/threshold_16bits_rgb.psd
|
||||
5f0f902f8ca88e9b149d107c7d2099d367965c576e21f329d029553ae4d80ad0 adjustments/threshold_cmyk.psd
|
||||
ea435249c68711f8f68fe58cf7c7f38d7e4259d0c919638c2eb5034857f55671 adjustments/threshold_grayscale.psd
|
||||
d61e095b3bf816a39eac075243ca0cadeb1922411b14db13c3fda598b741c5f0 adjustments/threshold_rgb.psd
|
||||
433b583db7361a0be3dd6be74031288da16f1a01b4573c125e1bdcdc9697acf4 advanced-blending.psd
|
||||
10f0065dde46150f1adbc9acc3594b85f42a0e02323e87ca1a5ec80d908ccb13 artboard-bgcolor.psd
|
||||
86d3ccc572cfeec57b271467ba103fb9e32c4f33351276abbf4f962913072b81 artboard.psd
|
||||
391f4c009b9bd4cc5fdffb3799c3476f7d690874e6ca458463ab7fe3b5739aee background-red-opacity-80.psd
|
||||
3bf2015eeb25319fd365711f9b5ea52bf1099f7f767dd1bea47d08283cc0eaa8 blend-and-clipping.psd
|
||||
6032d73dd7a02f2a24d748fc74a79848279ccbe113c2fca143e653bfd5da6cc9 blend-modes/cmyk-blend-modes.psd
|
||||
3a234f7b9664c07e2cb554fb92f464987e299d56aca8713519689afc2b7cde60 blend-modes/color-burn.psd
|
||||
0b320de07d9285ce6d718f1f99d762c96c6216a9f13582f6f2cd5c8a1dae6408 blend-modes/color-dodge.psd
|
||||
8f9bc703572cadffcb2d0be9b3e54d98c26998008693bde6de42f6da542a00f2 blend-modes/color.psd
|
||||
83a49ce3fa585ec40dc6a89dc007546eb90a19c934ecb3c4ba4c6a0793f7b495 blend-modes/darken.psd
|
||||
8a1a674394804c907478f67d6a410e12d4d44833d7804c5cbb78ad6f835f33d5 blend-modes/darker-color.psd
|
||||
a4c341a91a074b97efb15463c9c1133d6827367b6985ee0c1eefcba8425eca41 blend-modes/difference.psd
|
||||
d100f77081f28a75678d6b626379d2327e64c2c04b08afe8a763f59f7dda6c1d blend-modes/dissolve.psd
|
||||
59cccb4099dea57d63733fd675536f0a85af59986093b625734734e82f86de1f blend-modes/divide.psd
|
||||
9f9e7f72b28ea2a1179051e55995485c073a63627d880ef453e5fc7e0ffb9dcc blend-modes/exclusion.psd
|
||||
28ac78b43922a11106a058a0c8368576083f4c57016e6e775bea3a9e9d2a046d blend-modes/gray-blend-modes.psd
|
||||
272d1f6e12ba9853451cdcec6585440320507ed2a37a1aee8ed37d19662ef103 blend-modes/group-divider-blend-mode.psd
|
||||
b45a66ad68a902a715c0db39eb1754f1c98b091245c50620eae800f3a2030d01 blend-modes/hard-light.psd
|
||||
574ac1260e30c3f8d6b2975ba98674b5e052f7838c3c0cd880299f8879ef7e74 blend-modes/hard-mix.psd
|
||||
e70d0ae7d708ef3b0bbb62ad03f0c3d00268a0d15c5a4bb0c0c2ccba0a78f924 blend-modes/hue.psd
|
||||
4fc0432c1a154cddc0a70f558281cf5034caf561d61b91a0409e6bdc89c613d8 blend-modes/lighten.psd
|
||||
5ff8eddc32b369cbe24470727b01560174bc1a31a48cd90da766c29c79533823 blend-modes/lighter-color.psd
|
||||
c9dab0d70793b22dbb73ebbdb71c25f771e483f50a0c55193f9ea6f544342558 blend-modes/linear-burn.psd
|
||||
1fd41e7c186f999d736dba2bd2d09624bcf307e15bd6dc03461b2194546ee06b blend-modes/linear-dodge.psd
|
||||
28ee78b412a63832317bdff2400844ab10a91cb4c4f28d52ef3695428a730b9e blend-modes/linear-light.psd
|
||||
bbb36bfc321189dde7212605681907bc627a38a7fc6a97d7a3bb90004173605a blend-modes/luminosity.psd
|
||||
f0e16879b2cd78e7fa859ea00a22abfc601cb84347d9f854e055d3ed0ffd59a5 blend-modes/multiply.psd
|
||||
ad44357d551d7f59d496843f2260bf0e9645fcf63068d3535432383a0299292d blend-modes/normal.psd
|
||||
21cc256ef3fa5806bfa45c7a69c92e2a2e78a03e9d12025ba8cfa76b07941362 blend-modes/overlay.psd
|
||||
b7b95e85a67d79c46e84757b0ddf84e5f0dd3229fce994507cabe176db6d82f0 blend-modes/pass-through.psd
|
||||
c7e2b0aa5b3ca3eaed159b3783a2958053a25b27edf0a06922a826e59e231d9b blend-modes/pin-light.psd
|
||||
3005b561711e23fb17681e36d49c5200b48a89eed8e87b7fffe6c5fce95d3b0e blend-modes/rgb-blend-modes.psd
|
||||
b60e973294894556eef9e0bb65798e6ddfee3d71bd0389fef4813692b0e415f2 blend-modes/saturation.psd
|
||||
2786c78df04ff6724a112ca3eb2a9ba7468a4dcb3d5e8ab804c8e13711556195 blend-modes/screen.psd
|
||||
3e68729590ac9e5031bd870301e7df2ada3490297d3d372d97015c09aa114a8c blend-modes/soft-light.psd
|
||||
863638e0769d2bab51ce97f19184063cde4457bc5a8fa4c9024311f6251d8def blend-modes/subtract.psd
|
||||
e350f2bf3f0b756a3ba61eb6e62f8ff747561da095ca09d54e212835ed23161e blend-modes/vivid-light.psd
|
||||
463ca1c2f93622285315ff6d69e7c2a38bf7f0143bae4676a89ccb924d596662 broken-groups.psb
|
||||
c76302640f11dca2a81d7ece60d5e4095bc62a442fc03e8ede78d1c1550a8d07 broken-groups.psd
|
||||
60c2fcfe7245bfa141b565b54cdaa36ee4ad56133a883260c663f857db935f15 clip-adjustment.psd
|
||||
92b54b07d584d65924cb2944bc44b0dac7ede671c3e667202fcf6d067837eccb clipping-mask.psb
|
||||
0a44262635e68fe2c6823eed6cb840ddc43b9d2620c8d6948e1aae81291ac55a clipping-mask.psd
|
||||
624fa86083a976f9bce2dcb73d063e8b1144233fff65396c7d79524ab9960772 clipping-mask2.psd
|
||||
27adc3c327ac4fb1cc069bf4dfee8d8ff70784180f3855bb0bf605709a91537a clipping-mask3.psd
|
||||
366f250a766fa5ff7198a6ff292138854666957bb58099bfebba4448fe96126f clipping-mask4.psd
|
||||
a80dd95e43e05ecb1dc2e59189a74ad90d7e4cd44a071e8477eb6b63edfe7c08 clipping-mask5.psd
|
||||
3f868555748e87a357890f2f20afa150f5820a460d73d3d0c1d2c52b14ebea0b cmyk-alpha-spot.psd
|
||||
f563bc6bc16595c837eece4c8f3cbe9d20acfd004d48163de95c76ec40a9ecae cmyk-gray-ramp.psd
|
||||
d5164efc0221f84cd3cc9573db3d8a4805691e04e67b738b4160c042a7b9fb9d cmyk-spot.psd
|
||||
579787b33f69062b1cec82c3ddeeff1da33303f3379c96f516fdd07eab834fc6 colormodes/100x20_1bit_bitmap_rle.psd
|
||||
edaf3b8cd5f621b4f35436778e91be2d487f76dc381caa1cc8bd17d325041f14 colormodes/20x5_1bit_bitmap.psd
|
||||
4b25f12981dd2d435ba2669e3e771198c76765d3934a6cf57c069e0ad009cf6e colormodes/4x4_16bit_cmyk.psd
|
||||
5ee15880f77c1a0e2566864d8770a26efe3aa261245e360267973ebdd542a49c colormodes/4x4_16bit_grayscale.psd
|
||||
35f1727b45b73eb6191a726937f336c8ef6515992bd4913c80e67f46dbc5f12b colormodes/4x4_16bit_lab.psd
|
||||
36730fdab702fbbde8f8b96451d1099186a4443d02180119542ed50aab34c88d colormodes/4x4_16bit_multichannel.psd
|
||||
5ac6e33ce952a5a6daf86144bedf849d60fd0008513f94c57fc1973960249a9a colormodes/4x4_16bit_rgb.psd
|
||||
56ec3967706373af835a319aa2ef6698351e07b8b495e5296ba4fe8848c2dd5f colormodes/4x4_1bit_bitmap.psd
|
||||
f46c66327138fd85986ad514b9002c2cf0f4d654223e2ef6652a907b110ab8cd colormodes/4x4_32bit_grayscale.psd
|
||||
7104010764a3223399c3c1c797d64502be4391d266645edf0e600b75ea77e1f5 colormodes/4x4_32bit_rgb.psd
|
||||
1b662e547d5edc6e10e9a84a03434988c6133795f8b416a03004afa9dd0334fa colormodes/4x4_8bit_cmyk.psd
|
||||
4169c367a7e4698f55955901849c38b8bd284dd61f8ddcbef8bafa70e54bb19b colormodes/4x4_8bit_duotone.psd
|
||||
2811614db8536c363ffd4b9c97baf8965b58fda5c894ffeffe1067d1287edacf colormodes/4x4_8bit_grayscale.psd
|
||||
1da88234b4c1d33ef2fed6a3a496f68e3c28c0bce420400917736e9b72f194cc colormodes/4x4_8bit_index_color.psd
|
||||
48e52730a29cf715f9df9be12d6730af9e8125d27c5293910fedd7d621cf03e9 colormodes/4x4_8bit_lab.psd
|
||||
b656bd96b10350fc6911afaabbc2fa6a48cf5c1ae59c1a8e2f952539bd4f1930 colormodes/4x4_8bit_rgb.psd
|
||||
1f87ed0f6bace7587c0ab3fb48ffe22a7796a42e11743b6a10c42a4d2bd56d0f colormodes/4x4_8bit_rgba.psd
|
||||
528599e9b1e9456b7e5764da2c75c6cb7ba976774b7d352caa34d34451775f27 colorprofiles/north_america_newspaper.psd
|
||||
96ab1a61b0067548a830d8c9a9b07309657c4052a252ae92fa15866357e273c4 descriptors/hsb-color-swatches.psd
|
||||
67986bbc829fd8d391dab43b471f7b7b8edf9c4635db5b6af4970ecabab6fabc descriptors/lab-color-swatches.psd
|
||||
eb0cab1dd8decb1be3c0ceb95eaf555cc4370a011ee25dc76fbb164483c2b55e descriptors/stroke-color-descriptors-gray.psd
|
||||
2d47b073029143e2224e303fad965973a70e526a8c88234d1fb9be116eaa3aa8 descriptors/stroke-color-descriptors-hsb-with-rgb-mode.psd
|
||||
9f472778a8dca363a3dcac7f8a5aab14d53a8a6c84ed6f7b12381c96b099cf09 descriptors/stroke-color-descriptors-lab.psd
|
||||
cc95a5bd2bdc6cf2de60ec1741acfa6672c5c4a12d6e750bde2e91fd6e5939a6 descriptors/stroke-color-descriptors-rgb.psd
|
||||
0dc6114f10b814368f2fac1708668777cf0ede0159ee0484f3b9f40c165b8691 effect-stroke-gradient.psd
|
||||
f1523bab5138726aa1f5ff89b3ca061175cc75e779148c2c750a914dd05e399f effects/antialiased-stroke-edge.psd
|
||||
496e91bd45d5ca9ed97e67d3f6c61cf88c8b169228e7b25e30b78c159c24714e effects/blend-modes.psd
|
||||
9ecadbb3216d4a5c2cd1df273b215635445afe7798dd0af204cf8d9d70f1eebc effects/center-stroke-sizes.psd
|
||||
f7c0631ead3370a97aa445f610665796d14475528acc1184e276dd5d3f47ca3e effects/double-stroke-effects.psd
|
||||
0dc5b4c928ecd890a93b6c887d14f709bda441a1982c9839b48f5c12e142261b effects/effect-enums.psd
|
||||
42815f35af3998fd2fa4785bca94bb79124063e228d43c1fd206f71cdc43de87 effects/effects-enabled.psd
|
||||
04fa5572072685682da74a90e5ab2228750f67f17cd43afb45ba0f9a27151da7 effects/feathered-stroke.psd
|
||||
678d9ca2357056ac84e3494d0965268e7c700fc656101efc863d43e57cca0239 effects/group-clips-child-stroke.psd
|
||||
62c699af1abd88422787aad0014e923b4ac1aa7e10d460f0ef485cb6310dd379 effects/group-stroke-off-canvas.psd
|
||||
341a20b123b85ca0a9bdae0df3d74d8831341371c774081a4638856a1821f7ea effects/inset-stroke-sizes.psd
|
||||
5be59daf7c5130513ab1c1e8900a9a317602434abd1ab46aaa9f4a98e5335d08 effects/outside-stroke.psd
|
||||
f0c53835a0c5a8279512497f3448f2cfaf6ec31831eaab160c32779ed6a93fd2 effects/shape-fx.psd
|
||||
d4db7556433ada2b7530de3e2a826327ec5497d77a2e2ce78dc48ccb980637c6 effects/shape-fx2.psd
|
||||
f9a513b4192b336b644caf93153f20ac30fef448298a7dd64f06f46728f770f6 effects/stroke-composite.psd
|
||||
bd6514a680c05740ec8392d7ca624a2a6a90e8bddfdb65a698fc184980344af1 effects/stroke-effect-transparent-shape.psd
|
||||
8481c35a8739c6d3177237194d1da1528bcfa70ed8831394176c6b2b21e11a1c effects/stroke-effects.psd
|
||||
b6fb31441e7c69d07045549495307864ff0fb2c21518aff0080b5effacd97af3 effects/stroke-without-vector-mask.psd
|
||||
ec878104e88476b0ff3ac05943d88a690eb19b1440c63585cf22ba50f0baccc8 empty-group.psd
|
||||
e63dd3689546f26113080a86a3ab6ad74c9200b44fa917c19bbaaab906692da5 empty-layer.psb
|
||||
f616f4984c6225c57751d8b6303368caaf6212a92070331ccce99f43352af0c0 empty-layer.psd
|
||||
40fb3984526471ff38b6836c647075802c66d2cded2e88d675898a5486317e4a fill_adjustments.psd
|
||||
88d1e42e5c70a6f3f99b57e1feb623e0c9460807fbb00cb80223e213415e0a1d gradient-fill.psb
|
||||
30d617696f4453b8f4fb6688af152aea30d8e5730f632105d3f8feecc6ecc150 gradient-fill.psd
|
||||
39af0f5d0ef8ca1fb31d2e4ebf75f5bc5d8a9a506ba3829b998575ff3375ba82 gradient-sizes.psd
|
||||
9c5e709a1659b9d65ebe5bec6719191bf7dedae72b224fbb63414225f812b018 gradient-styles.psd
|
||||
2fcaed63aec566c47872f8e84eb4a070b4e5b27d88bf8432dd1ec11fb418cb40 gradients/noise-gradient-cmyk.psd
|
||||
de9b1ea4b76ac5abadfddb794d98ac0189a46b9c575d5ad276d02306c9c6188e gradients/noise-gradient-grayscale.psd
|
||||
8e6a6b2594af88e7d8af0f48df40803634b1073239d823703a768c9ed7128940 gradients/noise-gradient-lab.psd
|
||||
83e205b5f380dd2623b166cb45f7f35765c3c93a787b242f0a9ea20cf9782bdf gradients/noise-gradient-rgb.psd
|
||||
99e7b4652b50e4b518e9f071bd630b3e73f3c874b5b0348db44fa911793ed1db gray0.psb
|
||||
f7160dd9dea521cb05e586dd62cff3f7a2429b070b2aba38cdc71b5522e9217c gray0.psd
|
||||
daf50e30e718c674f7c6e62ff88a098087ff27da632da6c3de87fcbcafeb45b2 gray1.psb
|
||||
a8af49b6771e85355195d5e379b80d1e44e21a3ff6a1cb7272949fba68fb0a09 gray1.psd
|
||||
b8e346ee1909c793c5d1be36b01dacad7152d3f40757b4271cfb869d4a5ba141 group-clipping/group-clipping.psd
|
||||
e5322646c91d54e068293e96580faebc3eae5d82d75ab3b38bd0e5c7cfb14ad2 group.psb
|
||||
1aaf572b69f0b0fa7c04b7b3a14c97e310a0c7bbdc06e733c9dd0d0cdcf7df1c group.psd
|
||||
1dccb23ffa47e59a548d96a893612e48d3f8f876ce3cc81779657ad19ae886a6 hidden-groups.psb
|
||||
57ebb0195f61309c9c73fddec6a9c44ce931ccb3f165f8a78c73861a1fe3862d hidden-groups.psd
|
||||
61504449562ab7e9f72c9182279385df4f7374c002c5443fdaf1db3bf8e738b9 hidden-layer.psb
|
||||
3aaadd20b5ff9d5e778239ee734c9c2bf57fc56bccc82c1b003c3719df46dba0 hidden-layer.psd
|
||||
d4b49e5bc0f514a32727b2067e460ca0b3592645cdff655bcaa4f319338ca384 history.psb
|
||||
b93a3fe87dc807596050f0ab696c7b7972171dda27430593ae50f8516141619a history.psd
|
||||
e554563c160386836cfd186ba7bd657f08c0c57e60fd7f1156fd3d9bf08de4ae issues/issue397.psd
|
||||
2ed49149deeffbccb89aebbf8c5a19dee12a864b17641c7bc03a89e957f3eaa8 layer-name-emoji.psd
|
||||
4a875e7a01d12346e1ee9a3e0c66e2e08b29c9786c3d966e39296c7488a79647 layer_comps.psb
|
||||
4179ed664df5e5b3473c62587039f6a373a0f1494f6bc490acec7e57493d1537 layer_comps.psd
|
||||
6ab41ce6e17b3287c703dc4cf4d898506ffc1bd9babf20d0f1de5ef6df1d2e1e layer_effects.psd
|
||||
807a1362da4d756f9096a75d9b0fb0ec46c3c5feeafe3ce96ccc66db7ba5a014 layer_mask_data.psb
|
||||
e30f5d2f51ef2dc5790b45b50ff3911ab7991ffdf63230561364a2574c1392aa layer_mask_data.psd
|
||||
e2d4a3c2162bb022c115ebeb51eeb5b9f22978e12e47f419aeeeac45f9e2be71 layer_params.psb
|
||||
23c2c5af17b56f682193cd6d1095ab57ea685eeaf4a9061399bf79d6a0a28b19 layer_params.psd
|
||||
8d995a89eee1b4b11e0639cb4546e46aff8c9fd168a7b7ddfb4abb9f4b75e806 layers-minimal/gradient-fill.psd
|
||||
c1f7bb729d8476f328388ed58fd9fadc72819426b23b45f7cbf9ece1b0e36865 layers-minimal/pattern-fill.psd
|
||||
5862b2f0cae962f73468cf32f4f1410fb785010201a074dba7102f83a15dfa00 layers-minimal/pixel-layer.psd
|
||||
53f4791a888269f835a65e30fd29d2cc775490c35cdefe16987129e00bbd9a18 layers-minimal/shape-layer.psd
|
||||
f451b816bb5292564eaad9a29eb7543c3b2ad6b5dc35d1babc0a4c799502ee01 layers-minimal/smartobject-layer.psd
|
||||
e22eebb32a29bf05072882b2de9f15132da70b90417784439798432a69ee6267 layers-minimal/solid-color-fill.psd
|
||||
b24e6f4c7277dd499ebb20c88ac06698f4563e75bfeb6aa6cb0d2466140fd81f layers-minimal/type-layer.psd
|
||||
70d843798d732f2a56f3dc8614d7c5e6c575b45328ece7e9fe61a340ef3b48ad layers/black-and-white.psd
|
||||
ba069aff12a110616c4d1ba7522f0cda8dc312fa4129f4cb8bdc21e30c805744 layers/brightness-contrast.psd
|
||||
974fe05663302be94379d72d08d1d1d6e7025ea7ddad012cd1cd81e0d3cb94d9 layers/channel-mixer.psd
|
||||
cc7fdeb95d6b06790f07d0564b8c5591b19af94839ed453e38517fea14d02cdf layers/color-balance.psd
|
||||
10d5b941563e3305c741e2c1725483546634349270ae0fc2bc49b2778c1b181f layers/color-lookup.psd
|
||||
8c343f76e9eb964425c5a6efdcb29829fc21f47ad06ac465a9acf1ed6036c0a9 layers/curves-with-vectormask.psd
|
||||
e34d8f3bf915d4e48527efd2c6231d39b0705943208391ec03571015aa37ac54 layers/curves.psd
|
||||
1eb4ffdab3c21acf455baf7084cfafefe816d628f4edc7d45206f3e0dffe183b layers/exposure.psd
|
||||
2bac8500879484584fae6af3d90c8c2a2b2f566d350971e79dfc5283c993e2ae layers/gradient-fill.psd
|
||||
8980760d269e95954e55c9142aca02fb21d27138b66d1e62f6921ab9bc131968 layers/gradient-map-v3-classic.psd
|
||||
38fdbffa9b6b0144aad4ddcd66333ed698e9091ec318a1516240e9a0b7b85bb2 layers/gradient-map-v3-linear.psd
|
||||
416a2db01f11356df8f3a0ce24cb66cc977c15124cf820bc9d2729a90987f488 layers/gradient-map-v3-perceptual.psd
|
||||
a6b436d69b68b76ceb0fc4f8d7f3d080823ce5591d1a71f30aa391a6a19f01bf layers/gradient-map.psd
|
||||
83b4847a919359154ed5c01baa39606f5de42abd30eff73bc101f979b3303fb3 layers/group.psd
|
||||
679b1b6468b1c68d65f698d2c4dbdb185232092d78a2f9f6e77c897f400c7111 layers/hue-saturation.psd
|
||||
a282a963588bb02022dc09fd1174b2d6afe0df1110953585df3bc046e5fccd5f layers/invert.psd
|
||||
f15bbf06fbd48fc3ad35a5ec266fe2927c39db2bf591fcad7186a4815e633920 layers/levels.psd
|
||||
078fd9ff7469953557201775726afce0b1de85e691bb8bb1d7f797d0dc26c907 layers/pattern-fill.psb
|
||||
f726a18877069161884ec933c5ea4962918f45de8317a47abe6bb9075baf059f layers/pattern-fill.psd
|
||||
80710c84f302f6fc873da8aeb4da99a08953dbfce0962ae70725542ad25f3fff layers/photo-filter.psd
|
||||
480743cc294c702c43c8ef80ac9d4bd9a52c2534a2ccec8708689eeef673964c layers/pixel-layer.psd
|
||||
4a0f93719c3c3c755f49c4e8f60b482f42da1085b40428e00c8f61f4e7d5caf7 layers/posterize.psd
|
||||
14f75f6cb941b81e57a64f03346c13beb731227b16fa3494d9b12e796fdcdbd7 layers/selective-color.psd
|
||||
23b3cc8dfcaca161443ab7345e49fdcd5494ceb864c16221b61ad99473a53830 layers/shape-layer.psd
|
||||
a34abf773a64f21b59d5a854f7d394e7ac41e402d0ce546ddd86d19c764a63a2 layers/smartobject-layer.psd
|
||||
d8229e39489dd992b1965593603c660353d4444f080f97a5233fdc1b1e14fcf2 layers/solid-color-fill.psd
|
||||
223d49f2caa50399c1525594d4a4da215143d24baac8d8b909e603579473cd22 layers/threshold.psd
|
||||
7a61ae63f2518e436320d48c9b6e4cd7b3df64f3d86dac23dd83055f12bc88ba layers/type-layer.psd
|
||||
a18173ed1f3fc01e392b410b0c745f8f7cb4cfcd3d74af80fb426faac015cc1e layers/vibrance.psd
|
||||
7f4240b160a541a0aded458ffc83aaeb9c8d59306342f6065ec9cc0d41be21ec mask-density-layermask.psd
|
||||
185ccf0a483da25af5c3a25942158d849f8bda24ab8be615088fab4f08d01730 mask-density-layervectormask.psd
|
||||
efcce23eeca979a32c9a954b76a9f2948c656b7c48ec1b86606525949796364c mask-density-vectormask.psd
|
||||
989e198abd0bc7e7573db1c5240ea713994113f03cb5a785be941679921561c2 mask-disabled.psd
|
||||
0fa601f84afa646472484a21556b6947f89773e9ce9fa172b96b4f37d8846963 mask-index.psd
|
||||
71bff054dbb4e1749192ba7d99034c689c0337c46c9fdcac86d63dbce9ca299b mask-parameters-no-real-channel.psd
|
||||
1e774b247a20457ad8f3aaa36314bab0d487ea88ec96cf8b9d992f5dbf0b8785 mask-vector-density.psd
|
||||
263ed5883f2af5a1ffb90562d155b2b56a1288b761b7a39d9ec40c3862fc9a9e mask.psb
|
||||
a6928c0e306022d474876e61f4b1dc5b7c1ed1e1cb422e6e72511da0a6aae90b mask.psd
|
||||
41779602a0840558846ac51197efea4205759c45327233ffa440b653c5e63a38 mask_parameters.psd
|
||||
1eb6407018962be307b9eebd41739e56828bfd0fd7073d2a41fb7dcdd537ed7d masks.psb
|
||||
8cd1a0d53274436e79662a96d0f8d020a73df0626cf7921429047a6fe7bf0d07 masks.psd
|
||||
cc6363e24089c2f8910ae44b8067825e2af5e1c43c5d8b3bc6323934fa3cfacd masks/2.psd
|
||||
2b99f27b5a6b3d1dc6a10baa526f804b642a1c7feaffc47a6014bf950d60d7e0 masks2.psb
|
||||
5f9e03f6f1c8ac04dd1e15f7253b489942c3958c00a0e4876b2f944f8ce31d67 masks2.psd
|
||||
00519ff3184b2f426258163b60437cc722dfdcd596a198f85ddd45c17b22fcd5 masks3.psd
|
||||
ed428417c175d15d135666b5896c3e280259f6d243515acc71d4f6c276729897 metadata.psb
|
||||
d279fb8ea593aa1e06b109eed3427594e536211d639299225ed0fbe3586bba6c metadata.psd
|
||||
801b1c14522974ec39099eec445aafab47fc89700b96f1f788466698a09e6bcc multichannel-pattern-fill.psd
|
||||
213f56c58b7b484c0cb7239873854ed2ec7863f350c2283d42c66929cdb54f50 note.psb
|
||||
0b343c50db1a572393ea6a41d48f1aec7fa8d20c862a1716985b73d1a20d8b5f note.psd
|
||||
b3568a8c6491d2da4b9e2aa8c6842b443c09b51aca6ffe44bcbfcc2ae17189ef opacity-fill.psd
|
||||
a9297d5920b230136e66b8cdc5b6f23b3780f55e2075010f938c9ca0ee7d0599 passthrough_blendmodes1.psd
|
||||
a13688cc0baab09d96d5d5878f01c00897cfdc6cf87de5cc5fcff24236ee0e52 passthrough_blendmodes2.psd
|
||||
ce32b24f793fd3c64c79c16b71888ce6e7ab0b3c398ba121e97379ca6cd64a87 passthrough_blendmodes3.psd
|
||||
59e55f9ca8d252980f600998b55ccf81b21d42bc43bf4daeaedd8b296d1a5be7 passthrough_clipping_mask_adjustment.psd
|
||||
27b434d10abcf08c7836ae0511437a3928cb9dd208e731c153d2d078508154e3 passthrough_clipping_mask_blendmode.psd
|
||||
f909304768db5243a833dfc270c78a6701ec36f73e7024b7544f026d15c610f7 passthrough_fill_adjustment.psd
|
||||
eeb3ca39ce23b40ac6e45ece19527f2da33b81597b4549c26a25a8b2e3b85780 passthrough_fill_blendmode.psd
|
||||
514910f20d2a2339a386c35299efe1922c8a44a23591834c997329f1409450f3 passthrough_opacity.psd
|
||||
d6e4db16b3916c26416231d21657bc679a7af3fe8009864bde4b1d177ceb9a01 passthrough_vector_mask.psd
|
||||
17200edea3e3e69f7b884111020a506abba897880479e69506f4355a51ce09e4 path-operations/combine-group.psd
|
||||
61513fbddb67c94d00f7c8a88191074ea9b259f2fd78a180945c49a1e1aa60e3 path-operations/combine.psd
|
||||
3ff08258a3b1710729606342cdf0f00e849e76333233b0a5340448f7a23814d7 path-operations/exclude-first.psd
|
||||
27f0f3899d176f43bd0231118a2f62cae566c593f7b86a75dee251e125b34115 path-operations/exclude-group.psd
|
||||
d50e60b672bfe21ac02ebefb446c12b6d5d5d15530560a785f50e4e8f6f66791 path-operations/exclude.psd
|
||||
79412dda0453661605b724cbcbef417200188ecb9f87e4778151e4b731b952ac path-operations/intersect-all.psd
|
||||
787a94b10c127efaab037c40b9c797af8d83e4ef7ab6846f730f5adf7addac5b path-operations/intersect-first.psd
|
||||
1f9d38f6cbb3e8bd5648c4acecf5b5a1b1c3f3f0ef94e925bf42d5c0b31d3c2f path-operations/intersect-group.psd
|
||||
3237bb98d4c07ea2e9e533795f515d8958158385872be7e2a4e0508ba53374be path-operations/subtract-all.psd
|
||||
81a1536a432907a6ab8c8a41d3ff061ecfe5e3686970ba2e3697d8e79f60e9b9 path-operations/subtract-first.psd
|
||||
126a90c715c831f41c0b1926819d20dcd6fae14625174e45aa72a2f30318dfd1 path-operations/subtract-group.psd
|
||||
83fdf32685b4d7c035099be3132bc57b687f44c662ccda5ddcb9efd9e2b8569c path-operations/subtract-second.psd
|
||||
8624d62d1d2a94eff2119a08621e02e0d8513db4da0b24e9939e2a9feffb5841 patterns.psd
|
||||
2445db54440058a7a67c807a5e7a0817e2ad9f5f09c300af137a5e0da02515c4 pen-text.psb
|
||||
83514b47cb106a79bc1d676dfd851be372d315f6ba209a943cb7add03f242aa4 pen-text.psd
|
||||
066eeb1bce9c123ffcb57f380d9a51e0687024fdd264904ed3a44c3d5666490c placedLayer.psb
|
||||
69ea01bf88cb85c48d3a78c3bb9e06ae141c9c9fc6a88267eae95c315e16180e placedLayer.psd
|
||||
596a8131e4d63f65a0cc2b6a513fadc06f844a25ce8714be397f506d23abc398 semi-transparent-layers.psb
|
||||
2d190efddf648408ad0115852f8a029eec44de10bd29f6ff222aaa574cc31bc1 semi-transparent-layers.psd
|
||||
bdac1216edac692b5941cd28d60a6ded9ca7b23b32e903af47066c3f9dce958f slices.psb
|
||||
dc7540774270ede4e5d8f76326a495854adb9b69a99c466c1f7d262b2b7f7484 slices.psd
|
||||
4dfea68f5d42bd663bb8e9a375a5eb19622fb06b340acc22c6de1536b878f9f3 smart-object-slice.psb
|
||||
12ef0c357ec24e094390619878fc3476cb7198c8e7690aceb3e18e0b51dc1b28 smart-object-slice.psd
|
||||
885b3481df602f877f363f45f7206c4095ad347ab01276843d7fb37901d1ec0b stroke-center-no-fill.psd
|
||||
9dd230f2ab0bd34a47da0cc4410890adfa650c509fa1f4c7f5624146c698f924 stroke-center.psd
|
||||
46cfa6e630f6badbdb92376620d481c2128a76439b50d652d00534e6c51d8ce3 stroke-outer-no-fill.psd
|
||||
6a61905b234c7447281214976878205a135d5900bbb81db0c6db7eda1187a877 stroke-outer.psd
|
||||
ab39632f4266a2bc72cfb676f06aa5fc60f1c4951fed2f9fd67165895e61890c stroke.psd
|
||||
d28350bc188655b1bfb4741877af946f07810f2eaa50c95d33939c210dd79e96 text.psb
|
||||
eb40ec54a5f270913463bcc847be65672ce6fff0d0d56288725f58e299e00f1c text.psd
|
||||
405f13bbbcc9001d8771f313d898ba683d79a7e79005650d8c1a017a4fb0ca2a third-party-psds/cactus_top.psd
|
||||
01cdafadb5828ac23dd9b5ed4d308c00509f36f64da5760002b7e7f7e3d235d9 transparency/clip-opacity.psd
|
||||
3b0273eaa87051c0482fca601ca9ba111595e74a77494771f911402f0c1da1c4 transparency/fill-opacity.psd
|
||||
3f2fa613f3bd4ba33038c5897c0ea1fee9afcd1de396fd4d961fee6215e3e3f8 transparency/knockout-deep-cyanbg.psd
|
||||
0cc7495b55f1b937f9d1c465e6d736f58233fbfbbea69b7eadefb49ac831ef8c transparency/knockout-deep-nested-pt.psd
|
||||
d206355323809135408357a715814112852fe52d55269a9e5feb681567dff6f8 transparency/knockout-deep-nested.psd
|
||||
fe1de1727f836bcaf86fd76845aae29e66a268abf4977b80571e30413a57deeb transparency/knockout-deep-nobg.psd
|
||||
3cd5136ed85f14ba251d00867fb3df76377ff007b36247fe4180eeb21f0cc9d9 transparency/knockout-deep-normal.psd
|
||||
c963f7026d2e9f304984a82103a4c0ce05393151890f2277f93dfa02a9b5789c transparency/knockout-deep-passthrough.psd
|
||||
4db674ff13022a3b688d0e23fc087f0978fab8f45a581d601ef050d05ae14189 transparency/knockout-isolated-groups.psd
|
||||
33bc40c3c70f813ba6c0d6ea5d8ea29b9d8a12ca27dabeb83726ff91a3c2e37c transparency/knockout-none-cyanbg.psd
|
||||
9755c4adda49116b7afac2280e9a481ec1420e65ecbd3de54736df0fb902aaf5 transparency/knockout-none-nested.psd
|
||||
ac034b07a50b3badb803053da33859c89d64a12f97adf7bfe5c139c254cbb3d8 transparency/knockout-none-normal.psd
|
||||
6c378b8aa2f93fcf9f58d58744cd7674f5bd76e6832b82f3988ca9a260619c86 transparency/knockout-none-passthrough.psd
|
||||
38aca2dd78bf0682450468363907a2389edd28c345bdf1de3125b8f46a56178f transparency/knockout-shallow-nested-pt.psd
|
||||
b94230c312c3caea229e13a27264fd9f89f719fa98de0cf87f539cfb8ab25dd6 transparency/knockout-shallow-nested.psd
|
||||
86d3c7fc67576dc3472a54a9c3478525881875948b5ca81aca0496d4b10fdc78 transparency/transparency-group.psd
|
||||
6710f99e5b32843cebc7a1a5d252b8d4156735cfe10f971bd76e860541bd7ede transparentbg-gimp.psb
|
||||
9c700d3e7ad0429ac3ea83a40bb02f364a3d4e7713e7d608e23a8e03014d3b9c transparentbg-gimp.psd
|
||||
f4c14e457a74dcfc979c0ac4e2b25378036cb983dda1ee273a3f5267405f24d1 transparentbg.psb
|
||||
d6b3ebbc2928e80d6cb9e53e2bfc935083a2f76def4325547c382c0141fda8bb transparentbg.psd
|
||||
62e9eec973d1ef9c61515ffe6eba7d6285e6943e0e1b1e01b125a6f2b3896346 unicode_pathname.psb
|
||||
9ce5876f237020b4a10607d60bef0d4c568d7d330ac91016778196762f429f42 unicode_pathname.psd
|
||||
4e6a91fd815b5f42aa5f1873979f668747594645a7845b700c01e6c53d03988d vector-mask-disabled.psd
|
||||
0596ec478f78842bdb471cffe0ceadae132904d5062efa3425721b0413122be9 vector-mask.psb
|
||||
e0dd21d3325a42cd81c6abc17a4fdc283101eca0ba306f852fe8bc167926d502 vector-mask.psd
|
||||
fc6c32967f6a62641fa02fa314fe24717de49b74da0e1367abdb51a4b55cd49f vector-mask2.psd
|
||||
344eb19a6ae1be6d5282eb0483a2db62c58ee5bddff8a3168dada53d391c6dbd vector-mask3.psd
|
||||
+10
-2
@@ -5,6 +5,8 @@
|
||||
|
||||
mod ico;
|
||||
mod layers;
|
||||
mod psd_tools;
|
||||
mod sha256;
|
||||
mod stats;
|
||||
mod version;
|
||||
|
||||
@@ -18,8 +20,10 @@ commands:
|
||||
layers enforce the crate dependency layering (plan/architecture.md §3)
|
||||
wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates
|
||||
ci fmt --check, clippy -D warnings, test, layers, wasm (stops at first failure)
|
||||
corpus [--download]
|
||||
show where test corpora live; --download fetches PngSuite into corpus/pngsuite
|
||||
corpus [--download] [--psd-tools [--update-manifest]]
|
||||
show where test corpora live; --download fetches PngSuite into corpus/pngsuite;
|
||||
--psd-tools fetches the psd-tools PSDs (MIT, pinned commit, sha256-verified)
|
||||
into corpus/psd-tools
|
||||
stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`)
|
||||
parity Photoshop menu parity; rewrites docs/parity.md
|
||||
version [set X.Y.Z[-pre]]
|
||||
@@ -35,6 +39,7 @@ fn main() -> ExitCode {
|
||||
Some("layers") => cmd_layers(),
|
||||
Some("wasm") => cmd_wasm(),
|
||||
Some("ci") => cmd_ci(),
|
||||
Some("corpus") if rest.contains(&"--psd-tools") => psd_tools::fetch(rest.contains(&"--update-manifest")),
|
||||
Some("corpus") => cmd_corpus(rest.contains(&"--download")),
|
||||
Some("stats") => stats::run(&root(), rest.contains(&"--exact")),
|
||||
Some("parity") => cmd_parity(),
|
||||
@@ -214,6 +219,9 @@ Tests that use a corpus skip cleanly when it is absent.
|
||||
against the `image` crate. Fetch: cargo xtask corpus --download
|
||||
corpus/psd/ PSD samples from MIT/BSD projects (ag-psd, psd-tools test data).
|
||||
Copy files in manually; licences must be MIT/BSD/CC0.
|
||||
corpus/psd-tools/ the full psd-tools test set (MIT) at a pinned commit, verified
|
||||
against xtask/psd-tools-corpus.sha256. Fetch: cargo xtask corpus --psd-tools
|
||||
Run: PHOTOCRAFT_PSDTOOLS_CORPUS=1 cargo test --release -p photocraft-io --test corpus
|
||||
corpus/tiff/ libtiff pics (optional)
|
||||
corpus/exr/ OpenEXR sample images (optional)
|
||||
corpus/raw/ raw.pixls.us samples, CC0 (optional)
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
//! `cargo xtask corpus --psd-tools`: fetches the psd-tools test PSDs
|
||||
//! (MIT, https://github.com/psd-tools/psd-tools/tree/main/tests/psd_files) at a
|
||||
//! pinned commit into `corpus/psd-tools/` (gitignored, never committed) and
|
||||
//! verifies every file against `xtask/psd-tools-corpus.sha256`.
|
||||
//!
|
||||
//! Re-running is cheap: an already complete, verified copy is left alone.
|
||||
//! To move to a newer upstream commit, change `COMMIT`, delete
|
||||
//! `corpus/psd-tools`, run with `--psd-tools --update-manifest` and review the
|
||||
//! manifest diff (and the corpus floors in `crates/io/tests/corpus.rs`).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use crate::{root, run, sha256};
|
||||
|
||||
/// psd-tools commit the corpus is pinned to (main, 2026-10-05).
|
||||
pub const COMMIT: &str = "96eb134c17b2c65edf4c4151c0f00b802ada86c2";
|
||||
/// Generic User-Agent: no personal details in requests.
|
||||
const USER_AGENT: &str = "Photocraft-dev";
|
||||
const MANIFEST: &str = "xtask/psd-tools-corpus.sha256";
|
||||
|
||||
/// `(sha256, relative path)` pairs from the manifest.
|
||||
fn manifest() -> Result<Vec<(String, String)>, String> {
|
||||
let path = root().join(MANIFEST);
|
||||
let text = std::fs::read_to_string(&path).map_err(|e| format!("read {}: {e}", path.display()))?;
|
||||
text.lines()
|
||||
.filter(|l| !l.trim().is_empty())
|
||||
.map(|l| {
|
||||
let (hash, file) = l.split_once(" ").ok_or_else(|| format!("{MANIFEST}: bad line {l:?}"))?;
|
||||
if hash.len() != 64 || file.contains("..") || file.starts_with('/') {
|
||||
return Err(format!("{MANIFEST}: bad line {l:?}"));
|
||||
}
|
||||
Ok((hash.to_string(), file.to_string()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Files in `dest` that are missing or do not match the manifest.
|
||||
fn mismatches(dest: &Path, files: &[(String, String)]) -> Vec<String> {
|
||||
files
|
||||
.iter()
|
||||
.filter_map(|(hash, file)| match std::fs::read(dest.join(file)) {
|
||||
Ok(bytes) if sha256::hex(&bytes) == *hash => None,
|
||||
Ok(_) => Some(format!("{file}: sha256 mismatch")),
|
||||
Err(_) => Some(format!("{file}: missing")),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn collect_psds(dir: &Path, base: &Path, out: &mut Vec<PathBuf>) {
|
||||
let Ok(rd) = std::fs::read_dir(dir) else { return };
|
||||
for e in rd.flatten() {
|
||||
let p = e.path();
|
||||
if p.is_dir() {
|
||||
collect_psds(&p, base, out);
|
||||
} else if p.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("psd") || e.eq_ignore_ascii_case("psb")) {
|
||||
out.push(p.strip_prefix(base).unwrap_or(&p).to_path_buf());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fetch(update_manifest: bool) -> Result<(), String> {
|
||||
let corpus = root().join("corpus");
|
||||
let dest = corpus.join("psd-tools");
|
||||
let files = if update_manifest { Vec::new() } else { manifest()? };
|
||||
if !update_manifest && dest.is_dir() && mismatches(&dest, &files).is_empty() {
|
||||
println!("psd-tools: {} files in {} already match {MANIFEST}", files.len(), dest.display());
|
||||
return Ok(());
|
||||
}
|
||||
let staging = corpus.join(".psd-tools-download");
|
||||
let _ = std::fs::remove_dir_all(&staging);
|
||||
std::fs::create_dir_all(&staging).map_err(|e| format!("create {}: {e}", staging.display()))?;
|
||||
let tgz = staging.join("psd-tools.tar.gz");
|
||||
let url = format!("https://codeload.github.com/psd-tools/psd-tools/tar.gz/{COMMIT}");
|
||||
let mut curl = Command::new("curl");
|
||||
curl.args(["-fsSL", "-A", USER_AGENT, "-o"]).arg(&tgz).arg(&url);
|
||||
run(curl, &format!("curl {url}"))?;
|
||||
let top = format!("psd-tools-{COMMIT}");
|
||||
let mut tar = Command::new("tar");
|
||||
tar.arg("-xzf").arg(&tgz).arg("-C").arg(&staging).arg(format!("{top}/tests/psd_files")).arg(format!("{top}/LICENSE"));
|
||||
run(tar, "tar -xzf psd-tools.tar.gz")?;
|
||||
let extracted = staging.join(&top).join("tests/psd_files");
|
||||
let fresh = if update_manifest {
|
||||
let mut found = Vec::new();
|
||||
collect_psds(&extracted, &extracted, &mut found);
|
||||
found.sort();
|
||||
let mut lines = String::new();
|
||||
let mut out = Vec::new();
|
||||
for f in found {
|
||||
let rel = f.to_string_lossy().replace('\\', "/");
|
||||
let bytes = std::fs::read(extracted.join(&f)).map_err(|e| format!("read {rel}: {e}"))?;
|
||||
let hash = sha256::hex(&bytes);
|
||||
lines.push_str(&format!("{hash} {rel}\n"));
|
||||
out.push((hash, rel));
|
||||
}
|
||||
std::fs::write(root().join(MANIFEST), lines).map_err(|e| format!("write {MANIFEST}: {e}"))?;
|
||||
println!("psd-tools: wrote {} entries to {MANIFEST}", out.len());
|
||||
out
|
||||
} else {
|
||||
files
|
||||
};
|
||||
let bad = mismatches(&extracted, &fresh);
|
||||
if !bad.is_empty() {
|
||||
return Err(format!("psd-tools download does not match {MANIFEST} ({} problems): {}", bad.len(), bad.join(", ")));
|
||||
}
|
||||
// Keep only the verified PSD/PSB files (plus the licence), then swap into place.
|
||||
let _ = std::fs::remove_dir_all(&dest);
|
||||
for (_, file) in &fresh {
|
||||
let to = dest.join(file);
|
||||
if let Some(parent) = to.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| format!("create {}: {e}", parent.display()))?;
|
||||
}
|
||||
std::fs::rename(extracted.join(file), &to).map_err(|e| format!("move {file}: {e}"))?;
|
||||
}
|
||||
std::fs::rename(staging.join(&top).join("LICENSE"), dest.join("LICENSE")).map_err(|e| format!("move LICENSE: {e}"))?;
|
||||
std::fs::write(
|
||||
dest.join("SOURCES.md"),
|
||||
format!(
|
||||
"# psd-tools test corpus\n\nThe PSD/PSB files of https://github.com/psd-tools/psd-tools/tree/{COMMIT}/tests/psd_files, \
|
||||
unmodified. MIT licence, Copyright (c) 2019 Kota Yamaguchi (see `LICENSE`). Fetched and sha256-verified by \
|
||||
`cargo xtask corpus --psd-tools` against `{MANIFEST}`. Gitignored; never commit these files.\n"
|
||||
),
|
||||
)
|
||||
.map_err(|e| format!("write SOURCES.md: {e}"))?;
|
||||
let _ = std::fs::remove_dir_all(&staging);
|
||||
println!("psd-tools: {} verified files in {} (commit {COMMIT})", fresh.len(), dest.display());
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
//! SHA-256 (FIPS 180-4), for verifying downloaded test corpora without
|
||||
//! depending on `sha256sum`/`shasum` being installed.
|
||||
|
||||
const K: [u32; 64] = [
|
||||
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74,
|
||||
0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d,
|
||||
0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e,
|
||||
0x92722c85, 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5,
|
||||
0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
|
||||
];
|
||||
|
||||
/// Lower-case hex SHA-256 of `data`.
|
||||
pub fn hex(data: &[u8]) -> String {
|
||||
let mut h: [u32; 8] = [0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19];
|
||||
let mut msg = data.to_vec();
|
||||
let bits = (data.len() as u64).wrapping_mul(8);
|
||||
msg.push(0x80);
|
||||
while msg.len() % 64 != 56 {
|
||||
msg.push(0);
|
||||
}
|
||||
msg.extend_from_slice(&bits.to_be_bytes());
|
||||
for block in msg.as_chunks::<64>().0 {
|
||||
let mut w = [0u32; 64];
|
||||
for (i, word) in block.as_chunks::<4>().0.iter().enumerate() {
|
||||
w[i] = u32::from_be_bytes(*word);
|
||||
}
|
||||
for i in 16..64 {
|
||||
let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
|
||||
let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
|
||||
w[i] = w[i - 16].wrapping_add(s0).wrapping_add(w[i - 7]).wrapping_add(s1);
|
||||
}
|
||||
let [mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut hh] = h;
|
||||
for i in 0..64 {
|
||||
let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25);
|
||||
let ch = (e & f) ^ (!e & g);
|
||||
let t1 = hh.wrapping_add(s1).wrapping_add(ch).wrapping_add(K[i]).wrapping_add(w[i]);
|
||||
let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22);
|
||||
let maj = (a & b) ^ (a & c) ^ (b & c);
|
||||
let t2 = s0.wrapping_add(maj);
|
||||
hh = g;
|
||||
g = f;
|
||||
f = e;
|
||||
e = d.wrapping_add(t1);
|
||||
d = c;
|
||||
c = b;
|
||||
b = a;
|
||||
a = t1.wrapping_add(t2);
|
||||
}
|
||||
for (x, v) in h.iter_mut().zip([a, b, c, d, e, f, g, hh]) {
|
||||
*x = x.wrapping_add(v);
|
||||
}
|
||||
}
|
||||
h.iter().map(|v| format!("{v:08x}")).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn known_vectors() {
|
||||
assert_eq!(super::hex(b""), "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855");
|
||||
assert_eq!(super::hex(b"abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
|
||||
let long = b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq";
|
||||
assert_eq!(super::hex(long), "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1");
|
||||
assert_eq!(super::hex(&[b'a'; 1000]), "41edece42d63e8d9bf515a9ba6932e1c20cbc9f5a5d134645adb5db1b9737ea3");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user