diff --git a/.coderabbit.yaml b/.coderabbit.yaml
new file mode 100644
index 0000000000..2408084cf9
--- /dev/null
+++ b/.coderabbit.yaml
@@ -0,0 +1,6 @@
+# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
+reviews:
+ auto_review:
+ # Dependabot bumps are checked by CI; skip CodeRabbit reviews on them.
+ ignore_usernames:
+ - "dependabot[bot]"
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000000..2c4b8cb1a4
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,30 @@
+version: 2
+updates:
+ # Maintain Rust / Cargo dependencies
+ - package-ecosystem: "cargo"
+ directory: "/"
+ schedule:
+ interval: "daily"
+ time: "06:00"
+ timezone: "America/New_York"
+ # Consolidate updates into a single PR rather than one PR per crate
+ groups:
+ cargo-dependencies:
+ patterns:
+ - "*"
+ commit-message:
+ prefix: "chore(deps)"
+
+ # Maintain GitHub Actions workflows
+ - package-ecosystem: "github-actions"
+ directory: "/"
+ schedule:
+ interval: "daily"
+ time: "06:00"
+ timezone: "America/New_York"
+ groups:
+ actions:
+ patterns:
+ - "*"
+ commit-message:
+ prefix: "ci(deps)"
diff --git a/AGENTS.md b/AGENTS.md
index 97b5ccdbc0..a003c661ba 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -27,7 +27,7 @@ PhotoCraft is an open-source, native, Photoshop-comparable image editor written
crates/
geom cms color raster L0 foundation (geometry, ICC colour management, pixel formats + blend math, COW tiles)
psd codecs L0 standalone format crates (no workspace deps; publishable)
- tablet L0 standalone pen tablet input (macOS AppKit, X11 XInput2); the one isolated unsafe crate
+ tablet L0 standalone pen tablet input (macOS AppKit, X11 XInput2, Windows barrel button); the one isolated unsafe crate
doc L1 document model (layers, masks, adjustments, effects, smart objects: pure data)
ops paint algo text vector L2 history, brush engine, imaging algorithms, type engine, paths/shapes
compose gpu format L3 CPU compositor (the oracle), wgpu compositor, .pcraft native format
@@ -52,7 +52,7 @@ xtask/ cargo xtask layers | wasm | ci | stats | corpus | t
People trust PhotoCraft with their work, and a crash loses it. A malformed file, a bad command or MCP param, a corrupt settings file, an odd keystroke or a full disk must produce an error the user or agent can act on, never a panic. Don't ship a feature by adding a panic path; fix a crash before building on top of it. The shared standard is `../craftrules/standards/never-crash.md`.
- **Non-test code never panics.** No `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!` or `unimplemented!`. Return the crate's error type and propagate with `?`; use `ok_or(..)?`, `let .. else { return Err(..) }`, `if let`, or `unwrap_or*` where a fallback is truly correct (never one that silently corrupts a document). Unfinished features return an "unsupported" error. The only exception is a provably infallible literal: `#[allow(clippy::expect_used)]` plus `.expect("why it can't fail")`.
-- **No `unsafe`.** The workspace sets `unsafe_code = "forbid"`. The one exception is the isolated helper crate `photocraft-tablet` (`crates/tablet`): winit drops pen tablet data, and reading it on macOS needs an AppKit event monitor (Objective-C interop). Only its `src/macos.rs` allows `unsafe` (`unsafe_code = "deny"` crate-wide, every block has a `SAFETY:` comment, tested against real `NSEvent`s); its X11 path and all mapping code are safe. Don't add `unsafe` anywhere else.
+- **No `unsafe`.** The workspace sets `unsafe_code = "forbid"`. The one exception is the isolated helper crate `photocraft-tablet` (`crates/tablet`): winit drops pen tablet data, and reading it on macOS needs an AppKit event monitor (Objective-C interop) and on Windows a pointer message hook (`GetPointerPenInfo`). Only its `src/macos.rs` and `src/windows.rs` allow `unsafe` (`unsafe_code = "deny"` crate-wide, every block has a `SAFETY:` comment, tested against real `NSEvent`s and pointer messages); its X11 path and all mapping code are safe. Don't add `unsafe` anywhere else.
- **Input-derived numbers are hostile.** Use `get()` rather than `[i]`/`[a..b]` for indices from files, params, selections or arithmetic on them; slice strings only at char boundaries; use `checked_*`/`saturating_*` for lengths, offsets and counts; guard division by zero and NaN/inf casts; cap allocations sized by input.
- **Bound recursion** with depth limits or seen-sets (documents can be deep or cyclic).
- **Don't cascade.** Handle lock poisoning (`lock().unwrap_or_else(PoisonError::into_inner)`) and treat thread joins as `Result`s.
diff --git a/Cargo.lock b/Cargo.lock
index 26920a968e..b0ff196c55 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -849,7 +849,7 @@ dependencies = [
"maybe-owned",
"rustix 1.1.5",
"rustix-linux-procfs",
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
"winx",
]
@@ -1234,7 +1234,7 @@ source = "git+https://github.com/storytold/craft-libs?rev=3d37da389a3618ab9b7610
dependencies = [
"craft-winprint-gdi",
"miniz_oxide 0.8.9",
- "roxmltree",
+ "roxmltree 0.20.0",
"tempfile",
"winprint",
"winsafe",
@@ -1896,7 +1896,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -1934,6 +1934,18 @@ dependencies = [
"pin-project-lite",
]
+[[package]]
+name = "exacl"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8de9e551ced9d700a31dacb8b168129640240fcd7a279a3fa4d59608b84ebcae"
+dependencies = [
+ "bitflags 2.13.2",
+ "log",
+ "scopeguard",
+ "uuid",
+]
+
[[package]]
name = "exr"
version = "1.74.2"
@@ -2104,7 +2116,7 @@ version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646"
dependencies = [
- "roxmltree",
+ "roxmltree 0.20.0",
]
[[package]]
@@ -2129,9 +2141,17 @@ dependencies = [
"hashbrown 0.17.1",
"linebender_resource_handle",
"memmap2",
+ "objc2 0.6.4",
+ "objc2-core-foundation",
+ "objc2-core-text",
+ "objc2-foundation 0.3.2",
"parlance",
"read-fonts",
+ "roxmltree 0.21.1",
"smallvec",
+ "windows",
+ "windows-core",
+ "yeslogic-fontconfig-sys",
]
[[package]]
@@ -3033,7 +3053,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
dependencies = [
"hermit-abi",
"libc",
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -3695,7 +3715,7 @@ dependencies = [
"objc2-foundation 0.3.2",
"png 0.18.1",
"thiserror 2.0.21",
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -4441,7 +4461,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -4618,7 +4638,7 @@ dependencies = [
[[package]]
name = "photocraft"
-version = "0.10.0"
+version = "0.11.1"
dependencies = [
"arboard",
"async-io",
@@ -4663,7 +4683,7 @@ dependencies = [
[[package]]
name = "photocraft-affinity"
-version = "0.10.0"
+version = "0.11.1"
dependencies = [
"crc32fast",
"flate2",
@@ -4673,7 +4693,7 @@ dependencies = [
[[package]]
name = "photocraft-algo"
-version = "0.10.0"
+version = "0.11.1"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4686,7 +4706,7 @@ dependencies = [
[[package]]
name = "photocraft-automation"
-version = "0.10.0"
+version = "0.11.1"
dependencies = [
"base64 0.22.1",
"cap-std",
@@ -4699,6 +4719,7 @@ dependencies = [
"photocraft-geom",
"photocraft-io",
"rmcp",
+ "rustix 1.1.5",
"schemars",
"serde",
"serde_json",
@@ -4708,7 +4729,7 @@ dependencies = [
[[package]]
name = "photocraft-cli"
-version = "0.10.0"
+version = "0.11.1"
dependencies = [
"photocraft-automation",
"photocraft-codecs",
@@ -4722,7 +4743,7 @@ dependencies = [
[[package]]
name = "photocraft-cms"
-version = "0.10.0"
+version = "0.11.1"
dependencies = [
"moxcms",
"rayon",
@@ -4730,7 +4751,7 @@ dependencies = [
[[package]]
name = "photocraft-codecs"
-version = "0.10.0"
+version = "0.11.1"
dependencies = [
"exr",
"flate2",
@@ -4751,7 +4772,7 @@ dependencies = [
[[package]]
name = "photocraft-color"
-version = "0.10.0"
+version = "0.11.1"
dependencies = [
"photocraft-cms",
"serde",
@@ -4759,7 +4780,7 @@ dependencies = [
[[package]]
name = "photocraft-compose"
-version = "0.10.0"
+version = "0.11.1"
dependencies = [
"criterion",
"photocraft-color",
@@ -4774,7 +4795,7 @@ dependencies = [
[[package]]
name = "photocraft-doc"
-version = "0.10.0"
+version = "0.11.1"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4785,7 +4806,7 @@ dependencies = [
[[package]]
name = "photocraft-engine"
-version = "0.10.0"
+version = "0.11.1"
dependencies = [
"blake3",
"craft-print",
@@ -4811,20 +4832,23 @@ dependencies = [
"photocraft-text",
"photocraft-vector",
"rayon",
- "roxmltree",
+ "roxmltree 0.20.0",
+ "same-file",
"serde",
"serde_json",
"tempfile",
"thiserror 2.0.21",
"wat",
+ "winsafe",
]
[[package]]
name = "photocraft-format"
-version = "0.10.0"
+version = "0.11.1"
dependencies = [
"blake3",
"crc32fast",
+ "exacl",
"flate2",
"libc",
"photocraft-codecs",
@@ -4838,18 +4862,19 @@ dependencies = [
"serde",
"serde_json",
"thiserror 2.0.21",
+ "winsafe",
]
[[package]]
name = "photocraft-geom"
-version = "0.10.0"
+version = "0.11.1"
dependencies = [
"serde",
]
[[package]]
name = "photocraft-gpu"
-version = "0.10.0"
+version = "0.11.1"
dependencies = [
"half",
"log",
@@ -4866,14 +4891,14 @@ dependencies = [
[[package]]
name = "photocraft-heif"
-version = "0.10.0"
+version = "0.11.1"
dependencies = [
"heic-rs",
]
[[package]]
name = "photocraft-io"
-version = "0.10.0"
+version = "0.11.1"
dependencies = [
"blake3",
"craft-docpath",
@@ -4896,25 +4921,25 @@ dependencies = [
"photocraft-vector",
"proptest",
"resvg",
- "roxmltree",
+ "roxmltree 0.20.0",
"serde_json",
"thiserror 2.0.21",
]
[[package]]
name = "photocraft-ipp"
-version = "0.10.0"
+version = "0.11.1"
[[package]]
name = "photocraft-ops"
-version = "0.10.0"
+version = "0.11.1"
dependencies = [
"photocraft-doc",
]
[[package]]
name = "photocraft-paint"
-version = "0.10.0"
+version = "0.11.1"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4925,7 +4950,7 @@ dependencies = [
[[package]]
name = "photocraft-plugins"
-version = "0.10.0"
+version = "0.11.1"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4940,7 +4965,7 @@ dependencies = [
[[package]]
name = "photocraft-psd"
-version = "0.10.0"
+version = "0.11.1"
dependencies = [
"flate2",
"photocraft-psd",
@@ -4951,17 +4976,18 @@ dependencies = [
[[package]]
name = "photocraft-raster"
-version = "0.10.0"
+version = "0.11.1"
dependencies = [
"photocraft-color",
"photocraft-geom",
"proptest",
+ "rayon",
"serde",
]
[[package]]
name = "photocraft-raw"
-version = "0.10.0"
+version = "0.11.1"
dependencies = [
"flate2",
"image",
@@ -4976,7 +5002,7 @@ dependencies = [
[[package]]
name = "photocraft-tablet"
-version = "0.10.0"
+version = "0.11.1"
dependencies = [
"block2 0.6.2",
"objc2 0.6.4",
@@ -4988,7 +5014,7 @@ dependencies = [
[[package]]
name = "photocraft-testkit"
-version = "0.10.0"
+version = "0.11.1"
dependencies = [
"memory-stats",
"photocraft-color",
@@ -4999,7 +5025,7 @@ dependencies = [
[[package]]
name = "photocraft-text"
-version = "0.10.0"
+version = "0.11.1"
dependencies = [
"flate2",
"parley",
@@ -5008,12 +5034,13 @@ dependencies = [
"photocraft-geom",
"photocraft-psd",
"photocraft-raster",
+ "rayon",
"skrifa",
]
[[package]]
name = "photocraft-ui-egui"
-version = "0.10.0"
+version = "0.11.1"
dependencies = [
"base64 0.22.1",
"craft-print",
@@ -5055,7 +5082,7 @@ dependencies = [
[[package]]
name = "photocraft-vector"
-version = "0.10.0"
+version = "0.11.1"
dependencies = [
"photocraft-color",
"photocraft-doc",
@@ -5066,7 +5093,7 @@ dependencies = [
[[package]]
name = "photocraft-web"
-version = "0.10.0"
+version = "0.11.1"
dependencies = [
"eframe",
"egui",
@@ -5088,7 +5115,7 @@ dependencies = [
[[package]]
name = "photocraft-web-boot"
-version = "0.10.0"
+version = "0.11.1"
dependencies = [
"flate2",
"js-sys",
@@ -5481,7 +5508,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -5942,6 +5969,15 @@ version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
+[[package]]
+name = "roxmltree"
+version = "0.21.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb"
+dependencies = [
+ "memchr",
+]
+
[[package]]
name = "rustc-hash"
version = "1.1.0"
@@ -6000,7 +6036,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -6067,7 +6103,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -6705,7 +6741,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -6932,10 +6968,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
- "getrandom 0.3.4",
+ "getrandom 0.4.3",
"once_cell",
"rustix 1.1.5",
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -7335,7 +7371,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -7442,7 +7478,7 @@ dependencies = [
"kurbo 0.11.3",
"log",
"pico-args",
- "roxmltree",
+ "roxmltree 0.20.0",
"rustybuzz",
"simplecss",
"siphasher",
@@ -8095,7 +8131,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -8675,7 +8711,7 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
[[package]]
name = "xtask"
-version = "0.10.0"
+version = "0.11.1"
dependencies = [
"serde",
"serde_json",
@@ -8688,6 +8724,17 @@ version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448"
+[[package]]
+name = "yeslogic-fontconfig-sys"
+version = "6.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d8b8abf912b9a29ff112e1671c97c33636903d13a69712037190e6805af4f76"
+dependencies = [
+ "dlib",
+ "once_cell",
+ "pkg-config",
+]
+
[[package]]
name = "yoke"
version = "0.8.3"
diff --git a/Cargo.toml b/Cargo.toml
index 3f8dbb0a72..b3f49802e3 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.10.0"
+version = "0.11.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
diff --git a/README.md b/README.md
index 12e2ef4b12..7554fcd8f2 100644
--- a/README.md
+++ b/README.md
@@ -20,6 +20,9 @@
+
+
+
diff --git a/apps/photocraft-cli/build.rs b/apps/photocraft-cli/build.rs
index c757a4a7c5..3951c46b81 100644
--- a/apps/photocraft-cli/build.rs
+++ b/apps/photocraft-cli/build.rs
@@ -12,6 +12,14 @@ fn main() -> std::io::Result<()> {
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
return Ok(());
}
+ // Printing to PDF (winprint's `PdfCreateRenderer`) imports Windows.Data.Pdf.dll, which
+ // stripped Windows images (NTLite, some N/LTSC builds) lack. A load-time import stops the exe
+ // from starting at all ("windows.data.pdf.dll was not found", #5884), so load it on first use;
+ // the print command checks for it first and reports an error instead.
+ if std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc") {
+ println!("cargo:rustc-link-arg-bins=/DELAYLOAD:windows.data.pdf.dll");
+ println!("cargo:rustc-link-arg-bins=delayimp.lib");
+ }
let mut res = winresource::WindowsResource::new();
// FileVersion / ProductVersion strings and the numeric FILEVERSION / PRODUCTVERSION are left
// to `WindowsResource::new`, which reads the Cargo package version. A pre-release such as
diff --git a/apps/photocraft-cli/src/lib.rs b/apps/photocraft-cli/src/lib.rs
index 03001ab02d..0e3429d074 100644
--- a/apps/photocraft-cli/src/lib.rs
+++ b/apps/photocraft-cli/src/lib.rs
@@ -15,19 +15,21 @@ pub const USAGE: &str = "\
photocraft-cli: headless PhotoCraft
USAGE:
- photocraft-cli convert [--format ] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>]
+ photocraft-cli convert [--format ] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression ] [--dds-mipmaps]
Convert between formats (.pcraft, .psd, .png, .jpg, .tif, .webp, .exr, …).
TIFF output is flat unless --tiff-layers keeps the layers (Photoshop layer data).
--quality sets the JPEG or WebP quality; a WebP written with a quality is lossy, without one lossless.
--tga-bits sets a TGA's bits per pixel: 32 writes the alpha channel (or else the transparency) as
its alpha, 24 none. Without it: 32 with an alpha channel or transparency, else 24.
--dpx-bits sets a DPX's bits per sample. Without it: 8 for an 8-bit document, else 16.
+ --dds-compression sets a DDS texture's compression: none (uncompressed, the default), bc1
+ (DXT1: alpha on or off), bc2 (DXT3) or bc3 (DXT5). --dds-mipmaps also writes the full mip chain.
photocraft-cli info [--compact]
Print the document as JSON (size, mode, depth, layer tree).
- photocraft-cli run ( | --new ) --cmd [--params ] [--cmd …] [--out ] [--format ] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>]
+ photocraft-cli run ( | --new ) --cmd [--params ] [--cmd …] [--out ] [--format ] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression ] [--dds-mipmaps]
Open a file, run engine commands in order, save the result. Each --params
applies to the preceding --cmd. Prints each command's JSON result.
- photocraft-cli batch --actions [--action ] --in --out [--format ] [--quality <1-100>] [--in-place] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>]
+ photocraft-cli batch --actions [--action ] --in --out [--format ] [--quality <1-100>] [--in-place] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression ] [--dds-mipmaps]
Apply an action list to every image in a directory. A Photoshop Actions file (.atn) is a
set: its action --action names plays, as below. JSON steps are [id, params] pairs,
{\"command\": id, \"params\": {…}} objects or bare ids, as a recorded action or droplet stores them
@@ -69,18 +71,23 @@ struct Subcommand {
}
const SUBCOMMANDS: &[Subcommand] = &[
- Subcommand { name: "convert", values: &["--format", "--quality", "--tga-bits", "--dpx-bits"], bare: &["--tiff-layers"], run: convert },
+ Subcommand {
+ name: "convert",
+ values: &["--format", "--quality", "--tga-bits", "--dpx-bits", "--dds-compression"],
+ bare: &["--tiff-layers", "--dds-mipmaps"],
+ run: convert,
+ },
Subcommand { name: "info", values: &[], bare: &["--compact"], run: |a, out, _| info(a, out) },
Subcommand {
name: "run",
- values: &["--new", "--cmd", "--params", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits"],
- bare: &["--tiff-layers"],
+ values: &["--new", "--cmd", "--params", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits", "--dds-compression"],
+ bare: &["--tiff-layers", "--dds-mipmaps"],
run: run_cmds,
},
Subcommand {
name: "batch",
- values: &["--actions", "--action", "--in", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits"],
- bare: &["--in-place", "--tiff-layers"],
+ values: &["--actions", "--action", "--in", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits", "--dds-compression"],
+ bare: &["--in-place", "--tiff-layers", "--dds-mipmaps"],
run: batch,
},
Subcommand { name: "droplet", values: &["--out"], bare: &[], run: droplet },
@@ -219,6 +226,11 @@ fn export_opts(a: &Args) -> Result {
o.encode.dpx_bits =
Some(b.parse().ok().and_then(photocraft_io::DpxBits::from_bits).ok_or_else(|| format!("bad --dpx-bits `{b}`: expected 8, 10, 12 or 16"))?);
}
+ if let Some(c) = a.get("--dds-compression") {
+ o.encode.dds.compression =
+ photocraft_io::DdsCompression::from_name(c).ok_or_else(|| format!("bad --dds-compression `{c}`: expected none, bc1, bc2 or bc3"))?;
+ }
+ o.encode.dds.mipmaps = a.has("--dds-mipmaps");
Ok(o)
}
diff --git a/apps/photocraft-cli/tests/cli.rs b/apps/photocraft-cli/tests/cli.rs
index 3c28a8196e..19258ca9ed 100644
--- a/apps/photocraft-cli/tests/cli.rs
+++ b/apps/photocraft-cli/tests/cli.rs
@@ -163,19 +163,29 @@ fn info_and_convert_report_decode_warnings() {
gif.extend_from_slice(b"\x2C\0\0\0\0\x01\0\x01\0\0\x02\x02\x44\x01\0");
}
gif.push(0x3B);
+ // A GIF opens in Indexed Color (#7886), so `convert` to PNG also notes the palette PNG it
+ // writes, after the decode warning.
let cases = [
- ("whole.jpg", &jpeg[..], None),
- ("half.jpg", &jpeg[..jpeg.len() / 2], Some("JPEG data ends early (the file is truncated or damaged); part of the image is missing")),
- ("anim.gif", &gif[..], Some("only the first of 3 frames was imported")),
+ ("whole.jpg", &jpeg[..], None, None),
+ ("half.jpg", &jpeg[..jpeg.len() / 2], Some("JPEG data ends early (the file is truncated or damaged); part of the image is missing"), None),
+ ("anim.gif", &gif[..], Some("only the first of 3 frames was imported"), Some("written as an 8-bit palette PNG (2 colours)")),
];
- for (name, bytes, warning) in cases {
+ for (name, bytes, warning, export) in cases {
let file = d.join(name);
std::fs::write(&file, bytes).unwrap();
let (out, _) = ok(bin().arg("info").arg(&file).arg("--compact"));
let v: Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["warnings"], json!(warning.into_iter().collect::>()), "{name}");
let (_, err) = ok(bin().arg("convert").arg(&file).arg(d.join(format!("{name}.png"))));
- assert_eq!(err.trim(), warning.map(|w| format!("warning: {w}")).unwrap_or_default(), "{name}");
+ let expected: Vec = warning.into_iter().chain(export).map(|w| format!("warning: {w}")).collect();
+ assert_eq!(
+ err.trim(),
+ expected.join(
+ "
+"
+ ),
+ "{name}"
+ );
}
std::fs::remove_dir_all(d).unwrap();
}
@@ -494,6 +504,39 @@ fn dpx_converts_both_ways_with_the_bits_asked_for() {
}
}
+/// DDS converts both ways; `--dds-compression` picks the FourCC (bytes 84–87; none writes
+/// uncompressed pixels) and `--dds-mipmaps` the mip count (bytes 28–31). A compression that
+/// isn't none, bc1, bc2 or bc3 is refused and nothing is written (#1327).
+#[test]
+fn dds_converts_both_ways_with_the_compression_asked_for() {
+ let d = tmp("dds");
+ let a = d.join("a.png");
+ write_png(&a, 8, 4, 3);
+ for (args, fourcc, mips) in [
+ (&[][..], [0; 4], 1),
+ (&["--dds-compression", "bc1"][..], *b"DXT1", 1),
+ (&["--dds-compression", "DXT3", "--dds-mipmaps"][..], *b"DXT3", 4),
+ (&["--dds-compression", "bc3"][..], *b"DXT5", 1),
+ (&["--dds-mipmaps"][..], [0; 4], 4),
+ ] {
+ let out = d.join("a.dds");
+ ok(bin().arg("convert").arg(&a).arg(&out).args(args));
+ let bytes = std::fs::read(&out).unwrap();
+ let mip_count = u32::from_le_bytes(bytes[28..32].try_into().unwrap());
+ assert_eq!((&bytes[..4], &bytes[84..88], mip_count), (&b"DDS "[..], &fourcc[..], mips), "{args:?}");
+ let back = d.join("back.png");
+ ok(bin().arg("convert").arg(&out).arg(&back));
+ assert!(back.exists(), "{args:?}");
+ }
+ for bad in ["bc7", "dxt2", ""] {
+ let out = d.join("bad.dds");
+ let o = bin().arg("convert").arg(&a).arg(&out).args(["--dds-compression", bad]).output().unwrap();
+ assert_eq!(o.status.code(), Some(1), "--dds-compression {bad}");
+ assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("bad --dds-compression `{bad}`")), "--dds-compression {bad}");
+ assert!(!out.exists(), "--dds-compression {bad} wrote a file");
+ }
+}
+
/// `--tga-bits` picks a TGA's bits per pixel (byte 16 of its header); anything but 24 or 32 is
/// refused and nothing is written.
#[test]
diff --git a/apps/photocraft-web-boot/Cargo.toml b/apps/photocraft-web-boot/Cargo.toml
index dd12341af2..7443ead089 100644
--- a/apps/photocraft-web-boot/Cargo.toml
+++ b/apps/photocraft-web-boot/Cargo.toml
@@ -21,4 +21,4 @@ flate2 = { workspace = true }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
-web-sys = { version = "0.3", features = ["Window", "Document", "Element", "Node", "Request", "RequestInit", "RequestCache", "Response", "ResponseInit", "Headers", "ReadableStream", "WritableStream", "TransformStream", "TransformStreamDefaultController", "Transformer", "StreamPipeOptions", "ReadableWritablePair", "console"] }
+web-sys = { version = "0.3", features = ["Window", "Document", "Element", "Node", "Request", "RequestInit", "RequestCache", "Response", "ResponseInit", "Headers", "ReadableStream", "ReadableStreamDefaultReader", "WritableStream", "WritableStreamDefaultWriter", "TransformStream", "TransformStreamDefaultController", "Transformer", "StreamPipeOptions", "ReadableWritablePair", "console"] }
diff --git a/apps/photocraft-web-boot/src/main.rs b/apps/photocraft-web-boot/src/main.rs
index 56c2bf1517..c7dc50d9d5 100644
--- a/apps/photocraft-web-boot/src/main.rs
+++ b/apps/photocraft-web-boot/src/main.rs
@@ -8,7 +8,10 @@
//! one part, however large the app grows.
//!
//! The browser inflates the parts (`DecompressionStream`: Safari 16.4, Firefox 113, Chrome 80).
-//! In one that can't, this module inflates them itself, a little slower.
+//! In one that can't, this module inflates them itself, a little slower. Some servers recognise
+//! the gzip in a part and send it with `Content-Encoding: gzip` (Apache's `mod_mime_magic`
+//! labels it `x-gzip`), so the browser has inflated it already: the joined download is then
+//! taken as it is, by its first bytes.
//!
//! A download that fails is tried again, the later tries past the browser's cache (a bad copy
//! there would otherwise fail forever). When every try fails the page says so and offers a reload.
@@ -36,6 +39,34 @@ fn parts(list: &str) -> Option> {
(!parts.is_empty() && parts.iter().all(plain)).then_some(parts)
}
+/// What the joined parts hold, told by their first bytes.
+#[cfg(any(target_arch = "wasm32", test))]
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+enum Payload {
+ /// The gzip packaging made, still to be inflated.
+ Gzip,
+ /// The app's WebAssembly: the server sent the gzip with a `Content-Encoding` the browser
+ /// already undid.
+ Wasm,
+}
+
+/// Enough leading bytes to tell the payloads apart.
+#[cfg(any(target_arch = "wasm32", test))]
+const HEAD: usize = 4;
+
+/// What a download starting with `head` (at least `HEAD` bytes, or all there were) holds, or
+/// `None` when it is neither, such as a web page or nothing at all.
+#[cfg(any(target_arch = "wasm32", test))]
+fn payload(head: &[u8]) -> Option {
+ if head.starts_with(&[0x1f, 0x8b]) {
+ Some(Payload::Gzip)
+ } else if head.starts_with(b"\0asm") {
+ Some(Payload::Wasm)
+ } else {
+ None
+ }
+}
+
/// Gzip inflated a chunk at a time, for browsers that can't do it themselves.
#[cfg(any(target_arch = "wasm32", test))]
struct Inflater(flate2::write::GzDecoder>);
@@ -69,11 +100,11 @@ mod boot {
use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::{JsFuture, spawn_local};
use web_sys::{
- Headers, ReadableStream, ReadableWritablePair, RequestCache, RequestInit, Response, ResponseInit, StreamPipeOptions, TransformStream,
- TransformStreamDefaultController, Transformer, WritableStream,
+ Headers, ReadableStream, ReadableStreamDefaultReader, ReadableWritablePair, RequestCache, RequestInit, Response, ResponseInit, StreamPipeOptions,
+ TransformStream, TransformStreamDefaultController, Transformer, WritableStream, WritableStreamDefaultWriter,
};
- use super::Inflater;
+ use super::{HEAD, Inflater, Payload};
/// Where packaging puts the app, next to this module's own files.
const PARTS: &str = "./app/parts.txt";
@@ -172,11 +203,71 @@ mod boot {
Ok(compressed.pipe_through(&pair))
}
+ /// The next chunk `reader` gives, or `None` at the end of the stream.
+ async fn read(reader: &ReadableStreamDefaultReader) -> Result, JsValue> {
+ let result = JsFuture::from(reader.read()).await?;
+ if js_sys::Reflect::get(&result, &JsValue::from_str("done"))?.is_truthy() {
+ return Ok(None);
+ }
+ Ok(Some(js_sys::Reflect::get(&result, &JsValue::from_str("value"))?.dyn_into()?))
+ }
+
+ /// Writes `chunk` into `writer`. When nothing will read it any more (the app's loader gave up
+ /// on its stream), the download from `reader` is cancelled too, so it doesn't stay pending.
+ async fn write(chunk: &js_sys::Uint8Array, reader: &ReadableStreamDefaultReader, writer: &WritableStreamDefaultWriter) -> Result<(), JsValue> {
+ if let Err(e) = JsFuture::from(writer.write_with_chunk(chunk)).await {
+ let _ = reader.cancel_with_reason(&e);
+ return Err(e);
+ }
+ Ok(())
+ }
+
+ /// Writes the `held` chunks, then the rest of `reader`'s stream, into `writer`, then closes it.
+ async fn pass_on(held: Vec, reader: &ReadableStreamDefaultReader, writer: &WritableStreamDefaultWriter) -> Result<(), JsValue> {
+ for chunk in &held {
+ write(chunk, reader, writer).await?;
+ }
+ while let Some(chunk) = read(reader).await? {
+ write(&chunk, reader, writer).await?;
+ }
+ JsFuture::from(writer.close()).await?;
+ Ok(())
+ }
+
+ /// The app's WebAssembly from the `joined` parts: inflated, unless its first bytes show the
+ /// browser has already done that.
+ async fn unpacked(joined: &ReadableStream) -> Result {
+ let reader: ReadableStreamDefaultReader = joined.get_reader().unchecked_into();
+ let (mut head, mut held) = (Vec::with_capacity(HEAD), Vec::new());
+ while head.len() < HEAD {
+ let Some(chunk) = read(&reader).await? else { break };
+ let wanted = u32::try_from(HEAD - head.len()).unwrap_or(u32::MAX).min(chunk.length());
+ head.extend(chunk.subarray(0, wanted).to_vec());
+ held.push(chunk);
+ }
+ let Some(payload) = super::payload(&head) else {
+ let e = JsValue::from_str("the download is not PhotoCraft's WebAssembly");
+ // Stops the rest of the download, which nothing will read.
+ let _ = reader.cancel_with_reason(&e);
+ return Err(e);
+ };
+ let rest = TransformStream::new()?;
+ let writer = rest.writable().get_writer()?;
+ spawn_local(async move {
+ if let Err(e) = pass_on(held, &reader, &writer).await {
+ let _ = writer.abort_with_reason(&e);
+ }
+ });
+ match payload {
+ Payload::Gzip => inflated(&rest.readable()),
+ Payload::Wasm => Ok(rest.readable()),
+ }
+ }
+
async fn run(fresh: bool) -> Result<(), JsValue> {
let list = JsFuture::from(fetch(PARTS, fresh).await?.text()?).await?.as_string().unwrap_or_default();
let parts = super::parts(&list).ok_or_else(|| JsValue::from_str("app/parts.txt is not a parts list"))?;
let joined = TransformStream::new()?;
- let wasm = inflated(&joined.readable())?;
// Why the download stopped: the app's loader only reports that its stream broke.
let cause = Rc::new(RefCell::new(None));
let failed = cause.clone();
@@ -187,6 +278,10 @@ mod boot {
failed.replace(Some(e));
}
});
+ let wasm = match unpacked(&joined.readable()).await {
+ Ok(wasm) => wasm,
+ Err(e) => return Err(cause.take().unwrap_or(e)),
+ };
let headers = Headers::new()?;
headers.set("Content-Type", "application/wasm")?;
let init = ResponseInit::new();
@@ -258,7 +353,7 @@ fn main() {
#[cfg(test)]
mod tests {
- use super::{Inflater, MARKER, parts};
+ use super::{HEAD, Inflater, MARKER, Payload, parts, payload};
fn gzip(bytes: &[u8]) -> Vec {
let mut z = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
@@ -317,6 +412,22 @@ mod tests {
assert!(run(b"").is_err(), "nothing at all");
}
+ /// A server that recognises the gzip (Apache's `mod_mime_magic`) sends it with
+ /// `Content-Encoding: x-gzip`, and the browser inflates it on the way (#7639). The joined parts
+ /// are then the app's WebAssembly, which must start as it is, not be inflated a second time.
+ #[test]
+ fn the_first_bytes_tell_gzip_from_wasm_the_browser_already_inflated() {
+ let mut wasm = b"\0asm\x01\0\0\0".to_vec();
+ wasm.extend(sample());
+ let packed = gzip(&wasm);
+ assert_eq!(payload(&packed[..HEAD]), Some(Payload::Gzip), "as packaged");
+ assert_eq!(payload(&wasm[..HEAD]), Some(Payload::Wasm), "inflated by the browser");
+ assert_eq!(payload(&packed[..2]), Some(Payload::Gzip), "a download shorter than HEAD");
+ assert_eq!(payload(b""), None, "a web page");
+ assert_eq!(payload(b"\0as"), None, "a cut-off wasm header");
+ assert_eq!(payload(b""), None, "nothing at all");
+ }
+
#[test]
fn a_parts_list_gives_its_names_in_order() {
let list = format!("{MARKER}\nphotocraft-web-0123abcd.part-aa\r\n\n photocraft-web-0123abcd.part-ab \n");
diff --git a/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs
index 6fd3620483..a248ecbb13 100644
--- a/apps/photocraft-web/src/web.rs
+++ b/apps/photocraft-web/src/web.rs
@@ -427,6 +427,8 @@ fn services(inbox: Inbox, place_inbox: Inbox, cursor_pos: photocraft_ui_egui::Cu
}
})),
write: Some(Box::new(|path: &str, bytes: &[u8]| download(path, bytes))),
+ // A download may be cancelled and never reports back: it doesn't save the document.
+ write_unconfirmed: true,
encode_png: Some(Box::new(|w, h, rgba| {
let img = Image::from_u8(w, h, ChannelLayout::Rgba, rgba.to_vec()).map_err(|e| e.to_string())?;
photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &EncodeOptions::default()).map_err(|e| e.to_string())
diff --git a/apps/photocraft/build.rs b/apps/photocraft/build.rs
index cbba622589..48abc9f1a5 100644
--- a/apps/photocraft/build.rs
+++ b/apps/photocraft/build.rs
@@ -11,6 +11,14 @@ fn main() {
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
return;
}
+ // Printing to PDF (winprint's `PdfCreateRenderer`) imports Windows.Data.Pdf.dll, which
+ // stripped Windows images (NTLite, some N/LTSC builds) lack. A load-time import stops the exe
+ // from starting at all ("windows.data.pdf.dll was not found", #5884), so load it on first use;
+ // the print command checks for it first and reports an error instead.
+ if std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc") {
+ println!("cargo:rustc-link-arg-bins=/DELAYLOAD:windows.data.pdf.dll");
+ println!("cargo:rustc-link-arg-bins=delayimp.lib");
+ }
let mut res = winresource::WindowsResource::new();
res.set_icon("../../assets/app-icon/photocraft.ico")
.set("ProductName", "PhotoCraft")
diff --git a/apps/photocraft/src/app_dirs.rs b/apps/photocraft/src/app_dirs.rs
index 5775e254df..a75520f3c4 100644
--- a/apps/photocraft/src/app_dirs.rs
+++ b/apps/photocraft/src/app_dirs.rs
@@ -10,8 +10,8 @@
//! 3. The platform convention: macOS `~/Library/Application Support/Photocraft`, Windows
//! `%APPDATA%\Photocraft`, Linux `$XDG_CONFIG_HOME/photocraft` or `~/.config/photocraft`.
//!
-//! One exception: on Windows the platform convention keeps crash-recovery autosaves out of the
-//! roaming profile ([`recovery_dir`]).
+//! One exception: the platform convention keeps crash-recovery autosaves out of what is synced
+//! with the settings: the Windows roaming profile and the Linux config folder ([`recovery_dir`]).
use std::ffi::OsString;
use std::path::{Path, PathBuf};
@@ -77,18 +77,35 @@ pub fn recovery_dir() -> Option {
})
}
-/// The recovery folder for `data` (#5648). Autosaves hold whole documents and are rewritten every
-/// few minutes, so on Windows the platform convention puts them in the machine-local
-/// `%LOCALAPPDATA%\Photocraft\Recovery`: the roaming `%APPDATA%` may be synced at sign-in and
-/// sign-out or capped by policy. They stay out of `%TEMP%`, which cleanup tools may empty before
-/// a crashed document is recovered. The override and portable folders keep everything in one
-/// place, and other platforms already keep these folders local.
+/// The recovery folder for `data`. Autosaves hold whole documents and are rewritten every few
+/// minutes, so with the platform convention they stay out of what is synced with the settings:
+/// - Windows (#5648): the machine-local `%LOCALAPPDATA%\Photocraft\Recovery`; the roaming
+/// `%APPDATA%` may be synced at sign-in and sign-out or capped by policy.
+/// - Linux and other XDG systems (#6612): `$XDG_STATE_HOME/photocraft/Recovery`, by default
+/// `~/.local/state/photocraft/Recovery`, where the XDG Base Directory spec puts state that
+/// should outlive a restart but isn't configuration; dotfile managers and config sync often copy
+/// `~/.config` whole. As the spec asks, a relative `XDG_STATE_HOME` is ignored.
+/// - macOS: `~/Library/Application Support/Photocraft/Recovery` as before. Apple's guidelines put
+/// app-created data that can't be regenerated there; `~/Library/Caches` may be purged by the
+/// system.
+///
+/// They are never in a temp or cache folder, which cleanup tools may empty before a crashed
+/// document is recovered. The override and portable folders keep everything in one place.
pub fn resolve_recovery(data: &DataDir, env: impl Fn(&str) -> Option) -> Option {
- if cfg!(windows)
- && data.mode == Mode::Platform
- && let Some(local) = env("LOCALAPPDATA").filter(|l| !l.is_empty())
- {
- return Some(PathBuf::from(local).join("Photocraft").join(RECOVERY));
+ if data.mode == Mode::Platform {
+ if cfg!(windows) {
+ if let Some(local) = env("LOCALAPPDATA").filter(|l| !l.is_empty()) {
+ return Some(PathBuf::from(local).join("Photocraft").join(RECOVERY));
+ }
+ } else if !cfg!(target_os = "macos") {
+ let state = env("XDG_STATE_HOME")
+ .map(PathBuf::from)
+ .filter(|s| s.is_absolute())
+ .or_else(|| env("HOME").filter(|h| !h.is_empty()).map(|h| PathBuf::from(h).join(".local").join("state")));
+ if let Some(state) = state {
+ return Some(state.join("photocraft").join(RECOVERY));
+ }
+ }
}
data.dir.as_ref().map(|d| d.join(RECOVERY))
}
@@ -623,6 +640,34 @@ mod tests {
let _ = std::fs::remove_dir_all(&root);
}
+ /// #6612: on Linux (and other XDG systems) autosaves go to `$XDG_STATE_HOME`, by default
+ /// `~/.local/state`, not the config folder; a relative `XDG_STATE_HOME` is ignored. Windows
+ /// and macOS don't read it, and an override or portable folder keeps them inside itself.
+ #[test]
+ fn autosaves_go_to_the_xdg_state_folder() {
+ let root = temp("recovery-xdg");
+ let settings = root.join("home").join(".config").join("photocraft");
+ let home = root.join("home");
+ let state = root.join("state");
+ let platform = DataDir { dir: Some(settings.clone()), mode: Mode::Platform, warning: None };
+ let xdg = !cfg!(windows) && !cfg!(target_os = "macos");
+ let expect = |xdg_path: PathBuf| Some(if xdg { xdg_path } else { settings.join("Recovery") });
+ assert_eq!(resolve_recovery(&platform, env_of(&[("XDG_STATE_HOME", &state), ("HOME", &home)])), expect(state.join("photocraft").join("Recovery")));
+ let default = home.join(".local").join("state").join("photocraft").join("Recovery");
+ assert_eq!(resolve_recovery(&platform, env_of(&[("HOME", &home)])), expect(default.clone()));
+ let relative = PathBuf::from("relative/state");
+ assert_eq!(resolve_recovery(&platform, env_of(&[("XDG_STATE_HOME", &relative), ("HOME", &home)])), expect(default.clone()));
+ let empty = PathBuf::new();
+ assert_eq!(resolve_recovery(&platform, env_of(&[("XDG_STATE_HOME", &empty), ("HOME", &home)])), expect(default));
+ // No home at all: beside the settings, as before.
+ assert_eq!(resolve_recovery(&platform, |_| None), Some(settings.join("Recovery")));
+ for mode in [Mode::Override, Mode::Portable] {
+ let d = DataDir { dir: Some(settings.clone()), mode, warning: None };
+ assert_eq!(resolve_recovery(&d, env_of(&[("XDG_STATE_HOME", &state), ("HOME", &home)])), Some(settings.join("Recovery")), "{mode:?}");
+ }
+ let _ = std::fs::remove_dir_all(&root);
+ }
+
/// An autosave bundle and its sidecar, as `format::Autosaver` writes them.
fn autosave(dir: &Path, key: &str) {
std::fs::create_dir_all(dir.join(format!("{key}.pcraft"))).unwrap();
@@ -784,6 +829,7 @@ mod tests {
/// now-existing folder and the original is put back, so both autosaves stay (#6412 review).
#[test]
fn a_launch_that_times_out_never_writes_where_a_move_removes() {
+ let _spawns = crate::no_child_spawns();
let root = temp("recovery-timeout");
let legacy = root.join("roaming").join("Recovery");
autosave(&legacy, "old");
@@ -866,6 +912,7 @@ mod tests {
/// once the lock is released, the move happens.
#[test]
fn a_move_waits_for_another_process_holding_the_lock() {
+ let _spawns = crate::no_child_spawns();
let root = temp("recovery-lock");
let legacy = root.join("roaming").join("Recovery");
autosave(&legacy, "a");
diff --git a/apps/photocraft/src/gpu_startup.rs b/apps/photocraft/src/gpu_startup.rs
index e79b4bafac..33be58cd4c 100644
--- a/apps/photocraft/src/gpu_startup.rs
+++ b/apps/photocraft/src/gpu_startup.rs
@@ -12,8 +12,10 @@
//! - macOS: Metal → CPU
//!
//! The choice that worked is remembered in `performance.gpuBackend` (Preferences › Performance,
-//! where it can be reset). With `auto`, Intel adapters on Windows use DX12. `WGPU_BACKEND` still
-//! overrides everything, and `--safe-gpu` forces the CPU path for one launch.
+//! where it can be reset). With `auto`, Intel adapters on Windows use DX12, and when DXGI lists an
+//! Intel adapter the instance leaves Vulkan out, so the first start doesn't load the Intel Vulkan
+//! driver either. `WGPU_BACKEND` still overrides everything, and `--safe-gpu` forces the CPU path
+//! for one launch.
//!
//! "CPU" composites on the CPU and draws the window with a software adapter where the platform
//! has one (WARP on Windows, llvmpipe over GL elsewhere). On Linux a GL adapter that can't present
@@ -204,6 +206,28 @@ pub fn backends(plan: &Plan, os: Os) -> Option {
})
}
+/// Instance backends for `auto` on Windows (no `WGPU_BACKEND`) when `dx12_vendors`, the PCI vendor
+/// ids of the DX12 adapters, includes Intel: DX12, with GL as the last resort (see [`rank`]). An
+/// instance with Vulkan loads the Intel Vulkan driver before the adapter selector runs, and #4's
+/// crash is in that driver. `dx12_vendors` is called only for that plan; `None` keeps [`backends`]'
+/// choice.
+pub fn windows_auto_backends(plan: &Plan, os: Os, dx12_vendors: impl FnOnce() -> Vec) -> Option {
+ let auto = plan.env.is_none() && plan.backend == GpuBackend::Auto && os == Os::Windows;
+ (auto && dx12_vendors().contains(&INTEL)).then_some(wgpu::Backends::DX12 | wgpu::Backends::GL)
+}
+
+/// The PCI vendor ids of the DX12 adapters, from a DX12-only instance (DXGI, no Vulkan driver)
+/// with `desc`'s flags and backend options (the shader compiler from [`dx12_compiler`]).
+fn dx12_vendors(desc: &wgpu::InstanceDescriptor) -> Vec {
+ let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
+ backends: wgpu::Backends::DX12,
+ flags: desc.flags,
+ backend_options: desc.backend_options.clone(),
+ ..wgpu::InstanceDescriptor::new_without_display_handle()
+ });
+ crate::services::block_on(instance.enumerate_adapters(wgpu::Backends::DX12)).iter().map(|a| a.get_info().vendor).collect()
+}
+
/// The DX12 shader compiler (#712). wgpu's default loads `dxcompiler.dll` by name, and the
/// Windows DLL search falls through to the current directory and `PATH`, so another program's DXC
/// build (a browser's, an SDK's) got loaded and failed device creation or crashed the first shader
@@ -228,6 +252,8 @@ pub struct Candidate {
pub backend: wgpu::Backend,
/// Can present to the window.
pub surface_ok: bool,
+ /// Its name matches the adapter the user asked for ([`adapter_matches`]).
+ pub wanted: bool,
}
/// Rank of an adapter (lower is better): high-performance device types first (egui's power
@@ -259,19 +285,59 @@ fn rank(a: &Candidate, backend: GpuBackend, os: Os) -> (u8, u8, u8) {
(fallback, ty, be)
}
-/// The adapter to use among `adapters` (`None` when there is none).
+/// The adapter to use among `adapters` (`None` when there is none). Adapters the user asked for
+/// win over the rest when any of them can present; otherwise the ranking decides (see
+/// [`wanted_missed`]).
pub fn pick(adapters: &[Candidate], backend: GpuBackend, os: Os) -> Option {
- adapters.iter().enumerate().filter(|(_, a)| a.surface_ok).min_by_key(|(i, a)| (rank(a, backend, os), *i)).map(|(i, _)| i)
+ let usable = |a: &&Candidate| a.surface_ok;
+ let any_wanted = adapters.iter().filter(usable).any(|a| a.wanted);
+ adapters.iter().enumerate().filter(|(_, a)| a.surface_ok && (a.wanted || !any_wanted)).min_by_key(|(i, a)| (rank(a, backend, os), *i)).map(|(i, _)| i)
+}
+
+/// Whether an adapter called `name` is the one the user asked for: `wanted` is a case-insensitive
+/// piece of its name ("nvidia"), or several words that each appear in it ("arc a370m" finds
+/// "Intel(R) Arc(tm) A370M Graphics"). An empty `wanted` matches nothing.
+pub fn adapter_matches(name: &str, wanted: &str) -> bool {
+ photocraft_engine::prefs::gpu_adapter_matches(name, wanted)
+}
+
+/// The user asked for an adapter and none of `adapters` that can present is it.
+pub fn wanted_missed(adapters: &[Candidate], wanted: Option<&str>) -> bool {
+ wanted.is_some() && !adapters.iter().any(|a| a.surface_ok && a.wanted)
+}
+
+/// Choosing an adapter is Linux-only for now: Windows has its own Intel/DX12 selection and macOS a
+/// single Metal path, and neither is tested with a requested adapter.
+pub const ADAPTER_CHOICE: bool = cfg!(target_os = "linux");
+
+/// The adapter request that applies to this launch: none off Linux (`linux`), and none in CPU mode,
+/// which wants a software adapter whatever the preference says.
+pub fn applicable_adapter(wanted: Option, backend: GpuBackend, linux: bool) -> Option {
+ wanted.filter(|_| linux && backend != GpuBackend::Cpu)
+}
+
+/// Whether `PHOTOCRAFT_GPU_ADAPTER` makes this launch its own process: only when startup will act
+/// on it (a non-empty value, on Linux, outside CPU mode). Otherwise it changes nothing, and the
+/// launch may still be forwarded to the running window.
+pub fn adapter_override_is_process_specific(env: Option<&str>, mode: RenderingMode, linux: bool) -> bool {
+ env.is_some_and(|v| !v.trim().is_empty()) && linux && mode != RenderingMode::Cpu
+}
+
+/// The adapter to ask for: `PHOTOCRAFT_GPU_ADAPTER` when set (for one launch), else
+/// `performance.gpuAdapter`; `None` is automatic.
+pub fn wanted_adapter(env: Option<&str>, pref: &str) -> Option {
+ [env.unwrap_or(""), pref].iter().map(|s| s.trim()).find(|s| !s.is_empty()).map(|s| s.chars().take(256).collect())
}
/// Whether picking `chosen` applied the Intel-on-Windows DX12 default (a Vulkan adapter of the
-/// same vendor was available).
-pub fn intel_dx12_applied(adapters: &[Candidate], chosen: usize, os: Os) -> bool {
+/// same vendor was available, or `vulkan_left_out`: [`windows_auto_backends`] left Vulkan out of
+/// the instance).
+pub fn intel_dx12_applied(adapters: &[Candidate], chosen: usize, os: Os, vulkan_left_out: bool) -> bool {
let Some(c) = adapters.get(chosen) else { return false };
os == Os::Windows
&& c.vendor == INTEL
&& c.backend == wgpu::Backend::Dx12
- && adapters.iter().any(|a| a.vendor == INTEL && a.backend == wgpu::Backend::Vulkan)
+ && (vulkan_left_out || adapters.iter().any(|a| a.vendor == INTEL && a.backend == wgpu::Backend::Vulkan))
}
/// wgpu compiles indirect-call validation shaders inside `request_device`; Metal's compiler
@@ -285,7 +351,7 @@ fn skips_indirect_validation(os: Os, intel: bool) -> bool {
/// Configure eframe's wgpu setup for `plan`: instance backends, the DX12 shader compiler, adapter
/// selection (Intel on Windows, software adapters for `cpu`) and the marker update once the
/// adapter is chosen. `note` receives a remark for System Info (e.g. the Intel default).
-pub fn configure(setup: &mut egui_wgpu::WgpuSetup, plan: &Plan, os: Os, sentinel: SharedSentinel, note: Arc>>) {
+pub fn configure(setup: &mut egui_wgpu::WgpuSetup, plan: &Plan, os: Os, wanted: Option, sentinel: SharedSentinel, note: Arc>>) {
if let egui_wgpu::WgpuSetup::CreateNew(create) = setup {
if let Some(b) = backends(plan, os) {
create.instance_descriptor.backends = b;
@@ -298,7 +364,13 @@ pub fn configure(setup: &mut egui_wgpu::WgpuSetup, plan: &Plan, os: Os, sentinel
let env = std::env::var("WGPU_DX12_COMPILER").ok();
create.instance_descriptor.backend_options.dx12.shader_compiler = dx12_compiler(env.as_deref(), exe.as_deref().and_then(Path::parent));
}
- let select = plan.env.is_none() && (plan.backend == GpuBackend::Cpu || (plan.backend == GpuBackend::Auto && os == Os::Windows));
+ let without_vulkan = windows_auto_backends(plan, os, || dx12_vendors(&create.instance_descriptor));
+ if let Some(b) = without_vulkan {
+ create.instance_descriptor.backends = b;
+ }
+ let vulkan_left_out = without_vulkan.is_some();
+ let wanted = applicable_adapter(wanted, plan.backend, ADAPTER_CHOICE);
+ let select = wanted.is_some() || (plan.env.is_none() && (plan.backend == GpuBackend::Cpu || (plan.backend == GpuBackend::Auto && os == Os::Windows)));
if select {
let backend = plan.backend;
create.native_adapter_selector = Some(Arc::new(move |adapters: &[wgpu::Adapter], surface: Option<&wgpu::Surface<'_>>| {
@@ -311,15 +383,20 @@ pub fn configure(setup: &mut egui_wgpu::WgpuSetup, plan: &Plan, os: Os, sentinel
device_type: i.device_type,
backend: i.backend,
surface_ok: surface.is_none_or(|s| a.is_surface_supported(s)),
+ wanted: wanted.as_deref().is_some_and(|w| adapter_matches(&i.name, w)),
}
})
.collect();
let i = pick(&cands, backend, os).ok_or_else(|| "no graphics adapter found".to_string())?;
+ if let Some(w) = wanted.as_deref().filter(|_| wanted_missed(&cands, wanted.as_deref())) {
+ *note.lock().unwrap_or_else(PoisonError::into_inner) =
+ Some(format!("No graphics adapter named \"{w}\" can draw the window; using the automatic choice"));
+ }
if backend == GpuBackend::Cpu && cands.get(i).is_some_and(|c| c.device_type != wgpu::DeviceType::Cpu) {
*note.lock().unwrap_or_else(PoisonError::into_inner) =
Some("CPU image rendering; no software graphics adapter is available, so the window uses hardware graphics".into());
}
- if backend == GpuBackend::Auto && intel_dx12_applied(&cands, i, os) {
+ if backend == GpuBackend::Auto && intel_dx12_applied(&cands, i, os, vulkan_left_out) {
*note.lock().unwrap_or_else(PoisonError::into_inner) =
Some("Intel graphics on Windows: using DirectX 12 (the Intel Vulkan driver is known to crash)".into());
}
@@ -443,6 +520,13 @@ pub fn read_display_server(path: Option<&Path>) -> photocraft_engine::prefs::Lin
.unwrap_or_default()
}
+/// Preferences › Performance › Graphics Adapter (`performance.gpuAdapter`), read before the window
+/// opens; empty (automatic) when the file or value is missing.
+pub fn read_gpu_adapter(path: Option<&Path>) -> String {
+ let v: Value = path.and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null);
+ v.get("performance").and_then(|p| p.get("gpuAdapter")).and_then(Value::as_str).unwrap_or("").chars().take(256).collect()
+}
+
/// Preferences › Performance › Low-Latency Drawing (`performance.lowLatencyDrawing`, off unless
/// turned on), read before the window opens: it picks the surface's present mode.
pub fn read_low_latency(path: Option<&Path>) -> bool {
@@ -604,7 +688,82 @@ mod tests {
}
fn cand(vendor: u32, device_type: wgpu::DeviceType, backend: wgpu::Backend) -> Candidate {
- Candidate { vendor, device_type, backend, surface_ok: true }
+ Candidate { vendor, device_type, backend, surface_ok: true, wanted: false }
+ }
+
+ #[test]
+ fn a_requested_adapter_beats_the_automatic_ranking() {
+ use wgpu::{Backend as B, DeviceType as T};
+ // The order Vulkan lists this machine's GPUs in: iGPU, dGPU, eGPU.
+ let mut a = [cand(INTEL, T::IntegratedGpu, B::Vulkan), cand(INTEL, T::DiscreteGpu, B::Vulkan), cand(0x10de, T::DiscreteGpu, B::Vulkan)];
+ assert_eq!(pick(&a, Auto, Os::Other), Some(1), "automatic takes the first discrete adapter, the Arc");
+ a[2].wanted = true;
+ assert_eq!(pick(&a, Auto, Os::Other), Some(2));
+ a[2].wanted = false;
+ a[0].wanted = true;
+ assert_eq!(pick(&a, Auto, Os::Other), Some(0), "even the iGPU, when asked for");
+ // A wanted adapter that can't present is skipped, not an error.
+ a[0].surface_ok = false;
+ assert_eq!(pick(&a, Auto, Os::Other), Some(1));
+ assert!(wanted_missed(&a, Some("iris")));
+ assert!(!wanted_missed(&a, None));
+ a[0].surface_ok = true;
+ assert!(!wanted_missed(&a, Some("iris")));
+ // Among several wanted ones (same GPU on Vulkan and GL) the usual ranking decides.
+ let mut gl = [cand(0x10de, T::DiscreteGpu, B::Gl), cand(0x10de, T::DiscreteGpu, B::Vulkan)];
+ gl.iter_mut().for_each(|c| c.wanted = true);
+ assert_eq!(pick(&gl, Auto, Os::Other), Some(1));
+ }
+
+ #[test]
+ fn an_adapter_request_applies_on_linux_outside_cpu_mode() {
+ let w = || Some("nvidia".to_string());
+ assert_eq!(applicable_adapter(w(), Auto, true), w());
+ assert_eq!(applicable_adapter(w(), Vulkan, true), w());
+ assert_eq!(applicable_adapter(w(), Cpu, true), None);
+ assert_eq!(applicable_adapter(w(), Auto, false), None, "Windows and macOS keep their own selection");
+ assert_eq!(applicable_adapter(None, Auto, true), None);
+ }
+
+ #[test]
+ fn an_adapter_override_keeps_its_own_process_only_when_it_applies() {
+ let on = |env, mode, linux| adapter_override_is_process_specific(env, mode, linux);
+ assert!(on(Some("nvidia"), RenderingMode::Auto, true));
+ assert!(on(Some("nvidia"), RenderingMode::Gpu, true));
+ assert!(!on(Some("nvidia"), RenderingMode::Cpu, true), "ignored in CPU mode");
+ assert!(!on(Some("nvidia"), RenderingMode::Auto, false), "ignored off Linux");
+ assert!(!on(Some(" "), RenderingMode::Auto, true));
+ assert!(!on(None, RenderingMode::Auto, true));
+ }
+
+ #[test]
+ fn adapter_names_match_loosely_and_the_environment_wins() {
+ assert!(adapter_matches("NVIDIA GeForce RTX 5060 Ti", "nvidia"));
+ assert!(adapter_matches("Intel(R) Arc(tm) A370M Graphics (DG2)", " ARC A370M "));
+ assert!(!adapter_matches("Intel(R) Iris(R) Xe Graphics (ADL GT2)", "arc"));
+ assert!(!adapter_matches("anything", ""));
+ assert!(!adapter_matches("anything", " "));
+ assert_eq!(wanted_adapter(None, ""), None);
+ assert_eq!(wanted_adapter(Some(" "), "iris"), Some("iris".into()));
+ assert_eq!(wanted_adapter(Some("nvidia"), "iris"), Some("nvidia".into()));
+ assert_eq!(wanted_adapter(Some(&"x".repeat(1000)), "").map(|w| w.len()), Some(256));
+ }
+
+ #[test]
+ fn gpu_adapter_is_read_before_the_window_opens() {
+ let dir = std::env::temp_dir().join(format!("photocraft-gpu-adapter-{}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ let file = dir.join("preferences.json");
+ let read = |json: &str| {
+ std::fs::write(&file, json).unwrap();
+ read_gpu_adapter(Some(&file))
+ };
+ assert_eq!(read(r#"{"performance":{"gpuAdapter":"nvidia"}}"#), "nvidia");
+ assert_eq!(read(r#"{"performance":{"gpuAdapter":7}}"#), "");
+ assert_eq!(read(r#"{"performance":{}}"#), "");
+ assert_eq!(read("not json"), "");
+ assert_eq!(read_gpu_adapter(None), "");
+ let _ = std::fs::remove_dir_all(&dir);
}
#[test]
@@ -613,13 +772,13 @@ mod tests {
let intel = [cand(INTEL, T::IntegratedGpu, B::Vulkan), cand(INTEL, T::IntegratedGpu, B::Dx12), cand(INTEL, T::IntegratedGpu, B::Gl)];
let i = pick(&intel, Auto, Os::Windows).unwrap();
assert_eq!(intel[i].backend, B::Dx12);
- assert!(intel_dx12_applied(&intel, i, Os::Windows));
+ assert!(intel_dx12_applied(&intel, i, Os::Windows, false));
// Elsewhere (and for other vendors) Vulkan stays first.
assert_eq!(intel[pick(&intel, Auto, Os::Other).unwrap()].backend, B::Vulkan);
let nv = [cand(0x10de, T::DiscreteGpu, B::Dx12), cand(0x10de, T::DiscreteGpu, B::Vulkan)];
let i = pick(&nv, Auto, Os::Windows).unwrap();
assert_eq!(nv[i].backend, B::Vulkan);
- assert!(!intel_dx12_applied(&nv, i, Os::Windows));
+ assert!(!intel_dx12_applied(&nv, i, Os::Windows, false));
// Intel iGPU + discrete GPU: the discrete one wins (high performance), on Vulkan.
let hybrid = [intel[0], intel[1], nv[1]];
assert_eq!(pick(&hybrid, Auto, Os::Windows), Some(2));
@@ -630,6 +789,45 @@ mod tests {
assert_eq!(pick(&[], Auto, Os::Windows), None);
}
+ /// #4: an instance with Vulkan loads the Intel Vulkan driver, which crashed at startup, before
+ /// the selector picks Intel's DX12 adapter. With an Intel adapter in the DX12 list, `auto` on
+ /// Windows creates the instance without Vulkan.
+ #[test]
+ fn auto_on_windows_with_an_intel_adapter_leaves_vulkan_out_of_the_instance() {
+ use wgpu::{Backend as B, DeviceType as T};
+ const NVIDIA: u32 = 0x10de;
+ const AMD: u32 = 0x1002;
+ const WARP: u32 = 0x1414;
+ let auto = plan(Auto, None, None, false, Os::Windows);
+ let dx12_gl = Some(wgpu::Backends::DX12 | wgpu::Backends::GL);
+ assert_eq!(windows_auto_backends(&auto, Os::Windows, || vec![INTEL, WARP]), dx12_gl);
+ // Intel iGPU + discrete GPU: the discrete GPU is then used on DX12.
+ assert_eq!(windows_auto_backends(&auto, Os::Windows, || vec![NVIDIA, INTEL, WARP]), dx12_gl);
+ // No Intel adapter: egui's default backends, Vulkan included.
+ assert_eq!(windows_auto_backends(&auto, Os::Windows, || vec![NVIDIA, WARP]), None);
+ assert_eq!(windows_auto_backends(&auto, Os::Windows, || vec![AMD]), None);
+ assert_eq!(windows_auto_backends(&auto, Os::Windows, Vec::new), None);
+ // `WGPU_BACKEND`, `--safe-gpu`, a chosen backend, a crash fallback and other platforms
+ // don't list the DX12 adapters at all.
+ let not_listed = || -> Vec { panic!("DX12 adapters listed") };
+ for (p, os) in [
+ (plan(Auto, None, Some("vulkan"), false, Os::Windows), Os::Windows),
+ (plan(Auto, None, None, true, Os::Windows), Os::Windows),
+ (plan(Vulkan, None, None, false, Os::Windows), Os::Windows),
+ (plan(Auto, Some(&crashed("auto", "vulkan")), None, false, Os::Windows), Os::Windows),
+ (plan(Auto, None, None, false, Os::Other), Os::Other),
+ (plan(Auto, None, None, false, Os::Mac), Os::Mac),
+ ] {
+ assert_eq!(windows_auto_backends(&p, os, not_listed), None, "{p:?}");
+ }
+ // Without Vulkan adapters to compare with, System Info still names the Intel default.
+ let listed = [cand(INTEL, T::IntegratedGpu, B::Dx12), cand(WARP, T::Cpu, B::Dx12), cand(INTEL, T::IntegratedGpu, B::Gl)];
+ let i = pick(&listed, Auto, Os::Windows).unwrap();
+ assert_eq!(i, 0);
+ assert!(intel_dx12_applied(&listed, i, Os::Windows, true));
+ assert!(!intel_dx12_applied(&listed, i, Os::Windows, false));
+ }
+
#[test]
fn cpu_prefers_software_adapters() {
use wgpu::{Backend as B, DeviceType as T};
@@ -894,7 +1092,7 @@ mod tests {
// The app's setup replaces wgpu's default (`Auto`: DXC by name from the search path).
let mut setup = egui_wgpu::WgpuSetup::without_display_handle();
let plan = plan(Auto, None, None, false, Os::Windows);
- configure(&mut setup, &plan, Os::Windows, Arc::new(Mutex::new(None)), Arc::default());
+ configure(&mut setup, &plan, Os::Windows, None, Arc::new(Mutex::new(None)), Arc::default());
let egui_wgpu::WgpuSetup::CreateNew(create) = &setup else { panic!("a new instance") };
if std::env::var_os("WGPU_DX12_COMPILER").is_none() {
let safe = match &create.instance_descriptor.backend_options.dx12.shader_compiler {
@@ -918,13 +1116,13 @@ mod tests {
let mut setup = egui_wgpu::WgpuSetup::without_display_handle();
let egui_wgpu::WgpuSetup::CreateNew(before) = &setup else { panic!("a new instance") };
let before = before.instance_descriptor.flags;
- configure(&mut setup, &plan(Auto, None, None, safe_gpu, os), os, Arc::new(Mutex::new(None)), Arc::default());
+ configure(&mut setup, &plan(Auto, None, None, safe_gpu, os), os, None, Arc::new(Mutex::new(None)), Arc::default());
let egui_wgpu::WgpuSetup::CreateNew(create) = &setup else { panic!("a new instance") };
assert_eq!(create.instance_descriptor.flags, before, "{os:?}: flags untouched");
}
if cfg!(target_arch = "x86_64") && std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_none() {
let mut setup = egui_wgpu::WgpuSetup::without_display_handle();
- configure(&mut setup, &plan(Auto, None, None, false, Os::Mac), Os::Mac, Arc::new(Mutex::new(None)), Arc::default());
+ configure(&mut setup, &plan(Auto, None, None, false, Os::Mac), Os::Mac, None, Arc::new(Mutex::new(None)), Arc::default());
let egui_wgpu::WgpuSetup::CreateNew(create) = &setup else { panic!("a new instance") };
assert!(!create.instance_descriptor.flags.contains(wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL));
}
diff --git a/apps/photocraft/src/logging.rs b/apps/photocraft/src/logging.rs
index 33b7df2462..5c8f0697dd 100644
--- a/apps/photocraft/src/logging.rs
+++ b/apps/photocraft/src/logging.rs
@@ -420,6 +420,7 @@ mod tests {
#[test]
fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() {
+ let _spawns = crate::no_child_spawns();
let dir = temp_dir("rotate");
for run in 1..=4 {
let LogFile { path, file: _file, owner: _owner } = rotate(&dir).expect("rotate");
@@ -438,6 +439,7 @@ mod tests {
/// `.2.log`, and a fourth deleted it.
#[test]
fn a_second_start_leaves_the_running_instance_log_alone() {
+ let _spawns = crate::no_child_spawns();
let dir = temp_dir("second");
let mut first = rotate(&dir).expect("first start");
assert_eq!(first.path, dir.join(LOG_FILE));
@@ -461,6 +463,7 @@ mod tests {
#[test]
fn instance_logs_are_pruned_only_when_no_one_holds_them() {
+ let _spawns = crate::no_child_spawns();
let dir = temp_dir("prune");
let first = rotate(&dir).expect("first start");
// Earlier side-by-side runs that have exited, oldest first; the oldest has no lock file
diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs
index 61553d5ef2..b71f167fa6 100644
--- a/apps/photocraft/src/main.rs
+++ b/apps/photocraft/src/main.rs
@@ -71,8 +71,7 @@ mod services;
// macOS gets one instance from LaunchServices (open-documents Apple events).
#[cfg(any(not(target_os = "macos"), test))]
mod single_instance;
-// Windows gets pen pressure from winit (WM_POINTER); the web runner has its own listener.
-#[cfg(any(target_os = "macos", target_os = "linux", test))]
+// Tablet support (macOS AppKit monitor, Linux X11/Wayland, Windows barrel button).
mod tablet;
mod ui_state;
mod window_frame;
@@ -205,6 +204,131 @@ fn print_stdout(text: &str) {
let _ = out.write_all(text.as_bytes()).and_then(|()| out.flush());
}
+/// Everything the desktop launcher takes from the command line, on top of the environment
+/// defaults handed in to `parse_launch_args`.
+#[derive(Debug, Default, PartialEq, Eq)]
+struct LaunchArgs {
+ control_port: Option,
+ control_token: Option,
+ control_token_file: Option,
+ automation_read_root: Option,
+ automation_write_root: Option,
+ files: Vec,
+ /// Arguments that are not valid Unicode (issue #1108): a file manager can hand over a Latin-1
+ /// file name on Linux, and `std::env::args()` would panic before any window opened. Paths are
+ /// reported in a notice once the app is up; a control token is a usage error like a bad port.
+ unreadable_paths: Vec,
+ safe_gpu: bool,
+ new_instance: bool,
+ /// Options that apply only to the process they're given to; a launch with any keeps its own.
+ launch_options: Vec<&'static str>,
+ present_mode_arg: Option,
+ frame_latency_arg: Option,
+ stroke_trace_arg: Option,
+ in_window_menus: bool,
+ /// `--help` / `-h`: print [`USAGE`] and exit 0; parsing stops there.
+ help: bool,
+ /// `--version`: print the version and exit 0; parsing stops there.
+ version: bool,
+ /// Usage errors (a bad or missing control/automation value, an unknown option); a non-empty
+ /// list fails the launch with exit status 2 (issues #701, #6429, #6431).
+ errors: Vec,
+}
+
+/// Parse the desktop launch arguments over the environment defaults in `out`. A control or
+/// automation flag with no value is a usage error naming the flag (issue #6429), never a silent
+/// fall back to a generated token or to no root: like `--control` with a bad port, it exits 2.
+/// So is an unknown option (#6431); a file whose name starts with `-` goes after `--`.
+fn parse_launch_args(args: impl Iterator- , mut out: LaunchArgs) -> LaunchArgs {
+ let mut args = args;
+ // After `--`, every argument is a file, even one that starts with `-`.
+ let mut only_files = false;
+ while let Some(a) = args.next() {
+ if only_files {
+ match a.into_string() {
+ Ok(s) => out.files.push(s),
+ Err(raw) => out.unreadable_paths.push(raw.to_string_lossy().into_owned()),
+ }
+ continue;
+ }
+ match a.to_str() {
+ Some("--") => only_files = true,
+ Some("--help" | "-h") => {
+ out.help = true;
+ break;
+ }
+ Some("--control") => match args.next() {
+ Some(value) => match parse_control_port(&value.to_string_lossy(), "--control") {
+ Ok(port) => out.control_port = Some(port),
+ Err(error) => out.errors.push(error),
+ },
+ None => out.errors.push("--control: missing port value (expected `--control
`)".to_string()),
+ },
+ Some("--control-token") => match args.next().map(std::ffi::OsString::into_string) {
+ Some(Ok(token)) => out.control_token = Some(token),
+ Some(Err(raw)) => out.errors.push(format!("--control-token: value is not valid Unicode (`{}`)", raw.to_string_lossy())),
+ None => out.errors.push("--control-token: missing token value (expected `--control-token `)".to_string()),
+ },
+ Some("--control-token-file") => match args.next() {
+ Some(value) => out.control_token_file = Some(std::path::PathBuf::from(value)),
+ None => out.errors.push("--control-token-file: missing file path value (expected `--control-token-file `)".to_string()),
+ },
+ Some("--automation-read-root") => {
+ out.launch_options.push("--automation-read-root");
+ match args.next() {
+ Some(value) => out.automation_read_root = Some(std::path::PathBuf::from(value)),
+ None => out.errors.push("--automation-read-root: missing directory value (expected `--automation-read-root `)".to_string()),
+ }
+ }
+ Some("--automation-write-root") => {
+ out.launch_options.push("--automation-write-root");
+ match args.next() {
+ Some(value) => out.automation_write_root = Some(std::path::PathBuf::from(value)),
+ None => out.errors.push("--automation-write-root: missing directory value (expected `--automation-write-root `)".to_string()),
+ }
+ }
+ Some("--safe-gpu") => {
+ out.launch_options.push("--safe-gpu");
+ out.safe_gpu = true;
+ }
+ Some(flag @ ("--present-mode" | "--frame-latency" | "--stroke-trace")) => {
+ out.launch_options.push(match flag {
+ "--present-mode" => "--present-mode",
+ "--frame-latency" => "--frame-latency",
+ _ => "--stroke-trace",
+ });
+ let flag = flag.to_string();
+ match args.next().map(std::ffi::OsString::into_string) {
+ Some(Ok(v)) => match flag.as_str() {
+ "--present-mode" => out.present_mode_arg = Some(v),
+ "--frame-latency" => out.frame_latency_arg = Some(v),
+ _ => out.stroke_trace_arg = Some(v),
+ },
+ // A bad value is reported and ignored; it never fails the launch.
+ _ => eprintln!("photocraft: {flag}: missing or unreadable value; ignored"),
+ }
+ }
+ Some("--in-window-menus") => {
+ out.launch_options.push("--in-window-menus");
+ out.in_window_menus = true;
+ }
+ Some("--new-instance") => out.new_instance = true,
+ Some("--version") => {
+ out.version = true;
+ break;
+ }
+ // Old macOS passes a process serial number when launched from Finder.
+ Some(s) if s.starts_with("-psn_") => {}
+ // A typo such as `--safegpu`, or `--help` spelt another way, must not open the window
+ // and then fail to open the flag as a file (#6431).
+ Some(s) if s.starts_with('-') => out.errors.push(format!("unknown option `{s}` (put `--` before a file name that starts with `-`)")),
+ Some(s) => out.files.push(s.to_owned()),
+ None => out.unreadable_paths.push(a.to_string_lossy().into_owned()),
+ }
+ }
+ out
+}
+
#[cfg(test)]
mod control_port_tests {
use super::{parse_control_port, usage_exit_code};
@@ -234,124 +358,152 @@ mod control_port_tests {
}
}
+#[cfg(test)]
+mod launch_args_tests {
+ use super::{LaunchArgs, parse_launch_args, usage_exit_code};
+ use std::ffi::OsString;
+
+ fn parse(values: &[&str]) -> LaunchArgs {
+ parse_launch_args(values.iter().map(OsString::from), LaunchArgs::default())
+ }
+
+ #[test]
+ fn flags_without_a_value_are_usage_errors() {
+ // Issue #6429: a missing value must fail the launch, not silently fall back to a
+ // generated token or to no root.
+ for flag in ["--control", "--control-token", "--control-token-file", "--automation-read-root", "--automation-write-root"] {
+ let parsed = parse(&[flag]);
+ assert_eq!(parsed.errors.len(), 1, "{flag}: {:?}", parsed.errors);
+ assert!(parsed.errors[0].starts_with(flag), "{}", parsed.errors[0]);
+ assert!(parsed.errors[0].contains("missing"), "{}", parsed.errors[0]);
+ assert_eq!(usage_exit_code(&parsed.errors), Some(2));
+ }
+ }
+
+ #[test]
+ fn missing_value_does_not_clear_an_environment_default() {
+ // `--automation-read-root` with no value must not drop a root set through the env var.
+ let defaults = LaunchArgs { automation_read_root: Some("/env/read".into()), automation_write_root: Some("/env/write".into()), ..Default::default() };
+ let parsed = parse_launch_args(["--automation-read-root"].iter().map(OsString::from), defaults);
+ assert_eq!(parsed.automation_read_root.as_deref(), Some(std::path::Path::new("/env/read")));
+ assert_eq!(parsed.automation_write_root.as_deref(), Some(std::path::Path::new("/env/write")));
+ assert_eq!(parsed.errors.len(), 1, "{:?}", parsed.errors);
+ }
+
+ #[test]
+ fn values_are_parsed_and_no_errors_remain() {
+ let parsed = parse(&[
+ "--control",
+ "50494",
+ "--control-token",
+ "abcd",
+ "--control-token-file",
+ "/tmp/token",
+ "--automation-read-root",
+ "/r",
+ "--automation-write-root",
+ "/w",
+ "--safe-gpu",
+ "--in-window-menus",
+ "--new-instance",
+ "--present-mode",
+ "mailbox",
+ "photo.psd",
+ ]);
+ assert!(parsed.errors.is_empty(), "{:?}", parsed.errors);
+ assert_eq!(parsed.control_port, Some(50494));
+ assert_eq!(parsed.control_token.as_deref(), Some("abcd"));
+ assert_eq!(parsed.control_token_file.as_deref(), Some(std::path::Path::new("/tmp/token")));
+ assert_eq!(parsed.automation_read_root.as_deref(), Some(std::path::Path::new("/r")));
+ assert_eq!(parsed.automation_write_root.as_deref(), Some(std::path::Path::new("/w")));
+ assert!(parsed.safe_gpu);
+ assert!(parsed.in_window_menus);
+ assert!(parsed.new_instance);
+ assert_eq!(parsed.present_mode_arg.as_deref(), Some("mailbox"));
+ assert_eq!(parsed.launch_options, vec!["--automation-read-root", "--automation-write-root", "--safe-gpu", "--in-window-menus", "--present-mode"]);
+ assert_eq!(parsed.files, vec!["photo.psd".to_string()]);
+ }
+
+ #[test]
+ fn version_stops_parsing() {
+ let parsed = parse(&["--version", "--control"]);
+ assert!(parsed.version);
+ assert!(parsed.errors.is_empty(), "{:?}", parsed.errors);
+ }
+
+ #[test]
+ fn help_stops_parsing_and_double_dash_ends_options() {
+ let parsed = parse(&["-h", "--safegpu", "--control"]);
+ assert!(parsed.help);
+ assert!(parsed.errors.is_empty(), "{:?}", parsed.errors);
+
+ let parsed = parse(&["--safegpu", "--", "--help", "-x"]);
+ assert!(!parsed.help);
+ assert_eq!(parsed.errors.len(), 1, "{:?}", parsed.errors);
+ assert!(parsed.errors[0].contains("unknown option `--safegpu`"), "{}", parsed.errors[0]);
+ assert_eq!(parsed.files, vec!["--help".to_string(), "-x".to_string()]);
+ }
+}
+
fn main() -> eframe::Result {
// First, so the panic hook and every start-up warning are recorded (`logging`).
let logger = logging::install();
crash_guard::install_hook();
// The system font scan can take seconds (minutes on some Macs): run it off the UI thread.
photocraft_ui_egui::type_tool::warm_fonts();
- let mut control_port: Option = None;
- let mut usage_errors: Vec = Vec::new();
+ let mut launch = LaunchArgs {
+ automation_read_root: std::env::var_os("PHOTOCRAFT_AUTOMATION_READ_ROOT").map(std::path::PathBuf::from),
+ automation_write_root: std::env::var_os("PHOTOCRAFT_AUTOMATION_WRITE_ROOT").map(std::path::PathBuf::from),
+ in_window_menus: std::env::var_os("PHOTOCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0"),
+ ..Default::default()
+ };
if let Some(value) = std::env::var("PHOTOCRAFT_CONTROL_PORT").ok().filter(|v| !v.trim().is_empty()) {
match parse_control_port(&value, "PHOTOCRAFT_CONTROL_PORT") {
- Ok(port) => control_port = Some(port),
- Err(error) => usage_errors.push(error),
+ Ok(port) => launch.control_port = Some(port),
+ Err(error) => launch.errors.push(error),
}
}
- let mut control_token = None;
- let mut control_token_file = None;
- let mut automation_read_root = std::env::var_os("PHOTOCRAFT_AUTOMATION_READ_ROOT").map(std::path::PathBuf::from);
- let mut automation_write_root = std::env::var_os("PHOTOCRAFT_AUTOMATION_WRITE_ROOT").map(std::path::PathBuf::from);
- let mut files = Vec::new();
- // Arguments that are not valid Unicode (issue #1108): a file manager can hand over a Latin-1
- // file name on Linux, and `std::env::args()` would panic before any window opened. Paths are
- // reported in a notice once the app is up; a control token is a usage error like a bad port.
- let mut unreadable_paths: Vec = Vec::new();
- let mut safe_gpu = false;
- let mut new_instance = false;
- // Options that apply only to the process they're given to; a launch with any keeps its own.
- let mut launch_options: Vec<&'static str> = Vec::new();
- let (mut present_mode_arg, mut frame_latency_arg, mut stroke_trace_arg): (Option, Option, Option) = (None, None, None);
- let mut in_window_menus = std::env::var_os("PHOTOCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0");
- let mut args = std::env::args_os().skip(1);
- // After `--`, every argument is a file, even one that starts with `-`.
- let mut only_files = false;
- while let Some(a) = args.next() {
- if only_files {
- match a.into_string() {
- Ok(s) => files.push(s),
- Err(raw) => unreadable_paths.push(raw.to_string_lossy().into_owned()),
- }
- continue;
- }
- match a.to_str() {
- Some("--") => only_files = true,
- Some("--help" | "-h") => {
- print_stdout(USAGE);
- return Ok(());
- }
- Some("--control") => match args.next() {
- Some(value) => match parse_control_port(&value.to_string_lossy(), "--control") {
- Ok(port) => control_port = Some(port),
- Err(error) => usage_errors.push(error),
- },
- None => usage_errors.push("--control: missing port value (expected `--control `)".to_string()),
- },
- Some("--control-token") => match args.next().map(std::ffi::OsString::into_string) {
- Some(Ok(token)) => control_token = Some(token),
- Some(Err(raw)) => usage_errors.push(format!("--control-token: value is not valid Unicode (`{}`)", raw.to_string_lossy())),
- None => control_token = None,
- },
- Some("--control-token-file") => control_token_file = args.next().map(std::path::PathBuf::from),
- Some("--automation-read-root") => {
- launch_options.push("--automation-read-root");
- automation_read_root = args.next().map(std::path::PathBuf::from);
- }
- Some("--automation-write-root") => {
- launch_options.push("--automation-write-root");
- automation_write_root = args.next().map(std::path::PathBuf::from);
- }
- Some("--safe-gpu") => {
- launch_options.push("--safe-gpu");
- safe_gpu = true;
- }
- Some(flag @ ("--present-mode" | "--frame-latency" | "--stroke-trace")) => {
- launch_options.push(match flag {
- "--present-mode" => "--present-mode",
- "--frame-latency" => "--frame-latency",
- _ => "--stroke-trace",
- });
- let flag = flag.to_string();
- match args.next().map(std::ffi::OsString::into_string) {
- Some(Ok(v)) => match flag.as_str() {
- "--present-mode" => present_mode_arg = Some(v),
- "--frame-latency" => frame_latency_arg = Some(v),
- _ => stroke_trace_arg = Some(v),
- },
- // A bad value is reported and ignored; it never fails the launch.
- _ => eprintln!("photocraft: {flag}: missing or unreadable value; ignored"),
- }
- }
- Some("--in-window-menus") => {
- launch_options.push("--in-window-menus");
- in_window_menus = true;
- }
- Some("--new-instance") => new_instance = true,
- Some("--version") => {
- print_stdout(&format!("photocraft {}\n", photocraft_engine::build_info::long_version()));
- return Ok(());
- }
- // Old macOS passes a process serial number when launched from Finder.
- Some(s) if s.starts_with("-psn_") => {}
- // A typo such as `--safegpu`, or `--help` spelt another way, must not open the window
- // and then fail to open the flag as a file (#6431).
- Some(s) if s.starts_with('-') => usage_errors.push(format!("unknown option `{s}` (put `--` before a file name that starts with `-`)")),
- Some(s) => files.push(s.to_owned()),
- None => unreadable_paths.push(a.to_string_lossy().into_owned()),
- }
+ launch = parse_launch_args(std::env::args_os().skip(1), launch);
+ if launch.help {
+ print_stdout(USAGE);
+ return Ok(());
+ }
+ if launch.version {
+ print_stdout(&format!("photocraft {}\n", photocraft_engine::build_info::long_version()));
+ return Ok(());
}
- // A malformed control port must not silently drop the control server (issue #701), and an
- // unknown option must not open the window (#6431): name the bad argument and fail the launch,
- // like `photocraft-cli` does for the same typos. Exit status 2 is the usual command-line usage
- // error, so a launcher sees the failure.
- if let Some(code) = usage_exit_code(&usage_errors) {
- for error in &usage_errors {
+ // A malformed control port must not silently drop the control server (issue #701), a control
+ // or automation flag without its value must not silently fall back to a generated token or
+ // to no root (issue #6429), and an unknown option must not open the window (#6431): name the
+ // bad argument and fail the launch, like `photocraft-cli` does for the same typos. Exit
+ // status 2 is the usual command-line usage error, so a launcher sees the failure.
+ if let Some(code) = usage_exit_code(&launch.errors) {
+ for error in &launch.errors {
eprintln!("photocraft: {error}");
}
eprintln!("Run `photocraft --help` for usage.");
std::process::exit(code);
}
+ let LaunchArgs {
+ control_port,
+ control_token,
+ control_token_file,
+ automation_read_root,
+ automation_write_root,
+ files,
+ unreadable_paths,
+ safe_gpu,
+ new_instance,
+ mut launch_options,
+ present_mode_arg,
+ frame_latency_arg,
+ stroke_trace_arg,
+ in_window_menus,
+ help: _,
+ version: _,
+ errors: _,
+ } = launch;
// The log file lives under the settings directory; opened after the arguments, so `--version`
// and usage errors leave no file behind. Records logged until now are written to it first.
@@ -363,6 +515,11 @@ fn main() -> eframe::Result {
}
}
+ // An adapter named in the environment is chosen at startup, which a forwarded launch never reaches.
+ let rendering_mode = gpu_startup::read_rendering_prefs(services::prefs_file().as_deref()).1;
+ if gpu_startup::adapter_override_is_process_specific(std::env::var("PHOTOCRAFT_GPU_ADAPTER").ok().as_deref(), rendering_mode, gpu_startup::ADAPTER_CHOICE) {
+ launch_options.push("PHOTOCRAFT_GPU_ADAPTER");
+ }
// One PhotoCraft per user (#3708): files opened from Explorer or a file manager while it runs
// become tabs in its window. Only with the per-user settings folder (a portable copy's may be
// shared between users), and not for automation, `--new-instance` or per-process options.
@@ -474,6 +631,11 @@ fn main() -> eframe::Result {
let tablet_monitor = std::cell::OnceCell::new();
#[cfg(target_os = "macos")]
let tablet_monitor = &tablet_monitor;
+ // The Windows pointer message hook; dropping it unhooks when the event loop returns.
+ #[cfg(target_os = "windows")]
+ let tablet_monitor_win = std::cell::OnceCell::new();
+ #[cfg(target_os = "windows")]
+ let tablet_monitor_win = &tablet_monitor_win;
// Read the displays' ICC profiles while the window opens (colour-managed canvas; `None`
// where the platform has no reader).
@@ -525,7 +687,9 @@ fn main() -> eframe::Result {
}
let gpu_note: std::sync::Arc>> = Default::default();
// The adapter's real texture limits (egui asks for 8192 px), so big documents stay on the GPU.
- gpu_startup::configure(&mut options.wgpu_options.wgpu_setup, &plan, os, sentinel.clone(), gpu_note.clone());
+ let wanted_adapter =
+ gpu_startup::wanted_adapter(std::env::var("PHOTOCRAFT_GPU_ADAPTER").ok().as_deref(), &gpu_startup::read_gpu_adapter(services::prefs_file().as_deref()));
+ gpu_startup::configure(&mut options.wgpu_options.wgpu_setup, &plan, os, wanted_adapter, sentinel.clone(), gpu_note.clone());
// Preferences › Performance › Low-Latency Drawing (#1343): without vsync a stroke shows about
// 50 ms sooner; wgpu picks Immediate, else Mailbox, else Fifo by what the driver offers.
if gpu_startup::read_low_latency(services::prefs_file().as_deref()) {
@@ -621,12 +785,19 @@ fn main() -> eframe::Result {
info.canvas = "cpu".into();
if let Some(rs) = cc.wgpu_render_state.clone() {
app.perf.gpu_info.set_adapter(&rs.adapter.get_info());
+ app.perf.gpu_info.set_available(&rs.available_adapters);
// Every egui texture uploads through this adapter, whichever canvas draws:
// Iris Xe on DX12 shows stale memory in all-zero texels (texel_guard.rs).
photocraft_ui_egui::texel_guard::install(&cc.egui_ctx, &rs.adapter.get_info());
let software_window = rs.adapter.get_info().device_type == eframe::wgpu::DeviceType::Cpu;
if software_window && mode != photocraft_engine::prefs::RenderingMode::Cpu {
- app.perf.gpu_info.fallback = Some("No compatible hardware graphics adapter; using software graphics.".into());
+ // Keep an earlier note, such as a requested adapter that couldn't be used.
+ let software = "No compatible hardware graphics adapter; using software graphics.";
+ let fallback = &mut app.perf.gpu_info.fallback;
+ *fallback = Some(match fallback.take() {
+ Some(earlier) => format!("{earlier}. {software}"),
+ None => software.into(),
+ });
}
if !software_window
&& plan.backend != photocraft_engine::prefs::GpuBackend::Cpu
@@ -703,8 +874,13 @@ fn main() -> eframe::Result {
if let Some(monitor) = tablet::install_macos(&app.stylus.feed) {
let _ = tablet_monitor.set(monitor);
}
+ #[cfg(target_os = "windows")]
+ if let Some(monitor) = tablet::install_windows() {
+ let _ = tablet_monitor_win.set(monitor);
+ }
#[cfg(target_os = "linux")]
tablet::spawn_x11(&app.stylus.feed, display);
+ app.services.right_button_down = tablet::right_button_service();
// Paths on the command line (Linux/Windows file associations, `photocraft a.psd`).
app.open_paths(&files);
if !unreadable_paths.is_empty() {
@@ -770,6 +946,19 @@ fn main() -> eframe::Result {
result
}
+/// Tests that spawn child processes hold this for reading, tests that check who holds a file lock
+/// for writing ([`no_child_spawns`]): until its `exec`, a child holds a copy of every open file,
+/// `flock`s included, so a spawn in another test could keep a lock this process just dropped held
+/// for a moment.
+#[cfg(test)]
+pub(crate) static CHILD_SPAWNS: std::sync::RwLock<()> = std::sync::RwLock::new(());
+
+/// Held by a test that checks who holds a file lock: no other test spawns a child meanwhile.
+#[cfg(test)]
+pub(crate) fn no_child_spawns() -> std::sync::RwLockWriteGuard<'static, ()> {
+ CHILD_SPAWNS.write().unwrap_or_else(std::sync::PoisonError::into_inner)
+}
+
#[cfg(test)]
mod tests {
#[test]
diff --git a/apps/photocraft/src/monitor_profile.rs b/apps/photocraft/src/monitor_profile.rs
index 8e33869696..c210e096ec 100644
--- a/apps/photocraft/src/monitor_profile.rs
+++ b/apps/photocraft/src/monitor_profile.rs
@@ -495,6 +495,7 @@ mod tests {
#[cfg(unix)]
#[test]
fn a_hung_helper_is_stopped_and_big_replies_dont_block() {
+ let _spawns = crate::CHILD_SPAWNS.read().unwrap_or_else(std::sync::PoisonError::into_inner);
let t0 = std::time::Instant::now();
let e = run_helper(std::process::Command::new("sleep").arg("30"), std::time::Duration::from_millis(300)).unwrap_err();
assert!(e.contains("took longer than"), "{e}");
diff --git a/apps/photocraft/src/portal_save/tests.rs b/apps/photocraft/src/portal_save/tests.rs
index ed44a10d9c..5a839b6025 100644
--- a/apps/photocraft/src/portal_save/tests.rs
+++ b/apps/photocraft/src/portal_save/tests.rs
@@ -36,17 +36,22 @@ fn mock_portal(script: Script) -> Option<(zbus::blocking::Connection, Seen, std:
/// once the user is done with it (here after 300 ms).
fn mock_portal_with(script: Script, own_paths: bool) -> Option<(zbus::blocking::Connection, Seen, std::process::Child)> {
use std::io::BufRead;
+ let spawns = crate::CHILD_SPAWNS.read().unwrap_or_else(std::sync::PoisonError::into_inner);
let mut daemon =
std::process::Command::new("dbus-daemon").args(["--session", "--nofork", "--print-address=1"]).stdout(std::process::Stdio::piped()).spawn().ok()?;
+ drop(spawns);
let mut address = String::new();
std::io::BufReader::new(daemon.stdout.take()?).read_line(&mut address).ok()?;
let address = address.trim().to_string();
let server = zbus::blocking::connection::Builder::address(address.as_str()).unwrap().name("org.freedesktop.portal.Desktop").unwrap().build().unwrap();
let seen: Seen = Arc::default();
let log = seen.clone();
+ // A stream only gets the messages that arrive after it exists: made in the thread, it could
+ // miss a `SaveFile` sent before the thread got going, and the call would wait forever.
+ let messages = zbus::blocking::MessageIterator::from(&server);
std::thread::spawn(move || {
let mut script = script.into_iter();
- for msg in zbus::blocking::MessageIterator::from(&server) {
+ for msg in messages {
let Ok(msg) = msg else { break };
let header = msg.header();
if header.message_type() != zbus::message::Type::MethodCall || header.member().map(|m| m.as_str()) != Some("SaveFile") {
diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs
index d4e7a01553..999952f186 100644
--- a/apps/photocraft/src/services.rs
+++ b/apps/photocraft/src/services.rs
@@ -6,7 +6,7 @@ use photocraft_doc::{Document, Layer, Size};
use photocraft_format::RecoveryStore;
use photocraft_geom::Rect;
use photocraft_ui_egui::{FileDialogAnswer, FileDialogReply, FileDialogRequest, Recoverable, Services};
-use std::cell::RefCell;
+use std::cell::{Cell, RefCell};
use std::future::Future;
use std::path::{Path, PathBuf};
use std::pin::Pin;
@@ -36,16 +36,47 @@ fn open_filter_extensions(extensions: &[&str]) -> Vec {
extensions.iter().map(|ext| if case_sensitive_globs { ext.chars().map(class).collect() } else { ext.to_string() }).collect()
}
-/// An explicit SVG filter keeps vector artwork discoverable even when a native picker defaults to
-/// the PhotoCraft-only filter. SVG is also included in All Formats for normal multi-format opens.
+/// File › Open's file types after All Formats, as Photoshop lists them: one per format, so a folder
+/// of mixed files can be narrowed to one kind (#6337). Together they cover every extension of
+/// [`OPEN_EXTS`]. An explicit SVG filter also keeps vector artwork discoverable when a native
+/// picker defaults to another filter.
+const OPEN_FILTERS: &[(&str, &[&str])] = &[
+ ("PhotoCraft", &["pcraft"]),
+ ("Photoshop", &["psd", "psb", "psdt"]),
+ ("Affinity", &["af", "afdesign", "afphoto", "afpub"]),
+ ("Krita", &["kra", "krz"]),
+ ("OpenRaster", &["ora"]),
+ ("Paint.NET", &["pdn"]),
+ ("SVG", SVG_EXTS),
+ ("PNG", &["png"]),
+ ("JPEG", &["jpg", "jpeg"]),
+ ("HEIF", &["heic", "heif", "hif"]),
+ ("TIFF", &["tif", "tiff"]),
+ ("WebP", &["webp"]),
+ ("GIF", &["gif"]),
+ ("BMP", &["bmp"]),
+ ("Targa", &["tga"]),
+ ("ICO", &["ico"]),
+ ("QOI", &["qoi"]),
+ ("PCX", &["pcx"]),
+ ("DDS", &["dds"]),
+ ("XPM", &["xpm"]),
+ ("Portable Bitmap", &["pbm", "pgm", "ppm", "pam", "pfm"]),
+ ("OpenEXR", &["exr"]),
+ ("Radiance HDR", &["hdr"]),
+ ("FITS", &["fits", "fit", "fts"]),
+ ("DPX and Cineon", &["dpx", "cin"]),
+ ("Camera Raw", &["dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf"]),
+ ("Brushes", &["abr"]),
+ ("Gradients", &["grd"]),
+ ("Swatches", &["aco", "ase"]),
+ ("Keyboard Shortcuts", &["kys"]),
+];
+
fn open_filters() -> Vec<(&'static str, Vec)> {
- vec![
- ("All Formats", open_filter_extensions(OPEN_EXTS)),
- ("PhotoCraft", open_filter_extensions(&["pcraft"])),
- ("OpenRaster", open_filter_extensions(&["ora"])),
- ("Krita", open_filter_extensions(&["kra", "krz"])),
- ("SVG", open_filter_extensions(SVG_EXTS)),
- ]
+ std::iter::once(("All Formats", open_filter_extensions(OPEN_EXTS)))
+ .chain(OPEN_FILTERS.iter().map(|(name, exts)| (*name, open_filter_extensions(exts))))
+ .collect()
}
/// File › Save As formats: (filter name, extensions). The filter matching the suggested name's
@@ -68,6 +99,8 @@ const SAVE_FILTERS: &[(&str, &[&str])] = &[
("OpenEXR", &["exr"]),
("FITS", &["fits", "fit"]),
("DPX", &["dpx"]),
+ // Game and 3D textures; DDS Options asks for the compression and mipmaps (#1327).
+ ("DDS", &["dds"]),
];
/// Non-document files the shell saves (Swatches panel exports): offered alone, so the dialog
@@ -160,10 +193,7 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>,
let dialog = if let Some(exts) = extensions {
dialog.add_filter("Supported Files", &open_filter_extensions(&exts.iter().map(String::as_str).collect::>()))
} else {
- open_filters()
- .into_iter()
- .fold(dialog, |dialog, (name, extensions)| dialog.add_filter(name, &extensions))
- .add_filter("Paint.NET", &open_filter_extensions(&["pdn"]))
+ open_filters().into_iter().fold(dialog, |dialog, (name, extensions)| dialog.add_filter(name, &extensions))
};
if multiple {
let picked = dialog.pick_files();
@@ -244,7 +274,7 @@ fn path_of(file: &rfd::FileHandle) -> String {
}
/// Runs `future` to completion on this thread, sleeping until it is woken.
-fn block_on(future: impl Future) -> T {
+pub(crate) fn block_on(future: impl Future) -> T {
struct Unpark(std::thread::Thread);
impl std::task::Wake for Unpark {
fn wake(self: Arc) {
@@ -265,7 +295,7 @@ fn block_on(future: impl Future) -> T {
/// Per-user settings directory: `PHOTOCRAFT_CONFIG_DIR`, else `/PhotoCraftData` in
/// portable mode, else the platform convention. Everything the app persists lives under it
-/// except, on Windows, crash-recovery autosaves; see [`crate::app_dirs`].
+/// except, on Windows and Linux, crash-recovery autosaves; see [`crate::app_dirs`].
pub fn config_dir() -> Option {
crate::app_dirs::config_dir()
}
@@ -280,8 +310,9 @@ pub fn presets_dir() -> Option {
config_dir().map(|d| d.join("Presets"))
}
-/// Crash-recovery autosaves: under the settings directory, except on Windows, where they stay
-/// out of the roaming profile (see [`crate::app_dirs::recovery_dir`]).
+/// Crash-recovery autosaves: under the settings directory, except where that is synced with the
+/// settings: the Windows roaming profile and the Linux config folder (see
+/// [`crate::app_dirs::resolve_recovery`]).
fn recovery_dir() -> Option {
crate::app_dirs::recovery_dir()
}
@@ -464,6 +495,7 @@ fn shell_word(s: &str) -> String {
pub fn native(automation: Option) -> Services {
let clip: Rc>> = Rc::default();
+ let clip_unreadable: Rc> = Rc::default();
let automation_read = automation.clone().map(|workspace| {
Box::new(move |path: &str| {
let bytes = workspace.read(path).map_err(|error| error.to_string())?;
@@ -537,26 +569,47 @@ pub fn native(automation: Option) ->
}),
clipboard_get_image: Some({
let clip = clip.clone();
+ let unreadable = clip_unreadable.clone();
Box::new(move || {
- let mut slot = clip.try_borrow_mut().ok()?;
+ let mut slot = clip.try_borrow_mut().map_err(|_| "clipboard is busy".to_string())?;
let cb = match slot.as_mut() {
Some(c) => c,
- None => slot.insert(arboard::Clipboard::new().ok()?),
+ None => {
+ // arboard opens only when some backend works. In a Flatpak on Wayland none
+ // does (#5031): the sandbox hides the data-control protocol from it, and
+ // `--socket=fallback-x11` gives no X server while a compositor runs.
+ let opened = arboard::Clipboard::new();
+ unreadable.set(opened.is_err());
+ slot.insert(opened.map_err(|e| e.to_string())?)
+ }
};
// Copied files first: Finder also puts the file's icon on the clipboard as an
// image, which would otherwise paste instead of the file.
- if let Some(img) = cb.get().file_list().ok().and_then(|paths| image_from_files(&paths)) {
- return Some(img);
+ match cb.get().file_list() {
+ Ok(paths) => {
+ if let Some(img) = image_from_files(&paths) {
+ return Ok(Some(img));
+ }
+ }
+ Err(error @ arboard::Error::ClipboardOccupied) => return Err(error.to_string()),
+ Err(_) => {}
}
- if let Ok(img) = cb.get_image() {
- return Some((img.width as u32, img.height as u32, img.bytes.into_owned()));
+ match cb.get_image() {
+ Ok(img) => Ok(Some((img.width as u32, img.height as u32, img.bytes.into_owned()))),
+ Err(error) => {
+ #[cfg(target_os = "linux")]
+ if let Some(image) = wayland_clipboard_image() {
+ return Ok(Some(image));
+ }
+ match error {
+ arboard::Error::ContentNotAvailable => Ok(None),
+ _ => Err(error.to_string()),
+ }
+ }
}
- #[cfg(target_os = "linux")]
- return wayland_clipboard_image();
- #[cfg(not(target_os = "linux"))]
- None
})
}),
+ clipboard_unreadable: Some(Box::new(move || clip_unreadable.get())),
load_prefs: Some(Box::new(|| std::fs::read_to_string(prefs_file()?).ok())),
photoshop_shortcuts: Some(Box::new(|| crate::photoshop_settings::live_keyboard_shortcuts(&|k| std::env::var_os(k)))),
save_prefs: Some(Box::new(|text: &str| write_atomic(&prefs_file().ok_or("no config directory")?, text.as_bytes()))),
@@ -695,6 +748,57 @@ mod tests {
assert_eq!(with_chosen_type(Path::new("/pics/art.psd"), opened, &[]), PathBuf::from("/pics/art.psd"));
}
+ /// Regression using the real clipboard on an isolated Windows CI runner.
+ #[cfg(target_os = "windows")]
+ #[test]
+ #[ignore = "requires exclusive use of the Windows clipboard"]
+ fn busy_native_clipboard_preserves_copied_shape() {
+ for id in ["edit.paste", "edit.pasteSpecial.pasteInPlace"] {
+ let mut native = native(None);
+ let services = Services {
+ clipboard_set_image: native.clipboard_set_image.take(),
+ clipboard_get_image: native.clipboard_get_image.take(),
+ ..Default::default()
+ };
+ let mut app = PhotocraftApp::new(Session::new(), services);
+ app.run("file.new", json!({"width": 80, "height": 60})).unwrap();
+ app.run("shape.create", json!({"kind": "rect", "rect": [10, 10, 40, 20], "fill": "#ff0000"})).unwrap();
+ app.run("edit.copy", json!({})).unwrap();
+ assert!(app.session.clipboard.as_ref().unwrap().layers.is_some());
+ assert!(app.services.clipboard_get_image.as_mut().unwrap()().unwrap().is_some(), "native export succeeded");
+ app.run("file.new", json!({"width": 80, "height": 60})).unwrap();
+
+ // Another thread holds the OS clipboard open beyond arboard's bounded retry window.
+ let (ready_tx, ready_rx) = std::sync::mpsc::channel();
+ let (release_tx, release_rx) = std::sync::mpsc::channel();
+ let holder = std::thread::spawn(move || {
+ // A non-null window distinguishes this owner from arboard's null-window opens.
+ let guard = winsafe::HWND::GetDesktopWindow().OpenClipboard().unwrap();
+ ready_tx.send(()).unwrap();
+ release_rx.recv_timeout(std::time::Duration::from_secs(10)).unwrap();
+ drop(guard);
+ });
+ ready_rx.recv_timeout(std::time::Duration::from_secs(10)).unwrap();
+ let contention = arboard::Clipboard::new().unwrap().get_image().err();
+ let first = app.run(id, json!({}));
+ release_tx.send(()).unwrap();
+ holder.join().unwrap();
+ assert!(matches!(contention, Some(arboard::Error::ClipboardOccupied)), "diagnostic must actually occupy the clipboard: {contention:?}");
+ eprintln!("paste while clipboard occupied: {first:?}");
+ assert!(first.is_err(), "report contention rather than a successful no-op");
+ assert!(
+ app.session.clipboard.as_ref().is_some_and(|clip| clip.layers.is_some()),
+ "a temporary OS read failure must not discard the copied editable shape"
+ );
+ app.run(id, json!({})).unwrap();
+ let state = app.session.active().unwrap();
+ assert!(
+ matches!(state.doc.layer(state.active_layer.unwrap()).unwrap().content, photocraft_doc::LayerContent::Shape(_)),
+ "retry keeps the shape editable"
+ );
+ }
+ }
+
#[cfg(all(unix, not(target_os = "macos")))]
#[test]
fn open_filters_cover_uppercase_and_mixed_case_extensions() {
@@ -729,6 +833,26 @@ mod tests {
assert!(svg_filter.iter().any(|glob| matches(glob, "svg")) && svg_filter.iter().any(|glob| matches(glob, "svgz")));
}
+ /// #6337: after All Formats, every format File › Open reads has its own filter, and no filter
+ /// offers an extension All Formats doesn't.
+ #[test]
+ fn every_open_format_has_its_own_filter() {
+ let filters = open_filters();
+ assert_eq!(filters[0], ("All Formats", open_filter_extensions(OPEN_EXTS)), "All Formats comes first");
+ let mut named: Vec<&str> = OPEN_FILTERS.iter().flat_map(|(_, exts)| exts.iter().copied()).collect();
+ let total = named.len();
+ named.sort_unstable();
+ named.dedup();
+ assert_eq!(named.len(), total, "an extension is listed under two filters");
+ let mut all = OPEN_EXTS.to_vec();
+ all.sort_unstable();
+ assert_eq!(named, all, "the named filters cover All Formats exactly");
+ let names: Vec<&str> = filters.iter().map(|(name, _)| *name).collect();
+ for name in ["Photoshop", "Affinity", "HEIF", "PNG", "JPEG", "TIFF", "Portable Bitmap", "OpenEXR", "Radiance HDR", "Paint.NET"] {
+ assert!(names.contains(&name), "{name}");
+ }
+ }
+
#[cfg(not(all(unix, not(target_os = "macos"))))]
#[test]
fn open_filters_keep_literal_extensions_on_windows_and_macos() {
@@ -844,7 +968,7 @@ mod tests {
assert!(format.caps().write, "{name}: .{ext} can't be written");
}
}
- for ext in ["ico", "gif", "bmp"] {
+ for ext in ["ico", "gif", "bmp", "dds"] {
assert!(save_filters("Untitled-1.psd").iter().any(|(_, exts)| exts.iter().any(|e| e == ext)), ".{ext} is offered");
}
assert_eq!(save_filters("icon.ico")[0].0, "ICO", "an .ico name leads with its own type");
diff --git a/apps/photocraft/src/tablet.rs b/apps/photocraft/src/tablet.rs
index fc063d2fbc..19ba28996c 100644
--- a/apps/photocraft/src/tablet.rs
+++ b/apps/photocraft/src/tablet.rs
@@ -1,10 +1,12 @@
-//! Pen tablet pressure, tilt, rotation and eraser end on macOS and Linux X11 (issue #79).
+//! Pen tablet pressure, tilt, rotation and eraser end on macOS and Linux X11 (issue #79), and
+//! barrel button on Windows (#5905).
//!
-//! winit 0.30 drops tablet data on both, so `photocraft-tablet` reads it beside winit (an AppKit
-//! local event monitor; XInput2 raw events on a second X connection) and this module writes each
-//! sample into the UI's [`StylusFeed`], where the canvas reads it exactly like the web runner's
-//! Pointer Events and automation's simulated pen. Windows needs nothing here: winit forwards
-//! `WM_POINTER` pressure as touch force.
+//! winit 0.30 drops tablet data on both macOS and X11, so `photocraft-tablet` reads it beside winit
+//! (an AppKit local event monitor; XInput2 raw events on a second X connection) and this module writes
+//! each sample into the UI's [`StylusFeed`], where the canvas reads it exactly like the web runner's
+//! Pointer Events and automation's simulated pen. On Windows, winit forwards `WM_POINTER` pressure
+//! as touch force, but drops `PEN_FLAG_BARREL` (`0x0001`); `photocraft-tablet` recovers the barrel
+//! button through a thread-local message hook beside winit.
//!
//! Wayland compositors give a pen only to clients that bind the tablet protocol
//! (`zwp_tablet_v2`), which winit 0.30 doesn't (and binding it on winit's connection needs
@@ -19,15 +21,36 @@ use photocraft_tablet::Sample;
use photocraft_ui_egui::stylus::{PenSample, StylusFeed};
/// A tablet sample as the UI's pen sample.
+#[allow(dead_code)]
pub fn pen_sample(s: Sample) -> PenSample {
PenSample { pressure: s.pressure, tilt_x: s.tilt_x, tilt_y: s.tilt_y, rotation: s.rotation, eraser: s.eraser }.sanitized()
}
/// Writes tablet samples into `feed` (`None` = a mouse).
+#[allow(dead_code)]
pub fn sink(feed: StylusFeed) -> impl Fn(Option) + Send + 'static {
move |s| feed.set(s.map(pen_sample))
}
+/// Install the Windows pointer message hook (main thread; keep the result until the event loop
+/// returns). On failure this logs why: the pen barrel button is then missed.
+#[cfg(target_os = "windows")]
+pub fn install_windows() -> Option {
+ photocraft_tablet::windows::Monitor::install().map_err(|e| log::warn!("{e}")).ok()
+}
+
+/// Stylus barrel button down check (Windows). The right mouse button is left to egui, which
+/// reports the logical secondary button (swapped buttons included) for the app's own window only.
+#[cfg(target_os = "windows")]
+pub fn right_button_service() -> Option {
+ Some(Box::new(photocraft_tablet::windows::is_barrel_button_down))
+}
+
+#[cfg(not(target_os = "windows"))]
+pub fn right_button_service() -> Option {
+ None
+}
+
/// Install the AppKit tablet monitor (main thread, once winit created the shared application:
/// from eframe's app creation, not before the event loop). Keep the result until the event loop
/// returns. On failure this logs why: pens then paint like a mouse.
diff --git a/apps/photocraft/src/window_frame.rs b/apps/photocraft/src/window_frame.rs
index e418049d07..56c9be1b6a 100644
--- a/apps/photocraft/src/window_frame.rs
+++ b/apps/photocraft/src/window_frame.rs
@@ -20,6 +20,11 @@
//! the work area plus its own frame, clear of the taskbar. winit pins the client area to the work
//! area either way (`WM_NCCALCSIZE`), so the app's pixels do not move.
//!
+//! An auto-hidden taskbar reserves no work area, so that trim leaves the maximize covering the
+//! whole monitor, and the shell, taking it for a full-screen app, no longer reveals the taskbar
+//! when the pointer reaches its edge (#4845). On each edge with an auto-hidden taskbar the guard
+//! instead stops the window one pixel short of the monitor, as other custom-frame apps do.
+//!
//! The Windows calls go through safe `winsafe` wrappers; the workspace forbids `unsafe`, and
//! winsafe can't wrap a raw handle without it, so the window is found by its handle instead.
@@ -29,7 +34,20 @@ pub use platform::service;
mod platform {
use eframe::wgpu::rwh::{HasWindowHandle, RawWindowHandle};
use photocraft_ui_egui::WindowFrameFn;
- use winsafe::{HWND, HwndPlace, POINT, SIZE, co};
+ use winsafe::prelude::Handle;
+ use winsafe::{AtomStr, HWND, HwndPlace, POINT, SIZE, co};
+
+ /// A rect in desktop coordinates: `(left, top, right, bottom)`, right and bottom exclusive.
+ type Rect = (i32, i32, i32, i32);
+
+ /// A monitor edge.
+ #[derive(Clone, Copy, Debug, PartialEq, Eq)]
+ enum Edge {
+ Left,
+ Top,
+ Right,
+ Bottom,
+ }
/// A guard that keeps the window borderless each frame, or `None` when the app uses the
/// system title bar (the window is decorated) or its handle can't be found.
@@ -99,8 +117,11 @@ mod platform {
return;
};
let (m, w) = (mi.rcMonitor, mi.rcWork);
- let Some((left, top, right, bottom)) =
- clamped_rect((r.left, r.top, r.right, r.bottom), (m.left, m.top, m.right, m.bottom), (w.left, w.top, w.right, w.bottom))
+ let (rect, monitor, work) = ((r.left, r.top, r.right, r.bottom), (m.left, m.top, m.right, m.bottom), (w.left, w.top, w.right, w.bottom));
+ // The taskbars (a couple of `FindWindowEx` calls) are only looked up while the window
+ // covers the whole monitor, which a trimmed maximize no longer does.
+ let Some((left, top, right, bottom)) = clamped_rect(rect, monitor, work)
+ .or_else(|| covers(rect, monitor).then(|| short_of_autohide_taskbars(rect, monitor, work, &taskbar_edges(monitor))).flatten())
else {
return;
};
@@ -153,6 +174,69 @@ mod platform {
(corrected != rect).then_some(corrected)
}
+ /// Whether `rect` reaches every edge of `monitor`: what the shell takes for a full-screen app.
+ fn covers(rect: Rect, monitor: Rect) -> bool {
+ rect.0 <= monitor.0 && rect.1 <= monitor.1 && rect.2 >= monitor.2 && rect.3 >= monitor.3
+ }
+
+ /// The edges of `monitor` that have a taskbar (the primary `Shell_TrayWnd` or a secondary
+ /// monitor's `Shell_SecondaryTrayWnd`), shown or hidden.
+ fn taskbar_edges(monitor: Rect) -> Vec {
+ let mut edges = Vec::new();
+ for class in ["Shell_TrayWnd", "Shell_SecondaryTrayWnd"] {
+ let mut after: Option = None;
+ // One taskbar per monitor; the bound only guards against a misbehaving shell.
+ for _ in 0..64 {
+ let Ok(Some(tray)) = HWND::NULL.FindWindowEx(after.as_ref(), AtomStr::from_str(class), None) else {
+ break;
+ };
+ if let Ok(r) = tray.GetWindowRect() {
+ edges.extend(taskbar_edge((r.left, r.top, r.right, r.bottom), monitor));
+ }
+ after = Some(tray);
+ }
+ }
+ edges
+ }
+
+ /// The edge of `monitor` a taskbar with rect `taskbar` sits on: the one nearest its centre,
+ /// or `None` when it is on another monitor. An auto-hidden taskbar is parked mostly past its
+ /// edge with a sliver left on screen, so its centre may lie outside the monitor.
+ fn taskbar_edge(taskbar: Rect, monitor: Rect) -> Option {
+ let (tl, tt, tr, tb) = (i64::from(taskbar.0), i64::from(taskbar.1), i64::from(taskbar.2), i64::from(taskbar.3));
+ let (ml, mt, mr, mb) = (i64::from(monitor.0), i64::from(monitor.1), i64::from(monitor.2), i64::from(monitor.3));
+ if tl >= tr || tt >= tb || ml >= mr || mt >= mb || tr <= ml || tl >= mr || tb <= mt || tt >= mb {
+ return None;
+ }
+ // Doubled centre and distances, so nothing rounds.
+ let (cx, cy) = (tl + tr, tt + tb);
+ [(Edge::Left, cx - 2 * ml), (Edge::Top, cy - 2 * mt), (Edge::Right, 2 * mr - cx), (Edge::Bottom, 2 * mb - cy)]
+ .into_iter()
+ .min_by_key(|&(_, distance)| distance)
+ .map(|(edge, _)| edge)
+ }
+
+ /// A maximize covering its monitor, trimmed to stop one pixel short of each edge where a
+ /// taskbar reserves no work area (it auto-hides), so the shell still reveals it there.
+ /// `None` when there is nothing to trim.
+ fn short_of_autohide_taskbars(rect: Rect, monitor: Rect, work: Rect, taskbars: &[Edge]) -> Option {
+ if !covers(rect, monitor) {
+ return None;
+ }
+ let (mut l, mut t, mut r, mut b) = rect;
+ for edge in taskbars {
+ match edge {
+ Edge::Left if work.0 == monitor.0 => l = monitor.0.checked_add(1)?,
+ Edge::Top if work.1 == monitor.1 => t = monitor.1.checked_add(1)?,
+ Edge::Right if work.2 == monitor.2 => r = monitor.2.checked_sub(1)?,
+ Edge::Bottom if work.3 == monitor.3 => b = monitor.3.checked_sub(1)?,
+ _ => {}
+ }
+ }
+ let trimmed = (l, t, r, b);
+ (l < r && t < b && trimmed != rect).then_some(trimmed)
+ }
+
/// `style` without the OS title bar and frame (`WS_CAPTION`, which already includes
/// `WS_BORDER`). The sizing border (`WS_THICKFRAME`) stays, so edge resizing and Aero snap
/// keep working, as they did with winit 0.27's undecorated windows.
@@ -162,7 +246,7 @@ mod platform {
#[cfg(test)]
mod tests {
- use super::{borderless, clamped_rect};
+ use super::{Edge, borderless, clamped_rect, short_of_autohide_taskbars, taskbar_edge};
use winsafe::co;
#[test]
@@ -221,6 +305,35 @@ mod platform {
assert_eq!(clamped_rect((-500, -8, 1428, 1088), monitor, work), None, "dragged");
assert_eq!(clamped_rect((0, 48, 960, 1080), monitor, work), None, "half-screen");
}
+
+ #[test]
+ fn a_maximize_leaves_a_pixel_strip_on_an_auto_hidden_taskbar_edge() {
+ // #4845: an auto-hidden taskbar reserves no work area.
+ let monitor = (0, 0, 1920, 1080);
+ let rect = (-8, -8, 1928, 1088);
+ for (edge, expected) in
+ [(Edge::Bottom, (-8, -8, 1928, 1079)), (Edge::Top, (-8, 1, 1928, 1088)), (Edge::Left, (1, -8, 1928, 1088)), (Edge::Right, (-8, -8, 1919, 1088))]
+ {
+ assert_eq!(short_of_autohide_taskbars(rect, monitor, monitor, &[edge]), Some(expected));
+ assert_eq!(short_of_autohide_taskbars(expected, monitor, monitor, &[edge]), None, "already trimmed");
+ }
+ assert_eq!(short_of_autohide_taskbars(rect, monitor, monitor, &[]), None, "no taskbar");
+ // A taskbar that reserves its work area is the clamp's job.
+ assert_eq!(short_of_autohide_taskbars(rect, monitor, (0, 0, 1920, 1032), &[Edge::Bottom]), None);
+ assert_eq!(short_of_autohide_taskbars((0, 0, 960, 1080), monitor, monitor, &[Edge::Bottom]), None, "half-screen");
+ }
+
+ #[test]
+ fn a_taskbar_sits_on_the_monitor_edge_nearest_its_centre() {
+ let monitor = (0, 0, 1920, 1080);
+ assert_eq!(taskbar_edge((0, 1032, 1920, 1080), monitor), Some(Edge::Bottom), "shown");
+ assert_eq!(taskbar_edge((0, 1078, 1920, 1126), monitor), Some(Edge::Bottom), "auto-hidden");
+ assert_eq!(taskbar_edge((0, -46, 1920, 2), monitor), Some(Edge::Top));
+ assert_eq!(taskbar_edge((-60, 0, 2, 1080), monitor), Some(Edge::Left));
+ assert_eq!(taskbar_edge((1918, 0, 1980, 1080), monitor), Some(Edge::Right));
+ assert_eq!(taskbar_edge((1920, 1032, 3840, 1080), monitor), None, "other monitor");
+ assert!(taskbar_edge((i32::MIN, i32::MIN, i32::MAX, i32::MAX), monitor).is_some(), "no overflow");
+ }
}
}
diff --git a/book/src/automation/mcp.md b/book/src/automation/mcp.md
index 4f3d02496a..2c36b464c2 100644
--- a/book/src/automation/mcp.md
+++ b/book/src/automation/mcp.md
@@ -5,6 +5,9 @@
- **Headless:** an in-process `Headless` session owns the engine and performs file I/O.
- **Bridge:** `BridgeClient` forwards tools to a desktop application started with `photocraft --control `.
+For a source checkout, first [install the CLI on PATH and register the server](../../../docs/mcp.md#install-and-register).
+The installed binary survives `cargo clean`; a symlink into `target/release` does not.
+
Start the headless server with:
```sh
diff --git a/book/src/formats/raster-formats.md b/book/src/formats/raster-formats.md
index 2e65d51550..c333cbc737 100644
--- a/book/src/formats/raster-formats.md
+++ b/book/src/formats/raster-formats.md
@@ -19,6 +19,7 @@ Every route that writes a document to a flat format (Save As, the CLI, MCP `doc_
- Formats without transparency (JPEG, Radiance HDR) get the image composited over white, as Export As and Quick Export do; in CMYK, white is no ink.
- Formats that can't embed an ICC profile (GIF, BMP, TGA, QOI, ICO, Netpbm/PFM) get RGB documents in another profile (linear light, Display P3, …) converted to sRGB through the colour engine (perceptual intent), since an untagged file is read as sRGB. OpenEXR and Radiance HDR get linear sRGB instead (linear gray for a grayscale OpenEXR), which is how they are tagged when opened.
- TGA, as Photoshop's Targa: an RGB Targa is 24 or 32 bits per pixel. Save As and Save a Copy ask (Targa Options, starting at 32 with an alpha channel, else 24); Export As has a Resolution choice (Auto, 24 or 32 bits/pixel); scripts pass `tgaBits` (`--tga-bits` on the CLI). At 32 bits the document's alpha channel is the file's alpha when it has exactly one (spot channels don't count; with several, none is used), and the colours are written exactly, never premultiplied or cleared where the alpha is 0, so a game texture's mask and its colours both survive. Without a usable alpha channel the layers' transparency is the alpha (opaque without any). 24 bits write no alpha, compositing transparency over white. Without a choice: 32 with an alpha channel or transparency, else 24. A 32-bit Targa opens as an opaque Background with its alpha as "Alpha 1", so it round-trips. Grayscale Targas are 8-bit.
+- DDS, for game and 3D textures: Save As and Save a Copy ask (DDS Options) for the compression (None, uncompressed and lossless; BC1/DXT1, whose alpha is on or off; BC2/DXT3; BC3/DXT5) and whether to generate mipmaps (the full chain down to 1×1, each level a box filter of the one above). Scripts pass `ddsCompression` (`none`, `bc1`, `bc2`, `bc3`) and `ddsMipmaps` (`--dds-compression`, `--dds-mipmaps` on the CLI). Opening one reads its top mip level.
Flat TIFF and JPEG exports of composited CMYK documents convert the RGB composite back through
the document's CMYK ICC profile (relative colorimetric with black point compensation) and embed
diff --git a/contributors/people.toml b/contributors/people.toml
index 3948e2992b..b3cab8e04f 100644
--- a/contributors/people.toml
+++ b/contributors/people.toml
@@ -159,13 +159,35 @@ sync_github_name = false
real_name = "Ufuk Efe"
sync_github_name = true
+[people.nicolasfalesy]
+sync_github_name = true
+
[people.sidney]
real_name = "Sidney Markowitz"
sync_github_name = true
+[people.TaylorBurke]
+sync_github_name = true
+
[people.1Git2Clone]
sync_github_name = true
[people.Silvertree2010]
display_name = "Netter"
sync_github_name = false
+
+[people.Byxis]
+sync_github_name = true
+
+[people.00xJS]
+sync_github_name = true
+
+[people.guillehr2]
+real_name = "Guillem Hermida"
+sync_github_name = true
+
+[people.fjamesprice]
+sync_github_name = true
+
+[people.ha1des]
+sync_github_name = true
diff --git a/crates/affinity/README.md b/crates/affinity/README.md
index 3e4e066156..535c3736c6 100644
--- a/crates/affinity/README.md
+++ b/crates/affinity/README.md
@@ -139,11 +139,18 @@ zstd window, 4096 saved revisions, array lengths no longer than the
bytes left, 16 777 216 decoded values per document stream (fields and array elements together, counted before
anything is allocated: a value takes about 40 bytes in memory however few it took in the file; the largest of
those 189 public documents uses 3.3 million), 384 levels of object nesting, 128 levels of layers, 500 000 layers, four million curve
-nodes per curve, 64 megapixels per pixel layer (cropped to its content first) and 1024 gradient
+nodes per curve, 4 levels of spot colours defined through spot colours, 64 megapixels per pixel layer (cropped to its content first) and 1024 gradient
stops; `photocraft-io` adds 300 000 pixels a side per document and 64 megapixels per image on the
canvas. Every archive entry's CRC-32 and size must match. Malformed input returns an `Error`; the
crate has no panics outside tests.
+Compound outlines have a separate active-reference cycle check and a depth cap of 64. Across
+the document, geometry expansion admits at most 500 000 combined outline calls and operand-slot
+visits (including nulls and non-object values), and four million points created or copied into
+compounds. Operand slots are charged before filtering, without first collecting an operand list.
+A compound that crosses a limit or contains a cycle is omitted
+with a warning as a whole; ordinary shared operands remain valid and keep each occurrence's transform.
+
## Privacy
Affinity documents can hold the folder they were saved in, user names, original image paths and
diff --git a/crates/affinity/src/geometry.rs b/crates/affinity/src/geometry.rs
index 08554134db..6c8ea602c9 100644
--- a/crates/affinity/src/geometry.rs
+++ b/crates/affinity/src/geometry.rs
@@ -6,10 +6,103 @@ use crate::stream::{ObjId, Tag, Value};
/// Nodes per curve object.
const MAX_NODES: usize = 4_000_000;
+const MAX_COMPOUND_DEPTH: usize = 64;
+const MAX_OUTLINE_VISITS: usize = 500_000;
+const MAX_OUTLINE_POINTS: usize = 4_000_000;
+
+/// Geometry is expanded per occurrence, since parametric outlines can depend on their world
+/// transform. Bound work across the entire document instead of memoizing by object id alone.
+pub(crate) struct Budget {
+ visits_left: usize,
+ points_left: usize,
+}
+
+impl Default for Budget {
+ fn default() -> Self {
+ Self { visits_left: MAX_OUTLINE_VISITS, points_left: MAX_OUTLINE_POINTS }
+ }
+}
+
+impl Budget {
+ /// Charge an outline call or a raw compound operand slot before inspecting it.
+ fn visit(&mut self) -> Result<(), &'static str> {
+ self.visits_left = self.visits_left.checked_sub(1).ok_or("compound geometry budget exhausted")?;
+ Ok(())
+ }
+
+ /// Charge both leaf geometry and each copy into a compound before allocating that copy.
+ fn path(&mut self, path: &Path) -> Result<(), &'static str> {
+ let points = path.subpaths.iter().fold(0usize, |n, s| n.saturating_add(1).saturating_add(s.segments.len().saturating_mul(3)));
+ let Some(left) = self.points_left.checked_sub(points) else {
+ // Do not rebuild an expensive leaf for every subsequent root after exhaustion.
+ self.visits_left = 0;
+ return Err("compound geometry budget exhausted");
+ };
+ self.points_left = left;
+ Ok(())
+ }
+}
/// The node-space outline of a curve, shape or compound node, and whether it fills even-odd.
/// Instances (master-page or symbol copies) take the geometry of their master.
pub(crate) fn outline(r: &mut Reader, id: ObjId, class: Tag, world: Affine) -> Option<(Path, bool)> {
+ match outline_inner(r, id, class, world, &mut Vec::new()) {
+ Ok(path) => path,
+ Err(reason) => {
+ r.warn(reason);
+ None
+ }
+ }
+}
+
+fn outline_inner(r: &mut Reader, id: ObjId, class: Tag, world: Affine, active: &mut Vec) -> Result, &'static str> {
+ r.geometry_budget.visit()?;
+ if active.contains(&id) {
+ return Err("cyclic compound shape");
+ }
+ // Independent of the layer-tree limit: Comp operands do not pass through Reader::node.
+ if active.len() >= MAX_COMPOUND_DEPTH {
+ return Err("compound depth exceeds 64 levels");
+ }
+ if class != Tag::of(b"Comp") {
+ let path = leaf_outline(r, id, class, world);
+ if let Some((path, _)) = &path {
+ r.geometry_budget.path(path)?;
+ }
+ return Ok(path);
+ }
+ active.push(id);
+ let result = compound(r, id, world, active);
+ active.pop();
+ result
+}
+
+fn compound(r: &mut Reader, id: ObjId, world: Affine, active: &mut Vec) -> Result, &'static str> {
+ let s = r.s;
+ // One path of all operands' contours, filled even-odd, as before. A safety failure rejects
+ // the whole outline: a partial add/subtract shape could silently change its meaning.
+ let mut path = Path::default();
+ // Charge every slot before filtering nulls or malformed values. Stream::objs would scan
+ // and allocate the whole list first, repeating uncharged work for each shared occurrence.
+ let children = if let Some(Value::Array(children)) = s.field(id, b"Chld") { children.as_slice() } else { &[] };
+ for child in children {
+ r.geometry_budget.visit()?;
+ let Value::Obj(Some(c)) = child else { continue };
+ let c = *c;
+ let local = s.floats::<6>(c, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY);
+ let Some(class) = s.class(c) else { return Ok(None) };
+ if s.enumeration(c, b"ComO").is_some_and(|(op, _)| op != 0 && op != 2) {
+ r.warn("compound shapes that intersect or exclude (imported as add)");
+ }
+ if let Some((p, _)) = outline_inner(r, c, class, local.then(world), active)? {
+ r.geometry_budget.path(&p)?;
+ path.subpaths.extend(p.transformed(local).subpaths);
+ }
+ }
+ Ok(Some((path, true)))
+}
+
+fn leaf_outline(r: &mut Reader, id: ObjId, class: Tag, world: Affine) -> Option<(Path, bool)> {
let s = r.s;
match &class.0.to_be_bytes() {
b"PCrv" | b"TxtC" => {
@@ -18,23 +111,6 @@ pub(crate) fn outline(r: &mut Reader, id: ObjId, class: Tag, world: Affine) -> O
let multi = path.subpaths.len() > 1;
Some((path, multi))
}
- b"Comp" => {
- // One path of all the operands' contours in the compound's space, filled even-odd: this
- // matches add and subtract operands in every public sample; a true union of overlapping
- // added operands would differ.
- let mut path = Path::default();
- for c in s.objs(id, b"Chld") {
- let local = s.floats::<6>(c, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY);
- let class = s.class(c)?;
- if s.enumeration(c, b"ComO").is_some_and(|(op, _)| op != 0 && op != 2) {
- r.warn("compound shapes that intersect or exclude (imported as add)");
- }
- if let Some((p, _)) = outline(r, c, class, local.then(world)) {
- path.subpaths.extend(p.transformed(local).subpaths);
- }
- }
- Some((path, true))
- }
_ => {
let (shape, box_owner) = match s.obj(id, b"Shpe") {
Some(sh) => (sh, id),
diff --git a/crates/affinity/src/model.rs b/crates/affinity/src/model.rs
index 7fba5a4597..7c05072419 100644
--- a/crates/affinity/src/model.rs
+++ b/crates/affinity/src/model.rs
@@ -298,6 +298,7 @@ pub(crate) struct Reader<'s, 'a, 'b> {
warnings: BTreeMap,
nodes: usize,
active: HashSet,
+ pub(crate) geometry_budget: crate::geometry::Budget,
/// Decoded pixels still available ([`Limits::max_pixel_bytes`] less what the layers read so
/// far hold).
pixels_left: usize,
@@ -305,7 +306,16 @@ pub(crate) struct Reader<'s, 'a, 'b> {
impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
fn new(s: &'s Stream, archive: &'b mut Archive<'a>, limits: Limits) -> Self {
- Self { s, archive, dpi: 72.0, warnings: BTreeMap::new(), nodes: 0, active: HashSet::new(), pixels_left: limits.max_pixel_bytes }
+ Self {
+ s,
+ archive,
+ dpi: 72.0,
+ warnings: BTreeMap::new(),
+ nodes: 0,
+ active: HashSet::new(),
+ geometry_budget: crate::geometry::Budget::default(),
+ pixels_left: limits.max_pixel_bytes,
+ }
}
/// Admit a layer's decoded pixels before they are allocated (#4960). `rgba` is the layer's
diff --git a/crates/affinity/src/paint.rs b/crates/affinity/src/paint.rs
index c4ebff8446..343c276b60 100644
--- a/crates/affinity/src/paint.rs
+++ b/crates/affinity/src/paint.rs
@@ -134,8 +134,19 @@ fn floats(v: &Value) -> Option> {
}
}
+/// Spot colours defined through spot colours. A spot colour (`Pant`) is read through its RGB
+/// equivalent, else its base colour, which in real files hold plain colours. Colours are shared
+/// objects, so one can link to itself, and with both links followed at every level a colour read
+/// costs up to 2^(depth+1) lookups.
+const MAX_SPOT_DEPTH: usize = 4;
+
/// A colour object (`RGBA`, `HSLA`, `CMYK`, `GRAY`, `LABA`, `Pant`).
pub(crate) fn color(r: &mut Reader, id: ObjId) -> Option {
+ color_at(r, id, 0)
+}
+
+/// [`color`] of an object `depth` spot colours down.
+fn color_at(r: &mut Reader, id: ObjId, depth: usize) -> Option {
let s = r.s;
let class = s.class(id)?;
let col = || -> Option> {
@@ -166,8 +177,12 @@ pub(crate) fn color(r: &mut Reader, id: ObjId) -> Option {
Color::Lab { l: 100.0 * u(0)?, a: 255.0 * u(1)? - 128.0, b: 255.0 * u(2)? - 128.0, alpha: u(3)? }
}
b"Pant" => {
+ if depth >= MAX_SPOT_DEPTH {
+ r.warn("spot colours nested deeper than 4 levels (left empty)");
+ return None;
+ }
r.warn("spot colours (imported as their RGB equivalent)");
- return s.obj(id, b"srgb").and_then(|c| color(r, c)).or_else(|| s.obj(id, b"base").and_then(|c| color(r, c)));
+ return s.obj(id, b"srgb").and_then(|c| color_at(r, c, depth + 1)).or_else(|| s.obj(id, b"base").and_then(|c| color_at(r, c, depth + 1)));
}
b"RegC" => Color::Cmyk { c: 1.0, m: 1.0, y: 1.0, k: 1.0, a: 1.0 },
_ => {
diff --git a/crates/affinity/src/tests.rs b/crates/affinity/src/tests.rs
index 59ae99044a..2c68a064a3 100644
--- a/crates/affinity/src/tests.rs
+++ b/crates/affinity/src/tests.rs
@@ -538,3 +538,174 @@ mod pixels {
);
}
}
+
+/// Spot colours that link to themselves or to each other. Each document is read on a 1 MiB
+/// stack, the smallest main-thread stack PhotoCraft runs on (Windows), first in a child process
+/// that must finish within 20 s. A stack overflow aborts the process it happens in, so unbounded
+/// recursion fails the one test that caused it instead of taking the other tests in the binary
+/// down with it.
+mod spot_colours {
+ use std::io::{Read, Write};
+ use std::process::{Command, Stdio};
+ use std::time::{Duration, Instant};
+
+ use crate::container::Limits;
+ use crate::model::{Document, Kind};
+ use crate::paint::{Color, Paint};
+ use crate::stream::Tag;
+ use crate::synth::{self, F, Method, tag};
+
+ /// A one-page document whose page holds `top`, read in a child process first and then here.
+ fn read(top: Vec) -> Document {
+ let spread = F::Def(2, vec![tag(b"Sprd")], vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 400.0, 300.0])), (tag(b"Chld"), F::Shared(top))]);
+ let data = synth::stream(&[(tag(b"DocR"), F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(vec![spread]))]))]);
+ let bytes = synth::container(&[("doc.dat", &data, Method::Zlib)], None);
+ let test = std::thread::current().name().unwrap_or("reader").to_owned();
+ read_in_child(&bytes, &test);
+ read_on_small_stack(bytes, &test).unwrap()
+ }
+
+ /// Reads on a 1 MiB thread named after the test, so a stack overflow message names the test.
+ fn read_on_small_stack(bytes: Vec, test: &str) -> Result {
+ let reader = std::thread::Builder::new().name(test.to_owned()).stack_size(1 << 20).spawn(move || crate::read(&bytes, Limits::default())).unwrap();
+ reader.join().unwrap_or_else(|panic| std::panic::resume_unwind(panic))
+ }
+
+ /// Set in the child process to the name of the test it reads for.
+ const CHILD: &str = "PHOTOCRAFT_AFFINITY_READ_CHILD";
+
+ /// Runs `read_child` in a new process of this test binary on `bytes` and fails the test if
+ /// that process crashes, panics or takes over 20 s, or never read the document.
+ fn read_in_child(bytes: &[u8], test: &str) {
+ let mut child = Command::new(std::env::current_exe().unwrap())
+ .args(["tests::spot_colours::read_child", "--exact", "--ignored", "--nocapture", "--test-threads=1"])
+ .env(CHILD, test)
+ .stdin(Stdio::piped())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .spawn()
+ .unwrap();
+ let drain = |mut pipe: Box| {
+ std::thread::spawn(move || {
+ let mut text = String::new();
+ let _ = pipe.read_to_string(&mut text);
+ text
+ })
+ };
+ let stdout = drain(Box::new(child.stdout.take().unwrap()));
+ let stderr = drain(Box::new(child.stderr.take().unwrap()));
+ // Fed from a thread, so a child that never reads cannot block the deadline below; a child
+ // that dies first breaks the pipe and is reported by its exit status.
+ let (mut stdin, input) = (child.stdin.take().unwrap(), bytes.to_vec());
+ let feed = std::thread::spawn(move || {
+ let _ = stdin.write_all(&input);
+ });
+ let deadline = Instant::now() + Duration::from_secs(20);
+ let status = loop {
+ if let Some(status) = child.try_wait().unwrap() {
+ break Some(status);
+ }
+ if Instant::now() >= deadline {
+ let _ = child.kill();
+ let _ = child.wait();
+ break None;
+ }
+ std::thread::sleep(Duration::from_millis(10));
+ };
+ feed.join().unwrap();
+ let output = format!("{}{}", stdout.join().unwrap(), stderr.join().unwrap());
+ match status {
+ None => panic!("reading the document did not finish within 20 s:\n{output}"),
+ Some(status) if !status.success() => panic!("reading the document failed in a child process ({status}):\n{output}"),
+ // A filter that matched no test would also exit successfully, having read nothing.
+ Some(_) => assert!(output.contains(&format!("read {} bytes", bytes.len())), "the child process did not read the document:\n{output}"),
+ }
+ }
+
+ /// Not a test of its own: `read_in_child` runs it in a child process to read the document on
+ /// its stdin. Without the variable, as when run with `--ignored`, it does nothing.
+ #[test]
+ #[ignore = "run by read_in_child in a child process"]
+ fn read_child() {
+ let Some(test) = std::env::var_os(CHILD) else { return };
+ let mut bytes = Vec::new();
+ std::io::stdin().read_to_end(&mut bytes).unwrap();
+ let len = bytes.len();
+ let _ = read_on_small_stack(bytes, &test.to_string_lossy());
+ println!("read {len} bytes");
+ }
+
+ /// An ellipse filled with `colour`.
+ fn filled(id: u32, colour: F) -> F {
+ F::Def(
+ id,
+ vec![tag(b"ShpN")],
+ vec![
+ (tag(b"ShpB"), F::F64s(vec![0.0, 0.0, 1.0, 1.0])),
+ (tag(b"Shpe"), F::Def(id + 1_000_000, vec![tag(b"ShpE")], vec![])),
+ (tag(b"BFFl"), F::Shared(vec![F::Def(id + 2_000_000, vec![tag(b"FilS")], vec![(tag(b"Colr"), colour)])])),
+ ],
+ )
+ }
+
+ fn spot(id: u32, fields: Vec<(Tag, F)>) -> F {
+ F::Def(id, vec![tag(b"Pant")], fields)
+ }
+
+ fn fills(doc: &Document) -> &[Paint] {
+ match &doc.spreads[0].nodes[0].kind {
+ Kind::Shape { fills, .. } => fills,
+ other => panic!("expected a shape, got {other:?}"),
+ }
+ }
+
+ fn warned(doc: &Document, what: &str) -> bool {
+ doc.warnings.iter().any(|w| w.contains(what))
+ }
+
+ #[test]
+ fn a_spot_colour_that_is_its_own_rgb_equivalent_is_refused() {
+ let doc = read(vec![filled(10, spot(20, vec![(tag(b"srgb"), F::Ref(20))]))]);
+ assert!(fills(&doc).is_empty(), "{:?}", fills(&doc));
+ assert!(warned(&doc, "spot colours nested deeper than 4 levels"), "{:?}", doc.warnings);
+ }
+
+ #[test]
+ fn spot_colours_that_are_each_others_base_and_rgb_equivalent_are_refused() {
+ let doc = read(vec![filled(10, spot(20, vec![(tag(b"base"), spot(21, vec![(tag(b"srgb"), F::Ref(20))]))]))]);
+ assert!(fills(&doc).is_empty(), "{:?}", fills(&doc));
+ assert!(warned(&doc, "spot colours nested deeper than 4 levels"), "{:?}", doc.warnings);
+ }
+
+ #[test]
+ fn spot_colours_of_spot_colours_do_not_take_exponential_time() {
+ // Each level's RGB equivalent and base are the next level, and the last is in an unknown
+ // model, so both are followed everywhere: 2^41 colour reads before the fix.
+ fn level(id: u32, left: u32) -> F {
+ if left == 0 {
+ return F::Def(id, vec![tag(b"XXXX")], vec![]);
+ }
+ spot(id, vec![(tag(b"srgb"), level(id + 1, left - 1)), (tag(b"base"), F::Ref(id + 1))])
+ }
+ let doc = read(vec![filled(10, level(100, 40))]);
+ assert!(fills(&doc).is_empty(), "{:?}", fills(&doc));
+ }
+
+ /// `spots` spot colours, each defined through the next, around an opaque RGB colour.
+ fn spots(id: u32, spots: u32) -> F {
+ if spots == 0 {
+ let rgba: Vec = [1.0f32, 0.5, 0.25, 1.0].iter().flat_map(|c| c.to_le_bytes()).collect();
+ return F::Def(id, vec![tag(b"RGBA")], vec![(tag(b"_col"), F::Struct(rgba))]);
+ }
+ spot(id, vec![(tag(b"srgb"), self::spots(id + 1, spots - 1))])
+ }
+
+ #[test]
+ fn spot_colours_nest_four_levels_and_no_deeper() {
+ let doc = read(vec![filled(10, spots(100, 4))]);
+ assert_eq!(fills(&doc), [Paint::Solid(Color::Rgb { r: 1.0, g: 0.5, b: 0.25, a: 1.0 })]);
+ let doc = read(vec![filled(10, spots(100, 5))]);
+ assert!(fills(&doc).is_empty(), "{:?}", fills(&doc));
+ assert!(warned(&doc, "spot colours nested deeper than 4 levels"), "{:?}", doc.warnings);
+ }
+}
diff --git a/crates/affinity/tests/compound_limits.rs b/crates/affinity/tests/compound_limits.rs
new file mode 100644
index 0000000000..76f2e6e4a1
--- /dev/null
+++ b/crates/affinity/tests/compound_limits.rs
@@ -0,0 +1,212 @@
+//! #6423: compound operands form a graph, not necessarily a tree.
+#![cfg(feature = "synth")]
+#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
+
+use photocraft_affinity::model::{Document, Kind};
+use photocraft_affinity::synth::{self, F, Method, tag};
+use photocraft_affinity::{Limits, read};
+use std::time::{Duration, Instant};
+
+fn compound(id: u32, children: Vec) -> F {
+ F::Def(id, vec![tag(b"Comp")], vec![(tag(b"Chld"), F::Shared(children))])
+}
+
+fn rectangle() -> F {
+ F::Def(100, vec![tag(b"ShpN")], vec![(tag(b"ShpB"), F::F64s(vec![0.0, 0.0, 10.0, 20.0])), (tag(b"Shpe"), F::Def(101, vec![tag(b"ShNR")], vec![]))])
+}
+
+fn document_bytes(definitions: Vec, roots: Vec) -> Vec {
+ let data = synth::stream(&[
+ (tag(b"Defs"), F::Shared(definitions)),
+ (
+ tag(b"DocR"),
+ F::Def(
+ 1,
+ vec![tag(b"DocN")],
+ vec![(
+ tag(b"Chld"),
+ F::Shared(vec![F::Def(
+ 2,
+ vec![tag(b"Sprd")],
+ vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 400.0, 300.0])), (tag(b"Chld"), F::Shared(roots))],
+ )]),
+ )],
+ ),
+ ),
+ ]);
+ synth::container(&[("doc.dat", &data, Method::Zlib)], None)
+}
+
+fn document(definitions: Vec, roots: Vec) -> Document {
+ read(&document_bytes(definitions, roots), Limits::default()).unwrap()
+}
+
+// A stack overflow aborts rather than unwinds. Keep that failure (and an exponential hang)
+// inside a child process, so the regression fails without killing the entire test runner.
+fn isolated(name: &str, check: impl FnOnce()) {
+ if std::env::var("PHOTOCRAFT_COMPOUND_CASE").as_deref() == Ok(name) {
+ check();
+ return;
+ }
+ let mut child = std::process::Command::new(std::env::current_exe().unwrap())
+ .args(["--exact", name, "--nocapture"])
+ .env("PHOTOCRAFT_COMPOUND_CASE", name)
+ .spawn()
+ .unwrap();
+ let start = Instant::now();
+ loop {
+ if let Some(status) = child.try_wait().unwrap() {
+ assert!(status.success(), "compound import failed: {status}");
+ break;
+ }
+ if start.elapsed() > Duration::from_secs(10) {
+ child.kill().unwrap();
+ child.wait().unwrap();
+ panic!("compound import exceeded ten seconds");
+ }
+ std::thread::sleep(Duration::from_millis(20));
+ }
+}
+
+fn unsupported(doc: &Document, warning: &str) {
+ assert!(matches!(doc.spreads[0].nodes[0].kind, Kind::Unsupported));
+ assert!(doc.warnings.iter().any(|w| w.contains(warning)), "{:?}", doc.warnings);
+}
+
+#[test]
+fn self_referencing_compound_is_reported_without_aborting() {
+ isolated("self_referencing_compound_is_reported_without_aborting", || {
+ let doc = document(vec![], vec![compound(10, vec![F::Ref(10)]), rectangle()]);
+ unsupported(&doc, "cyclic compound");
+ assert!(matches!(doc.spreads[0].nodes[1].kind, Kind::Shape { .. }), "a cycle must not poison unrelated shapes");
+ });
+}
+
+#[test]
+fn indirectly_cyclic_compound_is_not_imported_as_a_partial_shape() {
+ isolated("indirectly_cyclic_compound_is_not_imported_as_a_partial_shape", || {
+ let doc = document(vec![rectangle()], vec![compound(10, vec![F::Ref(100), compound(11, vec![F::Ref(10)])])]);
+ unsupported(&doc, "cyclic compound");
+ });
+}
+
+fn nested(levels: u32, repeated: bool) -> Document {
+ // Flat definitions bypass the stream parser's nesting cap: only references form the depth.
+ let mut definitions = vec![rectangle()];
+ let mut previous = 100;
+ for id in 200..200 + levels {
+ let children = if repeated { vec![F::Ref(previous), F::Ref(previous)] } else { vec![F::Ref(previous)] };
+ definitions.push(compound(id, children));
+ previous = id;
+ }
+ document(definitions, vec![F::Ref(previous)])
+}
+
+#[test]
+fn deep_compound_graph_is_bounded_on_a_small_stack() {
+ isolated("deep_compound_graph_is_bounded_on_a_small_stack", || {
+ std::thread::Builder::new().stack_size(512 * 1024).spawn(|| unsupported(&nested(1000, false), "compound depth")).unwrap().join().unwrap();
+ });
+}
+
+#[test]
+fn shared_operands_cannot_expand_exponentially() {
+ isolated("shared_operands_cannot_expand_exponentially", || unsupported(&nested(40, true), "compound geometry budget"));
+}
+
+#[test]
+fn shared_empty_operands_also_have_a_work_limit() {
+ isolated("shared_empty_operands_also_have_a_work_limit", || {
+ let mut definitions = vec![compound(100, vec![])];
+ let mut previous = 100;
+ for id in 200..240 {
+ definitions.push(compound(id, vec![F::Ref(previous), F::Ref(previous)]));
+ previous = id;
+ }
+ unsupported(&document(definitions, vec![F::Ref(previous)]), "compound geometry budget");
+ });
+}
+
+#[test]
+fn geometry_budget_is_shared_across_layer_roots() {
+ isolated("geometry_budget_is_shared_across_layer_roots", || {
+ let mut definitions = vec![rectangle()];
+ let mut previous = 100;
+ for id in 200..214 {
+ definitions.push(compound(id, vec![F::Ref(previous), F::Ref(previous)]));
+ previous = id;
+ }
+ let mut roots = vec![F::Ref(previous); 12];
+ roots.push(F::Ref(100));
+ let doc = document(definitions, roots);
+ let nodes = &doc.spreads[0].nodes;
+ assert!(matches!(nodes[0].kind, Kind::Shape { .. }), "a modest shared graph still imports");
+ assert!(matches!(nodes.last().unwrap().kind, Kind::Unsupported), "later roots cannot reset the exhausted budget");
+ assert!(doc.warnings.iter().any(|w| w.contains("compound geometry budget")));
+ });
+}
+
+#[test]
+fn ordinary_shared_operands_keep_both_transformed_copies() {
+ let mut translated = compound(201, vec![F::Ref(100)]);
+ let F::Def(_, _, fields) = &mut translated else { unreachable!() };
+ fields.push((tag(b"Xfrm"), F::F64s(vec![1.0, 0.0, 30.0, 0.0, 1.0, 40.0])));
+ let doc = document(vec![rectangle(), compound(200, vec![F::Ref(100)]), translated], vec![compound(202, vec![F::Ref(200), F::Ref(201)])]);
+ assert!(doc.warnings.is_empty(), "{:?}", doc.warnings);
+ let Kind::Shape { path, .. } = &doc.spreads[0].nodes[0].kind else { panic!("expected compound outline") };
+ assert_eq!(path.subpaths.len(), 2);
+ for (a, b) in path.subpaths[0].segments.iter().flatten().zip(path.subpaths[1].segments.iter().flatten()) {
+ assert_eq!(b.x, a.x + 30.0);
+ assert_eq!(b.y, a.y + 40.0);
+ }
+}
+
+#[test]
+fn null_operand_slots_cannot_bypass_geometry_budget() {
+ isolated("null_operand_slots_cannot_bypass_geometry_budget", || {
+ let bytes = document_bytes(vec![compound(10, vec![F::Null; 1_000_000])], vec![F::Ref(10); 20_000]);
+ let start = Instant::now();
+ let doc = read(&bytes, Limits::default()).unwrap();
+ eprintln!("one million nulls / 20,000 placements: {} bytes, read {:.3}s", bytes.len(), start.elapsed().as_secs_f64());
+ unsupported(&doc, "compound geometry budget");
+ assert_eq!(doc.spreads[0].nodes.len(), 20_000);
+ assert!(doc.spreads[0].nodes.iter().all(|n| matches!(n.kind, Kind::Unsupported)));
+ });
+}
+
+#[test]
+fn null_operand_slots_share_budget_across_layer_roots() {
+ isolated("null_operand_slots_share_budget_across_layer_roots", || {
+ let doc = document(vec![compound(10, vec![F::Null; 1_000])], vec![F::Ref(10); 1_000]);
+ assert!(matches!(doc.spreads[0].nodes[0].kind, Kind::Shape { .. }), "a small empty compound remains valid");
+ assert!(matches!(doc.spreads[0].nodes.last().unwrap().kind, Kind::Unsupported));
+ assert!(doc.warnings.iter().any(|w| w.contains("compound geometry budget")));
+ });
+}
+
+#[test]
+fn null_operand_slots_cannot_leave_a_partial_shape() {
+ isolated("null_operand_slots_cannot_leave_a_partial_shape", || {
+ let mut slots = vec![F::Ref(100)];
+ slots.extend(vec![F::Null; 1_000_000]);
+ let doc = document(vec![rectangle(), compound(10, slots)], vec![F::Ref(10)]);
+ unsupported(&doc, "compound geometry budget");
+ });
+}
+
+#[test]
+fn non_object_operand_slots_also_consume_budget() {
+ isolated("non_object_operand_slots_also_consume_budget", || {
+ let comp = F::Def(10, vec![tag(b"Comp")], vec![(tag(b"Chld"), F::U8s(vec![0; 1_000_000]))]);
+ unsupported(&document(vec![comp], vec![F::Ref(10)]), "compound geometry budget");
+ });
+}
+
+#[test]
+fn ordinary_null_operand_slots_preserve_shared_contours() {
+ let doc = document(vec![rectangle(), compound(10, vec![F::Null, F::Ref(100), F::Null, F::Ref(100), F::Null])], vec![F::Ref(10)]);
+ assert!(doc.warnings.is_empty(), "{:?}", doc.warnings);
+ let Kind::Shape { path, .. } = &doc.spreads[0].nodes[0].kind else { panic!("expected compound outline") };
+ assert_eq!(path.subpaths.len(), 2);
+ assert_eq!(path.subpaths[0], path.subpaths[1]);
+}
diff --git a/crates/algo/examples/bench_multiband.rs b/crates/algo/examples/bench_multiband.rs
new file mode 100644
index 0000000000..9d58b81a54
--- /dev/null
+++ b/crates/algo/examples/bench_multiband.rs
@@ -0,0 +1,42 @@
+//! Time and peak memory of the Photomerge multi-band blend: `n` RGB sources of `mp` megapixels
+//! (3:2), each overlapping the next by half its width.
+//! `cargo run --release -p photocraft-algo --example bench_multiband [n] [mp]` (default 4 × 24 MP).
+//! Peak memory is the growth of the process's high-water mark during the blend (Linux only).
+use std::time::Instant;
+
+use photocraft_algo::panorama::{self, RoiImage};
+
+/// The process's peak resident set in MiB (`VmHWM`), where `/proc` has it.
+fn peak_mib() -> Option {
+ let s = std::fs::read_to_string("/proc/self/status").ok()?;
+ let kb: f64 = s.lines().find(|l| l.starts_with("VmHWM:"))?.split_whitespace().nth(1)?.parse().ok()?;
+ Some(kb / 1024.0)
+}
+
+fn main() {
+ let mut args = std::env::args().skip(1);
+ let n: usize = args.next().and_then(|a| a.parse().ok()).unwrap_or(4).max(1);
+ let mp: f64 = args.next().and_then(|a| a.parse().ok()).unwrap_or(24.0);
+ let sh = ((mp * 1e6 / 1.5).sqrt().round() as usize).max(2);
+ let sw = sh * 3 / 2;
+ let step = sw / 2;
+ let (cw, ch) = (step * (n - 1) + sw, sh);
+ let rois: Vec = (0..n)
+ .map(|k| {
+ let px = (0..sw * sh * 3).map(|i| ((i / 3 % sw * 7 + i / 3 / sw * 3 + k * 31 + i % 3 * 50) % 251) as f32 / 251.0).collect();
+ RoiImage { x0: (step * k) as i32, y0: 0, w: sw, h: sh, ch: 3, px, alpha: vec![1.0; sw * sh] }
+ })
+ .collect();
+ // Each canvas column belongs to the image whose centre is nearest, as a seam would split it.
+ let wts: Vec> = (0..n)
+ .map(|k| (0..sw * sh).map(|i| if ((step * k + i % sw + step / 2) / step).saturating_sub(1).min(n - 1) == k { 1.0 } else { 0.0 }).collect())
+ .collect();
+ let levels = panorama::blend_levels(cw, ch).min(7);
+ let before = peak_mib();
+ let t0 = Instant::now();
+ let (px, cov) = panorama::multiband(cw, ch, &rois, &wts, levels);
+ let ms = t0.elapsed().as_secs_f64() * 1000.0;
+ let peak = peak_mib().zip(before).map_or("n/a".to_string(), |(a, b)| format!("{:.0} MiB", a - b));
+ println!("{n} × {sw}×{sh} ({mp} MP) RGB, canvas {cw}×{ch}, {levels} levels: {ms:.0} ms, peak +{peak} during the blend");
+ std::hint::black_box((px, cov));
+}
diff --git a/crates/algo/src/blur2.rs b/crates/algo/src/blur2.rs
index e917405083..bb6b47656c 100644
--- a/crates/algo/src/blur2.rs
+++ b/crates/algo/src/blur2.rs
@@ -335,7 +335,7 @@ pub(crate) fn lens_blur(src: &Image, out: Rect, ctx: &Ctx, spec: &LensSpec) -> V
}
src.get(x, y, n - 1)
}
- DepthSource::LayerMask => spec.depth_map?.get(x, y, 0),
+ DepthSource::LayerMask | DepthSource::Channel => spec.depth_map?.get(x, y, 0),
};
Some(if spec.invert { 1.0 - d } else { d })
};
diff --git a/crates/algo/src/camera_raw.rs b/crates/algo/src/camera_raw.rs
index f855b08bfe..3cadd1d20c 100644
--- a/crates/algo/src/camera_raw.rs
+++ b/crates/algo/src/camera_raw.rs
@@ -67,22 +67,16 @@ impl Encoding {
pub(crate) fn of(gamma: f32) -> Self {
Encoding(if gamma.is_finite() && gamma > 0.0 { gamma.clamp(0.5, 4.0) } else { 0.0 })
}
+ /// A value in this encoding as linear light.
pub(crate) fn decode(self, v: f32) -> f32 {
if self.0 > 0.0 { v.max(0.0).powf(self.0) } else { srgb_to_linear(v) }
}
+ /// Linear light back into this encoding, the inverse of [`Encoding::decode`].
pub(crate) fn encode(self, v: f32) -> f32 {
if self.0 > 0.0 { v.max(0.0).powf(1.0 / self.0) } else { linear_to_srgb(v) }
}
}
-fn srgb_encode(v: f32) -> f32 {
- linear_to_srgb(v)
-}
-
-fn srgb_decode(v: f32) -> f32 {
- srgb_to_linear(v)
-}
-
fn luma_linear(c: [f32; 3]) -> f32 {
luma(c)
}
diff --git a/crates/algo/src/camera_raw/local_tone.rs b/crates/algo/src/camera_raw/local_tone.rs
index e1305c5d49..1dbf58421f 100644
--- a/crates/algo/src/camera_raw/local_tone.rs
+++ b/crates/algo/src/camera_raw/local_tone.rs
@@ -173,6 +173,39 @@ fn up(p: &Plane, w: usize, h: usize) -> Plane {
Plane { w, h, v: out }
}
+/// The source rows of [`up`]`(p, w, h)` at output row `y`, one per tap (`None` where the
+/// zero-stuffed row is all zeros).
+fn up_rows(p: &Plane, h: usize, y: usize) -> [Option; 5] {
+ let lh = p.h.min(h.div_ceil(2));
+ std::array::from_fn(|k| {
+ let m = mirror(y as isize + k as isize - 2, h);
+ (m.is_multiple_of(2) && m / 2 < lh).then_some(m / 2)
+ })
+}
+
+/// One sample of [`up`]`(p, w, h)`: column `x` of the output row whose [`up_rows`] are `rows`.
+/// It adds the same terms in the same order as `up`, so it gives the same bits.
+fn up_at(p: &Plane, w: usize, rows: &[Option; 5], x: usize) -> f32 {
+ let lw = p.w.min(w.div_ceil(2));
+ let col = |j: usize| -> f32 { (0..5).filter_map(|k| rows[k].map(|r| TAPS[k] * (p.v[r * p.w + j] * 4.0))).sum() };
+ if x >= 2 && x + 2 < w {
+ // Away from the edges no tap mirrors.
+ (0..5)
+ .filter_map(|k| {
+ let i = x + k - 2;
+ (i.is_multiple_of(2) && i / 2 < lw).then(|| TAPS[k] * col(i / 2))
+ })
+ .sum()
+ } else {
+ (0..5)
+ .filter_map(|k| {
+ let m = mirror(x as isize + k as isize - 2, w);
+ (m.is_multiple_of(2) && m / 2 < lw).then(|| TAPS[k] * col(m / 2))
+ })
+ .sum()
+ }
+}
+
/// Levels down to about 2 pixels on the short side.
fn depth(w: usize, h: usize) -> usize {
let mut s = w.min(h);
@@ -217,14 +250,14 @@ fn llf(u: &Plane, remap: impl Fn(f32, f32) -> f32 + Sync) -> Plane {
});
let gr = gaussian(m, n);
for l in 0..n - 1 {
- let coarse = up(&gr[l + 1], gr[l].w, gr[l].h);
- let (gl, fine, lw) = (&g[l].v, &gr[l].v, g[l].w);
+ let (gl, fine, lw, lh, coarse) = (&g[l].v, &gr[l].v, g[l].w, g[l].h, &gr[l + 1]);
rows(&mut lap[l], lw, |y, row| {
+ let taps = up_rows(coarse, lh, y);
for (x, o) in row.iter_mut().enumerate() {
let i = y * lw + x;
let wgt = (1.0 - (gl[i].clamp(lo, hi) - r).abs() / step).max(0.0);
if wgt > 0.0 {
- *o += wgt * (fine[i] - coarse.v[i]);
+ *o += wgt * (fine[i] - up_at(coarse, lw, &taps, x));
}
}
});
@@ -244,8 +277,10 @@ fn llf(u: &Plane, remap: impl Fn(f32, f32) -> f32 + Sync) -> Plane {
/// The `q` quantile (0 … 1) of `v`.
fn quantile(v: &[f32], q: f32) -> f32 {
let mut s = v.to_vec();
- s.sort_by(f32::total_cmp);
- s[((s.len() - 1) as f32 * q).round() as usize]
+ // Selecting the one element is linear, a full sort is not (5 sorts of the work copy were a
+ // fifteenth of a render). `total_cmp` ties are bit-identical, so it is the sorted element.
+ let i = ((s.len() - 1) as f32 * q).round() as usize;
+ *s.select_nth_unstable_by(i, f32::total_cmp).1
}
/// 1st and 99th percentiles of the log luminance on a copy at most 256 pixels across.
@@ -444,11 +479,33 @@ mod tests {
assert_eq!(blur_step(&p, step).v, blur_step_reference(&p, step).v, "blur {w}×{h} step {step}");
}
for (uw, uh) in [(w * 2, h * 2), (w * 2 - 1, h * 2 - 1), (w * 2, h * 2 - 1), (w.max(2) * 2 - 3, h * 2)] {
- assert_eq!(up(&p, uw, uh).v, up_reference(&p, uw, uh).v, "up {w}×{h} → {uw}×{uh}");
+ let full = up(&p, uw, uh);
+ assert_eq!(full.v, up_reference(&p, uw, uh).v, "up {w}×{h} → {uw}×{uh}");
+ // The filter samples `up` one pixel at a time; every sample has the same bits.
+ for y in 0..uh {
+ let taps = up_rows(&p, uh, y);
+ for x in 0..uw {
+ assert_eq!(up_at(&p, uw, &taps, x).to_bits(), full.v[y * uw + x].to_bits(), "up_at {w}×{h} → {uw}×{uh} at {x},{y}");
+ }
+ }
}
}
}
+ #[test]
+ fn quantile_is_the_sorted_element() {
+ let p = noise_plane(301, 211, 9);
+ let mut v = p.v.clone();
+ // Ties and signed zeros, which `total_cmp` orders by their bits.
+ v.extend([0.0, -0.0, 0.0, -0.0, 3.5, 3.5, f32::MIN_POSITIVE]);
+ let mut sorted = v.clone();
+ sorted.sort_by(f32::total_cmp);
+ for q in [0.0, 0.001, 0.01, 0.25, 0.5, 0.99, 1.0] {
+ let want = sorted[((sorted.len() - 1) as f32 * q).round() as usize];
+ assert_eq!(quantile(&v, q).to_bits(), want.to_bits(), "q {q}");
+ }
+ }
+
fn grey(w: usize, h: usize, f: impl Fn(usize, usize) -> f32) -> Vec<[f32; 4]> {
(0..w * h).map(|i| f(i % w, i / w)).map(|v| [v, v, v, 1.0]).collect()
}
diff --git a/crates/algo/src/camera_raw/whites_blacks.rs b/crates/algo/src/camera_raw/whites_blacks.rs
index 382ade50e9..93bb9cff31 100644
--- a/crates/algo/src/camera_raw/whites_blacks.rs
+++ b/crates/algo/src/camera_raw/whites_blacks.rs
@@ -369,14 +369,14 @@ pub(crate) fn apply(px: &mut [[f32; 4]], width: usize, w: f32, b: f32, enc: Enco
if per_channel {
for c in 0..3 {
let over = (lin[c] - 1.0).max(0.0);
- q[c] = enc.encode(super::srgb_decode(grey(super::srgb_encode(lin[c].min(1.0)))) + over);
+ q[c] = enc.encode(super::srgb_to_linear(grey(super::linear_to_srgb(lin[c].min(1.0)))) + over);
}
} else {
let y = super::luma_linear(lin).max(0.0);
if y <= 1e-7 {
continue;
}
- let y2 = super::srgb_decode(grey(super::srgb_encode(y.min(1.0)))) + (y - 1.0).max(0.0);
+ let y2 = super::srgb_to_linear(grey(super::linear_to_srgb(y.min(1.0)))) + (y - 1.0).max(0.0);
let k = y2 / y;
for c in 0..3 {
q[c] = enc.encode(lin[c] * k);
diff --git a/crates/algo/src/features.rs b/crates/algo/src/features.rs
index ec74891074..80de7b993e 100644
--- a/crates/algo/src/features.rs
+++ b/crates/algo/src/features.rs
@@ -45,12 +45,9 @@ pub const PATCH_RADIUS: usize = 15;
struct Rng(u64);
impl Rng {
+ /// The next value of the SplitMix64 sequence.
fn next(&mut self) -> u64 {
- self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
- let mut z = self.0;
- z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
- z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
- z ^ (z >> 31)
+ crate::photo_util::splitmix64(&mut self.0)
}
fn unit(&mut self) -> f64 {
(self.next() >> 11) as f64 / (1u64 << 53) as f64
diff --git a/crates/algo/src/inpaint.rs b/crates/algo/src/inpaint.rs
index ee472b2296..2dd40198d8 100644
--- a/crates/algo/src/inpaint.rs
+++ b/crates/algo/src/inpaint.rs
@@ -65,15 +65,13 @@ const SOURCE_MOMENTS_MAX: usize = 1 << 20;
#[derive(Clone)]
pub(crate) struct Rng(u64);
impl Rng {
+ /// A generator whose sequence is fixed by `seed`.
pub(crate) fn new(seed: u64) -> Self {
Rng(seed ^ 0x9E37_79B9_7F4A_7C15)
}
+ /// The next value of the SplitMix64 sequence.
pub(crate) fn next(&mut self) -> u64 {
- self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
- let mut z = self.0;
- z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
- z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
- z ^ (z >> 31)
+ crate::photo_util::splitmix64(&mut self.0)
}
/// Skip `n` numbers (the same state as after `n` calls of [`Rng::next`]).
pub(crate) fn skip(&mut self, n: u64) {
diff --git a/crates/algo/src/lib.rs b/crates/algo/src/lib.rs
index c2ff6d2e6c..6cefbbd4c1 100644
--- a/crates/algo/src/lib.rs
+++ b/crates/algo/src/lib.rs
@@ -482,7 +482,8 @@ pub enum FilterParams {
distribution: Distribution,
monochromatic: bool,
seed: u32,
- /// Depth for [`DepthSource::LayerMask`] (channel 0, document coordinates), supplied by the caller.
+ /// Depth for [`DepthSource::LayerMask`] and [`DepthSource::Channel`] (channel 0, document
+ /// coordinates), supplied by the caller.
#[serde(skip)]
depth_map: Option>,
},
@@ -744,8 +745,8 @@ pub fn kernel(params: &FilterParams, src: &Image, out: Rect, ctx: &Ctx) -> Vec noise::add(src, out, ctx, *amount, *distribution, *monochromatic, *seed),
FilterParams::Median { radius } => noise::median(src, out, *radius, None),
FilterParams::DustAndScratches { radius, threshold } => noise::median(src, out, *radius, Some(*threshold)),
- FilterParams::Minimum { radius, preserve } => other::min_max_preserve(src, out, *radius, false, *preserve),
- FilterParams::Maximum { radius, preserve } => other::min_max_preserve(src, out, *radius, true, *preserve),
+ FilterParams::Minimum { radius, preserve } => min_max_ink(src, out, ctx, *radius, false, *preserve),
+ FilterParams::Maximum { radius, preserve } => min_max_ink(src, out, ctx, *radius, true, *preserve),
FilterParams::Offset { horizontal, vertical, undefined } => other::offset(src, out, ctx, *horizontal, *vertical, *undefined),
FilterParams::Mosaic { cell_size } => stylize::mosaic(src, out, ctx, *cell_size),
FilterParams::Emboss { angle, height, amount } => stylize::emboss(src, out, ctx, *angle, *height, *amount),
@@ -909,6 +910,23 @@ pub fn kernel(params: &FilterParams, src: &Image, out: Rect, ctx: &Ctx) -> Vec Vec {
+ if !fxutil::subtractive(ctx) {
+ return other::min_max_preserve(src, out, radius, max, preserve);
+ }
+ let n = src.ch.max(1);
+ let cc = if ctx.alpha { n - 1 } else { n };
+ let flip = |data: &mut [f32]| data.chunks_exact_mut(n).for_each(|px| px.iter_mut().take(cc).for_each(|v| *v = 1.0 - *v));
+ let mut inv = src.clone();
+ flip(&mut inv.data);
+ let mut res = other::min_max_preserve(&inv, out, radius, max, preserve);
+ flip(&mut res);
+ res
+}
+
/// Output tile size used by [`apply`].
pub const TILE: i32 = 256;
@@ -972,6 +990,7 @@ fn auto_tile(params: &FilterParams, bounds: Rect) -> i32 {
}
/// [`apply`] / [`apply_in`] with an explicit tile size (tests use it to check tile independence).
+/// Nonpositive tile sizes are treated as one pixel.
pub fn apply_tiled(
surface: &Surface,
params: &FilterParams,
@@ -1014,6 +1033,8 @@ fn apply_tiled_with_impl(
ctl: &photocraft_raster::Interrupt,
prepare_fast_paths: bool,
) -> Option {
+ // Every dispatch path needs a positive step, including Motion Blur's fallback.
+ let tile = tile.max(1);
if ctl.cancelled() {
return None;
}
diff --git a/crates/algo/src/panorama.rs b/crates/algo/src/panorama.rs
index abe17db395..75fa9a4bfb 100644
--- a/crates/algo/src/panorama.rs
+++ b/crates/algo/src/panorama.rs
@@ -1170,10 +1170,28 @@ fn band_row(band: &PyramidBand, l: usize, ch: usize, py: usize) -> Option<(usize
})
}
+/// Estimated bytes of band construction [`multiband`] keeps in flight at once. Images are built
+/// and accumulated in batches under it, so its peak memory doesn't grow with the image count
+/// (#7758). Four 24 MP RGB sources (~1.2 GB each) fit in one batch: building bands side by side
+/// uses a many-core machine better than one band's row-parallel work, and one at a time made the
+/// usual 2–4 image Photomerge 12–36% slower on a 32-thread desktop.
+#[cfg(not(target_arch = "wasm32"))]
+const BAND_BUDGET: usize = 5 << 30;
+/// Without threads the bands of a batch are built one after another anyway, so each is added as
+/// soon as it is built (the browser's address space is 4 GB).
+#[cfg(target_arch = "wasm32")]
+const BAND_BUDGET: usize = 0;
+
/// Multi-band blend of `imgs` into a `w × h` panorama (origin 0,0) with one weight map per image
/// over the same grid as its ROI (`weights[i]` has `w_i × h_i` entries). Returns the colour
/// channels (`ch` per pixel) and the total coverage.
pub fn multiband(w: usize, h: usize, imgs: &[RoiImage], weights: &[Vec], levels: usize) -> (Vec, Vec) {
+ multiband_batched(w, h, imgs, weights, levels, BAND_BUDGET)
+}
+
+/// [`multiband`], building at most `budget` estimated bytes of bands at a time (at least one
+/// image). Each pixel still adds the bands in image order, so the result doesn't depend on it.
+fn multiband_batched(w: usize, h: usize, imgs: &[RoiImage], weights: &[Vec], levels: usize, budget: usize) -> (Vec, Vec) {
let ch = imgs.first().map_or(1, |i| i.ch);
let levels = levels.max(1);
let align = 1i32 << (levels - 1);
@@ -1187,13 +1205,22 @@ pub fn multiband(w: usize, h: usize, imgs: &[RoiImage], weights: &[Vec], le
let mut wsum: Vec> = sizes.iter().map(|&(lw, lh)| vec![0.0; lw * lh]).collect();
let margin = align * 4;
// Each image on its ROI grown by a margin and aligned to the coarsest level's grid.
- let bands = par_map(imgs.len(), |k| {
- let im = &imgs[k];
+ let grid = |im: &RoiImage| {
let gx0 = ((im.x0 - margin).max(0) / align) * align;
let gy0 = ((im.y0 - margin).max(0) / align) * align;
let gx1 = (im.x0 + im.w as i32 + margin).min(w as i32);
let gy1 = (im.y0 + im.h as i32 + margin).min(h as i32);
- let (gw, gh) = ((gx1 - gx0).max(1) as usize, (gy1 - gy0).max(1) as usize);
+ (gx0, gy0, (gx1 - gx0).max(1) as usize, (gy1 - gy0).max(1) as usize)
+ };
+ // Building one band holds its grid's colour and weight planes about three times over: the
+ // pyramids are 4/3 of a plane, and the grid copy, coverage and `expand` scratch come on top.
+ let cost = |im: &RoiImage| {
+ let (_, _, gw, gh) = grid(im);
+ gw.saturating_mul(gh).saturating_mul(ch + 1).saturating_mul(4 * 3)
+ };
+ let band = |k: usize| -> PyramidBand {
+ let im = &imgs[k];
+ let (gx0, gy0, gw, gh) = grid(im);
// Each grid pixel takes the image pixel that lands on it (every image pixel lands on at
// most one), so rows of the grid are independent and fill in parallel.
let src_of = |gx: usize, gy: usize| -> Option {
@@ -1225,68 +1252,85 @@ pub fn multiband(w: usize, h: usize, imgs: &[RoiImage], weights: &[Vec], le
}
});
let bin: Vec = par_map1(&cov, |a| if a > 0.5 { 1.0 } else { 0.0 });
+ drop(cov);
push_pull(gw, gh, ch, &mut px, &bin);
- // Laplacian of the colours, Gaussian of the weights.
+ drop(bin);
+ // Laplacian of the colours, Gaussian of the weights. Planes move into the pyramids rather
+ // than being copied: with one image built at a time, a copy is a serial pass over it.
let mut lap: Vec<(usize, usize, Vec)> = Vec::new();
let mut gau: Vec> = Vec::new();
let (mut cw, mut chh, mut cur, mut cwt) = (gw, gh, px, wt);
for l in 0..levels {
if l + 1 == levels {
- lap.push((cw, chh, cur.clone()));
- gau.push(cwt.clone());
+ lap.push((cw, chh, cur));
+ gau.push(cwt);
break;
}
let (nw, nh, down) = reduce(cw, chh, ch, &cur);
let up = expand(nw, nh, ch, &down, cw, chh);
lap.push((cw, chh, par_zip(&cur, &up, |a, b| a - b)));
- gau.push(cwt.clone());
let (_, _, wdown) = reduce(cw, chh, 1, &cwt);
+ gau.push(cwt);
cw = nw;
chh = nh;
cur = down;
cwt = wdown;
}
(gx0, gy0, lap, gau)
- });
- // Accumulate the bands. Each pixel adds the bands in the same order as the serial loop (per
- // level, bands one after another), but rows of a level are independent, so they run in
- // parallel: every row walks the bands itself.
- for l in 0..levels {
- let pw = sizes[l].0;
- par_rows(&mut wsum[l], pw, 1, |py, row| {
- for b in &bands {
- let Some((ox, lw, g, _)) = band_row(b, l, ch, py) else { continue };
- for x in 0..lw {
- let px = ox + x;
- if px >= pw {
- break;
- }
- let k = g[x];
- if k <= 0.0 {
- continue;
- }
- row[px] += k;
- }
- }
- });
- par_rows(&mut acc[l], pw, ch, |py, row| {
- for b in &bands {
- let Some((ox, lw, g, band)) = band_row(b, l, ch, py) else { continue };
- for x in 0..lw {
- let px = ox + x;
- if px >= pw {
- break;
- }
- let k = g[x];
- if k <= 0.0 {
- continue;
- }
- for c in 0..ch {
- row[px * ch + c] += k * band[x * ch + c];
+ };
+ // Build the bands a batch at a time and add each batch before building the next. Each pixel
+ // adds the bands in the same order as the serial loop (per level, bands one after another;
+ // batches follow each other in image order), but rows of a level are independent, so they
+ // run in parallel: every row walks the batch's bands itself.
+ let mut start = 0;
+ while start < imgs.len() {
+ let mut end = start + 1;
+ let mut bytes = cost(&imgs[start]);
+ while let Some(im) = imgs.get(end)
+ && bytes.saturating_add(cost(im)) <= budget
+ {
+ bytes = bytes.saturating_add(cost(im));
+ end += 1;
+ }
+ let bands = par_map(end - start, |j| band(start + j));
+ for l in 0..levels {
+ let pw = sizes[l].0;
+ par_rows(&mut wsum[l], pw, 1, |py, row| {
+ for b in &bands {
+ let Some((ox, lw, g, _)) = band_row(b, l, ch, py) else { continue };
+ for x in 0..lw {
+ let px = ox + x;
+ if px >= pw {
+ break;
+ }
+ let k = g[x];
+ if k <= 0.0 {
+ continue;
+ }
+ row[px] += k;
}
}
- }
- });
+ });
+ par_rows(&mut acc[l], pw, ch, |py, row| {
+ for b in &bands {
+ let Some((ox, lw, g, band)) = band_row(b, l, ch, py) else { continue };
+ for x in 0..lw {
+ let px = ox + x;
+ if px >= pw {
+ break;
+ }
+ let k = g[x];
+ if k <= 0.0 {
+ continue;
+ }
+ for c in 0..ch {
+ row[px * ch + c] += k * band[x * ch + c];
+ }
+ }
+ }
+ });
+ }
+ start = end;
}
for l in 0..levels {
let pw = sizes[l].0;
@@ -1910,6 +1954,28 @@ mod parallel_tests {
assert_eq!(multiband(9, 7, &[], &[], 3).0.len(), ref_multiband(9, 7, &[], &[], 3).0.len());
}
+ /// Batching the bands (#7758) changes when they are built, not the result. Budgets from none
+ /// (one image per batch) up to everything in steps of a fifth pass through every split of the
+ /// three images.
+ #[test]
+ fn multiband_batches_match_the_serial_code() {
+ let budgets: Vec =
+ std::iter::successors(Some(1024.0f64), |b| Some(b * 1.2)).take_while(|b| *b < 4e6).map(|b| b as usize).chain([0, usize::MAX]).collect();
+ for (n, &(w, h)) in [(40usize, 30usize), (97, 61), (201, 129)].iter().enumerate() {
+ for ch in [1usize, 3] {
+ let (imgs, wts) = rois(w, h, ch, n as u64 * 17 + ch as u64);
+ for levels in [1usize, 3, 5] {
+ let want = ref_multiband(w, h, &imgs, &wts, levels);
+ for &budget in &budgets {
+ let got = multiband_batched(w, h, &imgs, &wts, levels, budget);
+ assert_eq!(bits(&got.0), bits(&want.0), "colours {w}x{h}x{ch} levels {levels} budget {budget}");
+ assert_eq!(bits(&got.1), bits(&want.1), "coverage {w}x{h}x{ch} levels {levels} budget {budget}");
+ }
+ }
+ }
+ }
+ }
+
/// Hashes recorded from the code before it was made parallel (plain f32 arithmetic only, so
/// they hold on every platform).
#[test]
diff --git a/crates/algo/src/params_ext.rs b/crates/algo/src/params_ext.rs
index 3a9572fb46..2349604196 100644
--- a/crates/algo/src/params_ext.rs
+++ b/crates/algo/src/params_ext.rs
@@ -182,6 +182,9 @@ pub enum DepthSource {
None,
Transparency,
LayerMask,
+ /// An alpha channel of the document (Photoshop lists them after Layer Mask), supplied by the
+ /// caller like the layer mask.
+ Channel,
}
/// Built-in Shape Blur kernels.
diff --git a/crates/algo/src/photo_util.rs b/crates/algo/src/photo_util.rs
index bf65bc4459..7316088079 100644
--- a/crates/algo/src/photo_util.rs
+++ b/crates/algo/src/photo_util.rs
@@ -159,15 +159,21 @@ pub(crate) fn par_map(n: usize, f: impl Fn(usize) -> T + Sync + Send) -
(0..n).map(f).collect()
}
+/// Advances `state` one SplitMix64 step and returns the mixed output.
+pub(crate) fn splitmix64(state: &mut u64) -> u64 {
+ *state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
+ let mut z = *state;
+ z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
+ z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
+ z ^ (z >> 31)
+}
+
/// Deterministic 64-bit generator (SplitMix64).
pub(crate) struct Rng(pub u64);
impl Rng {
+ /// The next value of the SplitMix64 sequence.
pub fn next(&mut self) -> u64 {
- self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
- let mut z = self.0;
- z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
- z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
- z ^ (z >> 31)
+ splitmix64(&mut self.0)
}
pub fn below(&mut self, n: usize) -> usize {
(self.next() % n.max(1) as u64) as usize
@@ -183,15 +189,7 @@ pub(crate) fn hash01(x: i64, y: i64, seed: u64) -> f32 {
(z >> 40) as f32 / (1u64 << 24) as f32
}
-/// sRGB transfer function, decode (extended linearly below 0 and as a power above 1).
-pub(crate) fn srgb_to_linear(v: f32) -> f32 {
- if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }
-}
-
-/// sRGB transfer function, encode.
-pub(crate) fn linear_to_srgb(v: f32) -> f32 {
- if v <= 0.003_130_8 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 }
-}
+pub(crate) use photocraft_color::convert::{linear_to_srgb, srgb_to_linear};
/// Bilinear sample of channel `c` of a `w × h × ch` buffer, clamped to the edges.
pub(crate) fn bilinear(buf: &[f32], w: usize, h: usize, ch: usize, c: usize, x: f32, y: f32) -> f32 {
@@ -217,6 +215,41 @@ pub(crate) fn catmull_rom(t: f32) -> [f32; 4] {
mod tests {
use super::*;
+ /// The shared step and [`Rng`] reproduce the inlined copies they replaced, bit for bit.
+ #[test]
+ fn splitmix64_output_is_unchanged() {
+ /// The SplitMix64 step as each generator inlined it before.
+ fn inlined(state: &mut u64) -> u64 {
+ *state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
+ let mut z = *state;
+ z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
+ z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
+ z ^ (z >> 31)
+ }
+ let mut zero = 0;
+ assert_eq!([0; 3].map(|_| splitmix64(&mut zero)), [0xE220_A839_7B1D_CDAF, 0x6E78_9E6A_A1B9_65F4, 0x06C4_5D18_8009_454F]);
+ for seed in [0, 1, 42, 0x2545_F491_4F6C_DD1D, u64::MAX] {
+ let (mut shared, mut copy, mut rng) = (seed, seed, Rng(seed));
+ for _ in 0..10_000 {
+ let want = inlined(&mut copy);
+ assert_eq!(splitmix64(&mut shared), want);
+ assert_eq!(rng.next(), want);
+ }
+ }
+ }
+
+ /// The shared sRGB functions match the removed copies bit for bit.
+ #[test]
+ fn srgb_transfer_output_is_unchanged() {
+ let decode = |v: f32| if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) };
+ let encode = |v: f32| if v <= 0.003_130_8 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 };
+ for i in -2000..=6000 {
+ let v = i as f32 / 4000.0;
+ assert_eq!(srgb_to_linear(v).to_bits(), decode(v).to_bits(), "decode {v}");
+ assert_eq!(linear_to_srgb(v).to_bits(), encode(v).to_bits(), "encode {v}");
+ }
+ }
+
#[test]
fn dense_and_sparse_solvers_agree() {
// Overdetermined line fit y = 2x + 1.
diff --git a/crates/algo/src/render2.rs b/crates/algo/src/render2.rs
index ed6567ca5c..19a4b8218d 100644
--- a/crates/algo/src/render2.rs
+++ b/crates/algo/src/render2.rs
@@ -274,15 +274,13 @@ pub fn composite(surface: &mut Surface, prims: &[Prim], clip: Rect, selection: O
pub struct Rng(u64);
impl Rng {
+ /// A generator whose sequence is fixed by `seed` and `salt`.
pub fn new(seed: u32, salt: u64) -> Rng {
Rng(((seed as u64) << 32) ^ salt.wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ 0x2545_F491_4F6C_DD1D)
}
+ /// The next value of the SplitMix64 sequence.
pub fn next_u64(&mut self) -> u64 {
- self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
- let mut z = self.0;
- z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
- z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
- z ^ (z >> 31)
+ crate::photo_util::splitmix64(&mut self.0)
}
/// Uniform in [0, 1).
pub fn f(&mut self) -> f32 {
diff --git a/crates/algo/src/retouch.rs b/crates/algo/src/retouch.rs
index f7c1c18557..00388fe399 100644
--- a/crates/algo/src/retouch.rs
+++ b/crates/algo/src/retouch.rs
@@ -124,12 +124,13 @@ pub fn local_blur(data: &[f32], w: usize, h: usize, ch: usize, alpha: Option = (-r..=r).map(|i| (-((i * i) as f32) / (2.0 * sigma * sigma).max(1e-6)).exp()).collect();
let ks: f32 = k.iter().sum();
let k: Vec = k.into_iter().map(|v| v / ks).collect();
- // Horizontal pass over the window rows extended vertically by r.
+ // Horizontal pass over the window rows extended vertically by r. Rows are independent, so
+ // both passes run them in parallel: a 2500 px Blur dab is ~10⁹ multiply-adds (#5426).
let th = oh + 2 * r as usize;
let stride = ow * ch;
let mut tmp = vec![0.0f32; stride * th];
- let mut row = vec![0.0f32; (ow + 2 * r as usize) * ch];
- for ty in 0..th {
+ let scratch = || vec![0.0f32; (ow + 2 * r as usize) * ch];
+ let horizontal = |row: &mut Vec, (ty, dst): (usize, &mut [f32])| {
let y = (y0 as i32 + ty as i32 - r).clamp(0, h as i32 - 1) as usize;
// Clamp and premultiply each source pixel once, rather than once for every kernel tap.
for (rx, px) in row.chunks_exact_mut(ch).enumerate() {
@@ -145,7 +146,6 @@ pub fn local_blur(data: &[f32], w: usize, h: usize, ch: usize, alpha: Option 1e-6 { out[o + c] / av } else { data[((y0 + oy) * w + x0 + ox) * ch + c] };
+ *v = if av > 1e-6 { *v / av } else { data[((y0 + oy) * w + x0 + ox) * ch + c] };
}
}
}
}
+ };
+ #[cfg(not(target_arch = "wasm32"))]
+ {
+ use rayon::prelude::*;
+ out.par_chunks_mut(stride).enumerate().for_each(vertical);
+ }
+ #[cfg(target_arch = "wasm32")]
+ {
+ out.chunks_mut(stride).enumerate().for_each(vertical);
}
out
}
diff --git a/crates/algo/src/segment/grabcut.rs b/crates/algo/src/segment/grabcut.rs
index 573c09079e..e1c1d89de0 100644
--- a/crates/algo/src/segment/grabcut.rs
+++ b/crates/algo/src/segment/grabcut.rs
@@ -126,7 +126,9 @@ pub fn object_select(sampler: &dyn Sampler, canvas: Rect, rect: Rect, work_px: u
}
let (fg, bg) = grabcut(&img, &mut trimap, 8)?;
let low: Vec = trimap.iter().map(|t| is_fg(*t)).collect();
- let low = super::clean_mask(&low, lw, lh, 0.1, 0.002);
+ let low = super::clean_mask(&low, lw, lh, 0.1, 0.0);
+ // Every enclosed hole, however small, fills only when it looks like the object, so background seen through it stays open.
+ let low = super::fill_holes_by_model(&low, &img, &fg, &bg, 0.0, 0.05);
super::finish_region(sampler, window, step, &low, lw, lh, Some((&fg, &bg)))
}
@@ -209,7 +211,9 @@ pub fn object_at(sampler: &dyn Sampler, canvas: Rect, x: f32, y: f32, work_px: u
let mut seeds = vec![false; w * h];
seeds[sy * w + sx] = true;
let low = super::keep_seeded(&low, &seeds, w, h);
- let low = super::clean_mask(&low, w, h, 0.1, 0.01);
+ let low = super::clean_mask(&low, w, h, 0.1, 0.0);
+ // As in the rectangle mode: enclosed holes fill only when they look like the object.
+ let low = super::fill_holes_by_model(&low, &img, &fg, &bg, 0.0, 0.05);
if !low.iter().any(|v| *v) {
return None;
}
diff --git a/crates/algo/src/segment/tests.rs b/crates/algo/src/segment/tests.rs
index 743c3dc460..e060913c9b 100644
--- a/crates/algo/src/segment/tests.rs
+++ b/crates/algo/src/segment/tests.rs
@@ -912,3 +912,61 @@ fn select_subject_dump_pictures() {
write("ring", &img, None);
write("ring-selected", &img, reg.as_ref());
}
+
+/// A dark square on a light background with a square hole that shows the background colour.
+fn square_with_hole(hole: usize) -> RgbImage {
+ let (w, h) = (400usize, 300usize);
+ let mut rng = Rng::new(11);
+ let (x0, y0) = (100usize, 50usize);
+ RgbImage::from_fn(w, h, |x, y| {
+ let n = 0.02 * rng.normal();
+ let in_square = x >= x0 && x < x0 + 200 && y >= y0 && y < y0 + 200;
+ let in_hole = x >= x0 + 96 && x < x0 + 96 + hole && y >= y0 + 96 && y < y0 + 96 + hole;
+ let c = if in_square && !in_hole { [0.1, 0.12, 0.15] } else { [0.9, 0.9, 0.85] };
+ c.map(|v: f32| (v + n).clamp(0.0, 1.0))
+ })
+}
+
+#[test]
+fn object_select_keeps_small_background_hole_open() {
+ let img = square_with_hole(8);
+ let s = ImageSampler { img: &img, origin: (0, 0) };
+ let canvas = Rect::new(0, 0, 400, 300);
+ let reg = grabcut::object_select(&s, canvas, Rect::new(80, 30, 320, 270), 1_000_000).unwrap();
+ assert!(reg.at(150, 100) >= 0.5, "the square itself is selected");
+ assert!(reg.at(200, 150) < 0.5, "background seen through the object stays unselected");
+}
+
+#[test]
+fn object_at_keeps_small_background_hole_open() {
+ let img = square_with_hole(16);
+ let s = ImageSampler { img: &img, origin: (0, 0) };
+ let canvas = Rect::new(0, 0, 400, 300);
+ let reg = grabcut::object_at(&s, canvas, 130.0, 80.0, 1_000_000).unwrap();
+ assert!(reg.at(150, 100) >= 0.5, "the square itself is selected");
+ assert!(reg.at(203, 153) < 0.5, "background seen through the object stays unselected");
+}
+
+#[test]
+fn hole_fill_without_specks_fills_object_coloured_holes_only() {
+ // The hole check Object Selection uses: no hole fills just for being small, a speck of the object's colour still does.
+ let (w, h) = (40usize, 40usize);
+ let mut rng = Rng::new(5);
+ let in_square = |x: usize, y: usize| (5..35).contains(&x) && (5..35).contains(&y);
+ let bg_hole = |x: usize, y: usize| (10..12).contains(&x) && (10..12).contains(&y);
+ let fg_hole = |x: usize, y: usize| (25..27).contains(&x) && (25..27).contains(&y);
+ let img = RgbImage::from_fn(w, h, |x, y| {
+ let n = 0.02 * rng.normal();
+ let c = if in_square(x, y) && !bg_hole(x, y) { [0.1, 0.12, 0.15] } else { [0.9, 0.9, 0.85] };
+ c.map(|v: f32| (v + n).clamp(0.0, 1.0))
+ });
+ let mask: Vec = (0..w * h).map(|i| in_square(i % w, i / w) && !bg_hole(i % w, i / w) && !fg_hole(i % w, i / w)).collect();
+ // The colour models, as GrabCut would fit them: the object colour vs. the backdrop colour.
+ let dark: Vec = (0..w * h).map(|i| in_square(i % w, i / w) && !bg_hole(i % w, i / w)).collect();
+ let (fs, bs): (Vec<_>, Vec<_>) = img.px.iter().zip(&dark).partition(|(_, m)| **m);
+ let fit = |v: Vec<(&[f32; 3], &bool)>| Gmm::fit(&v.iter().map(|(p, _)| **p).collect::>(), 3, gmm::DEFAULT_REG).unwrap();
+ let (fg, bg) = (fit(fs), fit(bs));
+ let out = fill_holes_by_model(&mask, &img, &fg, &bg, 0.0, 0.05);
+ assert!(out[25 * w + 25], "a speck of the object's colour fills");
+ assert!(!out[10 * w + 10], "a speck of the background's colour stays open");
+}
diff --git a/crates/algo/src/tests.rs b/crates/algo/src/tests.rs
index 148a0723ec..b7fc92113c 100644
--- a/crates/algo/src/tests.rs
+++ b/crates/algo/src/tests.rs
@@ -90,6 +90,27 @@ fn radial_blur_quality_defaults_for_older_serialized_parameters() {
assert_eq!(decoded, FilterParams::RadialBlur { amount: 20.0, method: RadialMethod::Spin, quality: RadialQuality::Good, center_x: 0.5, center_y: 0.5 });
}
+#[test]
+fn nonpositive_tile_sizes_match_one_pixel_tiles() {
+ let area = Rect::new(0, 0, 4, 3);
+ for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
+ let source = pattern(depth, area);
+ for params in all_filters().into_iter().chain([FilterParams::ShapeBlur { radius: 3.0, shape: BlurShape::Star }]) {
+ let expected = apply_tiled(&source, ¶ms, area, area, None, 1, Some(area));
+ for tile in [0, -64, i32::MIN] {
+ let actual = apply_tiled(&source, ¶ms, area, area, None, tile, Some(area));
+ assert_eq!(actual.read_region(area), expected.read_region(area), "{}: {depth:?}, tile={tile}", params.label());
+ let cancellable = apply_tiled_with(&source, ¶ms, area, area, None, tile, Some(area), &photocraft_raster::Interrupt::NONE)
+ .expect("a nonpositive tile size is normalized, not rejected");
+ assert_eq!(cancellable.read_region(area), expected.read_region(area), "{}: {depth:?}, tile={tile}", params.label());
+ let cancelled = photocraft_raster::Interrupt::cancel_only(&|| true);
+ assert!(apply_tiled_with(&source, ¶ms, area, area, None, tile, Some(area), &cancelled).is_none());
+ }
+ }
+ assert_eq!(source.read_region(area), pattern(depth, area).read_region(area));
+ }
+}
+
#[test]
fn every_filter_is_tile_independent() {
let s = pattern(SampleType::U8, R);
@@ -248,6 +269,32 @@ fn minimum_maximum_spread() {
assert!((mn.pixel(10, 10)[0] - 0.5).abs() < 1e-6);
}
+/// CMYK samples are ink, so Maximum spreads paper white and Minimum spreads ink, as Photoshop
+/// does: Maximum r 1 removes a 1-px C 100% line on white, Minimum widens it to 3 px. Alpha keeps
+/// the usual polarity (Maximum grows opaque areas, Minimum grows transparent ones).
+#[test]
+fn minimum_maximum_treat_cmyk_as_ink() {
+ for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
+ let mut s = Surface::new(PixelFormat::new(ColorMode::Cmyk, depth, true));
+ s.fill_rect(R, &[0.0, 0.0, 0.0, 0.0, 1.0]);
+ s.fill_rect(Rect::new(20, 0, 21, 30), &[1.0, 0.0, 0.0, 0.0, 1.0]);
+ s.write_pixel(5, 5, &[0.0, 0.0, 0.0, 0.0, 0.0]);
+ for preserve in [Preserve::Squareness, Preserve::Roundness] {
+ let mx = run(&s, &FilterParams::Maximum { radius: 1.0, preserve });
+ for x in 17..24 {
+ assert!(mx.pixel(x, 15)[0].abs() < 1e-6, "{depth:?} {preserve:?} Maximum C at x {x}: {:?}", mx.pixel(x, 15));
+ }
+ assert!((mx.pixel(5, 5)[4] - 1.0).abs() < 1e-6, "{depth:?} {preserve:?} Maximum grows alpha");
+ let mn = run(&s, &FilterParams::Minimum { radius: 1.0, preserve });
+ for x in 17..24 {
+ let want = if (19..=21).contains(&x) { 1.0 } else { 0.0 };
+ assert!((mn.pixel(x, 15)[0] - want).abs() < 1e-6, "{depth:?} {preserve:?} Minimum C at x {x}: {:?}", mn.pixel(x, 15));
+ }
+ assert!(mn.pixel(6, 5)[4].abs() < 1e-6, "{depth:?} {preserve:?} Minimum shrinks alpha");
+ }
+ }
+}
+
#[test]
fn maximum_roundness_grows_a_point_into_a_disc() {
// One white pixel on black: Maximum spreads it over the structuring element itself.
diff --git a/crates/algo/src/tone.rs b/crates/algo/src/tone.rs
index 2632d4a29a..5a0f752fac 100644
--- a/crates/algo/src/tone.rs
+++ b/crates/algo/src/tone.rs
@@ -65,6 +65,35 @@ pub fn replace_color(px: &mut [[f32; 4]], color: [f32; 3], fuzziness: f32, hue:
mask
}
+/// Replace Color's Result swatch: `color` after the hue (±180°), saturation and lightness (±100)
+/// shift, as a fully matched pixel of that colour becomes.
+pub fn replace_color_result(color: [f32; 3], hue: f32, saturation: f32, lightness: f32) -> [f32; 3] {
+ let mut px = [[color[0], color[1], color[2], 1.0]];
+ replace_color_masked(&mut px, &[1.0], hue, saturation, lightness);
+ [px[0][0], px[0][1], px[0][2]]
+}
+
+/// The hue (±180°), saturation and lightness (±100) shift that turns `from` into `to`, as
+/// Photoshop's Result Color Picker sets the sliders. A shift the sliders can't reach (a grey has
+/// no hue to turn, black or white no saturation to give) goes as far as they do.
+pub fn replace_color_shift(from: [f32; 3], to: [f32; 3]) -> (f32, f32, f32) {
+ let [fh, fs, fl] = rgb_to_hsl(from);
+ let [th, ts, tl] = rgb_to_hsl(to);
+ // Hue turns the short way round; a grey target keeps the hue where it is.
+ let hue = if ts <= 0.0 { 0.0 } else { ((th - fh + 0.5).rem_euclid(1.0) - 0.5) * 360.0 };
+ // Inverse of the shift in `replace_color_masked`: up moves toward 1, down toward 0.
+ let amount = |from: f32, to: f32| {
+ if to >= from {
+ if from >= 1.0 { 0.0 } else { (to - from) / (1.0 - from) * 100.0 }
+ } else if from <= 0.0 {
+ 0.0
+ } else {
+ (to / from - 1.0) * 100.0
+ }
+ };
+ (hue.clamp(-180.0, 180.0), amount(fs, ts).clamp(-100.0, 100.0), amount(fl, tl).clamp(-100.0, 100.0))
+}
+
/// Replace Color's hue, saturation and lightness shift, applied to each pixel as far as `mask`
/// (`0..=1`, one per pixel: the colour match, localized or not) covers it.
pub fn replace_color_masked(px: &mut [[f32; 4]], mask: &[f32], hue: f32, saturation: f32, lightness: f32) {
@@ -277,6 +306,28 @@ pub fn hdr_toning(px: &mut [[f32; 4]], w: usize, h: usize, p: &HdrToning) {
mod tests {
use super::*;
+ /// #6278: the Result swatch is the sampled colour after the shift, and a picked Result sets
+ /// the sliders that give it.
+ #[test]
+ fn replace_color_result_and_shift_round_trip() {
+ let from = [0.8, 0.2, 0.2];
+ let near = |a: [f32; 3], b: [f32; 3]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 2e-3);
+ assert!(near(replace_color_result(from, 0.0, 0.0, 0.0), from), "no shift, no change");
+ for to in [[0.2, 0.3, 0.9], [0.9, 0.85, 0.1], [0.4, 0.1, 0.1], [0.95, 0.6, 0.6], [0.1, 0.6, 0.3]] {
+ let (h, s, l) = replace_color_shift(from, to);
+ assert!((-180.0..=180.0).contains(&h) && (-100.0..=100.0).contains(&s) && (-100.0..=100.0).contains(&l));
+ assert!(near(replace_color_result(from, h, s, l), to), "{to:?}: ({h}, {s}, {l}) gives {:?}", replace_color_result(from, h, s, l));
+ }
+ // Red to blue turns the short way (−120°, not +240°).
+ assert!((replace_color_shift([1.0, 0.0, 0.0], [0.0, 0.0, 1.0]).0 + 120.0).abs() < 0.5);
+ // A grey target: saturation all the way down, the hue left alone.
+ let (h, s, _) = replace_color_shift(from, [0.5, 0.5, 0.5]);
+ assert_eq!((h, s), (0.0, -100.0));
+ // Black has no saturation to raise: the slider stays put rather than dividing by zero.
+ let (h, s, l) = replace_color_shift([0.0, 0.0, 0.0], [0.2, 0.6, 0.3]);
+ assert!(h.is_finite() && s.is_finite() && l > 0.0);
+ }
+
fn ramp(w: usize, h: usize) -> Vec<[f32; 4]> {
(0..w * h)
.map(|i| {
diff --git a/crates/algo/src/transform.rs b/crates/algo/src/transform.rs
index 3e67f1cdee..91260bd285 100644
--- a/crates/algo/src/transform.rs
+++ b/crates/algo/src/transform.rs
@@ -333,7 +333,12 @@ fn warp(
// A source straddling the horizon draws only its front side: the inverse w at a pixel has the
// sign of the forward w at the source point it samples, and the other side would show as a
// mirrored copy wrapped round infinity (#4863).
- let front = side.is_none().then(|| h.front_side([src_rect.x0 as f64, src_rect.y0 as f64, src_rect.x1 as f64, src_rect.y1 as f64]));
+ // A clip wholly on one side of the inverse horizon only samples that side, so that side is drawn
+ // (a Perspective Crop low on a converging photo has most of the layer beyond its horizon, #7557).
+ let clip_w = clip.map(|c| [(c.x0, c.y0), (c.x1, c.y0), (c.x1, c.y1), (c.x0, c.y1)].map(|(x, y)| inv.w(x as f64, y as f64)));
+ let clip_side = clip_w.and_then(|cw| [1.0, -1.0].into_iter().find(|&s| cw.iter().all(|&w| s * w > 0.0)));
+ let front =
+ side.is_none().then(|| clip_side.unwrap_or_else(|| h.front_side([src_rect.x0 as f64, src_rect.y0 as f64, src_rect.x1 as f64, src_rect.y1 as f64])));
let bounds = corner_bounds(&corners);
let dst = match (clip, bounds) {
// The warped corners only bound the result while the whole source is in front of the horizon.
@@ -713,6 +718,22 @@ mod tests {
assert_eq!(ok.front_side([0.0, 0.0, 100.0, 100.0]), ok.w(50.0, 50.0).signum());
}
+ /// #7557: a Perspective Crop quad low on a strongly converging photo puts the horizon across
+ /// the layer with most of it (and its centre) on the far side. The clip maps wholly into the
+ /// quad, so its side is the one drawn, not the source's majority.
+ #[test]
+ fn a_clip_wholly_on_one_side_draws_that_side() {
+ let mut s = rgba();
+ s.fill_rect(Rect::new(0, 0, 100, 100), &[1.0, 0.0, 0.0, 1.0]);
+ let q = [[40.0, 70.0], [60.0, 70.0], [90.0, 95.0], [10.0, 95.0]];
+ let h = quad_to_rect(&q, 50.0, 39.0).unwrap();
+ assert!(h.front_side([0.0, 0.0, 100.0, 100.0]) * h.w(50.0, 80.0) < 0.0, "the source's majority is behind the quad's side");
+ for interp in [Interp::Nearest, Interp::Bilinear, Interp::Bicubic] {
+ let w = warp_surface_clipped(&s, s.content_bounds(), &h, interp, Rect::new(0, 0, 50, 39)).unwrap();
+ assert_eq!(w.rgba(25, 20), [1.0, 0.0, 0.0, 1.0], "{interp:?}");
+ }
+ }
+
#[test]
fn identity_and_translation_are_exact() {
let mut s = rgba();
diff --git a/crates/algo/src/warp.rs b/crates/algo/src/warp.rs
index 188de07d47..54a6c7c868 100644
--- a/crates/algo/src/warp.rs
+++ b/crates/algo/src/warp.rs
@@ -14,8 +14,9 @@
//! later (lower-right) cell wins. Work is split per destination tile (parallel on native).
use photocraft_color::PixelFormat;
-use photocraft_geom::Rect;
-use photocraft_raster::Surface;
+use photocraft_geom::{Rect, TileCoord};
+use photocraft_raster::{Surface, Tile};
+use std::sync::Arc;
use crate::transform::{Interp, bicubic};
@@ -207,7 +208,7 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2
}
let n = fmt.channels();
let a = n - 1;
- let work = |(t, tris): (&Rect, &Vec<[usize; 3]>)| -> Option<(Rect, Vec)> {
+ let work = |(t, tris): (&Rect, &Vec<[usize; 3]>)| -> Option<(TileCoord, Arc)> {
if tris.is_empty() {
return None;
}
@@ -296,6 +297,11 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2
f64::from(px[((y - foot.y0) as usize * fw + (x - foot.x0) as usize) * n + c])
}
};
+ // Offset of pixel (x, y) when the `taps` x `taps` block from it lies inside `foot`, so
+ // the samplers can index `px` directly instead of bounds-checking every tap.
+ let base = |x: i32, y: i32, taps: i32| -> Option {
+ (x >= foot.x0 && y >= foot.y0 && x + taps <= foot.x1 && y + taps <= foot.y1).then(|| ((y - foot.y0) as usize * fw + (x - foot.x0) as usize) * n)
+ };
let mut outp = vec![0.0f32; w * h * n];
let mut any = false;
let mut acc = [0.0f64; 8];
@@ -313,6 +319,17 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2
Interp::Bilinear => {
let (ix, iy) = (u.floor() as i32, v.floor() as i32);
let (fx, fy) = (u - f64::from(ix), v - f64::from(iy));
+ if let Some(o) = base(ix, iy, 2) {
+ for (dy, wy) in [(0, 1.0 - fy), (1, fy)] {
+ for (dx, wx) in [(0, 1.0 - fx), (1, fx)] {
+ let p = &px[o + (dy * fw + dx) * n..][..n];
+ for (s, v) in acc.iter_mut().zip(p) {
+ *s += f64::from(*v) * wx * wy;
+ }
+ }
+ }
+ return;
+ }
for (dy, wy) in [(0, 1.0 - fy), (1, fy)] {
for (dx, wx) in [(0, 1.0 - fx), (1, fx)] {
for (c, s) in acc.iter_mut().enumerate().take(n) {
@@ -327,6 +344,21 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2
let (u, v) = (snap(u), snap(v));
let (ix, iy) = (u.floor() as i32, v.floor() as i32);
let (wx, wy) = (bicubic(u - f64::from(ix)), bicubic(v - f64::from(iy)));
+ if let Some(o) = base(ix - 1, iy - 1, 4) {
+ for (j, wyj) in wy.iter().enumerate() {
+ for (i, wxi) in wx.iter().enumerate() {
+ let k = wxi * wyj;
+ if k == 0.0 {
+ continue;
+ }
+ let p = &px[o + (j * fw + i) * n..][..n];
+ for (s, v) in acc.iter_mut().zip(p) {
+ *s += f64::from(*v) * k;
+ }
+ }
+ }
+ return;
+ }
for (j, wyj) in wy.iter().enumerate() {
for (i, wxi) in wx.iter().enumerate() {
let k = wxi * wyj;
@@ -378,19 +410,27 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2
}
outp[b + a] = al as f32;
}
- any.then_some((*t, outp))
+ if !any {
+ return None;
+ }
+ // Encoded (and dropped if fully transparent) here, in parallel, rather than one tile at
+ // a time once all are done. `t` lies in one tile.
+ let mut one = Surface::new(fmt);
+ one.write_region(*t, &outp);
+ one.prune();
+ if one.tile_count() == 0 {
+ return None;
+ }
+ one.take_tiles(*t).pop()
};
#[cfg(not(target_arch = "wasm32"))]
- let done: Vec<(Rect, Vec)> = {
+ let done: Vec<(TileCoord, Arc)> = {
use rayon::prelude::*;
tiles.par_iter().zip(bins.par_iter()).filter_map(work).collect()
};
#[cfg(target_arch = "wasm32")]
- let done: Vec<(Rect, Vec)> = tiles.iter().zip(bins.iter()).filter_map(work).collect();
- for (r, v) in done {
- out.write_region(r, &v);
- }
- out.prune();
+ let done: Vec<(TileCoord, Arc)> = tiles.iter().zip(bins.iter()).filter_map(work).collect();
+ out.put_tiles(done);
out
}
diff --git a/crates/automation/Cargo.toml b/crates/automation/Cargo.toml
index fb718e5668..f5e88b3591 100644
--- a/crates/automation/Cargo.toml
+++ b/crates/automation/Cargo.toml
@@ -30,3 +30,7 @@ cap-std = "4"
[dev-dependencies]
rmcp = { version = "3.5", default-features = false, features = ["client", "server", "macros", "transport-io"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-std", "sync", "net", "io-util", "time"] }
+
+# Reading back an extended attribute the write path should have preserved (#7095).
+[target.'cfg(unix)'.dev-dependencies]
+rustix = { version = "1.1", default-features = false, features = ["std", "fs"] }
diff --git a/crates/automation/src/files.rs b/crates/automation/src/files.rs
index cd34ab3410..fc4526eedb 100644
--- a/crates/automation/src/files.rs
+++ b/crates/automation/src/files.rs
@@ -19,7 +19,8 @@ pub struct Opened {
/// Decode a document that was read through a filesystem capability.
pub fn open_bytes(name: &str, bytes: &[u8]) -> Result {
if Path::new(name).extension().is_some_and(|extension| extension.eq_ignore_ascii_case(photocraft_format::EXTENSION)) {
- return Ok(Opened { document: photocraft_format::load_from_bytes(bytes)?, warnings: Vec::new(), source_read_only: false });
+ let document = photocraft_format::load_from_bytes(bytes)?;
+ return Ok(Opened { warnings: photocraft_io::open_warnings(&document), document, source_read_only: false });
}
let r = photocraft_io::import(name, bytes)?;
Ok(Opened { document: r.document, warnings: r.warnings, source_read_only: r.source_read_only })
@@ -29,7 +30,8 @@ pub fn open_bytes(name: &str, bytes: &[u8]) -> Result {
/// natively; everything else goes through `photocraft-io` (PSD, PNG, …).
pub fn open(path: &Path) -> Result {
if path.is_dir() {
- return Ok(Opened { document: photocraft_format::load_path(path)?, warnings: Vec::new(), source_read_only: false });
+ let document = photocraft_format::load_path(path)?;
+ return Ok(Opened { warnings: photocraft_io::open_warnings(&document), document, source_read_only: false });
}
let bytes = photocraft_format::read_file(path).map_err(|e| AutomationError::Io(format!("{}: {e}", path.display())))?;
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
diff --git a/crates/automation/src/rpc.rs b/crates/automation/src/rpc.rs
index 15a0e24e30..3db25c4fdd 100644
--- a/crates/automation/src/rpc.rs
+++ b/crates/automation/src/rpc.rs
@@ -114,6 +114,7 @@ impl Headless {
let mut opts =
photocraft_io::ExportOptions { tiff_layers: p.get("tiffLayers").and_then(Value::as_bool).unwrap_or(false), tga_bits, ..Default::default() };
opts.encode.dpx_bits = photocraft_engine::file_cmds::dpx_bits_param(&p, "doc.save").map_err(|e| bad(e.to_string()))?;
+ opts.encode.dds = photocraft_engine::file_cmds::dds_param(&p, "doc.save").map_err(|e| bad(e.to_string()))?.unwrap_or_default();
if let Some(q) = p.get("quality").and_then(Value::as_u64) {
opts.encode.jpeg_quality = q.clamp(1, 100) as u8;
opts.encode.webp_quality = q.clamp(1, 100) as u8;
diff --git a/crates/automation/src/security.rs b/crates/automation/src/security.rs
index 6198f22851..0735511f20 100644
--- a/crates/automation/src/security.rs
+++ b/crates/automation/src/security.rs
@@ -97,11 +97,15 @@ pub fn validate_token(token: &str) -> Result<(), AutomationError> {
}
/// Compare fixed-width tokens without an early exit on a mismatching byte.
+///
+/// Hex letters match in either case: [`validate_token`] accepts uppercase, and the server keeps
+/// the token lowercased, so a client sending the token exactly as it was configured must still
+/// authenticate (#6425). Only `A`–`Z` fold, so a non-hex byte never matches a hex digit.
pub fn token_matches(expected: &str, supplied: &str) -> bool {
if expected.len() != TOKEN_HEX_LEN || supplied.len() != TOKEN_HEX_LEN {
return false;
}
- expected.bytes().zip(supplied.bytes()).fold(0u8, |different, (a, b)| different | (a ^ b)) == 0
+ expected.bytes().zip(supplied.bytes()).fold(0u8, |different, (a, b)| different | (a.to_ascii_lowercase() ^ b.to_ascii_lowercase())) == 0
}
/// Validate the first TCP frame without exposing any control method before authentication.
@@ -368,6 +372,45 @@ mod tests {
assert_eq!(reply["result"]["authenticated"], true);
}
+ fn auth_line(token: &str) -> String {
+ json!({"id": 1, "method": AUTH_METHOD, "params": {"token": token}}).to_string()
+ }
+
+ /// #6425: `validate_token` accepts uppercase hex and the server keeps the token lowercased, so a
+ /// client sending the token exactly as it was configured was refused.
+ #[test]
+ fn a_token_authenticates_in_any_letter_case() {
+ let configured = "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF";
+ let mixed = "0123456789AbCdEf0123456789aBcDeF0123456789ABCDEF0123456789abcdef";
+ let expected = server_token(Some(configured), None).unwrap();
+ for supplied in [configured, &configured.to_ascii_lowercase(), mixed] {
+ let (reply, authenticated) = authentication_reply(&auth_line(supplied), &expected);
+ assert!(authenticated, "{supplied}: {reply}");
+ assert_eq!(reply["result"]["authenticated"], true);
+ }
+ // The comparison is case-insensitive in both directions.
+ assert!(token_matches(configured, &configured.to_ascii_lowercase()));
+
+ // A different token, or a non-hex character that only folds to a hex one, still fails.
+ let other = "1123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF";
+ let (_, authenticated) = authentication_reply(&auth_line(other), &expected);
+ assert!(!authenticated);
+ let not_hex = "0123456789ABCDEG0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF";
+ assert!(!token_matches(&expected, not_hex));
+ }
+
+ /// #6425: the same for a token file whose contents are uppercase.
+ #[test]
+ fn an_uppercase_token_file_authenticates_its_exact_contents() {
+ let path = std::env::temp_dir().join(format!("photocraft-control-token-upper-{}-{}.txt", std::process::id(), generate_token().unwrap()));
+ let contents = generate_token().unwrap().to_ascii_uppercase();
+ create_token_file(&path, &contents).unwrap();
+ let expected = server_token(None, Some(&path));
+ std::fs::remove_file(&path).unwrap();
+ let (reply, authenticated) = authentication_reply(&auth_line(&contents), &expected.unwrap());
+ assert!(authenticated, "{reply}");
+ }
+
#[test]
fn repeat_auth_on_an_authenticated_connection_says_so() {
let line = json!({"id": 3, "method": AUTH_METHOD, "params": {"token": "anything"}}).to_string();
@@ -437,16 +480,23 @@ mod tests {
#[test]
fn an_authenticated_connection_outlives_the_authentication_deadline() {
let (server, mut client) = tcp_pair();
+ // The test thread writes the first line before the deadline starts. The sender writes the
+ // second line once the connection has authenticated and the deadline has passed.
+ client.write_all(b"one\n").unwrap();
+ let limit = Duration::from_secs(1);
+ let mut reader = std::io::BufReader::new(AuthDeadlineStream::new(server, limit));
+ let deadline = Instant::now() + limit;
+ let (tx, rx) = std::sync::mpsc::channel();
let sender = std::thread::spawn(move || {
- client.write_all(b"one\n").unwrap();
- std::thread::sleep(Duration::from_millis(500));
+ rx.recv().unwrap();
+ std::thread::sleep((deadline + Duration::from_millis(300)).saturating_duration_since(Instant::now()));
client.write_all(b"two\n").unwrap();
});
- let mut reader = std::io::BufReader::new(AuthDeadlineStream::new(server, Duration::from_millis(200)));
let mut line = String::new();
assert_eq!(read_bounded_line(&mut reader, &mut line).unwrap(), LineRead::Line);
assert_eq!(line, "one\n");
reader.get_mut().authenticated().unwrap();
+ tx.send(()).unwrap();
// Sent 300 ms after the deadline: only the idle timeout applies now.
assert_eq!(read_bounded_line(&mut reader, &mut line).unwrap(), LineRead::Line);
assert_eq!(line, "two\n");
diff --git a/crates/automation/src/server.rs b/crates/automation/src/server.rs
index f52376a9c5..567d0b4837 100644
--- a/crates/automation/src/server.rs
+++ b/crates/automation/src/server.rs
@@ -170,6 +170,13 @@ pub struct SaveParams {
/// DPX: 8, 10, 12 or 16 bits per sample. Unset: 8 for an 8-bit document, else 16.
#[serde(default, rename = "dpxBits")]
pub dpx_bits: Option,
+ /// DDS: "none" (uncompressed, the default), "bc1" (DXT1: alpha on or off), "bc2" (DXT3) or
+ /// "bc3" (DXT5).
+ #[serde(default, rename = "ddsCompression")]
+ pub dds_compression: Option,
+ /// DDS: also write the full mip chain, each level a box filter of the one above.
+ #[serde(default, rename = "ddsMipmaps")]
+ pub dds_mipmaps: bool,
#[serde(default)]
pub index: Option,
}
@@ -1001,10 +1008,15 @@ impl PhotocraftMcp {
Some((_, Some(bits))) => Some(bits),
Some((b, None)) => return Ok(fail(format!("`dpxBits` must be 8, 10, 12 or 16, got {b}"))),
};
+ let dds_compression = match p.dds_compression.as_deref().map(|c| (c, photocraft_io::DdsCompression::from_name(c))) {
+ None => None,
+ Some((_, Some(c))) => Some(c),
+ Some((c, None)) => return Ok(fail(format!("`ddsCompression` must be none, bc1, bc2 or bc3, got {c:?}"))),
+ };
if let Some(b) = self.bridge_client() {
// The bridge forwards to the running app's `app.save`, which takes only an optional
// path (without one it writes back to the document's own layered file, as headless
- // does) and the Targa bits, so the other options are headless-only. Saying so beats
+ // does), the Targa bits and the DDS options, so the other options are headless-only. Saying so beats
// saving with defaults while the caller believes their quality or format was applied.
let unsupported: Vec<&str> = [
p.format.is_some().then_some("format"),
@@ -1026,12 +1038,19 @@ impl PhotocraftMcp {
if let Some(bits) = tga_bits {
params["tgaBits"] = json!(bits.bits());
}
+ if let Some(c) = dds_compression {
+ params["ddsCompression"] = json!(c.name());
+ }
+ if p.dds_mipmaps {
+ params["ddsMipmaps"] = json!(true);
+ }
return to_result(b.call("app.save", params).await);
}
let Some(r) = self
.headless_op(move |h| {
let mut opts = photocraft_io::ExportOptions { tiff_layers: p.tiff_layers, tga_bits, ..Default::default() };
opts.encode.dpx_bits = dpx_bits;
+ opts.encode.dds = photocraft_io::DdsOptions { compression: dds_compression.unwrap_or_default(), mipmaps: p.dds_mipmaps };
if let Some(q) = p.quality {
opts.encode.jpeg_quality = q.clamp(1, 100);
opts.encode.webp_quality = q.clamp(1, 100);
diff --git a/crates/automation/src/workspace.rs b/crates/automation/src/workspace.rs
index f73a702385..99dedac77d 100644
--- a/crates/automation/src/workspace.rs
+++ b/crates/automation/src/workspace.rs
@@ -74,6 +74,13 @@ impl AuthorizedWorkspace {
/// as [`photocraft_format::atomic_write`], through the directory capability). On failure
/// the previous file is untouched and the temporary file is removed.
///
+ /// Replacing a file carries its extended attributes — macOS Finder tags, Linux `user.*` —
+ /// onto the file that takes its place (#7095), through the two open descriptors rather than
+ /// their paths, so nothing here reaches outside the write root to finish a write. The rest
+ /// of what [`photocraft_format::atomic_write`] preserves does not follow: its permission
+ /// step is tracked in #4963, and the Windows `ReplaceFileW` swap is path-based, which this
+ /// path may not use.
+ ///
/// The parent directory must already exist. `cap-std` performs path
/// resolution and file creation relative to the held directory handle, so
/// a non-existent final target is supported without ambient path access.
@@ -85,10 +92,20 @@ impl AuthorizedWorkspace {
let tmp = parent.join(photocraft_format::atomic::temp_name(&leaf));
let mut options = OpenOptions::new();
options.write(true).create_new(true);
- let written = root.dir.open_with(&tmp, &options).and_then(|mut file| {
+ let written = root.dir.open_with(&tmp, &options).and_then(|file| {
+ let mut file = file.into_std();
file.write_all(bytes)?;
+ #[cfg(unix)]
+ {
+ use std::os::fd::AsFd;
+ // Best effort, and only when there is a file being replaced: a save still
+ // succeeds for a document whose Finder tag could not come along.
+ if let Ok(old) = root.dir.open(&relative) {
+ let _ = photocraft_format::atomic::meta::copy_xattrs(old.as_fd(), file.as_fd());
+ }
+ }
// Falls back to a plain fsync on shares that refuse a full flush (#1336).
- photocraft_format::atomic::sync_file(&file.into_std())
+ photocraft_format::atomic::sync_file(&file)
});
let renamed = written.and_then(|()| photocraft_format::atomic::retry_rename(RenameRetry::platform(), || root.dir.rename(&tmp, &root.dir, &relative)));
if let Err(e) = renamed {
@@ -572,6 +589,32 @@ mod tests {
assert!(temp_files(&inside).is_empty());
}
+ /// #7095: replacing a file through the automation path keeps the old file's extended
+ /// attributes, the same as a save through `atomic_write` does.
+ #[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))]
+ #[test]
+ fn write_keeps_the_replaced_files_extended_attributes() {
+ #[cfg(target_os = "macos")]
+ const TAG: &str = "com.apple.metadata:_kMDItemUserTags";
+ #[cfg(not(target_os = "macos"))]
+ const TAG: &str = "user.photocraft.test";
+
+ let (inside, _, workspace) = roots("xattr");
+ let path = inside.join("doc.psd");
+ workspace.write("doc.psd", b"original").unwrap();
+ if rustix::fs::setxattr(&path, TAG, b"tagged", rustix::fs::XattrFlags::empty()).is_err() {
+ return; // a file system without extended attributes: nothing to assert
+ }
+
+ workspace.write("doc.psd", b"replacement, longer").unwrap();
+
+ assert_eq!(std::fs::read(&path).unwrap(), b"replacement, longer");
+ let mut buf = [0u8; 64];
+ let len = rustix::fs::getxattr(&path, TAG, buf.as_mut_slice()).expect("the attribute was dropped");
+ assert_eq!(&buf[..len], b"tagged");
+ assert!(temp_files(&inside).is_empty());
+ }
+
#[cfg(unix)]
#[test]
fn write_into_read_only_folder_keeps_the_original() {
diff --git a/crates/automation/tests/mcp.rs b/crates/automation/tests/mcp.rs
index 7386f3d58c..5a9fa7c56e 100644
--- a/crates/automation/tests/mcp.rs
+++ b/crates/automation/tests/mcp.rs
@@ -415,6 +415,45 @@ async fn headless_doc_save_writes_targa_bits() {
cleanup(&dir);
}
+/// `ddsCompression` and `ddsMipmaps` reach the app's `app.save` through the bridge; an unknown
+/// compression is refused before anything is sent.
+#[tokio::test(flavor = "multi_thread")]
+async fn bridge_doc_save_forwards_dds_options() {
+ let (addr, app) = fake_app().await;
+ let client = connect(PhotocraftMcp::bridge(&addr, CONTROL_TOKEN).unwrap()).await;
+ let saved = json_of(&call(&client, "doc_save", json!({"path": "out.dds", "ddsCompression": "DXT5", "ddsMipmaps": true})).await);
+ assert_eq!(saved, json!({"saved": {"path": "out.dds", "ddsCompression": "bc3", "ddsMipmaps": true}}));
+ let r = call(&client, "doc_save", json!({"path": "out.dds", "ddsCompression": "bc7"})).await;
+ assert_eq!(r.is_error, Some(true), "{}", text(&r));
+ assert!(text(&r).contains("ddsCompression"), "{}", text(&r));
+ client.cancel().await.unwrap();
+ app.abort();
+}
+
+/// Headless `doc_save`/`doc_export` write the DDS compression (the FourCC, bytes 84–87) and mip
+/// count (bytes 28–31) asked for; unset, an uncompressed texture without mipmaps.
+#[tokio::test(flavor = "multi_thread")]
+async fn headless_doc_save_writes_dds_options() {
+ let dir = tmp("dds-options");
+ let client = connect(headless_in(&dir)).await;
+ call(&client, "doc_new", json!({"width": 16, "height": 8, "background": "white"})).await;
+ for (tool, params, fourcc, mips) in [
+ ("doc_export", json!({"path": "plain.dds"}), [0; 4], 1),
+ ("doc_export", json!({"path": "bc1.dds", "ddsCompression": "bc1"}), *b"DXT1", 1),
+ ("doc_save", json!({"path": "bc3.dds", "ddsCompression": "bc3", "ddsMipmaps": true}), *b"DXT5", 5),
+ ] {
+ let r = call(&client, tool, params.clone()).await;
+ assert_ne!(r.is_error, Some(true), "{params}: {}", text(&r));
+ let bytes = std::fs::read(dir.join(params["path"].as_str().unwrap())).unwrap();
+ assert_eq!((&bytes[84..88], u32::from_le_bytes(bytes[28..32].try_into().unwrap())), (&fourcc[..], mips), "{params}");
+ }
+ let r = call(&client, "doc_export", json!({"path": "bad.dds", "ddsCompression": "bc7"})).await;
+ assert_eq!(r.is_error, Some(true), "{}", text(&r));
+ assert!(!dir.join("bad.dds").exists());
+ client.cancel().await.unwrap();
+ cleanup(&dir);
+}
+
#[tokio::test(flavor = "multi_thread")]
async fn bridge_response_budget_drops_connection_without_retrying_the_operation() {
use photocraft_automation::{BridgeClient, budgets::MAX_RESPONSE_BYTES};
diff --git a/crates/cms/README.md b/crates/cms/README.md
index c48d5366fd..70b4ab3c9c 100644
--- a/crates/cms/README.md
+++ b/crates/cms/README.md
@@ -20,6 +20,9 @@ t.eval(&[1.0, 0.0, 0.0], &mut out); // exact single colou
* `Transform::proof(src, proof, display, intent, bpc, simulate_paper)`: soft-proof chain.
* `GamutCheck::new(src, proof, ΔE)`: out-of-gamut test (gamut warning).
* `Lut3d::from_transform(&t, 33).to_rgba16f_bytes()`: display transform as a 3D texture.
+* `Transform::gpu_matrix_shaper()`: accurate RGB input curves + matrix + output curves for
+ display shaders. Float tables retain near-black detail; unsupported chains return `None`
+ for the 3D LUT fallback. No GPU dependency is required by this crate.
* `transform::cached(src, dst, opts)`: process-wide transform cache.
* `Profile::to_bytes()`: original bytes of parsed profiles (byte-exact), or an ICC v4.3 encoding.
* `Profile::same_colors(&other)`: the same colour space whatever the encoding (every sample of
@@ -36,6 +39,10 @@ t.eval(&[1.0, 0.0, 0.0], &mut out); // exact single colou
tetrahedral interpolation (4D: linear on the first input between two tetrahedra); purely
analytic chains (e.g. Lab ↔ RGB) are evaluated exactly. Buffers ≥ 16k pixels run on rayon
(native only).
+* GPU matrix/TRC display transforms reuse the 4097-entry input curves, 3×3 matrix plus
+ offsets, and 4096-entry output curves indexed by `x^(1/4)`. Each channel is interpolated
+ separately using float texels; SDR input/output are clamped to `[0, 1]`. A final RGB
+ 33³ table cannot resolve steep inverse gamma curves near black, even with float texels.
* Rendering intents: AToB0/1/2 and BToA0/1/2 by intent with AToB0/BToA0 fallback; absolute
colorimetric scales by media white (`wtpt`; v2 display profiles treated as D50; a zero,
negative or non-finite white is refused with `CmsError::Invalid`); BPC uses
diff --git a/crates/cms/src/gpu_matrix_shaper.rs b/crates/cms/src/gpu_matrix_shaper.rs
new file mode 100644
index 0000000000..09631bf98d
--- /dev/null
+++ b/crates/cms/src/gpu_matrix_shaper.rs
@@ -0,0 +1,131 @@
+//! Separable RGB display transforms without a coarse 3D interpolation near black.
+
+/// RGB input curves, a 3×3 matrix with offsets, and RGB output curves for a GPU display
+/// transform. Values are floats independent of the document's pixel depth.
+///
+/// The input table is sampled linearly at `clamp(rgb, 0, 1) * INPUT_INTERVALS`. Apply
+/// [`Self::matrix_rows`] to that result, then sample the output table at
+/// `clamp(linear_rgb, 0, 1)^(1/4) * (OUTPUT_ENTRIES - 1)`. Interpolate each channel
+/// separately between its two adjacent table entries. These nonuniform output samples
+/// retain shadow detail where a pure gamma curve's inverse has an infinite slope.
+///
+/// Tables contain RGB values and a padding alpha of 1, suitable for RGBA32Float texels.
+/// Use unfiltered texture loads and manual interpolation if float filtering is unavailable.
+/// This representation covers SDR display output; input and output are clamped to `[0, 1]`.
+/// Construction validates all sizes and values; arbitrary CLUT/proofing chains retain the
+/// [`crate::Lut3d`] fallback.
+#[derive(Clone, Debug, PartialEq)]
+pub struct GpuMatrixShaper {
+ matrix: [[f32; 4]; 3],
+ input: Vec<[f32; 4]>,
+ output: Vec<[f32; 4]>,
+}
+
+impl GpuMatrixShaper {
+ /// The input table has one more entry than this number of equal intervals.
+ pub const INPUT_INTERVALS: usize = 4096;
+ /// Output entries are spaced uniformly in the fourth root of the linear value.
+ pub const OUTPUT_ENTRIES: usize = 4096;
+
+ /// Row-major matrix: each row contains three RGB coefficients followed by its offset.
+ pub fn matrix_rows(&self) -> [[f32; 4]; 3] {
+ self.matrix
+ }
+
+ /// RGB input curves at `i / INPUT_INTERVALS`, with padding alpha 1.
+ pub fn input_table(&self) -> &[[f32; 4]] {
+ &self.input
+ }
+
+ /// RGB output curves at `(i / (OUTPUT_ENTRIES - 1))^4`, with padding alpha 1.
+ pub fn output_table(&self) -> &[[f32; 4]] {
+ &self.output
+ }
+
+ pub(crate) fn from_parts(matrix: [[f32; 4]; 3], input: &[Vec], output: Option<&[Vec]>) -> Option {
+ if !matrix.iter().flatten().all(|v| v.is_finite()) {
+ return None;
+ }
+ let input = pack_table(if input.is_empty() { None } else { Some(input) }, Self::INPUT_INTERVALS + 1, |u| u)?;
+ let output = pack_table(output, Self::OUTPUT_ENTRIES, |u| u * u * u * u)?;
+ let maxima: [f64; 3] = std::array::from_fn(|channel| input.iter().map(|entry| f64::from(entry[channel]).abs()).fold(0.0, f64::max));
+ for row in matrix {
+ let magnitude =
+ row[..3].iter().zip(maxima).map(|(coefficient, maximum)| f64::from(*coefficient).abs() * maximum).sum::() + f64::from(row[3]).abs();
+ if magnitude > f64::from(f32::MAX) {
+ return None;
+ }
+ }
+ Some(Self { matrix, input, output })
+ }
+}
+
+fn pack_table(tables: Option<&[Vec]>, entries: usize, identity: impl Fn(f32) -> f32) -> Option> {
+ if let Some(tables) = tables
+ && (tables.len() != 3 || tables.iter().any(|t| t.len() != entries))
+ {
+ return None;
+ }
+ let mut packed: Vec<[f32; 4]> = Vec::new();
+ // Both lengths are fixed constants, but a failed allocation still means the caller
+ // should retain its existing fallback rather than abort while opening a document.
+ packed.try_reserve_exact(entries).ok()?;
+ for i in 0..entries {
+ let rgb = match tables {
+ Some(tables) => [*tables.first()?.get(i)?, *tables.get(1)?.get(i)?, *tables.get(2)?.get(i)?],
+ None => [identity(i as f32 / (entries - 1) as f32); 3],
+ };
+ if !rgb.iter().all(|v| v.is_finite()) {
+ return None;
+ }
+ if let Some(previous) = packed.last()
+ && rgb.iter().zip(previous).any(|(next, before)| !(*next - *before).is_finite())
+ {
+ return None;
+ }
+ packed.push([rgb[0], rgb[1], rgb[2], 1.0]);
+ }
+ Some(packed)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ const IDENTITY: [[f32; 4]; 3] = [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]];
+
+ #[test]
+ fn rejects_invalid_tables_and_nonfinite_values() {
+ let inputs = vec![vec![0.0; GpuMatrixShaper::INPUT_INTERVALS + 1]; 3];
+ let outputs = vec![vec![0.0; GpuMatrixShaper::OUTPUT_ENTRIES]; 3];
+ assert!(GpuMatrixShaper::from_parts(IDENTITY, &inputs, Some(&outputs)).is_some());
+ assert!(GpuMatrixShaper::from_parts(IDENTITY, &inputs[..2], Some(&outputs)).is_none());
+ assert!(GpuMatrixShaper::from_parts(IDENTITY, &inputs, Some(&outputs[..2])).is_none());
+ assert!(GpuMatrixShaper::from_parts(IDENTITY, &inputs, Some(&[])).is_none());
+ let mut invalid = inputs.clone();
+ invalid[0].pop();
+ assert!(GpuMatrixShaper::from_parts(IDENTITY, &invalid, Some(&outputs)).is_none());
+ for v in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
+ let mut invalid = inputs.clone();
+ invalid[1][123] = v;
+ assert!(GpuMatrixShaper::from_parts(IDENTITY, &invalid, Some(&outputs)).is_none());
+ let mut invalid = outputs.clone();
+ invalid[2][321] = v;
+ assert!(GpuMatrixShaper::from_parts(IDENTITY, &inputs, Some(&invalid)).is_none());
+ let mut invalid = IDENTITY;
+ invalid[1][3] = v;
+ assert!(GpuMatrixShaper::from_parts(invalid, &inputs, Some(&outputs)).is_none());
+ }
+ }
+
+ #[test]
+ fn rejects_finite_data_that_overflows_shader_arithmetic() {
+ let mut matrix = IDENTITY;
+ matrix[0] = [f32::MAX; 4];
+ assert!(GpuMatrixShaper::from_parts(matrix, &[], None).is_none());
+ let mut inputs = vec![vec![0.0; GpuMatrixShaper::INPUT_INTERVALS + 1]; 3];
+ inputs[0][100] = -f32::MAX;
+ inputs[0][101] = f32::MAX;
+ assert!(GpuMatrixShaper::from_parts(IDENTITY, &inputs, None).is_none());
+ }
+}
diff --git a/crates/cms/src/lib.rs b/crates/cms/src/lib.rs
index f93b00372b..259d51bf77 100644
--- a/crates/cms/src/lib.rs
+++ b/crates/cms/src/lib.rs
@@ -8,6 +8,7 @@
//! * [`builtin`] ships sRGB, Display P3, Adobe RGB (1998)-compatible, ProPhoto-compatible,
//! linear sRGB, Rec. 2020, Gray 2.2, sGray, Lab D50 and a synthetic coated CMYK profile.
//! * [`Lut3d`] samples a display transform into a 3D texture for the GPU canvas.
+//! * [`GpuMatrixShaper`] preserves near-black precision for separable RGB display transforms.
//!
//! Implemented from the public ICC.1:2010 (v4.3) and ICC.1:2001-04 (v2) specifications,
//! CIE 15, and Adobe's published black point compensation paper. This crate depends on no
@@ -19,6 +20,7 @@ pub mod builtin;
pub mod clut;
pub mod curve;
pub mod gamut;
+mod gpu_matrix_shaper;
pub mod lut3d;
pub mod lutfile;
pub mod math;
@@ -32,6 +34,7 @@ pub use builtin::Builtin;
pub use clut::Clut;
pub use curve::Curve;
pub use gamut::GamutCheck;
+pub use gpu_matrix_shaper::GpuMatrixShaper;
pub use lut3d::Lut3d;
pub use profile::{ColorSpace, Pcs, Profile, ProfileClass};
pub use transform::{SampleKind, Transform, TransformOptions, black_point, cached};
diff --git a/crates/cms/src/transform.rs b/crates/cms/src/transform.rs
index d262956a78..04d11247f4 100644
--- a/crates/cms/src/transform.rs
+++ b/crates/cms/src/transform.rs
@@ -9,7 +9,7 @@ use crate::curve::Curve;
use crate::math;
use crate::pipeline::{Pipeline, Stage};
use crate::profile::{ColorSpace, Pcs, Profile, ProfileClass};
-use crate::{CmsError, Intent};
+use crate::{CmsError, GpuMatrixShaper, Intent};
/// Sample storage of a byte buffer for [`Transform::convert_bytes_many`].
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -39,9 +39,9 @@ impl Default for TransformOptions {
/// Grid sizes of the device-link tables built for integer (and fast float) evaluation.
pub const GRID_3D: usize = 33;
pub const GRID_4D: usize = 17;
-const TABLE_1D: usize = 4096;
+const TABLE_1D: usize = GpuMatrixShaper::INPUT_INTERVALS;
/// Resolution of output shaper tables (indexed by `x^(1/4)`).
-const OUT_TABLE: usize = 4096;
+const OUT_TABLE: usize = GpuMatrixShaper::OUTPUT_ENTRIES;
/// ICC v4 perceptual reference medium black (ICC.1:2010 §6.3.4.4), XYZ.
const PERCEPTUAL_BLACK: [f64; 3] = [0.00336, 0.0034731, 0.00287];
@@ -226,6 +226,31 @@ impl Transform {
&self.pipeline
}
+ /// A separable RGB transform for accurate GPU display conversion, when available.
+ ///
+ /// Reuses the input/matrix/output acceleration of the integer conversion path instead
+ /// of interpolating final encoded RGB over a coarse 3D grid. Returns `None` for other
+ /// channel counts, CLUTs, proofing stages, or invalid/unavailable table data, so callers
+ /// can keep their existing display-LUT fallback. This is an SDR representation; see
+ /// [`GpuMatrixShaper`] for the precise sampling and clamping contract.
+ pub fn gpu_matrix_shaper(&self) -> Option {
+ if self.inputs != 3 || self.outputs != 3 {
+ return None;
+ }
+ let Core::Shaper { curves, matrix, .. } = &self.core else { return None };
+ let mut rows = [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]];
+ if let Some((nr, nc, coefficients, offsets)) = matrix {
+ if *nr != 3 || *nc != 3 || coefficients.len() != 9 || offsets.len() != 3 {
+ return None;
+ }
+ for (row, (coefficients, offset)) in rows.iter_mut().zip(coefficients.as_chunks::<3>().0.iter().zip(offsets)) {
+ let [r, g, b] = coefficients;
+ *row = [*r, *g, *b, *offset];
+ }
+ }
+ GpuMatrixShaper::from_parts(rows, curves, self.post.as_ref().map(|p| p.tables.as_slice()))
+ }
+
/// Exact evaluation of one colour (`inputs` → `outputs` normalised values).
#[inline]
pub fn eval(&self, input: &[f32], out: &mut [f32]) {
diff --git a/crates/cms/tests/gpu_matrix_shaper.rs b/crates/cms/tests/gpu_matrix_shaper.rs
new file mode 100644
index 0000000000..20f05e8aa1
--- /dev/null
+++ b/crates/cms/tests/gpu_matrix_shaper.rs
@@ -0,0 +1,197 @@
+//! Independent ICC matrix/TRC reference for display shadows.
+//!
+//! Fixtures are generated here from the published sRGB equation and a synthetic monitor,
+//! with no photograph or device profile. The expected values implement ICC.1:2010 Annex F.3
+//! directly in f64, without Transform, Curve evaluation, or photocraft_cms::math:
+//! https://www.color.org/specification/ICC1v43_2010-12.pdf
+
+use photocraft_cms::pipeline::{Pipeline, Stage};
+use photocraft_cms::{Builtin, Clut, Curve, GpuMatrixShaper, Intent, Lut3d, Profile, Transform, TransformOptions};
+
+const SOURCE_MATRIX: [[f64; 3]; 3] = [[0.4360747, 0.3850649, 0.1430804], [0.2225045, 0.7168786, 0.0606169], [0.0139322, 0.0971045, 0.7141733]];
+// Deliberately synthetic, D50-referenced wider-gamut monitor; not a user's measured profile.
+const MONITOR_MATRIX: [[f64; 3]; 3] = [[0.55, 0.27, 0.1442], [0.25, 0.69, 0.06], [0.01, 0.06, 0.7549]];
+const GAMMA: f64 = 2.2;
+const SOURCE_ENTRIES: usize = 1024;
+const DISPLAY_TOLERANCE: f64 = 1.0 / 255.0;
+
+fn fixed16(x: f64) -> f64 {
+ (x * 65536.0).round() / 65536.0
+}
+
+fn srgb_sample(i: usize) -> f64 {
+ let x = i as f64 / (SOURCE_ENTRIES - 1) as f64;
+ let linear = if x <= 0.04045 { x / 12.92 } else { ((x + 0.055) / 1.055).powf(2.4) };
+ (linear * 65535.0).round() / 65535.0
+}
+
+fn synthetic_profiles() -> (Profile, Profile) {
+ let mut source = Builtin::Srgb.profile().clone();
+ source.description = "Synthetic sampled sRGB for display regression".into();
+ source.matrix = Some(SOURCE_MATRIX);
+ source.trc = Some(std::array::from_fn(|_| Curve::Table((0..SOURCE_ENTRIES).map(|i| srgb_sample(i) as f32).collect())));
+ let source = source.with_encoded_bytes();
+ let mut monitor = Builtin::Srgb.profile().clone();
+ monitor.description = "Synthetic gamma 2.2 display for regression".into();
+ monitor.matrix = Some(MONITOR_MATRIX);
+ monitor.trc = Some(std::array::from_fn(|_| Curve::Parametric { kind: 0, p: [GAMMA, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0] }));
+ let monitor = monitor.with_encoded_bytes();
+ // Exercise actual ICC serialization/parsing, including fixed-point matrix/TRC values.
+ (Profile::parse(&source.to_bytes()).unwrap(), Profile::parse(&monitor.to_bytes()).unwrap())
+}
+
+fn synthetic_transform(bpc: bool) -> Transform {
+ let (source, monitor) = synthetic_profiles();
+ Transform::new(&source, &monitor, Intent::RelativeColorimetric, bpc).unwrap()
+}
+
+fn reference(rgb: [f32; 3]) -> [f64; 3] {
+ let linear = rgb.map(|x| {
+ let p = f64::from(x.clamp(0.0, 1.0)) * (SOURCE_ENTRIES - 1) as f64;
+ let i = (p as usize).min(SOURCE_ENTRIES - 2);
+ srgb_sample(i) + (srgb_sample(i + 1) - srgb_sample(i)) * (p - i as f64)
+ });
+ let xyz = SOURCE_MATRIX.map(|row| row.iter().zip(linear).map(|(m, x)| fixed16(*m) * x).sum::());
+ // Solve M_monitor * linear_monitor = XYZ using elimination, rather than the production
+ // matrix inverse/multiplication implementation.
+ let mut a: [[f64; 4]; 3] = std::array::from_fn(|r| [fixed16(MONITOR_MATRIX[r][0]), fixed16(MONITOR_MATRIX[r][1]), fixed16(MONITOR_MATRIX[r][2]), xyz[r]]);
+ for pivot in 0..3 {
+ let divisor = a[pivot][pivot];
+ for value in &mut a[pivot][pivot..] {
+ *value /= divisor;
+ }
+ let normalized = a[pivot];
+ for (r, row) in a.iter_mut().enumerate() {
+ if r != pivot {
+ let factor = row[pivot];
+ for (value, coefficient) in row.iter_mut().zip(normalized).skip(pivot) {
+ *value -= factor * coefficient;
+ }
+ }
+ }
+ }
+ std::array::from_fn(|c| a[c][3].clamp(0.0, 1.0).powf(1.0 / fixed16(GAMMA)))
+}
+
+/// CPU interpretation of the public GPU data layout; expected colours come only from
+/// `reference`, so this tests the sampled representation, not Transform against itself.
+fn sample_gpu(gpu: &GpuMatrixShaper, rgb: [f32; 3]) -> [f32; 3] {
+ fn interpolate(table: &[[f32; 4]], channel: usize, coordinate: f32) -> f32 {
+ let i = (coordinate as usize).min(table.len() - 2);
+ let f = coordinate - i as f32;
+ table[i][channel] + (table[i + 1][channel] - table[i][channel]) * f
+ }
+ let linear: [f32; 3] = std::array::from_fn(|c| interpolate(gpu.input_table(), c, rgb[c].clamp(0.0, 1.0) * GpuMatrixShaper::INPUT_INTERVALS as f32));
+ let rows = gpu.matrix_rows();
+ std::array::from_fn(|c| {
+ let row = rows[c];
+ let value = row[0] * linear[0] + row[1] * linear[1] + row[2] * linear[2] + row[3];
+ let coordinate = value.clamp(0.0, 1.0).sqrt().sqrt() * (GpuMatrixShaper::OUTPUT_ENTRIES - 1) as f32;
+ interpolate(gpu.output_table(), c, coordinate).clamp(0.0, 1.0)
+ })
+}
+
+fn check_reference(gpu: &GpuMatrixShaper, rgb: [f32; 3]) {
+ let actual = sample_gpu(gpu, rgb);
+ let expected = reference(rgb);
+ for c in 0..3 {
+ assert!((f64::from(actual[c]) - expected[c]).abs() <= DISPLAY_TOLERANCE, "input {rgb:?}, channel {c}: GPU {actual:?}, ICC {expected:?}");
+ }
+}
+
+#[test]
+fn sampled_srgb_to_gamma_display_preserves_neutral_and_colored_shadows() {
+ for bpc in [false, true] {
+ let transform = synthetic_transform(bpc);
+ let gpu = transform.gpu_matrix_shaper().unwrap();
+ assert_eq!(gpu.input_table().len(), GpuMatrixShaper::INPUT_INTERVALS + 1);
+ assert_eq!(gpu.output_table().len(), GpuMatrixShaper::OUTPUT_ENTRIES);
+ assert!(gpu.input_table().iter().chain(gpu.output_table()).all(|v| v[3] == 1.0));
+ for v in 0..=255 {
+ check_reference(&gpu, [v as f32 / 255.0; 3]);
+ }
+ // Includes coloured shadows within the old LUT's very first 0..8/255 cell.
+ for r in 0..=16 {
+ for g in 0..=16 {
+ for b in 0..=16 {
+ check_reference(&gpu, [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0]);
+ }
+ }
+ }
+ for rgb in [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0], [0.9, 0.6, 0.3]] {
+ check_reference(&gpu, rgb);
+ }
+ }
+}
+
+#[test]
+fn coarse_final_rgb_lut_fails_the_same_icc_shadow_reference() {
+ let transform = synthetic_transform(false);
+ let gpu = transform.gpu_matrix_shaper().unwrap();
+ let coarse = Lut3d::from_transform(&transform, 33);
+ let rgb = [2.0 / 255.0; 3];
+ let expected = reference(rgb);
+ let old = coarse.sample(rgb);
+ // Even float LUT nodes fail, before the additional rounding of RGBA8 upload. This
+ // guards the cause of the original bug rather than a particular observed screenshot.
+ assert!(old.iter().zip(expected).all(|(a, e)| e - f64::from(*a) > 2.0 / 255.0), "old {old:?}, reference {expected:?}");
+ check_reference(&gpu, rgb);
+}
+
+#[test]
+fn gpu_display_representation_accepts_u8_u16_and_float_samples() {
+ let transform = synthetic_transform(false);
+ let gpu = transform.gpu_matrix_shaper().unwrap();
+ for v in [0_u8, 1, 2, 7, 8, 16, 32, 128, 255] {
+ check_reference(&gpu, [v as f32 / 255.0; 3]);
+ }
+ for v in [0_u16, 1, 2, 15, 63, 257, 514, 1028, 4096, 32768, 65535] {
+ check_reference(&gpu, [v as f32 / 65535.0; 3]);
+ }
+ for rgb in [[0.000003, 0.00001, 0.00002], [0.00123, 0.00456, 0.00789], [0.2, 0.45, 0.8], [-0.5, 0.0, 2.0]] {
+ check_reference(&gpu, rgb);
+ }
+ assert_eq!(sample_gpu(&gpu, [0.0; 3]), [0.0; 3]);
+ // White differs very slightly from 1 because the synthetic matrices use ICC s15Fixed16.
+ check_reference(&gpu, [1.0; 3]);
+}
+
+#[test]
+fn absent_curves_and_matrix_use_identity_without_losing_offsets() {
+ let identity = Transform::from_pipeline(Pipeline::new(3, Vec::new()), TransformOptions::default()).gpu_matrix_shaper().unwrap();
+ for rgb in [[0.0; 3], [1.0; 3], [0.001, 0.2, 0.8], [-2.0, 0.5, 2.0]] {
+ for (actual, input) in sample_gpu(&identity, rgb).into_iter().zip(rgb) {
+ assert!((actual - input.clamp(0.0, 1.0)).abs() < 1e-6);
+ }
+ }
+ let matrix = Stage::Matrix { rows: 3, cols: 3, m: vec![0.8, 0.0, 0.0, 0.0, 0.7, 0.0, 0.0, 0.0, 0.6], offset: vec![0.01, 0.02, 0.03] };
+ let transform = Transform::from_pipeline(Pipeline::new(3, vec![matrix]), TransformOptions::default()).gpu_matrix_shaper().unwrap();
+ let input = [0.1, 0.2, 0.3];
+ let output = sample_gpu(&transform, input);
+ for (actual, expected) in output.into_iter().zip([0.09, 0.16, 0.21]) {
+ assert!((actual - expected).abs() < 1e-6, "{output:?}");
+ }
+}
+
+#[test]
+fn unsupported_clut_proof_and_non_rgb_transforms_retain_fallback() {
+ let identity_clut = Clut::sample(vec![2; 3], 3, |input, output| output.copy_from_slice(input));
+ let clut = Transform::from_pipeline(Pipeline::new(3, vec![Stage::Clut(identity_clut)]), TransformOptions::default());
+ assert!(clut.gpu_matrix_shaper().is_none());
+ let srgb = Builtin::Srgb.profile();
+ let lab = Transform::new(Builtin::LabD50.profile(), srgb, Intent::RelativeColorimetric, false).unwrap();
+ assert!(lab.gpu_matrix_shaper().is_none());
+ let gray = Transform::new(Builtin::GrayGamma22.profile(), srgb, Intent::RelativeColorimetric, false).unwrap();
+ assert!(gray.gpu_matrix_shaper().is_none());
+ let proof = Transform::proof(srgb, Builtin::DisplayP3.profile(), srgb, Intent::RelativeColorimetric, false, false).unwrap();
+ assert!(proof.gpu_matrix_shaper().is_none());
+}
+
+#[test]
+fn malformed_matrix_storage_is_not_exposed_to_the_gpu() {
+ for (coefficients, offsets) in [(vec![2.0; 8], vec![0.0; 3]), (vec![2.0; 9], vec![0.0; 2]), (vec![f64::INFINITY; 9], vec![0.0; 3])] {
+ let transform =
+ Transform::from_pipeline(Pipeline::new(3, vec![Stage::Matrix { rows: 3, cols: 3, m: coefficients, offset: offsets }]), TransformOptions::default());
+ assert!(transform.gpu_matrix_shaper().is_none());
+ }
+}
diff --git a/crates/codecs/README.md b/crates/codecs/README.md
index 46baf50f82..539365c95b 100644
--- a/crates/codecs/README.md
+++ b/crates/codecs/README.md
@@ -40,7 +40,7 @@ let out = encode(&img, Format::Tiff, &EncodeOptions::default())?;
| BMP | yes | yes | U8 | RGB, RGBA | yes | no | no | no | no | no | no | `image` |
| TGA | yes | yes | U8 | Gray, GrayA, RGB, RGBA | yes | no | no | no | no | no | no | `image` (read) / built in (RLE write, one scanline per packet) |
| ICO | yes | yes | U8 | RGBA (at most 256×256) | yes | no | no | no | no | no | no | `image` |
-| DDS | yes | **no** | U8 | Gray, GrayA, RGB, RGBA | yes | no | no | no | no | no | n/a | built in |
+| DDS | yes | yes | U8 | Gray, GrayA, RGB, RGBA | yes | no | no | no | no | no | yes (BC1–BC3, when `EncodeOptions::dds` asks for one) | built in |
| Netpbm (PBM/PGM/PPM/PAM/PFM) | yes | yes | U8, U16, F32 (float: PFM gray/RGB only) | all six (PAM `CMYK`/`CMYK_ALPHA`) | yes (PAM) | no | no | no | no | no | no | built in |
| QOI | yes | yes | U8 | RGB, RGBA | yes | no | no | no | no | no | no | `image` |
| OpenEXR | yes | yes | F16, F32 | Y, YA, RGB, RGBA | yes | no | no | no | no | no | no | `exr` |
@@ -73,7 +73,15 @@ embedded table (and a PNG's transparent entry) so `photocraft-io` can open them
(8, 16, 24 or 32 bits, RGB or luminance, with or without alpha) or BC1/BC2/BC3 (DXT1/DXT3/DXT5),
with the legacy header or the DX10 one. Everything opens as U8; the other mip levels are
ignored. Cubemaps, texture arrays, volume textures, premultiplied DXT2/DXT4 and other formats
- (BC4–BC7, float) are refused with `CodecError::Unsupported`. Writing is not provided.
+ (BC4–BC7, float) are refused with `CodecError::Unsupported`. Writing (`EncodeOptions::dds`) makes
+ a 2D texture with the legacy header: uncompressed by default (BGRA, BGRX, or luminance with or
+ without alpha, so it reads back exactly), or BC1/BC2/BC3. The block encoder is our own, from
+ Microsoft's public block-compression documentation: RGB565 endpoints fitted along each block's
+ principal axis and refined by least squares, indices picked against the palette the reader
+ decodes; BC1 turns texels under half opacity into its transparent black, and BC3 picks
+ whichever alpha mode fits. `mipmaps` adds the full chain down to 1×1, each level a box filter
+ of the one above (the last column and row of an odd-sized level average three pixels, so none
+ is dropped). Cubemaps, arrays, BC4–BC7 and sRGB (DX10-only) formats aren't written.
* **FITS.** The image in the primary HDU, or the first `IMAGE` extension, is read; `NAXIS3 = 3`
opens as RGB and any other plane count opens the first plane with `DecodeWarning::MorePages`.
diff --git a/crates/codecs/src/codecs/dds.rs b/crates/codecs/src/codecs/dds.rs
index efe93c7f2e..d35c7d1092 100644
--- a/crates/codecs/src/codecs/dds.rs
+++ b/crates/codecs/src/codecs/dds.rs
@@ -1,13 +1,24 @@
-//! DirectDraw Surface (DDS) reader: the top mip level of a 2D texture, either uncompressed
-//! (8 to 32 bits per pixel described by channel masks, RGB or luminance) or block-compressed
-//! BC1/BC2/BC3 (DXT1/DXT3/DXT5), with or without the DX10 header. Read-only. Cubemaps,
-//! texture arrays and volume textures are refused rather than flattened to one face or slice.
+//! DirectDraw Surface (DDS) textures, after Microsoft's public DDS documentation ("DDS",
+//! "DDS_HEADER", "DDS_PIXELFORMAT", "DDS_HEADER_DXT10").
+//!
+//! Reading: the top mip level of a 2D texture, either uncompressed (8 to 32 bits per pixel
+//! described by channel masks, RGB or luminance) or block-compressed BC1/BC2/BC3
+//! (DXT1/DXT3/DXT5), with or without the DX10 header. Cubemaps, texture arrays and volume
+//! textures are refused rather than flattened to one face or slice.
+//!
+//! Writing: a 2D texture, uncompressed (BGRA or BGRX, luminance with or without alpha) or
+//! BC1/BC2/BC3, optionally with its full mip chain. The legacy header describes all of these,
+//! so no DX10 header is written; formats only DXGI can name (BC4–BC7, sRGB) aren't written.
+use std::borrow::Cow;
+
+use super::dds_bc::{self, Block, Mask};
use crate::SampleType;
use crate::error::CodecError;
+use crate::fidelity::Plan;
use crate::format::Format;
use crate::image::{ChannelLayout, Image};
-use crate::options::Limits;
+use crate::options::{DdsCompression, EncodeOptions, Limits};
/// `DDS ` plus the 124-byte DDS_HEADER.
const HEADER: usize = 128;
@@ -224,27 +235,12 @@ pub(crate) fn decode(b: &[u8], limits: &Limits) -> Result {
Image::from_u8(width, height, layout, pixels)
}
-fn rgb565(c: u16) -> [u8; 3] {
- let scale = |v: u16, max: u16| ((u32::from(v) * 255 + u32::from(max) / 2) / u32::from(max)) as u8;
- [scale(c >> 11, 31), scale((c >> 5) & 63, 63), scale(c & 31, 31)]
-}
-
/// A BC1 colour block. BC2 and BC3 colour blocks always have four colours; only BC1's
/// `c0 <= c1` order selects three colours and transparent black.
fn bc1(block: &[u8], one_bit_alpha: bool) -> [[u8; 4]; 16] {
let byte = |i: usize| block.get(i).copied().unwrap_or(0);
let (c0, c1) = (u16::from_le_bytes([byte(0), byte(1)]), u16::from_le_bytes([byte(2), byte(3)]));
- let (a, b) = (rgb565(c0), rgb565(c1));
- let mix = |wa: u32, wb: u32| {
- let d = wa + wb;
- let at = |i: usize| ((wa * u32::from(a[i]) + wb * u32::from(b[i]) + d / 2) / d) as u8;
- [at(0), at(1), at(2), 255]
- };
- let palette = if c0 > c1 || !one_bit_alpha {
- [[a[0], a[1], a[2], 255], [b[0], b[1], b[2], 255], mix(2, 1), mix(1, 2)]
- } else {
- [[a[0], a[1], a[2], 255], [b[0], b[1], b[2], 255], mix(1, 1), [0; 4]]
- };
+ let palette = dds_bc::colour_palette(c0, c1, c0 > c1 || !one_bit_alpha);
let indices = u32::from_le_bytes([byte(4), byte(5), byte(6), byte(7)]);
std::array::from_fn(|t| palette[(indices >> (2 * t)) as usize & 3])
}
@@ -259,21 +255,9 @@ fn bc2_alpha(block: &[u8]) -> [u8; 16] {
/// 0 and 255 when `a0 <= a1`.
fn bc3_alpha(block: &[u8]) -> [u8; 16] {
let byte = |i: usize| block.get(i).copied().unwrap_or(0);
- let (a0, a1) = (u32::from(byte(0)), u32::from(byte(1)));
- let mut palette = [a0, a1, 0, 0, 0, 0, 0, 255];
- if a0 > a1 {
- for (k, v) in palette.iter_mut().enumerate().skip(2) {
- let k = k as u32;
- *v = ((8 - k) * a0 + (k - 1) * a1 + 3) / 7;
- }
- } else {
- for (k, v) in palette.iter_mut().enumerate().take(6).skip(2) {
- let k = k as u32;
- *v = ((6 - k) * a0 + (k - 1) * a1 + 2) / 5;
- }
- }
+ let palette = dds_bc::alpha_palette(byte(0), byte(1));
let bits = u64::from_le_bytes([byte(2), byte(3), byte(4), byte(5), byte(6), byte(7), 0, 0]);
- std::array::from_fn(|t| palette[((bits >> (3 * t)) & 7) as usize] as u8)
+ std::array::from_fn(|t| palette[((bits >> (3 * t)) & 7) as usize])
}
fn with_alpha(mut texels: [[u8; 4]; 16], alpha: [u8; 16]) -> [[u8; 4]; 16] {
@@ -282,3 +266,260 @@ fn with_alpha(mut texels: [[u8; 4]; 16], alpha: [u8; 16]) -> [[u8; 4]; 16] {
}
texels
}
+
+const DDSD_CAPS: u32 = 0x1;
+const DDSD_HEIGHT: u32 = 0x2;
+const DDSD_WIDTH: u32 = 0x4;
+const DDSD_PITCH: u32 = 0x8;
+const DDSD_PIXELFORMAT: u32 = 0x1000;
+const DDSD_MIPMAPCOUNT: u32 = 0x2_0000;
+const DDSD_LINEARSIZE: u32 = 0x8_0000;
+const DDSCAPS_COMPLEX: u32 = 0x8;
+const DDSCAPS_TEXTURE: u32 = 0x1000;
+const DDSCAPS_MIPMAP: u32 = 0x40_0000;
+
+fn encode_err(reason: impl std::fmt::Display) -> CodecError {
+ CodecError::encode(Format::Dds, reason)
+}
+
+/// How a written texture stores its pixels.
+#[derive(Clone, Copy, PartialEq, Eq)]
+enum Store {
+ /// 8-bit luminance.
+ Luminance,
+ /// 8-bit luminance, then 8-bit alpha.
+ LuminanceAlpha,
+ /// Blue, green, red and an unused byte (written as 255, so a reader that takes it for
+ /// alpha still sees an opaque texture).
+ Bgrx,
+ Bgra,
+ Bc1,
+ Bc2,
+ Bc3,
+}
+
+impl Store {
+ /// The storage for an image of `layout`, as [`crate::fidelity::plan`] chose it.
+ fn new(compression: DdsCompression, layout: ChannelLayout) -> Result {
+ Ok(match (compression, layout) {
+ (DdsCompression::None, ChannelLayout::Gray) => Store::Luminance,
+ (DdsCompression::None, ChannelLayout::GrayA) => Store::LuminanceAlpha,
+ (DdsCompression::None, ChannelLayout::Rgb) => Store::Bgrx,
+ (DdsCompression::None, ChannelLayout::Rgba) => Store::Bgra,
+ (DdsCompression::Bc1, ChannelLayout::Rgba) => Store::Bc1,
+ (DdsCompression::Bc2, ChannelLayout::Rgba) => Store::Bc2,
+ (DdsCompression::Bc3, ChannelLayout::Rgba) => Store::Bc3,
+ (_, layout) => return Err(encode_err(format!("{layout:?} pixels can't be written as `{}`", compression.name()))),
+ })
+ }
+
+ /// Bytes per pixel, or per 4×4 block for a block compression.
+ fn unit(self) -> usize {
+ match self {
+ Store::Luminance => 1,
+ Store::LuminanceAlpha => 2,
+ Store::Bgrx | Store::Bgra => 4,
+ Store::Bc1 => 8,
+ Store::Bc2 | Store::Bc3 => 16,
+ }
+ }
+
+ fn is_block(self) -> bool {
+ matches!(self, Store::Bc1 | Store::Bc2 | Store::Bc3)
+ }
+
+ /// Bytes of one row of pixels (or of 4×4 blocks) of a level `width` wide.
+ fn row_bytes(self, width: usize) -> Option {
+ if self.is_block() { width.div_ceil(4).checked_mul(self.unit()) } else { width.checked_mul(self.unit()) }
+ }
+
+ fn level_bytes(self, width: usize, height: usize) -> Option {
+ let rows = if self.is_block() { height.div_ceil(4) } else { height };
+ self.row_bytes(width)?.checked_mul(rows)
+ }
+
+ /// DDS_PIXELFORMAT: flags, FourCC, bits per pixel and the red, green, blue and alpha masks.
+ fn pixel_format(self) -> (u32, [u8; 4], u32, [u32; 4]) {
+ match self {
+ Store::Luminance => (DDPF_LUMINANCE, [0; 4], 8, [0xff, 0, 0, 0]),
+ Store::LuminanceAlpha => (DDPF_LUMINANCE | DDPF_ALPHAPIXELS, [0; 4], 16, [0xff, 0, 0, 0xff00]),
+ Store::Bgrx => (DDPF_RGB, [0; 4], 32, [0xff_0000, 0xff00, 0xff, 0]),
+ Store::Bgra => (DDPF_RGB | DDPF_ALPHAPIXELS, [0; 4], 32, [0xff_0000, 0xff00, 0xff, 0xff00_0000]),
+ Store::Bc1 => (DDPF_FOURCC, *b"DXT1", 0, [0; 4]),
+ Store::Bc2 => (DDPF_FOURCC, *b"DXT3", 0, [0; 4]),
+ Store::Bc3 => (DDPF_FOURCC, *b"DXT5", 0, [0; 4]),
+ }
+ }
+}
+
+/// Levels in a full mip chain: the larger side halves down to 1.
+fn mip_levels(width: u32, height: u32) -> u32 {
+ 32 - width.max(height).max(1).leading_zeros()
+}
+
+fn half((w, h): (usize, usize)) -> (usize, usize) {
+ ((w / 2).max(1), (h / 2).max(1))
+}
+
+pub(crate) fn encode(image: &Image, plan: Plan, opts: &EncodeOptions) -> Result, CodecError> {
+ let img = image.converted(plan.layout, SampleType::U8);
+ let pixels = img.as_u8().ok_or_else(|| encode_err("the image was not converted to 8 bits per channel"))?;
+ let store = Store::new(opts.dds.compression, img.layout())?;
+ let (width, height) = img.dimensions();
+ let levels = if opts.dds.mipmaps { mip_levels(width, height) } else { 1 };
+ let top = (width as usize, height as usize);
+ // Every level's size, checked before anything is allocated.
+ let mut sizes = Vec::new();
+ let mut size = top;
+ for _ in 0..levels {
+ sizes.push(store.level_bytes(size.0, size.1).ok_or_else(|| encode_err("texture size overflow"))?);
+ size = half(size);
+ }
+ let total = sizes.iter().try_fold(HEADER, |total, &s| total.checked_add(s)).ok_or_else(|| encode_err("texture size overflow"))?;
+ let header = header(width, height, store, levels, sizes.first().copied().unwrap_or(0))?;
+ let mut out = crate::options::alloc_zeroed::(total)?;
+ out.get_mut(..HEADER).ok_or_else(|| encode_err("output buffer too small"))?.copy_from_slice(&header);
+
+ let channels = img.layout().channels();
+ let mut level = Cow::Borrowed(pixels);
+ let (mut size, mut at) = (top, HEADER);
+ for (i, &bytes) in sizes.iter().enumerate() {
+ let dst = out.get_mut(at..at + bytes).ok_or_else(|| encode_err("output buffer too small"))?;
+ write_level(store, &level, size, dst)?;
+ at += bytes;
+ if i + 1 < sizes.len() {
+ let next = half(size);
+ level = Cow::Owned(downsample(&level, size, next, channels)?);
+ size = next;
+ }
+ }
+ Ok(out)
+}
+
+/// The `DDS ` signature and DDS_HEADER. `top` is the top level's size in bytes.
+fn header(width: u32, height: u32, store: Store, levels: u32, top: usize) -> Result<[u8; HEADER], CodecError> {
+ let mut b = [0u8; HEADER];
+ let mut put = |at: usize, v: u32| {
+ if let Some(d) = b.get_mut(at..at + 4) {
+ d.copy_from_slice(&v.to_le_bytes());
+ }
+ };
+ // Uncompressed: the pitch of one row. Compressed: the whole top level (its "linear size").
+ let (size_flag, pitch_or_size) = if store.is_block() { (DDSD_LINEARSIZE, Some(top)) } else { (DDSD_PITCH, store.row_bytes(width as usize)) };
+ let pitch_or_size =
+ pitch_or_size.and_then(|v| u32::try_from(v).ok()).ok_or_else(|| encode_err(format!("a {width}x{height} level is too large for a DDS header")))?;
+ let mipmapped = levels > 1;
+ put(4, 124);
+ put(8, DDSD_CAPS | DDSD_HEIGHT | DDSD_WIDTH | DDSD_PIXELFORMAT | size_flag | if mipmapped { DDSD_MIPMAPCOUNT } else { 0 });
+ put(12, height);
+ put(16, width);
+ put(20, pitch_or_size);
+ put(28, levels);
+ let (flags, fourcc, bits, masks) = store.pixel_format();
+ put(76, 32);
+ put(80, flags);
+ put(84, u32::from_le_bytes(fourcc));
+ put(88, bits);
+ for (i, mask) in masks.into_iter().enumerate() {
+ put(92 + 4 * i, mask);
+ }
+ put(108, DDSCAPS_TEXTURE | if mipmapped { DDSCAPS_COMPLEX | DDSCAPS_MIPMAP } else { 0 });
+ b[..4].copy_from_slice(b"DDS ");
+ Ok(b)
+}
+
+/// Runs `f` on each `len`-byte row of `out` with its index, in parallel on native targets.
+fn rows(out: &mut [u8], len: usize, f: impl Fn(usize, &mut [u8]) + Send + Sync) {
+ #[cfg(not(target_arch = "wasm32"))]
+ {
+ use rayon::prelude::*;
+ out.par_chunks_mut(len.max(1)).enumerate().for_each(|(i, row)| f(i, row));
+ }
+ #[cfg(target_arch = "wasm32")]
+ for (i, row) in out.chunks_mut(len.max(1)).enumerate() {
+ f(i, row);
+ }
+}
+
+/// Writes one level of `width` × `height` pixels (in the image's layout) into `dst`.
+fn write_level(store: Store, pixels: &[u8], (width, height): (usize, usize), dst: &mut [u8]) -> Result<(), CodecError> {
+ let row_bytes = store.row_bytes(width).ok_or_else(|| encode_err("texture size overflow"))?;
+ match store {
+ Store::Luminance | Store::LuminanceAlpha => {
+ // The image's gray (and alpha) samples, in the same order.
+ let src = pixels.get(..dst.len()).ok_or_else(|| encode_err("the level is smaller than its size"))?;
+ dst.copy_from_slice(src);
+ }
+ Store::Bgrx | Store::Bgra => {
+ let channels = if store == Store::Bgra { 4 } else { 3 };
+ rows(dst, row_bytes, |y, row| {
+ let src = pixels.get(y * width * channels..).unwrap_or_default();
+ for (d, s) in row.as_chunks_mut::<4>().0.iter_mut().zip(src.chunks_exact(channels)) {
+ let byte = |i: usize| s.get(i).copied().unwrap_or(255);
+ *d = [byte(2), byte(1), byte(0), byte(3)];
+ }
+ });
+ }
+ Store::Bc1 | Store::Bc2 | Store::Bc3 => {
+ let (texels, _) = pixels.as_chunks::<4>();
+ rows(dst, row_bytes, |by, row| {
+ for (bx, d) in row.chunks_exact_mut(store.unit()).enumerate() {
+ let (block, inside) = gather(texels, (width, height), bx, by);
+ let encoded: &[u8] = match store {
+ Store::Bc1 => &dds_bc::encode_bc1(&block, inside),
+ Store::Bc2 => &dds_bc::encode_bc2(&block, inside),
+ _ => &dds_bc::encode_bc3(&block, inside),
+ };
+ for (d, s) in d.iter_mut().zip(encoded) {
+ *d = *s;
+ }
+ }
+ });
+ }
+ }
+ Ok(())
+}
+
+/// Block (`bx`, `by`)'s texels, and which of them lie inside the image.
+fn gather(texels: &[[u8; 4]], (width, height): (usize, usize), bx: usize, by: usize) -> (Block, Mask) {
+ let mut block = [[0u8; 4]; 16];
+ let mut inside: Mask = 0;
+ for (t, texel) in block.iter_mut().enumerate() {
+ let (x, y) = (bx * 4 + t % 4, by * 4 + t / 4);
+ if x < width
+ && y < height
+ && let Some(p) = texels.get(y * width + x)
+ {
+ *texel = *p;
+ inside |= 1 << t;
+ }
+ }
+ (block, inside)
+}
+
+/// The next mip level: each pixel the average of the 2×2 pixels it covers, or of 2×3, 3×2 or
+/// 3×3 along the last column and row of an odd-sized level, so no pixel is dropped.
+fn downsample(src: &[u8], (w, h): (usize, usize), (nw, nh): (usize, usize), channels: usize) -> Result, CodecError> {
+ let len = nw.checked_mul(nh).and_then(|n| n.checked_mul(channels)).ok_or_else(|| encode_err("mip level size overflow"))?;
+ let mut dst = crate::options::alloc_zeroed::(len)?;
+ let span = |i: usize, n: usize, size: usize| 2 * i..=if i + 1 == n { size.saturating_sub(1) } else { 2 * i + 1 };
+ rows(&mut dst, nw * channels, |y, row| {
+ let ys = span(y, nh, h);
+ for (x, px) in row.chunks_exact_mut(channels.max(1)).enumerate() {
+ let xs = span(x, nw, w);
+ for (c, v) in px.iter_mut().enumerate() {
+ let (mut sum, mut n) = (0u32, 0u32);
+ for sy in ys.clone() {
+ for sx in xs.clone() {
+ if let Some(&s) = src.get((sy * w + sx) * channels + c) {
+ sum += u32::from(s);
+ n += 1;
+ }
+ }
+ }
+ *v = ((sum + n / 2) / n.max(1)) as u8;
+ }
+ }
+ });
+ Ok(dst)
+}
diff --git a/crates/codecs/src/codecs/dds_bc.rs b/crates/codecs/src/codecs/dds_bc.rs
new file mode 100644
index 0000000000..bbd55b4bf3
--- /dev/null
+++ b/crates/codecs/src/codecs/dds_bc.rs
@@ -0,0 +1,368 @@
+//! BC1, BC2 and BC3 (DXT1, DXT3, DXT5) blocks, after Microsoft's public block compression
+//! documentation ("Block Compression (Direct3D 10)" and "Texture Block Compression in Direct3D
+//! 11"): the palettes the DDS reader decodes with, and the block encoder the DDS writer uses.
+//!
+//! The encoder fits a block's colours with two RGB565 endpoints along their principal axis,
+//! refines them by least squares, and scores every candidate against the exact palette the
+//! reader rebuilds, so the indices it picks are the best ones for what a decoder shows.
+
+/// The 16 texels of a 4×4 block, row by row, as RGBA.
+pub(crate) type Block = [[u8; 4]; 16];
+
+/// Bit `t` set for each texel `t` of a block that counts: edge blocks of a texture whose size is
+/// not a multiple of four have texels outside the image.
+pub(crate) type Mask = u16;
+
+pub(crate) fn rgb565(c: u16) -> [u8; 3] {
+ let scale = |v: u16, max: u16| ((u32::from(v) * 255 + u32::from(max) / 2) / u32::from(max)) as u8;
+ [scale(c >> 11, 31), scale((c >> 5) & 63, 63), scale(c & 31, 31)]
+}
+
+/// The colours a colour block's indices select. Four-colour mode (BC1 with `c0 > c1`, and every
+/// BC2 and BC3 block) adds two colours at thirds; three-colour mode adds the midpoint and
+/// transparent black.
+pub(crate) fn colour_palette(c0: u16, c1: u16, four: bool) -> [[u8; 4]; 4] {
+ let (a, b) = (rgb565(c0), rgb565(c1));
+ let mix = |wa: u32, wb: u32| {
+ let d = wa + wb;
+ let at = |i: usize| ((wa * u32::from(a[i]) + wb * u32::from(b[i]) + d / 2) / d) as u8;
+ [at(0), at(1), at(2), 255]
+ };
+ if four {
+ [[a[0], a[1], a[2], 255], [b[0], b[1], b[2], 255], mix(2, 1), mix(1, 2)]
+ } else {
+ [[a[0], a[1], a[2], 255], [b[0], b[1], b[2], 255], mix(1, 1), [0; 4]]
+ }
+}
+
+/// A BC3 alpha block's eight values: six interpolated at sevenths when `a0 > a1`, else four
+/// interpolated at fifths followed by 0 and 255.
+pub(crate) fn alpha_palette(a0: u8, a1: u8) -> [u8; 8] {
+ let (a0, a1) = (u32::from(a0), u32::from(a1));
+ let mut palette = [a0, a1, 0, 0, 0, 0, 0, 255];
+ if a0 > a1 {
+ for (k, v) in palette.iter_mut().enumerate().skip(2) {
+ let k = k as u32;
+ *v = ((8 - k) * a0 + (k - 1) * a1 + 3) / 7;
+ }
+ } else {
+ for (k, v) in palette.iter_mut().enumerate().take(6).skip(2) {
+ let k = k as u32;
+ *v = ((6 - k) * a0 + (k - 1) * a1 + 2) / 5;
+ }
+ }
+ palette.map(|v| v as u8)
+}
+
+/// A BC1 block. Texels under half opacity become BC1's transparent black, which needs the
+/// three-colour mode; an opaque block takes whichever mode fits its colours better.
+pub(crate) fn encode_bc1(block: &Block, inside: Mask) -> [u8; 8] {
+ let transparent = select(block, inside, |t| t[3] < 128);
+ colour_block(block, inside & !transparent, transparent, true)
+}
+
+/// A BC2 block: four bits of alpha per texel, then a four-colour block.
+pub(crate) fn encode_bc2(block: &Block, inside: Mask) -> [u8; 16] {
+ let bits = block.iter().enumerate().fold(0u64, |bits, (t, texel)| bits | ((u64::from(texel[3]) * 15 + 127) / 255) << (4 * t));
+ join(bits.to_le_bytes(), colour_block(block, inside, 0, false))
+}
+
+/// A BC3 block: an interpolated alpha block, then a four-colour block.
+pub(crate) fn encode_bc3(block: &Block, inside: Mask) -> [u8; 16] {
+ join(alpha_block(block, inside), colour_block(block, inside, 0, false))
+}
+
+fn join(a: [u8; 8], b: [u8; 8]) -> [u8; 16] {
+ std::array::from_fn(|i| if i < 8 { a.get(i) } else { b.get(i - 8) }.copied().unwrap_or(0))
+}
+
+/// The texels of `inside` for which `f` holds.
+fn select(block: &Block, inside: Mask, f: impl Fn(&[u8; 4]) -> bool) -> Mask {
+ block.iter().enumerate().filter(|(t, texel)| inside >> t & 1 != 0 && f(texel)).fold(0, |m, (t, _)| m | 1 << t)
+}
+
+fn rgb_of(texel: &[u8; 4]) -> [f32; 3] {
+ [f32::from(texel[0]), f32::from(texel[1]), f32::from(texel[2])]
+}
+
+/// RGB565 of a colour, rounding each channel to the nearest level.
+fn to565(c: [f32; 3]) -> u16 {
+ let q = |v: f32, max: f32| (v.clamp(0.0, 255.0) * max / 255.0).round() as u16;
+ q(c[0], 31.0) << 11 | q(c[1], 63.0) << 5 | q(c[2], 31.0)
+}
+
+fn distance(a: &[u8; 4], b: &[u8; 4]) -> u32 {
+ (0..3).map(|i| u32::from(a[i].abs_diff(b[i])).pow(2)).sum()
+}
+
+/// A colour block (endpoints, then 2-bit indices) for the `opaque` texels' colours, with the
+/// `transparent` ones on BC1's transparent index. `bc1` allows the three-colour mode; BC2 and
+/// BC3 always decode four colours.
+fn colour_block(block: &Block, opaque: Mask, transparent: Mask, bc1: bool) -> [u8; 8] {
+ let colours: Vec<[f32; 3]> = block.iter().enumerate().filter(|(t, _)| opaque >> t & 1 != 0).map(|(_, texel)| rgb_of(texel)).collect();
+ if colours.is_empty() {
+ // Nothing visible: every texel on the transparent index (or index 0 of black).
+ let indices = if transparent != 0 { u32::MAX } else { 0 };
+ return pack(0, 0, indices);
+ }
+ let (lo, hi) = principal_endpoints(&colours);
+ let mut candidates = vec![(to565(lo), to565(hi))];
+ let modes: &[bool] = if transparent != 0 {
+ &[false]
+ } else if bc1 {
+ &[true, false]
+ } else {
+ &[true]
+ };
+ let mut best: Option<(u32, [u8; 8])> = None;
+ // Each refinement starts from the best block so far: its indices give the least-squares
+ // endpoints, which become one more candidate.
+ for round in 0..3 {
+ for &(e0, e1) in &candidates {
+ for &four in modes {
+ let (bytes, error) = fit(block, opaque, transparent, e0, e1, four);
+ if best.is_none_or(|(e, _)| error < e) {
+ best = Some((error, bytes));
+ }
+ }
+ }
+ let Some((error, bytes)) = best else { break };
+ if error == 0 || round == 2 {
+ break;
+ }
+ candidates = least_squares(block, opaque, &bytes).into_iter().collect();
+ if candidates.is_empty() {
+ break;
+ }
+ }
+ best.map_or([0; 8], |(_, bytes)| bytes)
+}
+
+/// Orders endpoints `e0`, `e1` for the mode (`c0 > c1` for four colours, `c0 <= c1` for three)
+/// and picks each texel's nearest palette colour. Returns the block and its squared error.
+fn fit(block: &Block, opaque: Mask, transparent: Mask, e0: u16, e1: u16, four: bool) -> ([u8; 8], u32) {
+ let (c0, c1) = if four == (e0 >= e1) { (e0, e1) } else { (e1, e0) };
+ // Equal endpoints can't be in four-colour order; the palette is then one colour either way.
+ let palette = colour_palette(c0, c1, four && c0 > c1);
+ let choices = if four && c0 > c1 { 4 } else { 3 };
+ let mut indices = 0u32;
+ let mut error = 0u32;
+ for (t, texel) in block.iter().enumerate() {
+ let index = if transparent >> t & 1 != 0 {
+ 3
+ } else if opaque >> t & 1 != 0 {
+ let (i, d) = palette.iter().take(choices).enumerate().map(|(i, p)| (i, distance(texel, p))).min_by_key(|&(_, d)| d).unwrap_or((0, 0));
+ error += d;
+ i as u32
+ } else {
+ 0
+ };
+ indices |= index << (2 * t);
+ }
+ (pack(c0, c1, indices), error)
+}
+
+fn pack(c0: u16, c1: u16, indices: u32) -> [u8; 8] {
+ let (c0, c1, i) = (c0.to_le_bytes(), c1.to_le_bytes(), indices.to_le_bytes());
+ [c0[0], c0[1], c1[0], c1[1], i[0], i[1], i[2], i[3]]
+}
+
+/// The ends of the colours' spread along their principal axis (the covariance's dominant
+/// eigenvector, by power iteration), clamped to the RGB cube.
+fn principal_endpoints(colours: &[[f32; 3]]) -> ([f32; 3], [f32; 3]) {
+ let n = colours.len().max(1) as f32;
+ let mean = [0, 1, 2].map(|i| colours.iter().map(|c| c[i]).sum::() / n);
+ let mut cov = [[0f32; 3]; 3];
+ for c in colours {
+ let d = [c[0] - mean[0], c[1] - mean[1], c[2] - mean[2]];
+ for (i, row) in cov.iter_mut().enumerate() {
+ for (j, v) in row.iter_mut().enumerate() {
+ *v += d[i] * d[j];
+ }
+ }
+ }
+ let times = |a: [f32; 3]| [0, 1, 2].map(|i| cov[i][0] * a[0] + cov[i][1] * a[1] + cov[i][2] * a[2]);
+ let iterate = |start: usize| {
+ let mut axis = [0f32; 3];
+ axis[start] = 1.0;
+ for _ in 0..8 {
+ let next = times(axis);
+ let len = next.iter().map(|v| v * v).sum::().sqrt();
+ if !len.is_normal() {
+ break;
+ }
+ axis = next.map(|v| v / len);
+ }
+ axis
+ };
+ // From each channel's axis, keeping the one the colours spread along most: a channel that
+ // varies on its own is already an eigenvector, so a single start there would never leave it
+ // even when the others vary more together.
+ let spread = |a: [f32; 3]| times(a).iter().zip(a).map(|(x, y)| x * y).sum::();
+ let axis = [0, 1, 2].map(iterate).into_iter().max_by(|&a, &b| spread(a).total_cmp(&spread(b))).unwrap_or([1.0, 0.0, 0.0]);
+ let along = |c: &[f32; 3]| (c[0] - mean[0]) * axis[0] + (c[1] - mean[1]) * axis[1] + (c[2] - mean[2]) * axis[2];
+ let (lo, hi) = colours.iter().map(along).fold((f32::MAX, f32::MIN), |(lo, hi), t| (lo.min(t), hi.max(t)));
+ let at = |t: f32| [0, 1, 2].map(|i| (mean[i] + t * axis[i]).clamp(0.0, 255.0));
+ if lo > hi { (mean, mean) } else { (at(lo), at(hi)) }
+}
+
+/// The endpoints that minimise the squared error for the indices `bytes` already chose: each
+/// texel's index fixes its weights between the endpoints, giving 2×2 normal equations per
+/// channel. `None` when the indices don't constrain both endpoints.
+fn least_squares(block: &Block, opaque: Mask, bytes: &[u8; 8]) -> Option<(u16, u16)> {
+ let (c0, c1) = (u16::from_le_bytes([bytes[0], bytes[1]]), u16::from_le_bytes([bytes[2], bytes[3]]));
+ let indices = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]);
+ let four = c0 > c1;
+ // How much of c0 each index holds.
+ let weight = |i: u32| match (four, i) {
+ (_, 0) => Some(1.0f32),
+ (_, 1) => Some(0.0),
+ (true, 2) => Some(2.0 / 3.0),
+ (true, _) => Some(1.0 / 3.0),
+ (false, 2) => Some(0.5),
+ (false, _) => None,
+ };
+ let (mut aa, mut ab, mut bb) = (0f32, 0f32, 0f32);
+ let (mut ax, mut bx) = ([0f32; 3], [0f32; 3]);
+ for (t, texel) in block.iter().enumerate() {
+ if opaque >> t & 1 == 0 {
+ continue;
+ }
+ let Some(a) = weight(indices >> (2 * t) & 3) else { continue };
+ let b = 1.0 - a;
+ let c = rgb_of(texel);
+ aa += a * a;
+ ab += a * b;
+ bb += b * b;
+ for ((x0, x1), v) in ax.iter_mut().zip(bx.iter_mut()).zip(c) {
+ *x0 += a * v;
+ *x1 += b * v;
+ }
+ }
+ let det = aa * bb - ab * ab;
+ if det.abs() < 1e-3 {
+ return None;
+ }
+ let e0 = [0, 1, 2].map(|i| (ax[i] * bb - bx[i] * ab) / det);
+ let e1 = [0, 1, 2].map(|i| (bx[i] * aa - ax[i] * ab) / det);
+ Some((to565(e0), to565(e1)))
+}
+
+/// A BC3 alpha block: whichever of the two modes (eight values between the extremes, or six
+/// between the extremes other than 0 and 255, plus exact 0 and 255) fits better.
+fn alpha_block(block: &Block, inside: Mask) -> [u8; 8] {
+ let alphas = || block.iter().enumerate().filter(move |(t, _)| inside >> t & 1 != 0).map(|(_, texel)| texel[3]);
+ let range = |values: &mut dyn Iterator- | values.fold(None, |r: Option<(u8, u8)>, a| Some(r.map_or((a, a), |(lo, hi)| (lo.min(a), hi.max(a)))));
+ let (lo, hi) = range(&mut alphas()).unwrap_or((0, 0));
+ // Eight values need a0 > a1; equal ends fall to the six-value mode, which starts with a0 too.
+ let eight = alpha_fit(block, inside, hi, lo);
+ let (mid_lo, mid_hi) = range(&mut alphas().filter(|a| !matches!(a, 0 | 255))).unwrap_or((0, 0));
+ let six = alpha_fit(block, inside, mid_lo, mid_hi);
+ if six.1 < eight.1 { six.0 } else { eight.0 }
+}
+
+fn alpha_fit(block: &Block, inside: Mask, a0: u8, a1: u8) -> ([u8; 8], u32) {
+ let palette = alpha_palette(a0, a1);
+ let mut bits = 0u64;
+ let mut error = 0u32;
+ for (t, texel) in block.iter().enumerate() {
+ if inside >> t & 1 == 0 {
+ continue;
+ }
+ let (i, d) = palette.iter().enumerate().map(|(i, &p)| (i, u32::from(p.abs_diff(texel[3])).pow(2))).min_by_key(|&(_, d)| d).unwrap_or((0, 0));
+ error += d;
+ bits |= (i as u64) << (3 * t);
+ }
+ let b = bits.to_le_bytes();
+ ([a0, a1, b[0], b[1], b[2], b[3], b[4], b[5]], error)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn decode_colour(bytes: &[u8; 8], bc1: bool) -> Block {
+ let (c0, c1) = (u16::from_le_bytes([bytes[0], bytes[1]]), u16::from_le_bytes([bytes[2], bytes[3]]));
+ let palette = colour_palette(c0, c1, c0 > c1 || !bc1);
+ let indices = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]);
+ std::array::from_fn(|t| palette[(indices >> (2 * t)) as usize & 3])
+ }
+
+ #[test]
+ fn solid_blocks_land_on_the_nearest_565_colour() {
+ for colour in [[0, 0, 0, 255], [255, 255, 255, 255], [12, 200, 77, 255]] {
+ let block = [colour; 16];
+ let out = decode_colour(&encode_bc1(&block, u16::MAX), true);
+ for texel in out {
+ assert!(texel.iter().zip(colour).all(|(a, b)| a.abs_diff(b) <= 4), "{texel:?} for {colour:?}");
+ }
+ }
+ }
+
+ #[test]
+ fn two_colour_blocks_are_exact_when_the_colours_are_565() {
+ let (a, b) = ([255, 0, 0, 255], [0, 0, 255, 255]);
+ let block: Block = std::array::from_fn(|t| if t % 3 == 0 { a } else { b });
+ for bc1 in [true, false] {
+ let bytes = if bc1 { encode_bc1(&block, u16::MAX) } else { colour_block(&block, u16::MAX, 0, false) };
+ assert_eq!(decode_colour(&bytes, bc1), block);
+ }
+ }
+
+ #[test]
+ fn bc1_punches_through_texels_under_half_alpha() {
+ let block: Block = std::array::from_fn(|t| if t < 8 { [200, 100, 50, 255] } else { [9, 9, 9, 127] });
+ let bytes = encode_bc1(&block, u16::MAX);
+ let (c0, c1) = (u16::from_le_bytes([bytes[0], bytes[1]]), u16::from_le_bytes([bytes[2], bytes[3]]));
+ assert!(c0 <= c1, "three-colour mode");
+ let out = decode_colour(&bytes, true);
+ assert!(out[..8].iter().all(|t| t[3] == 255) && out[8..].iter().all(|t| *t == [0; 4]), "{out:?}");
+ }
+
+ #[test]
+ fn four_colour_blocks_keep_c0_above_c1() {
+ // A ramp needs all four colours; BC2 and BC3 blocks must not rely on the order, but
+ // older decoders read c0 <= c1 as three colours, so the encoder never writes that.
+ let block: Block = std::array::from_fn(|t| [(t * 16) as u8, 0, 0, 255]);
+ let bytes = colour_block(&block, u16::MAX, 0, false);
+ assert!(u16::from_le_bytes([bytes[0], bytes[1]]) > u16::from_le_bytes([bytes[2], bytes[3]]));
+ }
+
+ #[test]
+ fn alpha_blocks_pick_the_mode_that_keeps_0_and_255() {
+ // 0, 255 and a cluster in between: the six-value mode stores 0 and 255 exactly.
+ let block: Block = std::array::from_fn(|t| [0, 0, 0, [0, 255, 100, 110][t % 4]]);
+ let bytes = alpha_block(&block, u16::MAX);
+ assert!(bytes[0] <= bytes[1], "six-value mode");
+ let palette = alpha_palette(bytes[0], bytes[1]);
+ let bits = u64::from_le_bytes([bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7], 0, 0]);
+ for (t, texel) in block.iter().enumerate() {
+ let got = palette[(bits >> (3 * t)) as usize & 7];
+ assert!(got.abs_diff(texel[3]) <= 2, "texel {t}: {got} for {}", texel[3]);
+ }
+ }
+
+ #[test]
+ fn the_principal_axis_is_found_when_one_channel_varies_on_its_own() {
+ // Red alternates on its own (variance 64); green and blue alternate together (variance 49
+ // each, 98 along their shared axis), uncorrelated with red. Starting on red alone would
+ // keep red's axis and flatten green and blue; the block must do better than its bounding box.
+ let block: Block = std::array::from_fn(|t| {
+ let gb = if (t / 2) % 2 == 0 { 121 } else { 135 };
+ [if t % 2 == 0 { 120 } else { 136 }, gb, gb, 255]
+ });
+ let error = |bytes: &[u8; 8]| decode_colour(bytes, false).iter().zip(&block).map(|(a, b)| distance(a, b)).sum::
();
+ let (_, bounding_box) = fit(&block, u16::MAX, 0, to565([120.0, 121.0, 121.0]), to565([136.0, 135.0, 135.0]), true);
+ let ours = error(&colour_block(&block, u16::MAX, 0, false));
+ assert!(ours < bounding_box, "{ours} vs the bounding box's {bounding_box}");
+ }
+
+ #[test]
+ fn texels_outside_the_image_are_ignored() {
+ // Only the top-left texel is inside: its colour alone decides the endpoints.
+ let mut block: Block = [[0, 255, 0, 255]; 16];
+ block[0] = [255, 0, 255, 255];
+ let out = decode_colour(&encode_bc1(&block, 1), true);
+ assert_eq!(out[0], [255, 0, 255, 255]);
+ }
+}
diff --git a/crates/codecs/src/codecs/gif_palette.rs b/crates/codecs/src/codecs/gif_palette.rs
new file mode 100644
index 0000000000..eb0a0e2180
--- /dev/null
+++ b/crates/codecs/src/codecs/gif_palette.rs
@@ -0,0 +1,126 @@
+//! The colour table of a GIF, read from its blocks without decoding pixels (GIF89a
+//! specification: Logical Screen Descriptor, Global and Local Color Tables, Graphic Control
+//! Extension).
+//!
+//! A GIF is always palette-based, so it opens in Indexed Color with the table of the frame that
+//! [`crate::decode`] keeps, the first: its Local Color Table when it has one, else the Global
+//! one, and the transparent entry that frame's Graphic Control Extension names.
+
+/// Colours of the table the first frame uses, and its transparent entry. `None` for anything
+/// that isn't a well-formed GIF up to its first image, or a first frame without a table.
+pub(crate) fn palette(b: &[u8]) -> Option<(Vec<[u8; 3]>, Option)> {
+ if !(b.starts_with(b"GIF87a") || b.starts_with(b"GIF89a")) {
+ return None;
+ }
+ // Logical Screen Descriptor: width, height, packed fields, background, aspect.
+ let packed = *b.get(10)?;
+ let mut at = 13;
+ let table = |at: usize, packed: u8| -> Option> {
+ let n = 2usize << (packed & 0x07);
+ let bytes = b.get(at..at.checked_add(n.checked_mul(3)?)?)?;
+ Some(bytes.as_chunks::<3>().0.to_vec())
+ };
+ let global = if packed & 0x80 != 0 {
+ let t = table(at, packed)?;
+ at += t.len() * 3;
+ Some(t)
+ } else {
+ None
+ };
+ let mut transparent = None;
+ loop {
+ match *b.get(at)? {
+ // Extension: label, then data sub-blocks up to a zero-length one.
+ 0x21 => {
+ let label = *b.get(at + 1)?;
+ at += 2;
+ // Graphic Control Extension: block size 4, packed (bit 0: transparent colour
+ // given), delay (2 bytes), transparent colour index.
+ if label == 0xF9 && *b.get(at)? == 4 {
+ let flags = *b.get(at + 1)?;
+ transparent = (flags & 0x01 != 0).then_some(*b.get(at + 4)?);
+ }
+ loop {
+ let size = usize::from(*b.get(at)?);
+ at = at.checked_add(1 + size)?;
+ if size == 0 {
+ break;
+ }
+ }
+ }
+ // Image Descriptor: position and size (8 bytes), packed fields, then the Local Color
+ // Table if bit 7 is set. This is the first frame: its table is the one used.
+ 0x2C => {
+ let packed = *b.get(at + 9)?;
+ let colors = if packed & 0x80 != 0 { table(at + 10, packed)? } else { global? };
+ let transparent = transparent.filter(|i| usize::from(*i) < colors.len());
+ return Some((colors, transparent));
+ }
+ // Trailer, or anything else: no image.
+ _ => return None,
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::palette;
+
+ /// A GIF up to its first image: `global` table entries, optional GCE transparency, optional
+ /// local table.
+ fn gif(global: &[[u8; 3]], transparent: Option, local: Option<&[[u8; 3]]>) -> Vec {
+ let size_bits = |n: usize| (n.max(2).next_power_of_two().trailing_zeros() - 1) as u8;
+ let mut b = b"GIF89a".to_vec();
+ b.extend([4, 0, 4, 0]);
+ b.push(if global.is_empty() { 0 } else { 0x80 | size_bits(global.len()) });
+ b.extend([0, 0]);
+ let pad = |b: &mut Vec, t: &[[u8; 3]]| {
+ let n = 2usize << size_bits(t.len());
+ for k in 0..n {
+ b.extend(t.get(k).copied().unwrap_or([0, 0, 0]));
+ }
+ };
+ if !global.is_empty() {
+ pad(&mut b, global);
+ }
+ // A comment extension first, to skip.
+ b.extend([0x21, 0xFE, 3, b'h', b'i', b'!', 0]);
+ if let Some(i) = transparent {
+ b.extend([0x21, 0xF9, 4, 0x01, 0, 0, i, 0]);
+ }
+ b.extend([0x2C, 0, 0, 0, 0, 4, 0, 4, 0]);
+ match local {
+ Some(t) => {
+ b.push(0x80 | size_bits(t.len()));
+ pad(&mut b, t);
+ }
+ None => b.push(0),
+ }
+ b.extend([2, 2, 0x4C, 0x01, 0, 0x3B]);
+ b
+ }
+
+ #[test]
+ fn the_first_frames_table_and_transparent_entry() {
+ let g = [[255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 255]];
+ assert_eq!(palette(&gif(&g, None, None)), Some((g.to_vec(), None)));
+ assert_eq!(palette(&gif(&g, Some(2), None)), Some((g.to_vec(), Some(2))));
+ // A local table wins over the global one.
+ let l = [[1, 2, 3], [4, 5, 6]];
+ assert_eq!(palette(&gif(&g, Some(1), Some(&l))), Some((l.to_vec(), Some(1))));
+ // A transparent index past the table is no transparent entry.
+ assert_eq!(palette(&gif(&l, Some(9), None)), Some((l.to_vec(), None)));
+ }
+
+ #[test]
+ fn no_table_or_not_a_gif_is_none() {
+ assert_eq!(palette(&gif(&[], None, None)), None, "no table at all");
+ assert_eq!(palette(b"\x89PNG\r\n\x1a\n"), None);
+ assert_eq!(palette(b""), None);
+ let whole = gif(&[[9, 9, 9], [8, 8, 8]], Some(0), None);
+ for cut in 0..whole.len() - 6 {
+ // Cut short anywhere before the image descriptor's fields: never a panic.
+ let _ = palette(&whole[..cut]);
+ }
+ }
+}
diff --git a/crates/codecs/src/codecs/jpeg.rs b/crates/codecs/src/codecs/jpeg.rs
index 9c37628872..a300de4bd1 100644
--- a/crates/codecs/src/codecs/jpeg.rs
+++ b/crates/codecs/src/codecs/jpeg.rs
@@ -33,6 +33,8 @@ pub(crate) struct JpegMeta {
pub exif: Option>,
pub xmp: Option,
pub dpi: Option<(f32, f32)>,
+ /// The APP13 Photoshop image resource blocks (`8BIM` ...), segments joined, without the header.
+ pub photoshop: Option>,
pub adobe_transform: Option,
/// (width, height, components) from the first SOFn.
pub frame: Option<(u32, u32, u8)>,
@@ -108,6 +110,7 @@ pub(crate) fn scan_metadata(b: &[u8]) -> JpegMeta {
.or_else(|| m.xmp.as_deref().and_then(xmp_resolution))
.or_else(|| m.exif.as_deref().and_then(exif_resolution))
.or(jfif_dpi);
+ m.photoshop = Some(photoshop).filter(|p| !p.is_empty());
if !icc_parts.is_empty() {
icc_parts.sort_by_key(|p| p.0);
let total = icc_parts[0].1 as usize;
@@ -226,7 +229,7 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result
}
let mut img = Image::from_raw(w, h, layout, SampleType::U8, px)?;
img.icc = meta.icc;
- img.meta = Metadata { exif: meta.exif, xmp: meta.xmp, dpi: meta.dpi, ..Default::default() };
+ img.meta = Metadata { exif: meta.exif, xmp: meta.xmp, dpi: meta.dpi, photoshop_resources: meta.photoshop, ..Default::default() };
if end == DataEnd::Truncated {
img.warnings.push(DecodeWarning::Truncated { format: F });
}
diff --git a/crates/codecs/src/codecs/mod.rs b/crates/codecs/src/codecs/mod.rs
index 32e68b336a..1980e887c8 100644
--- a/crates/codecs/src/codecs/mod.rs
+++ b/crates/codecs/src/codecs/mod.rs
@@ -1,10 +1,12 @@
pub(crate) mod dds;
+pub(crate) mod dds_bc;
pub(crate) mod deep_exr;
pub(crate) mod dpx;
pub(crate) mod exr;
pub(crate) mod exr_cryptomatte;
pub(crate) mod fits;
pub(crate) mod gif_frames;
+pub(crate) mod gif_palette;
pub(crate) mod heif;
pub(crate) mod jpeg;
pub(crate) mod pcx;
diff --git a/crates/codecs/src/codecs/png.rs b/crates/codecs/src/codecs/png.rs
index 7c1a633d17..f894b3a30d 100644
--- a/crates/codecs/src/codecs/png.rs
+++ b/crates/codecs/src/codecs/png.rs
@@ -7,7 +7,7 @@ use crate::Format;
use crate::error::CodecError;
use crate::fidelity::Plan;
use crate::image::{ChannelLayout, DecodeWarning, Image, Metadata, SampleType};
-use crate::options::{EncodeOptions, Limits, PngCompression, alloc_zeroed};
+use crate::options::{EncodeOptions, Limits, PngCompression, PngStereo, alloc_zeroed};
const F: Format = Format::Png;
pub(crate) const XMP_KEYWORD: &str = "XML:com.adobe.xmp";
@@ -127,6 +127,38 @@ pub fn palette(bytes: &[u8]) -> Result, CodecError> {
Ok(Some(PngPalette { colors, transparent }))
}
+/// The stereo pairing a PNG declares in its `sTER` chunk (PNG Extensions 1.4.0, §3.6), read
+/// from the headers without decoding pixels. `None` when the file has no CRC-valid `sTER` with a
+/// defined mode byte. The first valid chunk wins, as a later one would contradict it.
+pub fn stereo(bytes: &[u8]) -> Option {
+ let mut rest = bytes.get(8..)?;
+ while let Some((length, tail)) = rest.split_first_chunk::<4>() {
+ let Some((kind, tail)) = tail.split_first_chunk::<4>() else { break };
+ let length = u32::from_be_bytes(*length);
+ if length > i32::MAX as u32 {
+ break;
+ }
+ let Ok(length) = usize::try_from(length) else { break };
+ let Some(data) = tail.get(..length) else { break };
+ let Some((checksum, next)) = tail.get(length..).and_then(|t| t.split_first_chunk::<4>()) else { break };
+ rest = next;
+ if kind == b"IEND" {
+ break;
+ }
+ if kind != b"sTER" || data.len() != 1 {
+ continue;
+ }
+ let mut crc = flate2::Crc::new();
+ crc.update(kind);
+ crc.update(data);
+ if crc.sum() != u32::from_be_bytes(*checksum) {
+ continue;
+ }
+ return PngStereo::from_mode(data[0]);
+ }
+ None
+}
+
fn map_png_err(e: png::DecodingError) -> CodecError {
match e {
png::DecodingError::LimitsExceeded => CodecError::LimitExceeded("PNG decoder memory limit".into()),
@@ -346,6 +378,10 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result, bpp: usize, scratch: &mut [u8], o
}
};
let cost = |v: &[u8]| v.iter().map(|&b| u64::from((b as i8).unsigned_abs())).sum::();
- let mut best = (cost(row), 0u8);
+ let mut best = cost(row);
+ let start = out.len();
+ out.push(0);
+ out.extend_from_slice(row);
for kind in 1u8..=4 {
+ if best == 0 {
+ break;
+ }
+ let mut c = 0u64;
for i in 0..n {
let x = row[i];
scratch[i] = match kind {
@@ -515,27 +558,17 @@ fn filter_row(row: &[u8], prev: Option<&[u8]>, bpp: usize, scratch: &mut [u8], o
3 => x.wrapping_sub(((u16::from(left(i)) + u16::from(up(i))) / 2) as u8),
_ => x.wrapping_sub(paeth(left(i), up(i), ul(i))),
};
+ c += u64::from((scratch[i] as i8).unsigned_abs());
+ if c >= best {
+ break;
+ }
}
- let c = cost(&scratch[..n]);
- if c < best.0 {
- best = (c, kind);
+ if c < best {
+ best = c;
+ out[start] = kind;
+ out[start + 1..].copy_from_slice(&scratch[..n]);
}
}
- out.push(best.1);
- if best.1 == 0 {
- out.extend_from_slice(row);
- return;
- }
- for i in 0..n {
- let x = row[i];
- scratch[i] = match best.1 {
- 1 => x.wrapping_sub(left(i)),
- 2 => x.wrapping_sub(up(i)),
- 3 => x.wrapping_sub(((u16::from(left(i)) + u16::from(up(i))) / 2) as u8),
- _ => x.wrapping_sub(paeth(left(i), up(i), ul(i))),
- };
- }
- out.extend_from_slice(&scratch[..n]);
}
const ADLER_MOD: u32 = 65_521;
@@ -574,6 +607,7 @@ pub fn encode_indexed(
palette: &[[u8; 3]],
transparent: Option,
dpi: Option<(f32, f32)>,
+ stereo: Option,
) -> Result, CodecError> {
if width == 0 || height == 0 || indices.len() != width as usize * height as usize {
return Err(CodecError::InvalidImage("index data does not match the size".into()));
@@ -603,12 +637,95 @@ pub fn encode_indexed(
{
let encoder = png::Encoder::with_info(&mut out, info).map_err(|e| CodecError::encode(F, e))?;
let mut writer = encoder.write_header().map_err(|e| CodecError::encode(F, e))?;
+ if let Some(stereo) = stereo {
+ writer.write_chunk(png::chunk::ChunkType(*b"sTER"), &[stereo.mode()]).map_err(|e| CodecError::encode(F, e))?;
+ }
writer.write_image_data(indices).map_err(|e| CodecError::encode(F, e))?;
writer.finish().map_err(|e| CodecError::encode(F, e))?;
}
Ok(out)
}
+#[cfg(test)]
+mod stereo_tests {
+ use super::*;
+
+ /// One CRC-valid chunk as it appears in a file.
+ fn chunk(kind: &[u8; 4], data: &[u8]) -> Vec {
+ let mut out = Vec::new();
+ out.extend_from_slice(&(data.len() as u32).to_be_bytes());
+ out.extend_from_slice(kind);
+ out.extend_from_slice(data);
+ let mut crc = flate2::Crc::new();
+ crc.update(kind);
+ crc.update(data);
+ out.extend_from_slice(&crc.sum().to_be_bytes());
+ out
+ }
+
+ fn png_with(stereo: &[u8]) -> Vec {
+ let mut out = b"\x89PNG\r\n\x1a\n".to_vec();
+ out.extend_from_slice(&chunk(b"IHDR", &[0, 0, 0, 2, 0, 0, 0, 1, 8, 2, 0, 0, 0]));
+ if !stereo.is_empty() {
+ out.extend_from_slice(&chunk(b"sTER", stereo));
+ }
+ out.extend_from_slice(&chunk(b"IDAT", &[0x78, 0x01, 0x01, 0x00, 0xff, 0xff, 0, 3, 1, 0, 0]));
+ out.extend_from_slice(&chunk(b"IEND", &[]));
+ out
+ }
+
+ #[test]
+ fn reads_both_defined_views() {
+ assert_eq!(stereo(&png_with(&[0])), Some(PngStereo::Horizontal));
+ assert_eq!(stereo(&png_with(&[1])), Some(PngStereo::Vertical));
+ assert_eq!(stereo(&png_with(&[])), None);
+ // Reserved and unknown mode bytes are not a pairing we can keep.
+ assert_eq!(stereo(&png_with(&[2])), None);
+ assert_eq!(stereo(&png_with(&[255])), None);
+ // A two-byte payload is not sTER data.
+ assert_eq!(stereo(&png_with(&[0, 0])), None);
+ assert_eq!(stereo(b"not a png"), None);
+ }
+
+ #[test]
+ fn a_broken_crc_is_not_a_stereo_mark() {
+ let mut bytes = png_with(&[0]);
+ let kind = bytes.windows(4).position(|w| w == b"sTER").unwrap();
+ // The CRC trails the type and the one-byte payload of the chunk.
+ bytes[kind + 8] ^= 0xff;
+ assert_eq!(stereo(&bytes), None);
+ }
+
+ #[test]
+ fn encode_writes_the_chunk_before_idat_and_it_reads_back() {
+ let img = Image::from_raw(2, 1, ChannelLayout::Rgb, SampleType::U8, vec![9; 6]).unwrap();
+ for view in [PngStereo::Horizontal, PngStereo::Vertical] {
+ let opts = EncodeOptions { png_stereo: Some(view), ..Default::default() };
+ let bytes = crate::encode(&img, Format::Png, &opts).unwrap();
+ assert_eq!(stereo(&bytes), Some(view));
+ let back = decode(&bytes, &Limits::default()).unwrap();
+ assert_eq!(back.data(), img.data());
+ let ter = bytes.windows(4).position(|w| w == b"sTER").unwrap();
+ let idat = bytes.windows(4).position(|w| w == b"IDAT").unwrap();
+ assert!(ter < idat, "sTER before IDAT");
+ }
+ let plain = crate::encode(&img, Format::Png, &EncodeOptions::default()).unwrap();
+ assert_eq!(stereo(&plain), None);
+ }
+
+ #[test]
+ fn interlaced_and_indexed_encoders_keep_the_mark() {
+ let img = Image::from_raw(2, 2, ChannelLayout::Rgb, SampleType::U8, vec![9; 12]).unwrap();
+ let opts = EncodeOptions { png_stereo: Some(PngStereo::Vertical), png_interlaced: true, ..Default::default() };
+ let bytes = crate::encode(&img, Format::Png, &opts).unwrap();
+ assert_eq!(stereo(&bytes), Some(PngStereo::Vertical));
+ let pal = [[255, 0, 0], [0, 0, 255]];
+ let bytes = encode_indexed(2, 1, &[0, 1], &pal, None, None, Some(PngStereo::Horizontal)).unwrap();
+ assert_eq!(stereo(&bytes), Some(PngStereo::Horizontal));
+ assert_eq!(palette(&bytes).unwrap().map(|p| p.colors), Some(pal.to_vec()));
+ }
+}
+
#[cfg(test)]
mod indexed_tests {
use super::*;
@@ -617,14 +734,14 @@ mod indexed_tests {
fn png8_roundtrips_through_the_decoder() {
let pal = [[255, 0, 0], [0, 0, 255], [0, 0, 0]];
let idx = [0u8, 1, 2, 1, 0, 2];
- let bytes = encode_indexed(3, 2, &idx, &pal, Some(2), None).unwrap();
+ let bytes = encode_indexed(3, 2, &idx, &pal, Some(2), None, None).unwrap();
let img = decode(&bytes, &Limits::default()).unwrap();
assert_eq!(img.dimensions(), (3, 2));
let px = img.convert(ChannelLayout::Rgba, SampleType::U8);
assert_eq!(&px.data()[..8], &[255, 0, 0, 255, 0, 0, 255, 255]);
assert_eq!(px.data()[11], 0, "transparent entry");
- assert!(encode_indexed(3, 2, &[9; 6], &pal, None, None).is_err());
- assert!(encode_indexed(3, 3, &idx, &pal, None, None).is_err());
+ assert!(encode_indexed(3, 2, &[9; 6], &pal, None, None, None).is_err());
+ assert!(encode_indexed(3, 3, &idx, &pal, None, None, None).is_err());
}
fn png(color: png::ColorType, trns: Option<&[u8]>) -> Vec {
@@ -643,7 +760,7 @@ mod indexed_tests {
#[test]
fn palette_reads_the_table_and_its_one_transparent_entry() {
- let bytes = encode_indexed(2, 1, &[0, 1], &[[1, 2, 3], [4, 5, 6]], Some(1), None).unwrap();
+ let bytes = encode_indexed(2, 1, &[0, 1], &[[1, 2, 3], [4, 5, 6]], Some(1), None, None).unwrap();
let colors = vec![[1, 2, 3], [4, 5, 6]];
assert_eq!(palette(&bytes).unwrap(), Some(PngPalette { colors: colors.clone(), transparent: Some(1) }));
assert_eq!(palette(&png(png::ColorType::Indexed, None)).unwrap(), Some(PngPalette { colors, transparent: None }));
@@ -659,6 +776,109 @@ mod indexed_tests {
mod parallel_tests {
use super::*;
+ #[test]
+ #[ignore = "manual 24 MP release benchmark"]
+ fn benchmark_filter_selection() {
+ use std::{hint::black_box, time::Instant};
+ for pattern in ["zero", "identical", "varied"] {
+ let row: Vec = (0..6000 * 4).map(|i| if pattern == "zero" { 0 } else { (i * 17 + i / 13) as u8 }).collect();
+ let prev: Vec<_> = row.iter().map(|v| if pattern == "identical" { *v } else { v.wrapping_add(3) }).collect();
+ let mut scratch = vec![0; row.len()];
+ let mut out = Vec::with_capacity(row.len() + 1);
+ for trial in 0..6 {
+ type Filter = fn(&[u8], Option<&[u8]>, usize, &mut [u8], &mut Vec);
+ let mut paths: [(&str, Filter); 2] = [("main", previous_filter_row), ("split", filter_row)];
+ if trial % 2 == 1 {
+ paths.reverse();
+ }
+ for (name, path) in paths {
+ let t = Instant::now();
+ for _ in 0..4000 {
+ out.clear();
+ black_box(path)(black_box(&row), Some(black_box(&prev)), 4, &mut scratch, &mut out);
+ black_box(&out);
+ }
+ println!("filter,{pattern},{trial},{name},{:.6}", t.elapsed().as_secs_f64() * 1000.0);
+ }
+ }
+ }
+ }
+
+ // The main-branch implementation before this refactor.
+ fn previous_filter_row(row: &[u8], prev: Option<&[u8]>, bpp: usize, scratch: &mut [u8], out: &mut Vec) {
+ let n = row.len();
+ let up = |i: usize| prev.map_or(0, |p| p[i]);
+ let left = |i: usize| if i >= bpp { row[i - bpp] } else { 0 };
+ let ul = |i: usize| if i >= bpp { prev.map_or(0, |p| p[i - bpp]) } else { 0 };
+ let paeth = |a: u8, b: u8, c: u8| {
+ let p = i16::from(a) + i16::from(b) - i16::from(c);
+ let (pa, pb, pc) = ((p - i16::from(a)).abs(), (p - i16::from(b)).abs(), (p - i16::from(c)).abs());
+ if pa <= pb && pa <= pc {
+ a
+ } else if pb <= pc {
+ b
+ } else {
+ c
+ }
+ };
+ let cost = |v: &[u8]| v.iter().map(|&b| u64::from((b as i8).unsigned_abs())).sum::();
+ let mut best = (cost(row), 0u8);
+ for kind in 1u8..=4 {
+ for i in 0..n {
+ let x = row[i];
+ scratch[i] = match kind {
+ 1 => x.wrapping_sub(left(i)),
+ 2 => x.wrapping_sub(up(i)),
+ 3 => x.wrapping_sub(((u16::from(left(i)) + u16::from(up(i))) / 2) as u8),
+ _ => x.wrapping_sub(paeth(left(i), up(i), ul(i))),
+ };
+ }
+ let c = cost(&scratch[..n]);
+ if c < best.0 {
+ best = (c, kind);
+ }
+ }
+ out.push(best.1);
+ if best.1 == 0 {
+ out.extend_from_slice(row);
+ return;
+ }
+ for i in 0..n {
+ let x = row[i];
+ scratch[i] = match best.1 {
+ 1 => x.wrapping_sub(left(i)),
+ 2 => x.wrapping_sub(up(i)),
+ 3 => x.wrapping_sub(((u16::from(left(i)) + u16::from(up(i))) / 2) as u8),
+ _ => x.wrapping_sub(paeth(left(i), up(i), ul(i))),
+ };
+ }
+ out.extend_from_slice(&scratch[..n]);
+ }
+
+ #[test]
+ fn adaptive_filter_preserves_minimum_cost_and_ties() {
+ for bpp in [1, 2, 3, 4, 6, 8] {
+ for seed in 0..32usize {
+ let row: Vec = (0..137).map(|i| (i * seed + i / 5) as u8).collect();
+ let shifted: Vec<_> = row.iter().map(|v| v.wrapping_add(seed as u8)).collect();
+ for prev in [None, Some(row.as_slice()), Some(shifted.as_slice())] {
+ let mut expected = vec![99];
+ let mut actual = expected.clone();
+ let mut scratch = vec![0; row.len()];
+ previous_filter_row(&row, prev, bpp, &mut scratch, &mut expected);
+ filter_row(&row, prev, bpp, &mut scratch, &mut actual);
+ assert_eq!(actual, expected, "bpp={bpp}, seed={seed}");
+ }
+ }
+ }
+ let mut out = Vec::new();
+ filter_row(&[0; 16], None, 4, &mut [0; 16], &mut out);
+ assert_eq!(out, vec![0; 17]);
+ out.clear();
+ filter_row(&[1; 16], Some(&[0; 16]), 1, &mut [0; 16], &mut out);
+ assert_eq!(out, [vec![1, 1], vec![0; 15]].concat());
+ }
+
#[test]
fn adler_combine_matches_one_pass() {
let a: Vec = (0..100_000u32).map(|i| (i * 7 + i / 13) as u8).collect();
diff --git a/crates/codecs/src/codecs/tiff_exif.rs b/crates/codecs/src/codecs/tiff_exif.rs
index d07f555f30..00d47d0702 100644
--- a/crates/codecs/src/codecs/tiff_exif.rs
+++ b/crates/codecs/src/codecs/tiff_exif.rs
@@ -6,7 +6,9 @@
//! the same reader ([`super::tiff_ifd::File`]): reading a TIFF builds a block from the file's own
//! directories, writing one copies the block's directories into the file. Values are copied as
//! bytes (re-ordered when the byte orders differ), so tags PhotoCraft doesn't know survive.
-//! Classic TIFF only on the reading side: an EXIF block has 4-byte offsets.
+//! Classic TIFF only on the reading side: an EXIF block has 4-byte offsets. What is copied is
+//! bounded by the stream's own size and a fixed cap, because entries of a hostile file can
+//! overlap (see [`read`]).
use tiff::encoder::{DirectoryEncoder, TiffEncoder, TiffKind};
use tiff::tags::{Tag, Type};
@@ -27,6 +29,11 @@ const TAG_INTEROP_IFD: u16 = 40965;
const IFD0_TAGS: [u16; 7] = [270, 271, 272, 305, 306, 315, 33432];
/// At most this many entries are copied from one directory.
const MAX_FIELDS: usize = 1024;
+/// At most this many value bytes are copied from one stream, over all its directories. Real EXIF
+/// is far smaller (the largest Nikon maker note seen is under 1 MB); the cap leaves room for
+/// that and for a few such notes. A stream never yields more than its own length either (see
+/// [`read`]).
+const MAX_COPIED_BYTES: usize = 4 * 1024 * 1024;
/// One entry with its value bytes in the byte order of the stream it came from.
#[derive(Clone, Debug, PartialEq)]
@@ -53,13 +60,22 @@ impl ExifDirs {
}
}
-fn fields(f: &File<'_>, ifd: &Ifd, keep: impl Fn(u16) -> bool) -> Vec {
- ifd.entries
- .iter()
- .filter(|e| keep(e.tag) && !matches!(e.tag, TAG_EXIF_IFD | TAG_GPS_IFD | TAG_INTEROP_IFD))
- .filter_map(|e| Some(Field { tag: e.tag, ty: e.ty, count: u32::try_from(e.count).ok()?, value: f.data(e)?.to_vec() }))
- .take(MAX_FIELDS)
- .collect()
+/// The entries of `ifd` that `keep` accepts, with their values copied. An entry's value only has
+/// to lie inside the file, so entries may overlap: `budget` is the number of value bytes that may
+/// still be copied (shared by all the directories of one stream), and an entry whose value does
+/// not fit in what is left is skipped, so later, smaller entries can still be kept.
+fn fields(f: &File<'_>, ifd: &Ifd, budget: &mut usize, keep: impl Fn(u16) -> bool) -> Vec {
+ let mut out = Vec::new();
+ for e in ifd.entries.iter().filter(|e| keep(e.tag) && !matches!(e.tag, TAG_EXIF_IFD | TAG_GPS_IFD | TAG_INTEROP_IFD)) {
+ if out.len() >= MAX_FIELDS {
+ break;
+ }
+ let (Ok(count), Some(value)) = (u32::try_from(e.count), f.data(e)) else { continue };
+ let Some(left) = budget.checked_sub(value.len()) else { continue };
+ *budget = left;
+ out.push(Field { tag: e.tag, ty: e.ty, count, value: value.to_vec() });
+ }
+ out
}
fn sub(f: &File<'_>, ifd: &Ifd, tag: u16) -> Option {
@@ -69,14 +85,24 @@ fn sub(f: &File<'_>, ifd: &Ifd, tag: u16) -> Option {
/// The EXIF parts of the classic TIFF stream `b` (a TIFF file, or an EXIF block without its
/// `Exif\0\0` prefix): IFD0's descriptive tags and the Exif and GPS directories.
+///
+/// The values copied add up to at most [`MAX_COPIED_BYTES`] and to at most `b.len()`: the values
+/// of a well-formed stream are distinct bytes of it, while a hostile one can have many entries
+/// point at the same bytes and so ask for far more than its size. Entries that do not fit in what
+/// is left are left out (the small descriptive directories come first, so a huge maker note
+/// cannot crowd out the GPS position).
pub(crate) fn read(b: &[u8]) -> Option {
let f = File::parse(b)?;
if f.big {
return None;
}
let ifd0 = f.ifd(f.first_ifd)?;
- let dir = |tag: u16| sub(&f, &ifd0, tag).map(|d| fields(&f, &d, |_| true)).unwrap_or_default();
- Some(ExifDirs { le: f.le, ifd0: fields(&f, &ifd0, |t| IFD0_TAGS.contains(&t)), exif: dir(TAG_EXIF_IFD), gps: dir(TAG_GPS_IFD) })
+ let mut budget = MAX_COPIED_BYTES.min(b.len());
+ let ifd0_fields = fields(&f, &ifd0, &mut budget, |t| IFD0_TAGS.contains(&t));
+ let mut dir = |tag: u16| sub(&f, &ifd0, tag).map(|d| fields(&f, &d, &mut budget, |_| true)).unwrap_or_default();
+ let gps = dir(TAG_GPS_IFD);
+ let exif = dir(TAG_EXIF_IFD);
+ Some(ExifDirs { le: f.le, ifd0: ifd0_fields, exif, gps })
}
/// Bytes per element whose order a byte-order change reverses: a RATIONAL is two LONGs.
diff --git a/crates/codecs/src/fidelity.rs b/crates/codecs/src/fidelity.rs
index 594735edb4..a6937b571c 100644
--- a/crates/codecs/src/fidelity.rs
+++ b/crates/codecs/src/fidelity.rs
@@ -6,7 +6,7 @@ use std::fmt;
use crate::format::{Format, caps};
use crate::image::{ChannelLayout, Image, SampleType};
-use crate::options::EncodeOptions;
+use crate::options::{DdsCompression, EncodeOptions};
/// Something that will be lost or altered when writing an image to a format.
#[derive(Debug, Clone, PartialEq)]
@@ -165,6 +165,8 @@ pub(crate) fn plan(image: &Image, format: Format, opts: &EncodeOptions) -> Plan
},
// CIELab has no float form: float Lab is written with 16 bits.
Format::Tiff if layout.is_lab() && sample.is_float() => Plan { layout: pick_layout(layout, c.layouts, c.alpha), sample: SampleType::U16 },
+ // A block-compressed DDS stores RGBA whatever the source; uncompressed keeps gray and RGB.
+ Format::Dds if opts.dds.compression.is_block() => Plan { layout: ChannelLayout::Rgba, sample: SampleType::U8 },
_ => Plan { layout: pick_layout(layout, c.layouts, c.alpha), sample: pick_sample(sample, c.depths) },
}
}
@@ -211,7 +213,8 @@ pub fn fidelity_warnings_with(image: &Image, format: Format, opts: &EncodeOption
if sl.has_alpha() && !p.layout.has_alpha() && image.has_translucency() {
w.push(W::AlphaDiscarded);
}
- if format == Format::Gif && p.layout.has_alpha() && image.has_translucency() {
+ let binary_alpha = format == Format::Gif || (format == Format::Dds && opts.dds.compression == DdsCompression::Bc1);
+ if binary_alpha && p.layout.has_alpha() && image.has_translucency() {
w.push(W::AlphaBinarized);
}
if sl.is_cmyk() && !p.layout.is_cmyk() {
@@ -227,7 +230,7 @@ pub fn fidelity_warnings_with(image: &Image, format: Format, opts: &EncodeOption
// Compression
if format == Format::Gif {
w.push(W::PaletteQuantized);
- } else if c.lossy || (format == Format::WebP && !opts.webp_lossless) {
+ } else if c.lossy || (format == Format::WebP && !opts.webp_lossless) || (format == Format::Dds && opts.dds.compression.is_block()) {
w.push(W::LossyCompression);
}
diff --git a/crates/codecs/src/format.rs b/crates/codecs/src/format.rs
index fa2e1e6cb3..24611bd90d 100644
--- a/crates/codecs/src/format.rs
+++ b/crates/codecs/src/format.rs
@@ -19,7 +19,8 @@ pub enum Format {
Pcx,
/// X PixMap icons (XPM2 plain text and XPM3 C-string arrays).
Xpm,
- /// DirectDraw Surface: the top mip level of a 2D texture, uncompressed or BC1–BC3.
+ /// DirectDraw Surface: a 2D texture, uncompressed or BC1–BC3. Reading takes the top mip
+ /// level; writing can add the full mip chain ([`crate::DdsOptions`]).
Dds,
Qoi,
OpenExr,
@@ -71,10 +72,6 @@ pub const ASYMMETRIC_EXCEPTIONS: &[(Format, &str)] = &[
Format::Pcx,
"PCX is supported for opening legacy paint and clipart assets. New files should use PNG; writing obsolete indexed and planar variants is intentionally not provided.",
),
- (
- Format::Dds,
- "DDS is supported for opening game and 3D textures. Writing one well means choosing a block compression and generating mip levels, which belongs in a texture export, not Save As.",
- ),
(
Format::Avif,
"AVIF encode uses ravif (pure Rust) but decoding requires dav1d (C); read stays unsupported \
@@ -255,7 +252,8 @@ pub fn caps(format: Format) -> FormatCaps {
Format::Pnm => FormatCaps { depths: &[S::U8, S::U16, S::F32], layouts: ALL_LAYOUTS, ..base },
Format::Pcx => FormatCaps { read: true, write: false, layouts: &[L::Rgb], alpha: false, ..base },
Format::Xpm => FormatCaps { read: true, write: false, layouts: &[L::Rgba], ..base },
- Format::Dds => FormatCaps { read: true, write: false, layouts: RGB_GRAY, ..base },
+ // Uncompressed by default; a block compression is lossy (see `fidelity_warnings_with`).
+ Format::Dds => FormatCaps { layouts: RGB_GRAY, ..base },
Format::Qoi => base,
Format::OpenExr => FormatCaps { depths: &[S::F16, S::F32], layouts: RGB_GRAY, ..base },
Format::Hdr => FormatCaps { depths: &[S::F32], layouts: &[L::Rgb], alpha: false, lossy: true, ..base },
diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs
index 86611599d5..8c06694b9a 100644
--- a/crates/codecs/src/lib.rs
+++ b/crates/codecs/src/lib.rs
@@ -28,7 +28,7 @@ pub mod web;
pub use crate::codecs::exr_cryptomatte::{cryptomatte_id, cryptomatte_key, cryptomatte_preview_color};
pub use crate::codecs::gif_frames::{AnimationFrame, MAX_GIF_FRAMES};
-pub use crate::codecs::png::{PngPalette, encode_indexed as encode_png_indexed, palette as png_palette};
+pub use crate::codecs::png::{PngPalette, encode_indexed as encode_png_indexed, palette as png_palette, stereo as png_stereo};
pub use crate::codecs::tiff::{PhotoshopTags as TiffPhotoshopTags, photoshop_tags as tiff_photoshop_tags, writes_little_endian as tiff_writes_little_endian};
pub use crate::codecs::tiff_ifd::{TiffInfo, TiffPage, TiffPageKind};
pub use crate::error::CodecError;
@@ -38,7 +38,9 @@ pub use crate::format::{ASYMMETRIC_EXCEPTIONS, Format, FormatCaps, caps, detect,
pub use crate::image::{
ChannelLayout, CryptomatteBuffer, CryptomatteLayer, DecodeWarning, DeepChannel, DeepImage, ExrChannelInfo, ExrPartInfo, Image, Metadata, SampleType,
};
-pub use crate::options::{DecodeOptions, DpxBits, EncodeOptions, ExrCompression, Limits, PngCompression, TiffCompression};
+pub use crate::options::{
+ DdsCompression, DdsOptions, DecodeOptions, DpxBits, EncodeOptions, ExrCompression, Limits, PngCompression, PngStereo, TiffCompression,
+};
pub use crate::orientation::{exif_orientation, upright_exif, upright_xmp};
pub use crate::resolution::{exif_resolution, export_exif, export_xmp, photoshop_resolution, xmp_resolution};
pub use crate::source_settings::{WebpEncoding, jpeg_quality, webp_encoding};
@@ -126,6 +128,12 @@ pub fn probe_size(bytes: &[u8]) -> Result<(u32, u32), CodecError> {
Ok(if (5..=8).contains(&o) { (h, w) } else { (w, h) })
}
+/// The colour table of a GIF's first frame (its Local Color Table, else the Global one) and the
+/// entry its Graphic Control Extension makes transparent, read without decoding pixels.
+pub fn gif_palette(bytes: &[u8]) -> Option<(Vec<[u8; 3]>, Option)> {
+ crate::codecs::gif_palette::palette(bytes)
+}
+
/// Returns the embedded colour table for an indexed BMP, if it has one.
pub fn bmp_palette(bytes: &[u8]) -> Result>, CodecError> {
via_image::bmp_palette(bytes)
@@ -252,7 +260,7 @@ pub fn encode(image: &Image, format: Format, opts: &EncodeOptions) -> Result pnm::encode(image, plan, opts),
Format::Pcx => Err(CodecError::unsupported(format, "PCX is import-only; save as PNG instead")),
Format::Xpm => Err(CodecError::unsupported(format, "XPM is import-only; save as PNG instead")),
- Format::Dds => Err(CodecError::unsupported(format, "DDS is import-only; save as PNG instead")),
+ Format::Dds => dds::encode(image, plan, opts),
Format::Fits => fits::encode(image, plan, opts),
Format::Dpx => dpx::encode(image, plan, opts),
Format::Cineon => Err(CodecError::unsupported(format, "Cineon is import-only; save as DPX, TIFF or EXR instead")),
diff --git a/crates/codecs/src/options.rs b/crates/codecs/src/options.rs
index eee5b38c0a..08de328574 100644
--- a/crates/codecs/src/options.rs
+++ b/crates/codecs/src/options.rs
@@ -100,6 +100,34 @@ pub enum PngCompression {
Best,
}
+/// How a PNG's `sTER` chunk pairs its two views (PNG Extensions 1.4.0, §3.6).
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum PngStereo {
+ /// Left and right views side by side, left first.
+ Horizontal,
+ /// Left and right views stacked, left on top.
+ Vertical,
+}
+
+impl PngStereo {
+ /// The view for an `sTER` mode byte; reserved and unknown values are `None`.
+ pub fn from_mode(mode: u8) -> Option {
+ match mode {
+ 0 => Some(Self::Horizontal),
+ 1 => Some(Self::Vertical),
+ _ => None,
+ }
+ }
+
+ /// The `sTER` mode byte.
+ pub fn mode(self) -> u8 {
+ match self {
+ Self::Horizontal => 0,
+ Self::Vertical => 1,
+ }
+ }
+}
+
/// TIFF strip compression. The default is none, as in Photoshop: uncompressed strips are the
/// only kind every TIFF reader opens (Deflate and the predictor are not baseline TIFF, #4484).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
@@ -154,6 +182,61 @@ impl DpxBits {
}
}
+/// How a DDS texture stores its pixels.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+pub enum DdsCompression {
+ /// Uncompressed, 8 bits per channel: BGRA (BGRX without alpha), or luminance with or
+ /// without alpha for grayscale. Lossless.
+ #[default]
+ None,
+ /// BC1 (DXT1): 4 bits per pixel. Alpha is on or off: texels under half opacity become
+ /// transparent black.
+ Bc1,
+ /// BC2 (DXT3): 8 bits per pixel, with 4 bits of explicit alpha per texel.
+ Bc2,
+ /// BC3 (DXT5): 8 bits per pixel, with smooth interpolated alpha.
+ Bc3,
+}
+
+impl DdsCompression {
+ pub const ALL: [DdsCompression; 4] = [DdsCompression::None, DdsCompression::Bc1, DdsCompression::Bc2, DdsCompression::Bc3];
+
+ /// The name scripts and the CLI use: `none`, `bc1`, `bc2` or `bc3`.
+ pub fn name(self) -> &'static str {
+ match self {
+ DdsCompression::None => "none",
+ DdsCompression::Bc1 => "bc1",
+ DdsCompression::Bc2 => "bc2",
+ DdsCompression::Bc3 => "bc3",
+ }
+ }
+
+ /// [`Self::name`] back to a compression, also taking the DXT names (`dxt1`, `dxt3`,
+ /// `dxt5`), in any case. Anything else is `None`.
+ pub fn from_name(name: &str) -> Option {
+ match name.to_ascii_lowercase().as_str() {
+ "none" => Some(DdsCompression::None),
+ "bc1" | "dxt1" => Some(DdsCompression::Bc1),
+ "bc2" | "dxt3" => Some(DdsCompression::Bc2),
+ "bc3" | "dxt5" => Some(DdsCompression::Bc3),
+ _ => None,
+ }
+ }
+
+ /// A block compression (lossy), not uncompressed pixels.
+ pub fn is_block(self) -> bool {
+ self != DdsCompression::None
+ }
+}
+
+/// What a DDS file is written with.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+pub struct DdsOptions {
+ pub compression: DdsCompression,
+ /// Write the full mip chain down to 1×1, each level a box filter of the one above.
+ pub mipmaps: bool,
+}
+
/// Options for [`crate::encode`].
#[derive(Debug, Clone)]
pub struct EncodeOptions {
@@ -164,6 +247,9 @@ pub struct EncodeOptions {
pub png_compression: PngCompression,
/// Write Adam7-interlaced PNG.
pub png_interlaced: bool,
+ /// Write an `sTER` chunk marking the image as a stereo pair (PNG Extensions 1.4.0, §3.6),
+ /// so a stereo PNG keeps its mark when saved again (#6063).
+ pub png_stereo: Option,
/// WebP: lossless (VP8L, via `image-webp`) or lossy (our VP8 encoder, see
/// `codecs::vp8`) with [`Self::webp_quality`].
pub webp_lossless: bool,
@@ -176,6 +262,8 @@ pub struct EncodeOptions {
pub exr_compression: ExrCompression,
/// DPX bits per sample. `None` follows the image: 8 for 8-bit, 16 otherwise.
pub dpx_bits: Option,
+ /// DDS compression and mip chain.
+ pub dds: DdsOptions,
/// Embed the ICC profile when the format supports it.
pub embed_icc: bool,
/// Embed EXIF/XMP/DPI/text when the format supports it.
@@ -189,12 +277,14 @@ impl Default for EncodeOptions {
jpeg_chroma_subsampling: true,
png_compression: PngCompression::Default,
png_interlaced: false,
+ png_stereo: None,
webp_lossless: true,
webp_quality: 80,
tiff_compression: TiffCompression::None,
tiff_bigtiff: false,
exr_compression: ExrCompression::Zip16,
dpx_bits: None,
+ dds: DdsOptions::default(),
embed_icc: true,
embed_metadata: true,
}
diff --git a/crates/codecs/src/web.rs b/crates/codecs/src/web.rs
index 584ceb9d6e..dfd0cd9b90 100644
--- a/crates/codecs/src/web.rs
+++ b/crates/codecs/src/web.rs
@@ -285,7 +285,8 @@ pub fn decode_wbmp(bytes: &[u8]) -> Result<(u32, u32, Vec), CodecError> {
}
/// JPEG from 8-bit RGB (`rgb.len() == 3·w·h`): quality 1..=100, optional progressive scans and
-/// optimised Huffman tables (Save for Web's "Optimized"), optional ICC profile, density and XMP.
+/// optimised Huffman tables (Save for Web's "Optimized"), optional ICC profile, density, EXIF and
+/// XMP. EXIF that doesn't fit in one segment is left out (see [`jpeg_exif_fits`]).
#[allow(clippy::too_many_arguments)]
pub fn encode_jpeg_rgb8(
width: u32,
@@ -296,6 +297,7 @@ pub fn encode_jpeg_rgb8(
optimized: bool,
icc: Option<&[u8]>,
dpi: Option,
+ exif: Option<&[u8]>,
xmp: Option<&str>,
) -> Result, CodecError> {
let e = |e: jpeg_encoder::EncodingError| CodecError::encode(Format::Jpeg, e);
@@ -319,9 +321,14 @@ pub fn encode_jpeg_rgb8(
let v = d.round().min(65535.0) as u16;
enc.set_density(jpeg_encoder::Density::Inch { x: v, y: v });
}
+ let ppi = dpi.filter(|d| *d >= 1.0).map(|d| (d, d));
+ // Upright, without the source's thumbnail, at the JFIF resolution, as Save As writes it.
+ if let Some(seg) = exif.and_then(|x| crate::codecs::jpeg::exif_segment(x, ppi)) {
+ enc.add_app_segment(1, &seg).map_err(e)?;
+ }
if let Some(x) = xmp {
// Its `tiff:` resolution, if any, says what the JFIF density says (#1691).
- let x = crate::resolution::export_xmp(x, dpi.filter(|d| *d >= 1.0).map(|d| (d, d)));
+ let x = crate::resolution::export_xmp(x, ppi);
let mut seg = b"http://ns.adobe.com/xap/1.0/\0".to_vec();
seg.extend_from_slice(x.as_bytes());
enc.add_app_segment(1, &seg).map_err(e)?;
@@ -333,6 +340,11 @@ pub fn encode_jpeg_rgb8(
Ok(out)
}
+/// Whether [`encode_jpeg_rgb8`] can write `exif` at `dpi` (it must fit in one APP1 segment).
+pub fn jpeg_exif_fits(exif: &[u8], dpi: Option) -> bool {
+ crate::codecs::jpeg::exif_segment(exif, dpi.filter(|d| *d >= 1.0).map(|d| (d, d))).is_some()
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -359,7 +371,7 @@ mod tests {
let rgb: Vec = (0..16 * 16 * 3).map(|i| (i * 37 % 256) as u8).collect();
for (q, want) in [(1, (2, 2)), (50, (2, 2)), (51, (1, 1)), (60, (1, 1)), (89, (1, 1)), (100, (1, 1))] {
for progressive in [false, true] {
- let bytes = encode_jpeg_rgb8(16, 16, &rgb, q, progressive, false, None, None, None).unwrap();
+ let bytes = encode_jpeg_rgb8(16, 16, &rgb, q, progressive, false, None, None, None, None).unwrap();
assert_eq!(luma_sampling(&bytes), Some(want), "quality {q} progressive {progressive}");
}
}
@@ -428,8 +440,8 @@ mod tests {
#[test]
fn progressive_jpeg_decodes() {
let rgb: Vec = (0..64 * 48 * 3).map(|i| (i % 251) as u8).collect();
- let base = encode_jpeg_rgb8(64, 48, &rgb, 80, false, false, None, Some(72.0), Some(" ")).unwrap();
- let prog = encode_jpeg_rgb8(64, 48, &rgb, 80, true, true, None, None, None).unwrap();
+ let base = encode_jpeg_rgb8(64, 48, &rgb, 80, false, false, None, Some(72.0), None, Some(" ")).unwrap();
+ let prog = encode_jpeg_rgb8(64, 48, &rgb, 80, true, true, None, None, None, None).unwrap();
assert_ne!(base, prog);
assert_eq!(crate::decode(&base).unwrap().meta.xmp.as_deref(), Some(" "));
// SOF2 marks a progressive frame.
@@ -442,7 +454,7 @@ mod tests {
fn web_jpeg_xmp_resolution_follows_the_density() {
let rgb = vec![100u8; 8 * 8 * 3];
let xmp = r#" "#;
- let out = encode_jpeg_rgb8(8, 8, &rgb, 80, false, false, None, Some(72.0), Some(xmp)).unwrap();
+ let out = encode_jpeg_rgb8(8, 8, &rgb, 80, false, false, None, Some(72.0), None, Some(xmp)).unwrap();
let img = crate::decode(&out).unwrap();
assert_eq!(img.meta.dpi, Some((72.0, 72.0)));
assert_eq!(img.meta.xmp.as_deref().and_then(crate::xmp_resolution), Some((72.0, 72.0)));
@@ -461,7 +473,7 @@ mod subsampling_tests {
let rgb: Vec = (0..w * h).flat_map(|i| [((i % w) * 4) as u8, 76, ((i / w) * 5) as u8]).collect();
for q in [30, 60, 95] {
for (prog, opt) in [(false, false), (false, true), (true, false), (true, true)] {
- let bytes = encode_jpeg_rgb8(w, h, &rgb, q, prog, opt, None, None, None).unwrap();
+ let bytes = encode_jpeg_rgb8(w, h, &rgb, q, prog, opt, None, None, None, None).unwrap();
let ours = crate::decode(&bytes).unwrap().convert(crate::ChannelLayout::Rgb, crate::SampleType::U8);
let err = ours.data().iter().zip(&rgb).map(|(a, b)| i32::from(*a).abs_diff(i32::from(*b))).max().unwrap();
assert!(err < 24, "q{q} progressive={prog} optimized={opt}: max error {err}");
diff --git a/crates/codecs/tests/dds.rs b/crates/codecs/tests/dds.rs
index 766555f6e5..ebf4042f48 100644
--- a/crates/codecs/tests/dds.rs
+++ b/crates/codecs/tests/dds.rs
@@ -59,11 +59,12 @@ const WHITE: u16 = 0xffff;
const INDICES_0123: [u8; 4] = [0x04, 0x0e, 0, 0];
#[test]
-fn dds_is_readable_but_not_writable() {
+fn dds_is_readable_and_writable() {
assert_eq!(from_extension("texture.DDS"), Some(Format::Dds));
- assert!(caps(Format::Dds).read && !caps(Format::Dds).write);
- let img = photocraft_codecs::Image::from_u8(1, 1, ChannelLayout::Rgba, vec![0, 0, 0, 0]).unwrap();
- assert!(matches!(encode(&img, Format::Dds, &Default::default()), Err(CodecError::Unsupported { .. })));
+ assert!(caps(Format::Dds).read && caps(Format::Dds).write);
+ let img = photocraft_codecs::Image::from_u8(1, 1, ChannelLayout::Rgba, vec![1, 2, 3, 0]).unwrap();
+ let b = encode(&img, Format::Dds, &Default::default()).unwrap();
+ assert_eq!(decode(&b).unwrap().data(), &[1, 2, 3, 0]);
}
#[test]
diff --git a/crates/codecs/tests/dds_write.rs b/crates/codecs/tests/dds_write.rs
new file mode 100644
index 0000000000..3225dd14d6
--- /dev/null
+++ b/crates/codecs/tests/dds_write.rs
@@ -0,0 +1,372 @@
+//! Writing DDS textures: every storage read back through the reader, the header fields, sizes
+//! that aren't a multiple of four, the mip chain, and the block-compression error on ramps.
+
+use photocraft_codecs::{ChannelLayout, DdsCompression, DdsOptions, EncodeOptions, FidelityWarning, Format, Image, decode, encode, fidelity_warnings_with};
+
+const DDSD_PITCH: u32 = 0x8;
+const DDSD_MIPMAPCOUNT: u32 = 0x2_0000;
+const DDSD_LINEARSIZE: u32 = 0x8_0000;
+const REQUIRED: u32 = 0x1007; // CAPS | HEIGHT | WIDTH | PIXELFORMAT
+const DDSCAPS_COMPLEX: u32 = 0x8;
+const DDSCAPS_TEXTURE: u32 = 0x1000;
+const DDSCAPS_MIPMAP: u32 = 0x40_0000;
+const DDPF_ALPHAPIXELS: u32 = 0x1;
+const DDPF_FOURCC: u32 = 0x4;
+const DDPF_RGB: u32 = 0x40;
+const DDPF_LUMINANCE: u32 = 0x2_0000;
+
+const BLOCKS: [DdsCompression; 3] = [DdsCompression::Bc1, DdsCompression::Bc2, DdsCompression::Bc3];
+
+fn opts(compression: DdsCompression, mipmaps: bool) -> EncodeOptions {
+ EncodeOptions { dds: DdsOptions { compression, mipmaps }, ..Default::default() }
+}
+
+fn le32(b: &[u8], at: usize) -> u32 {
+ u32::from_le_bytes(b[at..at + 4].try_into().unwrap())
+}
+
+/// A ramp along one line through RGB (red up, green up half as fast, blue down) across the
+/// texture, and alpha falling from opaque down it: every block's colours lie on a line, which
+/// is what a colour block stores.
+fn ramp(w: u32, h: u32, layout: ChannelLayout) -> Image {
+ let ch = layout.channels();
+ let at = |v: u32, n: u32| (v * 255 / (n - 1).max(1)) as u8;
+ let mut data = Vec::new();
+ for y in 0..h {
+ for x in 0..w {
+ let r = at(x, w);
+ let rgba = [r, 64 + r / 2, 255 - r, 255 - at(y, h)];
+ match ch {
+ 1 => data.push(rgba[0]),
+ 2 => data.extend([rgba[0], rgba[3]]),
+ _ => data.extend(&rgba[..ch]),
+ }
+ }
+ }
+ Image::from_u8(w, h, layout, data).unwrap()
+}
+
+/// The same RGBA image, fully opaque.
+fn opaque(img: &Image) -> Image {
+ let data = img.data().chunks(4).flat_map(|p| [p[0], p[1], p[2], 255]).collect();
+ Image::from_u8(img.width(), img.height(), ChannelLayout::Rgba, data).unwrap()
+}
+
+/// Largest difference in channel `c` (of four) between RGBA images.
+fn max_diff_channel(a: &Image, b: &Image, c: usize) -> u8 {
+ a.data().chunks(4).zip(b.data().chunks(4)).map(|(x, y)| x[c].abs_diff(y[c])).max().unwrap_or(0)
+}
+
+/// Largest colour difference between RGBA images where `b` is visible (BC1 turns texels under
+/// half opacity into transparent black).
+fn max_colour_diff(a: &Image, b: &Image) -> u8 {
+ a.data().chunks(4).zip(b.data().chunks(4)).filter(|(_, y)| y[3] != 0).flat_map(|(x, y)| (0..3).map(|c| x[c].abs_diff(y[c]))).max().unwrap_or(0)
+}
+
+/// A 2D gradient: red across, green down and blue along the diagonal, opaque.
+fn gradient(w: u32, h: u32) -> Image {
+ let data = (0..h).flat_map(|y| (0..w).flat_map(move |x| [(x * 4).min(255) as u8, (y * 4).min(255) as u8, ((x + y) * 2).min(255) as u8, 255])).collect();
+ Image::from_u8(w, h, ChannelLayout::Rgba, data).unwrap()
+}
+
+/// PSNR of the colour (not the alpha) of two RGBA images.
+fn psnr(a: &Image, b: &Image) -> f64 {
+ let rgb = |i: &Image| i.data().chunks(4).flat_map(|p| [p[0], p[1], p[2]]).map(f64::from).collect::>();
+ let (a, b) = (rgb(a), rgb(b));
+ let mse = a.iter().zip(&b).map(|(x, y)| (x - y).powi(2)).sum::() / a.len() as f64;
+ if mse == 0.0 { f64::INFINITY } else { 10.0 * (255.0f64.powi(2) / mse).log10() }
+}
+
+/// Bytes of one level as a reader computes them from the header's pixel format.
+fn level_bytes(file: &[u8], w: usize, h: usize) -> usize {
+ if le32(file, 80) & DDPF_FOURCC != 0 {
+ let block = if &file[84..88] == b"DXT1" { 8 } else { 16 };
+ w.div_ceil(4) * h.div_ceil(4) * block
+ } else {
+ w * h * le32(file, 88) as usize / 8
+ }
+}
+
+/// Mip level `i` of a written texture, cut out as a single-level texture and decoded.
+fn level(file: &[u8], i: u32) -> Image {
+ let (w, h) = (le32(file, 16) as usize, le32(file, 12) as usize);
+ let size = |i: u32| ((w >> i).max(1), (h >> i).max(1));
+ let start = 128 + (0..i).map(|j| level_bytes(file, size(j).0, size(j).1)).sum::();
+ let (lw, lh) = size(i);
+ let mut b = file[..128].to_vec();
+ b[12..16].copy_from_slice(&(lh as u32).to_le_bytes());
+ b[16..20].copy_from_slice(&(lw as u32).to_le_bytes());
+ b[28..32].copy_from_slice(&1u32.to_le_bytes());
+ b.extend(&file[start..start + level_bytes(file, lw, lh)]);
+ decode(&b).unwrap()
+}
+
+#[test]
+fn uncompressed_textures_round_trip_every_layout_exactly() {
+ for (layout, pf_flags, bits, masks) in [
+ (ChannelLayout::Gray, DDPF_LUMINANCE, 8, [0xff, 0, 0, 0]),
+ (ChannelLayout::GrayA, DDPF_LUMINANCE | DDPF_ALPHAPIXELS, 16, [0xff, 0, 0, 0xff00]),
+ (ChannelLayout::Rgb, DDPF_RGB, 32, [0xff_0000, 0xff00, 0xff, 0]),
+ (ChannelLayout::Rgba, DDPF_RGB | DDPF_ALPHAPIXELS, 32, [0xff_0000, 0xff00, 0xff, 0xff00_0000]),
+ ] {
+ for (w, h) in [(1, 1), (5, 3), (17, 9)] {
+ let img = ramp(w, h, layout);
+ let b = encode(&img, Format::Dds, &EncodeOptions::default()).unwrap();
+ let back = decode(&b).unwrap();
+ assert_eq!(back.layout(), layout, "{layout:?} {w}x{h}");
+ assert_eq!(back.data(), img.data(), "{layout:?} {w}x{h}");
+
+ assert_eq!((&b[..4], le32(&b, 4), le32(&b, 76)), (&b"DDS "[..], 124, 32));
+ assert_eq!(le32(&b, 8), REQUIRED | DDSD_PITCH, "pitch, not linear size, and no mip count");
+ assert_eq!((le32(&b, 12), le32(&b, 16)), (h, w));
+ assert_eq!(le32(&b, 20), w * bits / 8, "pitch of {layout:?} {w}x{h}");
+ assert_eq!((le32(&b, 28), le32(&b, 108)), (1, DDSCAPS_TEXTURE));
+ assert_eq!((le32(&b, 80), le32(&b, 88)), (pf_flags, bits));
+ assert_eq!([le32(&b, 92), le32(&b, 96), le32(&b, 100), le32(&b, 104)], masks);
+ assert_eq!(b.len(), 128 + (w * h * bits / 8) as usize);
+ }
+ }
+}
+
+#[test]
+fn block_compressed_textures_round_trip_through_the_reader() {
+ for (compression, fourcc, block) in [(DdsCompression::Bc1, b"DXT1", 8), (DdsCompression::Bc2, b"DXT3", 16), (DdsCompression::Bc3, b"DXT5", 16)] {
+ // Not multiples of four, and the 1×1 and 2×2 textures smaller than one block.
+ for (w, h) in [(1, 1), (2, 2), (4, 4), (5, 3), (7, 13), (33, 17)] {
+ let img = ramp(w, h, ChannelLayout::Rgba);
+ let b = encode(&img, Format::Dds, &opts(compression, false)).unwrap();
+ let linear = w.div_ceil(4) * h.div_ceil(4) * block;
+ assert_eq!(&b[84..88], fourcc);
+ assert_eq!((le32(&b, 80), le32(&b, 88)), (DDPF_FOURCC, 0));
+ assert_eq!(le32(&b, 8), REQUIRED | DDSD_LINEARSIZE, "linear size, not pitch");
+ assert_eq!(le32(&b, 20), linear, "{compression:?} {w}x{h}: the top level's size");
+ assert_eq!(b.len(), 128 + linear as usize);
+
+ let back = decode(&b).unwrap();
+ assert_eq!((back.dimensions(), back.layout()), ((w, h), ChannelLayout::Rgba));
+ // Steep ramps across tiny textures put three colours where a block interpolates at
+ // thirds, so this bound is loose; it still catches swapped channels or misplaced
+ // blocks. `block_compression_error_on_gradients_is_bounded` is the tight one. A BC1
+ // block with transparent texels has only three colours, so BC1's colours are
+ // checked on the opaque ramp.
+ let (src, back_colour) = if compression == DdsCompression::Bc1 {
+ let o = opaque(&img);
+ let back = decode(&encode(&o, Format::Dds, &opts(compression, false)).unwrap()).unwrap();
+ (o, back)
+ } else {
+ (img.clone(), back.clone())
+ };
+ let colour = max_colour_diff(&src, &back_colour);
+ assert!(colour <= 24, "{compression:?} {w}x{h}: colour off by {colour}");
+ let alpha = max_diff_channel(&img, &back, 3);
+ match compression {
+ DdsCompression::Bc1 => assert!(back.data().chunks(4).zip(img.data().chunks(4)).all(|(b, i)| b[3] == if i[3] < 128 { 0 } else { 255 })),
+ _ => assert!(alpha <= 8, "{compression:?} {w}x{h}: alpha off by {alpha}"),
+ }
+ }
+ }
+}
+
+#[test]
+fn gray_and_rgb_sources_are_written_as_rgba_blocks() {
+ for layout in [ChannelLayout::Gray, ChannelLayout::GrayA, ChannelLayout::Rgb] {
+ for compression in BLOCKS {
+ let img = ramp(8, 8, layout);
+ let back = decode(&encode(&img, Format::Dds, &opts(compression, false)).unwrap()).unwrap();
+ let want = img.convert(ChannelLayout::Rgba, img.sample_type());
+ assert_eq!(back.layout(), ChannelLayout::Rgba);
+ let colour = max_colour_diff(&want, &back);
+ assert!(colour <= 8, "{layout:?} {compression:?}: colour off by {colour}");
+ }
+ }
+}
+
+#[test]
+fn block_compression_error_on_gradients_is_bounded() {
+ let (line, plane) = (ramp(64, 64, ChannelLayout::Rgba), gradient(64, 64));
+ for compression in BLOCKS {
+ let roundtrip = |img: &Image| decode(&encode(img, Format::Dds, &opts(compression, false)).unwrap()).unwrap();
+ let (line_back, plane_back) = (roundtrip(&line), roundtrip(&plane));
+ let (line_err, plane_err) = (max_colour_diff(&line, &line_back), max_colour_diff(&plane, &plane_back));
+ let p = psnr(&plane, &plane_back);
+ let alpha = max_diff_channel(&line, &line_back, 3);
+ // Colours on a line only lose the RGB565 rounding of the endpoints (up to 4 levels in
+ // red and blue). A gradient in two directions is not on a line: the spread off a
+ // block's best line alone limits this one to about 40 dB, and RGB565 costs a little
+ // more (the encoder measures 38.6 dB and 9 levels at most).
+ assert!(line_err <= 5, "{compression:?}: colour ramp off by {line_err}");
+ assert!(plane_err <= 10, "{compression:?}: 2D gradient off by {plane_err}");
+ assert!(p >= 38.0, "{compression:?}: 2D gradient PSNR {p:.1} dB");
+ match compression {
+ DdsCompression::Bc1 => assert!(line_back.data().chunks(4).zip(line.data().chunks(4)).all(|(b, i)| b[3] == if i[3] < 128 { 0 } else { 255 })),
+ // Four bits: within half a step of 17.
+ DdsCompression::Bc2 => assert!(alpha <= 8, "BC2 alpha off by {alpha}"),
+ // Eight values across a block's few levels of alpha.
+ _ => assert!(alpha <= 2, "BC3 alpha off by {alpha}"),
+ }
+ }
+}
+
+#[test]
+fn solid_and_cutout_textures_keep_their_values() {
+ // RGB565-exact colours and BC2's 4-bit alpha steps come back exactly.
+ let solid = Image::from_u8(6, 5, ChannelLayout::Rgba, [255, 0, 255, 170].repeat(30)).unwrap();
+ for compression in BLOCKS {
+ let back = decode(&encode(&solid, Format::Dds, &opts(compression, false)).unwrap()).unwrap();
+ let want = if compression == DdsCompression::Bc1 { [255, 0, 255, 255] } else { [255, 0, 255, 170] };
+ assert!(back.data().chunks(4).all(|p| p == want), "{compression:?}: {:?}", &back.data()[..4]);
+ }
+ // A cutout: BC1's transparent texels decode as transparent black, the rest stay opaque.
+ let cutout: Vec = (0..36).flat_map(|i| if (i / 6 + i % 6) % 2 == 0 { [200, 40, 40, 255] } else { [10, 10, 10, 0] }).collect();
+ let cutout = Image::from_u8(6, 6, ChannelLayout::Rgba, cutout).unwrap();
+ for compression in BLOCKS {
+ let back = decode(&encode(&cutout, Format::Dds, &opts(compression, false)).unwrap()).unwrap();
+ assert_eq!(max_diff_channel(&cutout, &back, 3), 0, "{compression:?}");
+ if compression == DdsCompression::Bc1 {
+ assert!(back.data().chunks(4).filter(|p| p[3] == 0).all(|p| p == [0, 0, 0, 0]));
+ }
+ }
+}
+
+#[test]
+fn mipmaps_write_the_full_chain_box_filtered() {
+ // 16×8 halves to 8×4, 4×2, 2×1 and 1×1: five levels.
+ let img = ramp(16, 8, ChannelLayout::Rgba);
+ let b = encode(&img, Format::Dds, &opts(DdsCompression::None, true)).unwrap();
+ assert_eq!(le32(&b, 8), REQUIRED | DDSD_PITCH | DDSD_MIPMAPCOUNT);
+ assert_eq!(le32(&b, 28), 5);
+ assert_eq!(le32(&b, 108), DDSCAPS_TEXTURE | DDSCAPS_COMPLEX | DDSCAPS_MIPMAP);
+ assert_eq!(b.len(), 128 + 4 * (16 * 8 + 8 * 4 + 4 * 2 + 2 + 1));
+ assert_eq!(decode(&b).unwrap().data(), img.data(), "the reader still reads the top level");
+ let mut above = img.clone();
+ for i in 1..5 {
+ let got = level(&b, i);
+ assert_eq!(got.dimensions(), ((16 >> i).max(1), (8 >> i).max(1)));
+ let want = box_filter(&above);
+ assert_eq!(got.data(), want.data(), "level {i}");
+ above = got;
+ }
+ // 1×1 has a single level.
+ let one = encode(&ramp(1, 1, ChannelLayout::Rgba), Format::Dds, &opts(DdsCompression::None, true)).unwrap();
+ assert_eq!((le32(&one, 28), le32(&one, 8) & DDSD_MIPMAPCOUNT), (1, 0));
+}
+
+/// The documented filter, written independently: each pixel averages the 2×2 pixels it covers,
+/// and the last column and row of an odd-sized level take in the leftover pixel too.
+fn box_filter(src: &Image) -> Image {
+ let (w, h) = (src.width() as usize, src.height() as usize);
+ let (nw, nh) = ((w / 2).max(1), (h / 2).max(1));
+ let ch = src.layout().channels();
+ let span = |i: usize, n: usize, size: usize| (2 * i, if i + 1 == n { size } else { 2 * i + 2 });
+ let mut out = Vec::new();
+ for y in 0..nh {
+ for x in 0..nw {
+ let ((x0, x1), (y0, y1)) = (span(x, nw, w), span(y, nh, h));
+ for c in 0..ch {
+ let values: Vec =
+ (y0..y1).flat_map(|sy| (x0..x1).map(move |sx| (sy, sx))).map(|(sy, sx)| u32::from(src.data()[(sy * w + sx) * ch + c])).collect();
+ let n = values.len() as u32;
+ out.push(((values.iter().sum::() + n / 2) / n) as u8);
+ }
+ }
+ }
+ Image::from_u8(nw as u32, nh as u32, src.layout(), out).unwrap()
+}
+
+#[test]
+fn odd_sized_mip_chains_keep_every_pixel() {
+ // 5×3 → 2×1 → 1×1. The 2×1 level's right pixel averages columns 2–4 of all three rows.
+ let img = ramp(5, 3, ChannelLayout::Gray);
+ let b = encode(&img, Format::Dds, &opts(DdsCompression::None, true)).unwrap();
+ assert_eq!(le32(&b, 28), 3);
+ assert_eq!(b.len(), 128 + 5 * 3 + 2 + 1);
+ let second = level(&b, 1);
+ assert_eq!(second.data(), box_filter(&img).data());
+ let px = |x: usize, y: usize| u32::from(img.data()[y * 5 + x]);
+ let right: u32 = (0..3).flat_map(|y| (2..5).map(move |x| (x, y))).map(|(x, y)| px(x, y)).sum();
+ assert_eq!(u32::from(second.data()[1]), (right + 4) / 9);
+}
+
+#[test]
+fn block_compressed_mip_chains_have_a_block_per_small_level() {
+ for compression in BLOCKS {
+ let block = if compression == DdsCompression::Bc1 { 8 } else { 16 };
+ let img = ramp(12, 6, ChannelLayout::Rgba);
+ let b = encode(&img, Format::Dds, &opts(compression, true)).unwrap();
+ // 12×6, 6×3, 3×1, 1×1: 3×2, 2×1, 1 and 1 blocks.
+ assert_eq!(le32(&b, 28), 4);
+ assert_eq!(le32(&b, 8), REQUIRED | DDSD_LINEARSIZE | DDSD_MIPMAPCOUNT);
+ assert_eq!(le32(&b, 20), 6 * block);
+ assert_eq!(b.len(), 128 + (6 + 2 + 1 + 1) * block as usize);
+ for i in 0..4 {
+ let got = level(&b, i);
+ assert_eq!(got.dimensions(), ((12 >> i).max(1), (6 >> i).max(1)), "{compression:?} level {i}");
+ }
+ }
+}
+
+#[test]
+fn strips_and_tiny_textures_encode_with_every_option() {
+ for (w, h) in [(1, 1), (1, 9), (9, 1), (2, 3), (3, 2)] {
+ for compression in DdsCompression::ALL {
+ for mipmaps in [false, true] {
+ let img = ramp(w, h, ChannelLayout::Rgba);
+ let b = encode(&img, Format::Dds, &opts(compression, mipmaps)).unwrap_or_else(|e| panic!("{w}x{h} {compression:?} {mipmaps}: {e}"));
+ assert_eq!(decode(&b).unwrap().dimensions(), (w, h));
+ }
+ }
+ }
+}
+
+#[test]
+fn fidelity_warnings_follow_the_compression() {
+ let translucent = ramp(8, 8, ChannelLayout::Rgba);
+ let warns = |c| fidelity_warnings_with(&translucent, Format::Dds, &opts(c, false));
+ assert!(warns(DdsCompression::None).is_empty(), "uncompressed RGBA loses nothing");
+ for c in BLOCKS {
+ assert!(warns(c).contains(&FidelityWarning::LossyCompression), "{c:?}");
+ }
+ assert!(warns(DdsCompression::Bc1).contains(&FidelityWarning::AlphaBinarized));
+ assert!(!warns(DdsCompression::Bc3).contains(&FidelityWarning::AlphaBinarized));
+}
+
+#[test]
+fn compression_names_round_trip() {
+ for c in DdsCompression::ALL {
+ assert_eq!(DdsCompression::from_name(c.name()), Some(c));
+ }
+ assert_eq!(DdsCompression::from_name("DXT5"), Some(DdsCompression::Bc3));
+ assert_eq!(DdsCompression::from_name("dxt1"), Some(DdsCompression::Bc1));
+ for bad in ["", "bc7", "dxt2", "uncompressed "] {
+ assert_eq!(DdsCompression::from_name(bad), None, "{bad:?}");
+ }
+}
+
+/// Encode timings on a synthetic ~30 MP texture (6336×4752). Ignored by default; run in release:
+///
+/// ```sh
+/// cargo test --release -p photocraft-codecs --test dds_write -- --ignored --nocapture
+/// ```
+#[test]
+#[ignore]
+fn dds_encode_timings_on_30_mp() {
+ let (w, h) = (6336u32, 4752u32);
+ let mut s = 0x1234_5678u32;
+ let mut data = Vec::with_capacity(w as usize * h as usize * 4);
+ for y in 0..h {
+ for x in 0..w {
+ s ^= s << 13;
+ s ^= s >> 17;
+ s ^= s << 5;
+ data.extend([(x / 25 + y / 19) as u8, (x / 31) as u8 ^ (s as u8 & 3), (y / 23) as u8, (255 - x / 25) as u8]);
+ }
+ }
+ let img = Image::from_u8(w, h, ChannelLayout::Rgba, data).unwrap();
+ for compression in DdsCompression::ALL {
+ for mipmaps in [false, true] {
+ let t = std::time::Instant::now();
+ let b = encode(&img, Format::Dds, &opts(compression, mipmaps)).unwrap();
+ eprintln!("{compression:?} mipmaps={mipmaps}: {:.0} ms, {} MB", t.elapsed().as_secs_f64() * 1e3, b.len() >> 20);
+ }
+ }
+}
diff --git a/crates/codecs/tests/roundtrip.rs b/crates/codecs/tests/roundtrip.rs
index 7918d313b6..9c6cb8cb6f 100644
--- a/crates/codecs/tests/roundtrip.rs
+++ b/crates/codecs/tests/roundtrip.rs
@@ -137,6 +137,9 @@ mod fits {
mod dpx {
cases!(Format::Dpx);
}
+mod dds {
+ cases!(Format::Dds);
+}
mod qoi {
cases!(Format::Qoi);
}
diff --git a/crates/codecs/tests/tiff_exif.rs b/crates/codecs/tests/tiff_exif.rs
index 92e8c91df0..7e06a0e5b8 100644
--- a/crates/codecs/tests/tiff_exif.rs
+++ b/crates/codecs/tests/tiff_exif.rs
@@ -166,3 +166,97 @@ fn no_exif_is_written_without_embed_metadata() {
let tiff = encode(&img, Format::Tiff, &EncodeOptions { embed_metadata: false, ..Default::default() }).unwrap();
assert_eq!(decode(&tiff).unwrap().meta.exif, None);
}
+
+// ---------------------------------------------------------------------------
+// Bounded copying: an entry's value only has to lie inside the file, so a hostile file can have
+// many entries point at the same bytes.
+// ---------------------------------------------------------------------------
+
+/// A valid 1x1 gray TIFF whose Exif directory has `entries` UNDEFINED entries of `count` bytes
+/// that all point at the same `count` bytes: the file is about `count` bytes long and asks to
+/// copy `entries * count`.
+fn overlapping_tiff(entries: usize, count: u32) -> Vec {
+ let entry = |tag: u16, ty: u16, count: u32, value: u32| {
+ [tag.to_le_bytes().to_vec(), ty.to_le_bytes().to_vec(), count.to_le_bytes().to_vec(), value.to_le_bytes().to_vec()].concat()
+ };
+ let at_exif = 8 + 2 + 12 * 10 + 4;
+ let data_at = at_exif + 2 + 12 * entries + 4;
+ let mut out = b"II".to_vec();
+ out.extend(42u16.to_le_bytes());
+ out.extend(8u32.to_le_bytes());
+ out.extend(10u16.to_le_bytes());
+ for (tag, ty, value) in
+ [(256, 3, 1), (257, 3, 1), (258, 3, 8), (259, 3, 1), (262, 3, 1), (273, 4, 0), (277, 3, 1), (278, 3, 1), (279, 4, 1), (34665, 4, at_exif as u32)]
+ {
+ out.extend(entry(tag, ty, 1, value));
+ }
+ out.extend(0u32.to_le_bytes());
+ out.extend((entries as u16).to_le_bytes());
+ for i in 0..entries {
+ out.extend(entry(i as u16 + 1, 7, count, data_at as u32));
+ }
+ out.extend(0u32.to_le_bytes());
+ out.extend(vec![0x5A; count as usize]);
+ out
+}
+
+#[test]
+fn overlapping_field_values_are_bounded() {
+ // About 62 KB on disk; copied entry by entry it would be 1,000 x 50,000 bytes.
+ let tiff = overlapping_tiff(1000, 50_000);
+ assert!(tiff.len() < 64 * 1024);
+ let img = decode(&tiff).expect("the image itself is fine");
+ assert_eq!(img.dimensions(), (1, 1));
+ let exif = img.meta.exif.expect("the first entries are kept");
+ assert!(exif.len() <= 2 * tiff.len(), "{} bytes of EXIF from a {} byte file", exif.len(), tiff.len());
+}
+
+#[test]
+fn overlapping_field_values_in_an_exif_block_are_bounded_on_export() {
+ // The same block as an image's EXIF: the writer reads it with the same code.
+ let block = overlapping_tiff(1000, 50_000);
+ let mut img = Image::from_raw(2, 2, ChannelLayout::Gray, SampleType::U8, vec![0; 4]).unwrap();
+ img.meta.exif = Some(block.clone());
+ let tiff = encode(&img, Format::Tiff, &EncodeOptions::default()).unwrap();
+ assert!(tiff.len() <= 2 * block.len(), "a {} byte block wrote a {} byte file", block.len(), tiff.len());
+}
+
+/// An EXIF block with a maker note of `len` bytes, an exposure time and a GPS latitude reference.
+fn block_with_maker_note(le: bool, len: usize) -> Vec {
+ let b = Block { le };
+ b.build(
+ vec![(271, 2, 4, b"Cam\0".to_vec())],
+ vec![(33434, 5, 1, b.rational(1, 125)), (37500, 7, len as u32, (0..len).map(|i| (i * 7) as u8).collect())],
+ vec![(1, 2, 2, b"N\0".to_vec())],
+ )
+}
+
+#[test]
+fn large_maker_notes_still_survive() {
+ // Nikon maker notes run from 100 KB to nearly 1 MB.
+ for len in [200 * 1024, 1000 * 1024] {
+ let block = block_with_maker_note(true, len);
+ let mut img = Image::from_raw(4, 3, ChannelLayout::Rgb, SampleType::U8, vec![90; 36]).unwrap();
+ img.meta.exif = Some(block.clone());
+ let tiff = encode(&img, Format::Tiff, &EncodeOptions::default()).unwrap();
+ let back = decode(&tiff).unwrap().meta.exif.expect("EXIF read back");
+ let (ble, _, bexif, bgps) = dirs(&back);
+ let (ole, _, oexif, ogps) = dirs(&block);
+ assert_eq!(normalized(&bexif, ble), normalized(&oexif, ole), "{len} byte maker note");
+ assert_eq!(normalized(&bgps, ble), normalized(&ogps, ole));
+ }
+}
+
+#[test]
+fn a_field_beyond_the_cap_is_dropped_and_the_rest_kept() {
+ let block = block_with_maker_note(false, 6 * 1024 * 1024);
+ let mut img = Image::from_raw(2, 2, ChannelLayout::Gray, SampleType::U8, vec![0; 4]).unwrap();
+ img.meta.exif = Some(block);
+ let tiff = encode(&img, Format::Tiff, &EncodeOptions::default()).unwrap();
+ assert!(tiff.len() < 1024 * 1024, "{} bytes", tiff.len());
+ let back = decode(&tiff).unwrap().meta.exif.expect("EXIF read back");
+ let (_, ifd0, exif, gps) = dirs(&back);
+ assert_eq!(exif.iter().map(|e| e.0).collect::>(), [33434], "the exposure time stays, the 6 MiB maker note goes");
+ assert_eq!(gps.iter().map(|e| e.0).collect:: |