mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-22 15:14:11 -04:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86953e0ebf | ||
|
|
6d8cbac7dc | ||
|
|
18e2d45420 | ||
|
|
87fa191818 | ||
|
|
ca18689304 | ||
|
|
8e518d8169 | ||
|
|
f4ea3139d7 | ||
|
|
8352b26c1a |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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
@@ -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"; \
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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) |
|
||||
@@ -0,0 +1,2 @@
|
||||
sonar.projectKey=storytold_photocraft
|
||||
sonar.organization=storytold
|
||||
@@ -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
@@ -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],
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user