mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-24 01:47:08 -06:00
Photoshop oracles via storytold/photocraft-corpus: pinned corpora, corpus feature, test-corpus, CI job (#190) (#190)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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]| {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 |
|
||||
|---|---|---|---|---|
|
||||
|
||||
Executable
+7
@@ -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 "$@"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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(", ")))
|
||||
}
|
||||
@@ -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
@@ -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(()) }
|
||||
}
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user