Merge pull request #2945 from diegochagas/feat/xcf-read

Open GIMP XCF files: layers, groups, masks, blend modes, channels and selection
This commit is contained in:
skylar taylor-barrick
2026-10-21 14:35:32 -06:00
committed by GitHub
17 changed files with 2305 additions and 25 deletions
+2 -2
View File
@@ -19,7 +19,7 @@ PhotoCraft is an open-source, native, Photoshop-comparable image editor written
| `docs/parity-checklist.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` |
| [photocraft-corpus](https://github.com/storytold/photocraft-corpus) + `docs/development.md` › Test corpora | Real-file test oracles (our Photoshop-authored PSDs and GIMP-authored XCFs); 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
@@ -107,7 +107,7 @@ cargo xtask test-corpus # if you touched psd, io, codecs, compose, gpu, text
While iterating, run less and keep the full commands above as the final gate: `cargo test -p <crate> --lib --tests` skips the examples and doctests plain `cargo test` builds every time (ui-egui has 11 examples), and `cargo test -p <crate> --test <file>` runs one integration-test file. `cargo nextest run` runs tests in parallel across binaries; see `docs/development.md` › Faster test loops.
**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.
**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- and GIMP-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).
+1
View File
@@ -110,6 +110,7 @@ next to the files:
| 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/gimp/` (28 files, 251 KB) | GIMP XCF oracles: 40×30 procedural layers written by GIMP 3.2.6 through the corpus generator `tools/gimp-xcf/xcf_fixtures.py`, each with GIMP's merged rendering as a PNG | PhotoCraft contributors (authored with GIMP 3.2.6 by a script) | [https://github.com/storytold/photocraft-corpus](https://github.com/storytold/photocraft-corpus) (`gimp/`, 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/heif/` (9 files, 0.1 MB) | HEIC/HEIF test files: `heic-rs/` checkerboards, RGB strips and a grid-tiled photo with EXIF and XMP (synthetic pixels encoded by macOS `sips`, each `.ref.png` Apple's decode); `pillow-heif/` the 10-bit RGBA `RGBA_10__29x100.heif` and its source `RGBA_16__29x100.png` | Thomas Braun (heic-rs); Pillow-Heif contributors | [heic-rs `tests/fixtures`](https://github.com/tbraun96/heic-rs/tree/main/tests/fixtures), [pillow-heif `tests/images`](https://github.com/bigcat88/pillow_heif/tree/master/tests/images) | MIT OR Apache-2.0 (heic-rs); BSD-3-Clause (pillow-heif) |
+1 -1
View File
@@ -202,7 +202,7 @@ Every screenshot here is the real app at work on public-domain art, rendered off
</td>
<td width="33%" valign="top">
<h4>🗂️ Formats</h4>
PSD and PSB, layered TIFF (Photoshop's layer data in the TIFF, read and written in either byte order), <a href="docs/pdn.md">Paint.NET PDN3 (read, with editable layers and all 14 blend modes)</a>, <a href="docs/ora.md">OpenRaster (read and write, with layers, groups and blend modes, for Krita, MyPaint and GIMP)</a>, <a href="docs/kra.md">Krita .kra (read, with paint layers, groups, blend modes and transparency masks)</a>, SVG (opens as shape layers, places as a vector Smart Object), plus flat PNG, JPEG, TIFF, WebP (lossy and lossless), GIF, BMP, TGA, ICO, QOI, PNM, OpenEXR, Radiance HDR and FITS (astronomy images: 8/16-bit and 32-bit float, gray or RGB, header kept), and the native <code>.pcraft</code> format. Flat formats retain the depths they support: PNG keeps 8/16-bit samples, TIFF keeps 8/16-bit and 32-bit float samples, and OpenEXR keeps half/float samples; JPEG, WebP and the other 8-bit formats reduce higher-depth samples with an export warning. See the <a href="crates/codecs/README.md#capability-matrix-default-build">codec capability matrix</a> for each format. HEIC photos from iPhone and Mac are read-only (included in official builds; optional <code>--features heif</code> for source builds). AVIF is write-only with the optional <code>avif</code> build feature; AVIF decoding and JPEG XL are unavailable. Affinity documents (<code>.af</code>, <code>.afdesign</code>, <code>.afphoto</code>, <code>.afpub</code>) open natively, read only, with artboards, layers, vectors, text, images and masks; effects, adjustments and other unsupported parts are listed in a warning, and Affinity export is unavailable.
PSD and PSB, layered TIFF (Photoshop's layer data in the TIFF, read and written in either byte order), <a href="docs/pdn.md">Paint.NET PDN3 (read, with editable layers and all 14 blend modes)</a>, <a href="docs/ora.md">OpenRaster (read and write, with layers, groups and blend modes, for Krita, MyPaint and GIMP)</a>, <a href="docs/xcf.md">GIMP XCF (read, with layers, groups, masks, blend modes, channels and the selection)</a>, <a href="docs/kra.md">Krita .kra (read, with paint layers, groups, blend modes and transparency masks)</a>, SVG (opens as shape layers, places as a vector Smart Object), plus flat PNG, JPEG, TIFF, WebP (lossy and lossless), GIF, BMP, TGA, ICO, QOI, PNM, OpenEXR, Radiance HDR and FITS (astronomy images: 8/16-bit and 32-bit float, gray or RGB, header kept), and the native <code>.pcraft</code> format. Flat formats retain the depths they support: PNG keeps 8/16-bit samples, TIFF keeps 8/16-bit and 32-bit float samples, and OpenEXR keeps half/float samples; JPEG, WebP and the other 8-bit formats reduce higher-depth samples with an export warning. See the <a href="crates/codecs/README.md#capability-matrix-default-build">codec capability matrix</a> for each format. HEIC photos from iPhone and Mac are read-only (included in official builds; optional <code>--features heif</code> for source builds). AVIF is write-only with the optional <code>avif</code> build feature; AVIF decoding and JPEG XL are unavailable. Affinity documents (<code>.af</code>, <code>.afdesign</code>, <code>.afphoto</code>, <code>.afpub</code>) open natively, read only, with artboards, layers, vectors, text, images and masks; effects, adjustments and other unsupported parts are listed in a warning, and Affinity export is unavailable.
</td>
<td width="33%" valign="top">
<h4>🪄 The everyday essentials</h4>
+3 -3
View File
@@ -19,9 +19,9 @@ type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
/// Everything File › Open reads: PhotoCraft, Photoshop, OpenRaster, Krita and Affinity documents, flat images, and
/// Photoshop brushes (.abr), gradients (.grd) and swatches (.aco, .ase), which go to the preset libraries.
const OPEN_EXTS: &[&str] = &[
"pcraft", "pdn", "ora", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr",
"pbm", "pgm", "ppm", "pam", "pfm", "dds", "fits", "fit", "fts", "dpx", "cin", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef",
"orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub",
"pcraft", "pdn", "ora", "xcf", "xcfgz", "gz", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico",
"qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm", "dds", "fits", "fit", "fts", "dpx", "cin", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef",
"nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub",
];
const SVG_EXTS: &[&str] = &["svg", "svgz"];
const CANVAS_ID: &str = "photocraft_canvas";
+4 -3
View File
@@ -20,9 +20,9 @@ mod recovery_tests;
/// Photoshop brushes (.abr), gradients (.grd), swatches (.aco, .ase) and keyboard shortcut sets
/// (.kys), which go to the preset libraries and Edit › Keyboard Shortcuts.
const OPEN_EXTS: &[&str] = &[
"pcraft", "pdn", "ora", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr",
"pbm", "pgm", "ppm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "cin", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw",
"arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub",
"pcraft", "pdn", "ora", "xcf", "xcfgz", "gz", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico",
"qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "cin", "heic", "heif", "hif", "dng", "cr2",
"cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub",
];
const SVG_EXTS: &[&str] = &["svg", "svgz"];
@@ -45,6 +45,7 @@ const OPEN_FILTERS: &[(&str, &[&str])] = &[
("Photoshop", &["psd", "psb", "psdt"]),
("Affinity", &["af", "afdesign", "afphoto", "afpub"]),
("Krita", &["kra", "krz"]),
("GIMP", &["xcf", "xcfgz", "gz"]),
("OpenRaster", &["ora"]),
("Paint.NET", &["pdn"]),
("SVG", SVG_EXTS),
+12 -4
View File
@@ -105,7 +105,7 @@ pub fn list_images(dir: &str) -> Result<Vec<String>> {
let mut out: Vec<String> = rd
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.is_file() && p.extension().and_then(|x| x.to_str()).is_some_and(|x| OPENABLE.contains(&x.to_ascii_lowercase().as_str())))
.filter(|p| p.is_file() && openable_name(&p.to_string_lossy()))
.map(|p| p.to_string_lossy().into_owned())
.collect();
out.sort();
@@ -116,12 +116,20 @@ pub fn list_images(dir: &str) -> Result<Vec<String>> {
Err(EngineError::Other(format!("cannot list {dir}: no file system on the web")))
}
/// Whether a folder entry is one the batch commands open: an `OPENABLE` extension, or GIMP's
/// gzipped `.xcf.gz`, whose last extension alone says nothing.
#[cfg(not(target_arch = "wasm32"))]
fn openable_name(path: &str) -> bool {
let lower = file_name(path).to_ascii_lowercase();
lower.ends_with(".xcf.gz") || lower.rsplit_once('.').is_some_and(|(_, ext)| OPENABLE.contains(&ext))
}
/// Extensions the batch commands pick up from a folder.
#[cfg(not(target_arch = "wasm32"))]
const OPENABLE: &[&str] = &[
"pdn", "ora", "kra", "krz", "psd", "psb", "pcraft", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "exr", "hdr", "qoi", "ico", "pnm",
"ppm", "pgm", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "nef", "nrw", "arw", "pef", "svg", "svgz", "af", "afdesign", "afphoto",
"afpub",
"pdn", "ora", "xcf", "xcfgz", "kra", "krz", "psd", "psb", "pcraft", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "exr", "hdr", "qoi",
"ico", "pnm", "ppm", "pgm", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "nef", "nrw", "arw", "pef", "svg", "svgz", "af", "afdesign",
"afphoto", "afpub",
];
/// Whether saving `doc` as a TIFF writes Photoshop layer data (anything beyond a lone
+12
View File
@@ -1458,3 +1458,15 @@ fn batch_naming_rejects_outputs_that_replace_inputs_before_processing() {
assert_eq!(std::fs::read(&first).unwrap(), before[0]);
assert_eq!(std::fs::read(&second).unwrap(), before[1]);
}
/// A folder batch picks up GIMP's gzipped `.xcf.gz`, whose last extension alone (`gz`) says nothing.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn folder_listing_recognises_gzipped_xcf_by_its_double_extension() {
assert!(super::openable_name("/scans/page 01.xcf.gz"));
assert!(super::openable_name("C:\\scans\\PAGE.XCF.GZ"));
assert!(super::openable_name("page.xcfgz"));
assert!(super::openable_name("page.PNG"));
assert!(!super::openable_name("archive.tar.gz"));
assert!(!super::openable_name("notes.txt"));
}
+19
View File
@@ -20,6 +20,8 @@
//! opacity and visibility; see `ora`.
//! * Krita (`.kra`, `.krz`) documents open with their paint layers, groups and transparency
//! masks (import only); see `kra`.
//! * GIMP XCF (`.xcf`, `.xcf.gz`) documents open with their layers, groups, masks, blend modes,
//! channels and selection, read only; see `xcf`.
//! * Affinity documents (`.af`, `.afdesign`, `.afphoto`, `.afpub`) open natively
//! with no source save path, what isn't imported listed in the warnings; a file
//! whose native data can't be read opens as its embedded preview; see `affinity`.
@@ -68,6 +70,7 @@ mod text_import;
pub mod text_styles_map;
pub mod tiff_layers;
pub mod vector_map;
mod xcf;
use photocraft_codecs::{CodecError, EncodeOptions};
pub use photocraft_codecs::{DdsCompression, DdsOptions, DpxBits};
@@ -94,6 +97,9 @@ pub enum IoError {
/// OpenRaster read failure.
#[error("OpenRaster: {0}")]
Ora(String),
/// A GIMP XCF file that could not be read.
#[error("XCF: {0}")]
Xcf(String),
/// PSD parse/write failure.
#[error("PSD: {0}")]
Psd(#[from] PsdError),
@@ -337,6 +343,14 @@ fn import_stages(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt, m
if kra::is_kra(bytes) || (kra::EXTENSIONS.iter().any(|e| has_extension(name, e)) && photocraft_codecs::detect(bytes).is_none()) {
return kra::import(name, bytes, ctl);
}
// GIMP files are gzip or raw; as above, a recognized raster signature still wins over a
// misleading .xcf suffix.
if xcf::is_xcf(bytes) || (has_extension(name, "xcf") && photocraft_codecs::detect(bytes).is_none()) {
return xcf::import(name, bytes, ctl);
}
if xcf::is_gzipped_xcf(name, bytes) {
return xcf::import(name, &xcf::gunzip(bytes)?, ctl);
}
if has_extension(name, "pdn") || bytes.starts_with(b"PDN3") {
return pdn::import(name, bytes, ctl);
}
@@ -429,6 +443,11 @@ fn export_stages(doc: &Document, name_or_ext: &str, opts: &ExportOptions, ctl: &
if kra::EXTENSIONS.contains(&ext.as_str()) {
return Err(IoError::Unsupported("Krita files are import-only; save as .ora to open the layers in Krita, or .pcraft to keep everything".into()));
}
if ext == "xcf" {
return Err(IoError::Unsupported(
"XCF is GIMP's own format and PhotoCraft only opens it; save as .pcraft to keep the layers, or export a flat image".into(),
));
}
if ext == "ora" {
return ora::export(doc);
}
+1018
View File
File diff suppressed because it is too large Load Diff
+704
View File
@@ -0,0 +1,704 @@
//! Synthetic XCF files (written here, from the specification) for the reader: structure,
//! every compression, precisions, groups, masks, channels, limits and malformed input.
use super::*;
use photocraft_raster::Interrupt;
/// A small XCF writer for tests: version 11+ layout (64-bit pointers) by default.
pub(super) struct W {
pub out: Vec<u8>,
pub wide: bool,
}
impl W {
pub fn new(version: u32) -> Self {
let mut w = W { out: Vec::new(), wide: version >= 11 };
w.out.extend_from_slice(b"gimp xcf ");
if version == 0 {
w.out.extend_from_slice(b"file\0");
} else {
w.out.extend_from_slice(format!("v{version:03}\0").as_bytes());
}
w
}
pub fn u32(&mut self, v: u32) {
self.out.extend_from_slice(&v.to_be_bytes());
}
pub fn ptr(&mut self, v: usize) {
if self.wide {
self.out.extend_from_slice(&(v as u64).to_be_bytes());
} else {
self.u32(v as u32);
}
}
pub fn string(&mut self, s: &str) {
if s.is_empty() {
self.u32(0);
} else {
self.u32(s.len() as u32 + 1);
self.out.extend_from_slice(s.as_bytes());
self.out.push(0);
}
}
pub fn prop(&mut self, kind: u32, payload: &[u8]) {
self.u32(kind);
self.u32(payload.len() as u32);
self.out.extend_from_slice(payload);
}
pub fn prop_u32(&mut self, kind: u32, v: u32) {
self.prop(kind, &v.to_be_bytes());
}
pub fn end(&mut self) {
self.u32(0);
self.u32(0);
}
/// Reserves a pointer slot, returning where to patch it.
pub fn slot(&mut self) -> usize {
let at = self.out.len();
self.ptr(0);
at
}
pub fn patch(&mut self, slot: usize, target: usize) {
if self.wide {
self.out[slot..slot + 8].copy_from_slice(&(target as u64).to_be_bytes());
} else {
self.out[slot..slot + 4].copy_from_slice(&(target as u32).to_be_bytes());
}
}
}
/// RLE-encodes one tile of `bpp`-byte pixels, plane by plane, with runs and literals.
pub(super) fn rle(pixels: &[u8], bpp: usize) -> Vec<u8> {
let n = pixels.len() / bpp;
let mut out = Vec::new();
for b in 0..bpp {
let plane: Vec<u8> = (0..n).map(|i| pixels[i * bpp + b]).collect();
let mut i = 0;
while i < n {
let v = plane[i];
let mut run = 1;
while i + run < n && plane[i + run] == v && run < 65535 {
run += 1;
}
if run >= 2 {
if run <= 127 {
out.push((run - 1) as u8);
} else {
out.push(127);
out.push((run / 256) as u8);
out.push((run % 256) as u8);
}
out.push(v);
i += run;
} else {
let start = i;
while i < n && (i + 1 >= n || plane[i + 1] != plane[i]) && i - start < 127 {
i += 1;
}
let len = i - start;
out.push((256 - len) as u8);
out.extend_from_slice(&plane[start..i]);
}
}
}
out
}
/// Writes a hierarchy for `w`×`h` pixels of `bpp` bytes (`data` row-major), returning its offset.
pub(super) fn hierarchy(w: &mut W, width: u32, height: u32, bpp: usize, data: &[u8], compression: Compression) -> usize {
let at = w.out.len();
w.u32(width);
w.u32(height);
w.u32(bpp as u32);
let level_slot = w.slot();
w.ptr(0);
let level = w.out.len();
w.patch(level_slot, level);
w.u32(width);
w.u32(height);
let across = width.div_ceil(TILE) as usize;
let down = height.div_ceil(TILE) as usize;
let slots: Vec<usize> = (0..across * down).map(|_| w.slot()).collect();
w.ptr(0);
for (i, slot) in slots.into_iter().enumerate() {
let (tx, ty) = ((i % across) as u32, (i / across) as u32);
let (tw, th) = ((width - tx * TILE).min(TILE), (height - ty * TILE).min(TILE));
let mut tile = Vec::new();
for py in 0..th {
for px in 0..tw {
let src = ((ty * TILE + py) as usize * width as usize + (tx * TILE + px) as usize) * bpp;
tile.extend_from_slice(&data[src..src + bpp]);
}
}
let encoded = match compression {
Compression::None => tile,
Compression::Rle => rle(&tile, bpp),
Compression::Zlib => {
use std::io::Write;
let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
e.write_all(&tile).unwrap();
e.finish().unwrap()
}
};
let here = w.out.len();
w.patch(slot, here);
w.out.extend_from_slice(&encoded);
}
at
}
/// A layer to write.
pub(super) struct TestLayer {
pub name: &'static str,
pub width: u32,
pub height: u32,
pub kind: u32,
pub x: i32,
pub y: i32,
pub opacity: f32,
pub visible: bool,
pub mode: u32,
pub props: Vec<(u32, Vec<u8>)>,
pub data: Vec<u8>,
pub mask: Option<Vec<u8>>,
pub apply_mask: bool,
}
impl TestLayer {
pub fn rgba(name: &'static str, width: u32, height: u32, data: Vec<u8>) -> Self {
TestLayer { name, width, height, kind: 1, x: 0, y: 0, opacity: 1.0, visible: true, mode: 28, props: Vec::new(), data, mask: None, apply_mask: true }
}
}
pub(super) struct TestImage {
pub version: u32,
pub width: u32,
pub height: u32,
pub base_type: u32,
pub precision: u32,
pub compression: Compression,
pub image_props: Vec<(u32, Vec<u8>)>,
pub layers: Vec<TestLayer>,
pub channels: Vec<(&'static str, Vec<u8>, bool)>,
}
impl TestImage {
pub fn rgb8(width: u32, height: u32) -> Self {
TestImage {
version: 11,
width,
height,
base_type: 0,
precision: 150,
compression: Compression::Rle,
image_props: Vec::new(),
layers: Vec::new(),
channels: Vec::new(),
}
}
pub fn write(&self) -> Vec<u8> {
let mut w = W::new(self.version);
w.u32(self.width);
w.u32(self.height);
w.u32(self.base_type);
if self.version >= 4 {
w.u32(self.precision);
}
let comp = match self.compression {
Compression::None => 0u8,
Compression::Rle => 1,
Compression::Zlib => 2,
};
w.prop(17, &[comp]);
for (k, v) in &self.image_props {
w.prop(*k, v);
}
w.end();
let layer_slots: Vec<usize> = self.layers.iter().map(|_| w.slot()).collect();
w.ptr(0);
let channel_slots: Vec<usize> = self.channels.iter().map(|_| w.slot()).collect();
w.ptr(0);
if self.version >= 18 {
w.ptr(0);
}
let bytes = match self.precision {
100 | 150 => 1,
200 | 250 | 500 | 550 => 2,
300 | 350 | 600 | 650 => 4,
_ => 8,
};
for (layer, slot) in self.layers.iter().zip(layer_slots) {
let at = w.out.len();
w.patch(slot, at);
w.u32(layer.width);
w.u32(layer.height);
w.u32(layer.kind);
w.string(layer.name);
w.prop_u32(6, (layer.opacity * 255.0).round() as u32);
w.prop(33, &layer.opacity.to_be_bytes());
w.prop_u32(8, u32::from(layer.visible));
w.prop_u32(7, layer.mode);
let mut off = Vec::new();
off.extend_from_slice(&layer.x.to_be_bytes());
off.extend_from_slice(&layer.y.to_be_bytes());
w.prop(15, &off);
if layer.mask.is_some() {
w.prop_u32(11, u32::from(layer.apply_mask));
}
for (k, v) in &layer.props {
w.prop(*k, v);
}
w.end();
let hslot = w.slot();
let mslot = w.slot();
if self.version >= 20 {
w.ptr(0);
}
let channels = match layer.kind {
0 => 3,
1 => 4,
2 | 4 => 1,
_ => 2,
};
if !layer.data.is_empty() {
let h = hierarchy(&mut w, layer.width, layer.height, channels * bytes, &layer.data, self.compression);
w.patch(hslot, h);
}
if let Some(mask) = &layer.mask {
let at = w.out.len();
w.patch(mslot, at);
w.u32(layer.width);
w.u32(layer.height);
w.string("mask");
w.end();
let hs = w.slot();
let h = hierarchy(&mut w, layer.width, layer.height, bytes, mask, self.compression);
w.patch(hs, h);
}
}
for ((name, data, selection), slot) in self.channels.iter().zip(channel_slots) {
let at = w.out.len();
w.patch(slot, at);
w.u32(self.width);
w.u32(self.height);
w.string(name);
if *selection {
w.prop(4, &[]);
}
w.end();
let hs = w.slot();
let h = hierarchy(&mut w, self.width, self.height, bytes, data, self.compression);
w.patch(hs, h);
}
w.out
}
}
fn open(bytes: &[u8]) -> Result<ImportResult> {
import("test.xcf", bytes, &Interrupt::NONE)
}
fn rgba(w: u32, h: u32, f: impl Fn(u32, u32) -> [u8; 4]) -> Vec<u8> {
let f = &f;
(0..h).flat_map(|y| (0..w).flat_map(move |x| f(x, y))).collect()
}
fn pixel(layer: &Layer, x: i32, y: i32) -> [f32; 4] {
let LayerContent::Raster(s) = &layer.content else { panic!("not raster") };
let px = s.read_region(Rect::new(x, y, x + 1, y + 1));
[px[0], px[1], px[2], px[3]]
}
#[test]
fn a_layer_decodes_with_every_compression() {
for compression in [Compression::None, Compression::Rle, Compression::Zlib] {
let mut img = TestImage::rgb8(70, 66);
img.compression = compression;
img.layers.push(TestLayer::rgba("A", 70, 66, rgba(70, 66, |x, y| [x as u8 * 3, y as u8 * 2, (x + y) as u8, 255 - x as u8])));
let r = open(&img.write()).unwrap();
let d = &r.document;
assert_eq!((d.size.width, d.size.height), (70, 66));
assert_eq!(d.layers.len(), 1, "{compression:?}");
for (x, y) in [(0, 0), (63, 0), (64, 0), (69, 65), (10, 64)] {
let p = pixel(&d.layers[0], x, y);
let want = [x as f32 * 3.0, y as f32 * 2.0, (x + y) as f32, 255.0 - x as f32].map(|v| v / 255.0);
assert!((0..4).all(|c| (p[c] - want[c]).abs() < 1e-6), "{compression:?} at {x},{y}: {p:?} vs {want:?}");
}
assert!(r.source_read_only);
}
}
#[test]
fn every_truncation_errors_never_panics() {
let mut img = TestImage::rgb8(20, 20);
img.layers.push(TestLayer::rgba("A", 20, 20, rgba(20, 20, |x, y| [x as u8, y as u8, 0, 255])));
let bytes = img.write();
for cut in 0..bytes.len() {
let _ = open(&bytes[..cut]);
}
assert!(open(&bytes).is_ok());
}
fn layer_at(layers: &[Layer], name: &str) -> Layer {
layers.iter().find(|l| l.name == name).cloned().unwrap_or_else(|| panic!("no layer {name}"))
}
#[test]
fn properties_offsets_opacity_mode_visibility_locks_and_tags() {
let mut img = TestImage::rgb8(30, 20);
let mut l = TestLayer::rgba("Top", 10, 8, rgba(10, 8, |x, y| [x as u8 * 20, y as u8 * 30, 0, 200]));
l.x = -3;
l.y = 15;
l.opacity = 0.4;
l.visible = false;
l.mode = 30; // Multiply
l.props = vec![(10, 1u32.to_be_bytes().to_vec()), (28, 1u32.to_be_bytes().to_vec()), (32, 1u32.to_be_bytes().to_vec()), (34, 2u32.to_be_bytes().to_vec())];
img.layers.push(l);
img.layers.push(TestLayer::rgba("Bottom", 30, 20, rgba(30, 20, |_, _| [9, 9, 9, 255])));
let r = open(&img.write()).unwrap();
let d = &r.document;
assert_eq!(d.layers.iter().map(|l| l.name.as_str()).collect::<Vec<_>>(), ["Bottom", "Top"], "topmost in the file is last in the stack");
let top = &d.layers[1];
assert_eq!((top.blend, top.opacity, top.visible), (BlendMode::Multiply, 0.4, false));
assert!(top.locks.transparency && top.locks.pixels && top.locks.position);
assert_eq!(top.label, LabelColor::Green);
// Offset (-3, 15): the layer's pixel (5, 2) sits at document (2, 17).
let p = pixel(top, 2, 17);
assert!((p[0] - 100.0 / 255.0).abs() < 1e-6 && (p[1] - 60.0 / 255.0).abs() < 1e-6 && (p[3] - 200.0 / 255.0).abs() < 1e-6, "{p:?}");
assert_eq!(pixel(top, 2, 14)[3], 0.0, "above the layer");
}
#[test]
fn groups_follow_item_paths_and_pass_through() {
// File order: Top, Group (0), child A [0,0], Nested group [0,1], nested child [0,1,0], Bottom.
let mut img = TestImage::rgb8(16, 16);
let px = |v: u8| rgba(4, 4, move |_, _| [v, v, v, 255]);
img.layers.push(TestLayer::rgba("Top", 4, 4, px(1)));
let mut group = TestLayer::rgba("Group", 1, 1, Vec::new());
group.props = vec![(29, vec![]), (31, 0u32.to_be_bytes().to_vec())];
group.mode = 28;
img.layers.push(group);
let mut a = TestLayer::rgba("A", 4, 4, px(2));
a.props = vec![(30, [1u32, 0].iter().flat_map(|v| v.to_be_bytes()).collect())];
img.layers.push(a);
let mut nested = TestLayer::rgba("Nested", 1, 1, Vec::new());
nested.props = vec![(29, vec![]), (30, [1u32, 1].iter().flat_map(|v| v.to_be_bytes()).collect())];
nested.mode = 61; // Pass through
img.layers.push(nested);
let mut b = TestLayer::rgba("B", 4, 4, px(3));
b.props = vec![(30, [1u32, 1, 0].iter().flat_map(|v| v.to_be_bytes()).collect())];
img.layers.push(b);
img.layers.push(TestLayer::rgba("Bottom", 16, 16, rgba(16, 16, |_, _| [0, 0, 0, 255])));
let d = open(&img.write()).unwrap().document;
assert_eq!(d.layers.iter().map(|l| l.name.as_str()).collect::<Vec<_>>(), ["Bottom", "Group", "Top"]);
let g = &d.layers[1];
assert!(g.is_group() && g.blend == BlendMode::Normal);
if let LayerContent::Group(gr) = &g.content {
assert!(!gr.expanded);
}
let children = g.children().unwrap();
assert_eq!(children.iter().map(|l| l.name.as_str()).collect::<Vec<_>>(), ["Nested", "A"], "bottom first inside the group too");
assert_eq!(children[0].blend, BlendMode::PassThrough);
assert_eq!(children[0].children().unwrap()[0].name, "B");
assert_eq!(pixel(&layer_at(children, "A"), 0, 0)[0], 2.0 / 255.0);
}
#[test]
fn a_path_into_nothing_lands_at_the_top_level_with_a_note() {
let mut img = TestImage::rgb8(8, 8);
let mut stray = TestLayer::rgba("Stray", 2, 2, rgba(2, 2, |_, _| [5, 5, 5, 255]));
stray.props = vec![(30, [7u32, 0].iter().flat_map(|v| v.to_be_bytes()).collect())];
img.layers.push(stray);
img.layers.push(TestLayer::rgba("Bottom", 8, 8, rgba(8, 8, |_, _| [0, 0, 0, 255])));
let r = open(&img.write()).unwrap();
assert_eq!(r.document.layers.len(), 2);
assert!(r.warnings.iter().any(|w| w.contains("item path")), "{:?}", r.warnings);
}
#[test]
fn masks_are_read_at_the_layer_offset_applied_or_not() {
for apply in [true, false] {
let mut img = TestImage::rgb8(20, 20);
let mut l = TestLayer::rgba("Masked", 6, 6, rgba(6, 6, |_, _| [255, 0, 0, 255]));
l.x = 4;
l.y = 5;
l.mask = Some((0..36).map(|i| (i * 7) as u8).collect());
l.apply_mask = apply;
img.layers.push(l);
let d = open(&img.write()).unwrap().document;
let mask = d.layers[0].mask.as_ref().expect("mask");
assert_eq!(mask.enabled, apply);
assert_eq!(mask.surface.read_region(Rect::new(4 + 3, 5 + 2, 4 + 4, 5 + 3))[0], (2 * 6 + 3) as f32 * 7.0 / 255.0);
}
}
#[test]
fn sixteen_bit_and_float_samples_are_big_endian() {
// 16-bit gamma integer (250): one 2x1 gray+alpha layer.
let mut img = TestImage::rgb8(2, 1);
img.base_type = 1;
img.precision = 250;
let mut data = Vec::new();
for v in [0x1234u16, 0xffff, 0x8000, 0x0001] {
data.extend_from_slice(&v.to_be_bytes());
}
let mut l = TestLayer::rgba("G", 2, 1, data);
l.kind = 3; // gray with alpha
img.layers.push(l);
let d = open(&img.write()).unwrap().document;
assert_eq!((d.mode, d.depth), (ColorMode::Grayscale, SampleType::U16));
let p = pixel_n(&d.layers[0], 0, 0, 2);
assert!((p[0] - 0x1234 as f32 / 65535.0).abs() < 1e-6 && (p[1] - 1.0).abs() < 1e-6, "{p:?}");
let p = pixel_n(&d.layers[0], 1, 0, 2);
assert!((p[0] - 0x8000 as f32 / 65535.0).abs() < 1e-6 && (p[1] - 1.0 / 65535.0).abs() < 1e-6, "{p:?}");
// 32-bit float linear (600): RGB without alpha, values above 1 kept, linear profile.
let mut img = TestImage::rgb8(1, 1);
img.precision = 600;
let mut data = Vec::new();
for v in [0.25f32, 1.5, -0.5] {
data.extend_from_slice(&v.to_be_bytes());
}
let mut l = TestLayer::rgba("F", 1, 1, data);
l.kind = 0;
img.layers.push(l);
let r = open(&img.write()).unwrap();
let d = &r.document;
assert_eq!(d.depth, SampleType::F32);
assert_eq!(pixel(&d.layers[0], 0, 0), [0.25, 1.5, -0.5, 1.0]);
assert!(d.icc_profile.is_some(), "linear light gets a linear profile");
assert!(d.layers[0].locks.transparency, "no alpha channel in the file: the layer is opaque");
// 16-bit float (500) and 64-bit float (700) decode too.
for (precision, bytes) in [(500u32, f16::from_f32(0.75).to_bits().to_be_bytes().to_vec()), (700, 0.75f64.to_be_bytes().to_vec())] {
let mut img = TestImage::rgb8(1, 1);
img.base_type = 1;
img.precision = precision;
let mut l = TestLayer::rgba("F", 1, 1, bytes);
l.kind = 2;
img.layers.push(l);
let d = open(&img.write()).unwrap().document;
assert_eq!(pixel_n(&d.layers[0], 0, 0, 2)[0], 0.75, "precision {precision}");
}
}
fn pixel_n(layer: &Layer, x: i32, y: i32, n: usize) -> Vec<f32> {
let LayerContent::Raster(s) = &layer.content else { panic!("not raster") };
let px = s.read_region(Rect::new(x, y, x + 1, y + 1));
assert_eq!(px.len(), n);
px
}
#[test]
fn indexed_images_use_the_colormap() {
let mut img = TestImage::rgb8(2, 1);
img.base_type = 2;
let mut cmap = 3u32.to_be_bytes().to_vec();
cmap.extend_from_slice(&[255, 0, 0, 0, 255, 0, 0, 0, 255]);
img.image_props.push((1, cmap));
let mut l = TestLayer::rgba("I", 2, 1, vec![2, 255, 1, 128]);
l.kind = 5; // indexed with alpha
img.layers.push(l);
let r = open(&img.write()).unwrap();
assert_eq!(r.document.mode, ColorMode::Rgb);
assert_eq!(pixel(&r.document.layers[0], 0, 0), [0.0, 0.0, 1.0, 1.0]);
assert_eq!(pixel(&r.document.layers[0], 1, 0), [0.0, 1.0, 0.0, 128.0 / 255.0]);
assert!(r.warnings.iter().any(|w| w.contains("indexed")));
}
#[test]
fn channels_become_alpha_channels_and_the_selection() {
let mut img = TestImage::rgb8(4, 4);
img.layers.push(TestLayer::rgba("L", 4, 4, rgba(4, 4, |_, _| [0, 0, 0, 255])));
img.channels.push(("Extra", (0..16).map(|i| i as u8 * 16).collect(), false));
img.channels.push(("Selection Mask", vec![255; 16], true));
let d = open(&img.write()).unwrap().document;
assert_eq!(d.channels.len(), 1);
assert_eq!(d.channels[0].name, "Extra");
assert_eq!(d.channels[0].surface.read_region(Rect::new(3, 1, 4, 2))[0], 7.0 * 16.0 / 255.0);
assert_eq!(d.selection.as_ref().unwrap().read_region(Rect::new(0, 0, 1, 1))[0], 1.0);
}
#[test]
fn image_parasites_resolution_guides_and_comment() {
let mut img = TestImage::rgb8(10, 10);
let mut res = Vec::new();
res.extend_from_slice(&300.0f32.to_be_bytes());
res.extend_from_slice(&300.0f32.to_be_bytes());
img.image_props.push((19, res));
let mut guides = Vec::new();
guides.extend_from_slice(&4i32.to_be_bytes());
guides.push(1);
guides.extend_from_slice(&7i32.to_be_bytes());
guides.push(2);
img.image_props.push((18, guides));
let mut parasites = Vec::new();
for (name, data) in [("gimp-comment", &b"hello\0"[..]), ("icc-profile", &[1u8, 2, 3][..])] {
parasites.extend_from_slice(&(name.len() as u32 + 1).to_be_bytes());
parasites.extend_from_slice(name.as_bytes());
parasites.push(0);
parasites.extend_from_slice(&0u32.to_be_bytes());
parasites.extend_from_slice(&(data.len() as u32).to_be_bytes());
parasites.extend_from_slice(data);
}
img.image_props.push((21, parasites));
// An unknown property is skipped by its length.
img.image_props.push((99, vec![1, 2, 3, 4, 5]));
img.layers.push(TestLayer::rgba("L", 10, 10, rgba(10, 10, |_, _| [0, 0, 0, 255])));
let d = open(&img.write()).unwrap().document;
assert_eq!(d.resolution_dpi, 300.0);
assert_eq!((d.guides.horizontal.as_slice(), d.guides.vertical.as_slice()), (&[4.0][..], &[7.0][..]));
assert_eq!(d.metadata.text, vec![("Comment".to_string(), "hello".to_string())]);
assert_eq!(d.icc_profile.as_deref().map(|p| p.as_slice()), Some(&[1u8, 2, 3][..]));
}
#[test]
fn clipping_text_vector_effects_and_floating_selections_are_reported() {
let mut img = TestImage::rgb8(4, 4);
img.version = 20;
let mut clipped = TestLayer::rgba("Clipped", 4, 4, rgba(4, 4, |_, _| [1, 1, 1, 255]));
let mut parasite = Vec::new();
parasite.extend_from_slice(&("gimphoto-clipped".len() as u32 + 1).to_be_bytes());
parasite.extend_from_slice(b"gimphoto-clipped\0");
parasite.extend_from_slice(&0u32.to_be_bytes());
parasite.extend_from_slice(&1u32.to_be_bytes());
parasite.push(1);
clipped.props = vec![(21, parasite), (26, 1u32.to_be_bytes().to_vec()), (47, vec![0; 8])];
img.layers.push(clipped);
let mut floating = TestLayer::rgba("Floating Selection", 2, 2, rgba(2, 2, |_, _| [2, 2, 2, 255]));
floating.props = vec![(5, vec![0; 8])];
img.layers.push(floating);
img.layers.push(TestLayer::rgba("Bottom", 4, 4, rgba(4, 4, |_, _| [0, 0, 0, 255])));
let r = open(&img.write()).unwrap();
assert_eq!(r.document.layers.iter().map(|l| l.name.as_str()).collect::<Vec<_>>(), ["Bottom", "Clipped"], "the floating selection is left out");
assert!(r.document.layers[1].clipped);
let w = r.warnings.join("\n");
assert!(w.contains("text layer") && w.contains("vector layer") && w.contains("floating selection"), "{w}");
}
#[test]
fn version_3_files_use_32_bit_pointers_and_legacy_modes() {
let mut img = TestImage::rgb8(5, 5);
img.version = 3;
let mut l = TestLayer::rgba("Old", 5, 5, rgba(5, 5, |x, _| [x as u8, 0, 0, 255]));
l.mode = 3; // Multiply (legacy)
img.layers.push(l);
let bytes = img.write();
assert!(bytes.starts_with(b"gimp xcf v003\0"));
let d = open(&bytes).unwrap().document;
assert_eq!((d.depth, d.layers[0].blend), (SampleType::U8, BlendMode::Multiply));
assert_eq!(pixel(&d.layers[0], 4, 0)[0], 4.0 / 255.0);
// Version 0 ("file") as well.
let mut img0 = TestImage::rgb8(2, 2);
img0.version = 0;
img0.layers.push(TestLayer::rgba("Oldest", 2, 2, rgba(2, 2, |_, _| [7, 7, 7, 255])));
assert!(open(&img0.write()).unwrap().document.layers.len() == 1);
}
#[test]
fn rle_long_runs_and_literals_round_trip() {
// A tile with runs over 127 and long literal stretches, in every byte plane.
let w = 64;
let data = rgba(w, 64, |x, y| if y < 40 { [200, 1, 2, 255] } else { [(x * 7 + y) as u8, (x * 3) as u8, y as u8, 255] });
let mut img = TestImage::rgb8(w, 64);
img.layers.push(TestLayer::rgba("R", w, 64, data.clone()));
let d = open(&img.write()).unwrap().document;
let got = pixel(&d.layers[0], 63, 63);
assert!((got[0] - (63 * 7 + 63) as u8 as f32 / 255.0).abs() < 1e-6 && (got[1] - 189.0 / 255.0).abs() < 1e-6, "{got:?}");
assert_eq!(pixel(&d.layers[0], 10, 10), [200.0 / 255.0, 1.0 / 255.0, 2.0 / 255.0, 1.0]);
}
#[test]
fn limits_and_malformed_structures_are_errors() {
// A canvas past the side limit.
let mut big = TestImage::rgb8(1, 1);
big.width = MAX_SIDE + 1;
assert!(open(&big.write()).is_err());
// A layer whose declared size has no pixels behind it (hierarchy pointer to garbage).
let mut img = TestImage::rgb8(4, 4);
img.layers.push(TestLayer::rgba("L", 4, 4, rgba(4, 4, |_, _| [0; 4])));
let mut bytes = img.write();
let n = bytes.len();
bytes[n - 10..].fill(0xff);
assert!(open(&bytes).is_err());
// Wrong signature, unknown version tag, unknown compression.
assert!(matches!(open(b"gimp xcf vXYZ\0"), Err(IoError::Xcf(_))));
assert!(open(b"not an xcf").is_err());
let mut img = TestImage::rgb8(2, 2);
img.layers.push(TestLayer::rgba("L", 2, 2, rgba(2, 2, |_, _| [0; 4])));
let mut bytes = img.write();
let at = 14 + 12 + 4 + 8;
bytes[at] = 3; // compression byte: reserved
assert!(open(&bytes).is_err());
}
#[test]
fn gzipped_files_are_recognised_by_name_and_inflated() {
use std::io::Write;
let mut img = TestImage::rgb8(3, 3);
img.layers.push(TestLayer::rgba("L", 3, 3, rgba(3, 3, |_, _| [4, 5, 6, 255])));
let plain = img.write();
let mut e = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
e.write_all(&plain).unwrap();
let gz = e.finish().unwrap();
assert!(is_gzipped_xcf("a/b/photo.XCF.GZ", &gz) && is_gzipped_xcf("x.xcfgz", &gz));
assert!(!is_gzipped_xcf("photo.xcf", &gz) && !is_gzipped_xcf("photo.xcf.gz", &plain));
let d = crate::import("photo.xcf.gz", &gz).unwrap().document;
assert_eq!(pixel(&d.layers[0], 1, 1), [4.0 / 255.0, 5.0 / 255.0, 6.0 / 255.0, 1.0]);
assert!(gunzip(&gz[..gz.len() / 2]).is_err() || crate::import("photo.xcf.gz", &gz[..gz.len() / 2]).is_err());
}
/// A recognized raster signature wins over a misleading `.xcf` suffix, as with `.kra` and `.svg`.
#[test]
fn a_png_with_an_xcf_suffix_opens_by_content() {
let img =
photocraft_codecs::Image::from_raw(12, 8, photocraft_codecs::ChannelLayout::Rgba, photocraft_codecs::SampleType::U8, vec![90; 12 * 8 * 4]).unwrap();
let png = photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap();
let r = crate::import("renamed.xcf", &png).unwrap();
assert_eq!((r.document.size.width, r.document.size.height), (12, 8));
assert!(crate::import("broken.xcf", b"not an image at all").is_err());
}
/// `PROP_FLOAT_OPACITY` wins over `PROP_OPACITY` whichever comes first in the file.
#[test]
fn float_opacity_wins_over_opacity_in_either_order() {
let mut img = TestImage::rgb8(4, 4);
// The writer emits OPACITY (0.4 → 102) then FLOAT_OPACITY 0.4; an OPACITY 255 after them must not win.
let mut byte_last = TestLayer::rgba("Float then byte", 4, 4, rgba(4, 4, |_, _| [1, 2, 3, 255]));
byte_last.opacity = 0.4;
byte_last.props = vec![(6, 255u32.to_be_bytes().to_vec())];
// A later FLOAT_OPACITY replaces the earlier one.
let mut float_last = TestLayer::rgba("Byte then float", 4, 4, rgba(4, 4, |_, _| [1, 2, 3, 255]));
float_last.opacity = 0.4;
float_last.props = vec![(33, 0.25f32.to_be_bytes().to_vec())];
img.layers.push(byte_last);
img.layers.push(float_last);
let d = open(&img.write()).unwrap().document;
assert_eq!(layer_at(&d.layers, "Float then byte").opacity, 0.4);
assert_eq!(layer_at(&d.layers, "Byte then float").opacity, 0.25);
}
/// A path that fails below the top level (here at depth 2) still lands at the top level, so the
/// group it would have been pushed into keeps its indices for the siblings placed after it.
#[test]
fn a_path_failing_below_the_top_level_lands_at_the_top_level() {
let mut img = TestImage::rgb8(8, 8);
let px = |v: u8| rgba(2, 2, move |_, _| [v, v, v, 255]);
let mut group = TestLayer::rgba("Group", 1, 1, Vec::new());
group.props = vec![(29, vec![]), (31, 0u32.to_be_bytes().to_vec())];
img.layers.push(group);
let mut stray = TestLayer::rgba("Stray", 2, 2, px(5));
stray.props = vec![(30, [0u32, 4, 0].iter().flat_map(|v| v.to_be_bytes()).collect())];
img.layers.push(stray);
let mut a = TestLayer::rgba("A", 2, 2, px(6));
a.props = vec![(30, [0u32, 0].iter().flat_map(|v| v.to_be_bytes()).collect())];
img.layers.push(a);
img.layers.push(TestLayer::rgba("Bottom", 8, 8, rgba(8, 8, |_, _| [0, 0, 0, 255])));
let r = open(&img.write()).unwrap();
let d = &r.document;
assert_eq!(d.layers.iter().map(|l| l.name.as_str()).collect::<Vec<_>>(), ["Bottom", "Stray", "Group"]);
let children = layer_at(&d.layers, "Group").children().unwrap().iter().map(|l| l.name.clone()).collect::<Vec<_>>();
assert_eq!(children, ["A"], "the valid sibling placed after the stray one still lands in the group");
assert!(r.warnings.iter().any(|w| w.contains("Stray") && w.contains("item path")), "{:?}", r.warnings);
}
+347
View File
@@ -0,0 +1,347 @@
//! GIMP XCF files written by GIMP 3.2.6 itself (feature `corpus`; `cargo xtask test-corpus -p io`).
//! They come from `corpus/gimp/` (`cargo xtask corpus --gimp`; pin `PHOTOCRAFT_CORPUS_GIMP_COMMIT` in
//! `xtask/src/corpus_pins.rs`, generator `tools/gimp-xcf/xcf_fixtures.py` in photocraft-corpus); a
//! missing corpus fails. What the reader gets out is checked against the procedural images the
//! generator drew (`pattern`, `alpha`), and our composite of each file against GIMP's own merged
//! rendering saved next to it (`<name>.png`).
#![cfg(feature = "corpus")]
use photocraft_color::{BlendMode, ColorMode, SampleType};
use photocraft_doc::{Document, Layer, LayerContent};
use photocraft_geom::Rect;
use photocraft_io::{ExportOptions, export, import};
fn fixture(name: &str) -> Vec<u8> {
let p = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/gimp").join(name);
std::fs::read(&p).unwrap_or_else(|e| panic!("{}: {e}: run `cargo xtask corpus --gimp`", p.display()))
}
fn open(name: &str) -> photocraft_io::ImportResult {
import(name, &fixture(name)).unwrap_or_else(|e| panic!("{name}: {e}"))
}
const W: u32 = 40;
const H: u32 = 30;
fn pattern(x: u32, y: u32, c: u32, maxval: u32) -> u32 {
(x * 31 + y * 17 + c * 101 + 7) * 13 % (maxval + 1)
}
fn alpha(x: u32, y: u32, w: u32, h: u32, maxval: u32) -> u32 {
((x + y) * maxval / (w + h - 4).max(1)).min(maxval)
}
fn raster(layer: &Layer) -> &photocraft_raster::Surface {
match &layer.content {
LayerContent::Raster(s) => s,
_ => panic!("{} is not a pixel layer", layer.name),
}
}
fn px(layer: &Layer, x: i32, y: i32) -> Vec<f32> {
raster(layer).read_region(Rect::new(x, y, x + 1, y + 1))
}
fn names(layers: &[Layer]) -> Vec<&str> {
layers.iter().map(|l| l.name.as_str()).collect()
}
/// Largest premultiplied channel difference between our composite and GIMP's merged PNG, and
/// the share of pixels over `tolerance`.
fn composite_error(doc: &Document, png: &str, tolerance: f32) -> (f32, f32, String) {
let oracle = import(png, &fixture(png)).unwrap().document;
assert_eq!(doc.size, oracle.size, "{png}");
let ours = photocraft_compose::flatten(doc);
let theirs = photocraft_compose::flatten(&oracle);
let w = doc.size.width as usize;
let mut worst = (0.0f32, String::new());
let mut over = 0usize;
for (i, (a, b)) in ours.px.iter().zip(&theirs.px).enumerate() {
let e = (0..4).map(|c| if c == 3 { (a[3] - b[3]).abs() } else { (a[c] * a[3] - b[c] * b[3]).abs() }).fold(0.0f32, f32::max);
over += usize::from(e > tolerance);
if e > worst.0 {
let q = |p: &[f32; 4]| p.map(|v| (v * 255.0).round() as i32);
worst = (e, format!("at ({}, {}): ours {:?}, GIMP {:?}", i % w, i / w, q(a), q(b)));
}
}
(worst.0, over as f32 / ours.px.len().max(1) as f32, worst.1)
}
#[test]
fn layers_8bit_open_with_their_properties_and_pixels() {
let r = open("layers-8bit.xcf");
let d = &r.document;
assert_eq!((d.size.width, d.size.height, d.mode, d.depth), (W, H, ColorMode::Rgb, SampleType::U8));
assert_eq!(names(&d.layers), ["Background", "Multiply 60%", "Screen off-canvas", "Hidden"]);
assert!((d.resolution_dpi - 300.0).abs() < 0.01);
assert_eq!((d.guides.horizontal.as_slice(), d.guides.vertical.as_slice()), (&[10.0][..], &[25.0][..]));
assert!(r.source_read_only);
// The background: opaque, every sample as drawn.
let bg = &d.layers[0];
assert!(bg.locks.transparency, "an opaque XCF layer opens as a locked-transparency layer");
for (x, y) in [(0, 0), (39, 29), (17, 11)] {
let p = px(bg, x as i32, y as i32);
for c in 0..3 {
assert!((p[c] * 255.0 - pattern(x, y, c as u32, 255) as f32).abs() < 0.51, "background at {x},{y} channel {c}: {p:?}");
}
assert_eq!(p[3], 1.0);
}
// Multiply at 60%, offset (5, 4), lock alpha, green tag.
let m = &d.layers[1];
assert_eq!((m.blend, m.opacity, m.visible, m.locks.transparency, m.label), (BlendMode::Multiply, 0.6, true, true, photocraft_doc::LabelColor::Green));
let p = px(m, 5, 4);
assert!(
(p[0] * 255.0 - pattern(3, 0, 0, 255) as f32).abs() < 0.51 && p[3] == 0.0,
"top-left of the layer is transparent (alpha gradient starts at 0): {p:?}"
);
let p = px(m, 24, 19);
assert!((p[0] * 255.0 - pattern(19 + 3, 15, 0, 255) as f32).abs() < 0.51 && (p[3] * 255.0 - alpha(19, 15, 20, 16, 255) as f32).abs() < 0.51, "{p:?}");
assert_eq!(px(m, 4, 4)[3], 0.0, "outside the layer");
// Screen, partly off-canvas at (-8, 20): its pixels at x < 0 exist.
let s = &d.layers[2];
assert_eq!(s.blend, BlendMode::Screen);
assert!((px(s, -8, 20)[0] * 255.0 - pattern(7, 0, 0, 255) as f32).abs() < 0.51);
assert!((px(s, 15, 31)[3] * 255.0 - alpha(23, 11, 24, 12, 255) as f32).abs() < 0.51);
// Hidden.
assert!(!d.layers[3].visible);
// The only note: GIMP composites this file in linear light.
assert_eq!(r.warnings.len(), 1, "{:?}", r.warnings);
assert!(r.warnings[0].contains("linear light"), "{:?}", r.warnings);
}
/// Per mode: our composite of the `legacy-modes-perceptual.xcf` cell against GIMP's. Modes with
/// a Photoshop equivalent must match; the approximated ones are reported.
#[test]
fn each_mode_against_gimp_in_gamma_space() {
let d = open("legacy-modes-perceptual.xcf").document;
let oracle = import("legacy-modes-perceptual.png", &fixture("legacy-modes-perceptual.png")).unwrap().document;
let ours = photocraft_compose::flatten(&d);
let theirs = photocraft_compose::flatten(&oracle);
let w = d.size.width as usize;
// Where the layer pixel is fully opaque the formulas must agree. Where it is translucent,
// GIMP applies the pixel's alpha before clamping the result of a mode that can leave 0..1
// (Addition, Subtract, Divide, Dodge, Burn, Linear light) and Photoshop clamps first, so
// those pixels are only reported.
let exact = ["Addition", "Subtract", "Divide", "Dodge", "Burn", "Hard light", "Exclusion", "Linear light"];
let mut report = Vec::new();
let mut failed = false;
for (i, layer) in d.layers.iter().skip(1).enumerate() {
let (x0, y0) = ((i % 5) * 8, (i / 5) * 10);
let (mut worst_opaque, mut worst_all) = (0.0f32, 0.0f32);
for y in y0..y0 + 8 {
for x in x0..x0 + 8 {
let (a, b) = (ours.px[y * w + x], theirs.px[y * w + x]);
let e = (0..3).map(|c| (a[c] - b[c]).abs()).fold(0.0, f32::max);
worst_all = worst_all.max(e);
let opaque = alpha((x - x0) as u32, (y - y0) as u32, 8, 8, 255) == 255;
if opaque {
worst_opaque = worst_opaque.max(e);
}
}
}
let must = exact.contains(&layer.name.as_str());
let ok = !must || worst_opaque <= 2.5 / 255.0;
failed |= !ok;
report.push(format!(
"{} {:<14} {:?}: opaque pixels worst {:.1}/255, all {:.1}/255",
if ok { "ok " } else { "FAIL" },
layer.name,
layer.blend,
worst_opaque * 255.0,
worst_all * 255.0
));
}
println!("{}", report.join("\n"));
assert!(!failed, "\n{}", report.join("\n"));
}
#[test]
fn groups_nest_and_pass_through() {
let r = open("groups.xcf");
let d = &r.document;
assert_eq!(names(&d.layers), ["Background", "Group", "Top"]);
let g = &d.layers[1];
assert!(g.is_group());
assert!((g.opacity - 0.7).abs() < 1e-6);
assert_eq!(g.blend, BlendMode::Normal, "a GIMP group in Normal mode composites as a unit");
let children = g.children().unwrap();
assert_eq!(names(children), ["Child Multiply", "Nested pass-through"]);
assert_eq!(children[0].blend, BlendMode::Multiply);
let nested = &children[1];
assert!(nested.is_group());
assert_eq!(nested.blend, BlendMode::PassThrough);
assert_eq!(names(nested.children().unwrap()), ["Nested child Screen"]);
assert_eq!(nested.children().unwrap()[0].blend, BlendMode::Screen);
assert!((px(&nested.children().unwrap()[0], 18, 10)[0] * 255.0 - pattern(5, 0, 0, 255) as f32).abs() < 0.51, "nested child at its offset");
}
#[test]
fn masks_open_applied_or_disabled() {
let d = open("mask.xcf").document;
assert_eq!(names(&d.layers), ["Background", "Masked", "Mask disabled"]);
let masked = d.layers[1].mask.as_ref().expect("mask");
assert!(masked.enabled);
// A left-to-right gradient across 30 px at the layer's offset (5, 5). The script drew it as
// gamma-encoded values and GIMP stores mask coverage linearly, so the midpoint is where
// sRGB 15/29 lands in linear light.
let v = |x: i32| masked.surface.read_region(Rect::new(x, 10, x + 1, 11))[0];
let linear = |s: f32| if s <= 0.04045 { s / 12.92 } else { ((s + 0.055) / 1.055).powf(2.4) };
assert!(v(5) < 0.01 && (v(5 + 29) - 1.0).abs() < 0.01 && (v(5 + 15) - linear(15.0 / 29.0)).abs() < 0.01, "{} {} {}", v(5), v(34), v(20));
let disabled = d.layers[2].mask.as_ref().expect("mask");
assert!(!disabled.enabled);
assert!(disabled.surface.read_region(Rect::new(20, 20, 21, 21))[0] < 0.01, "a black mask, kept but not applied");
}
#[test]
fn gray16_keeps_its_depth_and_mode() {
let d = open("gray16.xcf").document;
assert_eq!((d.mode, d.depth), (ColorMode::Grayscale, SampleType::U16));
assert_eq!(names(&d.layers), ["Background", "Gray alpha"]);
let bg = &d.layers[0];
assert_eq!(raster(bg).channels(), 2);
for (x, y) in [(0, 0), (39, 29), (13, 7)] {
let p = px(bg, x as i32, y as i32);
assert!((p[0] * 65535.0 - pattern(x, y, 0, 65535) as f32).abs() < 0.6, "gray at {x},{y}: {p:?}");
}
let top = &d.layers[1];
let p = px(top, 10 + 19, 8 + 14);
assert!((p[0] * 65535.0 - pattern(19 + 9, 14, 0, 65535) as f32).abs() < 0.6 && (p[1] * 65535.0 - alpha(19, 14, 20, 15, 65535) as f32).abs() < 0.6, "{p:?}");
}
#[test]
fn float_linear_opens_as_32_bit_float_with_a_linear_profile() {
let d = open("float-linear.xcf").document;
assert_eq!((d.mode, d.depth), (ColorMode::Rgb, SampleType::F32));
let icc = d.icc_profile.as_ref().expect("a linear-light profile");
assert_eq!(**icc, photocraft_cms::Builtin::LinearSrgb.profile().to_bytes().as_ref().clone());
let bg = &d.layers[0];
for (x, y) in [(0, 0), (39, 29), (5, 21)] {
let p = px(bg, x as i32, y as i32);
for (c, v) in p.iter().take(3).enumerate() {
assert_eq!(*v, (x * 3 + y * 5 + c as u32 * 7) as f32 / 64.0, "float at {x},{y} channel {c}");
}
}
assert!(px(bg, 39, 29)[0] > 1.0, "values above 1 are kept");
let top = &d.layers[1];
let p = px(top, 12 + 4, 7 + 6);
assert_eq!(p[3], 10.0 / 30.0);
}
#[test]
fn indexed_opens_as_rgb_with_a_note() {
let r = open("indexed.xcf");
assert_eq!((r.document.mode, r.document.depth), (ColorMode::Rgb, SampleType::U8));
assert!(r.warnings.iter().any(|w| w.contains("indexed")), "{:?}", r.warnings);
assert_eq!(names(&r.document.layers), ["Background", "Top"]);
}
#[test]
fn text_layers_open_as_pixels_with_a_note() {
let r = open("text.xcf");
assert_eq!(names(&r.document.layers), ["Background", "XCF"]);
assert!(r.warnings.iter().any(|w| w.contains("text layer")), "{:?}", r.warnings);
let text = &r.document.layers[1];
let s = raster(text);
let region = s.read_region(Rect::new(10, 10, 10 + 60, 10 + 30));
assert!(region.as_chunks::<4>().0.iter().any(|p| p[3] > 0.5), "the rendered glyphs are there");
}
#[test]
fn channels_and_selection_come_through() {
let d = open("channel.xcf").document;
assert_eq!(d.channels.len(), 1);
assert_eq!(d.channels[0].name, "Alpha 1");
let v = |y: i32| d.channels[0].surface.read_region(Rect::new(3, y, 4, y + 1))[0];
assert!(v(0) < 0.01 && (v(29) - 1.0).abs() < 0.01, "a top-to-bottom gradient");
let sel = d.selection.as_ref().expect("the saved selection");
assert!((sel.read_region(Rect::new(10, 10, 11, 11))[0] - 1.0).abs() < 0.01 && sel.read_region(Rect::new(1, 1, 2, 2))[0] < 0.01);
}
#[test]
fn modes_map_to_photoshops_and_the_rest_warn() {
let r = open("legacy-modes.xcf");
let by_name = |n: &str| r.document.layers.iter().find(|l| l.name == n).unwrap_or_else(|| panic!("{n}"));
assert_eq!(by_name("Addition").blend, BlendMode::LinearDodge);
assert_eq!(by_name("Subtract").blend, BlendMode::Subtract);
assert_eq!(by_name("Divide").blend, BlendMode::Divide);
assert_eq!(by_name("Dodge").blend, BlendMode::ColorDodge);
assert_eq!(by_name("Burn").blend, BlendMode::ColorBurn);
assert_eq!(by_name("Hard light").blend, BlendMode::HardLight);
assert_eq!(by_name("Exclusion").blend, BlendMode::Exclusion);
assert_eq!(by_name("Linear light").blend, BlendMode::LinearLight);
assert_eq!(by_name("Grain merge").blend, BlendMode::Normal);
assert_eq!(by_name("HSV Hue").blend, BlendMode::Hue);
assert_eq!(by_name("LCH Color").blend, BlendMode::Color);
let w = r.warnings.join("\n");
assert!(w.contains("Grain merge") && w.contains("HSV Value") && w.contains("LCH Color"), "{w}");
}
#[test]
fn xcf_cannot_be_written_and_says_so() {
let d = open("layers-8bit.xcf").document;
let e = export(&d, "out.xcf", &ExportOptions::default()).err().map(|e| e.to_string()).unwrap_or_default();
assert!(e.contains("pcraft"), "{e}");
}
#[test]
fn broken_files_are_errors_not_crashes() {
let bytes = fixture("groups.xcf");
assert!(import("x.xcf", b"gimp xcf v011\0").is_err());
assert!(import("x.xcf", &bytes[..bytes.len() / 2]).is_err());
for cut in (0..bytes.len()).step_by(7) {
let _ = import("x.xcf", &bytes[..cut]);
}
}
/// Our composite against GIMP's merged rendering. The `-perceptual` files have every layer set
/// to blend and composite in gamma-encoded RGB, which is how PhotoCraft composes, so they must
/// match tightly. GIMP's defaults compose in linear light, so those files measure the known gap
/// (the tolerance and the allowed share of pixels over it are the numbers seen, not goals).
/// Per file: tolerance (premultiplied, 0..1), the share of pixels allowed over it, and why.
const COMPOSITES: &[(&str, f32, f32, &str)] = &[
("layers-8bit-perceptual.xcf", 2.5 / 255.0, 0.0, "Normal, Multiply and Screen in gamma space"),
(
"groups-perceptual.xcf",
2.5 / 255.0,
0.16,
"groups and pass-through in gamma space; the 15% over are where GIMP's Clip to backdrop hides the Multiply and Screen children above the transparent part of the background",
),
("mask-perceptual.xcf", 2.5 / 255.0, 0.0, "masks in gamma space"),
("text-perceptual.xcf", 2.5 / 255.0, 0.0, "antialiased text in gamma space"),
(
"legacy-modes-perceptual.xcf",
2.5 / 255.0,
0.40,
"modes without an equivalent open as Normal and HSV/LCH modes are approximated; see each_mode_against_gimp_in_gamma_space",
),
("gray16.xcf", 2.0 / 255.0, 0.0, "16-bit gray, Normal"),
("indexed.xcf", 2.0 / 255.0, 0.0, "indexed to RGB"),
("channel.xcf", 2.0 / 255.0, 0.0, "channels are not rendered"),
("layers-8bit.xcf", 40.0 / 255.0, 0.0, "GIMP's default: Multiply blended in linear light"),
("groups.xcf", 140.0 / 255.0, 0.0, "GIMP's default: linear light and Clip to backdrop over a transparent background"),
("mask.xcf", 60.0 / 255.0, 0.0, "GIMP's default: alpha composited in linear light"),
("text.xcf", 75.0 / 255.0, 0.0, "GIMP's default: antialiased edges composited in linear light"),
];
#[test]
fn composites_match_gimps_merged_rendering() {
let mut report = Vec::new();
let mut failed = false;
for (name, tolerance, allowed, why) in COMPOSITES {
let d = open(name).document;
let png = name.replace(".xcf", ".png");
let (worst, over, at) = composite_error(&d, &png, *tolerance);
let ok = over <= *allowed;
failed |= !ok;
report.push(format!(
"{} {name}: worst {:.1}/255, {:.1}% over {:.1}/255 ({why}) {at}",
if ok { "ok " } else { "FAIL" },
worst * 255.0,
over * 100.0,
tolerance * 255.0
));
}
println!("{}", report.join("\n"));
assert!(!failed, "\n{}", report.join("\n"));
}
+4 -3
View File
@@ -496,6 +496,7 @@ against committed **sha256 manifests**. All pins are in one place:
| 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/gimp/` | 14 XCF files written by GIMP 3.2.6 with GIMP's merged rendering of each as a PNG (28 files), the oracles of the XCF reader | https://github.com/storytold/photocraft-corpus (ours, MIT OR Apache-2.0; its own pin) | `xtask/gimp-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/heif/` | 9 small HEIC/HEIF files (checkerboards, RGB strips, a grid-tiled photo with EXIF/XMP, each with Apple's decode as `.ref.png`; a 10-bit RGBA file with its source PNG), for the `heif` feature | heic-rs (MIT OR Apache-2.0) and pillow-heif (BSD-3-Clause) upstreams | `xtask/heif-corpus.sha256` |
@@ -535,13 +536,13 @@ scripts/fetch-corpus.sh # the same as cargo xtask corpus --all
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
- **Local mode:** `cargo xtask corpus --photoshop --local` copies `photoshop/` (and `--gimp --local` `gimp/`) 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
`PHOTOCRAFT_CORPUS_COMMIT` in `corpus_pins.rs` (`PHOTOCRAFT_CORPUS_GIMP_COMMIT` for `gimp/`), run
`cargo xtask corpus --photoshop --update-manifest` (or `--gimp --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
+99
View File
@@ -0,0 +1,99 @@
# GIMP XCF documents
PhotoCraft opens GIMP's native `.xcf` files (and `.xcf.gz`) with their layers, read only. File ›
Open, recent files, drops, the CLI (`convert`, `info`, `batch`) and automation (`doc.open`, MCP)
use the same reader in `photocraft-io` (`crates/io/src/xcf.rs`). A file is recognised by its
`gimp xcf ` signature, so an XCF with another extension still opens as one. Save suggests
`.pcraft`, which keeps everything PhotoCraft holds; exporting as `.xcf` fails with a message.
## What is kept
| | Open |
|---|---|
| Pixel layers (any size and position, also off-canvas), opaque or with alpha | yes |
| Groups, nested; a group in Pass through mode | yes |
| Names, visibility, opacity, offsets | yes |
| Layer masks | yes, applied or disabled as in GIMP |
| Blend modes | the 2.10 set and the legacy set, mapped to Photoshop's (table below) |
| Locks (alpha, content, position), colour tags | yes |
| Precision | 8 and 16-bit integer as such; 32-bit integer as 16-bit; 16/32/64-bit float as 32-bit float |
| Linear-light precisions | the document gets a linear sRGB (or linear gray) profile |
| Colour profile (`icc-profile` parasite), resolution, guides, comment | yes |
| Grayscale, RGB | as such; indexed images open as RGB |
| Extra channels, the saved selection | yes (alpha channels, the selection) |
| Text, vector and link layers | as their pixels, with a note |
| Compression | none, RLE, zlib |
| XCF versions | 0 to 26 (GIMP 1.x to 3.2); newer files open with a note, unknown parts skipped |
Not kept, each with a note on open: non-destructive layer effects (GIMP 3's layer filters; the
layer opens unfiltered), paths, floating selections, item sets, sample points. Layer effects are
not representable without GEGL's operations; paths could follow later.
## Blend modes
| GIMP | PhotoCraft |
|---|---|
| Normal, Dissolve, Multiply, Screen, Overlay, Difference, Darken only, Lighten only, Divide, Hard light, Vivid light, Pin light, Linear light, Hard mix, Exclusion, Linear burn, Subtract | the same |
| Addition | Linear Dodge (Add) |
| Dodge, Burn | Color Dodge, Color Burn |
| Pass through (groups) | Pass Through |
| Soft light (both the 2.10 and the legacy one) | Soft Light (GIMP's formula differs a little; noted) |
| HSV Hue / Saturation / Value, HSL Color, LCH Hue / Chroma / Color / Lightness, Luminance | Hue, Saturation, Luminosity, Color (Photoshop's HSL-based modes; noted) |
| Luma darken only, Luma lighten only | Darker Color, Lighter Color (noted) |
| Behind, Grain extract, Grain merge, Color erase, Erase, Merge, Split | Normal, with a note |
## How the composite differs from GIMP
Three of GIMP's defaults have no Photoshop counterpart, so a file opened in PhotoCraft can render
a little differently from GIMP; the first two are noted on open when they apply:
- **Linear light.** Since 2.10 GIMP blends most modes and composites every layer (alpha blending
too) in linear light by default. PhotoCraft, like Photoshop, composes in the document's own
encoding. Semi-transparent edges, masks and most blend modes therefore render slightly
differently: in the test files, a Multiply layer at 60% differs by up to 38/255 in one channel,
anti-aliased text edges by up to 73/255 at the edge pixels. A layer whose blend and composite
spaces GIMP set to *RGB (perceptual)* matches PhotoCraft within 2.5/255 (the `-perceptual` test
files).
- **Clip to backdrop.** GIMP composites every mode but Normal with *Clip to backdrop*: the layer
shows nothing where the layers beneath it are transparent. PhotoCraft shows it there. The
difference only appears over transparent areas of the stack.
- **Clamping after alpha.** For modes whose result can leave 0..1 (Addition, Subtract, Divide,
Dodge, Burn, Linear light), GIMP applies the pixel's alpha and the layer's opacity first and
clamps at the end; Photoshop clamps the blend result first. Fully opaque pixels at 100% match
exactly; translucent ones differ where the raw result overshoots.
Modes without an equivalent open as Normal, and the HSV/LCH/luma modes use Photoshop's HSL-based
formulas, so those layers render differently; the note names each layer.
## Limits and safety
Written from the public XCF specification (https://developer.gimp.org/core/standards/xcf/) and
files GIMP 3.2 writes; no GIMP code. Every read is bounds-checked and returns an error on a
malformed file, never a panic: canvases at most 300,000 px a side and 1 GiB of decoded pixels,
at most 8,192 layers and 1,024 channels, nesting at most 64 deep, property payloads at most
64 MiB, strings 1 MiB, gzipped files inflating to at most 1 GiB. Tile data is decoded into the
exact size the drawable declares; a run that crosses a tile or a zlib stream that ends early is an
error. The property lengths the specification warns about (`PROP_COLORMAP`, `PROP_USER_UNIT`) are
computed from the payload instead of trusted. Truncated files are covered by tests at every cut.
## Verification
Unit tests (`crates/io/src/xcf/tests.rs`) build XCF files from the specification with a small
writer and check every compression, precisions, pointer widths, groups by item path, masks,
channels, the colour map, parasites, limits and malformed input.
Real files come from GIMP 3.2.6 itself: `gimp/` of [photocraft-corpus](https://github.com/storytold/photocraft-corpus)
(generator `tools/gimp-xcf/xcf_fixtures.py`, run inside GIMP), fetched into `corpus/gimp/` by
`cargo xtask corpus --gimp` (28 files, 251 KB): procedural layers the tests regenerate sample for
sample, and for each file GIMP's own merged rendering as a PNG. `crates/io/tests/xcf.rs` (feature
`corpus`, run by `cargo xtask test-corpus -p io`) checks the layer data against the formulas and our
composite against GIMP's:
| File | Composite vs GIMP |
|---|---|
| `layers-8bit-perceptual`, `mask-perceptual`, `text-perceptual` | within 2.5/255 everywhere |
| `gray16`, `indexed`, `channel` | within 2/255 |
| `groups-perceptual` | within 2.5/255 except where Clip to backdrop applies (15% of pixels) |
| `legacy-modes-perceptual` | Addition, Subtract, Divide, Dodge, Burn, Hard light, Exclusion, Linear light within 2.5/255 on opaque pixels (translucent ones differ by the clamping order above); the approximated modes reported |
| the default (linear-light) files | the known gap, measured: up to 38/255 (Multiply), 59/255 (mask edge), 73/255 (text edge) |
+28
View File
@@ -0,0 +1,28 @@
cd8d886607bc407c11c3b3ff1d95d43c80e5ca6c0dbe32bf0b206cc7cd3504a7 channel.png
89e0cdacd88ab5e705fd02b419327979a279f18ce6b99b40f6c5717dfe486a2e channel.xcf
cc434c6c01a4afd2bffd18061de9d3161516dd92f24c2477d61a780f5dc5ca59 float-linear.png
e6729fa602ccc2b4c5eef2f399ca78a35c4bc0910d4e05173de415a81118ffc5 float-linear.xcf
f2107656985c9c7c7c1c627435d0429844ce55bb00918a893c07fe391a2e6a14 gray16.png
9b17c916b3f89ada8bd8bc9602ac4d2064d30c79462518914ffcc2471fc79db7 gray16.xcf
97c7cd9fb8f7a64dc23f15a120dfc00707a84189820d51f78c0d46c8774e7582 groups-perceptual.png
2799a2de44db4d203a817edd719c4d55ef9de3e7e5376cabf8bdae1f47eec4d5 groups-perceptual.xcf
6be6bb24d71e329f61318d4663ceedb1d58a66739910156179a18e6bc8c7bebf groups.png
f69c752f4de23cdd1e12463502a9c9ae3af684df66c6f8e0cf947364e01e6a52 groups.xcf
0d19a18bbdf9a28eb0699de52e4b3689ccf4895dc43413ff8c7df3ecaab19f4b indexed.png
96c48a46e5b3e220f274fdb515cc24c4e3ceae609f0b4d68e60b9461b76bdbc2 indexed.xcf
5ae4ec19ac8b056c22cca9d6d752bddac606fdf7ba983d60956da261252dcf36 layers-8bit-perceptual.png
30080fcbb90075b45a1ad6ab674ff9df66127f27f95a3c979c9dea87523a52fa layers-8bit-perceptual.xcf
5ab8111ad492730a7e90ae1439eaa8482f32d546719c6a002abf1de58ab7b18d layers-8bit.png
b408c8f746a3a790781703291c59b3cd3d185a967327480a4bd4bbe39d8f8bce layers-8bit.xcf
54313f695507d20fa8ab7dc7ec82be59af5b86afdc21c7b662d56f2465776c0a legacy-modes-perceptual.png
9c3bc560a1a82dde34e1c41f79a0ddb277788b0cac55cf91294d9eddb2126a72 legacy-modes-perceptual.xcf
96cccee5d525a3c00a68cffd7fec285d70c8213c933ef88737ea9c1e4d740ea8 legacy-modes.png
f2bdb5c8e7ee2e7d0fac4ad12fb4919366bcc84cdf22ec091b70eb148f95fc36 legacy-modes.xcf
34925fd060e4e58a85fe8b3934ad7a4ff82287a54e385978a4f14124c393d25b mask-perceptual.png
d932f76b5feaf4f229157a3da75c7e5c85602b3b6b890a3396acc87c2ae4f31c mask-perceptual.xcf
1d21f0b2562032f0b319e02b9951de7333483cb6dfbaeeb3a5a266225da39596 mask.png
2fc690147c3fcda0ab56c6c76aa5709a57671ed65d58759b0ebaa7ac4a92432f mask.xcf
9a17b6f6b460d8b880cfceaa0906f99b9c0a45487a7e7b0738b814dabc2fa558 text-perceptual.png
819633fb66710f02e575ae2bea79220717537287fe59bef52542b187cd3b9eca text-perceptual.xcf
21702736824b70ca50bc9c53f6fa182cc9a5f173f6cc0e1c53fca5a454ec882e text.png
a6928228a237a1c611de0296ea42bfd713e40e069654a9d4ae57c89222fa1d2b text.xcf
+19 -5
View File
@@ -8,7 +8,8 @@ use std::process::{Child, Command, Stdio};
use std::sync::atomic::{AtomicU64, Ordering};
use crate::corpus_pins::{
self, AG_PSD_COMMIT, HEIC_RS_COMMIT, OPENEXR_COMMIT, PHOTOCRAFT_CORPUS_COMMIT, PILLOW_HEIF_COMMIT, PNGSUITE_SHA256, PNGSUITE_URL, PSD_TOOLS_COMMIT,
self, AG_PSD_COMMIT, HEIC_RS_COMMIT, OPENEXR_COMMIT, PHOTOCRAFT_CORPUS_COMMIT, PHOTOCRAFT_CORPUS_GIMP_COMMIT, PILLOW_HEIF_COMMIT, PNGSUITE_SHA256,
PNGSUITE_URL, PSD_TOOLS_COMMIT,
};
use crate::pinned::{PinnedCorpus, USER_AGENT};
use crate::{cargo, root, run};
@@ -34,7 +35,7 @@ const CRITICAL: &[&str] = &[
"crates/affinity/",
"crates/raw/",
];
/// `cargo xtask corpus [--all | --pngsuite | --download | --psd | --psd-tools | --heif | --exr | --pixls | --affinity | --photoshop [--local]] [--update-manifest]`
/// `cargo xtask corpus [--all | --pngsuite | --download | --psd | --psd-tools | --heif | --exr | --pixls | --affinity | --photoshop [--local] | --gimp [--local]] [--update-manifest]`
pub fn cmd(args: &[&str]) -> Result<(), String> {
let update = args.contains(&"--update-manifest");
let mut did = false;
@@ -77,6 +78,14 @@ pub fn cmd(args: &[&str]) -> Result<(), String> {
}
did = true;
}
if args.contains(&"--gimp") {
if args.contains(&"--local") {
corpus_pins::GIMP.fetch_local(&local_clone()?, update)?;
} else {
corpus_pins::GIMP.fetch(update)?;
}
did = true;
}
if !did {
list();
}
@@ -95,7 +104,7 @@ fn local_clone() -> Result<PathBuf, 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. With
/// `corpus/photoshop` and `corpus/gimp` are copied from the authoring clone of photocraft-corpus instead. With
/// `update`, every pinned corpus is refetched and its manifest rewritten (`--update-manifest`).
pub fn fetch_all(local: bool, update: bool) -> Result<(), String> {
fetch_pngsuite()?;
@@ -105,10 +114,11 @@ pub fn fetch_all(local: bool, update: bool) -> Result<(), String> {
}
/// Calls `fetch(corpus, from_clone, update)` for every pinned corpus; `from_clone` is set for
/// `corpus/photoshop` when `local`.
/// the photocraft-corpus sets (`corpus/photoshop`, `corpus/gimp`) when `local`.
fn fetch_pinned(local: bool, update: bool, mut fetch: impl FnMut(&PinnedCorpus, bool, bool) -> Result<(), String>) -> Result<(), String> {
for c in corpus_pins::ALL {
fetch(c, local && std::ptr::eq(*c, &corpus_pins::PHOTOSHOP), update)?;
let ours = std::ptr::eq(*c, &corpus_pins::PHOTOSHOP) || std::ptr::eq(*c, &corpus_pins::GIMP);
fetch(c, local && ours, update)?;
}
Ok(())
}
@@ -136,6 +146,9 @@ fn list() {
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/gimp/ [{}] our GIMP-authored XCF oracles with GIMP's PNG renderings (MIT OR Apache-2.0),
from the same repository at {PHOTOCRAFT_CORPUS_GIMP_COMMIT}
manifest xtask/gimp-corpus.sha256. Fetch: --gimp (--gimp --local copies from the clone)
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}
@@ -153,6 +166,7 @@ fn list() {
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::GIMP.is_current()),
status(corpus_pins::PSD_MIXED.is_current()),
&PSD_TOOLS_COMMIT[..12],
&AG_PSD_COMMIT[..12],
+29 -1
View File
@@ -14,6 +14,9 @@ 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 = "f5b1178cab15309e05b7b504545df3c701f6d8fd";
/// https://github.com/storytold/photocraft-corpus: our GIMP-authored XCF oracles (`gimp/`). Its own
/// pin, so that set can move without refetching `photoshop/`. Bump through a PR after committing there.
pub const PHOTOCRAFT_CORPUS_GIMP_COMMIT: &str = "fbe9227a48d06de87061e527541209a178fd685c";
/// 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).
@@ -69,6 +72,31 @@ pub const PHOTOSHOP: PinnedCorpus = PinnedCorpus {
},
};
/// `corpus/gimp`: 14 XCF files written by GIMP 3.2.6 by the photocraft-corpus generator, each with
/// GIMP's merged rendering as a PNG (28 files), the oracles of `photocraft-io`'s XCF reader.
pub const GIMP: PinnedCorpus = PinnedCorpus {
name: "gimp",
dest: "gimp",
upstreams: &[Upstream {
prefix: "",
repo: "storytold/photocraft-corpus",
commit: PHOTOCRAFT_CORPUS_GIMP_COMMIT,
subdir: "gimp",
extras: &[("README.md", "README.md"), ("LICENSE-MIT", "LICENSE-MIT"), ("LICENSE-APACHE", "LICENSE-APACHE")],
}],
manifest: "xtask/gimp-corpus.sha256",
exts: &["xcf", "png"],
subset: false,
sources_md: |c| {
format!(
"# GIMP XCF oracle corpus\n\n`gimp/` of https://github.com/storytold/photocraft-corpus at commit {PHOTOCRAFT_CORPUS_GIMP_COMMIT} \
(see `README.md`). MIT OR Apache-2.0, authored by the PhotoCraft contributors with GIMP 3.2.6. Fetched and sha256-verified by \
`cargo xtask corpus --gimp` 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",
@@ -240,7 +268,7 @@ pub const AFFINITY: PinnedCorpus = PinnedCorpus {
},
};
/// Every pinned corpus, in fetch order.
pub const ALL: &[&PinnedCorpus] = &[&PSD_MIXED, &PSD_TOOLS, &PHOTOSHOP, &HEIF, &EXR, &AFFINITY];
pub const ALL: &[&PinnedCorpus] = &[&PSD_MIXED, &PSD_TOOLS, &PHOTOSHOP, &GIMP, &HEIF, &EXR, &AFFINITY];
/// `corpus/pixls`: real camera raws from <https://raw.pixls.us> (released into the public
/// domain / CC0 by their photographers; see ATTRIBUTION.md). One file per decode path
+3 -3
View File
@@ -25,15 +25,15 @@ 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 [--all | --pngsuite | --psd | --psd-tools | --pixls | --photoshop] [--local] [--update-manifest]
corpus [--all | --pngsuite | --psd | --psd-tools | --pixls | --photoshop | --gimp] [--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 but pixls,
--photoshop --local copies from ../photocraft-corpus or $PHOTOCRAFT_CORPUS_REPO)
--photoshop/--gimp --local copy from ../photocraft-corpus or $PHOTOCRAFT_CORPUS_REPO)
test-corpus [-p <crate>]... [--changed] [--pixls] [--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/raw changed;
--pixls (or -p raw) also fetches corpus/pixls and runs the raw corpus tests;
--local takes corpus/photoshop from the photocraft-corpus authoring clone
--local takes corpus/photoshop and corpus/gimp 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-checklist.md
i18n-coverage report stable UI translation coverage for registered languages