Compare commits

..
Author SHA1 Message Date
Dylan M. Taylor 86953e0ebf security: enforce HTTPS on curl downloads (SonarCloud S6506)
Add --proto '=https' to curl invocations so redirects cannot downgrade to
plaintext HTTP. Resolves SonarCloud S6506 (shell/docker/githubactions).
2026-10-21 17:07:12 -04:00
skylar taylor-barrick 6d8cbac7dc Merge pull request #2945 from diegochagas/feat/xcf-read
Open GIMP XCF files: layers, groups, masks, blend modes, channels and selection
2026-10-21 14:35:32 -06:00
skylar taylor-barrick 18e2d45420 Merge pull request #6318 from robertoffmoura/fix/floating-selection-move-lag
Fix lag when dragging a floating selection with the Move tool
2026-10-21 14:25:55 -06:00
Dylan M. Taylor 87fa191818 ci: add sonar-project.properties for CI-based analysis
Marks the project ineligible for SonarCloud Automatic Analysis, resolving the
CI-vs-automatic-analysis conflict so the SonarCloud workflow succeeds.
2026-10-21 16:00:47 -04:00
Diego Chagas ca18689304 Open GIMP XCF files: layers, groups, masks, modes, channels and selection (#1076)
PhotoCraft reads GIMP's .xcf (and .xcf.gz) files, read only, through a new
reader in photocraft-io written from the public XCF specification: versions
0 to 26, uncompressed, RLE and zlib tiles, 8/16/32-bit integer and
16/32/64-bit float precisions (linear precisions get a linear profile),
pixel layers at any position, groups by item path with pass-through,
layer masks applied or disabled, opacity, visibility, locks, colour tags,
the 2.10 and legacy blend modes mapped to Photoshop's (modes without an
equivalent open as Normal with a note), the ICC profile, resolution,
guides, the comment, extra channels and the saved selection; indexed
images open as RGB. Text, vector and link layers open as their pixels;
layer effects, paths and floating selections are reported.

Fixtures are written by GIMP 3.2 itself (scripts/xcf_fixtures.py), each
with GIMP's merged rendering as a PNG. In gamma-encoded blend and
composite spaces our composite matches GIMP within 0.5/255; GIMP's
linear-light default, its Clip to backdrop and its clamp-after-alpha
for unbounded modes are documented and measured in docs/xcf.md.
2026-10-21 16:57:22 -03:00
Dylan M. Taylor 8e518d8169 ci: add SonarCloud scan via shared reusable workflow
Calls storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
with secrets: inherit. Adds SonarCloud badge to README where missing and
removes the redundant sonar-project.properties (config now passed by the
reusable workflow).
2026-10-21 15:47:04 -04:00
Roberto Moura f4ea3139d7 Keep selection snap targets when dragging a guide with the Move tool 2026-10-21 16:37:54 +00:00
Roberto Moura 8352b26c1a Fix lag when dragging a floating selection with the Move tool
- Replace whole-region Vec<f32> read_region/write_region in CutParts::new
  with tile-parallel cut_piece + try_fill_surface/try_clear_surface,
  sharing interior Arc<Tile> allocations in O(1) and activating the
  solid_tile hole-fill fast path on Background and mask targets.
- Cache piece_bounds on CutParts so CutParts::place avoids scanning tile
  content bounds on every drag frame.
- Parallelize composite_over across top tiles with Rayon and share fully
  opaque top tiles by Arc<Tile> clone in O(1), preserving COW tile
  identity for GPU canvas uploads.
- Parallelize Surface::translated across target tiles with Rayon and
  prune cleared tiles in-place inside edit_tiles.
- Skip zero-offset floating preview generation on pointer-down in
  move_ui::floating_doc.
- Exclude SnapKind::Selection when moving selected pixels with the Move
  tool so small initial drags do not snap back to (0, 0).
- Keep navigator_plain active during Move/Brush/Patch drags on a GPU
  canvas so navigator_gpu is not evicted into a synchronous full-document
  CPU thumbnail composite on the first drag frame.
- Parallelize row normalization in compose::render_reduced_in_bands and
  add a direct read_rgba_into fast path for unmasked Normal layers over
  an empty backdrop.
2026-10-21 13:21:10 +00:00
31 changed files with 2746 additions and 91 deletions

No files matched your search

