diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml new file mode 100644 index 0000000000..ecee45a6d8 --- /dev/null +++ b/.github/workflows/sonarcloud.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index a003c661ba..dbe7e172a9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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//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 --lib --tests` skips the examples and doctests plain `cargo test` builds every time (ui-egui has 11 examples), and `cargo test -p --test ` 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). diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index e824448cc2..34e00db22d 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -111,6 +111,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) | diff --git a/Cargo.lock b/Cargo.lock index 90330fd4a5..2981662084 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4638,7 +4638,7 @@ dependencies = [ [[package]] name = "photocraft" -version = "0.11.1" +version = "0.12.0" dependencies = [ "arboard", "async-io", @@ -4683,7 +4683,7 @@ dependencies = [ [[package]] name = "photocraft-affinity" -version = "0.11.1" +version = "0.12.0" dependencies = [ "crc32fast", "flate2", @@ -4693,7 +4693,7 @@ dependencies = [ [[package]] name = "photocraft-algo" -version = "0.11.1" +version = "0.12.0" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4706,7 +4706,7 @@ dependencies = [ [[package]] name = "photocraft-automation" -version = "0.11.1" +version = "0.12.0" dependencies = [ "base64 0.22.1", "cap-std", @@ -4729,7 +4729,7 @@ dependencies = [ [[package]] name = "photocraft-cli" -version = "0.11.1" +version = "0.12.0" dependencies = [ "photocraft-automation", "photocraft-codecs", @@ -4743,7 +4743,7 @@ dependencies = [ [[package]] name = "photocraft-cms" -version = "0.11.1" +version = "0.12.0" dependencies = [ "moxcms", "rayon", @@ -4751,7 +4751,7 @@ dependencies = [ [[package]] name = "photocraft-codecs" -version = "0.11.1" +version = "0.12.0" dependencies = [ "exr", "flate2", @@ -4772,7 +4772,7 @@ dependencies = [ [[package]] name = "photocraft-color" -version = "0.11.1" +version = "0.12.0" dependencies = [ "photocraft-cms", "serde", @@ -4780,7 +4780,7 @@ dependencies = [ [[package]] name = "photocraft-compose" -version = "0.11.1" +version = "0.12.0" dependencies = [ "criterion", "photocraft-color", @@ -4795,7 +4795,7 @@ dependencies = [ [[package]] name = "photocraft-doc" -version = "0.11.1" +version = "0.12.0" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4806,7 +4806,7 @@ dependencies = [ [[package]] name = "photocraft-engine" -version = "0.11.1" +version = "0.12.0" dependencies = [ "blake3", "craft-print", @@ -4844,7 +4844,7 @@ dependencies = [ [[package]] name = "photocraft-format" -version = "0.11.1" +version = "0.12.0" dependencies = [ "blake3", "crc32fast", @@ -4867,14 +4867,14 @@ dependencies = [ [[package]] name = "photocraft-geom" -version = "0.11.1" +version = "0.12.0" dependencies = [ "serde", ] [[package]] name = "photocraft-gpu" -version = "0.11.1" +version = "0.12.0" dependencies = [ "half", "log", @@ -4892,14 +4892,14 @@ dependencies = [ [[package]] name = "photocraft-heif" -version = "0.11.1" +version = "0.12.0" dependencies = [ "heic-rs", ] [[package]] name = "photocraft-io" -version = "0.11.1" +version = "0.12.0" dependencies = [ "blake3", "craft-docpath", @@ -4929,18 +4929,18 @@ dependencies = [ [[package]] name = "photocraft-ipp" -version = "0.11.1" +version = "0.12.0" [[package]] name = "photocraft-ops" -version = "0.11.1" +version = "0.12.0" dependencies = [ "photocraft-doc", ] [[package]] name = "photocraft-paint" -version = "0.11.1" +version = "0.12.0" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4951,7 +4951,7 @@ dependencies = [ [[package]] name = "photocraft-plugins" -version = "0.11.1" +version = "0.12.0" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4966,7 +4966,7 @@ dependencies = [ [[package]] name = "photocraft-psd" -version = "0.11.1" +version = "0.12.0" dependencies = [ "flate2", "photocraft-psd", @@ -4977,7 +4977,7 @@ dependencies = [ [[package]] name = "photocraft-raster" -version = "0.11.1" +version = "0.12.0" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4988,7 +4988,7 @@ dependencies = [ [[package]] name = "photocraft-raw" -version = "0.11.1" +version = "0.12.0" dependencies = [ "flate2", "image", @@ -5003,7 +5003,7 @@ dependencies = [ [[package]] name = "photocraft-tablet" -version = "0.11.1" +version = "0.12.0" dependencies = [ "block2 0.6.2", "objc2 0.6.4", @@ -5015,7 +5015,7 @@ dependencies = [ [[package]] name = "photocraft-testkit" -version = "0.11.1" +version = "0.12.0" dependencies = [ "memory-stats", "photocraft-color", @@ -5026,7 +5026,7 @@ dependencies = [ [[package]] name = "photocraft-text" -version = "0.11.1" +version = "0.12.0" dependencies = [ "flate2", "parley", @@ -5041,7 +5041,7 @@ dependencies = [ [[package]] name = "photocraft-ui-egui" -version = "0.11.1" +version = "0.12.0" dependencies = [ "base64 0.22.1", "craft-print", @@ -5083,7 +5083,7 @@ dependencies = [ [[package]] name = "photocraft-vector" -version = "0.11.1" +version = "0.12.0" dependencies = [ "photocraft-color", "photocraft-doc", @@ -5094,7 +5094,7 @@ dependencies = [ [[package]] name = "photocraft-web" -version = "0.11.1" +version = "0.12.0" dependencies = [ "eframe", "egui", @@ -5116,7 +5116,7 @@ dependencies = [ [[package]] name = "photocraft-web-boot" -version = "0.11.1" +version = "0.12.0" dependencies = [ "flate2", "js-sys", @@ -8712,7 +8712,7 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" [[package]] name = "xtask" -version = "0.11.1" +version = "0.12.0" dependencies = [ "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index b3f49802e3..c3fdd1e10c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/photocraft", "apps/photocraft-cli", "xtask"] [workspace.package] -version = "0.11.1" +version = "0.12.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" diff --git a/README.md b/README.md index 7554fcd8f2..1de756458a 100644 --- a/README.md +++ b/README.md @@ -202,7 +202,7 @@ Every screenshot here is the real app at work on public-domain art, rendered off

🗂️ Formats

