Photoshop oracles via storytold/photocraft-corpus: pinned corpora, corpus feature, test-corpus, CI job (#190) (#190)

This commit is contained in:
Brandon Thomas
2026-10-05 17:47:33 -04:00
committed by GitHub
parent c454af8d96
commit 968baf639e
32 changed files with 1399 additions and 281 deletions
+23
View File
@@ -66,6 +66,29 @@ jobs:
if: runner.os == 'Linux'
run: cargo xtask scorecard --check
corpus:
# Real-file corpus tests (cargo feature `corpus`), on every PR and on main. The corpora are
# pinned in xtask/src/corpus_pins.rs and sha256-verified (xtask/*.sha256) after both a cache
# restore and a download; a missing corpus fails the tests. The cache key changes only when a
# pin or manifest does, so normally this restores in seconds (fork PRs read the base cache).
name: corpus tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
key: corpus
- name: Restore test corpora
uses: actions/cache@v4
with:
path: corpus/
key: corpus-${{ hashFiles('xtask/src/corpus_pins.rs', 'xtask/*.sha256') }}
- name: Fetch and verify corpora
run: cargo xtask corpus --all
- name: Corpus tests
run: cargo xtask test-corpus
wasm:
name: wasm32 check
runs-on: ubuntu-latest
+5 -1
View File
@@ -15,6 +15,7 @@ PhotoCraft is an open-source, native, Photoshop-comparable image editor written
| `docs/parity.md` | Generated list of every Photoshop menu item, live or missing |
| `docs/scorecard.md` | Generated scorecard: performance budgets and numbers, corpus floors, per-area checklists (tools, files, UI, type, automation, reliability, distribution), settings that do nothing |
| `crates/<name>/README.md` (where present) | Public API of that crate |
| [photocraft-corpus](https://github.com/storytold/photocraft-corpus) + `docs/development.md` › Test corpora | Real-file test oracles (our Photoshop-authored PSDs); with psd-tools, ag-psd and PngSuite fetched into `corpus/` by `cargo xtask corpus --all` at the pins in `xtask/src/corpus_pins.rs` |
## 2. Workspace map
@@ -33,7 +34,7 @@ apps/
photocraft desktop app (eframe/wgpu), TCP control server
photocraft-cli headless CLI (convert/info/run/batch/commands/mcp)
photocraft-web the same app in the browser (trunk + wasm-bindgen)
xtask/ cargo xtask layers | wasm | ci | stats | corpus | parity | perf | scorecard
xtask/ cargo xtask layers | wasm | ci | stats | corpus | test-corpus | parity | perf | scorecard
```
**Layering is enforced** by `cargo xtask layers`. A crate may depend only on lower layers. `psd`, `codecs` and `cms` depend on nothing in the workspace. Nothing below `ui-egui` may use egui, eframe, winit or rfd. A new crate must be registered in `xtask/src/layers.rs`.
@@ -94,8 +95,11 @@ cargo xtask scorecard # if you moved a number: flip the checklist row in s
# corpus floor, fix a dead preference, or meet a budget (then set enforce = true
# in perf/budgets.toml); commit the regenerated docs/scorecard.md (CI checks it)
cargo xtask perf --quick # if you touched a hot path; `cargo xtask perf --update-baseline` publishes a full run
cargo xtask test-corpus # if you touched psd, io, codecs, compose, gpu, text or format (or: --changed decides)
```
**Test corpora.** Real-file corpora live in `corpus/` (gitignored, never committed), fetched at pinned commits and sha256-verified by `cargo xtask corpus --all`: our Photoshop-authored oracles from https://github.com/storytold/photocraft-corpus plus psd-tools, ag-psd and PngSuite from their upstreams. Pins: `xtask/src/corpus_pins.rs`. The corpus tests are opt-in (cargo feature `corpus`): plain `cargo test` skips them, and with the feature on a missing corpus fails ("run `cargo xtask corpus --all`"). `cargo xtask test-corpus` fetches and runs them all. CI always runs them (the `corpus` job, cached by pin). Never commit corpus files; new oracles go to photocraft-corpus (its `AGENTS.md`), then a pin bump here. Details: `docs/development.md` › Test corpora.
Commands must **never panic** on bad input (Rule 9): every `run` closure and the code it calls returns `Err`, not a panic, for any params or document state. New commands come with a graceful-failure test (empty/out-of-range/wrong-type params → `Err`, not a crash).
Then append a terse entry to `log/devlog.md` (what landed, numbers, what's still open). Sessions can end abruptly (crashes, context limits), so the dev log plus a green tree is how the next agent picks up. Keep the tree building at every step.
+12 -7
View File
@@ -48,11 +48,16 @@ Artwork shown in the screenshots (all public domain, via Wikimedia Commons; deta
## Test data (not committed, not shipped)
`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.
`corpus/` is gitignored and no test fixtures are committed to the repository.
`cargo xtask corpus --all` fetches every corpus at the pinned commits in `xtask/src/corpus_pins.rs`
and verifies each file against the sha256 lists in `xtask/*.sha256`, with the upstream licence
next to the files:
`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.
| Path (fetched) | Title | Author | Source | License |
|---|---|---|---|---|
| `corpus/photoshop/` (256 PSDs) | Photoshop oracle corpus: smart filters, layer-style effects, type, adjustments in every mode and depth | PhotoCraft contributors (authored with Adobe Photoshop 2026 by a script) | [https://github.com/storytold/photocraft-corpus](https://github.com/storytold/photocraft-corpus) (`photoshop/`, with its generator and README) | MIT OR Apache-2.0 |
| `corpus/psd-tools/` (309 files) | psd-tools test set | Kota Yamaguchi and contributors | [psd-tools `tests/psd_files`](https://github.com/psd-tools/psd-tools/tree/main/tests/psd_files) | MIT, Copyright (c) 2019 Kota Yamaguchi |
| `corpus/psd/` (170 files) | Small selection of the psd-tools and ag-psd test files | Kota Yamaguchi; Agamnentzar | psd-tools (above) and [ag-psd `test/`](https://github.com/Agamnentzar/ag-psd/tree/master/test) | MIT (both) |
| `corpus/pngsuite/` | PngSuite | Willem van Schaik | <http://www.schaik.com/pngsuite/> | Public domain |
Files copied into `corpus/` by hand (tiff, exr, raw) must be MIT, BSD or CC0.
+7
View File
@@ -277,6 +277,13 @@ Developer, architecture, automation, format, and security documentation is maint
Security architecture, threat modeling, parser hardening, fuzzing, and vulnerability reporting are covered in the [security documentation](book/src/security/) and the repository [security policy](SECURITY.md).
## Test corpora
PhotoCraft is tested against real files: our own Photoshop-authored oracle PSDs in
[photocraft-corpus](https://github.com/storytold/photocraft-corpus) plus the psd-tools, ag-psd and PngSuite sets, pinned and
sha256-verified. Fetch them with `cargo xtask corpus --all` and run the tests with
`cargo xtask test-corpus` (details in [docs/development.md](docs/development.md#test-corpora)).
## The Crafting Apps
PhotoCraft is one of the **Crafting Apps**: free, open-source creative tools from the
+2
View File
@@ -7,6 +7,8 @@ license.workspace = true
rust-version.workspace = true
[features]
# Real-file corpus tests (opt-in; `cargo xtask test-corpus`). Fail when the corpus is missing.
corpus = []
default = []
# AVIF *encode* via ravif (pure Rust). Decoding AVIF needs dav1d (C), so AVIF
# stays read-unsupported and therefore asymmetric; it is off by default.
+5 -2
View File
@@ -196,14 +196,16 @@ fn tiff_f32_output_matches_oracle() {
}
// ---------------------------------------------------------------------------
// Optional PngSuite corpus (repo-root corpus/pngsuite/*.png)
// PngSuite corpus (repo-root corpus/pngsuite/*.png; feature `corpus`, fetched by
// `cargo xtask corpus --all`; missing = failure)
// ---------------------------------------------------------------------------
#[cfg(feature = "corpus")]
#[test]
fn pngsuite_corpus_matches_oracle() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/pngsuite");
let Ok(entries) = std::fs::read_dir(&dir) else {
return;
panic!("{} is missing: run `cargo xtask corpus --all`", dir.display());
};
let mut checked = 0;
for e in entries.flatten() {
@@ -224,4 +226,5 @@ fn pngsuite_corpus_matches_oracle() {
}
}
eprintln!("pngsuite: {checked} files compared");
assert!(checked > 100, "{}: only {checked} PNGs compared: run `cargo xtask corpus --all`", dir.display());
}
+4
View File
@@ -7,6 +7,10 @@ license.workspace = true
[lints]
workspace = true
[features]
# Real-file corpus tests (opt-in; `cargo xtask test-corpus`). Fail when the corpus is missing.
corpus = []
[dependencies]
photocraft-geom = { workspace = true }
photocraft-color = { workspace = true }
+5 -3
View File
@@ -306,9 +306,11 @@ mod tests {
let n = &s.active().unwrap().doc.notes[0];
assert_eq!(n.text, "from pcraft");
assert!(n.position[0] <= 84.0 && n.position[1] <= 60.0);
// A PSD with notes (corpus sample) appends two more.
let psd = concat!(env!("CARGO_MANIFEST_DIR"), "/../../corpus/psd/ag-psd/read-write/annotations/src.psd");
if std::path::Path::new(psd).exists() {
// A PSD with notes (corpus sample, feature `corpus`) appends two more.
#[cfg(feature = "corpus")]
{
let psd = concat!(env!("CARGO_MANIFEST_DIR"), "/../../corpus/psd/ag-psd/read-write/annotations/src.psd");
assert!(std::path::Path::new(psd).exists(), "{psd} is missing: run `cargo xtask corpus --all`");
s.execute("file.import.notes", json!({"path": psd})).unwrap();
assert_eq!(s.active().unwrap().doc.notes.len(), 3);
}
+133
View File
@@ -0,0 +1,133 @@
//! Re-render oracle over the Photoshop corpus (`corpus/photoshop`): our own Photoshop-authored
//! PSDs from https://github.com/storytold/photocraft-corpus, fetched at a pinned commit by
//! `cargo xtask corpus --all`. Feature `corpus` (run with `cargo xtask test-corpus`); a missing
//! corpus fails.
//!
//! The io corpus test (`crates/io/tests/corpus.rs`) flattens imported documents, which composite
//! Photoshop's own cached pixels for smart objects and type layers. This test throws those caches
//! away: every smart object is re-rendered from its embedded source through our smart-filter
//! stack, and every type layer is re-laid-out and re-rasterised by our text engine; the flatten is
//! then compared with Photoshop's merged composite. It is the failure map for smart filters and
//! the text engine. Results are printed per file and per feature group; the pass count must not
//! fall below the floor (raise it as features land, never lower it). Type re-renders only match
//! with the corpus fonts installed (see the corpus README), so the floor counts on none of them.
#![cfg(feature = "corpus")]
use std::path::{Path, PathBuf};
use photocraft_doc::{Document, LayerContent};
use photocraft_psd::PsdFile;
/// Within two 8-bit steps of Photoshop (same tolerance as the io corpus).
const PASS_TOL: f32 = 2.0 / 255.0;
/// Re-rendered files that match Photoshop's merged composite (of 84 with smart objects or type).
const PASS_FLOOR: usize = 1;
fn collect(dir: &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(&p, out);
} else if p.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("psd")) {
out.push(p);
}
}
}
/// Premultiplied max difference over pixels visible in either image (1.0 if sizes differ).
fn max_diff(a: &[[f32; 4]], b: &[[f32; 4]]) -> (f32, f32) {
if a.len() != b.len() {
return (1.0, 100.0);
}
let (mut m, mut bad) = (0.0f32, 0usize);
for (p, q) in a.iter().zip(b) {
let mut pm = 0.0f32;
for c in 0..4 {
let (x, y) = if c < 3 { (p[c] * p[3], q[c] * q[3]) } else { (p[c], q[c]) };
pm = pm.max((x - y).abs());
}
if pm > PASS_TOL {
bad += 1;
}
m = m.max(pm);
}
(m, 100.0 * bad as f32 / a.len().max(1) as f32)
}
/// Re-renders every smart object and type layer of `doc`; returns how many were re-rendered.
fn rerender(doc: &mut Document) -> Result<usize, String> {
let snapshot = doc.clone();
let ids: Vec<_> = snapshot.walk().into_iter().map(|(_, _, l)| l.id).collect();
let mut n = 0;
for id in ids {
let Some(l) = doc.layer_mut(id) else { continue };
match &mut l.content {
LayerContent::Smart(_) => {
if photocraft_engine::smart_cmds::refresh_layer(&snapshot, l).map_err(|e| e.to_string())? {
n += 1;
} else {
return Err("smart object source unavailable".into());
}
}
LayerContent::Text(t) => {
photocraft_engine::type_cmds::refresh(&snapshot, t);
n += 1;
}
_ => {}
}
}
Ok(n)
}
#[test]
fn photoshop_oracles_rerendered() {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/photoshop");
assert!(root.is_dir(), "{} is missing: run `cargo xtask corpus --all`", root.display());
let mut files = Vec::new();
collect(&root, &mut files);
files.sort();
let mut groups: std::collections::BTreeMap<String, (usize, usize)> = Default::default();
let (mut pass, mut total, mut errors) = (0, 0, 0);
eprintln!("{:<60} {:>7} {:>9} {:>8} status", "file (re-rendered)", "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 Ok(file) = PsdFile::from_bytes(&bytes) else { continue };
let Ok(imp) = photocraft_io::import(&name, &bytes) else {
eprintln!("{name:<60} IMPORT-ERROR");
errors += 1;
continue;
};
let mut doc = imp.document;
let n = match rerender(&mut doc) {
Ok(0) => continue,
Ok(n) => n,
Err(e) => {
eprintln!("{name:<60} RERENDER-ERROR {e}");
errors += 1;
continue;
}
};
let Ok(merged) = photocraft_io::merged_composite(&file) else { continue };
let ours = photocraft_compose::flatten(&doc).px;
let (m, bad) = max_diff(&ours, &merged);
let group = name.split(['/', '\\']).next().unwrap_or(".").to_string();
let g = groups.entry(group).or_default();
g.1 += 1;
total += 1;
let status = if m <= PASS_TOL {
pass += 1;
g.0 += 1;
"PASS"
} else {
"DIFF"
};
eprintln!("{name:<60} {n:>7} {m:>9.4} {bad:>7.2}% {status}");
}
eprintln!("photoshop re-render: {total} files with smart objects or type: {pass} pass (<= 2/255), {errors} errors");
for (g, (p, n)) in &groups {
eprintln!("photoshop re-render: {g:<16} {p:>4} / {n:<4} pass");
}
assert!(pass >= PASS_FLOOR, "re-render oracle pass count {pass} fell below the floor {PASS_FLOOR}");
}
+4
View File
@@ -9,6 +9,10 @@ rust-version.workspace = true
[lints]
workspace = true
[features]
# Real-file corpus tests (opt-in; `cargo xtask test-corpus`). Fail when the corpus is missing.
corpus = []
[dependencies]
photocraft-geom = { workspace = true }
photocraft-color = { workspace = true }
+4 -1
View File
@@ -340,17 +340,20 @@ pub fn keep_resource(doc: &Document, id: u16) -> bool {
mod tests {
use super::*;
#[cfg(feature = "corpus")]
const SAMPLE: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../corpus/psd/ag-psd/read-write/annotations/src.psd");
#[cfg(feature = "corpus")]
fn anno_of(bytes: &[u8]) -> Option<Vec<u8>> {
let i = bytes.windows(8).position(|w| w == b"8BIMAnno")?;
let n = u32::from_be_bytes(bytes[i + 8..i + 12].try_into().ok()?) as usize;
Some(bytes[i + 12..i + 12 + n].to_vec())
}
#[cfg(feature = "corpus")]
#[test]
fn parses_and_rewrites_the_sample_byte_exact() {
let Ok(file) = std::fs::read(SAMPLE) else { return };
let file = std::fs::read(SAMPLE).unwrap_or_else(|e| panic!("{SAMPLE}: {e}: run `cargo xtask corpus --all`"));
let raw = anno_of(&file).unwrap();
let notes = parse_anno(&raw).unwrap();
assert_eq!(notes.len(), 2);
+7 -3
View File
@@ -1,6 +1,7 @@
//! Notes (`Anno`) and the measurement scale (resource 1074) through PSD: verbatim while
//! unchanged, regenerated after edits.
#[cfg(feature = "corpus")]
use std::path::PathBuf;
use photocraft_color::{ColorMode, SampleType};
@@ -10,17 +11,20 @@ use photocraft_io::annotations_map::MEASUREMENT_SCALE;
use photocraft_io::{ExportOptions, export, import};
use photocraft_psd::PsdFile;
fn corpus(rel: &str) -> Option<Vec<u8>> {
std::fs::read(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../corpus/psd").join(rel)).ok()
#[cfg(feature = "corpus")]
fn corpus(rel: &str) -> Vec<u8> {
let p = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../corpus/psd").join(rel);
std::fs::read(&p).unwrap_or_else(|e| panic!("{}: {e}: run `cargo xtask corpus --all`", p.display()))
}
fn to_psd(doc: &Document) -> Vec<u8> {
export(doc, "x.psd", &ExportOptions::default()).expect("export").bytes
}
#[cfg(feature = "corpus")]
#[test]
fn corpus_notes_import_and_verbatim_export() {
let Some(bytes) = corpus("ag-psd/read-write/annotations/src.psd") else { return };
let bytes = corpus("ag-psd/read-write/annotations/src.psd");
let src = PsdFile::from_bytes(&bytes).unwrap();
let mut doc = import("src.psd", &bytes).unwrap().document;
assert_eq!(doc.notes.len(), 2);
+8 -4
View File
@@ -1,6 +1,7 @@
//! Layer comps (resource 1065 + per-layer `cmls`) and artboards (`artb`) through PSD and
//! `.pcraft`: byte-exact while unchanged, regenerated after edits.
#[cfg(feature = "corpus")]
use std::path::PathBuf;
use photocraft_color::{BlendMode, Color, ColorMode, PixelFormat, SampleType};
@@ -11,9 +12,10 @@ use photocraft_io::comps_map::LAYER_COMPS;
use photocraft_io::{ExportOptions, export, import};
use photocraft_psd::PsdFile;
fn corpus(rel: &str) -> Option<Vec<u8>> {
#[cfg(feature = "corpus")]
fn corpus(rel: &str) -> Vec<u8> {
let p = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../corpus/psd").join(rel);
std::fs::read(p).ok()
std::fs::read(&p).unwrap_or_else(|e| panic!("{}: {e}: run `cargo xtask corpus --all`", p.display()))
}
fn to_psd(doc: &Document) -> Vec<u8> {
@@ -24,9 +26,10 @@ fn layer_block<'a>(f: &'a PsdFile, name: &str, key: &[u8; 4]) -> Option<&'a [u8]
f.layers().iter().find(|r| r.name() == name).and_then(|r| r.block(key)).map(|b| b.data.as_slice())
}
#[cfg(feature = "corpus")]
#[test]
fn corpus_layer_comps_import_and_verbatim_export() {
let Some(bytes) = corpus("ag-psd/read-write/layer-comps/src.psd") else { return };
let bytes = corpus("ag-psd/read-write/layer-comps/src.psd");
let src = PsdFile::from_bytes(&bytes).unwrap();
let doc = import("src.psd", &bytes).unwrap().document;
let names: Vec<_> = doc.layer_comps.iter().map(|c| c.name.as_str()).collect();
@@ -86,9 +89,10 @@ fn corpus_layer_comps_import_and_verbatim_export() {
}
}
#[cfg(feature = "corpus")]
#[test]
fn corpus_artboards_import_and_verbatim_export() {
let Some(bytes) = corpus("psd-tools/gradient-sizes.psd") else { return };
let bytes = corpus("psd-tools/gradient-sizes.psd");
let src = PsdFile::from_bytes(&bytes).unwrap();
let doc = import("g.psd", &bytes).unwrap().document;
let boards = doc.artboards();
+61 -46
View File
@@ -1,13 +1,15 @@
//! Optional real-file corpora (gitignored, never committed; each test skips
//! silently when its corpus is absent):
//! Real-file corpora (feature `corpus`; run with `cargo xtask test-corpus`). The corpora are
//! gitignored, never committed, and fetched at pinned commits and sha256-verified by
//! `cargo xtask corpus --all` (pins: `xtask/src/corpus_pins.rs`). A missing corpus fails.
//!
//! - `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`).
//! - `corpus/psd/**/*.{psd,psb}`: a hand-picked mix of small ag-psd and psd-tools samples
//! (manifest `xtask/psd-corpus.sha256`).
//! - `corpus/psd-tools/**/*.{psd,psb}`: the complete psd-tools test set (MIT).
//! - `corpus/photoshop/**/*.psd`: our own Photoshop-authored oracles from
//! https://github.com/storytold/photocraft-corpus (smart filters, layer-style effect shapes,
//! the text engine, adjustments in every mode and depth), reported per feature group. Smart
//! objects and type layers composite Photoshop's cached pixels here; the engine's
//! `photoshop_oracles` test re-renders them with our smart-filter stack and text engine.
//!
//! For each file: parse → document → flatten, compared with the file's own
//! merged composite (Photoshop's rendering) as the oracle; then document →
@@ -28,11 +30,10 @@
//! 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.
//! Differences are expected where features are not yet rendered (effects, text engine, smart
//! filters, knockout); 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).
//!
@@ -41,6 +42,7 @@
//!
//! `*_mutations_never_panic` truncates and corrupts every corpus file and
//! asserts import + flatten return (Ok or Err) without panicking.
#![cfg(feature = "corpus")]
mod common;
@@ -135,10 +137,10 @@ const KNOWN_BAD: &[(&str, &str)] = &[("group-divider-blend-mode.psd", "psd-tools
/// 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,
/// Location, relative to the workspace root.
dir: &'static str,
/// Path components naming a feature group for the per-group totals (0: no groups).
group_depth: usize,
/// 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.
@@ -146,25 +148,28 @@ struct Source {
}
/// Hand-picked mix in `corpus/psd` (170 files: 134 vs the merged image + 12 vs the thumbnail).
const MIXED: Source = Source { label: "io corpus", env: "PHOTOCRAFT_CORPUS", default_dir: "corpus/psd", pass_floor: 146, roundtrip_floor: 169 };
const MIXED: Source = Source { label: "io corpus", dir: "corpus/psd", group_depth: 0, pass_floor: 146, roundtrip_floor: 169 };
/// The full psd-tools test set (309 files at the pinned commit; see `xtask/psd-tools-corpus.sha256`):
/// 219 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: 229, roundtrip_floor: 307 };
const PSD_TOOLS: Source = Source { label: "psd-tools corpus", dir: "corpus/psd-tools", group_depth: 0, pass_floor: 229, 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("../..").join(src.default_dir),
};
if !root.is_dir() {
assert!(env.is_none(), "{} is set but {} is not a directory", src.env, root.display());
return None;
}
Some((root, env.is_some()))
/// Our Photoshop-authored oracles (256 files; https://github.com/storytold/photocraft-corpus),
/// grouped by feature (`smart-filters`, `effects`, `text`, `adjustments/<mode><bits>`).
const PHOTOSHOP: Source = Source { label: "photoshop oracles", dir: "corpus/photoshop", group_depth: 2, pass_floor: 126, roundtrip_floor: 256 };
/// The corpus directory; a missing corpus fails the test.
fn locate(src: &Source) -> PathBuf {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..").join(src.dir);
assert!(root.is_dir(), "{}: {} is missing: run `cargo xtask corpus --all`", src.label, root.display());
root
}
/// Feature group of a corpus-relative path: its first `depth` directories.
fn group_of(name: &str, depth: usize) -> String {
let parts: Vec<&str> = name.split(['/', '\\']).collect();
let n = depth.min(parts.len().saturating_sub(1));
if n == 0 { ".".into() } else { parts[..n].join("/") }
}
fn files_in(root: &Path) -> Vec<PathBuf> {
@@ -369,9 +374,10 @@ fn check_file(name: &str, bytes: &[u8]) -> (Outcome, Option<f32>) {
}
fn run_oracle(src: &Source) {
let Some((root, enforce)) = locate(src) else { return };
let root = locate(src);
let files = files_in(&root);
BLOCK_ERRORS.with(|b| b.borrow_mut().clear());
let mut groups: std::collections::BTreeMap<String, (usize, usize)> = Default::default();
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%");
@@ -387,6 +393,11 @@ fn run_oracle(src: &Source) {
continue;
}
};
let g = groups.entry(group_of(&name, src.group_depth)).or_default();
g.1 += 1;
if matches!(outcome, Outcome::Pass | Outcome::ThumbPass) {
g.0 += 1;
}
match outcome {
Outcome::Pass => pass += 1,
Outcome::ThumbPass => thumb_pass += 1,
@@ -408,18 +419,21 @@ fn run_oracle(src: &Source) {
crashes.len()
);
let pass = pass + thumb_pass;
if src.group_depth > 0 {
for (g, (p, n)) in &groups {
eprintln!("{label}: {g:<28} {p:>4} / {n:<4} pass");
}
}
eprintln!("{label}: export -> re-import renders the same for {rt_same} files; differs for {}: {}", rt_diff.len(), rt_diff.join(", "));
let block_errors = BLOCK_ERRORS.with(|b| b.borrow().clone());
eprintln!("{label}: exported files whose tagged blocks fail the strict re-parse: {}", block_errors.len());
if std::env::var_os("PHOTOCRAFT_CORPUS_STRICT").is_some() {
assert_eq!(errors, 0);
}
if enforce {
assert!(crashes.is_empty(), "{label}: panics (Rule 9): {crashes:?}");
assert!(block_errors.is_empty(), "{label}: exported tagged blocks fail the strict re-parse (#200): {block_errors:?}");
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);
}
assert!(crashes.is_empty(), "{label}: panics (Rule 9): {crashes:?}");
assert!(block_errors.is_empty(), "{label}: exported tagged blocks fail the strict re-parse (#200): {block_errors:?}");
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.
@@ -434,9 +448,7 @@ fn next(state: &mut u64) -> u64 {
/// 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 root = locate(src);
let files = files_in(&root);
let (mut runs, mut panics, mut slow) = (0usize, Vec::new(), Vec::new());
for p in &files {
@@ -487,9 +499,7 @@ fn run_mutations(src: &Source) {
}
}
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);
}
assert!(panics.is_empty(), "{}: mutated files panicked (Rule 9): {panics:?}", src.label);
}
#[test]
@@ -502,6 +512,11 @@ fn psd_tools_import_flatten_oracle() {
run_oracle(&PSD_TOOLS);
}
#[test]
fn photoshop_oracle_corpus() {
run_oracle(&PHOTOSHOP);
}
#[test]
fn psd_tools_mutations_never_panic() {
run_mutations(&PSD_TOOLS);
+15 -8
View File
@@ -1,16 +1,23 @@
//! Type layers in the optional real-file corpus (`corpus/psd`, gitignored; skips if absent).
//! Type layers in the real-file corpus (`corpus/psd`, gitignored; feature `corpus`, fetched by
//! `cargo xtask corpus --all`; a missing corpus fails).
//!
//! For every text layer: the TySh/EngineData model must parse (text, fonts, sizes, colours,
//! runs), the `TySh` must survive a PSD round trip byte-for-byte, and our engine's re-render is
//! compared with Photoshop's cached pixels. The fonts usually differ, so the comparison checks
//! geometry (ink bounds and alpha overlap) and is reported; the assertions are loose.
#[cfg(feature = "corpus")]
use std::path::{Path, PathBuf};
use photocraft_doc::{Layer, LayerContent};
#[cfg(feature = "corpus")]
use photocraft_doc::Layer;
use photocraft_doc::LayerContent;
#[cfg(feature = "corpus")]
use photocraft_geom::Rect;
#[cfg(feature = "corpus")]
use photocraft_raster::Surface;
#[cfg(feature = "corpus")]
fn collect(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(rd) = std::fs::read_dir(dir) else {
return;
@@ -25,6 +32,7 @@ fn collect(dir: &Path, out: &mut Vec<PathBuf>) {
}
}
#[cfg(feature = "corpus")]
fn walk<'a>(layers: &'a [Layer], out: &mut Vec<&'a Layer>) {
for l in layers {
out.push(l);
@@ -34,17 +42,17 @@ fn walk<'a>(layers: &'a [Layer], out: &mut Vec<&'a Layer>) {
}
}
#[cfg(feature = "corpus")]
fn alpha(s: &Surface, r: Rect) -> Vec<f32> {
let n = s.channels();
s.read_region(r).chunks_exact(n).map(|p| p[n - 1]).collect()
}
#[cfg(feature = "corpus")]
#[test]
fn corpus_text_layers() {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/psd");
if !root.is_dir() {
return;
}
assert!(root.is_dir(), "{} is missing: run `cargo xtask corpus --all`", root.display());
let mut files = Vec::new();
collect(&root, &mut files);
files.sort();
@@ -115,12 +123,11 @@ fn corpus_text_layers() {
}
/// Unedited text layers are written back byte-for-byte.
#[cfg(feature = "corpus")]
#[test]
fn corpus_tysh_lossless() {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/psd/ag-psd/read-write/text/src.psd");
let Ok(bytes) = std::fs::read(&root) else {
return;
};
let bytes = std::fs::read(&root).unwrap_or_else(|e| panic!("{}: {e}: run `cargo xtask corpus --all`", root.display()));
let doc = photocraft_io::import("src.psd", &bytes).unwrap().document;
let out = photocraft_io::export(&doc, "out.psd", &Default::default()).unwrap().bytes;
let find = |b: &[u8]| {
+11 -8
View File
@@ -1,8 +1,10 @@
//! Vector data ⇄ PSD: synthetic round trips of paths, shape layers and vector masks, plus an
//! optional corpus check (`corpus/psd`, skipped when absent) that compares our rasterization
//! Vector data ⇄ PSD: synthetic round trips of paths, shape layers and vector masks, plus a
//! corpus check (`corpus/psd`, feature `corpus`) that compares our rasterization
//! of every plain shape layer with the pixels Photoshop stored for it.
#[cfg(feature = "corpus")]
use std::path::{Path as FsPath, PathBuf};
#[cfg(feature = "corpus")]
use std::sync::Arc;
use photocraft_color::{Color, ColorMode, SampleType};
@@ -12,6 +14,7 @@ use photocraft_doc::{
};
use photocraft_geom::{Point, Rect};
use photocraft_io::*;
#[cfg(feature = "corpus")]
use photocraft_psd::PsdFile;
fn roundtrip(d: &Document) -> Document {
@@ -161,6 +164,7 @@ fn vector_mask_roundtrip_and_removal() {
assert!(roundtrip(&g).layers[1].vector_mask.is_some());
}
#[cfg(feature = "corpus")]
fn collect(dir: &FsPath, out: &mut Vec<PathBuf>) {
let Ok(rd) = std::fs::read_dir(dir) else { return };
for e in rd.flatten() {
@@ -175,12 +179,11 @@ fn collect(dir: &FsPath, out: &mut Vec<PathBuf>) {
/// Every plain shape layer in the corpus (solid fill, no stroke, no effects, visible fill):
/// our fill coverage vs the alpha of Photoshop's pixels. Reported; asserted loosely.
#[cfg(feature = "corpus")]
#[test]
fn corpus_shape_coverage_matches_photoshop() {
let root = FsPath::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/psd");
if !root.is_dir() {
return;
}
assert!(root.is_dir(), "{} is missing: run `cargo xtask corpus --all`", root.display());
let mut files = Vec::new();
collect(&root, &mut files);
files.sort();
@@ -223,15 +226,15 @@ fn corpus_shape_coverage_matches_photoshop() {
}
/// (layer name, block key, data).
#[cfg(feature = "corpus")]
type NamedBlock = (String, [u8; 4], Arc<Vec<u8>>);
/// Unedited corpus shapes and vector masks export with byte-identical vector blocks.
#[cfg(feature = "corpus")]
#[test]
fn corpus_vector_blocks_survive_roundtrip() {
let root = FsPath::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/psd");
if !root.is_dir() {
return;
}
assert!(root.is_dir(), "{} is missing: run `cargo xtask corpus --all`", root.display());
let mut files = Vec::new();
collect(&root, &mut files);
files.sort();
+2
View File
@@ -11,6 +11,8 @@ name = "photocraft_psd"
path = "src/lib.rs"
[features]
# Real-file corpus tests (opt-in; `cargo xtask test-corpus`). Fail when the corpus is missing.
corpus = []
default = []
serde = ["dep:serde"]
testgen = []
+5 -7
View File
@@ -1,7 +1,7 @@
//! 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.
//! Corpus test (feature `corpus`; run with `cargo xtask test-corpus`): iterates
//! `corpus/psd/**/*.{psd,psb}` and `corpus/psd-tools/**/*.{psd,psb}` at the workspace root
//! (gitignored, fetched and verified by `cargo xtask corpus --all`). A missing corpus fails.
#![cfg(feature = "corpus")]
use std::path::{Path, PathBuf};
@@ -31,9 +31,7 @@ fn corpus_parse_and_byte_stable() {
}
fn parse_and_byte_stable(root: &Path) {
if !root.is_dir() {
return;
}
assert!(root.is_dir(), "{} is missing: run `cargo xtask corpus --all`", root.display());
let mut files = Vec::new();
collect(root, &mut files);
files.sort();
+2 -1
View File
@@ -322,7 +322,7 @@ Both backends consume the same plan. This is the only place that encodes Photosh
### 7.4 Oracle
**PSD files contain Photoshop's own merged composite.** Running our compositor on a PSD's layers and diffing against its embedded composite gives a free, large conformance test suite. `testkit` automates this over a PSD corpus.
**PSD files contain Photoshop's own merged composite.** Running our compositor on a PSD's layers and diffing against its embedded composite gives a free, large conformance test suite. `testkit` automates this over a PSD corpus. The corpora are fetched at pinned commits into `corpus/` (never committed) by `cargo xtask corpus --all`; our own Photoshop-authored oracles live in https://github.com/storytold/photocraft-corpus (see `docs/development.md` › Test corpora).
---
@@ -363,6 +363,7 @@ psd/src/
- Composite-oracle diffs (§7.4).
- `cargo-fuzz` on the parser.
- A corpus from MIT/BSD-licensed test sets (ag-psd, psd-tools), fetched by `xtask`, not committed.
- Our own Photoshop-authored oracle PSDs (smart filters, effect shapes, type, adjustments in every mode and depth) from https://github.com/storytold/photocraft-corpus, fetched at a pinned commit the same way.
- **Existing crates:** `psd` (read-only, limited) and the new `ag-psd` Rust port. We evaluate them in spike week 1, then either depend on one, fork it, or write our own. Given how central PSD is, writing our own behind a stable API is likely.
---
+1 -1
View File
@@ -6,7 +6,7 @@
- **Never crash:** non-test code must not panic: no `unwrap`/`expect`/`panic!`/`unreachable!`/`todo!`/`unimplemented!` and no `unsafe`. Errors go through `Result` and `?`, input-derived indices and sizes are checked, and every crash fix ships with a regression test. See *Never crash* in `AGENTS.md`.
- **Commands, not handlers:** new features are engine commands with tests, and the UI calls them (checklist below).
- **Layering:** `cargo xtask layers` must pass. Register new crates in `xtask/src/layers.rs`.
- **Tests:** required for every change. Format code needs round-trip and malformed-input tests. Pixel code is tested at 8, 16 and 32-bit.
- **Tests:** required for every change. Format code needs round-trip and malformed-input tests. Pixel code is tested at 8, 16 and 32-bit. If you touch psd, io, codecs, compose, gpu, text or format, also run the real-file corpus tests: `cargo xtask test-corpus` (fetches the pinned corpora, including our Photoshop oracles from https://github.com/storytold/photocraft-corpus, then runs the `corpus`-feature tests; CI always runs them). Never commit corpus files; see `docs/development.md` › Test corpora.
- **Style:** `cargo fmt`, and `cargo clippy -- -D warnings`. Match surrounding code. Comments explain *why*.
- **UI:** use `theme::Tokens` and `widgets::*`. Verify visually (offscreen `snapshot` example or the control channel) before submitting, and attach before/after screenshots to PRs.
- **Commits:** small, focused, with a clear subject line.
+67 -7
View File
@@ -176,7 +176,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`, `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.
- **Real-file corpora** in `corpus/` (gitignored, fetched at pinned commits, sha256-verified): opt-in locally through the `corpus` cargo feature, always run in CI. See [Test corpora](#test-corpora).
- **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.
@@ -287,14 +287,74 @@ Input calls (`ui.key`, `ui.type`, `ui.click`) now reply only after the app has p
so a following `ui.inspect` observes their effect.
## Test corpora
Real files are our best oracles, but they are large binaries, so they never go into this
repository. They live in `corpus/` (gitignored) and are fetched at **pinned commits** and checked
against committed **sha256 manifests**. All pins are in one place:
[`xtask/src/corpus_pins.rs`](../xtask/src/corpus_pins.rs).
| Directory | What | Source | Manifest |
|---|---|---|---|
| `corpus/photoshop/` | 256 PSDs we authored with Photoshop: smart filters, layer-style effect shapes, the text engine, adjustments in every mode and depth | https://github.com/storytold/photocraft-corpus (ours, MIT OR Apache-2.0) | `xtask/photoshop-corpus.sha256` |
| `corpus/psd/` | 170 small psd-tools and ag-psd files, the mix most PSD tests use | psd-tools and ag-psd upstreams (MIT) | `xtask/psd-corpus.sha256` |
| `corpus/psd-tools/` | the complete psd-tools test set (309 files) | psd-tools upstream (MIT) | `xtask/psd-tools-corpus.sha256` |
| `corpus/pngsuite/` | PngSuite | schaik.com release archive (public domain) | (fixed archive) |
```sh
cargo xtask corpus # where each corpus lives, its pin, present or missing
cargo xtask corpus --all # fetch everything missing or stale (cold: about 15 s; verified copies are left alone)
cargo xtask test-corpus # fetch, then cargo test --release --features corpus on psd, codecs, io, engine
cargo xtask test-corpus -p io # narrow to one crate (repeat -p for more)
cargo xtask test-corpus --changed # only if psd, io, codecs, compose, gpu, text or format changed vs origin/main
cargo xtask test-corpus -- --nocapture # pass arguments to the test binaries (per-file tables)
scripts/fetch-corpus.sh # the same as cargo xtask corpus --all
```
**Opt-in locally, strict when opted in, always in CI.**
- The corpus tests sit behind the `corpus` cargo feature of `photocraft-psd`, `photocraft-codecs`,
`photocraft-io` and `photocraft-engine`, so plain `cargo test` neither compiles nor needs them.
- With the feature on, a missing corpus is a failure ("run `cargo xtask corpus --all`"), never a
silent skip, and every floor is enforced.
- If you touch psd, io, codecs, compose, gpu, text or format, run `cargo xtask test-corpus` before
you finish.
- The CI `corpus` job runs `cargo xtask corpus --all` and then `cargo xtask test-corpus` on every PR
and on main. `corpus/` is cached with `actions/cache`, keyed on the hash of `corpus_pins.rs` and
the manifests. A download happens only when a pin changes; otherwise the cache restores in seconds
and is re-verified (sha256) before the tests run. Fork PRs read the base branch's cache.
**Our own corpus: [photocraft-corpus](https://github.com/storytold/photocraft-corpus).**
- **Authoring clone:** a normal git clone, `git clone git@github.com:storytold/photocraft-corpus.git`,
placed next to this checkout (`../photocraft-corpus`). Its generators write there, and you commit
and push there.
- **Consuming copy:** `corpus/photoshop/` here. It is a plain directory: the pinned snapshot
(downloaded as the codeload tarball of the pinned commit, then verified).
- **Local mode:** `cargo xtask corpus --photoshop --local` copies `photoshop/` from
`../photocraft-corpus` (or `PHOTOCRAFT_CORPUS_REPO=<path>`) instead of downloading. It warns when
the clone's HEAD isn't the pin or its files differ from the manifest. Use it to test regenerated
files before pushing: `cargo xtask test-corpus --local` copies them and runs every corpus test.
- **Bumping the pin:** commit and push in photocraft-corpus, then in a PR here set
`PHOTOCRAFT_CORPUS_COMMIT` in `corpus_pins.rs`, run
`cargo xtask corpus --photoshop --update-manifest`, commit the manifest diff, run
`cargo xtask test-corpus`, and raise floors that improved.
- **Not a submodule or a subtree:** a subtree would put the binaries back into this repository's
history, and submodules cause init and detached-HEAD friction for every contributor. A pin plus a
manifest gives the same reproducibility.
- The third-party sets (psd-tools, ag-psd, PngSuite) are fetched from their upstreams the same way,
and are never copied into photocraft-corpus.
## 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) 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)
`cargo xtask test-corpus -p io -- --nocapture` compares our composite of every 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`, the psd-tools set (also a truncation/corruption sweep
over every file that must never panic) and our Photoshop set `corpus/photoshop` (per-feature-group
totals: `smart-filters`, `effects`, `text`, `adjustments/<mode><bits>`). Smart objects and type
layers composite Photoshop's cached pixels there; `cargo xtask test-corpus -p engine -- --nocapture`
(`crates/engine/tests/photoshop_oracles.rs`) re-renders them with our smart-filter stack and text
engine and is the failure map for both. A panic is reported as `CRASH` and fails the 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
+4 -3
View File
@@ -62,8 +62,8 @@ Confidence: moderate — the next users of 0.2.x will move these numbers either
|---|---|---|---|
| Menu wiring | 626/626 menu items dispatch a command (`parity.md`) | high but shallow | Says nothing about behaviour. |
| PSD fidelity (rendering) | Corpus oracle 115/170 (68%): 30 differ, 26 have no usable reference, 1 import error | medium | Push to 170/170 under way (effects/strokes, multi-instance effects, 16/32-bit and colour modes, references for skipped files). |
| PSD round trip | 169/169 re-import identically; every adjustment layer and blend mode round-trips | high (within corpus) | Floors in `crates/io/tests/corpus.rs`; raise, never lower. |
| Smart filters / text / effect shapes in PSDs | Not measured: the corpus barely contains them | unknown (probably low–medium) | A Photoshop-authored reference set is being built (`corpus/photoshop`). |
| PSD round trip | 169/169 re-import identically; every adjustment layer and blend mode round-trips | high (within corpus) | Floors in `crates/io/tests/corpus.rs`; raise, never lower. Corpora: `cargo xtask corpus --all` (ours: https://github.com/storytold/photocraft-corpus). |
| Smart filters / text / effect shapes in PSDs | Measured on our Photoshop-authored set (https://github.com/storytold/photocraft-corpus, `corpus/photoshop`, 256 files): see the per-group floors in `crates/io/tests/corpus.rs` and `crates/engine/tests/photoshop_oracles.rs` | low–medium | Fix from the failure map (`cargo xtask test-corpus -- --nocapture`). |
| Core editing (layers, masks, selections, adjustments, filters, transforms) | Broad engine coverage; many interaction bugs fixed after 0.2.0 (adjustment dialogs, Curves, crop, Move/Transform modifiers, gesture origin) | medium | Fixes not yet user-validated. |
| UI / UX polish | Shortcut audit 214 → 0 failures; dock, Layers rows and menus reworked; first visual-QA sweep found 14 defects (#147–#157) | low–medium | Needs recurring visual QA with realistic documents. |
| Tools | ~20 Photoshop tools missing: Pencil, Mixer Brush (tool), Patch, Content-Aware Move, Red Eye, Pattern Stamp, Art History Brush, Freeform/Curvature Pen, anchor tools, Direct Selection, Magnetic Lasso, single row/column marquee, Color Sampler, Perspective Crop, Rotate View, the Vertical Type tool (vertical layout itself landed, #199: toggle via Type › Orientation) and type masks, Frame | low–medium | Magic/Background Eraser added; live gradients in progress (#180). |
@@ -81,7 +81,8 @@ Confidence: moderate — the next users of 0.2.x will move these numbers either
1. **Ship the fixes:** cut 0.2.1 once the current batch lands, so users validate them.
2. **PSD fidelity to 170/170** with round trips, plus the Photoshop-authored reference set for smart
filters, the text engine and effect shapes; enforce floors in `corpus.rs`.
filters, the text engine and effect shapes (https://github.com/storytold/photocraft-corpus, fetched by
`cargo xtask corpus --all`); enforce floors in `corpus.rs`.
3. **Workflow acceptance tests:** 30–50 real tasks (retouch a portrait, social post with text and
effects, composite with masks and adjustment layers, CMYK print prep…) scripted end to end and
checked against Photoshop's output on every build. Their pass rate becomes the headline parity
+3 -2
View File
@@ -7,7 +7,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js
| Area | Done | Partial | Missing | Key numbers |
|---|---:|---:|---:|---|
| [Performance](#performance) | 3 | 22 | 10 | 44 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 0 not run, 10 not measurable yet (baseline 2026-10-05) |
| [File compatibility](#file-compatibility) | 0 | 6 | 21 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 229/309, round trip ≥ 307/309 |
| [File compatibility](#file-compatibility) | 0 | 6 | 21 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 229/309, round trip ≥ 307/309; photoshop oracles: oracle ≥ 126/256, round trip ≥ 256/256 |
| [Tools](#tools) | 0 | 16 | 14 | |
| [Workspace and UI](#workspace-and-ui) | 0 | 3 | 20 | settings that do nothing: 61 of 130 (target 0) |
| [Type](#type) | 0 | 3 | 6 | |
@@ -76,12 +76,13 @@ Baseline: 2026-10-05, commit `f5d2bcb54a`, Apple M4 Pro (48 GB), GPU Apple M4 Pr
## File compatibility
Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus test fails below them when the corpus is present.
Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus tests (cargo feature `corpus`, `cargo xtask test-corpus`, always run in CI) fail below them.
| Corpus | Directory | Files | Oracle pass floor | Round-trip floor |
|---|---|---:|---:|---:|
| io corpus | `corpus/psd` | 170 | 146 | 169 |
| psd-tools corpus | `corpus/psd-tools` | 309 | 229 | 307 |
| photoshop oracles | `corpus/photoshop` | 256 | 126 | 256 |
| Id | Target | Status | Issue | Note |
|---|---|---|---|---|
+7
View File
@@ -0,0 +1,7 @@
#!/bin/sh
# Fetch every test corpus into corpus/ (pinned commits, sha256-verified; see docs/development.md,
# "Test corpora"). Same as `cargo xtask corpus --all`. Run the corpus tests with
# `cargo xtask test-corpus`.
set -eu
cd "$(dirname "$0")/.."
exec cargo xtask corpus --all "$@"
+256
View File
@@ -0,0 +1,256 @@
a72ac9d56578780866972ee700b44981ed1b68c250c511ec1c823d476a2e394d adjustments/cmyk16/baseline-layer-copy.psd
8673c9f5cfc9cbd3927d49e2366f3c46d1073e6add9f4843560a138cb252afdf adjustments/cmyk16/brightness-contrast-legacy.psd
a0bf926902c1e1f2836fc17a303cd3d6abd1d400a4ac22de9a914adcf2a282c6 adjustments/cmyk16/brightness-contrast.psd
dc40cf0b458c0092da0cc7a7a94e62e22cdb437af67a62c84d79354a3a525a9a adjustments/cmyk16/channel-mixer.psd
7cdc65122b7a56ad329dfe4da1d050a624076714b586de06ef628d349277755a adjustments/cmyk16/color-balance.psd
35598411d30607800e975226bfac1b933809f7d28410a32e55b91697f2c7b514 adjustments/cmyk16/curves.psd
b05fccbb4f0676584777d2ce0aff30ed1c84d4ab8c6571004dec8fdf4865c21f adjustments/cmyk16/gradient-map.psd
fa4dbcb05ffd619cc991608514596c6d8e102a00d64936d348834c0cd2920ca7 adjustments/cmyk16/hue-saturation-colorize.psd
e53449856627ad8da0f5a79d77b563fef3dc41d51c4d1d1fff230084162d103c adjustments/cmyk16/hue-saturation.psd
bfd7b439d500d6eaf7d278bac24fdea06fe5a3c87866eaf9fba52b434549d95a adjustments/cmyk16/invert.psd
f2ec5fc028cc61fa688d492d552fe099a25e1ff3731990e48c0c6f069df459a8 adjustments/cmyk16/levels.psd
fe2409e15acbeb0392ad2dccde63f67296ffe5850a59f16c15f0290ded76e790 adjustments/cmyk16/photo-filter.psd
8d08de4b9362ec62a7e7671ea6bba03d82dd9221a123af99ac5e0bb9e8b8366b adjustments/cmyk16/posterize-4.psd
5401e6580c3fdead63ff1a5fb1eea516f8087058dbde4cd545b7d511fe36d8ae adjustments/cmyk16/selective-color.psd
7c8f64ee137582bb0dc0f7cd0851286128d67ab294abc66c3d6f20f8610734bf adjustments/cmyk16/threshold-128.psd
5ac999b8356d6a93917fbe0849db26d8bd6b208fba014414e6aef90efe836905 adjustments/cmyk8/baseline-layer-copy.psd
97c9153c57992d5108c07c98ef932861b516f1f76849995890f7d07b16c19b96 adjustments/cmyk8/brightness-contrast-legacy.psd
2f2f18e533b234771d4bfadc1da93784dc74999fcd9b699f72fd4cb774edcab3 adjustments/cmyk8/brightness-contrast.psd
98630c27abe0ee6dc2ea3d1935ac508cfb2c8823e45046b8d3a73a0706be68e3 adjustments/cmyk8/channel-mixer.psd
42ce90d2a597fe189a8d8529ac377103b0ec9077c20e490c84c5635c4a335fd7 adjustments/cmyk8/color-balance.psd
704e7c141bdaafc50bea11d070ed6234a680e4e9a46a3ecc82582bb6d0326552 adjustments/cmyk8/curves.psd
0f620fd461dba8e5f609c5d4764338631284d75511ab5f85e8af2f334d5af7cc adjustments/cmyk8/gradient-map.psd
b415136d5a96a6a3f15019e811a662513be1d6f26a68cd0c84ddf8807f814bde adjustments/cmyk8/hue-saturation-colorize.psd
c23e04dc74e4171659ab8b57aee65ad6ee12325e152f48bddf639a716c32033c adjustments/cmyk8/hue-saturation.psd
55643dac46d3ca7f3ac3a55a0e86192454d4a36f104e7e5c188ab93718dae7cc adjustments/cmyk8/invert.psd
878459305a4eca17ed51e80f40ad47802308708dd792720da820196085dda18e adjustments/cmyk8/levels.psd
a4cf08d366217280e375c3f356ffbe47a5a9e73ec53839a9a4f7535fa0f9566a adjustments/cmyk8/photo-filter.psd
92b0606ebfdff354a345f2bbfeee9b8e3f400e4edcc72ef0c2ba6e40f23ef60c adjustments/cmyk8/posterize-4.psd
a7beaee81ae8343fb9a72c390a7a9cfa74c4d49db19111a34d520908c538f398 adjustments/cmyk8/selective-color.psd
cbd49d9d06d718315ee5d63db31eac6577d5b62992a8c84b3d9f3dae1795060f adjustments/cmyk8/threshold-128.psd
b83f91c4bda29287c8baa54fd99a616129250c80aeea1d4e3eb6566d67b7d385 adjustments/gray16/baseline-layer-copy.psd
16755e94ab6502aff95cdce97000a9b13f83cc8a65127ae1745cef0541af34d4 adjustments/gray16/brightness-contrast-legacy.psd
c894b0444dff3cc692ce376f7883997f65e82ddc213b4c96af06d03af18d5704 adjustments/gray16/brightness-contrast.psd
5bd11dac0d97bd08d8a9e12611aeb71775a3d660aec2d6aaec0e9200c7bba812 adjustments/gray16/curves.psd
bf2b3c0791cdd03764647257baabb32f5caaabba13cfc0816723874865f6b954 adjustments/gray16/exposure.psd
292d7700812d6967e7cbae399d4893d87e6f7020e57f9e0cd0996bd9ca57bc86 adjustments/gray16/gradient-map.psd
43f548e8e0ec306a035a89938b375799ad96a98f30852ee800b2ca8402d187e1 adjustments/gray16/invert.psd
477d41d9e65fe316e74f443d903cb21350a3841a574ceac521458d657809e20d adjustments/gray16/levels.psd
7fefe2ff5d15cfed2861dea7bfee7409ba3374c14da444e891444b3dc5d343b5 adjustments/gray16/posterize-4.psd
c445bd36a46a41ca2a17dff6bc18652f21ccaa5990e26c89e948cb92feb15b7a adjustments/gray16/threshold-128.psd
b8924389849513fabeb31a6cf724530df26161f70d3be3e409f5eecaa3c5800f adjustments/gray32/baseline-layer-copy.psd
8a88c4c18ba28972e43804dcf18665c5de4613c9f7cc37e3bf930392ef64326e adjustments/gray32/brightness-contrast-legacy.psd
d106ff622b31842130cd7d66a9eb3563a9053d553180821a5b6dd199eb6d7f62 adjustments/gray32/brightness-contrast.psd
c7939a0ccb659016e3922ae39669a44b559904b86b9b56dd95985bccc9b4b5b2 adjustments/gray32/curves.psd
e1daca4047885af3e005c077b4ee17d3d237173db1883145f8710ae8bf13a6cc adjustments/gray32/exposure.psd
db1d78d84eee47c75adc980eb6439c3c252b9367cf233ae42f0942a71f0ce99f adjustments/gray32/levels.psd
6fa1540980ebb675830a6feccbc168dea55463391833be33406412bddba30723 adjustments/gray8/baseline-layer-copy.psd
37b571ff3d4d5cbee04c264032df258de78716834cac2da7ed99cbe99729a141 adjustments/gray8/brightness-contrast-legacy.psd
47e831743115033fe653ec4629efcb4937ba2191057471c6ac6ce3fcab2bf25b adjustments/gray8/brightness-contrast.psd
6b55267f72862273445dade16c7a78dd8ca9bcdffe7810f5765e452bcf811fbd adjustments/gray8/curves.psd
2e02daf9d3b185762770eb76821f5769f85429450bf0877ff93e2c48f7b63e9a adjustments/gray8/exposure.psd
b58acaa4db29bdcc1f2a3d7779a2af18fda0e87f9d96ad2867cb53c3afb67a95 adjustments/gray8/gradient-map.psd
12c70f70bbaf5c6b0c17334d0114dc4268e531e4c7135b4aaa120f4994cc3150 adjustments/gray8/invert.psd
0e8f8244ad71a61c20b94f42368845c917ae837424c45ab5ae63d58437917f11 adjustments/gray8/levels.psd
d35901ddb8a8686cd29d9ca152b29a536339d8a457858a6a4e57ada64208318f adjustments/gray8/posterize-4.psd
04696eae7ce479e05ce8c8dc10de730dec6915aa25a052e28dce0c32811f0f5a adjustments/gray8/threshold-128.psd
c880c7fb100d75c1e5f4945e25b5fe1234c99f931dc785ec62bf05084c45cbe7 adjustments/lab16/baseline-layer-copy.psd
1e98efa7080388a4302dfc4bb34e695060c0ee33051cdc7172ee3af90a113251 adjustments/lab16/brightness-contrast-legacy.psd
4befccae6cd4ec403eec3bcda969d65307a2ee34cc17b9f283dde4c78e0ba780 adjustments/lab16/brightness-contrast.psd
4ed03804e54bf6ee6b78e3734e3206933f89c32d88205993ab519d239631719f adjustments/lab16/curves.psd
5750f0c402fb521df6e2f4fb3fed649361d0932d8f49f352525049b790748a13 adjustments/lab16/gradient-map.psd
dc061ab3fc224fd4887f2b181e4fe4a661f6c0c568c09087fc94f87376e06758 adjustments/lab16/hue-saturation-colorize.psd
7d0944e5deae288febc7ead26b650f2d2f021b464aa5e62390c7dda67d00069e adjustments/lab16/hue-saturation.psd
21a3c5cafdbf9b1e049c3b45f3a44a714e06807cf5c8f5f52b0c3f074d9af267 adjustments/lab16/invert.psd
498d8c8178f7998643d541bb532f6e67566406873afa5dffb52a2793f08b191f adjustments/lab16/levels.psd
8d3aca0371fe342ee810720e5b3d2e4fa510f54c197553cc435edcc84b437bc8 adjustments/lab16/photo-filter.psd
501c4917722c9c7ceb82e5918480b7b3350bf0c22048030ad99a8bf862d30508 adjustments/lab16/posterize-4.psd
9080641d2f92a350fb12def9f4b4fe490cab3ad88808e60d40e393bd65d379b4 adjustments/lab16/threshold-128.psd
ece5abcbb2efc27ff3bed174700155f2bfb012e0bde0146dc4966f780f39cf58 adjustments/lab8/baseline-layer-copy.psd
1b4510eef13d4393765408e7518732419995bcf0d679bb2681e205c326d07de7 adjustments/lab8/brightness-contrast-legacy.psd
76670eef132a8f3a20c14a197aa7cad838705235a365ca29965b695ff9be73ce adjustments/lab8/brightness-contrast.psd
25f57ec7a787198df08d4f5c8a539a9f1676d67ccd784928cb425b9a49b69ace adjustments/lab8/curves.psd
6ecd52ab42e48bf95410bda76d5c61c8fa4bb44d42ef354d58972b2219996429 adjustments/lab8/gradient-map.psd
3c8daaa96ee12bcfc4fcd9b7fa58aef98b30455d56f8861fbb89e30eb88b5fbd adjustments/lab8/hue-saturation-colorize.psd
c9b2b21eab43d5c84f5ff7d60fb40a28575a1037a168466c48a0dfc4d6e0178f adjustments/lab8/hue-saturation.psd
e4ce3ef3350f6a5944a15ef4dea27efa79c950d08d3454091455eaa3e653904d adjustments/lab8/invert.psd
f13cd7300e9dea246083cbea458ff3d0e0e7c75168449649d0b40522fe076b8c adjustments/lab8/levels.psd
30a08eaafda594e7adb4a3bd00a638b9d90c6b3f97b3286bc3b175df7d8efc79 adjustments/lab8/photo-filter.psd
fd18fbe6aa8296a77ee56d2ef0a97a6c6cccc840ebe37a6eb5d47c7988f775d9 adjustments/lab8/posterize-4.psd
9329e85c3d1086b967cfb79ab4e93dc8f56420d1c285a21afcec0209a45df7b0 adjustments/lab8/threshold-128.psd
f31e10d19689df9dba71ebdc60652b28eb364ad8da9040a80bc5aeffcf34228e adjustments/rgb16/baseline-layer-copy.psd
a003c0f86331cccf7fc22d707263f4290aaa856c74d7967b3eed4cdc059e8348 adjustments/rgb16/black-white-tint.psd
8e93194ad4587454ef85497f5f2a7e41cdf53ddc802e9d829d9e833dd188cf75 adjustments/rgb16/brightness-contrast-legacy.psd
640b6ba15db3d7ae11e6ab66f5f69d1d0f1f26a383b9d2625bd48ce07ac512ef adjustments/rgb16/brightness-contrast.psd
670f8e89a7bbbb125fcee8ad2f85d6943991974a967d1f7cb6bef8d5a0fdb0f7 adjustments/rgb16/channel-mixer.psd
67e2ffdfe7578d27e31626c46c1d02a1c791ee694fac3b14075fc419e1b3ce35 adjustments/rgb16/color-balance.psd
d265fa814a1f3b0b273277e6361377c607ab8d84e92998cb7ce32bb378bbb4e5 adjustments/rgb16/curves-per-channel.psd
5110de5abd4852bb178a8dfe24de877d05cbb718fb11e6505708186a36ffcd4e adjustments/rgb16/curves.psd
fa336dda24690d69d559e65c3e13e70aaaf1108c5c423b57a432e584f66a8922 adjustments/rgb16/exposure.psd
36427df813db4f658cf089f49f3e910b8608bff24e1a3e0d406330893cc380c3 adjustments/rgb16/gradient-map.psd
4fbc647a3bd41f7b6a133303aa9c0a140b2fffbbcae9a5d38be217db2a02a248 adjustments/rgb16/hue-saturation-colorize.psd
68e7f3a054ae869932694fbaff7274a5c42d7b24f0225704866cf823c7c02c12 adjustments/rgb16/hue-saturation.psd
357326a37773180dc46f38d50c2564b8711f95c1903bf1ac9b90d913f47c3d36 adjustments/rgb16/invert.psd
fbb18d1cfa5fc74cf1c4777b99acdc2465b2920574bd9069b5a2ac129c8151d9 adjustments/rgb16/levels.psd
9caa7008c9d91cec961e935611e29ed86c95aa7e36d3dd94cb9263299ee28b21 adjustments/rgb16/photo-filter.psd
77813afc8a0510ce93afd74b9b16c7ba6b1956e3370c1816e3532aadc043e47b adjustments/rgb16/posterize-4.psd
2959f37160ed8c9d43227bfb9e322aa88390b1e2a19ffc5c268bbf2981f2e5c4 adjustments/rgb16/selective-color.psd
df271003f8553a7936ac51f2a29cfa62849928189ceae325151cc18e1058d3ea adjustments/rgb16/threshold-128.psd
12243fcbf527f8ff12549149cffaf1c53c99df78a1bd920e6f0c7cceb66bc657 adjustments/rgb16/vibrance.psd
73199414108280d7717cc5c908a76f51c5e3091d7471e21e7d0bac1a75734f32 adjustments/rgb32/baseline-layer-copy.psd
ca4b4c0e0fd9a9811fe78170b69dbd71a78b17a0b592ff72ef094b50170a4636 adjustments/rgb32/black-white-tint.psd
7a324c38ca40359556842de48a6c2bf7bf77dbe1bb646c313f1471a2779da570 adjustments/rgb32/brightness-contrast-legacy.psd
3ed8b3f6bf60ee836a3bca969316c9d081a48267a4a1cf620489cce9dad47d6d adjustments/rgb32/brightness-contrast.psd
304cece11d39b95b36b5bba71839d0e3b0ac9cc4586dafda08591dd0caee2189 adjustments/rgb32/channel-mixer.psd
19ef867ea57c902aefd89503dc6d05b5f839e7687c99c7e482d2e2636fa62f74 adjustments/rgb32/color-balance.psd
781c929acc361409c224965e4c945eb13b2c779667e298a8569a560aad6655e0 adjustments/rgb32/curves-per-channel.psd
96342bdfa83cda50bb0e5899aeb54c4d656d6a595447923f02fc891225585f1a adjustments/rgb32/curves.psd
b07baeda9a7b7dff31e986d07c70f05a089383573a5a27e2390495ddfe4b00a1 adjustments/rgb32/exposure.psd
f42475cfe75ea2cb96ca47fce8b4c99a54821ed9cf0072e188f4e71efa32b6f3 adjustments/rgb32/hue-saturation-colorize.psd
e5c59828a07b8a888f18614f9c8445f62e3a8974955df2821e3ecda44ae856ba adjustments/rgb32/hue-saturation.psd
aa2eb41afb426f181a56a45fda2abda4e4d75fa6126007aa9179f54dc282a50f adjustments/rgb32/levels.psd
c653f562b531822416cb4c1136505d990738aa541199403bc1494e342f6ead43 adjustments/rgb32/photo-filter.psd
d9b465f4565a1dcf65781e45f4e4523a82f97d9781ad4236de9339d923fdddd2 adjustments/rgb32/selective-color.psd
9f8891cc875196eb26a91e91879bae1e59719cf7fb806d5d7c80301d0ca18914 adjustments/rgb8/baseline-layer-copy.psd
80a2e1ad2507335b722174b2e35ec9e76ad648d901370f153937a9c5da882a65 adjustments/rgb8/black-white-tint.psd
a49675a1bfc5c6638020511b6ac73fe90bd2681e7b9f4c73855b5180beb0e6fe adjustments/rgb8/brightness-contrast-legacy.psd
10074c81b1ce26fdbd263f2629e28b57781e28072cf2a8a01271827ff6fea7ab adjustments/rgb8/brightness-contrast.psd
88483af89e465b389b95a53b6b58a0b8dcf708aaf290bf9de53caf3e67dbfb6b adjustments/rgb8/channel-mixer.psd
d8e9eefd434a1c762a2df2ef17a19e13e2b0d16218a8235d3294748e9f45fd9b adjustments/rgb8/clipped-hue-sat-blend-color.psd
318ba3fc481a9ffd855d6914ceaea0e5539ab8bf3dfe4f4e3f6d23d6606f5aa4 adjustments/rgb8/color-balance.psd
abc3da8b42bf13384c2d593af425fbbf6f245ce0e2491b2de6aeb69a96380bc1 adjustments/rgb8/curves-per-channel.psd
d82a45068b914495a8459ff4d5f9c691899e57ecf5f3d616db0aff549e4ff8eb adjustments/rgb8/curves.psd
4b3a9d86d8ebe5bfac9b9accbfdf1ab6a29aac6063bf2c0acec11f301baf9376 adjustments/rgb8/exposure.psd
487b3bbc5ce633e2609f1f6acc986720e6b5c8fd800d90ece5c8439897f0d717 adjustments/rgb8/gradient-map.psd
1c5c3acff99ff37afa27fea4680c0bc95fe1bd1916e69b7ce66ff20c030a11ab adjustments/rgb8/hue-saturation-colorize.psd
9ab0ca62274d5c8b0781cf969a34b15b0744bd6cef04110864f0a4b531194b26 adjustments/rgb8/hue-saturation.psd
ed6443b9fadfb7cf42a367a64f0d35e89ab7eb18336908f40977ab0ed02c963f adjustments/rgb8/invert.psd
cffca23ba5ea56c0ee534123a53fd4382bae8d4a2b3579057dacc8a6e1c9c6c4 adjustments/rgb8/levels.psd
0431b691dc293ebb00ef423ebf23bc7f74500753e1c927380c3d9ad8bbb8f601 adjustments/rgb8/masked-curves-gradient-mask-opacity60.psd
77862a1f7bca7e13ae4e8245e81d85fc017e2a7bd61bcb71a873bbd1c34cfa58 adjustments/rgb8/photo-filter.psd
a262755c1d50322a08bef1ed8ea20fa43317a58089d0b34359dc2040b697a06a adjustments/rgb8/posterize-4.psd
53ce529ff5a4a5bc66d05d791429f418d43a1334af99b6cc6468a31295094266 adjustments/rgb8/selective-color.psd
d0abafbb1109b0e619e2a950b2fc7c20e4a43c2211bb73760ab78f1272ae6745 adjustments/rgb8/stack-levels-curves-hue-posterize.psd
5bf48b3feb75bfe715f4b1813a1e75995849c2aec5c7e3875c414ddce387a3cf adjustments/rgb8/threshold-128.psd
6284d9396c29417274aeba1df8f60a47b9f50d2bfba6d9793a72e9b395700c5b adjustments/rgb8/vibrance.psd
bbbbb451d15296d5f8f9cc4c4428df70ceea5bd980df27f351e8c53282bcf435 effects/bevel-emboss-chisel-hard.psd
785aaa2ee7ad0b5e87255f3a3567f0ef098a46044084abfab28dc451132c8cc0 effects/bevel-emboss-chisel-soft.psd
c6cfa46646584df02ac0bdb3916e789483cf690561b60766f623962ff1547fcd effects/bevel-emboss-smooth-colored-normal.psd
14b17d64262362cb7b3f75b5622723f4fc8532b15b9ed772f6a979754e38c774 effects/bevel-emboss-smooth.psd
1a54c1317628f4d7f0de0072fa3c8952d501b97e29f3e211d5c716f1f42971ad effects/bevel-inner-chisel-hard.psd
07af0f8aff34ee7a88224ec997c7342abcc072b18587dbc7990ba740827904fe effects/bevel-inner-chisel-soft.psd
a9399b5f64a9c0c2941487061446da218d9aa15634afd9f7c553ece4dae59318 effects/bevel-inner-contour-double-ring-aa-r70.psd
262897e70ca69d5f1953abf32f11d1e3bf7bd7b1cd7ba09c5806411add9cd3c7 effects/bevel-inner-contour-ramp-peak.psd
80f6812693ca2fefd63465824335e6e31a142c61c69ab74382e6fe8cfefaccf5 effects/bevel-inner-gloss-double-ring-aa.psd
3428a0fb2e5ba583d11502e69f3f185f79ff9efdea9397c79221a0d733620dbb effects/bevel-inner-gloss-ramp-peak.psd
4a5d2df7ea4ae8311bfd770a0cf3db6fda4812a8f7806e15be7f8db8b610efed effects/bevel-inner-smooth-a45-alt65-depth300.psd
e9d83d61777475d806b041acfeffc761aea682e631224dd3bf1157b9bed2145d effects/bevel-inner-smooth-down-soften4.psd
18386df1ac2289a0b663a9605f8da9c3f29a54fa0dbaf8383c2cabb8ad9ca3ea effects/bevel-inner-smooth.psd
8c4c02373bf126881cc4ff924f417e21512e996e0cf4e4305c07e633bab4af83 effects/bevel-outer-chisel-hard.psd
c2ee75350f618af38e8a01a70d2acfa4946d7b8647d66d03545d440a0e68f64d effects/bevel-outer-chisel-soft.psd
62b2c396be00b5994177957682a38515c7622e86d1a047d6becfd7c58b15e146 effects/bevel-outer-on-gradient-bg.psd
663d9ed575a2d2aa4c52a6d3ec7a5c213d27f0d8b7a42c866252307e4f191254 effects/bevel-outer-smooth.psd
88bb46bc8b09564d0ba31ac1ca51c5098e7bc1fe10eadaee34c4f9c7d6d444b7 effects/bevel-pillow-chisel-hard-gloss-and-contour.psd
e783af329d6f617cf96ee16d4d160a945b3e6848b632d04e03947de084f6bf0e effects/bevel-pillow-chisel-hard.psd
077767ed4793c712206653312a15c07af74fd75073172808b911b7a60952a89d effects/bevel-pillow-chisel-soft.psd
70f5dc0461e6801df4caf036e1fc7a7bb03a9122a148a58af1ea139a0fddfe58 effects/bevel-pillow-smooth.psd
7acc9af0adf16f32a079ac7c5577eed1413238c5cf51fc4ed69f8fe07f4d858d effects/bevel-stroke-chisel-hard.psd
110e96c2b0258e36e36a2210142b096534febe8271e6b3840041700d5e5b8789 effects/bevel-stroke-chisel-soft.psd
0e1482eb86e808d0cac3fa2b0cafc86c734787b5669eb9e2b7dec3de98c7f956 effects/bevel-stroke-emboss-center-stroke-chisel-soft.psd
7762caed02776268c87233fb4b6ee8680bdceb5e89317dfbf65cc52a44799c66 effects/bevel-stroke-emboss-inside-stroke.psd
638dc0e837cabc6d0ee158bed209af952f61b0d9e4ecde9d89bef8ac61209f12 effects/bevel-stroke-smooth.psd
b91f4f0b2ae856afb3699ae804610980c95a2534cdeac9c3bc4a1614cb3640f7 effects/combo-bevel-satin-glows-stroke.psd
c7c65c82da8289e8b542611ad5e9cc2e3fcb69cf3ba9c2372a9a1b122bd3bcc2 effects/inner-glow-center-precise-choke30.psd
b047a813a14bdbd53cfaff9ca2ba26455dec9beb8c010feb913d468546a9977d effects/inner-glow-edge-contour-ramp-peak.psd
8ad57903692280ba3d1cf438dc9165c8abe34411b609b8d8d57e67f00c8be365 effects/inner-glow-edge-soft.psd
c170707cde402b1790cdd40ff3fdeb8e4373375c1cb703da3b8074fc26ab2230 effects/outer-glow-contour-double-ring-r60.psd
da6f2622a5e2b7b84818542793de122da6f15d221f8b937b6e025a976ef18a4d effects/outer-glow-gradient.psd
d55dc366e754ed46cdfdc66495149ef9c51a679c3228a1fe1766aa6240b4951c effects/outer-glow-precise-spread40.psd
f20c9ec307607a6e781fe583675434a81e233836aa4cfb56390c7bb950470daf effects/outer-glow-soft.psd
ac79fa1c7ce6cd6e6db847bd2c5b3399569556caa1d2ee0783250069ba97fd7f effects/satin-default.psd
b1797dfbe45d795c4ff749ad89c707374596a51f20232d13385e86e70ac840f6 effects/satin-inverted-ramp-peak-aa.psd
66f58e80cce3ddf78f1eea9de50b3a6680565f53d11d9fcb9f867ed2f808455d effects/satin-linear-screen.psd
023f4c365695e3c3df415c1a506d0b53ed02818b49b4ea0e99bd434af7c5991d smart-filters/sf-add-noise-gaussian-mono-20-seed.psd
b19334171d4c217885c96acf268f2b2f9867c0a3ea11e4ecaff1e84c74fc1e5b smart-filters/sf-add-noise-uniform-12-seed.psd
1aff6589704afe1bfec5524e98c41d5597c7c21859e99aa4c4dd3d2de1b7678f smart-filters/sf-blend-difference-50.psd
c9dca359d516a1a7a3d791f6a974fea9c464d680f4e4d856f64505d63ed99656 smart-filters/sf-blend-multiply-60.psd
859a678d78a9e3b83608793b2423102d37582982eb1ea191d1049830dbd2026c smart-filters/sf-blend-screen-100.psd
c7a198d786d979179ac9a6733ea333e8d903e9157e91096d91105a8d66637a8a smart-filters/sf-box-blur-r5.psd
466e24d095ffe47937d28a0022a14cf0c37d4c3aadf85b7fbffb8222c277a6af smart-filters/sf-curves.psd
ee47fd52e0d97872721b4f1d3c0fe80ebce6f823de72b2140164e20fb3a92233 smart-filters/sf-emboss-135-3-100.psd
f8854458ae12da8a47fe42d518ac389dbe3159b2bbe2a93783f099569dc90ee0 smart-filters/sf-filter-mask-gradient.psd
52a91cdaa77fcc98ef342d02d43fb56f5586c3ee335b0d72fe70deeadbb12cfd smart-filters/sf-filter-mask-hard.psd
5c981732df2bbfdb19652c21f2b5964ee52f62df4ac4ee1994b2ff9519672dbb smart-filters/sf-gaussian-blur-r1.5.psd
174788381399a166109e770c536312fbbebcd27ea00bd952f86f50d434a0d115 smart-filters/sf-gaussian-blur-r20.psd
501cbf378b92b9c32f41344a551676c4ec39e516f02c50abe02633eb44017002 smart-filters/sf-gaussian-blur-r4.psd
a05044f49992e624c71f575b71b63d001c9c0668209e2768b6eb6ccce1bd8936 smart-filters/sf-high-pass-r3.psd
72ba610dceb18c14a44d3118700e3373e0c0b12c03220c441f7939a32d81d00f smart-filters/sf-layer-opacity-blend.psd
4f753eed08866810a902db17e6d82c0c98041e129ff62f6b7fccff42012640df smart-filters/sf-levels.psd
f72e19ee6db42b0e285f5b8caaf4031c7f248503e0fe9ccf4155761f7e369e2e smart-filters/sf-maximum-r2.psd
ac42bbfb868188069bfee2c06b349180ccf088b589f25e382f7a48643cd91943 smart-filters/sf-median-r3.psd
c3740ecf2601512d0113a79c672ec4affc5092ada5bb12c4f330b9897a720608 smart-filters/sf-minimum-r2.psd
bd561dc103f9abeda27af52b1101d56644343b7203ad9662174ae6e9ee175296 smart-filters/sf-mosaic-8.psd
b400368977d2f78822b9392869b61cf6b4f1656a115d38a4f6ecce43fa5d4693 smart-filters/sf-motion-blur-a0-d25.psd
75555689e92a8e984ccace9e4fc5c2a4f0ae6abe31364d2d93eea1df5799dbbd smart-filters/sf-motion-blur-a30-d12.psd
5327015b70f1427e6ac28054131e166209dec4643aaa1d4bbf79de472dcbef5a smart-filters/sf-none-plain.psd
c4698d699e525fed9ad37fc1b4d694b494c366264df77217ca51104789ee3484 smart-filters/sf-none-transformed.psd
3485236eaf01729d9f640f72c16f730e6adc663abf7148c9c17dd6cdf6acef10 smart-filters/sf-shadows-highlights.psd
3b1cab8cf7d539146df2beea4d4923e2dbcd2904c510a8867bbe352f94f2408f smart-filters/sf-stack-blend-and-mask.psd
0e31ef912f818de7cc75bd5604d6e5dcfd8867f8040cfdc1a595e72a77b5f4f6 smart-filters/sf-stack-blur-unsharp-motion.psd
f39f61eb27347074f8453d06e55fcca8df3dc58674353fa939e7e6faa99d1a2f smart-filters/sf-transformed-gaussian.psd
77d8dfa49b7a11df3e41ffac535fa5b0dccad2aaadd4c8a069fe8b9307eaa644 smart-filters/sf-unsharp-mask-150-r2-t0.psd
ff2b1189400dd2000e2117188f9d537e99773d91d1e5eb5ad2994755a06c8f05 smart-filters/sf-unsharp-mask-300-r5-t8.psd
b49c79a59e1bf27c5aa01859d22ff0e127c795e7ca731665302994a421181db9 text/align-point-left-center-right.psd
b7bdfd710a562b7776f8e2ea61ff572dfa27bb08cf64c642e3867c21a2822044 text/antialias-crisp.psd
8d03faba9e0c0bf8b416669eababff8060bd2601c89932769088401da241f62c text/antialias-none.psd
d58a6735dffeedac52ff97dc645e08716e743e8967b1fb356bd219861683d873 text/antialias-sharp.psd
679d9cc50d447bc13dc6423cd9312c1aff00c015cdebd72fbdf106e2bcabf227 text/antialias-smooth.psd
c351676cd1563c73e0fec570c6de0d7bde61557289d47f4eac95e9b0cbc0e54b text/antialias-strong.psd
64882ffef640371b3e1ab8fd6d4750e8b40a3880a5f21425d9fc9a96085d1d35 text/baseline-shift-runs.psd
12798bce71eb049a1c875d24a4f96b9319a028da06714a6a2167f39923ed0731 text/caps-all-caps.psd
da13596f17faea5cb22783b4b322948fabab98192e06f00fed3eeffb81ac99dd text/caps-small-caps-faux-arial.psd
11e38a0dbda2275979be9287c6e7c8518db2e0d00eebc89ef05d973ad02597dd text/caps-small-caps-opentype-source-serif.psd
11c27c05a1cce82a4905f7fb98a310a07a5a05e058f976f6426f341beba7ceb9 text/faux-bold-italic-runs.psd
c4d90278476b5c22120cdfa7646269bdce3dc3908013b4e17c55facad035206e text/kerning-metrics.psd
088fdc7779603923c9ce9b873e7ed369aa77fa15f400481e5cc6bb499ebd90c7 text/kerning-off.psd
4515c4ae650a32efc4ff4c10f6fbf89ccd11383f3502ad584dfee0463762e331 text/kerning-optical.psd
e98d48f0704cf01538b362de847470a7260d49bbc9b63d2071fa9988cb0d0603 text/leading-manual-30-paragraph.psd
b04abd8de6091818b7b777a8c408c181f402cbd5b75a399bad6e6283ecab5b92 text/leading-mixed-runs-point.psd
0252007af1d6af38714d54443ceaf2fd339970bec1991d274cff10e29cc40d07 text/opentype-discretionary-ligatures.psd
2522b2496e5bbda9e7d47ed20c8a5ba200a15673acbfc7a417c1cff411955e5a text/opentype-fractions.psd
2b7584d15861f6ac4871f9bc1355a7270998b5b7b1d6133d019ea64a45d3fb23 text/opentype-ligatures-on-off.psd
da479e7312d2548d4df7c33b2042a4a476caa0e6ecb7d6babd6b30fd167c6a4f text/opentype-oldstyle-figures.psd
29d73b905c8688360323caafa5c7cf127ca69d5acd40380972422d2eb9647c9a text/opentype-ordinals.psd
2b97aa928feecd470eebbc2b8084b1e38e09674b6568723384dd7568ced7bb75 text/paragraph-box-center.psd
dac6fcd54bb1e25a0a5d978bb0ac57a321d119ad54c04d0814e7083ff984466a text/paragraph-box-left.psd
ac90162bde727a50b03e38d60ace79c63b6550d41265e154d15fab86e4eb0737 text/paragraph-box-right.psd
f97d93cf6f30d5c699222056210607f79523fed3afd14f5d6500be4f9d559f1e text/paragraph-hyphenation-narrow.psd
33d54e862de11b12c725914710699a03cca0cbb2db504ea7209c993194bcce78 text/paragraph-indents-space-before-after.psd
3754cf3647d81e0c57d388705d8394caa6cfb2be459d9d3312d0621f05395d4f text/paragraph-justify-all.psd
1391606d9e38bdabb1fec1bcf5ef784a013220839b834e397c8542e297ca3d4a text/paragraph-justify-center.psd
15f2e504f7c06bcc6df4ec1e17215babf300230af5575e18221705c86da383c0 text/paragraph-justify-left.psd
f5fc6f814276d72c496d192fd9b99c3ebd79bea1566530a960a1d84baed13688 text/paragraph-justify-right.psd
929d3e0b755b4915329bbbcb23128b809b86547bfcf710e4471a6ca8032e163b text/paragraph-multi-style-runs.psd
113ec2a6c19a023757df909ae01137798d59935ec9b73150055a4eab49bc8ecd text/paragraph-no-hyphenation-narrow.psd
10962d5723db0400b11f070b701e8ad63462ffdb3dbc5417a4a5213d82795d7b text/path-circle.psd
3585ad8f9579e50eff4eecc993f986b0cfa453c105c39ba099f747c4fd0c97e7 text/path-wave-open.psd
ce30c40215d7a08581ff4be7937bca7e5fdf9a2faacffc77c0679861d94e4114 text/point-arial-24.psd
a46350eaa73daf9f9b1a10b05647c3a8c36684fa7374a82b6cfa7be4aa791e3f text/point-fonts-mixed-runs.psd
63269a1abc496ac8deeae2c16aa5a7b83810164f914486b9a2e1ff246ca872c5 text/point-multiline-fonts.psd
c31961b76e953f915b811ba34d0546b2eb179a26fa17c19e4a990e3dc04e0caf text/point-sizes-colors-runs.psd
9c70731ac43cc5227034b3c96afd53f25a11ce63892e649702f99869ad900d5e text/scale-horizontal-vertical.psd
5e2cba83b5209d1fa49113a42e953c1f2959d1dc371e4b76c2e5ff296682b382 text/super-subscript-runs.psd
8e24f354439757e18313a1cb5d627f80e2646bcdce6e71723599011a3ae97498 text/text-on-gradient-blend-multiply.psd
4e5bd113f2b825df7d5385c51ebfbf3e05aba1fd4bf6fcdb7e5d7a3f657bb3e3 text/text-with-layer-styles.psd
11c5a14bc55f34628893fad4e98064747c8a644c572bdb7b496450aeb59acd37 text/tracking-minus60-runs.psd
110e0c73f1dba131eaae97ac828001f347e957a2a0e4b81eb9b146af3c211829 text/tracking-plus200.psd
e5c64d48a33b768b3f9b7a85fa8ef9c28488dcb62df33f4e8b3b67ee526ac1ca text/underline-strikethrough.psd
11794ef0933b20b846427760a755d659da18501f802789111c9b951d6b1825b0 text/vertical-cjk-hiragino.psd
c7c23d929a8fe75d5cc02afff09b43eeef505fd41d7d616afeb95f2dbc984e75 text/vertical-latin-arial.psd
dd9d8e4b2147fd842d76e0fcee6c53868051feba1045ae59cbe5e323028e08c6 text/vertical-latin-upright.psd
3c627eab3c890f4183bb5d52bbbd24655aad30326be8c57e8a8549d250a02507 text/vertical-paragraph-box.psd
d292ae66af22d18b93a1a083a9accbcd514f4dbc3ad7784e12451e8fe8e44f82 text/warp-arc-50.psd
c4eea1f306495f67f9e8e763e5faf6a9f481356b607326e6b266e61001bb9c73 text/warp-bulge-60-persp.psd
23fffed5158ad2651507d953ec2d1f7f8d29096ec91023a34c144520a814839a text/warp-flag-40.psd
a4ce58653f5dc1366214055407286966c94d7cab80380f42f6adfb6732c81013 text/warp-rise-vertical-30.psd
a530ee19a34a3bcbfa8466cf90b86d739f7a55724e5e9d6b8deb6b1143786ede text/warp-twist-35.psd
+170
View File
@@ -0,0 +1,170 @@
bb9d161b20b1f5f24fb8db6b24bf4034e76e29205f38ca2641890226a892f6d0 ag-psd/layer-mask/src.psd
b329cc9b04e5b9b308bda7557961e4c21186d385606f04af105b46ad073e44a8 ag-psd/multiple-strokes-test.psd
51f676709d87726ea59b1a6e1fb9bf0e540ba470319c9ff2e5f02693b62affeb ag-psd/read-write/animation-effects/expected.psd
1bbbf190934791516a3829a749495b8d16258ac090e621534679552e8cf6f300 ag-psd/read-write/animation-effects/src.psd
5ae1b6c65fad86dea482f20a5b722eb109b773aef7199d3e7eea76349cdb232f ag-psd/read-write/animation-frame/expected.psd
86e93b139ef112152aeeb39477f0f7ae7f436674ae4ba7195919d3403a18d28e ag-psd/read-write/animation-frame/src.psd
06955743d0c232c29fa52de31c19eebc60d8447e4735b44faea4933bdc38219d ag-psd/read-write/animation-offset/expected.psd
f68e42a1e2b1df4069e9d756cb474893d76ee7d1ce1a7b230461a3a2abca44e5 ag-psd/read-write/animation-offset/src.psd
9ef472c91e42cdccd78a4ee08511d116e4c34b4b3d75afe7129ae1dc3e065c72 ag-psd/read-write/annotations/expected.psd
17f9d5be6fafb980495848667e77a62fc33c73ae7fd9faea6dc16749c56a1582 ag-psd/read-write/annotations/src.psd
9a360d71f69fcfb901e01f558d7638ce352884a9025a2c14629246771020774e ag-psd/read-write/boolean/expected.psd
941fe47b3af7e7dcb8b0fa9e6bd75672be50168d5dc904f6559e024f890747de ag-psd/read-write/boolean/src.psd
e98a9d05d92e3a9a4f9a0ae1d6f1d1a78dcc01e1c96f2ba11efbc44940f78e23 ag-psd/read-write/effects/expected.psd
1b28aca32edab6afe254fa5cec3c3f2179a123a8c2b49dc1cd92b3c2acd49a0b ag-psd/read-write/effects/src.psd
b16b8dd8034af1d446fa533afe47b7cf63a4d0e340394789d0ec29154efb9411 ag-psd/read-write/float-color/expected.psd
021a92686011e5a23d51991ebb8e6c226a3d071783375d6478d933b041e92f2d ag-psd/read-write/float-color/src.psd
8852ec5a6bde2953aa50fd1fb7e2548d966c65c6b8394af2981c7df440b63fdd ag-psd/read-write/gradient-mode/expected.psd
142bb1bfa5c8b7e0914ea96fecac69c51de9a25171b60808440d9ce626c851ff ag-psd/read-write/gradient-mode/src.psd
eaec5ddbf1d5edfdd8293356e585eeb73cad767aecb87d8a5913a7a5a574fcae ag-psd/read-write/gradient/expected.psd
d1661f020de3782c472e74baf5d39e91835db35eb5e63adaae2b4ee3da8b23e5 ag-psd/read-write/gradient/src.psd
55e5378f46ed78216c8abd8ab29631fa2cc54fb748e06becc3740cc6086d4179 ag-psd/read-write/group-blend/expected.psd
fa6dda1e79536531539272454b8dc35fffcf03b5c0f67e01ea330fb1171546f1 ag-psd/read-write/group-blend/src.psd
1d268b34c5ca924b49debb5d42449aef77b55bdaf1ae0aa3a1b65dfc409f79c8 ag-psd/read-write/layer-comps/expected.psd
d6b4daeed5856d1fcfd6f4ae72cf10c9ac91a415d6e9db26da483027b6ded366 ag-psd/read-write/layer-comps/src.psd
ce7f28490463306de466a6d2691b4a2510861b7cbff8d0f2b1243c2540f384b9 ag-psd/read-write/masks/expected.psd
bdb6c9d5b5c659086f9659f4cb5a35d5357b52fdf9ff12e7c3c872277f4f0204 ag-psd/read-write/masks/src.psd
70f7887ae01307c1546682fe5c6319125a36758fc7cccfbf5948d8e72de358c9 ag-psd/read-write/psb2/expected.psb
3aae71e57e7e6044c3df63ee9292151007eec7e63c44587e79c607d0eda34449 ag-psd/read-write/psb2/src.psb
3fb263f93035a976d07f905a884606078cb905d4d0ba7e32384e184967218ebc ag-psd/read-write/round/expected.psd
d6e1b3b9a9ca2fe7fefefc1b1608d259eb88e2fcf6ee824650855e068e87ef2a ag-psd/read-write/round/src.psd
31c406e80386d3bf189c0fa8bac0ab2dc56f013bba8ea939457e5daf821dd5c6 ag-psd/read-write/shapes/expected.psd
e6ce955cdcbf12e8dca78572734098404d3b8456dab32616ac2631fcb333f7b4 ag-psd/read-write/shapes/src.psd
2738c29bf233203325762ebfe61de977185d8318b2d93f3905c19a21bd88978c ag-psd/read-write/stroke/expected.psd
259b17a593b760630a3078bfd45d334bb9d2e7092cf3ec3c8427d246e764a4db ag-psd/read-write/stroke/src.psd
32d2504653a8f62f5d027de2b205bcf17f166eea82eda942ede765bdacaa22fb ag-psd/read-write/strokes/expected.psd
5bc7e774b6b5245678576cbfbc6d910d31599cb922cd763a10f16a38e8f3789e ag-psd/read-write/strokes/src.psd
0c59e83ebe706f2231760a598daceb754012aa587a295d69f233439befd83066 ag-psd/read-write/text-box/expected.psd
5e93866dddc6407b8f985471b00932327c11d400dab3519f70d89570ec511681 ag-psd/read-write/text-box/src.psd
7edb0fe9605a0e4f04fc46d9e06934f773b271b9217846498ee34a660a241c6d ag-psd/read-write/text/expected.psd
20a45f99e4a07a1377e29735b5bbba41d2d6167065c568e05e54142dfb303887 ag-psd/read-write/text/src.psd
16e7507b1be7c994aed009aae5109817bc380af5bc7798eb98e480a11b0bfc16 ag-psd/read-write/vector-and-mask/expected.psd
a654955ae0680252123eba20e541b74b4324caa4e6c84c896467a1f399335b99 ag-psd/read-write/vector-and-mask/src.psd
24a17fc2239ee1a4e5442ef41c42ded12dd805f1a4b37f7189515a4f850a3765 ag-psd/read-write/winding-non-zero/expected.psd
3331bfb5dd9712d1666874fa5b797ad6debc3b8587674126220a6a5d1fa4eb76 ag-psd/read-write/winding-non-zero/src.psd
bf81d8dbcfea45aab06e8bd97702fc7deb7cf793adb4de1d68131832f9c8ed92 ag-psd/read/alpha-composite/src.psd
1bbbf190934791516a3829a749495b8d16258ac090e621534679552e8cf6f300 ag-psd/read/animation-effects/src.psd
b7f467529c585537fd982ab9c0879b18b0d05340e72b06d705b324c11fb00d25 ag-psd/read/effects/src.psd
2effae19315366cfec32ec555b28dce48e0d431921df01b612bb46a6acf731bb ag-psd/read/gradient-overlay-2/src.psd
d1661f020de3782c472e74baf5d39e91835db35eb5e63adaae2b4ee3da8b23e5 ag-psd/read/gradient/src.psd
a9083a424b939d42e6fe3d3f27da676d7ea63dc86a469d368fcbd6d5f017cfcd ag-psd/read/group-drop-shadows/src.psd
95550bb019db61fe35a9d8f524d37d0ac334f8278dc04589bc49ee2516779612 ag-psd/write/effects/expected.psd
3ae85219dadb3028dab6f4f8f72e6e1a6ca4b5d40aeede8abf87ec24020150fa psd-tools/0layers.psb
0711306e56002f193e33aefe4a1e9ce89ceafaea04da243593bc0c1e697c7d64 psd-tools/0layers.psd
4f40d8f359571c86e485af6c711a52af90c4b8aac2ad84db6655cca75a61ce24 psd-tools/0layers_tblocks.psb
407c1b0659fd6283f2833b9cf95c7ca3538c5cc61d226e63cebf90a4c1c9f665 psd-tools/0layers_tblocks.psd
e0c9b37ffb5981aaedbeec2b2dfa3ad561b57e86e5e04758ed859ca819cd427c psd-tools/16bit5x5.psb
c78689ea7b576f23bbd8b6b4f4993a365266c3ffc9aaa02cc20bef4a36a9d7a0 psd-tools/16bit5x5.psd
85317ccdb7ec11a51f1983ad2055544a490839c01f8b313db8070c1b0f0b83f4 psd-tools/1layer.psb
c2b457581d549f4bea2e5c34a04b68b2917f640bce0e958b6bca9c65be75174b psd-tools/1layer.psd
ee06e0307b1d5966eccc84768c245183c9ad90dc8e0ab3a8113f75d407615d4d psd-tools/1layer2.psd
1434880f98b92d60193e6759859bc5ebe589e5deeed22246d0e37c85db58840f psd-tools/2layer_8ele_tblocks.psd
863c51eb4d4e72214a3cbdf06a0594c485e4262d899318b1ba914d4c848382ab psd-tools/2layers.psb
406ddf7cbf5a1065b992b9c0d2a59be6290a371b90f23b9a04c0e6bbc94beb0c psd-tools/2layers.psd
cd506872a4b1dc48bf7f64e10d321b99ea950c557cd3a83e303288e8ef6bf481 psd-tools/32bit5x5.psb
66a4260e6bb8a60deff94031a66fa502c74870d75a723b4180aef02c35a97d0e psd-tools/32bit5x5.psd
72ab9e417042a9258350326814ebbdeef84442ac3f453b5462f3d339adc29768 psd-tools/adjustment-fillers.psd
6c866b95c1458b5a639249f379fad009e3dd6f8cfa2d3dfbbb431ee637c30390 psd-tools/adjustment-mask.psd
7dc81242cc78a22cf11a9b587e335299235de37e29b869a7bbb37c6ad7bf29d8 psd-tools/adjustments/adjustment_clipping.psd
1577dded7ceed05fb786b775948501571cad06d90b20c61a5a0923067c501000 psd-tools/adjustments/adjustment_nested_composition_4.psd
f150b3200c829edd0613e9be83c22c37c2d993a8ee9927be3400ed2c2b14e2aa psd-tools/adjustments/adjustment_nested_composition_5.psd
c21289d955a9b24418f26ae423dc69ab1d7431c734510391f99cfa563ddf4167 psd-tools/adjustments/brightnesscontrast_grayscale.psd
d79c5b778d2b6e023c9b17fba58b0efa05d90c071f623d5b52a4bd8ad9d9fa82 psd-tools/adjustments/brightnesscontrast_legacy_grayscale.psd
f716cdf162c2c49e41aab1393f0b6f15336a8f66e664936b0294dc997ce272d0 psd-tools/adjustments/curves_grayscale.psd
01147b29385829f34cb5c746d7d779fc82cff89fb92fc47b9652300526271f5a psd-tools/adjustments/exposure_grayscale.psd
b4660b0e3af58db0e593232f31c63b46c74557b8bcc4bf7cc8835d9930f1f26c psd-tools/adjustments/invert_grayscale.psd
d5fa40edf2973be124d27cc485151d70a8c017d90ddcfd8b87c6d55b0ec17fb5 psd-tools/adjustments/levels_grayscale.psd
5c3219ce794da2c4c5d7dcf3e00f3152c07d0a2064d4104ba05767238889dd8a psd-tools/adjustments/posterize_grayscale.psd
ea435249c68711f8f68fe58cf7c7f38d7e4259d0c919638c2eb5034857f55671 psd-tools/adjustments/threshold_grayscale.psd
3a234f7b9664c07e2cb554fb92f464987e299d56aca8713519689afc2b7cde60 psd-tools/blend-modes/color-burn.psd
0b320de07d9285ce6d718f1f99d762c96c6216a9f13582f6f2cd5c8a1dae6408 psd-tools/blend-modes/color-dodge.psd
8f9bc703572cadffcb2d0be9b3e54d98c26998008693bde6de42f6da542a00f2 psd-tools/blend-modes/color.psd
83a49ce3fa585ec40dc6a89dc007546eb90a19c934ecb3c4ba4c6a0793f7b495 psd-tools/blend-modes/darken.psd
8a1a674394804c907478f67d6a410e12d4d44833d7804c5cbb78ad6f835f33d5 psd-tools/blend-modes/darker-color.psd
a4c341a91a074b97efb15463c9c1133d6827367b6985ee0c1eefcba8425eca41 psd-tools/blend-modes/difference.psd
d100f77081f28a75678d6b626379d2327e64c2c04b08afe8a763f59f7dda6c1d psd-tools/blend-modes/dissolve.psd
59cccb4099dea57d63733fd675536f0a85af59986093b625734734e82f86de1f psd-tools/blend-modes/divide.psd
9f9e7f72b28ea2a1179051e55995485c073a63627d880ef453e5fc7e0ffb9dcc psd-tools/blend-modes/exclusion.psd
272d1f6e12ba9853451cdcec6585440320507ed2a37a1aee8ed37d19662ef103 psd-tools/blend-modes/group-divider-blend-mode.psd
b45a66ad68a902a715c0db39eb1754f1c98b091245c50620eae800f3a2030d01 psd-tools/blend-modes/hard-light.psd
574ac1260e30c3f8d6b2975ba98674b5e052f7838c3c0cd880299f8879ef7e74 psd-tools/blend-modes/hard-mix.psd
e70d0ae7d708ef3b0bbb62ad03f0c3d00268a0d15c5a4bb0c0c2ccba0a78f924 psd-tools/blend-modes/hue.psd
4fc0432c1a154cddc0a70f558281cf5034caf561d61b91a0409e6bdc89c613d8 psd-tools/blend-modes/lighten.psd
5ff8eddc32b369cbe24470727b01560174bc1a31a48cd90da766c29c79533823 psd-tools/blend-modes/lighter-color.psd
c9dab0d70793b22dbb73ebbdb71c25f771e483f50a0c55193f9ea6f544342558 psd-tools/blend-modes/linear-burn.psd
1fd41e7c186f999d736dba2bd2d09624bcf307e15bd6dc03461b2194546ee06b psd-tools/blend-modes/linear-dodge.psd
28ee78b412a63832317bdff2400844ab10a91cb4c4f28d52ef3695428a730b9e psd-tools/blend-modes/linear-light.psd
bbb36bfc321189dde7212605681907bc627a38a7fc6a97d7a3bb90004173605a psd-tools/blend-modes/luminosity.psd
f0e16879b2cd78e7fa859ea00a22abfc601cb84347d9f854e055d3ed0ffd59a5 psd-tools/blend-modes/multiply.psd
ad44357d551d7f59d496843f2260bf0e9645fcf63068d3535432383a0299292d psd-tools/blend-modes/normal.psd
21cc256ef3fa5806bfa45c7a69c92e2a2e78a03e9d12025ba8cfa76b07941362 psd-tools/blend-modes/overlay.psd
b7b95e85a67d79c46e84757b0ddf84e5f0dd3229fce994507cabe176db6d82f0 psd-tools/blend-modes/pass-through.psd
c7e2b0aa5b3ca3eaed159b3783a2958053a25b27edf0a06922a826e59e231d9b psd-tools/blend-modes/pin-light.psd
b60e973294894556eef9e0bb65798e6ddfee3d71bd0389fef4813692b0e415f2 psd-tools/blend-modes/saturation.psd
2786c78df04ff6724a112ca3eb2a9ba7468a4dcb3d5e8ab804c8e13711556195 psd-tools/blend-modes/screen.psd
3e68729590ac9e5031bd870301e7df2ada3490297d3d372d97015c09aa114a8c psd-tools/blend-modes/soft-light.psd
863638e0769d2bab51ce97f19184063cde4457bc5a8fa4c9024311f6251d8def psd-tools/blend-modes/subtract.psd
e350f2bf3f0b756a3ba61eb6e62f8ff747561da095ca09d54e212835ed23161e psd-tools/blend-modes/vivid-light.psd
463ca1c2f93622285315ff6d69e7c2a38bf7f0143bae4676a89ccb924d596662 psd-tools/broken-groups.psb
60c2fcfe7245bfa141b565b54cdaa36ee4ad56133a883260c663f857db935f15 psd-tools/clip-adjustment.psd
92b54b07d584d65924cb2944bc44b0dac7ede671c3e667202fcf6d067837eccb psd-tools/clipping-mask.psb
0a44262635e68fe2c6823eed6cb840ddc43b9d2620c8d6948e1aae81291ac55a psd-tools/clipping-mask.psd
27adc3c327ac4fb1cc069bf4dfee8d8ff70784180f3855bb0bf605709a91537a psd-tools/clipping-mask3.psd
366f250a766fa5ff7198a6ff292138854666957bb58099bfebba4448fe96126f psd-tools/clipping-mask4.psd
a80dd95e43e05ecb1dc2e59189a74ad90d7e4cd44a071e8477eb6b63edfe7c08 psd-tools/clipping-mask5.psd
579787b33f69062b1cec82c3ddeeff1da33303f3379c96f516fdd07eab834fc6 psd-tools/colormodes/100x20_1bit_bitmap_rle.psd
edaf3b8cd5f621b4f35436778e91be2d487f76dc381caa1cc8bd17d325041f14 psd-tools/colormodes/20x5_1bit_bitmap.psd
5ee15880f77c1a0e2566864d8770a26efe3aa261245e360267973ebdd542a49c psd-tools/colormodes/4x4_16bit_grayscale.psd
35f1727b45b73eb6191a726937f336c8ef6515992bd4913c80e67f46dbc5f12b psd-tools/colormodes/4x4_16bit_lab.psd
36730fdab702fbbde8f8b96451d1099186a4443d02180119542ed50aab34c88d psd-tools/colormodes/4x4_16bit_multichannel.psd
5ac6e33ce952a5a6daf86144bedf849d60fd0008513f94c57fc1973960249a9a psd-tools/colormodes/4x4_16bit_rgb.psd
56ec3967706373af835a319aa2ef6698351e07b8b495e5296ba4fe8848c2dd5f psd-tools/colormodes/4x4_1bit_bitmap.psd
f46c66327138fd85986ad514b9002c2cf0f4d654223e2ef6652a907b110ab8cd psd-tools/colormodes/4x4_32bit_grayscale.psd
7104010764a3223399c3c1c797d64502be4391d266645edf0e600b75ea77e1f5 psd-tools/colormodes/4x4_32bit_rgb.psd
4169c367a7e4698f55955901849c38b8bd284dd61f8ddcbef8bafa70e54bb19b psd-tools/colormodes/4x4_8bit_duotone.psd
2811614db8536c363ffd4b9c97baf8965b58fda5c894ffeffe1067d1287edacf psd-tools/colormodes/4x4_8bit_grayscale.psd
1da88234b4c1d33ef2fed6a3a496f68e3c28c0bce420400917736e9b72f194cc psd-tools/colormodes/4x4_8bit_index_color.psd
48e52730a29cf715f9df9be12d6730af9e8125d27c5293910fedd7d621cf03e9 psd-tools/colormodes/4x4_8bit_lab.psd
b656bd96b10350fc6911afaabbc2fa6a48cf5c1ae59c1a8e2f952539bd4f1930 psd-tools/colormodes/4x4_8bit_rgb.psd
1f87ed0f6bace7587c0ab3fb48ffe22a7796a42e11743b6a10c42a4d2bd56d0f psd-tools/colormodes/4x4_8bit_rgba.psd
528599e9b1e9456b7e5764da2c75c6cb7ba976774b7d352caa34d34451775f27 psd-tools/colorprofiles/north_america_newspaper.psd
96ab1a61b0067548a830d8c9a9b07309657c4052a252ae92fa15866357e273c4 psd-tools/descriptors/hsb-color-swatches.psd
67986bbc829fd8d391dab43b471f7b7b8edf9c4635db5b6af4970ecabab6fabc psd-tools/descriptors/lab-color-swatches.psd
eb0cab1dd8decb1be3c0ceb95eaf555cc4370a011ee25dc76fbb164483c2b55e psd-tools/descriptors/stroke-color-descriptors-gray.psd
2d47b073029143e2224e303fad965973a70e526a8c88234d1fb9be116eaa3aa8 psd-tools/descriptors/stroke-color-descriptors-hsb-with-rgb-mode.psd
9f472778a8dca363a3dcac7f8a5aab14d53a8a6c84ed6f7b12381c96b099cf09 psd-tools/descriptors/stroke-color-descriptors-lab.psd
cc95a5bd2bdc6cf2de60ec1741acfa6672c5c4a12d6e750bde2e91fd6e5939a6 psd-tools/descriptors/stroke-color-descriptors-rgb.psd
0dc6114f10b814368f2fac1708668777cf0ede0159ee0484f3b9f40c165b8691 psd-tools/effect-stroke-gradient.psd
f1523bab5138726aa1f5ff89b3ca061175cc75e779148c2c750a914dd05e399f psd-tools/effects/antialiased-stroke-edge.psd
496e91bd45d5ca9ed97e67d3f6c61cf88c8b169228e7b25e30b78c159c24714e psd-tools/effects/blend-modes.psd
9ecadbb3216d4a5c2cd1df273b215635445afe7798dd0af204cf8d9d70f1eebc psd-tools/effects/center-stroke-sizes.psd
f7c0631ead3370a97aa445f610665796d14475528acc1184e276dd5d3f47ca3e psd-tools/effects/double-stroke-effects.psd
0dc5b4c928ecd890a93b6c887d14f709bda441a1982c9839b48f5c12e142261b psd-tools/effects/effect-enums.psd
42815f35af3998fd2fa4785bca94bb79124063e228d43c1fd206f71cdc43de87 psd-tools/effects/effects-enabled.psd
04fa5572072685682da74a90e5ab2228750f67f17cd43afb45ba0f9a27151da7 psd-tools/effects/feathered-stroke.psd
678d9ca2357056ac84e3494d0965268e7c700fc656101efc863d43e57cca0239 psd-tools/effects/group-clips-child-stroke.psd
62c699af1abd88422787aad0014e923b4ac1aa7e10d460f0ef485cb6310dd379 psd-tools/effects/group-stroke-off-canvas.psd
341a20b123b85ca0a9bdae0df3d74d8831341371c774081a4638856a1821f7ea psd-tools/effects/inset-stroke-sizes.psd
5be59daf7c5130513ab1c1e8900a9a317602434abd1ab46aaa9f4a98e5335d08 psd-tools/effects/outside-stroke.psd
f0c53835a0c5a8279512497f3448f2cfaf6ec31831eaab160c32779ed6a93fd2 psd-tools/effects/shape-fx.psd
d4db7556433ada2b7530de3e2a826327ec5497d77a2e2ce78dc48ccb980637c6 psd-tools/effects/shape-fx2.psd
f9a513b4192b336b644caf93153f20ac30fef448298a7dd64f06f46728f770f6 psd-tools/effects/stroke-composite.psd
bd6514a680c05740ec8392d7ca624a2a6a90e8bddfdb65a698fc184980344af1 psd-tools/effects/stroke-effect-transparent-shape.psd
8481c35a8739c6d3177237194d1da1528bcfa70ed8831394176c6b2b21e11a1c psd-tools/effects/stroke-effects.psd
b6fb31441e7c69d07045549495307864ff0fb2c21518aff0080b5effacd97af3 psd-tools/effects/stroke-without-vector-mask.psd
88d1e42e5c70a6f3f99b57e1feb623e0c9460807fbb00cb80223e213415e0a1d psd-tools/gradient-fill.psb
30d617696f4453b8f4fb6688af152aea30d8e5730f632105d3f8feecc6ecc150 psd-tools/gradient-fill.psd
39af0f5d0ef8ca1fb31d2e4ebf75f5bc5d8a9a506ba3829b998575ff3375ba82 psd-tools/gradient-sizes.psd
9c5e709a1659b9d65ebe5bec6719191bf7dedae72b224fbb63414225f812b018 psd-tools/gradient-styles.psd
2fcaed63aec566c47872f8e84eb4a070b4e5b27d88bf8432dd1ec11fb418cb40 psd-tools/gradients/noise-gradient-cmyk.psd
de9b1ea4b76ac5abadfddb794d98ac0189a46b9c575d5ad276d02306c9c6188e psd-tools/gradients/noise-gradient-grayscale.psd
8e6a6b2594af88e7d8af0f48df40803634b1073239d823703a768c9ed7128940 psd-tools/gradients/noise-gradient-lab.psd
83e205b5f380dd2623b166cb45f7f35765c3c93a787b242f0a9ea20cf9782bdf psd-tools/gradients/noise-gradient-rgb.psd
6ab41ce6e17b3287c703dc4cf4d898506ffc1bd9babf20d0f1de5ef6df1d2e1e psd-tools/layer_effects.psd
8d995a89eee1b4b11e0639cb4546e46aff8c9fd168a7b7ddfb4abb9f4b75e806 psd-tools/layers-minimal/gradient-fill.psd
2bac8500879484584fae6af3d90c8c2a2b2f566d350971e79dfc5283c993e2ae psd-tools/layers/gradient-fill.psd
8980760d269e95954e55c9142aca02fb21d27138b66d1e62f6921ab9bc131968 psd-tools/layers/gradient-map-v3-classic.psd
38fdbffa9b6b0144aad4ddcd66333ed698e9091ec318a1516240e9a0b7b85bb2 psd-tools/layers/gradient-map-v3-linear.psd
416a2db01f11356df8f3a0ce24cb66cc977c15124cf820bc9d2729a90987f488 psd-tools/layers/gradient-map-v3-perceptual.psd
a6b436d69b68b76ceb0fc4f8d7f3d080823ce5591d1a71f30aa391a6a19f01bf psd-tools/layers/gradient-map.psd
ab39632f4266a2bc72cfb676f06aa5fc60f1c4951fed2f9fd67165895e61890c psd-tools/stroke.psd
+195
View File
@@ -0,0 +1,195 @@
//! `cargo xtask corpus [...]` (fetch test corpora) and `cargo xtask test-corpus [...]` (fetch,
//! then run the corpus tests). Pins: `corpus_pins.rs`. Docs: `docs/development.md` › Test corpora.
use std::path::PathBuf;
use std::process::Command;
use crate::corpus_pins::{self, AG_PSD_COMMIT, PHOTOCRAFT_CORPUS_COMMIT, PNGSUITE_URL, PSD_TOOLS_COMMIT};
use crate::pinned::USER_AGENT;
use crate::{cargo, root, run};
/// Crates with corpus tests (behind their `corpus` feature).
pub const CORPUS_CRATES: &[&str] = &["photocraft-psd", "photocraft-codecs", "photocraft-io", "photocraft-engine"];
/// Paths whose changes make `test-corpus --changed` run (the file-format and rendering crates).
const CRITICAL: &[&str] = &["crates/psd/", "crates/io/", "crates/codecs/", "crates/compose/", "crates/gpu/", "crates/text/", "crates/format/"];
/// `cargo xtask corpus [--all | --pngsuite | --download | --psd | --psd-tools | --photoshop [--local]] [--update-manifest]`
pub fn cmd(args: &[&str]) -> Result<(), String> {
let update = args.contains(&"--update-manifest");
let mut did = false;
if args.contains(&"--all") {
return fetch_all(args.contains(&"--local"));
}
if args.contains(&"--pngsuite") || args.contains(&"--download") {
fetch_pngsuite()?;
did = true;
}
if args.contains(&"--psd") {
corpus_pins::PSD_MIXED.fetch(update)?;
did = true;
}
if args.contains(&"--psd-tools") {
corpus_pins::PSD_TOOLS.fetch(update)?;
did = true;
}
if args.contains(&"--photoshop") {
if args.contains(&"--local") {
corpus_pins::PHOTOSHOP.fetch_local(&local_clone()?, update)?;
} else {
corpus_pins::PHOTOSHOP.fetch(update)?;
}
did = true;
}
if !did {
list();
}
Ok(())
}
/// The authoring clone of photocraft-corpus: `$PHOTOCRAFT_CORPUS_REPO`, else `../photocraft-corpus`.
fn local_clone() -> Result<PathBuf, String> {
if let Some(p) = std::env::var_os("PHOTOCRAFT_CORPUS_REPO").filter(|v| !v.is_empty()) {
return Ok(PathBuf::from(p));
}
let sibling = root().parent().map(|p| p.join("photocraft-corpus"));
sibling.filter(|s| s.join("photoshop").is_dir()).ok_or_else(|| {
"no local photocraft-corpus clone: expected ../photocraft-corpus next to this checkout, or set PHOTOCRAFT_CORPUS_REPO=<path>".to_string()
})
}
/// Fetches every corpus that is missing or stale (verified ones are left alone). With `local`,
/// `corpus/photoshop` is copied from the authoring clone of photocraft-corpus instead.
pub fn fetch_all(local: bool) -> Result<(), String> {
fetch_pngsuite()?;
for c in corpus_pins::ALL {
if local && std::ptr::eq(*c, &corpus_pins::PHOTOSHOP) {
c.fetch_local(&local_clone()?, false)?;
} else {
c.fetch(false)?;
}
}
println!("all corpora present and verified under {}", root().join("corpus").display());
Ok(())
}
fn status(present: bool) -> &'static str {
if present { "present" } else { "missing" }
}
fn list() {
let corpus = root().join("corpus");
let png = corpus.join("pngsuite");
let png_ok = pngsuite_count(&png) > 0;
println!(
"Test corpora live under {} (gitignored, never committed). The corpus tests are opt-in
(cargo feature `corpus`) and fail, not skip, when a corpus is missing. CI always runs them.
cargo xtask corpus --all fetch every corpus below that is missing or stale (sha256-verified)
cargo xtask test-corpus fetch, then run every corpus test (--release --features corpus)
corpus/photoshop/ [{}] our Photoshop-authored oracles (MIT OR Apache-2.0), from
https://github.com/storytold/photocraft-corpus at {PHOTOCRAFT_CORPUS_COMMIT}
manifest xtask/photoshop-corpus.sha256. Fetch: --photoshop
(--photoshop --local copies from ../photocraft-corpus or $PHOTOCRAFT_CORPUS_REPO)
corpus/psd/ [{}] 170 small psd-tools + ag-psd files (MIT), psd-tools@{} and
ag-psd@{}, manifest xtask/psd-corpus.sha256. Fetch: --psd
corpus/psd-tools/ [{}] the full psd-tools test set (MIT) at {PSD_TOOLS_COMMIT}
manifest xtask/psd-tools-corpus.sha256. Fetch: --psd-tools
corpus/pngsuite/ [{}] PngSuite (public domain), {PNGSUITE_URL}. Fetch: --pngsuite
corpus/tiff/, corpus/exr/, corpus/raw/ optional, copied in by hand
Pins: xtask/src/corpus_pins.rs. Moving one: change it, then --<name> --update-manifest.",
corpus.display(),
status(corpus_pins::PHOTOSHOP.is_current()),
status(corpus_pins::PSD_MIXED.is_current()),
&PSD_TOOLS_COMMIT[..12],
&AG_PSD_COMMIT[..12],
status(corpus_pins::PSD_TOOLS.is_current()),
status(png_ok),
);
}
fn pngsuite_count(dir: &std::path::Path) -> usize {
std::fs::read_dir(dir).map(|d| d.flatten().filter(|e| e.path().extension().is_some_and(|x| x == "png")).count()).unwrap_or(0)
}
/// PngSuite: 175 PNGs from a fixed release archive (skipped when already present).
fn fetch_pngsuite() -> Result<(), String> {
let corpus = root().join("corpus");
let dest = corpus.join("pngsuite");
let have = pngsuite_count(&dest);
if have >= 175 {
println!("pngsuite: {have} PNG files in {} already", dest.display());
return Ok(());
}
std::fs::create_dir_all(&dest).map_err(|e| format!("create {}: {e}", dest.display()))?;
let tgz = corpus.join("PngSuite-2017jul19.tgz");
let mut curl = Command::new("curl");
curl.args(["-fsSL", "--retry", "3", "-A", USER_AGENT, "-o"]).arg(&tgz).arg(PNGSUITE_URL);
run(curl, &format!("curl {PNGSUITE_URL}"))?;
let mut tar = Command::new("tar");
tar.arg("-xzf").arg(&tgz).arg("-C").arg(&dest);
run(tar, "tar -xzf PngSuite-2017jul19.tgz")?;
let _ = std::fs::remove_file(&tgz);
let n = pngsuite_count(&dest);
println!("pngsuite: {n} PNG files in {}", dest.display());
if n == 0 { Err("no PNG files extracted".into()) } else { Ok(()) }
}
/// Files changed relative to `origin/main` (committed, staged or not).
fn changed_files() -> Result<Vec<String>, String> {
let out = Command::new("git").current_dir(root()).args(["diff", "--name-only", "origin/main"]).output().map_err(|e| format!("git diff: {e}"))?;
if !out.status.success() {
return Err(format!("git diff origin/main failed: {}", String::from_utf8_lossy(&out.stderr)));
}
Ok(String::from_utf8_lossy(&out.stdout).lines().map(str::to_string).collect())
}
/// `cargo xtask test-corpus [-p <crate>]... [--changed] [--local] [-- <test args>]`
pub fn test_cmd(args: &[&str]) -> Result<(), String> {
let (ours, passthrough) = match args.iter().position(|a| *a == "--") {
Some(i) => (&args[..i], &args[i + 1..]),
None => (args, &[][..]),
};
if ours.contains(&"--changed") {
let changed = changed_files()?;
let hits: Vec<&String> = changed.iter().filter(|f| CRITICAL.iter().any(|c| f.starts_with(c))).collect();
if hits.is_empty() {
println!("test-corpus --changed: nothing under {} changed since origin/main; skipping", CRITICAL.join(", "));
return Ok(());
}
println!("test-corpus --changed: {} critical files changed (e.g. {}); running the corpus tests", hits.len(), hits[0]);
}
let mut crates: Vec<String> = Vec::new();
let mut it = ours.iter();
while let Some(a) = it.next() {
match *a {
"-p" | "--package" => {
let name = it.next().ok_or("-p needs a crate name")?;
let full = if name.starts_with("photocraft-") { (*name).to_string() } else { format!("photocraft-{name}") };
if !CORPUS_CRATES.contains(&full.as_str()) {
return Err(format!("{full} has no corpus tests (crates: {})", CORPUS_CRATES.join(", ")));
}
crates.push(full);
}
"--changed" | "--local" => {}
other => return Err(format!("test-corpus: unknown argument `{other}`")),
}
}
if crates.is_empty() {
crates = CORPUS_CRATES.iter().map(|s| (*s).to_string()).collect();
}
fetch_all(ours.contains(&"--local"))?;
let mut c = cargo();
c.args(["test", "--release", "--lib", "--tests"]);
for k in &crates {
c.args(["-p", k]);
}
let features: Vec<String> = crates.iter().map(|k| format!("{k}/corpus")).collect();
c.args(["--features", &features.join(",")]);
if !passthrough.is_empty() {
c.arg("--").args(passthrough);
}
run(c, &format!("cargo test --release --features corpus ({})", crates.join(", ")))
}
+96
View File
@@ -0,0 +1,96 @@
//! **Every test-corpus pin lives here.** Real-file corpora are never committed to this
//! repository: `cargo xtask corpus --all` fetches them into `corpus/` (gitignored) at the commits
//! below and verifies each file against the `xtask/*.sha256` manifests. The CI corpus job's cache
//! key hashes this file and the manifests, so changing a pin is the only thing that refetches.
//!
//! - Our own oracles: https://github.com/storytold/photocraft-corpus (README, AGENTS.md).
//! - Third-party sets come from their upstreams (licences in ATTRIBUTION.md).
//!
//! Moving a pin: change the commit, run `cargo xtask corpus --<name> --update-manifest`, review the
//! manifest diff, and re-run `cargo xtask test-corpus` (adjust floors only upwards).
use crate::pinned::{PinnedCorpus, Upstream};
/// https://github.com/storytold/photocraft-corpus: our Photoshop-authored oracle PSDs
/// (`photoshop/`). Bump through a PR after committing there.
pub const PHOTOCRAFT_CORPUS_COMMIT: &str = "16427e2078634493e27cdc4a55b4d4786c49e575";
/// https://github.com/psd-tools/psd-tools (MIT): `tests/psd_files` (main, 2026-10-05).
pub const PSD_TOOLS_COMMIT: &str = "96eb134c17b2c65edf4c4151c0f00b802ada86c2";
/// https://github.com/Agamnentzar/ag-psd (MIT): `test/` (master, 2026-07-02).
pub const AG_PSD_COMMIT: &str = "387049670cb89b88fb8fe1b7c01aeacf98dd2e3b";
/// PngSuite (public domain), a fixed release archive.
pub const PNGSUITE_URL: &str = "http://www.schaik.com/pngsuite/PngSuite-2017jul19.tgz";
/// `corpus/photoshop`: 256 PSDs authored with Photoshop by the photocraft-corpus generator.
pub const PHOTOSHOP: PinnedCorpus = PinnedCorpus {
name: "photoshop",
dest: "photoshop",
upstreams: &[Upstream {
prefix: "",
repo: "storytold/photocraft-corpus",
commit: PHOTOCRAFT_CORPUS_COMMIT,
subdir: "photoshop",
extras: &[("README.md", "README.md"), ("LICENSE-MIT", "LICENSE-MIT"), ("LICENSE-APACHE", "LICENSE-APACHE")],
}],
manifest: "xtask/photoshop-corpus.sha256",
exts: &["psd", "psb"],
subset: false,
sources_md: |c| {
format!(
"# Photoshop oracle corpus\n\n`photoshop/` of https://github.com/storytold/photocraft-corpus at commit {PHOTOCRAFT_CORPUS_COMMIT} \
(see `README.md`). MIT OR Apache-2.0, authored by the PhotoCraft contributors. Fetched and sha256-verified by \
`cargo xtask corpus --photoshop` against `{}`. Gitignored; never commit these files.\n",
c.manifest
)
},
};
/// `corpus/psd-tools`: the complete psd-tools test set.
pub const PSD_TOOLS: PinnedCorpus = PinnedCorpus {
name: "psd-tools",
dest: "psd-tools",
upstreams: &[Upstream { prefix: "", repo: "psd-tools/psd-tools", commit: PSD_TOOLS_COMMIT, subdir: "tests/psd_files", extras: &[("LICENSE", "LICENSE")] }],
manifest: "xtask/psd-tools-corpus.sha256",
exts: &["psd", "psb"],
subset: false,
sources_md: |c| {
format!(
"# psd-tools test corpus\n\nThe PSD/PSB files of https://github.com/psd-tools/psd-tools/tree/{PSD_TOOLS_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 `{}`. Gitignored; never commit these files.\n",
c.manifest
)
},
};
/// `corpus/psd`: the hand-picked mix of small psd-tools and ag-psd files (170) that most PSD
/// tests use. The manifest selects the files.
pub const PSD_MIXED: PinnedCorpus = PinnedCorpus {
name: "psd",
dest: "psd",
upstreams: &[
Upstream {
prefix: "psd-tools/",
repo: "psd-tools/psd-tools",
commit: PSD_TOOLS_COMMIT,
subdir: "tests/psd_files",
extras: &[("LICENSE", "psd-tools/LICENSE")],
},
Upstream { prefix: "ag-psd/", repo: "Agamnentzar/ag-psd", commit: AG_PSD_COMMIT, subdir: "test", extras: &[("LICENSE", "ag-psd/LICENSE")] },
],
manifest: "xtask/psd-corpus.sha256",
exts: &["psd", "psb"],
subset: true,
sources_md: |c| {
format!(
"# PSD test corpus (mixed)\n\nSmall PSD/PSB files selected from https://github.com/psd-tools/psd-tools/tree/{PSD_TOOLS_COMMIT}/tests/psd_files \
(`psd-tools/`, MIT, Copyright (c) 2019 Kota Yamaguchi) and https://github.com/Agamnentzar/ag-psd/tree/{AG_PSD_COMMIT}/test \
(`ag-psd/`, MIT, Copyright (c) 2016 Agamnentzar), unmodified; licences next to them. Selected by and sha256-verified \
against `{}` by `cargo xtask corpus --psd`. Gitignored; never commit these files.\n",
c.manifest
)
},
};
/// Every pinned corpus, in fetch order.
pub const ALL: &[&PinnedCorpus] = &[&PSD_MIXED, &PSD_TOOLS, &PHOTOSHOP];
+13 -46
View File
@@ -3,10 +3,12 @@
//! Pure Rust (std + serde_json). External tools (`cargo`, `curl`, `tar`) are
//! invoked through `std::process::Command`.
mod corpus;
mod corpus_pins;
mod ico;
mod layers;
mod perf;
mod psd_tools;
mod pinned;
mod scorecard;
mod sha256;
mod stats;
@@ -22,10 +24,14 @@ 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] [--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
corpus [--all | --pngsuite | --psd | --psd-tools | --photoshop] [--local] [--update-manifest]
show where test corpora live and their pins (xtask/src/corpus_pins.rs), or fetch
them into corpus/ (pinned commits, sha256-verified; --all = every corpus;
--photoshop --local copies from ../photocraft-corpus or $PHOTOCRAFT_CORPUS_REPO)
test-corpus [-p <crate>]... [--changed] [--local] [-- <test args>]
fetch every corpus, then cargo test --release --features corpus on the corpus
crates; --changed runs only if psd/io/codecs/compose/gpu/text/format changed;
--local takes corpus/photoshop from the photocraft-corpus authoring clone
stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`)
parity Photoshop menu parity; rewrites docs/parity.md
perf [--quick] [--update-baseline] [--threshold PCT] [--bench NAME]... [--skip-build] [--reuse]
@@ -46,8 +52,8 @@ 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("corpus") => corpus::cmd(&rest),
Some("test-corpus") => corpus::test_cmd(&rest),
Some("stats") => stats::run(&root(), rest.contains(&"--exact")),
Some("parity") => cmd_parity(),
Some("perf") => perf::run(&root(), &rest),
@@ -215,42 +221,3 @@ fn cmd_ci() -> Result<(), String> {
println!("\nCI summary: all {} steps passed ({})", done.len(), done.join(", "));
Ok(())
}
const PNGSUITE_URL: &str = "http://www.schaik.com/pngsuite/PngSuite-2017jul19.tgz";
fn cmd_corpus(download: bool) -> Result<(), String> {
let corpus = root().join("corpus");
println!(
"Test corpora live under {} (git-ignored, never committed).
Tests that use a corpus skip cleanly when it is absent.
corpus/pngsuite/ PngSuite (public domain) — photocraft-codecs compares every file
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)
",
corpus.display()
);
if !download {
return Ok(());
}
let dest = corpus.join("pngsuite");
std::fs::create_dir_all(&dest).map_err(|e| format!("create {}: {e}", dest.display()))?;
let tgz = corpus.join("PngSuite-2017jul19.tgz");
let mut curl = Command::new("curl");
curl.args(["-fsSL", "-o"]).arg(&tgz).arg(PNGSUITE_URL);
run(curl, &format!("curl {PNGSUITE_URL}"))?;
let mut tar = Command::new("tar");
tar.arg("-xzf").arg(&tgz).arg("-C").arg(&dest);
run(tar, "tar -xzf PngSuite-2017jul19.tgz")?;
let _ = std::fs::remove_file(&tgz);
let n = std::fs::read_dir(&dest).map(|d| d.flatten().filter(|e| e.path().extension().is_some_and(|x| x == "png")).count()).unwrap_or(0);
println!("PngSuite: {n} PNG files in {}", dest.display());
if n == 0 { Err("no PNG files extracted".into()) } else { Ok(()) }
}
+265
View File
@@ -0,0 +1,265 @@
//! Shared fetcher for test corpora pinned to git commits and verified against a committed sha256
//! manifest (`<sha256> <relative path>` per line). The pins and corpus definitions live in
//! `corpus_pins.rs`. Corpora land in `corpus/<dest>/` (gitignored, never committed).
//!
//! Fetching downloads the GitHub tarball (`codeload`) of each pinned commit with a generic
//! User-Agent, extracts only the corpus subdirectory, checks every file against the manifest and
//! swaps the verified files into place. An already complete, verified copy is left alone, so
//! re-running is cheap (and a CI cache restore is verified the same way).
//! `--update-manifest` rewrites the manifest from the download (after moving a pin).
//! Local mode copies from a working clone instead of downloading (see `fetch_local`).
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::{root, run, sha256};
/// Generic User-Agent: no personal details in requests.
pub const USER_AGENT: &str = "Photocraft-dev";
/// One upstream repository contributing files to a corpus.
pub struct Upstream {
/// Path prefix of its files inside the corpus directory (`""` or `"ag-psd/"`).
pub prefix: &'static str,
/// GitHub `owner/repo`.
pub repo: &'static str,
/// Pinned commit.
pub commit: &'static str,
/// Directory holding the corpus files inside the repository.
pub subdir: &'static str,
/// Extra files copied into the corpus: (path inside the repository, path in the corpus).
pub extras: &'static [(&'static str, &'static str)],
}
pub struct PinnedCorpus {
/// Name used in messages and on the command line (`psd-tools`, `photoshop`).
pub name: &'static str,
/// Destination under `corpus/`.
pub dest: &'static str,
pub upstreams: &'static [Upstream],
/// Manifest path relative to the workspace root.
pub manifest: &'static str,
/// File extensions that make up the corpus (case-insensitive).
pub exts: &'static [&'static str],
/// True when the manifest selects a subset of the upstream files: `--update-manifest` then
/// re-hashes the listed files only.
pub subset: bool,
/// `SOURCES.md` written into `dest`.
pub sources_md: fn(&PinnedCorpus) -> String,
}
type Entries = Vec<(String, String)>;
impl PinnedCorpus {
pub fn dest_dir(&self) -> PathBuf {
root().join("corpus").join(self.dest)
}
/// `(sha256, relative path)` pairs from the manifest.
fn manifest_entries(&self) -> Result<Entries, String> {
let path = root().join(self.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!("{}: bad line {l:?}", self.manifest))?;
if hash.len() != 64 || file.contains("..") || file.starts_with('/') {
return Err(format!("{}: bad line {l:?}", self.manifest));
}
Ok((hash.to_string(), file.to_string()))
})
.collect()
}
fn collect(&self, 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() {
self.collect(&p, base, out);
} else if p.extension().and_then(|e| e.to_str()).is_some_and(|e| self.exts.iter().any(|x| e.eq_ignore_ascii_case(x))) {
out.push(p.strip_prefix(base).unwrap_or(&p).to_path_buf());
}
}
}
/// Hashes every corpus file under `dir` (paths prefixed with `prefix`).
fn scan(&self, dir: &Path, prefix: &str) -> Result<Entries, String> {
let mut found = Vec::new();
self.collect(dir, dir, &mut found);
let mut out = Vec::new();
for f in found {
let rel = f.to_string_lossy().replace('\\', "/");
let bytes = std::fs::read(dir.join(&f)).map_err(|e| format!("read {rel}: {e}"))?;
out.push((sha256::hex(&bytes), format!("{prefix}{rel}")));
}
Ok(out)
}
fn write_manifest(&self, files: &mut Entries) -> Result<(), String> {
files.sort_by(|a, b| a.1.cmp(&b.1));
let lines: String = files.iter().map(|(h, f)| format!("{h} {f}\n")).collect();
std::fs::write(root().join(self.manifest), lines).map_err(|e| format!("write {}: {e}", self.manifest))?;
println!("{}: wrote {} entries to {}", self.name, files.len(), self.manifest);
Ok(())
}
/// Entries of `files` that are missing or wrong when looked up through `locate`.
fn mismatches(files: &[(String, String)], locate: impl Fn(&str) -> Option<PathBuf>) -> Vec<String> {
files
.iter()
.filter_map(|(hash, file)| match locate(file).map(std::fs::read) {
Some(Ok(bytes)) if sha256::hex(&bytes) == *hash => None,
Some(Ok(_)) => Some(format!("{file}: sha256 mismatch")),
_ => Some(format!("{file}: missing")),
})
.collect()
}
/// True when `corpus/<dest>` exists and every manifest entry is present and matches.
pub fn is_current(&self) -> bool {
let dest = self.dest_dir();
dest.is_dir() && self.manifest_entries().is_ok_and(|files| Self::mismatches(&files, |f| Some(dest.join(f))).is_empty())
}
/// Replaces `dest` with the files (from `locate`) plus extras, moved or copied.
fn install(
&self,
files: &[(String, String)],
locate: impl Fn(&str) -> Option<PathBuf>,
extras: &[(PathBuf, String)],
copy: bool,
note: &str,
) -> Result<(), String> {
let dest = self.dest_dir();
let _ = std::fs::remove_dir_all(&dest);
let place = |from: &Path, to: &Path| -> Result<(), String> {
if let Some(parent) = to.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("create {}: {e}", parent.display()))?;
}
if copy {
std::fs::copy(from, to).map(|_| ()).map_err(|e| format!("copy {}: {e}", from.display()))
} else {
std::fs::rename(from, to).map_err(|e| format!("move {}: {e}", from.display()))
}
};
for (_, file) in files {
let from = locate(file).ok_or_else(|| format!("{file}: no upstream"))?;
place(&from, &dest.join(file))?;
}
for (from, to) in extras {
if from.is_file() {
place(from, &dest.join(to))?;
}
}
std::fs::write(dest.join("SOURCES.md"), (self.sources_md)(self)).map_err(|e| format!("write SOURCES.md: {e}"))?;
println!("{}: {} files in {} ({note})", self.name, files.len(), dest.display());
Ok(())
}
/// Downloads the pinned commits and installs the verified files (no-op when already current).
pub fn fetch(&self, update_manifest: bool) -> Result<(), String> {
if !update_manifest && self.is_current() {
println!("{}: {} already matches {}", self.name, self.dest_dir().display(), self.manifest);
return Ok(());
}
let corpus = root().join("corpus");
let staging = corpus.join(format!(".{}-download", self.dest));
let _ = std::fs::remove_dir_all(&staging);
std::fs::create_dir_all(&staging).map_err(|e| format!("create {}: {e}", staging.display()))?;
// (upstream, extracted corpus dir, extracted repo root)
let mut roots = Vec::new();
for (i, up) in self.upstreams.iter().enumerate() {
let tgz = staging.join(format!("archive{i}.tar.gz"));
let url = format!("https://codeload.github.com/{}/tar.gz/{}", up.repo, up.commit);
let mut curl = Command::new("curl");
curl.args(["-fsSL", "--retry", "3", "-A", USER_AGENT, "-o"]).arg(&tgz).arg(&url);
run(curl, &format!("curl {url}"))?;
let repo_name = up.repo.rsplit('/').next().unwrap_or(up.repo);
let top = format!("{repo_name}-{}", up.commit);
let out = staging.join(format!("u{i}"));
std::fs::create_dir_all(&out).map_err(|e| format!("create {}: {e}", out.display()))?;
let mut tar = Command::new("tar");
tar.arg("-xzf").arg(&tgz).arg("-C").arg(&out).arg(format!("{top}/{}", up.subdir));
for (from, _) in up.extras {
tar.arg(format!("{top}/{from}"));
}
run(tar, &format!("tar -xzf {}", tgz.display()))?;
let _ = std::fs::remove_file(&tgz);
let repo_root = out.join(&top);
roots.push((up, repo_root.join(up.subdir), repo_root));
}
let locate = |file: &str| -> Option<PathBuf> {
// Longest matching prefix wins.
roots
.iter()
.filter(|(up, _, _)| file.starts_with(up.prefix))
.max_by_key(|(up, _, _)| up.prefix.len())
.map(|(up, dir, _)| dir.join(&file[up.prefix.len()..]))
};
let mut files = if update_manifest && !self.subset {
let mut all = Vec::new();
for (up, dir, _) in &roots {
all.extend(self.scan(dir, up.prefix)?);
}
all
} else {
self.manifest_entries()?
};
if update_manifest {
if self.subset {
// Re-hash the selected files at the new pins.
for (hash, file) in files.iter_mut() {
let bytes = locate(file).map(std::fs::read).and_then(Result::ok).ok_or_else(|| format!("{file}: missing upstream"))?;
*hash = sha256::hex(&bytes);
}
}
self.write_manifest(&mut files)?;
}
let bad = Self::mismatches(&files, locate);
if !bad.is_empty() {
return Err(format!("{} download does not match {} ({} problems): {}", self.name, self.manifest, bad.len(), bad.join(", ")));
}
let extras: Vec<(PathBuf, String)> =
roots.iter().flat_map(|(up, _, repo_root)| up.extras.iter().map(move |(from, to)| (repo_root.join(from), (*to).to_string()))).collect();
let pins: Vec<String> = self.upstreams.iter().map(|u| format!("{}@{}", u.repo, &u.commit[..12.min(u.commit.len())])).collect();
self.install(&files, locate, &extras, false, &format!("verified; {}", pins.join(", ")))?;
let _ = std::fs::remove_dir_all(&staging);
Ok(())
}
/// Local development mode for a single-upstream corpus: copies it from a working clone of the
/// upstream repository instead of downloading, so regenerated files can be tested before they
/// are pushed and pinned. Warns (does not fail) when the clone's HEAD is not the pin or its
/// files differ from the manifest; `update_manifest` rewrites the manifest from the clone.
pub fn fetch_local(&self, clone: &Path, update_manifest: bool) -> Result<(), String> {
let [up] = self.upstreams else { return Err(format!("{}: local mode needs a single-upstream corpus", self.name)) };
let src = clone.join(up.subdir);
if !src.is_dir() {
return Err(format!("{}: {} is not a directory", self.name, src.display()));
}
let head = Command::new("git").arg("-C").arg(clone).args(["rev-parse", "HEAD"]).output().ok().filter(|o| o.status.success());
match head.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) {
Some(h) if h == up.commit => println!("{}: {} is at the pinned commit {h}", self.name, clone.display()),
Some(h) => eprintln!("warning: {}: {} is at {h}, not the pinned commit {}: tests will see unpinned files", self.name, clone.display(), up.commit),
None => eprintln!("warning: {}: could not read the git HEAD of {}", self.name, clone.display()),
}
let mut found = self.scan(&src, up.prefix)?;
found.sort_by(|a, b| a.1.cmp(&b.1));
if update_manifest {
self.write_manifest(&mut found)?;
} else {
let pinned = self.manifest_entries()?;
if pinned != found {
let changed = found.iter().filter(|f| !pinned.contains(f)).count();
let gone = pinned.iter().filter(|p| !found.iter().any(|f| f.1 == p.1)).count();
eprintln!(
"warning: {}: the clone differs from {} ({changed} new or changed, {gone} missing); after pushing it, bump the pin and run with --update-manifest",
self.name, self.manifest
);
}
}
let extras: Vec<(PathBuf, String)> = up.extras.iter().map(|(from, to)| (clone.join(from), (*to).to_string())).collect();
self.install(&found, |f| Some(src.join(&f[up.prefix.len()..])), &extras, true, &format!("copied from the local clone {}", clone.display()))
}
}
-128
View File
@@ -1,128 +0,0 @@
//! `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(())
}
+7 -3
View File
@@ -139,7 +139,7 @@ pub fn parse_corpus(src: &str) -> Vec<Corpus> {
let v = v.trim().trim_matches('"').to_string();
match k.trim() {
"label" => c.label = v,
"default_dir" => c.dir = v,
"dir" | "default_dir" => c.dir = v,
"pass_floor" => c.pass_floor = v.parse().ok(),
"roundtrip_floor" => c.roundtrip_floor = v.parse().ok(),
_ => {}
@@ -589,7 +589,7 @@ rows are targets the code doesn't meet yet: they are reported, and only their re
s.push_str(&format!("\n## {}\n\n", c.title));
match c.area.as_str() {
"file" => {
s.push_str("Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus test fails below them when the corpus is present.\n\n| Corpus | Directory | Files | Oracle pass floor | Round-trip floor |\n|---|---|---:|---:|---:|\n");
s.push_str("Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus tests (cargo feature `corpus`, `cargo xtask test-corpus`, always run in CI) fail below them.\n\n| Corpus | Directory | Files | Oracle pass floor | Round-trip floor |\n|---|---|---:|---:|---:|\n");
for k in &inp.corpora {
let n = |v: Option<u32>| v.map_or_else(|| "–".into(), |x| x.to_string());
s.push_str(&format!("| {} | `{}` | {} | {} | {} |\n", k.label, k.dir, n(k.files), n(k.pass_floor), n(k.roundtrip_floor)));
@@ -776,14 +776,18 @@ const MIXED: Source = Source { label: "io corpus", env: "PHOTOCRAFT_CORPUS", def
/// 202 vs the merged image.
const PSD_TOOLS: Source =
Source { label: "psd-tools corpus", env: "X", default_dir: "corpus/psd-tools", pass_floor: 212, roundtrip_floor: 307, extra: 5 };
/// Ours (256 files; https://github.com/storytold/photocraft-corpus), grouped by feature.
const PHOTOSHOP: Source = Source { label: "photoshop oracles", dir: "corpus/photoshop", group_depth: 2, pass_floor: 74, roundtrip_floor: 256 };
"#;
let c = parse_corpus(src);
assert_eq!(c.len(), 2);
assert_eq!(c.len(), 3);
assert_eq!(
(c[0].label.as_str(), c[0].dir.as_str(), c[0].files, c[0].pass_floor, c[0].roundtrip_floor),
("io corpus", "corpus/psd", Some(170), Some(133), Some(169))
);
assert_eq!((c[1].files, c[1].pass_floor, c[1].roundtrip_floor), (Some(309), Some(212), Some(307)));
assert_eq!((c[2].dir.as_str(), c[2].files, c[2].pass_floor, c[2].roundtrip_floor), ("corpus/photoshop", Some(256), Some(74), Some(256)));
assert!(parse_corpus("const X: u32 = 1;").is_empty());
}