+1 -1
View File
@@ -361,7 +361,7 @@ jobs:
cache-on-failure: true
- name: Install trunk
run: |
curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \
curl --proto '=https' -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \
| tar -xz -C "$HOME/.cargo/bin" trunk
trunk --version
# package.sh runs Trunk's release builds, validates relative URLs, cuts the wasm into
+20
View File
@@ -0,0 +1,20 @@
name: SonarCloud
on:
push:
branches: [main]
pull_request:
types: [opened, synchronize, reopened]
permissions:
contents: read
concurrency:
group: sonarcloud-${{ github.ref }}
cancel-in-progress: true
jobs:
sonarcloud:
# Tracked at @main so org-wide CI improvements propagate without PR churn.
uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
secrets: inherit
+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) |
+2 -2
View File
@@ -25,8 +25,8 @@ RUN set -eu; \
asset="trunk-${arch}-unknown-linux-musl.tar.gz"; \
url="https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}"; \
cd /tmp; \
curl -fsSL --retry 3 -A Photocraft-dev "$url/$asset" -o "$asset"; \
curl -fsSL --retry 3 -A Photocraft-dev "$url/$asset.sha256" -o "$asset.sha256"; \
curl --proto '=https' -fsSL --retry 3 -A Photocraft-dev "$url/$asset" -o "$asset"; \
curl --proto '=https' -fsSL --retry 3 -A Photocraft-dev "$url/$asset.sha256" -o "$asset.sha256"; \
printf '%s %s\n' "$(cat "$asset.sha256")" "$asset" | sha256sum -c -; \
tar -xzf "$asset" -C /usr/local/bin trunk; \
rm "$asset" "$asset.sha256"; \
+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),
+48 -5
View File
@@ -689,11 +689,33 @@ fn render_reduced_with(doc: &Document, b: Rect, w: u32, h: u32, damage: Option<R
});
debug_assert!(ok.is_ok());
}
for (i, p) in acc.iter_mut().enumerate() {
let ((x0, x1), (y0, y1)) = (span(ox0 + i % ow, fw, w), span(oy0 + i / ow, fh, h));
let n = ((y1 - y0) * (x1 - x0)).max(1) as f32;
let a = p[3];
*p = if a > 0.0 { [p[0] / a, p[1] / a, p[2] / a, a / n] } else { [0.0; 4] };
let col_w: Vec<usize> = (0..ow)
.map(|x| {
let (x0, x1) = span(ox0 + x, fw, w);
(x1 - x0).max(1)
})
.collect();
let norm_row = |row: usize, out: &mut [[f32; 4]]| {
let (y0, y1) = span(oy0 + row, fh, h);
let rh = (y1 - y0).max(1);
for (p, &cw) in out.iter_mut().zip(&col_w) {
let a = p[3];
if a > 0.0 {
let n = (rh * cw) as f32;
*p = [p[0] / a, p[1] / a, p[2] / a, a / n];
} else {
*p = [0.0; 4];
}
}
};
#[cfg(not(target_arch = "wasm32"))]
{
use rayon::prelude::*;
acc.par_chunks_mut(ow).enumerate().for_each(|(row, out)| norm_row(row, out));
}
#[cfg(target_arch = "wasm32")]
for (row, out) in acc.chunks_mut(ow).enumerate() {
norm_row(row, out);
}
Buffer { rect: out, px: acc }
}
@@ -1840,6 +1862,27 @@ fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer
blend_into_fill(backdrop, &content, layer.blend, layer.opacity, layer.fill_opacity, 1.0);
return;
}
if !matches!(layer.content, LayerContent::Group(_) | LayerContent::Fill(_) | LayerContent::Adjustment(_))
&& let Some(s) = layer.surface()
&& layer.blend == BlendMode::Normal
&& opacity >= 1.0
&& !clipped.iter().any(|c| c.visible)
&& layer.mask.is_none()
&& layer.vector_mask.is_none()
&& text_gamma(layer) == 1.0
&& !psblend::LAB_MIX.with(|l| l.get())
&& backdrop.px.iter().all(|p| p[3] <= 0.0)
{
s.read_rgba_into(rect, &mut backdrop.px);
for p in &mut backdrop.px {
if p[3] <= 0.0 {
*p = [0.0; 4];
} else if p[3] < 1.0 {
*p = psblend::composite_normal_rgb([0.0; 4], *p, 1.0);
}
}
return;
}
if clipped.is_empty()
&& let Some(surf) = pixel_surface(layer)
{
+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"));
}
+114 -29
View File
@@ -32,6 +32,7 @@ pub struct CutParts {
pub layer: LayerId,
rest: Surface,
piece: Surface,
piece_bounds: photocraft_geom::Rect,
/// The pieces are the layer's mask (`"target":"mask"`), not its pixels.
mask: bool,
}
@@ -55,41 +56,27 @@ impl CutParts {
};
let fmt = surf.format();
let with_alpha = PixelFormat::new(fmt.mode, fmt.sample, true);
let full = if fmt == with_alpha { surf.clone() } else { surf.convert(with_alpha) };
// A mask has no alpha: it keeps its format, and the cut leaves background colour.
let mut rest = if mask { surf.clone() } else { full.clone() };
let base = if mask || fmt == with_alpha { surf.clone() } else { surf.convert(with_alpha) };
let mut rest = base.clone();
let mut piece = Surface::new(with_alpha);
let fill = !copy && (mask || doc.is_background(layer));
// A mask's untouched pixels (its default, e.g. white) move too, so take the whole canvas.
let b = sel.content_bounds().intersect(&if mask { doc.bounds() } else { surf.content_bounds() });
let b = sel.content_bounds().intersect(&if mask { doc.bounds() } else { base.content_bounds() });
if !b.is_empty() {
let n = with_alpha.channels();
let a = n - 1;
let mut rp = full.read_region(b);
let mut pp = rp.clone();
let w = b.width() as usize;
for (i, (p, r)) in pp.chunks_exact_mut(n).zip(rp.chunks_exact_mut(n)).enumerate() {
let k = sel.sample_channel(b.x0 + (i % w) as i32, b.y0 + (i / w) as i32, 0);
if k <= 0.0 {
p.fill(0.0);
} else {
p[a] *= k;
}
if !copy && !fill {
r[a] *= 1.0 - k;
}
}
if !mask {
rest.write_region(b, &rp);
}
piece = cut_piece(&base, sel, b, with_alpha)?;
if fill {
crate::pixels::try_fill_surface(&mut rest, b, background, Some(sel), true)?;
let opaque_bg = [background[0], background[1], background[2], 1.0];
crate::pixels::try_fill_surface(&mut rest, b, opaque_bg, Some(sel), false)?;
if mask {
rest.prune();
}
} else if !copy && !mask {
crate::pixels::try_clear_surface(&mut rest, b, Some(sel))?;
}
rest.prune();
piece.write_region(b, &pp);
piece.prune();
}
Ok(Self { layer, rest, piece, mask })
let piece_bounds = piece.content_bounds();
Ok(Self { layer, rest, piece, piece_bounds, mask })
}
/// `doc` with the piece moved by whole pixels (dx, dy) over the cut-out layer.
@@ -107,10 +94,14 @@ impl CutParts {
} else {
l.surface_mut().ok_or_else(|| EngineError::Other("the layer has no pixels to move".into()))?
};
if self.piece_bounds.is_empty() {
*surf = self.rest.clone();
return Ok(());
}
let mut out = self.rest.clone();
let piece = photocraft_algo::resample::translate_surface(&self.piece, dx, dy);
let piece = self.piece.translated(dx, dy, self.piece_bounds);
if self.mask {
crate::fill_cmds::composite_over(&mut out, &piece, piece.content_bounds(), None)?;
crate::fill_cmds::composite_over(&mut out, &piece, self.piece_bounds.translate(dx, dy), None)?;
} else {
crate::transform_cmds::composite_over(&mut out, &piece);
}
@@ -119,6 +110,100 @@ impl CutParts {
}
}
/// Extract the selected pixels of `base` over `b` into a `with_alpha` surface, tile by tile in
/// parallel. Fully selected tiles on an alpha surface share `base`'s `Arc<Tile>` directly.
fn cut_piece(base: &Surface, sel: &Surface, b: photocraft_geom::Rect, with_alpha: PixelFormat) -> Result<Surface> {
use photocraft_color::{SampleType, read_sample};
use photocraft_geom::{TILE_SIZE, TileCoord};
use photocraft_raster::Tile;
use rayon::prelude::*;
let mut piece = Surface::new(with_alpha);
let src_fmt = base.format();
let same_fmt = src_fmt == with_alpha;
let n = with_alpha.channels();
let a = n - 1;
let dst_bpp = with_alpha.bytes_per_pixel();
let src_bpp = src_fmt.bytes_per_pixel();
let sb = with_alpha.sample.bytes();
let alpha_off = dst_bpp - sb;
let src_empty_default = same_fmt && base.default_bytes().iter().all(|&v| v == 0);
let oom = |e| EngineError::Other(format!("not enough memory while floating selection ({e}); the document was not changed"));
let mut coords = crate::allocation::capacity(b.tiles().count(), "floating selection")?;
coords.extend(b.tiles());
let tiles = coords
.into_par_iter()
.map(|tc| -> Result<Option<(TileCoord, Arc<Tile>)>> {
let tr = tc.rect().intersect(&b);
let mask = crate::fill_cmds::Mask::of(sel, tc);
if mask.empty(tr) {
return Ok(None);
}
let src_tile = base.tile(tc);
if same_fmt && src_tile.is_none() && src_empty_default {
return Ok(None);
}
if same_fmt
&& tr == tc.rect()
&& mask.full(tr)
&& let Some(t) = src_tile
{
return Ok(Some((tc, t.clone())));
}
let src_bytes = src_tile.map_or(base.default_bytes(), |t| t.bytes());
let mut out = piece.try_default_tile().map_err(oom)?;
let out_bytes = out.bytes_mut();
let mut px = [0.0f32; 8];
let mut has_content = false;
for y in tr.y0..tr.y1 {
let row = (y - tc.ty * TILE_SIZE) as usize * TILE_SIZE as usize;
for x in tr.x0..tr.x1 {
let k = mask.at(x, y);
if k <= 0.0 {
continue;
}
let idx = row + (x - tc.tx * TILE_SIZE) as usize;
let dst_i = idx * dst_bpp;
let Some(dst_px) = out_bytes.get_mut(dst_i..dst_i + dst_bpp) else { continue };
let src_i = if src_bytes.len() == src_bpp { 0 } else { idx * src_bpp };
let Some(src_px) = src_bytes.get(src_i..src_i + src_bpp) else { continue };
if same_fmt && k >= 1.0 {
if src_px.iter().any(|&v| v != 0) {
dst_px.copy_from_slice(src_px);
has_content = true;
}
continue;
}
if same_fmt {
for (c, v) in px.iter_mut().enumerate().take(n) {
*v = read_sample(src_px, src_fmt.sample, c);
}
px[a] *= k;
} else {
for (c, v) in px.iter_mut().enumerate().take(a) {
*v = read_sample(src_px, src_fmt.sample, c);
}
px[a] = k;
}
if px[a] <= 0.0 {
continue;
}
photocraft_raster::encode_pixel(&with_alpha, &px[..n], dst_px);
let non_zero = match with_alpha.sample {
SampleType::U8 => dst_px.get(alpha_off) != Some(&0) || dst_px.iter().any(|&v| v != 0),
SampleType::U16 => dst_px.get(alpha_off..dst_bpp) != Some(&[0, 0]) || dst_px.iter().any(|&v| v != 0),
SampleType::F32 => dst_px.iter().any(|&v| v != 0),
};
has_content |= non_zero;
}
}
Ok(has_content.then(|| (tc, Arc::new(out))))
})
.collect::<Result<Vec<_>>>()?;
piece.put_tiles(tiles.into_iter().flatten());
Ok(piece)
}
/// A piece floating `offset` whole pixels from where it was cut, over document `revision`.
#[derive(Clone, Debug)]
pub struct Floating {
+60
View File
@@ -226,3 +226,63 @@ fn cutting_an_untouched_part_of_the_mask_moves_it_and_leaves_the_background_colo
assert!((mask_at(&s, 12, 12) - 0.5).abs() < 0.01, "the hole takes the background colour");
assert_eq!(mask_at(&s, 40, 12), 0.0);
}
#[test]
fn float_shares_interior_tiles_on_cut_and_place() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 1024, "height": 1024})).unwrap();
s.tools.background = [0.2, 0.4, 0.8, 1.0];
let id = s.active().unwrap().active_layer.unwrap();
// Paint non-uniform pixels so original tiles are distinct.
s.edit("pattern", |doc, _| {
let surf = doc.layer_mut(id).unwrap().surface_mut().unwrap();
for ty in 0..4 {
for tx in 0..4 {
surf.write_pixel(tx * 256 + 5, ty * 256 + 5, &[tx as f32 * 0.25, ty as f32 * 0.25, 0.5, 1.0]);
}
}
Ok(())
})
.unwrap();
// Select (100..900)^2, which fully covers interior tiles (1, 1) and (2, 2).
s.execute("select.rect", json!({"x": 100, "y": 100, "width": 800, "height": 800})).unwrap();
s.execute("select.float", json!({})).unwrap();
let st = s.active().unwrap();
let orig = st.doc.layer(id).unwrap().surface().unwrap();
let parts = &floating(st).unwrap().parts;
let tc1 = photocraft_geom::TileCoord { tx: 1, ty: 1 };
let tc2 = photocraft_geom::TileCoord { tx: 2, ty: 2 };
// Interior tiles of `piece` share the original layer's Arc<Tile>.
assert!(Arc::ptr_eq(orig.tile(tc1).unwrap(), parts.piece.tile(tc1).unwrap()));
assert!(Arc::ptr_eq(orig.tile(tc2).unwrap(), parts.piece.tile(tc2).unwrap()));
// Interior tiles of the Background hole in `rest` share one solid fill Arc<Tile>.
assert!(Arc::ptr_eq(parts.rest.tile(tc1).unwrap(), parts.rest.tile(tc2).unwrap()));
// Moving by a whole tile (256, 0) shares the interior tile directly in the preview document.
let shown = displayed(st, (256, 0)).unwrap();
let shown_surf = shown.layer(id).unwrap().surface().unwrap();
let moved_tc = photocraft_geom::TileCoord { tx: 2, ty: 1 };
assert!(Arc::ptr_eq(orig.tile(tc1).unwrap(), shown_surf.tile(moved_tc).unwrap()));
}
#[test]
fn float_feathered_selection_preserves_partial_alpha_across_depths() {
for depth in [8, 16, 32] {
let mut s = Session::new();
s.execute("file.new", json!({"width": 120, "height": 80, "background": "transparent", "depth": depth})).unwrap();
let id = s.active().unwrap().active_layer.unwrap();
s.edit("paint", |doc, _| {
doc.layer_mut(id).unwrap().surface_mut().unwrap().fill_rect(Rect::new(0, 0, 55, 80), &[1.0, 0.0, 0.0, 1.0]);
Ok(())
})
.unwrap();
s.execute("select.rect", json!({"x": 20, "y": 20, "width": 30, "height": 30, "feather": 4.0})).unwrap();
s.execute("select.float", json!({"dx": 40, "dy": 0})).unwrap();
s.execute("select.drop", json!({})).unwrap();
let surf = s.active().unwrap().doc.layer(id).unwrap().surface().unwrap();
// Center of moved piece is opaque red; center of hole is transparent; feathered edge has partial alpha.
assert!(surf.rgba(75, 35)[3] > 0.95, "{depth}-bit moved center");
assert!(surf.rgba(35, 35)[3] < 0.05, "{depth}-bit hole center");
let edge_a = surf.rgba(60, 35)[3];
assert!(edge_a > 0.1 && edge_a < 0.9, "{depth}-bit feathered edge alpha={edge_a}");
}
}
+17 -9
View File
@@ -239,16 +239,21 @@ pub(crate) fn try_fill_surface(s: &mut Surface, area: Rect, color: [f32; 4], sel
let fmt = s.format();
// Coverage 1 of an opaque colour replaces the colour (and the alpha, unless it is kept).
let opaque = color[3] == 1.0 && (!lock_transparency || !fmt.alpha) && selection.is_none_or(|m| m.format().sample != photocraft_color::SampleType::F32);
let solid = opaque.then(|| {
let mut enc = [0.0f32; 8];
let n = photocraft_raster::from_rgba_into(&fmt, [color[0], color[1], color[2], 1.0], &mut enc);
s.solid_tile(enc.get(..n).unwrap_or(&[]))
});
let mut enc = [0.0f32; 8];
let enc_len = opaque.then(|| photocraft_raster::from_rgba_into(&fmt, [color[0], color[1], color[2], 1.0], &mut enc));
let solid = enc_len.map(|n| s.solid_tile(enc.get(..n).unwrap_or(&[])));
let whole = |full: bool| solid.as_ref().filter(|_| full).map(|t| TileEdit::Put(t.clone()));
edit_tiles(s, area, selection, "filling pixels", whole, |k, px| {
if k <= 0.0 {
return false;
}
if k >= 1.0
&& let Some(m) = enc_len
&& let Some(src) = enc.get(..px.len().min(m))
{
px.copy_from_slice(src);
return true;
}
fill_pixel(&fmt, px, color, k, lock_transparency);
true
})
@@ -294,9 +299,7 @@ pub(crate) fn try_clear_surface(s: &mut Surface, area: Rect, selection: Option<&
px.fill(0.0);
}
true
})?;
s.prune();
Ok(())
})
}
/// A whole-tile shortcut of [`edit_tiles`].
@@ -331,6 +334,7 @@ fn edit_tiles(
coords.extend(area.tiles());
// Untouched pixels read as the default where the surface has no tile.
let blank = if coords.iter().any(|&tc| s.tile(tc).is_none()) { Some(s.try_default_tile().map_err(oom)?) } else { None };
let dp = s.default_bytes();
let surf = &*s;
let edits = coords
.into_par_iter()
@@ -371,7 +375,11 @@ fn edit_tiles(
}
}
}
Ok(out.map(|t| (tc, Some(Arc::new(t)))))
Ok(match out {
Some(t) if bpp > 0 && t.bytes().chunks_exact(bpp).all(|p| p == dp) => current.map(|_| (tc, None)),
Some(t) => Some((tc, Some(Arc::new(t)))),
None => None,
})
})
.collect::<crate::Result<Vec<_>>>()?;
for (tc, tile) in edits.into_iter().flatten() {
+113 -2
View File
@@ -514,13 +514,124 @@ pub(crate) fn refresh_text(doc: &Document, l: &mut Layer) {
}
/// Normal-blend `top` over `dst` (both straight alpha, same format).
/// Works tile by tile in parallel on `top`'s allocated tiles: fully opaque tiles share their
/// `Arc<Tile>` directly with `dst`, and opaque/transparent pixels on border tiles copy encoded
/// bytes without a whole-region `f32` round trip.
pub(crate) fn composite_over(dst: &mut Surface, top: &Surface) {
use photocraft_color::{SampleType, read_sample};
use photocraft_geom::TileCoord;
use photocraft_raster::Tile;
use rayon::prelude::*;
use std::sync::Arc;
let fmt = dst.format();
let n = fmt.channels();
if n == 0 || !fmt.alpha || top.format() != fmt {
return;
}
let a = n - 1;
if top.default_pixel().get(a).is_none_or(|&ta| ta <= 0.0) {
let top_tiles: Vec<(TileCoord, Arc<Tile>)> = top.tiles().map(|(&tc, t)| (tc, t.clone())).collect();
if top_tiles.is_empty() {
return;
}
let bpp = fmt.bytes_per_pixel();
let sb = fmt.sample.bytes();
let alpha_off = bpp - sb;
let dst_empty_default = dst.default_bytes().iter().all(|&b| b == 0);
let dst_ref = &*dst;
let edits: Vec<(TileCoord, Arc<Tile>)> = top_tiles
.into_par_iter()
.filter_map(|(tc, top_tile)| {
let tb = top_tile.bytes();
let all_opaque = match fmt.sample {
SampleType::U8 => tb.chunks_exact(bpp).all(|px| px.get(alpha_off) == Some(&u8::MAX)),
SampleType::U16 => tb.chunks_exact(bpp).all(|px| px.get(alpha_off..bpp) == Some(&[u8::MAX, u8::MAX])),
SampleType::F32 => tb.chunks_exact(bpp).all(|px| read_sample(px, SampleType::F32, a) == 1.0),
};
if all_opaque {
return Some((tc, top_tile));
}
let all_transparent = match fmt.sample {
SampleType::U8 => tb.chunks_exact(bpp).all(|px| px.get(alpha_off) == Some(&0)),
SampleType::U16 => tb.chunks_exact(bpp).all(|px| px.get(alpha_off..bpp) == Some(&[0, 0])),
SampleType::F32 => tb.chunks_exact(bpp).all(|px| read_sample(px, SampleType::F32, a) <= 0.0),
};
if all_transparent {
return None;
}
if dst_ref.tile(tc).is_none() && dst_empty_default {
let clean_zero = match fmt.sample {
SampleType::U8 => tb.chunks_exact(bpp).all(|px| px.get(alpha_off) != Some(&0) || px.iter().all(|&b| b == 0)),
SampleType::U16 => tb.chunks_exact(bpp).all(|px| px.get(alpha_off..bpp) != Some(&[0, 0]) || px.iter().all(|&b| b == 0)),
SampleType::F32 => tb.chunks_exact(bpp).all(|px| read_sample(px, SampleType::F32, a) > 0.0 || px.iter().all(|&b| b == 0)),
};
if clean_zero {
return Some((tc, top_tile));
}
}
let mut out_tile = match dst_ref.tile(tc) {
Some(dt) => (**dt).clone(),
None => dst_ref.try_default_tile().ok()?,
};
let mut tp = [0.0f32; 8];
let mut dp = [0.0f32; 8];
for (out_px, top_px) in out_tile.bytes_mut().chunks_exact_mut(bpp).zip(tb.chunks_exact(bpp)) {
match fmt.sample {
SampleType::U8 => {
let ta_b = top_px[alpha_off];
if ta_b == 0 {
continue;
}
if ta_b == u8::MAX {
out_px.copy_from_slice(top_px);
continue;
}
}
SampleType::U16 => {
if top_px[alpha_off..bpp] == [0, 0] {
continue;
}
if top_px[alpha_off..bpp] == [u8::MAX, u8::MAX] {
out_px.copy_from_slice(top_px);
continue;
}
}
SampleType::F32 => {
let ta = read_sample(top_px, SampleType::F32, a);
if ta <= 0.0 {
continue;
}
if ta == 1.0 {
out_px.copy_from_slice(top_px);
continue;
}
}
}
for c in 0..n {
tp[c] = read_sample(top_px, fmt.sample, c);
dp[c] = read_sample(out_px, fmt.sample, c);
}
let (ta, da) = (tp[a], dp[a]);
let oa = ta + da * (1.0 - ta);
if oa > 0.0 {
for c in 0..a {
dp[c] = (tp[c] * ta + dp[c] * da * (1.0 - ta)) / oa;
}
}
dp[a] = oa;
photocraft_raster::encode_pixel(&fmt, &dp[..n], out_px);
}
Some((tc, Arc::new(out_tile)))
})
.collect();
dst.put_tiles(edits);
return;
}
let b = top.content_bounds();
if b.is_empty() {
return;
}
let n = dst.format().channels();
let a = n - 1;
let t = top.read_region(b);
let mut d = dst.read_region(b);
for (dp, tp) in d.chunks_exact_mut(n).zip(t.chunks_exact(n)) {
+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"));
}
+21 -14
View File
@@ -132,19 +132,18 @@ fn sample_buffer<'a>(channels: usize, stack: &'a mut [f32], heap: &'a mut Vec<f3
#[derive(Default)]
struct UniformTiles {
/// Per source tile (by address): the index of its value's tile in `canonical`, or `None`.
of: std::collections::HashMap<*const Tile, Option<usize>>,
of: std::collections::HashMap<usize, Option<usize>>,
canonical: Vec<Arc<Tile>>,
}
impl UniformTiles {
/// The tile to share for a target reading window `r` of `s`, when every tile under it holds
/// one and the same pixel value (`r` must lie on existing tiles).
fn window(&mut self, s: &Surface, r: Rect) -> Option<Arc<Tile>> {
let bpp = s.format.bytes_per_pixel();
fn window(&self, s: &Surface, r: Rect) -> Option<Arc<Tile>> {
let mut found = None;
for c in r.tiles() {
let t = s.tiles.get(&c)?;
let k = self.value_of(t, bpp)?;
let k = (*self.of.get(&(Arc::as_ptr(t) as usize))?)?;
if found.is_some_and(|f| f != k) {
return None;
}
@@ -154,7 +153,8 @@ impl UniformTiles {
}
fn value_of(&mut self, t: &Arc<Tile>, bpp: usize) -> Option<usize> {
if let Some(k) = self.of.get(&Arc::as_ptr(t)) {
let key = Arc::as_ptr(t) as usize;
if let Some(k) = self.of.get(&key) {
return *k;
}
let mut px = t.data.chunks_exact(bpp);
@@ -165,7 +165,7 @@ impl UniformTiles {
self.canonical.len() - 1
}
});
self.of.insert(Arc::as_ptr(t), k);
self.of.insert(key, k);
k
}
}
@@ -830,6 +830,9 @@ impl Surface {
let moved = content.translate(dx, dy);
let targets: std::collections::BTreeSet<TileCoord> = sources().flat_map(|(c, _)| c.rect().translate(dx, dy).intersect(&moved).tiles()).collect();
let mut uniform = UniformTiles::default();
for (_, t) in sources() {
uniform.value_of(t, bpp);
}
// The fill row: one tile row of the default pixel, so the regions a shift vacates
// fill with bulk copies (empty when the default is zero — the tile starts zeroed).
@@ -846,7 +849,7 @@ impl Surface {
// One tile row of the default pixel, to compare a finished tile with a row at a time.
let default_row = self.default_pixel.repeat(TILE_SIZE.unsigned_abs() as usize);
for dc in targets {
let build_target = |dc: TileCoord| -> Option<(TileCoord, Arc<Tile>)> {
let dr = dc.rect();
let sr = dr.translate(dx.saturating_neg(), dy.saturating_neg());
// Every pixel of the target comes from a source tile (none from the default pixel).
@@ -858,10 +861,7 @@ impl Surface {
&& sr.tiles().all(|sc| self.tiles.contains_key(&sc));
if covered && let Some(shared) = uniform.window(self, sr) {
// A tile of only the default pixel is left out, as `prune` would.
if shared.data.get(..bpp) != Some(&*self.default_pixel) {
out.tiles.insert(dc, shared);
}
continue;
return (shared.data.get(..bpp) != Some(&*self.default_pixel)).then_some((dc, shared));
}
let mut tile = Tile { data: vec![0u8; (TILE_SIZE * TILE_SIZE) as usize * bpp].into_boxed_slice() };
@@ -943,9 +943,16 @@ impl Surface {
}
// A target that got only default pixels isn't stored, as `prune` would drop it. Checked
// after the copy, a row at a time: a tile with content stops at its first such row.
if tile.data.chunks(default_row.len()).any(|row| row != default_row) {
out.tiles.insert(dc, Arc::new(tile));
}
tile.data.chunks(default_row.len()).any(|row| row != default_row).then(|| (dc, Arc::new(tile)))
};
#[cfg(not(target_arch = "wasm32"))]
{
use rayon::prelude::*;
out.tiles = targets.into_par_iter().filter_map(build_target).collect();
}
#[cfg(target_arch = "wasm32")]
{
out.tiles = targets.into_iter().filter_map(build_target).collect();
}
out
}
+3 -3
View File
@@ -1666,9 +1666,9 @@ fn ensure_gpu(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, visible:
damage = Some(crate::adjust_preview::live_switch_damage(app, raw_key, r, visible));
}
let preview_key = raw_key ^ display_key;
// The Navigator may draw this texture only while it holds the plain document (no live
// preview, which can be composited only where the view is).
if raw_key == 0 {
// The Navigator may draw this texture while it holds the plain document or a drag preview
// that updates the full texture (not an adjustment preview composited only where the view is).
if raw_key == 0 || navigator_waits_for_drag(app, idx) {
app.navigator_plain.insert(id);
} else {
app.navigator_plain.remove(&id);
+19
View File
@@ -213,6 +213,9 @@ fn floating_doc(app: &mut PhotocraftApp, idx: usize) -> Option<(Arc<Document>, u
floating: true,
});
}
if offset == (0, 0) && app.move_preview.as_ref().is_none_or(|p| p.offsets.is_empty()) {
return None;
}
let st = app.session.documents().get(idx)?;
let shown = app.move_preview.as_ref()?.offsets.last() != Some(&offset);
if shown {
@@ -535,4 +538,20 @@ mod tests {
drag(&mut app, [16.0, 16.0], [26.0, 16.0], egui::Modifiers::NONE);
assert!(alpha(&app, id, 10, 10) == 0.0 && alpha(&app, id, 58, 16) == 1.0, "the whole layer moved");
}
#[test]
fn floating_selection_does_not_snap_to_its_own_starting_outline_or_preview_at_zero() {
let (mut app, _id) = selected_pixels(8);
app.ui.extras.snap = true;
app.ui.view.snap_to.document_bounds = false;
let m = egui::Modifiers::NONE;
crate::canvas::tool_event(&mut app, ToolEvent::Down { x: 16.0, y: 16.0, pressure: 1.0 }, m);
assert!(super::display_doc(&mut app, 0).is_none(), "no preview composite at offset (0, 0) before the pointer moves");
// A small 2 px move within the snap radius must move immediately instead of snapping back to (0, 0).
crate::canvas::tool_event(&mut app, ToolEvent::Move { x: 18.0, y: 17.0, pressure: 1.0 }, m);
assert_eq!(super::floating_offset(&app), Some((2, 1)), "small drag moves without snapping to own selection bounds");
assert!(super::display_doc(&mut app, 0).is_some(), "preview is shown once offset is non-zero");
crate::canvas::tool_event(&mut app, ToolEvent::Up { x: 18.0, y: 17.0 }, m);
assert_eq!(offset(&app), Some((2, 1)));
}
}
+14
View File
@@ -72,6 +72,20 @@ fn navigator_draws_the_gpu_canvas_texture_and_follows_edits() {
assert!(near(preview_px(&mut h, 0.75, 0.5), [0, 0, 255]), "right half now blue");
assert!(!h.state().navigator_textures.contains_key(&id));
// Dragging the selected pixels with the Move tool keeps the Navigator on the GPU texture
// without falling back to a full-document CPU thumbnail composite on the UI thread.
h.state_mut().ui.tool = crate::state::Tool::Move;
crate::canvas::tool_event(h.state_mut(), crate::canvas::ToolEvent::Down { x: 250.0, y: 150.0, pressure: 1.0 }, egui::Modifiers::NONE);
crate::canvas::tool_event(h.state_mut(), crate::canvas::ToolEvent::Move { x: 150.0, y: 150.0, pressure: 1.0 }, egui::Modifiers::NONE);
h.run_steps(4);
assert!(h.state().navigator_gpu.contains_key(&id), "Navigator stays on GPU canvas during Move drag");
assert!(!h.state().navigator_textures.contains_key(&id), "no CPU thumbnail built during Move drag");
crate::canvas::tool_event(h.state_mut(), crate::canvas::ToolEvent::Up { x: 150.0, y: 150.0 }, egui::Modifiers::NONE);
h.state_mut().run("select.drop", json!({})).unwrap();
h.state_mut().run("edit.undo", json!({})).unwrap();
h.state_mut().run("select.deselect", json!({})).unwrap();
h.run_steps(4);
// A flipped view draws on the CPU: the Navigator goes back to its CPU thumbnail.
h.state_mut().ui.view.flip_horizontal = true;
h.run_steps(6);
+7 -1
View File
@@ -256,7 +256,7 @@ fn begin(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) {
// A guide never snaps to itself (or other guides).
targets = targets.filtered(|k| k != SnapKind::Guide);
}
if matches!(gesture, Gesture::SelectionMove { .. }) {
if matches!(gesture, Gesture::SelectionMove { .. }) || (matches!(gesture, Gesture::Move { .. }) && crate::move_ui::moves_selected_pixels_with(app, tool)) {
// A moving selection never snaps back to where it started.
targets = targets.filtered(|k| k != SnapKind::Selection);
}
@@ -1166,6 +1166,12 @@ mod tests {
assert_eq!(snap_guide(&mut app, true, 297.0), 300.0);
assert_eq!(snap_guide(&mut app, false, 150.0), 150.0, "document centre");
assert_eq!(snap_guide(&mut app, false, 170.0), 170.0);
// Dragging an existing guide with the Move tool while a selection is active still snaps to the selection.
app.run("view.newGuide", json!({"orientation": "vertical", "position": 10})).unwrap();
app.run("select.rect", json!({"x": 100, "y": 100, "width": 50, "height": 40})).unwrap();
app.ui.tool = Tool::Move;
begin(&mut app, [10.0, 50.0], egui::Modifiers::NONE);
assert_eq!(apply(&mut app, [98.0, 50.0], egui::Modifiers::NONE)[0], 100.0);
app.ui.extras.snap = false;
assert_eq!(snap_guide(&mut app, true, 297.0), 297.0);
}
+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) |
+2
View File
@@ -0,0 +1,2 @@
sonar.projectKey=storytold_photocraft
sonar.organization=storytold
+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