- PSD and PSB, layered TIFF (Photoshop's layer data in the TIFF, read and written in either byte order), Paint.NET PDN3 (read, with editable layers and all 14 blend modes), OpenRaster (read and write, with layers, groups and blend modes, for Krita, MyPaint and GIMP), Krita .kra (read, with paint layers, groups, blend modes and transparency masks), 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 .pcraft 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 codec capability matrix for each format. HEIC photos from iPhone and Mac are read-only (included in official builds; optional --features heif for source builds). AVIF is write-only with the optional avif build feature; AVIF decoding and JPEG XL are unavailable. Affinity documents (.af, .afdesign, .afphoto, .afpub) 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), Paint.NET PDN3 (read, with editable layers and all 14 blend modes), OpenRaster (read and write, with layers, groups and blend modes, for Krita, MyPaint and GIMP), GIMP XCF (read, with layers, groups, masks, blend modes, channels and the selection), Krita .kra (read, with paint layers, groups, blend modes and transparency masks), 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 .pcraft 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 codec capability matrix for each format. HEIC photos from iPhone and Mac are read-only (included in official builds; optional --features heif for source builds). AVIF is write-only with the optional avif build feature; AVIF decoding and JPEG XL are unavailable. Affinity documents (.af, .afdesign, .afphoto, .afpub) 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.

🪄 The everyday essentials

diff --git a/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs index 74f37aaa3d..74f22b1ad7 100644 --- a/apps/photocraft-web/src/web.rs +++ b/apps/photocraft-web/src/web.rs @@ -19,9 +19,9 @@ type Inbox = Arc)>>>; /// Everything File › Open reads: PhotoCraft, Photoshop, OpenRaster, Krita and Affinity documents, flat images, and /// Photoshop brushes (.abr), gradients (.grd) and swatches (.aco, .act, .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", "act", "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", "act", "ase", "af", "afdesign", "afphoto", "afpub", ]; const SVG_EXTS: &[&str] = &["svg", "svgz"]; const CANVAS_ID: &str = "photocraft_canvas"; diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index 2a6f53dd09..84a15c8d57 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -20,9 +20,9 @@ mod recovery_tests; /// Photoshop brushes (.abr), gradients (.grd), swatches (.aco, .act, .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", "act", "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", "act", "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), diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index e593452e76..374603932f 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -689,11 +689,33 @@ fn render_reduced_with(doc: &Document, b: Rect, w: u32, h: u32, damage: Option 0.0 { [p[0] / a, p[1] / a, p[2] / a, a / n] } else { [0.0; 4] }; + let col_w: Vec = (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 } } @@ -1907,6 +1929,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) { diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs index 6505120cf7..0bb2b78928 100644 --- a/crates/engine/src/file_cmds.rs +++ b/crates/engine/src/file_cmds.rs @@ -105,7 +105,7 @@ pub fn list_images(dir: &str) -> Result> { let mut out: Vec = 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> { 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 diff --git a/crates/engine/src/file_cmds/tests.rs b/crates/engine/src/file_cmds/tests.rs index 2fa6f98e21..913335afe1 100644 --- a/crates/engine/src/file_cmds/tests.rs +++ b/crates/engine/src/file_cmds/tests.rs @@ -1465,3 +1465,15 @@ fn exposure_text_near_one_second_is_not_one_over_one() { assert_eq!(exposure_text(0.99).as_deref(), Some("0.99 s")); assert_eq!(exposure_text(1.0 / 250.0).as_deref(), Some("1/250 s")); } + +/// 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")); +} diff --git a/crates/engine/src/float_cmds.rs b/crates/engine/src/float_cmds.rs index 71e343efcd..bffb670769 100644 --- a/crates/engine/src/float_cmds.rs +++ b/crates/engine/src/float_cmds.rs @@ -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,45 +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); - // The selection lifts its share of a pixel's coverage, up to all of it, and the - // rest stays (Photoshop): a layer's own transparency, loaded as the selection, - // lifts it whole and leaves no ghost behind (#7655). An opaque pixel splits as k - // and 1 − k, as a product would. - if k <= 0.0 { - p.fill(0.0); - } else { - p[a] = p[a].min(k); - } - if !copy && !fill { - r[a] = (r[a] - k).max(0.0); - } - } - 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_lift_coverage(&mut rest, b, 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. @@ -111,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); } @@ -123,6 +110,103 @@ 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` directly. +fn cut_piece(base: &Surface, sel: &Surface, b: photocraft_geom::Rect, with_alpha: PixelFormat) -> Result { + 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)>> { + 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); + } + // The selection lifts its share of a pixel's coverage, up to all of it + // (Photoshop): a layer's own transparency, loaded as the selection, lifts + // it whole (#7655). An opaque pixel splits as k and 1 − k. + px[a] = px[a].min(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::>>()?; + 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 { diff --git a/crates/engine/src/float_cmds/tests.rs b/crates/engine/src/float_cmds/tests.rs index 638051000b..130b07f2ab 100644 --- a/crates/engine/src/float_cmds/tests.rs +++ b/crates/engine/src/float_cmds/tests.rs @@ -276,3 +276,63 @@ fn a_layers_own_transparency_floats_its_soft_pixels_whole() { fn doc_rect(w: i32, h: i32) -> Rect { Rect::new(0, 0, w, h) } + +#[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. + 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. + 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}"); + } +} diff --git a/crates/engine/src/pixels.rs b/crates/engine/src/pixels.rs index b046de03bd..dc4e531f53 100644 --- a/crates/engine/src/pixels.rs +++ b/crates/engine/src/pixels.rs @@ -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,31 @@ pub(crate) fn try_clear_surface(s: &mut Surface, area: Rect, selection: Option<& px.fill(0.0); } true - })?; - s.prune(); - Ok(()) + }) +} + +/// Take the coverage a floating selection lifts out of `s` over `area`: each pixel loses its +/// selection coverage k of alpha, down to none, so a selection made from the layer's own +/// transparency leaves nothing behind (#7655), where [`try_clear_surface`] would leave a ghost. +pub(crate) fn try_lift_coverage(s: &mut Surface, area: Rect, selection: &Surface) -> crate::Result<()> { + let fmt = s.format(); + let n = fmt.channels(); + if !fmt.alpha { + return Ok(()); + } + let empty_default = s.default_bytes().iter().all(|&b| b == 0); + let whole = |full: bool| (full && empty_default).then_some(TileEdit::Drop); + edit_tiles(s, area, Some(selection), "lifting pixels", whole, |k, px| { + if k <= 0.0 { + return false; + } + let Some(a) = px.get_mut(n - 1) else { return false }; + *a = (*a - k).max(0.0); + if *a <= 0.0 { + px.fill(0.0); + } + true + }) } /// A whole-tile shortcut of [`edit_tiles`]. @@ -331,6 +358,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 +399,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::>>()?; for (tc, tile) in edits.into_iter().flatten() { diff --git a/crates/engine/src/transform_cmds.rs b/crates/engine/src/transform_cmds.rs index 22849b3dc7..fce8c1be49 100644 --- a/crates/engine/src/transform_cmds.rs +++ b/crates/engine/src/transform_cmds.rs @@ -553,13 +553,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` 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)> = 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)> = 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)) { diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index b620f9957a..d71a5d716b 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -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`. @@ -71,6 +73,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}; @@ -97,6 +100,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), @@ -359,6 +365,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); } @@ -456,6 +470,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); } diff --git a/crates/io/src/xcf.rs b/crates/io/src/xcf.rs new file mode 100644 index 0000000000..15586a55dd --- /dev/null +++ b/crates/io/src/xcf.rs @@ -0,0 +1,1018 @@ +//! GIMP XCF (`.xcf`, `.xcf.gz`) read. Layout from the public specification +//! (https://developer.gimp.org/core/standards/xcf/, versions 0 to 26): a header, image +//! properties, then pointers to layer, channel and path structures; each drawable's pixels sit in +//! 64×64 tiles, uncompressed, RLE-coded or zlib-coded. +//! +//! Read: pixel layers at any position and size, groups (nested, pass-through), names, opacity, +//! visibility, offsets, layer masks (applied or disabled), locks, colour tags, blend modes (the +//! legacy and the 2.10 sets; those without a Photoshop equivalent open as Normal with a warning), +//! 8/16/32-bit integer and 16/32/64-bit float precisions (linear precisions get a linear-light +//! profile), the embedded ICC profile, resolution, guides, the comment, extra channels and the +//! saved selection; indexed images open as RGB. Text, vector and link layers open as their pixels; +//! non-destructive layer effects, paths and floating selections are reported, not applied. +//! Write: not supported (GIMP's own format; `.pcraft` keeps everything PhotoCraft holds). +//! +//! The reader is written from the specification and from files GIMP 3.2 writes; no GIMP code. + +#[cfg(test)] +mod tests; + +use std::io::Read; +use std::sync::Arc; + +use photocraft_codecs::f16; +use photocraft_color::{BlendMode, ColorMode, PixelFormat, SampleType}; +use photocraft_doc::{AlphaChannel, Document, LabelColor, Layer, LayerContent, LayerMask}; +use photocraft_geom::{Rect, Size}; +use photocraft_raster::{Interrupt, Surface}; + +use crate::ora::Warnings; +use crate::{ImportResult, IoError}; + +type Result = std::result::Result; + +const MAGIC: &[u8] = b"gimp xcf "; +/// Canvas side limit (Photoshop's PSB limit). +pub(crate) const MAX_SIDE: u32 = 300_000; +/// Decoded pixels across every drawable are capped, as for OpenRaster and PDN. +pub(crate) const MAX_PIXEL_BYTES: usize = 1 << 30; +/// Layers and groups in one document. +pub(crate) const MAX_LAYERS: usize = 8192; +/// Extra channels in one document. +const MAX_CHANNELS: usize = 1024; +/// Group nesting. +const MAX_DEPTH: usize = 64; +/// A string field (layer names, parasite names). +const MAX_STRING: usize = 1 << 20; +/// A property payload or a parasite that is read into memory. +const MAX_PAYLOAD: usize = 64 << 20; +/// The largest file a gzipped XCF may inflate to. +const MAX_INFLATED: usize = 1 << 30; +/// Highest version this reader was written against; newer files still open (unknown properties +/// are skipped by their length), with a warning. +const KNOWN_VERSION: u32 = 26; +const TILE: u32 = 64; + +fn invalid(message: impl Into) -> IoError { + IoError::Xcf(message.into()) +} + +/// `true` for an XCF file (by its signature). +pub(crate) fn is_xcf(bytes: &[u8]) -> bool { + bytes.starts_with(MAGIC) +} + +/// `true` for a gzip stream named `.xcf.gz` / `.xcfgz` (GIMP writes and reads compressed XCF). +pub(crate) fn is_gzipped_xcf(name: &str, bytes: &[u8]) -> bool { + let lower = name.rsplit(['/', '\\']).next().unwrap_or(name).to_ascii_lowercase(); + bytes.starts_with(&[0x1f, 0x8b]) && (lower.ends_with(".xcf.gz") || lower.ends_with(".xcfgz")) +} + +/// Inflates a gzipped XCF, bounded. +pub(crate) fn gunzip(bytes: &[u8]) -> Result> { + let mut out = Vec::new(); + let mut dec = flate2::read::GzDecoder::new(bytes).take(MAX_INFLATED as u64 + 1); + dec.read_to_end(&mut out).map_err(|e| invalid(format!("gzip: {e}")))?; + if out.len() > MAX_INFLATED { + return Err(invalid("the compressed file inflates past the 1 GiB limit")); + } + Ok(out) +} + +/// How samples are stored: bytes per sample, whether they are floats, and whether the values are +/// linear light (else gamma-encoded, i.e. the profile's own curve). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct Precision { + bytes: usize, + float: bool, + linear: bool, +} + +impl Precision { + const GAMMA8: Precision = Precision { bytes: 1, float: false, linear: false }; + + fn from_header(version: u32, code: u32) -> Result { + let p = |bytes, float, linear| Ok(Precision { bytes, float, linear }); + match version { + 0..=3 => Ok(Self::GAMMA8), + 4 => match code { + 0 => p(1, false, false), + 1 => p(2, false, false), + 2 => p(4, false, true), + 3 => p(2, true, true), + 4 => p(4, true, true), + c => Err(invalid(format!("unknown precision {c} in an XCF version 4 file"))), + }, + 5 | 6 => match code { + 100 => p(1, false, true), + 150 => p(1, false, false), + 200 => p(2, false, true), + 250 => p(2, false, false), + 300 => p(4, false, true), + 350 => p(4, false, false), + 400 => p(2, true, true), + 450 => p(2, true, false), + 500 => p(4, true, true), + 550 => p(4, true, false), + c => Err(invalid(format!("unknown precision {c} in an XCF version {version} file"))), + }, + _ => match code { + 100 => p(1, false, true), + 150 => p(1, false, false), + 200 => p(2, false, true), + 250 => p(2, false, false), + 300 => p(4, false, true), + 350 => p(4, false, false), + 500 => p(2, true, true), + 550 => p(2, true, false), + 600 => p(4, true, true), + 650 => p(4, true, false), + 700 => p(8, true, true), + 750 => p(8, true, false), + c => Err(invalid(format!("unknown precision {c}"))), + }, + } + } + + /// The document depth the samples open at. + fn depth(self) -> SampleType { + match (self.float, self.bytes) { + (false, 1) => SampleType::U8, + (false, _) => SampleType::U16, + (true, _) => SampleType::F32, + } + } + + /// One sample, big-endian, as a normalised float. + fn sample(self, b: &[u8]) -> f32 { + match (self.float, self.bytes, b) { + (false, 1, [v]) => f32::from(*v) / 255.0, + (false, 2, [a, b]) => f32::from(u16::from_be_bytes([*a, *b])) / 65535.0, + (false, 4, [a, b, c, d]) => (u32::from_be_bytes([*a, *b, *c, *d]) as f64 / f64::from(u32::MAX)) as f32, + (true, 2, [a, b]) => f16::from_bits(u16::from_be_bytes([*a, *b])).to_f32(), + (true, 4, [a, b, c, d]) => f32::from_be_bytes([*a, *b, *c, *d]), + (true, 8, [a, b, c, d, e, f, g, h]) => f64::from_be_bytes([*a, *b, *c, *d, *e, *f, *g, *h]) as f32, + _ => 0.0, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Compression { + None, + Rle, + Zlib, +} + +/// Big-endian reads with bounds checks; every failure is an error, never a panic. +struct Cursor<'a> { + b: &'a [u8], + pos: usize, + /// XCF 11 and later store pointers as 64-bit. + wide: bool, +} + +impl<'a> Cursor<'a> { + fn at(&self, pos: usize) -> Cursor<'a> { + Cursor { b: self.b, pos, wide: self.wide } + } + + fn take(&mut self, n: usize) -> Result<&'a [u8]> { + let end = self.pos.checked_add(n).ok_or_else(|| invalid("offset overflow"))?; + let s = self.b.get(self.pos..end).ok_or_else(|| invalid(format!("the file ends early (wanted {n} bytes at {})", self.pos)))?; + self.pos = end; + Ok(s) + } + + fn u32(&mut self) -> Result { + let s = self.take(4)?; + Ok(u32::from_be_bytes([s[0], s[1], s[2], s[3]])) + } + + fn ptr(&mut self) -> Result { + if self.wide { + let s = self.take(8)?; + usize::try_from(u64::from_be_bytes([s[0], s[1], s[2], s[3], s[4], s[5], s[6], s[7]])).map_err(|_| invalid("pointer does not fit in memory")) + } else { + self.u32().map(|v| v as usize) + } + } + + /// A STRING: `n+1` then `n` bytes and a NUL (0 for the empty string). + fn string(&mut self) -> Result { + let n = self.u32()? as usize; + if n == 0 { + return Ok(String::new()); + } + if n > MAX_STRING { + return Err(invalid(format!("a string of {n} bytes exceeds the limit"))); + } + let s = self.take(n)?; + Ok(String::from_utf8_lossy(s.strip_suffix(&[0]).unwrap_or(s)).into_owned()) + } + + fn skip(&mut self, n: usize) -> Result<()> { + self.take(n).map(|_| ()) + } +} + +/// One property record: its type and payload. +struct Prop<'a> { + kind: u32, + data: &'a [u8], +} + +/// Reads a property list; `fixed` gives the native payload sizes the spec says GIMP trusts over +/// the length word. Ends at PROP_END (type 0) or at the end of the file. +fn props<'a>(c: &mut Cursor<'a>) -> Result>> { + let mut out = Vec::new(); + loop { + let kind = c.u32()?; + let len = c.u32()? as usize; + if kind == 0 { + return Ok(out); + } + // PROP_COLORMAP's length word was wrong in some releases (n+4 for 3n+4 bytes); its real + // size follows from the count. PROP_USER_UNIT's length is wrong in every release. + let len = match kind { + 1 => { + let n = c.at(c.pos).u32()? as usize; + 4usize.checked_add(n.checked_mul(3).ok_or_else(|| invalid("colormap overflow"))?).ok_or_else(|| invalid("colormap overflow"))? + } + 24 => user_unit_len(c)?, + _ => len, + }; + if len > MAX_PAYLOAD { + return Err(invalid(format!("property {kind} declares {len} bytes"))); + } + let data = c.take(len)?; + out.push(Prop { kind, data }); + if out.len() > 4096 { + return Err(invalid("too many properties")); + } + } +} + +/// The real size of a PROP_USER_UNIT payload: a float, a count and three (XCF 21+) or five +/// strings. Measured without consuming. +fn user_unit_len(c: &Cursor<'_>) -> Result { + let mut p = c.at(c.pos); + p.skip(8)?; + for _ in 0..3 { + p.string()?; + } + // The two name forms of older files, when present: another string pair before the next + // property. Decide by whether a plausible property header follows. + let three = p.pos - c.pos; + let mut q = p.at(p.pos); + let looks_like_prop = |c: &mut Cursor<'_>| c.u32().map(|k| k <= 64).unwrap_or(false); + if looks_like_prop(&mut q) { + return Ok(three); + } + p.string()?; + p.string()?; + Ok(p.pos - c.pos) +} + +fn be_u32(b: &[u8]) -> Option { + b.get(..4).map(|s| u32::from_be_bytes([s[0], s[1], s[2], s[3]])) +} + +fn be_i32(b: &[u8]) -> Option { + be_u32(b).map(|v| v as i32) +} + +fn be_f32(b: &[u8]) -> Option { + be_u32(b).map(f32::from_bits) +} + +/// The parasites of a PROP_PARASITES payload as (name, data); a later duplicate wins. +fn parasites(data: &[u8]) -> Vec<(String, Vec)> { + let mut c = Cursor { b: data, pos: 0, wide: false }; + let mut out: Vec<(String, Vec)> = Vec::new(); + while c.pos < data.len() { + let Ok(name) = c.string() else { break }; + let (Ok(_flags), Ok(len)) = (c.u32(), c.u32()) else { break }; + let Ok(payload) = c.take(len as usize) else { break }; + match out.iter_mut().find(|(n, _)| *n == name) { + Some(slot) => slot.1 = payload.to_vec(), + None => out.push((name, payload.to_vec())), + } + } + out +} + +/// What one tile's bytes decode to, for a drawable of `bpp` bytes per pixel. +fn decode_tile(c: &Cursor<'_>, at: usize, end: usize, compression: Compression, bpp: usize, pixels: usize) -> Result> { + let expected = pixels.checked_mul(bpp).ok_or_else(|| invalid("tile size overflow"))?; + let input = c.b.get(at..end.min(c.b.len())).ok_or_else(|| invalid("a tile pointer is outside the file"))?; + match compression { + Compression::None => input.get(..expected).map(<[u8]>::to_vec).ok_or_else(|| invalid("an uncompressed tile is cut short")), + Compression::Zlib => { + let mut out = Vec::with_capacity(expected); + flate2::read::ZlibDecoder::new(input).take(expected as u64).read_to_end(&mut out).map_err(|e| invalid(format!("zlib tile: {e}")))?; + if out.len() != expected { + return Err(invalid("a zlib tile is cut short")); + } + Ok(out) + } + Compression::Rle => rle_decode(input, bpp, pixels), + } +} + +/// RLE tile data: one run-length stream per byte position of the pixel, each of `pixels` bytes. +fn rle_decode(input: &[u8], bpp: usize, pixels: usize) -> Result> { + let mut out = vec![0u8; pixels * bpp]; + let mut pos = 0usize; + let mut plane = Vec::with_capacity(pixels); + for b in 0..bpp { + plane.clear(); + while plane.len() < pixels { + let op = *input.get(pos).ok_or_else(|| invalid("an RLE tile is cut short"))?; + pos += 1; + let (run, verbatim) = match op { + 0..=126 => (usize::from(op) + 1, false), + 127 => { + let (p, q) = (input.get(pos).copied(), input.get(pos + 1).copied()); + pos += 2; + ( + usize::from(p.ok_or_else(|| invalid("an RLE tile is cut short"))?) * 256 + + usize::from(q.ok_or_else(|| invalid("an RLE tile is cut short"))?), + false, + ) + } + 128 => { + let (p, q) = (input.get(pos).copied(), input.get(pos + 1).copied()); + pos += 2; + ( + usize::from(p.ok_or_else(|| invalid("an RLE tile is cut short"))?) * 256 + + usize::from(q.ok_or_else(|| invalid("an RLE tile is cut short"))?), + true, + ) + } + _ => (256 - usize::from(op), true), + }; + if plane.len() + run > pixels { + return Err(invalid("an RLE run crosses the end of its tile")); + } + if verbatim { + let data = input.get(pos..pos + run).ok_or_else(|| invalid("an RLE tile is cut short"))?; + pos += run; + plane.extend_from_slice(data); + } else { + let v = *input.get(pos).ok_or_else(|| invalid("an RLE tile is cut short"))?; + pos += 1; + plane.resize(plane.len() + run, v); + } + } + for (i, v) in plane.iter().enumerate() { + out[i * bpp + b] = *v; + } + } + Ok(out) +} + +/// Image-wide facts the drawables need. +struct Image<'a> { + c: Cursor<'a>, + version: u32, + precision: Precision, + compression: Compression, + base_type: u32, + colormap: Vec<[u8; 3]>, + budget: usize, + warnings: Warnings, + /// Layers GIMP composites with "Clip to backdrop" (its default for every mode but Normal). + clipped_to_backdrop: usize, +} + +/// Pixel data of a drawable: `channels` normalised samples per pixel, row-major. +struct Pixels { + width: u32, + height: u32, + channels: usize, + data: Vec, +} + +impl<'a> Image<'a> { + /// Reads a hierarchy (`hptr`): the first level's tiles into `channels` floats per pixel, + /// `source_channels` as stored (indexed images expand to RGB here). + fn hierarchy(&mut self, hptr: usize, source_channels: usize, ctl: &Interrupt<'_>) -> Result { + let mut c = self.c.at(hptr); + let (w, h, bpp) = (c.u32()?, c.u32()?, c.u32()? as usize); + if w == 0 || h == 0 || w > MAX_SIDE || h > MAX_SIDE { + return Err(invalid(format!("drawable size {w}x{h} is out of range"))); + } + let expected_bpp = source_channels.checked_mul(self.precision.bytes).ok_or_else(|| invalid("bpp overflow"))?; + if bpp != expected_bpp { + return Err(invalid(format!("{bpp} bytes per pixel where {expected_bpp} were expected"))); + } + let indexed = self.base_type == 2; + let channels = if indexed { source_channels + 2 } else { source_channels }; + let count = (w as usize).checked_mul(h as usize).and_then(|n| n.checked_mul(channels)).ok_or_else(|| invalid("drawable overflow"))?; + let bytes = count.checked_mul(4).ok_or_else(|| invalid("drawable overflow"))?; + self.budget = self.budget.checked_sub(bytes).ok_or_else(|| invalid("the layers exceed the 1 GiB pixel limit"))?; + let lptr = c.ptr()?; + let mut l = self.c.at(lptr); + let (lw, lh) = (l.u32()?, l.u32()?); + if (lw, lh) != (w, h) { + return Err(invalid("the level does not match its hierarchy")); + } + let across = w.div_ceil(TILE) as usize; + let down = h.div_ceil(TILE) as usize; + let mut tiles = Vec::with_capacity(across * down); + for _ in 0..across * down { + let p = l.ptr()?; + if p == 0 { + return Err(invalid("the tile list ends before every tile")); + } + tiles.push(p); + } + let mut data = vec![0.0f32; count]; + let stride = w as usize * channels; + for (i, &at) in tiles.iter().enumerate() { + if i % 64 == 0 { + ctl.check().map_err(|_| IoError::Cancelled)?; + } + let (tx, ty) = ((i % across) as u32, (i / across) as u32); + let (tw, th) = ((w - tx * TILE).min(TILE), (h - ty * TILE).min(TILE)); + let end = tiles.get(i + 1).copied().filter(|&n| n > at).unwrap_or(self.c.b.len()); + let raw = decode_tile(&self.c, at, end, self.compression, bpp, (tw * th) as usize)?; + for py in 0..th as usize { + for px in 0..tw as usize { + let src = (py * tw as usize + px) * bpp; + let dst = (ty as usize * TILE as usize + py) * stride + (tx as usize * TILE as usize + px) * channels; + let pixel = &raw[src..src + bpp]; + if indexed { + let idx = usize::from(pixel[0]); + let [r, g, b] = self.colormap.get(idx).copied().unwrap_or([pixel[0]; 3]); + data[dst] = f32::from(r) / 255.0; + data[dst + 1] = f32::from(g) / 255.0; + data[dst + 2] = f32::from(b) / 255.0; + if source_channels == 2 { + data[dst + 3] = f32::from(pixel[1]) / 255.0; + } + } else { + for ch in 0..channels { + data[dst + ch] = self.precision.sample(&pixel[ch * self.precision.bytes..(ch + 1) * self.precision.bytes]); + } + } + } + } + } + Ok(Pixels { width: w, height: h, channels, data }) + } + + /// A channel structure (a layer mask, the selection or an extra channel) as one-channel + /// pixels, with its properties. + fn channel(&mut self, ptr: usize, ctl: &Interrupt<'_>) -> Result<(String, Vec>, Pixels)> { + let mut c = self.c.at(ptr); + let (_w, _h) = (c.u32()?, c.u32()?); + let name = c.string()?; + let props = props(&mut c)?; + let hptr = c.ptr()?; + let saved = (self.base_type, self.precision); + // Channels are always one plain sample per pixel, even in indexed images. + self.base_type = 0; + let px = self.hierarchy(hptr, 1, ctl); + (self.base_type, self.precision) = saved; + Ok((name, props, px?)) + } +} + +/// A layer as read, before the tree is built. +struct Node { + layer: Layer, + path: Vec, + group: bool, + children: Vec, +} + +/// GIMP's layer mode as Photoshop's, with a note when the two differ. +fn blend_mode(mode: u32) -> (BlendMode, Option<&'static str>) { + use BlendMode as B; + match mode { + 0 | 28 => (B::Normal, None), + 1 => (B::Dissolve, None), + 2 | 29 => (B::Normal, Some("Behind")), + 3 | 30 => (B::Multiply, None), + 4 | 31 => (B::Screen, None), + 5 | 19 => (B::SoftLight, Some("GIMP's legacy Soft light")), + 45 => (B::SoftLight, Some("GIMP's Soft light")), + 23 => (B::Overlay, None), + 6 | 32 => (B::Difference, None), + 7 | 33 => (B::LinearDodge, None), + 8 | 34 => (B::Subtract, None), + 9 | 35 => (B::Darken, None), + 10 | 36 => (B::Lighten, None), + 11 | 37 => (B::Hue, Some("HSV Hue")), + 12 | 38 => (B::Saturation, Some("HSV Saturation")), + 13 | 39 => (B::Color, Some("HSL Color")), + 14 | 40 => (B::Luminosity, Some("HSV Value")), + 15 | 41 => (B::Divide, None), + 16 | 42 => (B::ColorDodge, None), + 17 | 43 => (B::ColorBurn, None), + 18 | 44 => (B::HardLight, None), + 20 | 46 => (B::Normal, Some("Grain extract")), + 21 | 47 => (B::Normal, Some("Grain merge")), + 22 | 57 => (B::Normal, Some("Color erase")), + 24 => (B::Hue, Some("LCH Hue")), + 25 => (B::Saturation, Some("LCH Chroma")), + 26 => (B::Color, Some("LCH Color")), + 27 => (B::Luminosity, Some("LCH Lightness")), + 48 => (B::VividLight, None), + 49 => (B::PinLight, None), + 50 => (B::LinearLight, None), + 51 => (B::HardMix, None), + 52 => (B::Exclusion, None), + 53 => (B::LinearBurn, None), + 54 => (B::DarkerColor, Some("Luma darken only")), + 55 => (B::LighterColor, Some("Luma lighten only")), + 56 => (B::Luminosity, Some("Luminance")), + 58 => (B::Normal, Some("Erase")), + 59 => (B::Normal, Some("Merge")), + 60 => (B::Normal, Some("Split")), + 61 => (B::PassThrough, None), + _ => (B::Normal, Some("an unknown mode")), + } +} + +fn label(tag: u32) -> LabelColor { + match tag { + 1 => LabelColor::Blue, + 2 => LabelColor::Green, + 3 => LabelColor::Yellow, + 4 => LabelColor::Orange, + 6 => LabelColor::Red, + 7 => LabelColor::Violet, + 8 => LabelColor::Gray, + _ => LabelColor::None, + } +} + +/// Writes `px` (its channels matching `surface`'s) at `x, y` of the document. +fn write_pixels(surface: &mut Surface, px: &Pixels, x: i32, y: i32) -> Result<()> { + let (w, h) = (px.width as i32, px.height as i32); + let rect = Rect::new(x, y, x.checked_add(w).ok_or_else(|| invalid("offset overflow"))?, y.checked_add(h).ok_or_else(|| invalid("offset overflow"))?); + if surface.channels() != px.channels || px.data.len() != (px.width as usize) * (px.height as usize) * px.channels { + return Err(invalid("pixel data does not match the drawable")); + } + surface.write_region(rect, &px.data); + surface.prune(); + Ok(()) +} + +/// Imports an XCF file (already inflated when it was gzipped). +pub(crate) fn import(name: &str, bytes: &[u8], ctl: &Interrupt<'_>) -> Result { + if !is_xcf(bytes) { + return Err(invalid("not an XCF file")); + } + let mut c = Cursor { b: bytes, pos: MAGIC.len(), wide: false }; + let tag = c.take(5)?; + let version = match tag { + b"file\0" => 0, + [b'v', a, b, d, 0] if a.is_ascii_digit() && b.is_ascii_digit() && d.is_ascii_digit() => { + u32::from(a - b'0') * 100 + u32::from(b - b'0') * 10 + u32::from(d - b'0') + } + _ => return Err(invalid("unknown XCF version tag")), + }; + c.wide = version >= 11; + let mut warnings = Warnings::default(); + if version > KNOWN_VERSION { + warnings.note(&format!("XCF version {version} is newer than this reader (version {KNOWN_VERSION}); unknown parts were skipped")); + } + let (w, h, base_type) = (c.u32()?, c.u32()?, c.u32()?); + if w == 0 || h == 0 || w > MAX_SIDE || h > MAX_SIDE { + return Err(invalid(format!("canvas {w}x{h} must be 1 to {MAX_SIDE} pixels a side"))); + } + if (w as usize).saturating_mul(h as usize).saturating_mul(16) > MAX_PIXEL_BYTES { + return Err(invalid("the canvas exceeds the 1 GiB pixel limit")); + } + let precision = if version >= 4 { Precision::from_header(version, c.u32()?)? } else { Precision::GAMMA8 }; + let mode = match base_type { + 0 => ColorMode::Rgb, + 1 => ColorMode::Grayscale, + 2 => ColorMode::Rgb, + t => return Err(invalid(format!("unknown image type {t}"))), + }; + let depth = precision.depth(); + if base_type == 2 { + warnings.note("the indexed image opens as RGB"); + } + if !precision.float && precision.bytes == 4 { + warnings.note("32-bit integer samples open as 16-bit"); + } + + // Image properties. + let image_props = props(&mut c)?; + let mut compression = Compression::None; + let mut colormap = Vec::new(); + let mut resolution = None; + let mut guides: Vec<(i32, u8)> = Vec::new(); + let mut icc = None; + let mut comment = None; + let mut path_count = 0usize; + for p in &image_props { + match p.kind { + 1 => { + let n = be_u32(p.data).unwrap_or(0) as usize; + colormap = p.data.get(4..).unwrap_or(&[]).as_chunks::<3>().0.iter().take(n.min(256)).copied().collect(); + } + 17 => { + compression = match p.data.first() { + Some(0) => Compression::None, + Some(1) => Compression::Rle, + Some(2) => Compression::Zlib, + Some(k) => return Err(invalid(format!("unknown compression {k}"))), + None => return Err(invalid("empty compression property")), + } + } + 18 => { + for g in p.data.as_chunks::<5>().0 { + guides.push((i32::from_be_bytes([g[0], g[1], g[2], g[3]]), g[4])); + } + } + 19 => { + if let (Some(x), Some(y)) = (be_f32(p.data), p.data.get(4..).and_then(be_f32)) { + resolution = Some((x, y)); + } + } + 21 => { + for (name, data) in parasites(p.data) { + match name.as_str() { + "icc-profile" if !data.is_empty() => icc = Some(data), + "gimp-comment" => comment = Some(String::from_utf8_lossy(&data).trim_end_matches('\0').to_string()), + _ => {} + } + } + } + 23 => path_count += be_u32(p.data.get(4..).unwrap_or(&[])).unwrap_or(0) as usize, + 25 => path_count += be_u32(p.data.get(8..).unwrap_or(&[])).unwrap_or(0) as usize, + _ => {} + } + } + + let mut layer_ptrs = Vec::new(); + loop { + let p = c.ptr()?; + if p == 0 { + break; + } + layer_ptrs.push(p); + if layer_ptrs.len() > MAX_LAYERS { + return Err(invalid(format!("more than {MAX_LAYERS} layers"))); + } + } + let mut channel_ptrs = Vec::new(); + loop { + let p = c.ptr()?; + if p == 0 { + break; + } + channel_ptrs.push(p); + if channel_ptrs.len() > MAX_CHANNELS { + return Err(invalid(format!("more than {MAX_CHANNELS} channels"))); + } + } + if version >= 18 { + let mut n = 0; + while c.ptr().ok().is_some_and(|p| p != 0) { + n += 1; + if n > MAX_CHANNELS { + break; + } + } + path_count += n; + } + + let mut image = Image { c: c.at(0), version, precision, compression, base_type, colormap, budget: MAX_PIXEL_BYTES, warnings, clipped_to_backdrop: 0 }; + let fmt = PixelFormat::new(mode, depth, true); + let mask_fmt = PixelFormat::new(ColorMode::Grayscale, depth, false); + let total = layer_ptrs.len().max(1) as f32; + + // Layers, topmost first in the file. + let mut nodes: Vec = Vec::new(); + let mut linear_compositing = false; + for (i, &ptr) in layer_ptrs.iter().enumerate() { + ctl.check().map_err(|_| IoError::Cancelled)?; + ctl.progress(0.05 + 0.85 * i as f32 / total); + if let Some((node, linear)) = image.layer(ptr, fmt, mask_fmt, ctl)? { + linear_compositing |= linear; + nodes.push(node); + } + } + let mut roots: Vec = Vec::new(); + for (i, mut node) in nodes.into_iter().enumerate() { + // Default path: the item's position at the top level, as a file without groups has it. + if node.path.is_empty() { + node.path = vec![i as u32]; + } + if node.path.len() > MAX_DEPTH { + image.warnings.layer(&node.layer.name, "is nested deeper than the reader allows and was moved to the top level"); + node.path.truncate(1); + } + place(&mut roots, node, &mut image.warnings); + } + let layers = finish(roots); + + // Channels: the selection and the extras. + let mut channels = Vec::new(); + let mut selection = None; + for &ptr in &channel_ptrs { + ctl.check().map_err(|_| IoError::Cancelled)?; + let (name, cprops, px) = image.channel(ptr, ctl)?; + let mut surface = Surface::new(mask_fmt); + write_pixels(&mut surface, &px, 0, 0)?; + if cprops.iter().any(|p| p.kind == 4) { + selection = Some(surface); + } else { + channels.push(AlphaChannel::new(name, surface)); + } + } + + let mut document = Document::new(name, Size::new(w, h), mode, depth); + document.layers = layers; + document.channels = channels; + document.selection = selection; + if let Some((x, y)) = resolution.filter(|(x, _)| *x > 0.0 && *x < 1.0e6) { + document.resolution_dpi = x; + if let Some(w) = crate::unequal_resolution_warning(f64::from(x), f64::from(y)) { + image.warnings.note(&w); + } + } + for (coord, orientation) in guides { + if coord < 0 && version < 15 { + continue; + } + match orientation { + 1 => document.guides.horizontal.push(f64::from(coord)), + 2 => document.guides.vertical.push(f64::from(coord)), + _ => {} + } + } + document.icc_profile = match (precision.linear, icc, mode) { + (true, icc, ColorMode::Grayscale) => { + if icc.is_some() { + image.warnings.note("the linear-light image's own profile was replaced by linear gray"); + } + Some(photocraft_cms::builtin::linear_gray().to_bytes()) + } + (true, icc, _) => { + if icc.is_some() { + image.warnings.note("the linear-light image's own profile was replaced by linear sRGB"); + } + Some(photocraft_cms::Builtin::LinearSrgb.profile().to_bytes()) + } + (false, Some(icc), _) => Some(Arc::new(icc)), + (false, None, _) => None, + }; + if let Some(comment) = comment.filter(|c| !c.is_empty()) { + document.metadata.text.push(("Comment".into(), comment)); + } + if path_count > 0 { + image.warnings.note(&format!("{path_count} path(s) were not imported")); + } + // GIMP clips a layer in any mode but Normal to the layers beneath it, so it shows nothing where + // they are transparent; PhotoCraft (like Photoshop) shows it there. Only tell when there is + // transparency to show through: a bottom layer that is not opaque. + if image.clipped_to_backdrop > 0 && document.layers.first().is_some_and(|l| !l.locks.transparency || l.is_group()) { + image.warnings.note(&format!( + "{} layer(s) use a mode GIMP clips to the layers beneath; where those are transparent GIMP shows nothing and PhotoCraft shows the layer", + image.clipped_to_backdrop + )); + } + // GIMP blends and composites in linear light by default (since 2.10); PhotoCraft, like + // Photoshop, in the document's own encoding, so semi-transparent edges and most blend modes + // render a little differently. Layers set to a perceptual space in GIMP match. + if !precision.linear && linear_compositing { + image.warnings.note("GIMP composites this image in linear light; PhotoCraft composites in gamma-encoded RGB, so blends and soft edges differ slightly"); + } + if document.layers.is_empty() { + image.warnings.note("the XCF file has no layers"); + } + ctl.progress(1.0); + Ok(ImportResult { document, warnings: image.warnings.finish(), source_read_only: true, preview_only: false }) +} + +/// Puts `node` where its item path says, under the groups already placed; a path that leads +/// nowhere (at any depth) puts it at the top level, so later siblings keep their indices. +fn place(roots: &mut Vec, node: Node, warnings: &mut Warnings) { + let depth = node.path.len().saturating_sub(1); + let mut probe: &Vec = roots; + let reachable = node.path.iter().take(depth).all(|&index| match probe.get(index as usize) { + Some(n) if n.group => { + probe = &n.children; + true + } + _ => false, + }); + if !reachable { + warnings.layer(&node.layer.name, "has an item path that leads to no group; opened at the top level"); + let mut node = node; + node.path = vec![]; + roots.push(node); + return; + } + let mut level = roots; + for &index in node.path.iter().take(depth) { + level = &mut level[index as usize].children; + } + level.push(node); +} + +/// Nodes top-first into layers bottom-first, groups recursively. +fn finish(nodes: Vec) -> Vec { + let mut out = Vec::with_capacity(nodes.len()); + for mut node in nodes.into_iter().rev() { + if node.group { + let children = finish(std::mem::take(&mut node.children)); + if let Some(c) = node.layer.children_mut() { + *c = children; + } + } + out.push(node.layer); + } + out +} + +impl<'a> Image<'a> { + /// Reads a layer structure, and whether GIMP composites it in linear light. `None` for a + /// floating selection, which is reported instead. + fn layer(&mut self, ptr: usize, fmt: PixelFormat, mask_fmt: PixelFormat, ctl: &Interrupt<'_>) -> Result> { + let mut c = self.c.at(ptr); + let (lw, lh, kind) = (c.u32()?, c.u32()?, c.u32()?); + let name = c.string()?; + let lprops = props(&mut c)?; + let hptr = c.ptr()?; + let mptr = c.ptr()?; + let mut effects = 0usize; + if self.version >= 20 { + while c.ptr()? != 0 { + effects += 1; + if effects > 1024 { + return Err(invalid("too many layer effects")); + } + } + } + let (type_rgb, alpha) = match kind { + 0 => (true, false), + 1 => (true, true), + 2 | 4 => (false, false), + 3 | 5 => (false, true), + k => return Err(invalid(format!("unknown layer type {k} on '{name}'"))), + }; + if type_rgb != (self.base_type != 1) && self.base_type != 2 { + return Err(invalid(format!("layer '{name}' does not match the image's colour model"))); + } + + let mut layer_mode = 0u32; + let mut opacity = 1.0f32; + // PROP_FLOAT_OPACITY wins over PROP_OPACITY whatever their order in the file. + let mut float_opacity = None; + let mut visible = true; + let (mut x, mut y) = (0i32, 0i32); + let mut apply_mask = true; + let mut group = false; + let mut expanded = true; + let mut path = Vec::new(); + let mut text = false; + let mut vector = false; + let mut link = false; + let mut floating = false; + let mut clipped = false; + let mut composite_mode = 0i32; + let mut composite_space = 0i32; + let mut blend_space = 0i32; + let mut locks = photocraft_doc::Locks::default(); + let mut tag = LabelColor::None; + for p in &lprops { + let v = be_u32(p.data).unwrap_or(0); + match p.kind { + 5 => floating = true, + 6 => opacity = (v.min(255) as f32) / 255.0, + 33 => float_opacity = be_f32(p.data).filter(|v| v.is_finite()).map(|v| v.clamp(0.0, 1.0)).or(float_opacity), + 7 => layer_mode = v, + 8 => visible = v != 0, + 10 => locks.transparency = v != 0, + 11 => apply_mask = v != 0, + 15 => { + x = be_i32(p.data).unwrap_or(0); + y = p.data.get(4..).and_then(be_i32).unwrap_or(0); + } + 26 => text = true, + 28 => locks.pixels = v != 0, + 29 => group = true, + 30 => path = p.data.as_chunks::<4>().0.iter().map(|s| u32::from_be_bytes(*s)).collect(), + 31 => expanded = v & 1 != 0, + 32 => locks.position = v != 0, + 34 => tag = label(v), + 35 => composite_mode = be_i32(p.data).unwrap_or(0), + 36 => composite_space = be_i32(p.data).unwrap_or(0), + 37 => blend_space = be_i32(p.data).unwrap_or(0), + 47 => vector = true, + 48 => link = true, + 21 => { + for (pname, _) in parasites(p.data) { + match pname.as_str() { + "gimphoto-clipped" => clipped = true, + "gimp-text-layer" => text = true, + _ => {} + } + } + } + _ => {} + } + } + if floating { + self.warnings.layer(&name, "is a floating selection and was not imported (anchor it in GIMP first)"); + return Ok(None); + } + + let (blend, note) = blend_mode(layer_mode); + if let Some(note) = note { + self.warnings.layer( + &name, + &format!( + "uses GIMP's {note} mode, which opens as {}", + if blend == BlendMode::Normal { "Normal".to_string() } else { format!("Photoshop's {blend:?}") } + ), + ); + } + match composite_mode.unsigned_abs() { + 0 | 1 => {} + 2 => self.clipped_to_backdrop += 1, + 3 => self.warnings.layer(&name, "composites with Clip to layer, which is not reproduced"), + 4 => self.warnings.layer(&name, "composites with Intersection, which is not reproduced"), + _ => {} + } + if text { + self.warnings.layer(&name, "is a text layer and opens as pixels"); + } + if vector { + self.warnings.layer(&name, "is a vector layer and opens as pixels"); + } + if link { + self.warnings.layer(&name, "is a link layer and opens as pixels"); + } + if effects > 0 { + self.warnings.layer(&name, &format!("has {effects} layer effect(s) (non-destructive filters) that are not applied")); + } + + let mut layer = if group { + let mut g = Layer::group(name.clone(), Vec::new()); + if let LayerContent::Group(gr) = &mut g.content { + gr.expanded = expanded; + } + g.blend = if blend == BlendMode::PassThrough { BlendMode::PassThrough } else { blend }; + g + } else { + let mut l = Layer::raster(name.clone(), fmt); + l.blend = if blend == BlendMode::PassThrough { BlendMode::Normal } else { blend }; + l + }; + layer.opacity = float_opacity.unwrap_or(opacity); + layer.visible = visible; + layer.locks = locks; + layer.label = tag; + layer.clipped = clipped; + + if !group && hptr != 0 { + let source_channels = match (self.base_type, alpha) { + (2, false) => 1, + (2, true) => 2, + (1, false) => 1, + (1, true) => 2, + (_, false) => 3, + (_, true) => 4, + }; + let mut px = self.hierarchy(hptr, source_channels, ctl)?; + if (px.width, px.height) != (lw, lh) { + return Err(invalid(format!("layer '{name}' pixels are {}x{} where {lw}x{lh} were declared", px.width, px.height))); + } + if !alpha { + // An opaque layer: add the alpha channel the document's pixel format carries. + let n = px.channels; + let mut data = Vec::with_capacity(px.data.len() / n * (n + 1)); + for p in px.data.chunks_exact(n) { + data.extend_from_slice(p); + data.push(1.0); + } + px = Pixels { width: px.width, height: px.height, channels: n + 1, data }; + } + let mut surface = Surface::new(fmt); + write_pixels(&mut surface, &px, x, y)?; + layer.content = LayerContent::Raster(surface); + if !alpha { + layer.locks.transparency = true; + } + } + if mptr != 0 { + let (_mname, _mprops, px) = self.channel(mptr, ctl)?; + let mut surface = Surface::new(mask_fmt); + write_pixels(&mut surface, &px, x, y)?; + layer.mask = Some(LayerMask { surface, enabled: apply_mask, linked: true, density: 1.0, feather: 0.0 }); + } + let linear = composite_space.unsigned_abs() == 1 || blend_space.unsigned_abs() == 1; + Ok(Some((Node { layer, path, group, children: Vec::new() }, linear))) + } +} diff --git a/crates/io/src/xcf/tests.rs b/crates/io/src/xcf/tests.rs new file mode 100644 index 0000000000..ee96f16509 --- /dev/null +++ b/crates/io/src/xcf/tests.rs @@ -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, + 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 { + let n = pixels.len() / bpp; + let mut out = Vec::new(); + for b in 0..bpp { + let plane: Vec = (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 = (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)>, + pub data: Vec, + pub mask: Option>, + pub apply_mask: bool, +} + +impl TestLayer { + pub fn rgba(name: &'static str, width: u32, height: u32, data: Vec) -> 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)>, + pub layers: Vec, + pub channels: Vec<(&'static str, Vec, 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 { + 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 = self.layers.iter().map(|_| w.slot()).collect(); + w.ptr(0); + let channel_slots: Vec = 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 { + import("test.xcf", bytes, &Interrupt::NONE) +} + +fn rgba(w: u32, h: u32, f: impl Fn(u32, u32) -> [u8; 4]) -> Vec { + 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::>(), ["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::>(), ["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::>(), ["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 { + 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::>(), ["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::>(), ["Bottom", "Stray", "Group"]); + let children = layer_at(&d.layers, "Group").children().unwrap().iter().map(|l| l.name.clone()).collect::>(); + 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); +} diff --git a/crates/io/tests/xcf.rs b/crates/io/tests/xcf.rs new file mode 100644 index 0000000000..5c2399c3ee --- /dev/null +++ b/crates/io/tests/xcf.rs @@ -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 (`.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 { + 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 { + 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")); +} diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs index 15706a47e7..df4886dfb0 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -152,19 +152,18 @@ fn sample_buffer<'a>(channels: usize, stack: &'a mut [f32], heap: &'a mut Vec>, + of: std::collections::HashMap>, canonical: Vec>, } 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> { - let bpp = s.format.bytes_per_pixel(); + fn window(&self, s: &Surface, r: Rect) -> Option> { 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; } @@ -174,7 +173,8 @@ impl UniformTiles { } fn value_of(&mut self, t: &Arc, bpp: usize) -> Option { - 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); @@ -185,7 +185,7 @@ impl UniformTiles { self.canonical.len() - 1 } }); - self.of.insert(Arc::as_ptr(t), k); + self.of.insert(key, k); k } } @@ -860,6 +860,9 @@ impl Surface { let moved = content.translate(dx, dy); let targets: std::collections::BTreeSet = 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). @@ -876,7 +879,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)> { 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). @@ -888,10 +891,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() }; @@ -973,9 +973,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 } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 186112da99..e4136738bb 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -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); diff --git a/crates/ui-egui/src/move_ui.rs b/crates/ui-egui/src/move_ui.rs index 1c75d4d406..5032c08500 100644 --- a/crates/ui-egui/src/move_ui.rs +++ b/crates/ui-egui/src/move_ui.rs @@ -222,6 +222,9 @@ fn floating_doc(app: &mut PhotocraftApp, idx: usize) -> Option<(Arc, 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 { @@ -593,4 +596,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))); + } } diff --git a/crates/ui-egui/src/navigator_gpu_tests.rs b/crates/ui-egui/src/navigator_gpu_tests.rs index 454181ff72..0fa423a692 100644 --- a/crates/ui-egui/src/navigator_gpu_tests.rs +++ b/crates/ui-egui/src/navigator_gpu_tests.rs @@ -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); diff --git a/crates/ui-egui/src/snap_ui.rs b/crates/ui-egui/src/snap_ui.rs index 2a5bd32773..a76ccaec1a 100644 --- a/crates/ui-egui/src/snap_ui.rs +++ b/crates/ui-egui/src/snap_ui.rs @@ -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); } diff --git a/docs/development.md b/docs/development.md index 6f66984193..71380fcadb 100644 --- a/docs/development.md +++ b/docs/development.md @@ -504,6 +504,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` | @@ -544,13 +545,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=`) 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 diff --git a/docs/xcf.md b/docs/xcf.md new file mode 100644 index 0000000000..5717ae5b28 --- /dev/null +++ b/docs/xcf.md @@ -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) | diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 0000000000..73d114e6bc --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,2 @@ +sonar.projectKey=storytold_photocraft +sonar.organization=storytold diff --git a/xtask/gimp-corpus.sha256 b/xtask/gimp-corpus.sha256 new file mode 100644 index 0000000000..707854ec55 --- /dev/null +++ b/xtask/gimp-corpus.sha256 @@ -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 diff --git a/xtask/src/corpus.rs b/xtask/src/corpus.rs index c65abba9f1..4cd276d6c4 100644 --- a/xtask/src/corpus.rs +++ b/xtask/src/corpus.rs @@ -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}; @@ -36,7 +37,7 @@ const CRITICAL: &[&str] = &[ "crates/raw/", "crates/engine/src/atn_cmds", ]; -/// `cargo xtask corpus [--all | --pngsuite | --download | --psd | --psd-tools | --heif | --exr | --pixls | --affinity | --atn | --photoshop [--local]] [--update-manifest]` +/// `cargo xtask corpus [--all | --pngsuite | --download | --psd | --psd-tools | --heif | --exr | --pixls | --affinity | --atn | --photoshop [--local] | --gimp [--local]] [--update-manifest]` pub fn cmd(args: &[&str]) -> Result<(), String> { let update = args.contains(&"--update-manifest"); let mut did = false; @@ -83,6 +84,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(); } @@ -101,7 +110,7 @@ fn local_clone() -> Result { } /// 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()?; @@ -111,10 +120,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(()) } @@ -142,6 +152,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} @@ -161,6 +174,7 @@ fn list() { Pins: xtask/src/corpus_pins.rs. Moving one: change it, then -- --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], diff --git a/xtask/src/corpus_pins.rs b/xtask/src/corpus_pins.rs index bfb152b6d7..641d1ef263 100644 --- a/xtask/src/corpus_pins.rs +++ b/xtask/src/corpus_pins.rs @@ -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", @@ -307,7 +335,7 @@ pub const ATN: PinnedCorpus = PinnedCorpus { }; /// Every pinned corpus, in fetch order. -pub const ALL: &[&PinnedCorpus] = &[&PSD_MIXED, &PSD_TOOLS, &PHOTOSHOP, &HEIF, &EXR, &AFFINITY, &ATN]; +pub const ALL: &[&PinnedCorpus] = &[&PSD_MIXED, &PSD_TOOLS, &PHOTOSHOP, &GIMP, &HEIF, &EXR, &AFFINITY, &ATN]; /// `corpus/pixls`: real camera raws from (released into the public /// domain / CC0 by their photographers; see ATTRIBUTION.md). One file per decode path diff --git a/xtask/src/main.rs b/xtask/src/main.rs index d849d58d55..544e427a2e 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -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 | --atn | --photoshop] [--local] [--update-manifest] + corpus [--all | --pngsuite | --psd | --psd-tools | --pixls | --atn | --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 ]... [--changed] [--pixls] [--local] [-- ] 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