mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-12 21:23:02 +00:00
Merge origin/main into fix/gradient-stop-drag-off
Resolve the parity-doc conflict: keep main's status line and entries, prepend this PR's entry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
dbf178969e
405 files changed
+34390
-1869
No files matched your search
+1
-1
@@ -145,7 +145,7 @@ Files copied into `corpus/` by hand (tiff, exr, raw) must be MIT, BSD or CC0.
|
||||
|
||||
| Path | Title | Author | Source | License |
|
||||
|---|---|---|---|---|
|
||||
| `crates/psd/tests/fixtures/atn/*.atn` | Independently encoded ATN version-16 parser fixtures | PhotoCraft contributors | Original synthetic test data; wire layout documented in `crates/psd/src/atn.rs`; provenance in the adjacent README | MIT OR Apache-2.0 |
|
||||
| `crates/psd/tests/fixtures/atn/*.atn` | Independently encoded ATN version-16 and version-12 parser fixtures | PhotoCraft contributors (version-12 files: @yoriswind) | Original synthetic test data; wire layout documented in `crates/psd/src/atn.rs`; provenance in the adjacent README | MIT OR Apache-2.0 |
|
||||
|
||||
## Embedded-vector editor source
|
||||
|
||||
|
||||
Generated
+100
-41
@@ -650,7 +650,7 @@ dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"cexpr",
|
||||
"clang-sys",
|
||||
"itertools 0.10.5",
|
||||
"itertools 0.13.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"regex",
|
||||
@@ -1388,6 +1388,16 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-pcraft-format"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=b8f7d93b00dfb8f172938f3d2d796ae62dcfe123#b8f7d93b00dfb8f172938f3d2d796ae62dcfe123"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-print"
|
||||
version = "0.1.0"
|
||||
@@ -3345,15 +3355,6 @@ version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.10.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.13.0"
|
||||
@@ -3889,6 +3890,12 @@ version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"
|
||||
|
||||
[[package]]
|
||||
name = "lz4_flex"
|
||||
version = "0.11.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "373f5eceeeab7925e0c1098212f2fbc4d416adec9d35051a6ab251e824c1854a"
|
||||
|
||||
[[package]]
|
||||
name = "maybe-owned"
|
||||
version = "0.3.4"
|
||||
@@ -4141,6 +4148,15 @@ version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8"
|
||||
|
||||
[[package]]
|
||||
name = "ntapi"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae"
|
||||
dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.4.8"
|
||||
@@ -4553,6 +4569,16 @@ dependencies = [
|
||||
"objc2-core-graphics",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-io-kit"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-io-surface"
|
||||
version = "0.3.2"
|
||||
@@ -4963,7 +4989,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft"
|
||||
version = "0.19.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"async-io",
|
||||
@@ -5018,7 +5044,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-affinity"
|
||||
version = "0.19.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
@@ -5028,7 +5054,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-algo"
|
||||
version = "0.19.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"photocraft-color",
|
||||
@@ -5043,7 +5069,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-automation"
|
||||
version = "0.19.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"cap-std",
|
||||
@@ -5054,7 +5080,9 @@ dependencies = [
|
||||
"photocraft-doc",
|
||||
"photocraft-engine",
|
||||
"photocraft-format",
|
||||
"photocraft-geom",
|
||||
"photocraft-io",
|
||||
"photocraft-psd",
|
||||
"photocraft-raster",
|
||||
"photocraft-raw",
|
||||
"rmcp",
|
||||
@@ -5068,7 +5096,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-cli"
|
||||
version = "0.19.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"clap_complete",
|
||||
@@ -5087,7 +5115,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-cms"
|
||||
version = "0.19.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"moxcms",
|
||||
"rayon",
|
||||
@@ -5095,7 +5123,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-codecs"
|
||||
version = "0.19.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"craft-jpeg",
|
||||
"exr",
|
||||
@@ -5119,7 +5147,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-color"
|
||||
version = "0.19.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"photocraft-cms",
|
||||
"serde",
|
||||
@@ -5127,7 +5155,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-compose"
|
||||
version = "0.19.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"photocraft-cms",
|
||||
@@ -5143,7 +5171,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-doc"
|
||||
version = "0.19.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
@@ -5154,7 +5182,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-engine"
|
||||
version = "0.19.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
@@ -5196,9 +5224,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-format"
|
||||
version = "0.19.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"craft-pcraft-format",
|
||||
"crc32fast",
|
||||
"exacl",
|
||||
"flate2",
|
||||
@@ -5219,14 +5248,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-geom"
|
||||
version = "0.19.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-gpu"
|
||||
version = "0.19.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"half",
|
||||
@@ -5246,14 +5275,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-heif"
|
||||
version = "0.19.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"heic-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-io"
|
||||
version = "0.19.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"craft-docpath",
|
||||
@@ -5289,18 +5318,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-ipp"
|
||||
version = "0.19.0"
|
||||
version = "0.20.1"
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-ops"
|
||||
version = "0.19.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"photocraft-doc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-paint"
|
||||
version = "0.19.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"photocraft-cms",
|
||||
"photocraft-color",
|
||||
@@ -5314,7 +5343,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-plugins"
|
||||
version = "0.19.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
@@ -5330,7 +5359,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-psd"
|
||||
version = "0.19.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"flate2",
|
||||
@@ -5342,18 +5371,22 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-raster"
|
||||
version = "0.19.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"lz4_flex",
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
"proptest",
|
||||
"rayon",
|
||||
"serde",
|
||||
"sysinfo",
|
||||
"winsafe",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-raw"
|
||||
version = "0.19.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
@@ -5368,7 +5401,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-tablet"
|
||||
version = "0.19.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"block2 0.6.2",
|
||||
"objc2 0.6.5",
|
||||
@@ -5380,7 +5413,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-testkit"
|
||||
version = "0.19.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"memory-stats",
|
||||
"serde_json",
|
||||
@@ -5388,7 +5421,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-text"
|
||||
version = "0.19.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"parley",
|
||||
@@ -5403,7 +5436,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-ui-egui"
|
||||
version = "0.19.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"craft-print",
|
||||
@@ -5448,7 +5481,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-vector"
|
||||
version = "0.19.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-doc",
|
||||
@@ -5459,7 +5492,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-web"
|
||||
version = "0.19.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -5481,7 +5514,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-web-boot"
|
||||
version = "0.19.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"js-sys",
|
||||
@@ -6651,6 +6684,17 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "sctk-adwaita"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec"
|
||||
dependencies = [
|
||||
"log",
|
||||
"smithay-client-toolkit 0.19.2",
|
||||
"tiny-skia",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
@@ -7337,6 +7381,20 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sysinfo"
|
||||
version = "0.40.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e8a3edbe0394ce115f3917a00349dd242be09eb5a091ed509eaf67b55cfb8a8"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"memchr",
|
||||
"ntapi",
|
||||
"objc2-core-foundation",
|
||||
"objc2-io-kit",
|
||||
"windows",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration"
|
||||
version = "0.7.0"
|
||||
@@ -8920,6 +8978,7 @@ dependencies = [
|
||||
"raw-window-handle",
|
||||
"redox_syscall 0.4.1",
|
||||
"rustix 0.38.44",
|
||||
"sctk-adwaita",
|
||||
"smithay-client-toolkit 0.19.2",
|
||||
"smol_str",
|
||||
"tracing",
|
||||
@@ -9197,7 +9256,7 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.19.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"serde",
|
||||
|
||||
+2
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/photocraft", "apps/photocraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.19.0"
|
||||
version = "0.20.1"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -42,6 +42,7 @@ photocraft-ipp = { path = "crates/ipp" }
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
|
||||
craft-jpeg = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
|
||||
craft-pcraft-format = { git = "https://github.com/storytold/craft-libs", rev = "b8f7d93b00dfb8f172938f3d2d796ae62dcfe123" }
|
||||
# craft-update: app-specific pretend versions apply to startup and manual checks.
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] }
|
||||
thiserror = "2"
|
||||
|
||||
@@ -23,6 +23,37 @@ https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (today BIZ UDP
|
||||
BIZ UDMincho; not the web build), under the SIL Open Font License 1.1. Their licence texts
|
||||
ship with the package as OFL-<family>.txt.
|
||||
|
||||
Linux Wayland window decorations
|
||||
-------------------------------
|
||||
sctk-adwaita 0.10.1 (https://github.com/PolyMeilex/sctk-adwaita,
|
||||
commit d5f343c45c04d4a026db57f76760819b7325db00) is used by winit on Linux
|
||||
for vector window controls. Its title-font features are disabled: no Cantarell
|
||||
font is embedded by this dependency and no font file is added to this repo.
|
||||
|
||||
sctk-adwaita's MIT licence:
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 Bartłomiej Maryńczak
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
The egui-icons font subset is embedded from epaint_default_fonts by crates/ui-egui/build.rs
|
||||
and registered in the native and web fallback stacks by crates/ui-egui/src/egui_fonts.rs.
|
||||
It is a subset of emoji-icon-font by John Slegers; the same upstream MIT license applies.
|
||||
|
||||
@@ -122,6 +122,7 @@ Detail and the next releases: [docs/roadmap.md](docs/roadmap.md).
|
||||
|
||||
| Date | Entry |
|
||||
|---|---|
|
||||
| 2026-10-21 | Linux can use font-free Adwaita controls when System Title Bar is selected, while the integrated CSD title bar remains the default (as in #4439). Saved preferences are respected; startup and preference-dialog regressions cover both choices (#6266). |
|
||||
| 2026-10-17 | Object Finder classifies background by boundary coverage, retaining large enclosed foregrounds and rejecting surrounding backdrop (#6548); learned segmentation remains open (#2788) |
|
||||
| 2026-10-10 | Full re-measure against Photoshop 2026 27.11.0 (installed bundle inspected): breadth ~76%, ready ~45%, alpha. Progress docs reorganized (`parity.md` → `parity-checklist.md`, `parity-estimate.md` merged into `target-app-parity.md`). 0.6.0 released. |
|
||||
| 2026-10-09 | Remove tool, Rotate View, crop rotation; OpenRaster and Paint.NET; 39 Affinity documents in the corpus |
|
||||
|
||||
@@ -83,11 +83,11 @@ pub fn load_actions(text: &str, name: Option<&str>) -> Result<(Steps, Vec<Action
|
||||
}
|
||||
|
||||
/// [`load_actions`] for a file on disk: a Photoshop Actions file (`.atn`, by extension or by
|
||||
/// its version word) is converted first; the played action's steps with no PhotoCraft
|
||||
/// its version word, 12 or 16) is converted first; the played action's steps with no PhotoCraft
|
||||
/// equivalent come back as warnings (they fail at playback, as in Photoshop when a step can't run).
|
||||
pub fn load_actions_file(path: &Path, name: Option<&str>) -> Result<(Steps, Vec<Action>, Vec<String>), ActionsError> {
|
||||
let bytes = std::fs::read(path).map_err(|source| ActionsError::Read { path: path.to_path_buf(), source })?;
|
||||
let is_atn = path.extension().is_some_and(|e| e.eq_ignore_ascii_case("atn")) || bytes.starts_with(&[0, 0, 0, 16]);
|
||||
let is_atn = path.extension().is_some_and(|e| e.eq_ignore_ascii_case("atn")) || matches!(bytes.get(..4), Some([0, 0, 0, 12 | 16]));
|
||||
if !is_atn {
|
||||
let text = String::from_utf8(bytes).map_err(|source| ActionsError::Utf8 { path: path.to_path_buf(), source })?;
|
||||
let (steps, set) = load_actions(&text, name)?;
|
||||
|
||||
@@ -55,6 +55,14 @@ pub(crate) struct ExportArgs {
|
||||
/// Compress TIFF output: none (the default), lzw or zip
|
||||
#[arg(long, value_name = "none|lzw|zip", value_parser = tiff_compression)]
|
||||
pub(crate) tiff_compression: Option<TiffCompression>,
|
||||
/// TIFF Save Transparency: on keeps the transparency (ahead of any alpha or spot channels),
|
||||
/// off composites the image over white. Without it: kept unless the document has alpha or
|
||||
/// spot channels
|
||||
#[arg(long, value_name = "on|off", value_parser = on_off)]
|
||||
pub(crate) tiff_transparency: Option<bool>,
|
||||
/// Also write a TIFF image pyramid: half-size copies after the full image
|
||||
#[arg(long)]
|
||||
pub(crate) tiff_pyramid: bool,
|
||||
/// 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
|
||||
#[arg(long, value_name = "24|32", value_parser = tga_bits)]
|
||||
@@ -74,6 +82,10 @@ pub(crate) struct ExportArgs {
|
||||
/// and CMYK shrink) or progressive
|
||||
#[arg(long, value_name = "baseline|optimized|progressive", value_parser = jpeg_format)]
|
||||
pub(crate) jpeg_format: Option<JpegFormat>,
|
||||
/// Request the flattened document at its canvas size in .pcraft output as
|
||||
/// composite/full.png; it may be omitted. Check the manifest's full_composite member.
|
||||
#[arg(long)]
|
||||
pub(crate) pcraft_full_composite: bool,
|
||||
}
|
||||
|
||||
/// Convert between formats (.pcraft, .psd, .png, .jpg, .tif, .webp, .exr, …)
|
||||
@@ -91,6 +103,9 @@ pub(crate) struct ConvertArgs {
|
||||
/// Allow an <OUT> that is the <IN> file, replacing the original
|
||||
#[arg(long)]
|
||||
pub(crate) in_place: bool,
|
||||
/// Explicitly approve high-memory PSD/PSB imports within the hard memory budget
|
||||
#[arg(long)]
|
||||
pub(crate) approve_high_memory: bool,
|
||||
#[command(flatten)]
|
||||
pub(crate) export: ExportArgs,
|
||||
}
|
||||
@@ -102,6 +117,9 @@ pub(crate) struct InfoArgs {
|
||||
/// One line of JSON instead of pretty-printed
|
||||
#[arg(long)]
|
||||
pub(crate) compact: bool,
|
||||
/// Explicitly approve high-memory PSD/PSB imports within the hard memory budget
|
||||
#[arg(long)]
|
||||
pub(crate) approve_high_memory: bool,
|
||||
}
|
||||
|
||||
/// Open a file (or start a new document), run engine commands in order, save the result
|
||||
@@ -130,6 +148,9 @@ pub(crate) struct RunArgs {
|
||||
/// Allow an --out that is the <FILE>, replacing the original
|
||||
#[arg(long)]
|
||||
pub(crate) in_place: bool,
|
||||
/// Explicitly approve high-memory PSD/PSB imports within the hard memory budget
|
||||
#[arg(long)]
|
||||
pub(crate) approve_high_memory: bool,
|
||||
#[command(flatten)]
|
||||
pub(crate) export: ExportArgs,
|
||||
/// `--cmd` and `--params` values in command-line order, set by [`parse`].
|
||||
@@ -169,6 +190,9 @@ pub(crate) struct BatchArgs {
|
||||
/// originals; --in-place allows it
|
||||
#[arg(long)]
|
||||
pub(crate) in_place: bool,
|
||||
/// Explicitly approve high-memory PSD/PSB imports within the hard memory budget
|
||||
#[arg(long)]
|
||||
pub(crate) approve_high_memory: bool,
|
||||
#[command(flatten)]
|
||||
pub(crate) export: ExportArgs,
|
||||
}
|
||||
@@ -252,6 +276,8 @@ enum ValueError {
|
||||
TiffCompression,
|
||||
#[error("expected baseline, optimized or progressive")]
|
||||
JpegFormat,
|
||||
#[error("expected on or off")]
|
||||
OnOff,
|
||||
/// Why a `--bridge` value, or a PHOTOCRAFT_CONTROL_PORT value for `mcp`, is refused.
|
||||
#[error("expected a port, 0-65535 (--bridge also takes 127.0.0.1:<PORT>)")]
|
||||
Bridge,
|
||||
@@ -306,6 +332,14 @@ fn tiff_compression(s: &str) -> Result<TiffCompression, ValueError> {
|
||||
}
|
||||
}
|
||||
|
||||
fn on_off(s: &str) -> Result<bool, ValueError> {
|
||||
match s {
|
||||
"on" => Ok(true),
|
||||
"off" => Ok(false),
|
||||
_ => Err(ValueError::OnOff),
|
||||
}
|
||||
}
|
||||
|
||||
fn jpeg_format(s: &str) -> Result<JpegFormat, ValueError> {
|
||||
JpegFormat::parse(s).ok_or(ValueError::JpegFormat)
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ pub(crate) fn batch(a: &BatchArgs, out: &mut dyn Write, err: &mut dyn Write) ->
|
||||
// Steps that play another action find it in the set; the session's playback stack
|
||||
// stops an action that calls itself.
|
||||
h.session.actions.list = set.clone();
|
||||
h.open(input)?;
|
||||
h.open_with_approval(input, a.approve_high_memory)?;
|
||||
for (id, p) in &actions {
|
||||
if photocraft_engine::actions_cmds::shell_view_command(id)
|
||||
|| photocraft_engine::actions_cmds::shell_save_command(id)
|
||||
|
||||
@@ -30,7 +30,8 @@ pub(crate) fn convert(a: &ConvertArgs, _out: &mut dyn Write, err: &mut dyn Write
|
||||
let (input, output) = (&a.input, &a.output);
|
||||
let opts = ExportOptions::from(&a.export);
|
||||
refuse_overwriting_input(a.in_place, input, output)?;
|
||||
let mut o = files::open(input)?;
|
||||
let import_opts = photocraft_io::ImportOptions { psd_high_memory_approved: a.approve_high_memory, ..Default::default() };
|
||||
let mut o = files::open_with_options(input, &import_opts)?;
|
||||
// Preferences › Raw Defaults › Resolution at its default, as `run` and `info` open a raw.
|
||||
photocraft_engine::file_cmds::raw_defaults(&photocraft_engine::Session::new(), &o.warnings, &mut o.document);
|
||||
warn_all(err, &o.warnings);
|
||||
@@ -43,7 +44,7 @@ pub(crate) fn convert(a: &ConvertArgs, _out: &mut dyn Write, err: &mut dyn Write
|
||||
pub(crate) fn info(a: &InfoArgs, out: &mut dyn Write) -> Result {
|
||||
let file = &a.file;
|
||||
let mut h = Headless::trusted_local();
|
||||
let opened = h.open(file)?;
|
||||
let opened = h.open_with_approval(file, a.approve_high_memory)?;
|
||||
let mut doc = h.inspect(None)?;
|
||||
let mut warnings = opened["warnings"].as_array().cloned().unwrap_or_default();
|
||||
if let Some(st) = h.session.active() {
|
||||
@@ -86,7 +87,7 @@ pub(crate) fn run_cmds(a: &RunArgs, out: &mut dyn Write, err: &mut dyn Write) ->
|
||||
if let Some(out) = &a.out {
|
||||
refuse_overwriting_input(a.in_place, file, out)?;
|
||||
}
|
||||
let o = h.open(file)?;
|
||||
let o = h.open_with_approval(file, a.approve_high_memory)?;
|
||||
let ws: Vec<String> = serde_json::from_value(o["warnings"].clone()).unwrap_or_default();
|
||||
warn_all(err, &ws);
|
||||
reported_fonts.extend(ws);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
fn main() {
|
||||
// May re-execute this process with glibc tuned to return freed document memory (#8743).
|
||||
photocraft_engine::malloc_tuning::apply();
|
||||
// `args_os`, not `args`: a non-Unicode argument (a Latin-1 file name on Linux) must reach clap,
|
||||
// not panic before anything is printed (issue #1108).
|
||||
let code = photocraft_cli::run(std::env::args_os().skip(1), &mut std::io::stdout(), &mut std::io::stderr());
|
||||
|
||||
@@ -8,7 +8,13 @@ use crate::{Result, args::ExportArgs};
|
||||
impl From<&ExportArgs> for ExportOptions {
|
||||
fn from(a: &ExportArgs) -> Self {
|
||||
// TIFF output is flat unless asked: scripted conversions keep predictable sizes.
|
||||
let mut o = ExportOptions { tiff_layers: a.tiff_layers, tga_bits: a.tga_bits, ..Default::default() };
|
||||
let mut o = ExportOptions {
|
||||
tiff_layers: a.tiff_layers,
|
||||
tiff_transparency: a.tiff_transparency,
|
||||
tga_bits: a.tga_bits,
|
||||
pcraft_full_composite: a.pcraft_full_composite,
|
||||
..Default::default()
|
||||
};
|
||||
if let Some(q) = a.quality {
|
||||
o.encode.jpeg_quality = q;
|
||||
// Asking for a quality asks for a lossy WebP; the default WebP stays lossless.
|
||||
@@ -19,6 +25,7 @@ impl From<&ExportArgs> for ExportOptions {
|
||||
if let Some(c) = a.tiff_compression {
|
||||
o.encode.tiff_compression = c;
|
||||
}
|
||||
o.encode.tiff_pyramid = a.tiff_pyramid;
|
||||
if let Some(f) = a.jpeg_format {
|
||||
o.encode.jpeg_format = f;
|
||||
}
|
||||
|
||||
@@ -1025,6 +1025,26 @@ fn tiff_output_is_flat_unless_tiff_layers_is_given() {
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pcraft_output_has_no_full_composite_unless_asked() {
|
||||
let d = tmp("pcraft-full-composite");
|
||||
let full = |path: &std::path::Path| photocraft_format::read_full_composite(&std::fs::read(path).unwrap()).unwrap().is_some();
|
||||
for (name, flag, expect) in [("plain.pcraft", None, false), ("full.pcraft", Some("--pcraft-full-composite"), true)] {
|
||||
let out = d.join(name);
|
||||
let mut cmd = bin();
|
||||
cmd.args(["run", "--new", r#"{"width":16,"height":16}"#]);
|
||||
if let Some(flag) = flag {
|
||||
cmd.arg(flag);
|
||||
}
|
||||
ok(cmd.arg("--out").arg(&out));
|
||||
assert_eq!(full(&out), expect, "{name}");
|
||||
}
|
||||
let converted = d.join("converted.pcraft");
|
||||
ok(bin().arg("convert").arg(d.join("plain.pcraft")).arg(&converted).arg("--pcraft-full-composite"));
|
||||
assert!(full(&converted));
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
/// Issue #1108: a non-Unicode argument (a Latin-1 file name on Linux) used to panic in
|
||||
/// `std::env::args()` before anything was printed. clap reads the arguments as they are: a path
|
||||
/// takes one, and text (`--filter`) refuses it with a usage error.
|
||||
@@ -1090,6 +1110,20 @@ fn batch_plays_a_photoshop_actions_file() {
|
||||
assert!(!o.status.success());
|
||||
}
|
||||
|
||||
/// A version 12 Actions file (Photoshop 5, often saved on classic Mac OS without an extension) is
|
||||
/// recognised by its version word like version 16, not read as a JSON actions list.
|
||||
#[test]
|
||||
fn extensionless_version_12_actions_file_is_read_as_atn() {
|
||||
let d = tmp("atn-v12");
|
||||
let path = d.join("Legacy Actions");
|
||||
std::fs::write(&path, include_bytes!("../../../../crates/psd/tests/fixtures/atn/v12-commands.atn")).unwrap();
|
||||
let (steps, set, warnings) = photocraft_cli::load_actions_file(&path, None).unwrap();
|
||||
assert_eq!(set.len(), 1);
|
||||
assert_eq!(steps, vec![("actions.play".to_string(), json!({"action": set[0].name}))]);
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
/// What `photocraft_cli::run` returned and wrote, in-process.
|
||||
struct Invoke {
|
||||
code: i32,
|
||||
@@ -1496,3 +1530,35 @@ fn batch_skips_deselect_with_nothing_selected() {
|
||||
assert_eq!((info["width"].as_u64(), info["height"].as_u64()), (Some(6), Some(8)), "the step after Deselect ran: {info}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// TIFF Options from the command line (#8827): `--tiff-compression`, `--tiff-transparency on|off`
|
||||
/// and `--tiff-pyramid`; a bad value is a usage error that writes nothing.
|
||||
#[test]
|
||||
fn tiff_options_choose_compression_transparency_and_pyramid() {
|
||||
let d = tmp("tiff-options");
|
||||
let a = d.join("a.png");
|
||||
write_png(&a, 200, 100, 3);
|
||||
// (flags, compression tag, alpha, directories)
|
||||
for (flags, compression, alpha, dirs) in [
|
||||
(&[][..], 1, true, 1),
|
||||
(&["--tiff-compression", "lzw"][..], 5, true, 1),
|
||||
(&["--tiff-transparency", "off", "--tiff-compression", "zip"][..], 8, false, 1),
|
||||
(&["--tiff-transparency", "on", "--tiff-pyramid"][..], 1, true, 3),
|
||||
] {
|
||||
let out = d.join("a.tif");
|
||||
ok(bin().arg("convert").arg(&a).arg(&out).args(flags));
|
||||
let bytes = std::fs::read(&out).unwrap();
|
||||
let info = photocraft_codecs::tiff_info(&bytes).unwrap();
|
||||
assert_eq!(info.pages.len(), dirs, "{flags:?}");
|
||||
assert!(info.pages.iter().all(|p| p.compression == compression), "{flags:?}: {:?}", info.pages);
|
||||
assert_eq!(photocraft_codecs::decode(&bytes).unwrap().layout().has_alpha(), alpha, "{flags:?}");
|
||||
}
|
||||
for (flag, bad, expected) in [("--tiff-transparency", "maybe", "expected on or off"), ("--tiff-compression", "jpeg", "expected none, lzw or zip")] {
|
||||
let out = d.join("bad.tif");
|
||||
let o = bin().arg("convert").arg(&a).arg(&out).args([flag, bad]).output().unwrap();
|
||||
let err = String::from_utf8_lossy(&o.stderr);
|
||||
assert_eq!(o.status.code(), Some(2), "{flag} {bad}");
|
||||
assert!(err.contains(flag) && err.contains(expected), "{flag} {bad}: {err}");
|
||||
assert!(!out.exists(), "{flag} {bad} wrote a file");
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
//! The crate's integration tests, linked into one test binary.
|
||||
|
||||
mod cli;
|
||||
#[cfg(all(target_os = "linux", target_env = "gnu"))]
|
||||
mod malloc_tuning;
|
||||
mod man_and_completions;
|
||||
mod orientation;
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
//! The binary re-executes itself with glibc's trim threshold set (#8743), keeping its arguments
|
||||
//! and a value the user set.
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use photocraft_engine::malloc_tuning::{TRIM_THRESHOLD, TRIM_THRESHOLD_VAR};
|
||||
|
||||
/// The environment of a running `serve` once it answered a request, as `NAME=value` entries.
|
||||
fn serve_environ(set: Option<&str>) -> Vec<String> {
|
||||
let mut cmd = Command::new(crate::binary());
|
||||
for var in [TRIM_THRESHOLD_VAR, "MALLOC_MMAP_THRESHOLD_", "MALLOC_TOP_PAD_", "MALLOC_MMAP_MAX_", "MALLOC_ARENA_MAX", "GLIBC_TUNABLES"] {
|
||||
cmd.env_remove(var);
|
||||
}
|
||||
if let Some(v) = set {
|
||||
cmd.env(TRIM_THRESHOLD_VAR, v);
|
||||
}
|
||||
let mut child = cmd.arg("serve").stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().unwrap();
|
||||
let mut stdin = child.stdin.take().unwrap();
|
||||
writeln!(stdin, r#"{{"id":1,"method":"session.list"}}"#).unwrap();
|
||||
let mut reply = String::new();
|
||||
BufReader::new(child.stdout.take().unwrap()).read_line(&mut reply).unwrap();
|
||||
assert!(reply.contains(r#""id":1"#), "serve answered after the re-exec: {reply}");
|
||||
// The same process: `exec` keeps the PID, so this is the re-executed program's environment.
|
||||
let environ = std::fs::read(format!("/proc/{}/environ", child.id())).unwrap();
|
||||
drop(stdin);
|
||||
child.wait().unwrap();
|
||||
environ.split(|&b| b == 0).map(|e| String::from_utf8_lossy(e).into_owned()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sets_the_trim_threshold() {
|
||||
let env = serve_environ(None);
|
||||
assert!(env.contains(&format!("{TRIM_THRESHOLD_VAR}={TRIM_THRESHOLD}")), "{env:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_a_value_the_user_set() {
|
||||
let env = serve_environ(Some("999999"));
|
||||
assert!(env.contains(&format!("{TRIM_THRESHOLD_VAR}=999999")), "{env:?}");
|
||||
}
|
||||
@@ -100,7 +100,7 @@ futures-lite = "=2.6.1"
|
||||
async-io = "=2.6.0"
|
||||
# Open the window through Xwayland for a pen on Wayland (`tablet.rs`, #639): eframe doesn't
|
||||
# re-export winit's `EventLoopBuilderExtX11`. Already in Cargo.lock (eframe); pinned exactly.
|
||||
winit = { version = "=0.30.13", default-features = false, features = ["x11"] }
|
||||
winit = { version = "=0.30.13", default-features = false, features = ["wayland-csd-adwaita-notitle", "x11"] }
|
||||
|
||||
# Safe Win32 wrappers on Windows: the pointer position for file drops (`cursor.rs`,
|
||||
# GetCursorPos) and the window frame for the custom title bar (`window_frame.rs`, window styles),
|
||||
|
||||
@@ -491,14 +491,31 @@ pub fn portable_marker(exe_dir: &Path) -> Option<PathBuf> {
|
||||
PORTABLE_MARKERS.iter().map(|m| exe_dir.join(m)).find(|p| p.is_file())
|
||||
}
|
||||
|
||||
/// Probe names tried before giving up on a portable data folder.
|
||||
const WRITE_PROBE_ATTEMPTS: u32 = 64;
|
||||
|
||||
/// Create `dir` and prove a file can be written in it (a folder can exist yet be read-only).
|
||||
///
|
||||
/// The probe is created exclusively (`create_new`), so an entry already at its name (a link
|
||||
/// someone else placed there, say) is never opened, written through or removed: the next name
|
||||
/// is tried instead. Only a probe this call created is removed.
|
||||
fn ensure_writable(dir: &Path) -> std::io::Result<()> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let probe = dir.join(format!(".write-test-{}", std::process::id()));
|
||||
std::fs::write(&probe, b"")?;
|
||||
// A leftover probe is harmless; don't fail portable mode over it.
|
||||
let _ = std::fs::remove_file(&probe);
|
||||
Ok(())
|
||||
let pid = std::process::id();
|
||||
for attempt in 0..WRITE_PROBE_ATTEMPTS {
|
||||
let probe = dir.join(format!(".write-test-{pid}-{attempt}"));
|
||||
match std::fs::OpenOptions::new().write(true).create_new(true).open(&probe) {
|
||||
Ok(file) => {
|
||||
drop(file);
|
||||
// A leftover probe is harmless; don't fail portable mode over it.
|
||||
let _ = std::fs::remove_file(&probe);
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "every write-test name is already taken"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -443,3 +443,28 @@ fn missing_exe_dir_or_env_never_panics() {
|
||||
let d = resolve(env_of(&[("PHOTOCRAFT_CONFIG_DIR", &empty), ("HOME", &empty), ("APPDATA", &empty)]), None);
|
||||
assert_eq!(d.dir, None, "empty variables are ignored");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn the_write_probe_never_follows_an_entry_already_at_its_name() {
|
||||
let root = temp("probe-link");
|
||||
let data = root.join("data");
|
||||
std::fs::create_dir_all(&data).unwrap();
|
||||
let victim = root.join("victim.txt");
|
||||
std::fs::write(&victim, b"keep me").unwrap();
|
||||
// Every name the probe tries, except the last, is a link to a file outside the folder.
|
||||
let pid = std::process::id();
|
||||
for attempt in 0..WRITE_PROBE_ATTEMPTS - 1 {
|
||||
std::os::unix::fs::symlink(&victim, data.join(format!(".write-test-{pid}-{attempt}"))).unwrap();
|
||||
}
|
||||
ensure_writable(&data).unwrap();
|
||||
assert_eq!(std::fs::read(&victim).unwrap(), b"keep me");
|
||||
// The links are left alone, and the probe that was created is gone.
|
||||
assert!(std::fs::symlink_metadata(data.join(format!(".write-test-{pid}-0"))).unwrap().file_type().is_symlink());
|
||||
assert!(!data.join(format!(".write-test-{pid}-{}", WRITE_PROBE_ATTEMPTS - 1)).exists());
|
||||
// With every name taken the folder is reported unwritable, still without touching the file.
|
||||
std::os::unix::fs::symlink(&victim, data.join(format!(".write-test-{pid}-{}", WRITE_PROBE_ATTEMPTS - 1))).unwrap();
|
||||
assert!(ensure_writable(&data).is_err());
|
||||
assert_eq!(std::fs::read(&victim).unwrap(), b"keep me");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
@@ -80,9 +80,9 @@ use photocraft_ui_egui::PhotocraftApp;
|
||||
|
||||
use photocraft_ui_egui::os_window::APP_ID;
|
||||
|
||||
/// Windows and Linux: no OS title bar; the app's top bar is the title bar, with its own caption
|
||||
/// buttons and edge resizing (`photocraft_ui_egui::titlebar`), as Photoshop does on Windows. macOS
|
||||
/// keeps its traffic lights over the integrated title strip.
|
||||
/// Windows and Linux can use the app's title bar, with its own caption buttons and edge resizing
|
||||
/// (`photocraft_ui_egui::titlebar`); the preference below selects the mode. macOS always keeps its
|
||||
/// system traffic lights over the integrated title strip.
|
||||
const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos");
|
||||
|
||||
/// Whether this start draws its own title bar: Windows and Linux do, unless Preferences ›
|
||||
@@ -340,7 +340,10 @@ fn write_start_marker(sentinel: &gpu_startup::SharedSentinel, plan: &gpu_startup
|
||||
}
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
// First, so the panic hook and every start-up warning are recorded (`logging`).
|
||||
// Before anything starts a thread: may re-execute this process with glibc tuned to return
|
||||
// the memory of closed documents (#8743).
|
||||
photocraft_engine::malloc_tuning::apply();
|
||||
// Then, so the panic hook and every start-up warning are recorded (`logging`).
|
||||
let logger = logging::install();
|
||||
crash_guard::install_hook();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -943,9 +946,12 @@ mod tests {
|
||||
let dir = std::env::temp_dir().join(format!("pc-titlebar-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let file = dir.join("prefs.json");
|
||||
assert_eq!(super::custom_titlebar(None), super::CUSTOM_TITLEBAR, "no preferences file: the default");
|
||||
let default_custom = super::CUSTOM_TITLEBAR;
|
||||
assert_eq!(super::custom_titlebar(None), default_custom, "no preferences file: the platform default");
|
||||
std::fs::write(&file, "not json").unwrap();
|
||||
assert_eq!(super::custom_titlebar(Some(&file)), super::CUSTOM_TITLEBAR, "an unreadable file: the default");
|
||||
assert_eq!(super::custom_titlebar(Some(&file)), default_custom, "an unreadable file: the platform default");
|
||||
std::fs::write(&file, r#"{"interface":{}}"#).unwrap();
|
||||
assert_eq!(super::custom_titlebar(Some(&file)), default_custom, "an older preference file: the platform default");
|
||||
std::fs::write(&file, r#"{"interface":{"systemTitleBar":true}}"#).unwrap();
|
||||
assert!(!super::custom_titlebar(Some(&file)));
|
||||
assert_eq!(super::native_options(false).viewport.decorations, Some(true));
|
||||
|
||||
@@ -361,9 +361,20 @@ fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), String> {
|
||||
photocraft_format::atomic_write(path, bytes).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// [`write_atomic`] reporting the fraction written (a background save's progress, #3389).
|
||||
fn write_atomic_progress(path: &Path, bytes: &[u8], progress: &dyn Fn(f32)) -> Result<(), String> {
|
||||
photocraft_format::atomic_write_progress(path, bytes, progress).map_err(|e| e.to_string())
|
||||
/// [`write_atomic`] for an encoded save, reporting the fraction written (a background save's
|
||||
/// progress, #3389). A PSD/PSB is serialized straight into the temporary file, so the whole file
|
||||
/// is never held in memory next to its model (#6607).
|
||||
fn write_encoded_atomic(path: &Path, encoded: &photocraft_io::Encoded, progress: &dyn Fn(f32)) -> Result<(), String> {
|
||||
match encoded {
|
||||
photocraft_io::Encoded::Bytes(bytes) => photocraft_format::atomic_write_progress(path, bytes, progress),
|
||||
photocraft_io::Encoded::Psd(_) => {
|
||||
// Only the progress bar's scale: a model whose length can't be worked out fails the
|
||||
// write the same way, through the atomic write's "not changed" error.
|
||||
let len = encoded.encoded_len().unwrap_or(0);
|
||||
photocraft_format::atomic_write_stream_progress(path, len, progress, &mut |w| encoded.write_to(w))
|
||||
}
|
||||
}
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Whether `a` and `b` reach the same file: the same device and inode on Unix, the same final path
|
||||
@@ -413,26 +424,26 @@ fn export_options(settings: &photocraft_ui_egui::ExportSettings) -> photocraft_i
|
||||
const ENCODE_SHARE: f32 = 0.85;
|
||||
|
||||
/// A save on a worker thread (`Services::save_file` and `Services::automation_save`, #2017):
|
||||
/// encode, then write through `write`. Cancelling while encoding leaves the target untouched;
|
||||
/// once writing starts the save can't be cancelled. The encoder's progress fills the first
|
||||
/// encode, then write through `write` (a PSD/PSB is serialized as it is written). Cancelling
|
||||
/// while encoding leaves the target untouched; once writing starts the save can't be cancelled. The encoder's progress fills the first
|
||||
/// [`ENCODE_SHARE`] of the job's progress, so the status bar and tab read "Saving NN%" (#3389).
|
||||
fn save_through(
|
||||
doc: &Document,
|
||||
path: &str,
|
||||
settings: &photocraft_ui_egui::ExportSettings,
|
||||
ctl: &photocraft_ui_egui::jobs_ui::SaveCtl,
|
||||
write: impl Fn(&str, &[u8], &dyn Fn(f32)) -> Result<(), String>,
|
||||
write: impl Fn(&str, &photocraft_io::Encoded, &dyn Fn(f32)) -> Result<(), String>,
|
||||
) -> Result<Vec<String>, String> {
|
||||
ctl.progress(0.0, "Encoding");
|
||||
let opts = export_options(settings);
|
||||
let cancelled = || ctl.check().is_err();
|
||||
let progress = |f: f32| ctl.progress(f * ENCODE_SHARE, "");
|
||||
let interrupt = photocraft_raster::Interrupt::new(&cancelled, &progress);
|
||||
let r = crate::crash_guard::guard("Save", || photocraft_io::export_with(doc, path, &opts, &interrupt).map_err(|e| e.to_string()))?;
|
||||
let r = crate::crash_guard::guard("Save", || photocraft_io::encode_with(doc, path, &opts, &interrupt).map_err(|e| e.to_string()))?;
|
||||
ctl.commit()?;
|
||||
ctl.progress(ENCODE_SHARE, "Writing");
|
||||
// The last bit is the flush and rename after the last chunk: 100% only once they're done.
|
||||
write(path, &r.bytes, &|f| ctl.progress(ENCODE_SHARE + (0.99 - ENCODE_SHARE) * f, ""))?;
|
||||
write(path, &r.encoded, &|f| ctl.progress(ENCODE_SHARE + (0.99 - ENCODE_SHARE) * f, ""))?;
|
||||
ctl.progress(1.0, "");
|
||||
Ok(r.warnings)
|
||||
}
|
||||
@@ -443,7 +454,7 @@ fn save_file(
|
||||
settings: &photocraft_ui_egui::ExportSettings,
|
||||
ctl: &photocraft_ui_egui::jobs_ui::SaveCtl,
|
||||
) -> Result<Vec<String>, String> {
|
||||
save_through(doc, path, settings, ctl, |p, b, progress| write_atomic_progress(Path::new(p), b, progress))
|
||||
save_through(doc, path, settings, ctl, |p, encoded, progress| write_encoded_atomic(Path::new(p), encoded, progress))
|
||||
}
|
||||
|
||||
type SharedRecovery = Rc<RefCell<Option<RecoveryStore>>>;
|
||||
@@ -497,6 +508,11 @@ fn guarded_clipboard_get(mut read: impl FnMut() -> Result<Option<(u32, u32, Vec<
|
||||
Box::new(move || crate::crash_guard::guard("Paste", &mut read))
|
||||
}
|
||||
|
||||
/// SVG reads use the same recovery boundary as raster clipboard reads.
|
||||
fn guarded_clipboard_svg(mut read: impl FnMut() -> Result<Option<Vec<u8>>, String> + 'static) -> photocraft_ui_egui::ClipboardGetSvgFn {
|
||||
Box::new(move || crate::crash_guard::guard("Paste", &mut read))
|
||||
}
|
||||
|
||||
fn guarded_clipboard_set(mut write: impl FnMut(u32, u32, &[u8]) -> Result<(), String> + 'static) -> photocraft_ui_egui::ClipboardSetFn {
|
||||
Box::new(move |w: u32, h: u32, px: &[u8]| crate::crash_guard::guard("Copy", || write(w, h, px)))
|
||||
}
|
||||
@@ -616,6 +632,18 @@ fn clipboard_text(read: Result<String, arboard::Error>) -> Result<Option<String>
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep SVG source for Paste As without accepting an unbounded clipboard string as a new copy.
|
||||
fn svg_clipboard_source(text: Option<String>) -> Result<Option<Vec<u8>>, String> {
|
||||
let Some(text) = text else { return Ok(None) };
|
||||
if !photocraft_io::svg::is_svg(text.as_bytes()) {
|
||||
return Ok(None);
|
||||
}
|
||||
if text.len() > 16 << 20 {
|
||||
return Err("SVG clipboard data exceeds the 16 MiB limit".into());
|
||||
}
|
||||
Ok(Some(text.into_bytes()))
|
||||
}
|
||||
|
||||
/// The shell command that starts this install under XWayland, where winit delivers file drops
|
||||
/// (winit 0.30 has none on Wayland, #386). A Flatpak gets the X11 socket only without the Wayland
|
||||
/// one, an AppImage relaunches by its own path, and anything else is `photocraft` on the PATH.
|
||||
@@ -657,7 +685,7 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
|
||||
});
|
||||
let automation_save = automation.clone().map(|workspace| {
|
||||
std::sync::Arc::new(move |doc: &Document, path: &str, settings: &photocraft_ui_egui::ExportSettings, ctl: &photocraft_ui_egui::jobs_ui::SaveCtl| {
|
||||
save_through(doc, path, settings, ctl, |p, b, _| workspace.write(p, b).map_err(|e| e.to_string()))
|
||||
save_through(doc, path, settings, ctl, |p, encoded, _| workspace.write(p, &encoded.bytes().map_err(|e| e.to_string())?).map_err(|e| e.to_string()))
|
||||
}) as photocraft_ui_egui::SaveFileFn
|
||||
});
|
||||
let step: fn(&str, &serde_json::Value) -> photocraft_engine::Result<()> = photocraft_automation::workspace::authorize_desktop_engine_step;
|
||||
@@ -748,6 +776,17 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
|
||||
)
|
||||
})
|
||||
}),
|
||||
clipboard_get_svg: Some({
|
||||
let clip = clip.clone();
|
||||
guarded_clipboard_svg(move || {
|
||||
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().map_err(|e| e.to_string())?),
|
||||
};
|
||||
svg_clipboard_source(clipboard_text(cb.get_text())?)
|
||||
})
|
||||
}),
|
||||
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)))),
|
||||
|
||||
@@ -4,6 +4,41 @@ use photocraft_format::list_recovery;
|
||||
use photocraft_ui_egui::{PhotocraftApp, prefs_ui};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// #6607: a PSD/PSB save streams the file model into the temporary file, which then holds
|
||||
/// exactly the exported bytes; a serializer error halfway leaves the old file and no
|
||||
/// temporary file behind.
|
||||
#[test]
|
||||
fn a_psd_save_streams_the_exported_bytes_and_keeps_the_old_file_on_error() {
|
||||
let dir = temp("stream");
|
||||
let mut doc = Document::new("streamed", photocraft_geom::Size::new(64, 48), photocraft_doc::ColorMode::Rgb, photocraft_color::SampleType::U8);
|
||||
let fmt = doc.pixel_format();
|
||||
let px: Vec<u8> = (0..64 * 48 * fmt.bytes_per_pixel()).map(|i| (i * 7 % 251) as u8).collect();
|
||||
doc.layers
|
||||
.push(photocraft_doc::Layer::new("A", photocraft_doc::LayerContent::Raster(photocraft_raster::Surface::from_interleaved(fmt, doc.bounds(), &px))));
|
||||
let opts = photocraft_io::ExportOptions::default();
|
||||
for name in ["a.psd", "a.psb"] {
|
||||
let path = dir.join(name);
|
||||
std::fs::write(&path, b"old").unwrap();
|
||||
let want = photocraft_io::export(&doc, name, &opts).unwrap().bytes;
|
||||
let r = photocraft_io::encode_with(&doc, name, &opts, &photocraft_raster::Interrupt::NONE).unwrap();
|
||||
let photocraft_io::Encoded::Psd(mut file) = r.encoded else { panic!("{name}: not streamed") };
|
||||
let seen = std::cell::RefCell::new(Vec::new());
|
||||
write_encoded_atomic(&path, &photocraft_io::Encoded::Psd(file.clone()), &|f| seen.borrow_mut().push(f)).unwrap();
|
||||
assert!(std::fs::read(&path).unwrap() == want, "{name}: the saved file differs from the export");
|
||||
assert_eq!(seen.borrow().last().copied(), Some(1.0), "{name}");
|
||||
|
||||
let info = file.layer_info.as_mut().unwrap();
|
||||
let layer = info.layers[0].clone();
|
||||
info.layers = vec![layer; 32768];
|
||||
let e = write_encoded_atomic(&path, &photocraft_io::Encoded::Psd(file), &|_| {}).unwrap_err();
|
||||
assert!(e.contains("32767") && e.contains("not changed"), "{name}: {e}");
|
||||
assert!(std::fs::read(&path).unwrap() == want, "{name}: a failed save replaced the file");
|
||||
let temps: Vec<_> = std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.file_name()).filter(|n| n.to_string_lossy().ends_with(".tmp")).collect();
|
||||
assert!(temps.is_empty(), "{name}: left behind {temps:?}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #6601: a chosen path whose name isn't valid Unicode is reported, never handed on lossily
|
||||
/// (Open would fail with "No such file", Save would write a second, renamed file).
|
||||
#[test]
|
||||
@@ -709,6 +744,33 @@ fn from_text(text: &str) -> Option<(u32, u32, Vec<u8>)> {
|
||||
clipboard_image(&mut (), |_| Ok(None), |_| Ok(None), |_| Ok(Some(text.to_string()))).expect("no read fails")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn svg_clipboard_source_preserves_markup_and_bounds_its_size() {
|
||||
let bytes = svg_clipboard_source(Some(HALF_RED.into())).unwrap().unwrap();
|
||||
assert_eq!(bytes, HALF_RED.as_bytes());
|
||||
for text in [None, Some("plain text".into()), Some("<p>text</p>".into())] {
|
||||
assert_eq!(svg_clipboard_source(text), Ok(None));
|
||||
}
|
||||
let mut oversized = HALF_RED.to_string();
|
||||
oversized.extend(std::iter::repeat_n(' ', (16 << 20) + 1));
|
||||
assert!(svg_clipboard_source(Some(oversized)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_panicking_svg_clipboard_read_is_reported_and_can_be_retried() {
|
||||
let panics = Rc::new(Cell::new(true));
|
||||
let fail = panics.clone();
|
||||
let mut get = guarded_clipboard_svg(move || {
|
||||
if fail.get() {
|
||||
panic!("SVG clipboard reader bug");
|
||||
}
|
||||
Ok(Some(HALF_RED.as_bytes().to_vec()))
|
||||
});
|
||||
assert!(get().unwrap_err().contains("Paste failed"));
|
||||
panics.set(false);
|
||||
assert_eq!(get().unwrap().unwrap(), HALF_RED.as_bytes());
|
||||
}
|
||||
|
||||
fn pixel(img: &(u32, u32, Vec<u8>), x: u32, y: u32) -> [u8; 4] {
|
||||
let at = ((y * img.0 + x) * 4) as usize;
|
||||
img.2[at..at + 4].try_into().unwrap()
|
||||
@@ -886,3 +948,22 @@ fn a_panicking_clipboard_read_is_reported_not_a_crash() {
|
||||
let mut set = guarded_clipboard_set(|_: u32, _: u32, _: &[u8]| panic!("clipboard writer bug"));
|
||||
assert!(set(1, 1, &[0; 4]).unwrap_err().contains("Copy failed"));
|
||||
}
|
||||
|
||||
/// #8827: what TIFF Options sets in `ExportSettings` reaches the file the app writes: the
|
||||
/// compression, Save Transparency and Save Image Pyramid.
|
||||
#[test]
|
||||
fn tiff_options_reach_the_written_file() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 200, "height": 100, "background": "transparent"})).unwrap();
|
||||
let doc = s.active().unwrap().doc.clone();
|
||||
let settings = photocraft_ui_egui::ExportSettings {
|
||||
tiff_compression: photocraft_codecs::TiffCompression::Lzw,
|
||||
tiff_transparency: Some(false),
|
||||
tiff_pyramid: true,
|
||||
..Default::default()
|
||||
};
|
||||
let bytes = photocraft_io::export(&doc, "t.tif", &export_options(&settings)).unwrap().bytes;
|
||||
let info = photocraft_codecs::tiff_info(&bytes).unwrap();
|
||||
assert_eq!(info.pages.iter().map(|p| (p.width, p.compression)).collect::<Vec<_>>(), [(200, 5), (100, 5), (50, 5)]);
|
||||
assert!(!photocraft_codecs::decode(&bytes).unwrap().layout().has_alpha());
|
||||
}
|
||||
@@ -8,9 +8,14 @@ tiles/<blake3>.zst
|
||||
blobs/<blake3>.zst
|
||||
thumb.png
|
||||
composite/preview.png
|
||||
composite/full.png
|
||||
save.lock
|
||||
```
|
||||
|
||||
`thumb.png` (at most 256 px on the longer side), `composite/preview.png` (at most 1024 px) and `composite/full.png` (the canvas size) are the flattened document as 8-bit RGBA PNGs, whatever the document's depth. The manifest names each one it holds, under `thumbnail`, `composite` and `full_composite`.
|
||||
|
||||
A save writes `composite/full.png` only when it is asked to: *Save Full-Size Composite in .pcraft Files* in Preferences › File Handling for the app's saves, `"pcraftFullComposite": true` for the engine's `file.saveACopy` and its batch and export commands, and for the automation `doc.save` and MCP `doc_save`, and `--pcraft-full-composite` for `photocraft-cli convert`, `run` and `batch`. It is off by default. `composite/full.png` embeds the ICC profile of its pixels: an RGB document's own profile, a grayscale document's gray profile as RGB, and sRGB for CMYK, Lab and documents without a usable profile. A save leaves it out when the canvas is larger than 2^26 pixels, when its pixels cannot be allocated or encoded, or when a ZIP bundle would pass 4 GiB or 65,535 entries with it. A directory bundle saved without it has the file from an earlier save removed.
|
||||
|
||||
`save.lock` exists only in directory bundles: a save holds an OS file lock on it while it writes objects, publishes the manifest and removes unreferenced objects, so concurrent saves of one bundle take turns. It is empty, and readers ignore it.
|
||||
|
||||
The manifest is versioned and describes the document tree. Tiles and blobs are content-addressed. Loaders verify ZIP entry bounds and CRCs; document content also uses stored hashes to detect mismatched or corrupted tile/blob content. Unsupported newer format versions fail explicitly.
|
||||
|
||||
@@ -291,6 +291,10 @@ sync_github_name = true
|
||||
[people.dylanmtaylor]
|
||||
sync_github_name = true
|
||||
|
||||
[people.Yuki-init]
|
||||
display_name = "DC@yuki.himejima"
|
||||
sync_github_name = false
|
||||
|
||||
[people.sn-o-w]
|
||||
real_name = "Cristian Silaghi"
|
||||
sync_github_name = false
|
||||
|
||||
@@ -20,6 +20,11 @@ const MAX_ENTRIES: usize = 1 << 22;
|
||||
/// Bytes of entry names the allocation tables may name. A name is retained twice (in the entry
|
||||
/// and as its map key), and tables can point many names at the same bytes.
|
||||
const MAX_NAME_BYTES: usize = 32 << 20;
|
||||
/// File and directory records parsed across every allocation table. Each table walks its own
|
||||
/// records, and tables may start inside one another and share a long suffix, so the per-table
|
||||
/// counts do not bound the work: a few thousand overlapping tables could reparse one large run
|
||||
/// of records each. Real files hold a few hundred saves of a few thousand entries.
|
||||
const MAX_TABLE_RECORDS: usize = 1 << 25;
|
||||
/// zstd window ceiling: real documents use a few MiB; this stops a header from reserving gigabytes.
|
||||
const MAX_ZSTD_WINDOW: u64 = 64 << 20;
|
||||
|
||||
@@ -175,6 +180,7 @@ impl<'a> Archive<'a> {
|
||||
let mut seen = HashSet::new();
|
||||
let mut next = header.fat_offset;
|
||||
let mut tables = 0usize;
|
||||
let mut records_left = MAX_TABLE_RECORDS;
|
||||
while next != 0 {
|
||||
if !seen.insert(next) {
|
||||
return Err(Error::Malformed("allocation tables form a cycle"));
|
||||
@@ -199,6 +205,9 @@ impl<'a> Archive<'a> {
|
||||
c.take(8)?;
|
||||
let dirs = c.u16()?;
|
||||
c.take(1)?;
|
||||
// Charge the declared records before walking them.
|
||||
let declared = usize::try_from(files).unwrap_or(usize::MAX).saturating_add(usize::from(dirs));
|
||||
records_left = records_left.checked_sub(declared).ok_or(Error::Limit("too many allocation records"))?;
|
||||
for _ in 0..files {
|
||||
let id = c.u32()?;
|
||||
let flag = c.u8()?;
|
||||
|
||||
@@ -177,7 +177,9 @@ pub(crate) fn curves(r: &mut Reader, crvs: ObjId) -> Option<Path> {
|
||||
r.warn("curves with more than four million nodes");
|
||||
return None;
|
||||
}
|
||||
if let Err(reason) = r.geometry_budget.records(len) {
|
||||
// Charge the subpath itself as well as its records: an empty subpath adds no records
|
||||
// or points, but a shared curve full of them is still walked for every occurrence.
|
||||
if let Err(reason) = r.geometry_budget.records(len.saturating_add(1)) {
|
||||
r.warn(reason);
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -125,18 +125,24 @@ pub struct Stroke {
|
||||
|
||||
/// Gradient stops per gradient.
|
||||
const MAX_STOPS: usize = 1024;
|
||||
/// Dash and gap lengths per stroke. Affinity's editor offers three dash/gap pairs; a longer
|
||||
/// pattern would be copied into every shape that shares its style.
|
||||
const MAX_DASHES: usize = 64;
|
||||
|
||||
fn floats(v: &Value) -> Option<Vec<f64>> {
|
||||
/// The first `n` components of a float vector or packed `f32` record. Colours and gradient
|
||||
/// positions use a few leading components; a shared colour with a huge record is decoded for
|
||||
/// every occurrence, so the rest is neither converted nor copied.
|
||||
fn floats(v: &Value, n: usize) -> Option<Vec<f64>> {
|
||||
match v {
|
||||
Value::Floats(f) => Some(f.clone()),
|
||||
Value::Bytes(b) if b.len() % 4 == 0 => Some(f32_samples(b)),
|
||||
Value::Floats(f) => Some(f.iter().take(n).copied().collect()),
|
||||
Value::Bytes(b) if b.len() % 4 == 0 => Some(f32_samples(b, n)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Little-endian `f32` samples widened to `f64`.
|
||||
fn f32_samples(b: &[u8]) -> Vec<f64> {
|
||||
b.as_chunks::<4>().0.iter().map(|c| f64::from(f32::from_le_bytes(*c))).collect()
|
||||
/// Up to `n` little-endian `f32` samples widened to `f64`.
|
||||
fn f32_samples(b: &[u8], n: usize) -> Vec<f64> {
|
||||
b.as_chunks::<4>().0.iter().take(n).map(|c| f64::from(f32::from_le_bytes(*c))).collect()
|
||||
}
|
||||
|
||||
/// Spot colours defined through spot colours. A spot colour (`Pant`) is read through its RGB
|
||||
@@ -154,26 +160,27 @@ pub(crate) fn color(r: &mut Reader, id: ObjId) -> Option<Color> {
|
||||
fn color_at(r: &mut Reader, id: ObjId, depth: usize) -> Option<Color> {
|
||||
let s = r.s;
|
||||
let class = s.class(id)?;
|
||||
let col = || -> Option<Vec<f64>> {
|
||||
let v = floats(s.field(id, b"_col")?)?;
|
||||
// `n` is how many components the model reads; only those are decoded and checked.
|
||||
let col = |n: usize| -> Option<Vec<f64>> {
|
||||
let v = floats(s.field(id, b"_col")?, n)?;
|
||||
v.iter().all(|f| f.is_finite()).then_some(v)
|
||||
};
|
||||
Some(match &class.0.to_be_bytes() {
|
||||
b"RGBA" => {
|
||||
let v = col()?;
|
||||
let v = col(4)?;
|
||||
Color::Rgb { r: *v.first()?, g: *v.get(1)?, b: *v.get(2)?, a: v.get(3)?.clamp(0.0, 1.0) }
|
||||
}
|
||||
b"HSLA" => {
|
||||
let v = col()?;
|
||||
let v = col(4)?;
|
||||
let (r2, g, b) = hsl(*v.first()?, *v.get(1)?, *v.get(2)?);
|
||||
Color::Rgb { r: r2, g, b, a: v.get(3)?.clamp(0.0, 1.0) }
|
||||
}
|
||||
b"CMYK" => {
|
||||
let v = col()?;
|
||||
let v = col(5)?;
|
||||
Color::Cmyk { c: *v.first()?, m: *v.get(1)?, y: *v.get(2)?, k: *v.get(3)?, a: v.get(4)?.clamp(0.0, 1.0) }
|
||||
}
|
||||
b"GRAY" => {
|
||||
let v = col()?;
|
||||
let v = col(2)?;
|
||||
Color::Gray { v: *v.first()?, a: v.get(1)?.clamp(0.0, 1.0) }
|
||||
}
|
||||
b"LABA" => {
|
||||
@@ -245,9 +252,10 @@ pub(crate) fn descriptor(r: &mut Reader, id: ObjId, local: Affine) -> Option<Pai
|
||||
}
|
||||
let cols = s.objs(grad, b"Cols");
|
||||
let posn: Vec<Vec<f64>> = match s.field(grad, b"Posn") {
|
||||
Some(Value::Array(a)) => a.iter().filter_map(floats).take(MAX_STOPS).collect(),
|
||||
// A position is (offset, bias): later components are not decoded.
|
||||
Some(Value::Array(a)) => a.iter().filter_map(|v| floats(v, 2)).take(MAX_STOPS).collect(),
|
||||
Some(Value::Records { size, data }) if *size % 4 == 0 && *size > 0 => {
|
||||
data.chunks_exact(usize::from(*size)).map(f32_samples).take(MAX_STOPS).collect()
|
||||
data.chunks_exact(usize::from(*size)).map(|b| f32_samples(b, 2)).take(MAX_STOPS).collect()
|
||||
}
|
||||
_ => Vec::new(),
|
||||
};
|
||||
@@ -274,23 +282,52 @@ pub(crate) fn descriptor(r: &mut Reader, id: ObjId, local: Affine) -> Option<Pai
|
||||
}
|
||||
}
|
||||
|
||||
fn current(r: &Reader, id: ObjId, list: &[u8; 4], index: &[u8; 4]) -> Option<ObjId> {
|
||||
let items = r.s.objs(id, list);
|
||||
let i = r.s.int(id, index).and_then(|i| usize::try_from(i).ok()).unwrap_or(0);
|
||||
items.get(i).or_else(|| items.first()).copied()
|
||||
/// The active entry of a paint list (the `index`-th object, else the first) and how many
|
||||
/// objects the list holds. Every raw slot, null ones included, is charged before the list is
|
||||
/// walked: a shared layer's lists are read again for each occurrence. A list beyond the
|
||||
/// document's work budget reads as empty.
|
||||
fn current(r: &mut Reader, id: ObjId, list: &[u8; 4], index: &[u8; 4]) -> (Option<ObjId>, usize) {
|
||||
let s = r.s;
|
||||
let Some(Value::Array(items)) = s.field(id, list) else { return (None, 0) };
|
||||
if r.charge_slots(items.len()).is_err() {
|
||||
r.warn("fill and stroke lists beyond the document work budget (left empty)");
|
||||
return (None, 0);
|
||||
}
|
||||
let want = s.int(id, index).and_then(|i| usize::try_from(i).ok()).unwrap_or(0);
|
||||
let (mut first, mut chosen, mut count) = (None, None, 0usize);
|
||||
for o in items.iter().filter_map(|v| if let Value::Obj(Some(o)) = v { Some(*o) } else { None }) {
|
||||
first = first.or(Some(o));
|
||||
if count == want {
|
||||
chosen = Some(o);
|
||||
}
|
||||
count += 1;
|
||||
}
|
||||
(chosen.or(first), count)
|
||||
}
|
||||
|
||||
/// The fills of `list` (artboard backgrounds use `BFFl`).
|
||||
pub(crate) fn fills(r: &mut Reader, id: ObjId, local: Affine, list: &[u8; 4]) -> Vec<crate::paint::Paint> {
|
||||
let d = current(r, id, list, b"BFCr").or_else(|| r.s.obj(id, b"BFil"));
|
||||
d.and_then(|d| descriptor(r, d, local)).filter(|p| *p != Paint::None).into_iter().collect()
|
||||
fills_counted(r, id, local, list).0
|
||||
}
|
||||
|
||||
/// [`fills`], and how many entries `list` holds.
|
||||
fn fills_counted(r: &mut Reader, id: ObjId, local: Affine, list: &[u8; 4]) -> (Vec<Paint>, usize) {
|
||||
let (d, count) = current(r, id, list, b"BFCr");
|
||||
let d = d.or_else(|| r.s.obj(id, b"BFil"));
|
||||
(d.and_then(|d| descriptor(r, d, local)).filter(|p| *p != Paint::None).into_iter().collect(), count)
|
||||
}
|
||||
|
||||
/// A node's fill and stroke. `local` maps the node's space to document pixels.
|
||||
pub(crate) fn node_paint(r: &mut Reader, id: ObjId, local: Affine) -> (Vec<Paint>, Vec<Stroke>) {
|
||||
let s = r.s;
|
||||
let fills = fills(r, id, local, b"BFFl");
|
||||
if r.s.objs(id, b"BFFl").len() > 1 || r.s.objs(id, b"LIFl").len() > 1 {
|
||||
let (fills, fill_count) = fills_counted(r, id, local, b"BFFl");
|
||||
let (pen, style, pen_count) = if s.field(id, b"LIFl").is_some() {
|
||||
let (pen, pens) = current(r, id, b"LIFl", b"LICr");
|
||||
(pen, current(r, id, b"LILn", b"LICr").0, pens)
|
||||
} else {
|
||||
(s.obj(id, b"PFil"), s.obj(id, b"LSty"), 0)
|
||||
};
|
||||
if fill_count > 1 || pen_count > 1 {
|
||||
r.warn("several fills or strokes on one object (only the active one is imported)");
|
||||
}
|
||||
if s.f64(id, b"FOpc").is_some_and(|o| o < 0.999) {
|
||||
@@ -299,11 +336,6 @@ pub(crate) fn node_paint(r: &mut Reader, id: ObjId, local: Affine) -> (Vec<Paint
|
||||
if s.obj(id, b"Trns").is_some() {
|
||||
r.warn("transparency gradients (imported without them)");
|
||||
}
|
||||
let (pen, style) = if s.field(id, b"LIFl").is_some() {
|
||||
(current(r, id, b"LIFl", b"LICr"), current(r, id, b"LILn", b"LICr"))
|
||||
} else {
|
||||
(s.obj(id, b"PFil"), s.obj(id, b"LSty"))
|
||||
};
|
||||
let behind = matches!(s.field(id, b"DrwO"), Some(Value::Array(a)) if a.first() == Some(&Value::Enum { id: 1, version: 0 }));
|
||||
let stroke = match (pen, style) {
|
||||
(Some(pen), Some(style)) => stroke(r, pen, style, local, behind),
|
||||
@@ -341,6 +373,17 @@ fn stroke(r: &mut Reader, pen: ObjId, ldsc: ObjId, local: Affine, behind: bool)
|
||||
_ => Align::Center,
|
||||
};
|
||||
let dash = match s.field(lsty, b"Patn") {
|
||||
// Each occurrence of a shared style keeps its own scaled copy: refuse long patterns
|
||||
// before scanning or copying them.
|
||||
Some(Value::Array(a)) if a.len() > MAX_DASHES => {
|
||||
r.warn("dash patterns longer than 64 entries (imported as solid strokes)");
|
||||
None
|
||||
}
|
||||
// The copies are retained across the document: charge them like other shared slots.
|
||||
Some(Value::Array(a)) if r.charge_slots(a.len()).is_err() => {
|
||||
r.warn("dash patterns beyond the document work budget (imported as solid strokes)");
|
||||
None
|
||||
}
|
||||
Some(Value::Array(a)) if data.get(10) == Some(&2) || !a.is_empty() => {
|
||||
let mut v: Vec<f64> = a.iter().filter_map(|x| if let Value::Float(f) = x { Some(*f) } else { None }).collect();
|
||||
while v.len() >= 2 && v.iter().rev().take(2).all(|x| *x == 0.0) {
|
||||
@@ -356,3 +399,19 @@ fn stroke(r: &mut Reader, pen: ObjId, ldsc: ObjId, local: Affine, behind: bool)
|
||||
}
|
||||
Some(Stroke { paint, width, cap, join, miter_limit, align, dash, behind })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn only_the_components_a_schema_reads_are_decoded() {
|
||||
// A 64 KiB record decodes to the four components asked for, not sixteen thousand.
|
||||
let record = Value::Bytes(1.0f32.to_le_bytes().repeat(16_383));
|
||||
assert_eq!(floats(&record, 4), Some(vec![1.0; 4]));
|
||||
assert_eq!(floats(&Value::Floats(vec![0.5; 6]), 2), Some(vec![0.5; 2]));
|
||||
// Fewer components than asked for are returned as they are, for the caller to refuse.
|
||||
assert_eq!(floats(&Value::Floats(vec![0.5; 3]), 5), Some(vec![0.5; 3]));
|
||||
assert_eq!(floats(&Value::Bytes(vec![0; 3]), 4), None);
|
||||
}
|
||||
}
|
||||
@@ -187,12 +187,12 @@ fn plane(
|
||||
Some(Value::Array(a)) => a,
|
||||
_ => &[],
|
||||
};
|
||||
let mut tiles = match s.field(bitmap, &tag(b"Idx", c)) {
|
||||
Some(Value::Array(a)) => a.as_slice(),
|
||||
// Tile references in order. Null slots are skipped, but every slot walked (null or not) is
|
||||
// charged below, so a shared bitmap's long null prefix is not rescanned for free.
|
||||
let mut tiles: &[Value] = match s.field(bitmap, &tag(b"Idx", c)) {
|
||||
Some(Value::Array(a)) => a,
|
||||
_ => &[],
|
||||
}
|
||||
.iter()
|
||||
.filter_map(|v| if let Value::Obj(Some(o)) = v { Some(*o) } else { None });
|
||||
};
|
||||
// The scan below runs for every occurrence of a shared bitmap, whatever it produces.
|
||||
r.charge_slots(states.len().min(tw.saturating_mul(th)))?;
|
||||
let row = (w as usize) * bps;
|
||||
@@ -206,7 +206,14 @@ fn plane(
|
||||
0 | 1 => None,
|
||||
2 => Some(0xFF),
|
||||
4 => {
|
||||
let Some(blck) = tiles.next() else { return Err(Error::Malformed("missing pixel tile")) };
|
||||
let Some(at) = tiles.iter().position(|v| matches!(v, Value::Obj(Some(_)))) else {
|
||||
r.charge_slots(tiles.len())?;
|
||||
return Err(Error::Malformed("missing pixel tile"));
|
||||
};
|
||||
r.charge_slots(at + 1)?;
|
||||
let Some(Value::Obj(Some(blck))) = tiles.get(at) else { return Err(Error::Malformed("missing pixel tile")) };
|
||||
let blck = *blck;
|
||||
tiles = tiles.get(at + 1..).unwrap_or_default();
|
||||
let (ox, oy) = (tx * TILE, ty * TILE);
|
||||
if ox >= bx1 || ox + TILE <= bx0 || oy >= py1 || oy + TILE <= py0 {
|
||||
continue;
|
||||
|
||||
@@ -12,9 +12,10 @@ const KAPPA: f64 = 0.552_284_749_830_793_4;
|
||||
const MAX_SIDES: i64 = 10_000;
|
||||
|
||||
/// The last value of a tag: a shape changed from one kind to another keeps a stale first block.
|
||||
/// Indexed: a shared shape is read again for every occurrence, and rescanning a large field
|
||||
/// list each time multiplied into work far beyond the file's size.
|
||||
fn last<'a>(r: &'a Reader, id: ObjId, tag: &[u8; 4]) -> Option<&'a Value> {
|
||||
let t = Tag::of(tag);
|
||||
r.s.object(id)?.fields.iter().rev().find(|(k, _)| *k == t).map(|(_, v)| v)
|
||||
r.s.object(id)?.get_last(Tag::of(tag))
|
||||
}
|
||||
|
||||
fn num(r: &Reader, id: ObjId, tag: &[u8; 4]) -> Option<f64> {
|
||||
@@ -51,12 +52,14 @@ pub(crate) fn shape(r: &mut Reader, shape: ObjId, b: [f64; 4], world: Affine) ->
|
||||
let class = r.s.class(shape)?;
|
||||
let sub = match &class.0.to_be_bytes() {
|
||||
b"ShNR" | b"ShRR" => {
|
||||
let types = match last(r, shape, b"CTyp") {
|
||||
Some(Value::Array(a)) => a.iter().map(|t| if let Value::Enum { id, .. } = t { Some(*id) } else { None }).collect(),
|
||||
// Only the four corners are read: a longer array is not scanned or copied, which
|
||||
// would otherwise repeat for every occurrence of a shared shape.
|
||||
let types: Vec<Option<u16>> = match last(r, shape, b"CTyp") {
|
||||
Some(Value::Array(a)) => a.iter().take(4).map(|t| if let Value::Enum { id, .. } = t { Some(*id) } else { None }).collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let radii = match last(r, shape, b"ShCR") {
|
||||
Some(Value::Floats(v)) => v.iter().map(|f| if f.is_finite() && *f > 0.0 { *f } else { 0.0 }).collect(),
|
||||
let radii: Vec<f64> = match last(r, shape, b"ShCR") {
|
||||
Some(Value::Floats(v)) => v.iter().take(4).map(|f| if f.is_finite() && *f > 0.0 { *f } else { 0.0 }).collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
// Corners follow the first one (top left) unless they were unlocked; then each has
|
||||
|
||||
@@ -90,26 +90,34 @@ pub struct Object {
|
||||
pub chain: Vec<Tag>,
|
||||
/// Fields in stream order; positional fields have tag 0.
|
||||
pub fields: Vec<(Tag, Value)>,
|
||||
/// Index for objects with more than [`SCAN`] fields: (tag, index of its first occurrence)
|
||||
/// sorted by tag, so `get` is a binary search. A shared object can be referenced by many
|
||||
/// sibling layers, and each reference used to rescan all of its fields; indexing once keeps
|
||||
/// repeated lookups from multiplying into quadratic work. Smaller objects have no index
|
||||
/// (and no per-object hash map to build): scanning a handful of fields is cheaper.
|
||||
index: Vec<(Tag, u32)>,
|
||||
/// Index for objects with more than [`SCAN`] fields: (tag, index of its first occurrence,
|
||||
/// index of its last occurrence) sorted by tag, so `get` and `get_last` are a binary search.
|
||||
/// A shared object can be referenced by many sibling layers, and each reference used to
|
||||
/// rescan all of its fields; indexing once keeps repeated lookups from multiplying into
|
||||
/// quadratic work. Smaller objects have no index (and no per-object hash map to build):
|
||||
/// scanning a handful of fields is cheaper.
|
||||
index: Vec<(Tag, u32, u32)>,
|
||||
}
|
||||
|
||||
/// Objects with at most this many fields are scanned instead of indexed.
|
||||
const SCAN: usize = 12;
|
||||
|
||||
fn build_index(fields: &[(Tag, Value)]) -> Vec<(Tag, u32)> {
|
||||
fn build_index(fields: &[(Tag, Value)]) -> Vec<(Tag, u32, u32)> {
|
||||
if fields.len() <= SCAN {
|
||||
return Vec::new();
|
||||
}
|
||||
// Positional fields share tag 0 and are not looked up by tag. Sorting by (tag, index) and
|
||||
// keeping the first of each tag preserves first-match semantics.
|
||||
let mut index: Vec<(Tag, u32)> = fields.iter().zip(0u32..).filter(|((t, _), _)| t.0 != 0).map(|((t, _), i)| (*t, i)).collect();
|
||||
// Positional fields share tag 0 and are not looked up by tag. Sorting by (tag, index) puts
|
||||
// each tag's occurrences together in stream order: the first and last of each run keep
|
||||
// first-match semantics for `get` and last-match semantics for `get_last`.
|
||||
let mut index: Vec<(Tag, u32, u32)> = fields.iter().zip(0u32..).filter(|((t, _), _)| t.0 != 0).map(|((t, _), i)| (*t, i, i)).collect();
|
||||
index.sort_unstable();
|
||||
index.dedup_by_key(|e| e.0);
|
||||
index.dedup_by(|later, kept| {
|
||||
let same = later.0 == kept.0;
|
||||
if same {
|
||||
kept.2 = later.2;
|
||||
}
|
||||
same
|
||||
});
|
||||
index
|
||||
}
|
||||
|
||||
@@ -127,7 +135,21 @@ impl Object {
|
||||
return self.fields.iter().find(|(t, _)| *t == tag).map(|(_, v)| v);
|
||||
}
|
||||
let at = self.index.binary_search_by_key(&tag, |e| e.0).ok()?;
|
||||
self.index.get(at).and_then(|&(_, i)| self.fields.get(i as usize)).map(|(_, v)| v)
|
||||
self.index.get(at).and_then(|&(_, i, _)| self.fields.get(i as usize)).map(|(_, v)| v)
|
||||
}
|
||||
|
||||
/// The last field with `tag`, for objects that may repeat a tag (a shape changed from one
|
||||
/// kind to another keeps a stale first block). Indexed like [`Object::get`], so repeated
|
||||
/// lookups on a large shared object do not rescan its fields.
|
||||
pub fn get_last(&self, tag: Tag) -> Option<&Value> {
|
||||
if tag.0 == 0 {
|
||||
return None;
|
||||
}
|
||||
if self.fields.len() <= SCAN {
|
||||
return self.fields.iter().rev().find(|(t, _)| *t == tag).map(|(_, v)| v);
|
||||
}
|
||||
let at = self.index.binary_search_by_key(&tag, |e| e.0).ok()?;
|
||||
self.index.get(at).and_then(|&(_, _, i)| self.fields.get(i as usize)).map(|(_, v)| v)
|
||||
}
|
||||
|
||||
/// Replaces the fields (the root object is filled in after it is pushed) and rebuilds the
|
||||
|
||||
@@ -297,6 +297,42 @@ mod archive {
|
||||
assert_eq!(Archive::open(&file, Limits::default()).unwrap_err(), Error::Malformed("allocation tables form a cycle"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allocation_records_are_charged_before_they_are_walked() {
|
||||
let d = doc();
|
||||
let mut file = synth::container(&[("doc.dat", &d, Method::Stored)], None);
|
||||
let fat = u64::from_le_bytes(file[16..24].try_into().unwrap()) as usize;
|
||||
// The table's file count sits after its tag, next, date and 24 reserved bytes.
|
||||
let files = fat + 4 + 8 + 8 + 24;
|
||||
file[files..files + 4].copy_from_slice(&u32::MAX.to_le_bytes());
|
||||
assert_eq!(Archive::open(&file, Limits::default()).unwrap_err(), Error::Limit("too many allocation records"));
|
||||
// A count within the budget still reaches the records (and here fails on missing data).
|
||||
file[files..files + 4].copy_from_slice(&1_000_000u32.to_le_bytes());
|
||||
assert!(matches!(Archive::open(&file, Limits::default()), Err(Error::Malformed(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allocation_records_are_charged_across_every_table() {
|
||||
let d = doc();
|
||||
let mut file = synth::container(&[("doc.dat", &d, Method::Stored), ("other.dat", &d, Method::Stored)], None);
|
||||
let fat = u64::from_le_bytes(file[16..24].try_into().unwrap()) as usize;
|
||||
// Chain a copy of the (valid, two-record) table after it; the copy is the last table.
|
||||
let second = file.len();
|
||||
let table = file[fat..].to_vec();
|
||||
file.extend(&table);
|
||||
file[fat + 4..fat + 12].copy_from_slice(&(second as u64).to_le_bytes());
|
||||
let files = second + 4 + 8 + 8 + 24;
|
||||
// Each count alone is within the archive budget of 2^25; together they exceed it by one.
|
||||
file[files..files + 4].copy_from_slice(&((1u32 << 25) - 1).to_le_bytes());
|
||||
assert_eq!(Archive::open(&file, Limits::default()).unwrap_err(), Error::Limit("too many allocation records"));
|
||||
// Exactly the budget is allowed: the second table is walked (and fails on missing data).
|
||||
file[files..files + 4].copy_from_slice(&((1u32 << 25) - 2).to_le_bytes());
|
||||
assert!(matches!(Archive::open(&file, Limits::default()), Err(Error::Malformed(_))));
|
||||
// Directory records count too: one more (after the file count and 8 bytes) is over.
|
||||
file[files + 12..files + 14].copy_from_slice(&1u16.to_le_bytes());
|
||||
assert_eq!(Archive::open(&file, Limits::default()).unwrap_err(), Error::Limit("too many allocation records"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_streams_fail_cleanly() {
|
||||
let deep = (0..2000).fold(Vec::new(), |inner, _| vec![(tag(b"Chld"), F::Obj(tag(b"Grup"), inner))]);
|
||||
@@ -416,6 +452,20 @@ mod shapes {
|
||||
rx.recv_timeout(std::time::Duration::from_secs(20)).unwrap_or_else(|_| panic!("reading the document did not finish")).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_subpaths_of_a_shared_curve_are_charged_for_every_occurrence() {
|
||||
// One curve of 100k empty subpaths, placed 100 times: no records and no points, but
|
||||
// ten million subpaths walked unless each one is charged.
|
||||
let mut data = vec![F::U8(0), F::U32(100_000)];
|
||||
for _ in 0..100_000 {
|
||||
data.extend([F::Bool(false), F::Records(18, Vec::new())]);
|
||||
}
|
||||
let curve = F::Def(5, vec![tag(b"PCrv")], vec![(tag(b"Crvs"), F::Obj(tag(b"PCvD"), vec![(tag(b"Data"), F::Pos(data))]))]);
|
||||
let group = F::Def(6, vec![tag(b"Grup")], vec![(tag(b"Chld"), F::Shared((0..100).map(|_| F::Ref(5)).collect()))]);
|
||||
let d = read(vec![curve, group], 6);
|
||||
assert!(d.warnings.iter().any(|w| w.contains("curve geometry budget exhausted")), "{:?}", d.warnings);
|
||||
}
|
||||
|
||||
fn path(document: &Document) -> &Path {
|
||||
match &document.spreads[0].nodes[0].kind {
|
||||
Kind::Shape { path, .. } => path,
|
||||
@@ -786,6 +836,18 @@ mod stream_storage {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_lookup_keeps_last_match_for_every_object_size() {
|
||||
for n in 0..60u32 {
|
||||
let fields: Vec<(Tag, Value)> = (0..n).map(|i| (if i % 5 == 0 { Tag(0) } else { Tag(100 + i % 7) }, Value::UInt(u64::from(i)))).collect();
|
||||
let object = Object::new(Tag(1), Kind::Inline, vec![Tag(1)], fields.clone());
|
||||
for t in 0..120 {
|
||||
let expected = if t == 0 { None } else { fields.iter().rev().find(|(k, _)| *k == Tag(t)).map(|(_, v)| v) };
|
||||
assert_eq!(object.get_last(Tag(t)), expected, "{n} fields, tag {t}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fixed_size_array_is_one_buffer_and_one_value_per_record() {
|
||||
let records: Vec<u8> = (0..18 * 5).map(|i| i as u8).collect();
|
||||
|
||||
+32
-15
@@ -1,7 +1,7 @@
|
||||
//! Blurs: Gaussian, box, motion, radial, surface.
|
||||
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_raster::Interrupt;
|
||||
use photocraft_raster::{Interrupt, scratch};
|
||||
|
||||
use crate::image::{Edge, Image, premultiply, unpremultiply};
|
||||
use crate::photo_util::{par_map, par_rows};
|
||||
@@ -78,7 +78,7 @@ pub(crate) fn conv_sep(src: &Image, out: Rect, kx: &[f32], ky: &[f32], alpha: bo
|
||||
// Premultiplied copy of the needed source window.
|
||||
let win = Rect::new(out.x0 - rx, out.y0 - ry, out.x1 + rx, out.y1 + ry);
|
||||
let ww = win.width() as usize;
|
||||
let mut p = vec![0.0f32; ww * win.height() as usize * n];
|
||||
let mut p = scratch::Buf::new(ww * win.height() as usize * n, 0.0f32);
|
||||
for y in win.y0..win.y1 {
|
||||
for x in win.x0..win.x1 {
|
||||
let o = ((y - win.y0) as usize * ww + (x - win.x0) as usize) * n;
|
||||
@@ -89,7 +89,7 @@ pub(crate) fn conv_sep(src: &Image, out: Rect, kx: &[f32], ky: &[f32], alpha: bo
|
||||
}
|
||||
premultiply(&mut p, n, alpha);
|
||||
// Horizontal pass: (ow × th).
|
||||
let mut tmp = vec![0.0f32; ow * th * n];
|
||||
let mut tmp = scratch::Buf::new(ow * th * n, 0.0f32);
|
||||
for ty in 0..th {
|
||||
let row = &p[ty * ww * n..(ty + 1) * ww * n];
|
||||
let dst = &mut tmp[ty * ow * n..(ty + 1) * ow * n];
|
||||
@@ -113,7 +113,7 @@ pub(crate) fn conv_sep(src: &Image, out: Rect, kx: &[f32], ky: &[f32], alpha: bo
|
||||
}
|
||||
}
|
||||
// Vertical pass.
|
||||
let mut res = vec![0.0f32; ow * oh * n];
|
||||
let mut res = scratch::take(ow * oh * n, 0.0f32);
|
||||
for oy in 0..oh {
|
||||
let d = &mut res[oy * ow * n..(oy + 1) * ow * n];
|
||||
for (i, kv) in ky.iter().enumerate() {
|
||||
@@ -182,11 +182,27 @@ pub(crate) fn box_widths(params: &FilterParams) -> Option<Vec<usize>> {
|
||||
}
|
||||
|
||||
/// Scratch buffers of one worker for [`box_blur`].
|
||||
#[derive(Default)]
|
||||
struct Scratch {
|
||||
line: Vec<f32>,
|
||||
next: Vec<f32>,
|
||||
acc: Vec<f32>,
|
||||
/// The source row being read (horizontal passes only).
|
||||
row: Vec<f32>,
|
||||
}
|
||||
|
||||
impl Scratch {
|
||||
/// Buffers with room for lines of `line` floats (and the shorter ones after the first pass).
|
||||
fn new(line: usize, row: usize) -> Self {
|
||||
Scratch { line: scratch::take_cap(line), next: scratch::take_cap(line), acc: Vec::new(), row: scratch::take_cap(row) }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Scratch {
|
||||
fn drop(&mut self) {
|
||||
scratch::give(std::mem::take(&mut self.line));
|
||||
scratch::give(std::mem::take(&mut self.next));
|
||||
scratch::give(std::mem::take(&mut self.row));
|
||||
}
|
||||
}
|
||||
|
||||
/// One running-sum box pass of radius `r` along a line of elements of `w` floats each (a pixel's
|
||||
@@ -252,7 +268,7 @@ fn box_pass_px<const N: usize>(src: &[f32], dst: &mut [f32], r: usize) {
|
||||
/// `dst` unfinished) when `ctl` is cancelled before a pass: a column strip's passes take tens of
|
||||
/// milliseconds on a tall image, too long to finish once cancelled (#6205).
|
||||
fn line_passes(dst: &mut [f32], w: usize, radii: &[usize], s: &mut Scratch, ctl: &Interrupt) {
|
||||
let Scratch { line, next, acc } = s;
|
||||
let Scratch { line, next, acc, .. } = s;
|
||||
let Some(len) = dst.len().checked_div(w) else { return };
|
||||
let mut tail: usize = radii.iter().sum();
|
||||
for (k, &r) in radii.iter().enumerate() {
|
||||
@@ -306,21 +322,21 @@ pub(crate) fn box_blur(
|
||||
// Horizontal passes over `used`'s rows only (the repeated rows above and below it would just
|
||||
// repeat their results), for the output columns. Each row is extended to the passes' reach by
|
||||
// repeating its end pixels.
|
||||
let mut h = vec![0.0f32; used.height() as usize * ow * n];
|
||||
let mut h = scratch::Buf::new(used.height() as usize * ow * n, 0.0f32);
|
||||
let (left, right) = ((used.x0 - out.x0.saturating_sub(reach)) as usize, (out.x1.saturating_add(reach) - used.x1) as usize);
|
||||
par_rows(&mut h, ow * ROWS_PER_TASK, n, |task, chunk| {
|
||||
let (mut row, mut s) = (Vec::new(), Scratch::default());
|
||||
let mut s = Scratch::new((used.width() as usize + left + right) * n, used.width() as usize * n);
|
||||
for (k, dst) in chunk.chunks_exact_mut(ow * n).enumerate() {
|
||||
if ctl.cancelled() {
|
||||
return;
|
||||
}
|
||||
let y = used.y0 + (task * ROWS_PER_TASK + k) as i32;
|
||||
read(Rect::new(used.x0, y, used.x1, y + 1), &mut row);
|
||||
premultiply(&mut row, n, alpha);
|
||||
let (Some(first), Some(last)) = (row.get(..n), row.get(row.len().saturating_sub(n)..)) else { return };
|
||||
read(Rect::new(used.x0, y, used.x1, y + 1), &mut s.row);
|
||||
premultiply(&mut s.row, n, alpha);
|
||||
let (Some(first), Some(last)) = (s.row.get(..n), s.row.get(s.row.len().saturating_sub(n)..)) else { return };
|
||||
s.line.clear();
|
||||
(0..left).for_each(|_| s.line.extend_from_slice(first));
|
||||
s.line.extend_from_slice(&row);
|
||||
s.line.extend_from_slice(&s.row);
|
||||
(0..right).for_each(|_| s.line.extend_from_slice(last));
|
||||
line_passes(dst, n, &radii, &mut s, ctl);
|
||||
}
|
||||
@@ -336,11 +352,11 @@ pub(crate) fn box_blur(
|
||||
}
|
||||
let (sx, w) = ((t.x0 - out.x0) as usize * n, t.width() as usize * n);
|
||||
let row = |y: usize| &h[y * stride + sx..y * stride + sx + w];
|
||||
let mut s = Scratch::default();
|
||||
let mut s = Scratch::new((rows + top + bottom) * w, 0);
|
||||
(0..top).for_each(|_| s.line.extend_from_slice(row(0)));
|
||||
(0..rows).for_each(|y| s.line.extend_from_slice(row(y)));
|
||||
(0..bottom).for_each(|_| s.line.extend_from_slice(row(rows - 1)));
|
||||
let mut res = vec![0.0f32; oh * w];
|
||||
let mut res = scratch::take(oh * w, 0.0f32);
|
||||
line_passes(&mut res, w, &radii, &mut s, ctl);
|
||||
if ctl.cancelled() {
|
||||
return (t, Vec::new());
|
||||
@@ -367,7 +383,7 @@ fn box_passes(src: &Image, out: Rect, alpha: bool, boxes: &[usize]) -> Vec<f32>
|
||||
buf.extend_from_slice(src.row(r));
|
||||
};
|
||||
let ow = out.width() as usize * n;
|
||||
let mut res = vec![0.0f32; ow * out.height() as usize];
|
||||
let mut res = scratch::take(ow * out.height() as usize, 0.0f32);
|
||||
if res.is_empty() {
|
||||
return res;
|
||||
}
|
||||
@@ -376,6 +392,7 @@ fn box_passes(src: &Image, out: Rect, alpha: bool, boxes: &[usize]) -> Vec<f32>
|
||||
for (dst, s) in res.chunks_exact_mut(ow).zip(data.chunks_exact(w)) {
|
||||
dst[x0..x0 + w].copy_from_slice(s);
|
||||
}
|
||||
scratch::give(data);
|
||||
}
|
||||
res
|
||||
}
|
||||
|
||||
@@ -204,7 +204,7 @@ fn direct_boxes(img: &Image, out: Rect, boxes: &[usize], alpha: bool) -> Vec<f32
|
||||
let rect = out.inflate(left);
|
||||
cur = Image { rect, ch: img.ch, data: conv_sep(&cur, rect, &k, &k, alpha) };
|
||||
}
|
||||
cur.data
|
||||
cur.into_data()
|
||||
}
|
||||
|
||||
/// Noisy pixels over `r` (alpha varying, some fully transparent).
|
||||
@@ -481,7 +481,8 @@ fn line_passes_stop_between_passes_when_cancelled() {
|
||||
let reach: usize = radii.iter().sum();
|
||||
let line: Vec<f32> = (0..(len + 2 * reach) * w).map(|i| (i % 7) as f32 + 1.0).collect();
|
||||
let run = |ctl: &Interrupt| {
|
||||
let mut s = Scratch { line: line.clone(), ..Scratch::default() };
|
||||
let mut s = Scratch::new(0, 0);
|
||||
s.line.extend_from_slice(&line);
|
||||
let mut dst = vec![0.0f32; len * w];
|
||||
line_passes(&mut dst, w, &radii, &mut s, ctl);
|
||||
dst
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Interleaved `f32` image regions and sampling helpers.
|
||||
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_raster::Surface;
|
||||
use photocraft_raster::{Surface, scratch};
|
||||
|
||||
/// How samples outside an image are read.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -14,8 +14,9 @@ pub enum Edge {
|
||||
Wrap,
|
||||
}
|
||||
|
||||
/// A rectangle of interleaved, normalized samples.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
/// A rectangle of interleaved, normalized samples. The samples come from, and go back to, the
|
||||
/// scratch pool.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct Image {
|
||||
pub rect: Rect,
|
||||
/// Channels per pixel (alpha last when the surface has alpha).
|
||||
@@ -23,6 +24,20 @@ pub struct Image {
|
||||
pub data: Vec<f32>,
|
||||
}
|
||||
|
||||
impl Clone for Image {
|
||||
fn clone(&self) -> Self {
|
||||
let mut data = scratch::take(self.data.len(), 0.0f32);
|
||||
data.copy_from_slice(&self.data);
|
||||
Image { rect: self.rect, ch: self.ch, data }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Image {
|
||||
fn drop(&mut self) {
|
||||
scratch::give(std::mem::take(&mut self.data));
|
||||
}
|
||||
}
|
||||
|
||||
impl Image {
|
||||
/// Reads `rect` from a surface.
|
||||
pub fn read(s: &Surface, rect: Rect) -> Self {
|
||||
@@ -53,9 +68,15 @@ impl Image {
|
||||
img
|
||||
}
|
||||
|
||||
/// The samples, taken out of the image (it can't be destructured: it returns them to the
|
||||
/// pool on drop).
|
||||
pub fn into_data(mut self) -> Vec<f32> {
|
||||
std::mem::take(&mut self.data)
|
||||
}
|
||||
|
||||
/// New zeroed image.
|
||||
pub fn new(rect: Rect, ch: usize) -> Self {
|
||||
Image { rect, ch, data: vec![0.0; rect.width() as usize * rect.height() as usize * ch] }
|
||||
Image { rect, ch, data: scratch::take(rect.width() as usize * rect.height() as usize * ch, 0.0) }
|
||||
}
|
||||
|
||||
#[inline]
|
||||
|
||||
+30
-1
@@ -1023,6 +1023,31 @@ fn auto_tile(params: &FilterParams, bounds: Rect) -> i32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// Conservative transient allocation estimate for filter admission. Output tiles remain in
|
||||
/// the paged store; whole-image kernels must admit their float input before reading it.
|
||||
pub fn working_bytes(params: &FilterParams, area: Rect, bounds: Rect, channels: usize) -> u64 {
|
||||
if area.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let threads = rayon::current_num_threads().max(1) as u64;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let threads = 1u64;
|
||||
let tile = auto_tile(params, bounds).max(1) as u64;
|
||||
let floats = (channels as u64).saturating_mul(4);
|
||||
// Result groups hold at most RESULT_BUDGET bytes of floats, and never more than the area.
|
||||
let base = area.size().area().saturating_mul(floats).min(RESULT_BUDGET as u64).saturating_mul(3);
|
||||
match params.halo_for(bounds) {
|
||||
Halo::Bounds => base.saturating_add(bounds.union(&area).size().area().saturating_mul(floats)),
|
||||
Halo::Radius(r) => {
|
||||
let side = tile.saturating_add((r.max(0) as u64).saturating_mul(2));
|
||||
base.saturating_add(
|
||||
side.saturating_mul(side).saturating_add(tile.saturating_mul(tile).saturating_mul(3)).saturating_mul(floats).saturating_mul(threads),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [`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(
|
||||
@@ -1269,7 +1294,10 @@ fn apply_tiled_with_impl(
|
||||
}
|
||||
for (t, data) in results {
|
||||
match data {
|
||||
TileData::Samples(data) => out.write_region(t, &data),
|
||||
TileData::Samples(data) => {
|
||||
out.write_region(t, &data);
|
||||
photocraft_raster::scratch::give(data);
|
||||
}
|
||||
TileData::Encoded(data) => out.write_interleaved(t, &data),
|
||||
}
|
||||
}
|
||||
@@ -1324,6 +1352,7 @@ fn apply_box_blur(
|
||||
mix_selection(data, *t, sel, &Image::read(surface, *t), fmt.alpha);
|
||||
}
|
||||
s.write_region(*t, data);
|
||||
photocraft_raster::scratch::give(std::mem::take(data));
|
||||
}
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
@@ -53,9 +53,10 @@ pub fn mask_from_surface(s: Option<&Surface>, area: Rect) -> Vec<f32> {
|
||||
let tc = TileCoord::containing(x, y);
|
||||
let x1 = (tc.rect().x1).min(area.x1);
|
||||
let dst = row.get_mut((x - area.x0) as usize..(x1 - area.x0) as usize).unwrap_or_default();
|
||||
let src = s.tile(tc).and_then(|t| {
|
||||
let guard = s.tile(tc).map(|t| t.bytes());
|
||||
let src = guard.as_deref().and_then(|bytes| {
|
||||
let base = (y - tc.ty * TILE_SIZE) as usize * TILE_SIZE as usize + (x - tc.tx * TILE_SIZE) as usize;
|
||||
t.bytes().get(base..base + dst.len())
|
||||
bytes.get(base..base + dst.len())
|
||||
});
|
||||
match src {
|
||||
Some(src) => dst.iter_mut().zip(src).for_each(|(d, b)| *d = lut[*b as usize]),
|
||||
@@ -115,7 +116,7 @@ fn surface_from_masks(area: Rect, new: &[f32], prev: Option<&[f32]>, f: impl Fn(
|
||||
let tr = tc.rect().intersect(&area);
|
||||
let (lx, span) = ((tr.x0 - area.x0) as usize, tr.width() as usize);
|
||||
let mut tile = Arc::clone(blank);
|
||||
let data = Arc::make_mut(&mut tile).bytes_mut();
|
||||
let mut data = Arc::make_mut(&mut tile).bytes_mut();
|
||||
let (mut lo, mut hi) = (if tr == tc.rect() { u8::MAX } else { 0 }, 0u8);
|
||||
for y in tr.y0..tr.y1 {
|
||||
let o = (y - tc.ty * TILE_SIZE) as usize * ts + (tr.x0 - tc.tx * TILE_SIZE) as usize;
|
||||
@@ -123,6 +124,7 @@ fn surface_from_masks(area: Rect, new: &[f32], prev: Option<&[f32]>, f: impl Fn(
|
||||
row((y - area.y0) as usize * w + lx, dst);
|
||||
(lo, hi) = dst.iter().fold((lo, hi), |(lo, hi), b| (lo.min(*b), hi.max(*b)));
|
||||
}
|
||||
drop(data);
|
||||
(hi > 0).then(|| (*tc, if lo == u8::MAX { Arc::clone(&full) } else { tile }))
|
||||
});
|
||||
s.put_tiles(built.into_iter().flatten());
|
||||
|
||||
@@ -69,8 +69,10 @@ fn build(old: Option<&Surface>, area: Rect, mode: SelectionMode, keep_outside: b
|
||||
for y in cut.y0..cut.y1 {
|
||||
let offset = y.abs_diff(rect.y0) as usize * ts + cut.x0.abs_diff(rect.x0) as usize;
|
||||
let len = cut.width() as usize;
|
||||
let Some(dst) = tile.bytes_mut().get_mut(offset..offset + len) else { continue };
|
||||
let src = previous.and_then(|t| t.bytes().get(offset..offset + len));
|
||||
let mut data = tile.bytes_mut();
|
||||
let Some(dst) = data.get_mut(offset..offset + len) else { continue };
|
||||
let previous_bytes = previous.map(|t| t.bytes());
|
||||
let src = previous_bytes.as_ref().and_then(|t| t.get(offset..offset + len));
|
||||
let start = (y.abs_diff(area.y0) as usize).checked_mul(width).and_then(|i| i.checked_add(cut.x0.abs_diff(area.x0) as usize));
|
||||
for (i, d) in dst.iter_mut().enumerate() {
|
||||
let new = start.and_then(|j| j.checked_add(i)).map(&sample).unwrap_or(0);
|
||||
@@ -86,7 +88,7 @@ fn build(old: Option<&Surface>, area: Rect, mode: SelectionMode, keep_outside: b
|
||||
let (lo, hi) = tile.bytes().iter().fold((u8::MAX, 0u8), |(lo, hi), b| (lo.min(*b), hi.max(*b)));
|
||||
let tile = if lo == out_default && hi == out_default {
|
||||
None
|
||||
} else if previous.is_some_and(|p| p.bytes() == tile.bytes()) {
|
||||
} else if previous.is_some_and(|p| *p.bytes() == *tile.bytes()) {
|
||||
previous.cloned()
|
||||
} else if lo == u8::MAX {
|
||||
Some(Arc::clone(&full))
|
||||
|
||||
@@ -53,14 +53,14 @@ pub fn inverse_surface(source: &Surface, area: Rect) -> Surface {
|
||||
let cell = cache.get(&(Arc::as_ptr(input) as usize))?;
|
||||
let tile = cell.get_or_init(|| {
|
||||
if format == PixelFormat::GRAY8 {
|
||||
if input.bytes() == full.bytes() {
|
||||
if *input.bytes() == *full.bytes() {
|
||||
return None;
|
||||
}
|
||||
if input.bytes() == blank.bytes() {
|
||||
if *input.bytes() == *blank.bytes() {
|
||||
return Some(Arc::clone(&full));
|
||||
}
|
||||
let mut tile = Tile::clone(input);
|
||||
for b in tile.bytes_mut() {
|
||||
for b in tile.bytes_mut().iter_mut() {
|
||||
*b = 255 - *b;
|
||||
}
|
||||
return Some(Arc::new(tile));
|
||||
@@ -78,7 +78,8 @@ pub fn inverse_surface(source: &Surface, area: Rect) -> Surface {
|
||||
let (x0, x1) = ((cut.x0 - rect.x0) as usize, (cut.x1 - rect.x0) as usize);
|
||||
for y in (cut.y0 - rect.y0) as usize..(cut.y1 - rect.y0) as usize {
|
||||
let start = y * TILE_SIZE as usize + x0;
|
||||
if let Some(row) = tile.bytes_mut().get_mut(start..start + x1 - x0) {
|
||||
let mut data = tile.bytes_mut();
|
||||
if let Some(row) = data.get_mut(start..start + x1 - x0) {
|
||||
for (i, b) in (start..).zip(row) {
|
||||
*b = input.map_or(default, |t| value(t, i));
|
||||
}
|
||||
@@ -91,9 +92,9 @@ pub fn inverse_surface(source: &Surface, area: Rect) -> Surface {
|
||||
}
|
||||
|
||||
fn canonical(tile: Tile, blank: &Arc<Tile>, full: &Arc<Tile>) -> Option<Arc<Tile>> {
|
||||
if tile.bytes() == blank.bytes() {
|
||||
if *tile.bytes() == *blank.bytes() {
|
||||
None
|
||||
} else if tile.bytes() == full.bytes() {
|
||||
} else if *tile.bytes() == *full.bytes() {
|
||||
Some(Arc::clone(full))
|
||||
} else {
|
||||
Some(Arc::new(tile))
|
||||
|
||||
@@ -202,7 +202,8 @@ pub fn polygon_surface(points: &[(f32, f32)], clip: Rect) -> Option<Surface> {
|
||||
}
|
||||
for ((r, tile), row) in spans.iter().zip(&mut tiles).zip(acc.as_chunks::<TS>().0) {
|
||||
let base = y.rem_euclid(TILE_SIZE) as usize * TS + r.x0.rem_euclid(TILE_SIZE) as usize;
|
||||
let Some(dst) = tile.bytes_mut().get_mut(base..base + r.width() as usize) else { continue };
|
||||
let mut data = tile.bytes_mut();
|
||||
let Some(dst) = data.get_mut(base..base + r.width() as usize) else { continue };
|
||||
for (d, v) in dst.iter_mut().zip(row) {
|
||||
*d = (v.min(1.0).clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
}
|
||||
|
||||
@@ -217,19 +217,25 @@ pub fn combine_surfaces(a: &Surface, b: &Surface, area: Rect, f: impl Fn(f32, f3
|
||||
}
|
||||
}
|
||||
let mut tile = Tile::clone(&blank);
|
||||
let data = tile.bytes_mut();
|
||||
let mut data = tile.bytes_mut();
|
||||
let same_tile = ta.zip(tb).is_some_and(|(left, right)| Arc::ptr_eq(left, right));
|
||||
let read_a = ta.map(|tile| tile.bytes());
|
||||
let read_b = tb.filter(|_| !same_tile).map(|tile| tile.bytes());
|
||||
let bytes_a = read_a.as_deref();
|
||||
let bytes_b = if same_tile { bytes_a } else { read_b.as_deref() };
|
||||
let (mut lo, mut hi) = (if tr == rect { u8::MAX } else { 0 }, 0u8);
|
||||
let (x0, x1) = ((tr.x0 - rect.x0) as usize, (tr.x1 - rect.x0) as usize);
|
||||
for ly in (tr.y0 - rect.y0) as usize..(tr.y1 - rect.y0) as usize {
|
||||
let (s, e) = (ly * TS + x0, ly * TS + x1);
|
||||
let src_a = ta.and_then(|t| t.bytes().get(s..e)).unwrap_or(&row_a[..x1 - x0]);
|
||||
let src_b = tb.and_then(|t| t.bytes().get(s..e)).unwrap_or(&row_b[..x1 - x0]);
|
||||
let src_a = bytes_a.and_then(|bytes| bytes.get(s..e)).unwrap_or(&row_a[..x1 - x0]);
|
||||
let src_b = bytes_b.and_then(|bytes| bytes.get(s..e)).unwrap_or(&row_b[..x1 - x0]);
|
||||
let Some(dst) = data.get_mut(s..e) else { continue };
|
||||
for (d, (x, y)) in dst.iter_mut().zip(src_a.iter().zip(src_b)) {
|
||||
*d = pair(*x, *y);
|
||||
(lo, hi) = (lo.min(*d), hi.max(*d));
|
||||
}
|
||||
}
|
||||
drop(data);
|
||||
(hi > 0).then(|| (tc, if lo == u8::MAX { Arc::clone(&full) } else { Arc::new(tile) }))
|
||||
});
|
||||
out.put_tiles(built.into_iter().flatten());
|
||||
@@ -258,15 +264,17 @@ pub fn combine_sparse(a: &Surface, b: &Surface, area: Rect, f: impl Fn(f32, f32)
|
||||
let tr = rect.intersect(&area);
|
||||
let tb = b.tile(tc)?;
|
||||
let mut tile = Tile::clone(a.tile(tc).unwrap_or(&untouched));
|
||||
let data = tile.bytes_mut();
|
||||
let mut data = tile.bytes_mut();
|
||||
let source = tb.bytes();
|
||||
let (x0, x1) = ((tr.x0 - rect.x0) as usize, (tr.x1 - rect.x0) as usize);
|
||||
for ly in (tr.y0 - rect.y0) as usize..(tr.y1 - rect.y0) as usize {
|
||||
let (s, e) = (ly * TS + x0, ly * TS + x1);
|
||||
let (Some(dst), Some(src)) = (data.get_mut(s..e), tb.bytes().get(s..e)) else { continue };
|
||||
let (Some(dst), Some(src)) = (data.get_mut(s..e), source.get(s..e)) else { continue };
|
||||
for (d, y) in dst.iter_mut().zip(src) {
|
||||
*d = pair(*d, *y);
|
||||
}
|
||||
}
|
||||
drop((data, source));
|
||||
let (lo, hi) = tile.bytes().iter().fold((u8::MAX, 0u8), |(lo, hi), v| (lo.min(*v), hi.max(*v)));
|
||||
Some((
|
||||
tc,
|
||||
|
||||
@@ -267,7 +267,12 @@ fn malformed_inputs_and_allocations_stop_gracefully() {
|
||||
assert!(crate::apply_in_with(&s, &p, rect, rect, None, rect, &Interrupt::NONE).is_none());
|
||||
assert_eq!(crate::apply_in(&s, &p, rect, rect, None, rect), s);
|
||||
}
|
||||
for bad in [Image { ch: 0, ..src.clone() }, Image { data: vec![0.0], ..src.clone() }, Image { rect: Rect::new(5, 0, 0, 2), ..src.clone() }] {
|
||||
let with = |edit: &dyn Fn(&mut Image)| {
|
||||
let mut bad = src.clone();
|
||||
edit(&mut bad);
|
||||
bad
|
||||
};
|
||||
for bad in [with(&|i| i.ch = 0), with(&|i| i.data = vec![0.0]), with(&|i| i.rect = Rect::new(5, 0, 0, 2))] {
|
||||
assert!(exact::run(&bad, rect, &ctx, 100.0, 1.0, 0.0, &Interrupt::NONE).is_none());
|
||||
}
|
||||
for rect in [Rect::new(i32::MIN, 0, i32::MAX, 2), Rect::new(0, 0, 300_000, 300_000), Rect::new(2, 0, 0, 2)] {
|
||||
|
||||
@@ -4,6 +4,18 @@ mod ext;
|
||||
mod mosaic;
|
||||
mod selection_mix;
|
||||
|
||||
#[test]
|
||||
fn relight_admission_tracks_the_document_dependent_working_set() {
|
||||
let params = FilterParams::Relight { angle: 0.0, elevation: 45.0, intensity: 50.0, ambient: 50.0, warmth: 0.0, softness: 100.0 };
|
||||
let small = Rect::new(0, 0, 128, 128);
|
||||
let large = Rect::new(0, 0, 86400, 43200);
|
||||
assert!(working_bytes(¶ms, small, small, 4) < working_bytes(¶ms, small, large, 4));
|
||||
assert_eq!(working_bytes(¶ms, Rect::EMPTY, large, 4), 0);
|
||||
// A tiny image must not reserve the 256 MiB group budget three times over.
|
||||
let tiny = Rect::new(0, 0, 24, 18);
|
||||
assert!(working_bytes(¶ms, tiny, tiny, 4) < 64 << 20, "{}", working_bytes(¶ms, tiny, tiny, 4));
|
||||
}
|
||||
|
||||
fn fmt(s: SampleType) -> PixelFormat {
|
||||
PixelFormat::new(ColorMode::Rgb, s, true)
|
||||
}
|
||||
|
||||
@@ -521,6 +521,7 @@ fn warp(
|
||||
outp[o + a] = al as f32;
|
||||
}
|
||||
}
|
||||
photocraft_raster::scratch::give(px);
|
||||
Ok(any.then_some((*t, outp)))
|
||||
};
|
||||
// Each tile is written as soon as it is warped, so only the tiles in flight hold their `f32`
|
||||
|
||||
@@ -29,8 +29,13 @@ cap-std = "4"
|
||||
clap = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
photocraft-geom = { workspace = true }
|
||||
# Synthetic camera raws (#7789).
|
||||
photocraft-raw = { workspace = true, features = ["testgen"] }
|
||||
# Tiny generated PSDs and the scoped estimate charge for the high-memory approval tests
|
||||
# (no multi-gigabyte fixture or global override).
|
||||
photocraft-io = { workspace = true, features = ["testgen"] }
|
||||
photocraft-psd = { workspace = true, features = ["testgen"] }
|
||||
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"] }
|
||||
|
||||
|
||||
@@ -18,17 +18,27 @@ pub struct Opened {
|
||||
|
||||
/// Decode a document that was read through a filesystem capability.
|
||||
pub fn open_bytes(name: &str, bytes: &[u8]) -> Result<Opened, AutomationError> {
|
||||
open_bytes_with_options(name, bytes, &photocraft_io::ImportOptions::default())
|
||||
}
|
||||
|
||||
/// Decode a document that was read through a filesystem capability with custom import options.
|
||||
pub fn open_bytes_with_options(name: &str, bytes: &[u8], opts: &photocraft_io::ImportOptions) -> Result<Opened, AutomationError> {
|
||||
if Path::new(name).extension().is_some_and(|extension| extension.eq_ignore_ascii_case(photocraft_format::EXTENSION)) {
|
||||
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)?;
|
||||
let r = photocraft_io::import_with_options(name, bytes, opts)?;
|
||||
Ok(Opened { document: r.document, warnings: r.warnings, source_read_only: r.source_read_only })
|
||||
}
|
||||
|
||||
/// Open a document from disk. Directory bundles and `.pcraft` ZIPs load
|
||||
/// natively; everything else goes through `photocraft-io` (PSD, PNG, …).
|
||||
pub fn open(path: &Path) -> Result<Opened, AutomationError> {
|
||||
open_with_options(path, &photocraft_io::ImportOptions::default())
|
||||
}
|
||||
|
||||
/// Open a document from disk with custom import options.
|
||||
pub fn open_with_options(path: &Path, opts: &photocraft_io::ImportOptions) -> Result<Opened, AutomationError> {
|
||||
if path.is_dir() {
|
||||
let document = photocraft_format::load_path(path)?;
|
||||
return Ok(Opened { warnings: photocraft_io::open_warnings(&document), document, source_read_only: false });
|
||||
@@ -38,23 +48,39 @@ pub fn open(path: &Path) -> Result<Opened, AutomationError> {
|
||||
// convert or info of a 2 GB file does not hold the file in memory next to the document.
|
||||
#[cfg(any(unix, windows))]
|
||||
{
|
||||
let r =
|
||||
photocraft_io::import_file(&name, path, &photocraft_io::ImportOptions::default(), &photocraft_raster::Interrupt::NONE).map_err(|e| match e {
|
||||
photocraft_io::IoError::Read(msg) => AutomationError::Io(msg),
|
||||
e => e.into(),
|
||||
})?;
|
||||
let r = photocraft_io::import_file(&name, path, opts, &photocraft_raster::Interrupt::NONE).map_err(|e| match e {
|
||||
photocraft_io::IoError::Read(msg) => AutomationError::Io(msg),
|
||||
e => e.into(),
|
||||
})?;
|
||||
Ok(Opened { document: r.document, warnings: r.warnings, source_read_only: r.source_read_only })
|
||||
}
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
{
|
||||
let bytes = photocraft_format::read_file(path).map_err(|e| AutomationError::Io(format!("{}: {e}", path.display())))?;
|
||||
open_bytes(&name, &bytes)
|
||||
open_bytes_with_options(&name, &bytes, opts)
|
||||
}
|
||||
}
|
||||
|
||||
/// Previews for a `.pcraft` bundle.
|
||||
pub fn previews(doc: &Document) -> SaveOptions {
|
||||
SaveOptions { thumbnail: Some(photocraft_compose::thumbnail(doc, 256)), composite: Some(photocraft_compose::thumbnail(doc, 1024)) }
|
||||
SaveOptions { thumbnail: Some(photocraft_compose::thumbnail(doc, 256)), composite: Some(photocraft_compose::thumbnail(doc, 1024)), ..Default::default() }
|
||||
}
|
||||
|
||||
/// [`previews`], plus the full-size composite when `opts` asks for it and it can be rendered
|
||||
/// ([`ExportOptions::pcraft_full_composite`]). Fails when a scratch tile of `doc` could not be
|
||||
/// read while they were rendered: the previews may then hold placeholder pixels, and the
|
||||
/// bundle's own scratch checks only cover reads made after it starts.
|
||||
fn native_options(doc: &Document, opts: &ExportOptions) -> Result<SaveOptions, AutomationError> {
|
||||
doc.scratch_health().map_err(AutomationError::Io)?;
|
||||
let generation = photocraft_raster::spill::read_error_generation();
|
||||
let mut out = previews(doc);
|
||||
if opts.pcraft_full_composite {
|
||||
out.full_composite = photocraft_io::pcraft_full_composite(doc);
|
||||
out.full_composite_icc = out.full_composite.as_ref().map(|_| photocraft_io::pcraft_full_composite_icc(doc));
|
||||
}
|
||||
photocraft_raster::spill::check_since(generation).map_err(AutomationError::Io)?;
|
||||
doc.scratch_health().map_err(AutomationError::Io)?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Encode the name the saved native bundle will report on reopening. Save As updates
|
||||
@@ -86,7 +112,7 @@ pub fn save(
|
||||
let named = named_for_output(doc, &path.to_string_lossy());
|
||||
let mut local = PcraftWriter::new();
|
||||
let w = writer.unwrap_or(&mut local);
|
||||
w.save_path(&named, path, &previews(&named))?;
|
||||
w.save_path(&named, path, &native_options(&named, opts)?)?;
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let r = photocraft_io::export(doc, &ext, opts)?;
|
||||
@@ -104,7 +130,7 @@ pub fn save_bytes(doc: &Document, name: &str, format_override: Option<&str>, opt
|
||||
.ok_or_else(|| AutomationError::BadRequest(format!("cannot tell the format of `{name}`; pass a format")))?;
|
||||
if ext == photocraft_format::EXTENSION {
|
||||
let named = named_for_output(doc, name);
|
||||
return Ok((photocraft_format::save_to_bytes(&named, &previews(&named))?, Vec::new()));
|
||||
return Ok((photocraft_format::save_to_bytes(&named, &native_options(&named, opts)?)?, Vec::new()));
|
||||
}
|
||||
let result = photocraft_io::export(doc, &ext, opts)?;
|
||||
Ok((result.bytes, result.warnings))
|
||||
|
||||
@@ -90,14 +90,25 @@ impl Headless {
|
||||
|
||||
/// Open a file and make it the active document.
|
||||
pub fn open(&mut self, path: &Path) -> Result<Value, AutomationError> {
|
||||
self.open_with_approval(path, false)
|
||||
}
|
||||
|
||||
/// Open a file with optional high-memory approval and make it the active document.
|
||||
pub fn open_with_approval(&mut self, path: &Path, approve_high_memory: bool) -> Result<Value, AutomationError> {
|
||||
let opts = photocraft_io::ImportOptions { psd_high_memory_approved: approve_high_memory, ..Default::default() };
|
||||
self.open_with_options(path, &opts)
|
||||
}
|
||||
|
||||
/// Open a file with custom import options and make it the active document.
|
||||
pub fn open_with_options(&mut self, path: &Path, opts: &photocraft_io::ImportOptions) -> Result<Value, AutomationError> {
|
||||
let requested = path.to_str().ok_or_else(|| AutomationError::BadRequest("automation paths must be valid UTF-8".into()))?;
|
||||
let mut o = match &self.filesystem {
|
||||
Filesystem::Denied => return Err(AutomationError::BadRequest("automation filesystem access is not granted: read authority is absent".into())),
|
||||
Filesystem::TrustedLocal => files::open(path)?,
|
||||
Filesystem::TrustedLocal => files::open_with_options(path, opts)?,
|
||||
Filesystem::Workspace(workspace) => {
|
||||
let bytes = workspace.read(requested)?;
|
||||
let name = path.file_name().and_then(|name| name.to_str()).unwrap_or(requested);
|
||||
files::open_bytes(name, &bytes)?
|
||||
files::open_bytes_with_options(name, &bytes, opts)?
|
||||
}
|
||||
};
|
||||
// Preferences › Raw Defaults › Resolution, as the app's File › Open applies it.
|
||||
@@ -129,7 +140,8 @@ impl Headless {
|
||||
/// Place a file into a document as a Smart Object layer: the capability-scoped counterpart
|
||||
/// of `file.placeEmbedded`, which reads an ambient path and is therefore refused in
|
||||
/// automation (#2810). `path` resolves beneath the read root the way `open` resolves one,
|
||||
/// and `params` carries the command's own options (`scale`, `fit`, `center`).
|
||||
/// and `params` carries the command's own options (`scale`, `fit`, `center`,
|
||||
/// `approveHighMemory`).
|
||||
pub fn place(&mut self, index: Option<usize>, path: &str, params: &Value) -> Result<Value, AutomationError> {
|
||||
self.on_doc(index, |this| {
|
||||
let mut opts = params.as_object().cloned().unwrap_or_default();
|
||||
@@ -358,6 +370,8 @@ impl Headless {
|
||||
if let Some(d) = self.session.close(i) {
|
||||
self.writers.remove(&d.doc.id.0);
|
||||
}
|
||||
// The closed document's buffers went to the scratch pool: hand them back to the OS.
|
||||
photocraft_raster::scratch::release();
|
||||
Ok(self.session_list())
|
||||
}
|
||||
|
||||
|
||||
@@ -61,6 +61,36 @@ pub const METHODS: &[&str] = &[
|
||||
"methods",
|
||||
];
|
||||
|
||||
/// Adds the structured memory fields (`required`, soft and hard limits and measured capacity)
|
||||
/// from a typed admission error to a JSON error object, so clients can offer approval.
|
||||
fn attach_memory_error_details(obj: &mut Value, err: &AutomationError) {
|
||||
match err {
|
||||
AutomationError::Import(photocraft_io::IoError::PsdApprovalRequired { required, soft_limit, hard_limit, available_ram })
|
||||
| AutomationError::Engine(photocraft_engine::EngineError::PsdApprovalRequired { required, soft_limit, hard_limit, available_ram, .. }) => {
|
||||
if let Some(m) = obj.as_object_mut() {
|
||||
m.insert("required".into(), json!(required));
|
||||
m.insert("soft".into(), json!(soft_limit));
|
||||
m.insert("softLimit".into(), json!(soft_limit));
|
||||
m.insert("hard".into(), json!(hard_limit));
|
||||
m.insert("hardLimit".into(), json!(hard_limit));
|
||||
if let Some(avail) = available_ram {
|
||||
m.insert("available".into(), json!(avail));
|
||||
m.insert("availableRam".into(), json!(avail));
|
||||
}
|
||||
}
|
||||
}
|
||||
AutomationError::Import(photocraft_io::IoError::PsdImportBudget { required, limit })
|
||||
| AutomationError::Engine(photocraft_engine::EngineError::PsdHardBudget { required, limit, .. }) => {
|
||||
if let Some(m) = obj.as_object_mut() {
|
||||
m.insert("required".into(), json!(required));
|
||||
m.insert("hard".into(), json!(limit));
|
||||
m.insert("hardLimit".into(), json!(limit));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn bad(msg: impl Into<String>) -> AutomationError {
|
||||
AutomationError::BadRequest(msg.into())
|
||||
}
|
||||
@@ -107,10 +137,18 @@ impl Headless {
|
||||
"session.list" => Ok(self.session_list()),
|
||||
"doc.open" => {
|
||||
let path = str_of(&p, "path").ok_or_else(|| bad("doc.open needs `path`"))?;
|
||||
self.open(&PathBuf::from(path))
|
||||
let approve = match p.get("approveHighMemory") {
|
||||
None | Some(Value::Null) => false,
|
||||
Some(Value::Bool(b)) => *b,
|
||||
Some(_) => return Err(bad("`approveHighMemory` must be a boolean")),
|
||||
};
|
||||
self.open_with_approval(&PathBuf::from(path), approve)
|
||||
}
|
||||
"doc.place" => {
|
||||
let path = str_of(&p, "path").ok_or_else(|| bad("doc.place needs `path`"))?;
|
||||
if p.get("approveHighMemory").is_some_and(|v| !v.is_boolean()) {
|
||||
return Err(bad("`approveHighMemory` must be a boolean"));
|
||||
}
|
||||
let mut opts = p.as_object().cloned().unwrap_or_default();
|
||||
opts.remove("path");
|
||||
opts.remove("index");
|
||||
@@ -126,10 +164,22 @@ impl Headless {
|
||||
"doc.new" => self.command_run("file.new", p),
|
||||
"doc.save" => {
|
||||
let tga_bits = photocraft_engine::file_cmds::tga_bits_param(&p, "doc.save").map_err(|e| bad(e.to_string()))?;
|
||||
let mut opts =
|
||||
photocraft_io::ExportOptions { tiff_layers: p.get("tiffLayers").and_then(Value::as_bool).unwrap_or(false), tga_bits, ..Default::default() };
|
||||
let mut opts = photocraft_io::ExportOptions {
|
||||
tiff_layers: p.get("tiffLayers").and_then(Value::as_bool).unwrap_or(false),
|
||||
tga_bits,
|
||||
pcraft_full_composite: photocraft_engine::file_cmds::pcraft_full_composite_param(&p, "doc.save")
|
||||
.map_err(|e| bad(e.to_string()))?
|
||||
.unwrap_or(false),
|
||||
..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(c) = photocraft_engine::file_cmds::tiff_compression_param(&p, "doc.save").map_err(|e| bad(e.to_string()))? {
|
||||
opts.encode.tiff_compression = c;
|
||||
}
|
||||
let tiff_bool = |key| photocraft_engine::file_cmds::tiff_bool_param(&p, key, "doc.save").map_err(|e| bad(e.to_string()));
|
||||
opts.tiff_transparency = tiff_bool("tiffTransparency")?;
|
||||
opts.encode.tiff_pyramid = tiff_bool("tiffPyramid")?.unwrap_or(false);
|
||||
match p.get("quality") {
|
||||
None | Some(Value::Null) => {}
|
||||
Some(v) => {
|
||||
@@ -224,7 +274,11 @@ impl Headless {
|
||||
let was_error = r.is_err();
|
||||
let result = match r {
|
||||
Ok(v) => json!({"ok": true, "result": v}),
|
||||
Err(e) => json!({"ok": false, "error": e.to_string()}),
|
||||
Err(e) => {
|
||||
let mut obj = json!({"ok": false, "error": e.to_string()});
|
||||
attach_memory_error_details(&mut obj, &e);
|
||||
obj
|
||||
}
|
||||
};
|
||||
if let Err(error) = reply_budget.charge(&result) {
|
||||
failed += 1;
|
||||
@@ -262,7 +316,11 @@ pub fn respond(h: &Mutex<Headless>, line: &str) -> Value {
|
||||
.unwrap_or_else(|_| Err(AutomationError::Other(format!("internal error: `{method}` panicked"))));
|
||||
match r {
|
||||
Ok(v) => json!({"id": id, "ok": true, "result": v}),
|
||||
Err(e) => json!({"id": id, "ok": false, "error": e.to_string()}),
|
||||
Err(e) => {
|
||||
let mut obj = json!({"id": id, "ok": false, "error": e.to_string()});
|
||||
attach_memory_error_details(&mut obj, &e);
|
||||
obj
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1028,3 +1028,88 @@ fn invalid_document_index_is_rejected_not_the_active_document() {
|
||||
assert_eq!(reply["ok"], true, "{reply}");
|
||||
assert_eq!(count(&h), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approve_high_memory_rejects_invalid_types_and_formats_structured_error() {
|
||||
let h = session();
|
||||
// Non-boolean approveHighMemory is rejected strictly
|
||||
let reply = respond(&h, r#"{"method":"doc.open","params":{"path":"test.psd","approveHighMemory":"yes"}}"#);
|
||||
assert_eq!(reply["ok"], false);
|
||||
assert!(reply["error"].as_str().unwrap().contains("must be a boolean"));
|
||||
|
||||
let reply_place = respond(&h, r#"{"method":"doc.place","params":{"path":"test.psd","approveHighMemory":123}}"#);
|
||||
assert_eq!(reply_place["ok"], false);
|
||||
assert!(reply_place["error"].as_str().unwrap().contains("must be a boolean"));
|
||||
|
||||
// Verify attach_memory_error_details adds machine-readable fields
|
||||
let mut err_obj = json!({"id": 1, "ok": false, "error": "test"});
|
||||
let auto_err = crate::AutomationError::Import(photocraft_io::IoError::PsdApprovalRequired {
|
||||
required: 5000,
|
||||
soft_limit: 2000,
|
||||
hard_limit: 8000,
|
||||
available_ram: Some(10000),
|
||||
});
|
||||
super::attach_memory_error_details(&mut err_obj, &auto_err);
|
||||
assert_eq!(err_obj["required"], 5000);
|
||||
assert_eq!(err_obj["soft"], 2000);
|
||||
assert_eq!(err_obj["softLimit"], 2000);
|
||||
assert_eq!(err_obj["hard"], 8000);
|
||||
assert_eq!(err_obj["hardLimit"], 8000);
|
||||
assert_eq!(err_obj["available"], 10000);
|
||||
assert_eq!(err_obj["availableRam"], 10000);
|
||||
|
||||
// An engine-command hard refusal (Open As / Place go through `EngineError`) keeps its typed
|
||||
// fields, and only the fields an approval request has: no soft limit and no capacity, since
|
||||
// no approval can raise the ceiling.
|
||||
let mut hard_obj = json!({"id": 2, "ok": false, "error": "test"});
|
||||
let hard = crate::AutomationError::Engine(photocraft_engine::EngineError::PsdHardBudget { name: "big.psd".into(), required: 9000, limit: 8000 });
|
||||
super::attach_memory_error_details(&mut hard_obj, &hard);
|
||||
assert_eq!(hard_obj["required"], 9000);
|
||||
assert_eq!(hard_obj["hard"], 8000);
|
||||
assert_eq!(hard_obj["hardLimit"], 8000);
|
||||
for absent in ["soft", "softLimit", "available", "availableRam"] {
|
||||
assert!(hard_obj.get(absent).is_none(), "a hard refusal has no {absent}: {hard_obj}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #10897: `doc.place` forwards the strict one-shot high-memory approval on both routes: the
|
||||
/// read-root route (the bytes are imported in-process) and trusted local (the ambient
|
||||
/// `file.placeEmbedded` command reads the path). A tiny PSD plus the scoped estimate charge
|
||||
/// reaches the real approval window without allocating anything close to a gigabyte.
|
||||
#[test]
|
||||
fn doc_place_forwards_high_memory_approval() {
|
||||
if cfg!(target_pointer_width = "32") {
|
||||
return; // the 3 GiB charge would exceed a 32-bit process cap
|
||||
}
|
||||
let gb = 1u64 << 30;
|
||||
let root = std::env::temp_dir().join(format!("pc-doc-place-approval-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let psd = photocraft_psd::testgen::small(photocraft_psd::Version::Psd, photocraft_psd::Compression::Zip).to_bytes().unwrap();
|
||||
std::fs::write(root.join("high.psd"), &psd).unwrap();
|
||||
// Known 5 GiB RAM: S = 2.5 GiB, H = 3.75 GiB. A 3 GiB estimate charge lands in (S, H].
|
||||
let snap = photocraft_io::memory::MemorySnapshot { available_ram: Some(5 * gb), swap_free: None, commit_headroom: None };
|
||||
let trial = |h: &mut Headless, path: &str| {
|
||||
photocraft_io::psd_budget::with_test_estimate_charge(3 * gb, || {
|
||||
photocraft_io::memory::with_mock_memory(snap, || {
|
||||
let refused = h.handle("doc.place", json!({"path": path})).unwrap_err();
|
||||
assert!(
|
||||
matches!(refused, crate::AutomationError::Engine(photocraft_engine::EngineError::PsdApprovalRequired { .. })),
|
||||
"an unapproved high-memory place must ask for approval: {refused}"
|
||||
);
|
||||
let placed = h.handle("doc.place", json!({"path": path, "approveHighMemory": true})).unwrap();
|
||||
assert!(placed["layer"].as_u64().is_some(), "the approval lets the same place proceed: {placed}");
|
||||
});
|
||||
});
|
||||
};
|
||||
let mut h = Headless::with_workspace(crate::AuthorizedWorkspace::new(Some(&root), Some(&root)).unwrap());
|
||||
h.handle("doc.new", json!({"width": 40, "height": 30})).unwrap();
|
||||
trial(&mut h, "high.psd");
|
||||
// Release directory capabilities before cleanup on Windows.
|
||||
drop(h);
|
||||
let mut t = Headless::trusted_local();
|
||||
t.handle("doc.new", json!({"width": 40, "height": 30})).unwrap();
|
||||
let absolute = root.join("high.psd").to_string_lossy().into_owned();
|
||||
trial(&mut t, &absolute);
|
||||
drop(t);
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
+177
-20
@@ -50,6 +50,9 @@ pub struct DocIndex {
|
||||
pub struct OpenParams {
|
||||
/// Path beneath the configured automation read root: relative (forward slashes) or absolute.
|
||||
pub path: String,
|
||||
/// Explicitly approve high-memory PSD/PSB imports that fit within the hard memory budget.
|
||||
#[serde(default)]
|
||||
pub approve_high_memory: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize, JsonSchema)]
|
||||
@@ -69,6 +72,9 @@ pub struct PlaceParams {
|
||||
/// Centre of the placed layer, in document pixels.
|
||||
#[serde(default)]
|
||||
pub center: Option<[f64; 2]>,
|
||||
/// Explicitly approve high-memory PSD/PSB imports that fit within the hard memory budget.
|
||||
#[serde(default)]
|
||||
pub approve_high_memory: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize, JsonSchema)]
|
||||
@@ -177,6 +183,17 @@ pub struct SaveParams {
|
||||
/// TIFF: keep the layers (Photoshop layer data). Off by default: a flat TIFF.
|
||||
#[serde(default, rename = "tiffLayers")]
|
||||
pub tiff_layers: bool,
|
||||
/// TIFF: image compression, "none" (the default), "lzw" or "zip".
|
||||
#[serde(default, rename = "tiffCompression")]
|
||||
pub tiff_compression: Option<String>,
|
||||
/// TIFF: Save Transparency. On keeps the transparency, ahead of any alpha or spot channels;
|
||||
/// off composites the image over white. Unset: kept unless the document has alpha or spot
|
||||
/// channels.
|
||||
#[serde(default, rename = "tiffTransparency")]
|
||||
pub tiff_transparency: Option<bool>,
|
||||
/// TIFF: Save Image Pyramid, half-size copies after the full image.
|
||||
#[serde(default, rename = "tiffPyramid")]
|
||||
pub tiff_pyramid: bool,
|
||||
/// TGA: 24 or 32 bits per pixel. 32 writes the alpha channel (or else the transparency) as the
|
||||
/// alpha, 24 none. Unset: 32 with an alpha channel or transparency, else 24.
|
||||
#[serde(default, rename = "tgaBits")]
|
||||
@@ -191,6 +208,10 @@ pub struct SaveParams {
|
||||
/// DDS: also write the full mip chain, each level a box filter of the one above.
|
||||
#[serde(default, rename = "ddsMipmaps")]
|
||||
pub dds_mipmaps: bool,
|
||||
/// .pcraft: request the flattened document at its canvas size as `composite/full.png`.
|
||||
/// The save may omit it; check the manifest's `full_composite` member. Off by default.
|
||||
#[serde(default, rename = "pcraftFullComposite")]
|
||||
pub pcraft_full_composite: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub index: Option<usize>,
|
||||
}
|
||||
@@ -480,13 +501,64 @@ fn png_result(png: &[u8], note: String) -> CallToolResult {
|
||||
bounded_tool_result(CallToolResult::success(vec![Content::image(b64, "image/png"), Content::text(note)]))
|
||||
}
|
||||
|
||||
/// Adds the typed byte counts of a memory refusal to an error object: an approval request keeps
|
||||
/// its soft limit, hard limit and (when known) capacity; a hard refusal keeps only its required
|
||||
/// and hard amounts, because no approval can raise that ceiling.
|
||||
fn attach_memory_error_details(obj: &mut Value, err: &AutomationError) {
|
||||
let Some(obj) = obj.as_object_mut() else { return };
|
||||
match err {
|
||||
AutomationError::Import(photocraft_io::IoError::PsdApprovalRequired { required, soft_limit, hard_limit, available_ram })
|
||||
| AutomationError::Engine(photocraft_engine::EngineError::PsdApprovalRequired { required, soft_limit, hard_limit, available_ram, .. }) => {
|
||||
obj.insert("required".into(), json!(required));
|
||||
obj.insert("soft".into(), json!(soft_limit));
|
||||
obj.insert("softLimit".into(), json!(soft_limit));
|
||||
obj.insert("hard".into(), json!(hard_limit));
|
||||
obj.insert("hardLimit".into(), json!(hard_limit));
|
||||
if let Some(avail) = available_ram {
|
||||
obj.insert("available".into(), json!(avail));
|
||||
obj.insert("availableRam".into(), json!(avail));
|
||||
}
|
||||
}
|
||||
AutomationError::Import(photocraft_io::IoError::PsdImportBudget { required, limit })
|
||||
| AutomationError::Engine(photocraft_engine::EngineError::PsdHardBudget { required, limit, .. }) => {
|
||||
obj.insert("required".into(), json!(required));
|
||||
obj.insert("hard".into(), json!(limit));
|
||||
obj.insert("hardLimit".into(), json!(limit));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn to_result(r: Result<Value, AutomationError>) -> Result<CallToolResult, McpError> {
|
||||
Ok(match r {
|
||||
Ok(v) => ok_json(&v),
|
||||
Err(e) => fail(e),
|
||||
Err(e) => {
|
||||
let mut obj = json!({"error": e.to_string()});
|
||||
attach_memory_error_details(&mut obj, &e);
|
||||
let text = serde_json::to_string(&obj).unwrap_or_else(|_| e.to_string());
|
||||
CallToolResult::error(vec![Content::text(text)])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// A `doc_place` call's command options, including the explicit high-memory approval.
|
||||
fn place_options(p: &PlaceParams) -> Value {
|
||||
let mut opts = serde_json::Map::new();
|
||||
if let Some(v) = p.scale {
|
||||
opts.insert("scale".into(), json!(v));
|
||||
}
|
||||
if let Some(v) = p.fit {
|
||||
opts.insert("fit".into(), json!(v));
|
||||
}
|
||||
if let Some(v) = p.center {
|
||||
opts.insert("center".into(), json!(v));
|
||||
}
|
||||
if p.approve_high_memory {
|
||||
opts.insert("approveHighMemory".into(), json!(true));
|
||||
}
|
||||
Value::Object(opts)
|
||||
}
|
||||
|
||||
fn bridge_only(name: &str) -> Result<CallToolResult, McpError> {
|
||||
Ok(fail(format!(
|
||||
"`{name}` drives the live GUI and needs bridge mode: start the app with \
|
||||
@@ -631,30 +703,25 @@ impl PhotocraftMcp {
|
||||
#[tool(description = "Open an image or document file and make it active. Returns index, size and import warnings.")]
|
||||
async fn doc_open(&self, Parameters(p): Parameters<OpenParams>) -> Result<CallToolResult, McpError> {
|
||||
let path = PathBuf::from(&p.path);
|
||||
if let Some(r) = self.headless_op(move |h| h.open(&path)).await {
|
||||
let approve = p.approve_high_memory;
|
||||
if let Some(r) = self.headless_op(move |h| h.open_with_approval(&path, approve)).await {
|
||||
return to_result(r);
|
||||
}
|
||||
let Some(b) = self.bridge_client() else {
|
||||
return Ok(no_backend());
|
||||
};
|
||||
to_result(b.call("app.open", json!({"path": p.path})).await)
|
||||
let mut params = json!({"path": p.path});
|
||||
if approve {
|
||||
params["approveHighMemory"] = json!(true);
|
||||
}
|
||||
to_result(b.call("app.open", params).await)
|
||||
}
|
||||
|
||||
#[tool(
|
||||
description = "Place a file into the document as a Smart Object layer (Place Embedded), reading `path` beneath the read root. `scale` (percent), `fit` and `center` are the command's own."
|
||||
description = "Place a file into the document as a Smart Object layer (Place Embedded), reading `path` beneath the read root. `scale` (percent), `fit`, `center` and `approve_high_memory` (one-shot PSD/PSB consent, default false) are the command's own."
|
||||
)]
|
||||
async fn doc_place(&self, Parameters(p): Parameters<PlaceParams>) -> Result<CallToolResult, McpError> {
|
||||
let mut opts = serde_json::Map::new();
|
||||
if let Some(v) = p.scale {
|
||||
opts.insert("scale".into(), json!(v));
|
||||
}
|
||||
if let Some(v) = p.fit {
|
||||
opts.insert("fit".into(), json!(v));
|
||||
}
|
||||
if let Some(v) = p.center {
|
||||
opts.insert("center".into(), json!(v));
|
||||
}
|
||||
let opts = Value::Object(opts);
|
||||
let opts = place_options(&p);
|
||||
// Bridge mode runs the app on this machine, which reads the path itself like any command.
|
||||
let mut bridge_params = opts.clone();
|
||||
if let Some(o) = bridge_params.as_object_mut() {
|
||||
@@ -792,7 +859,7 @@ impl PhotocraftMcp {
|
||||
}
|
||||
|
||||
#[tool(description = "Save the document. `.pcraft` is the lossless native format (incremental); other extensions \
|
||||
(psd, png, jpg, tif, webp, exr, …) export. Without `path` only a PSD, PSB or .pcraft document is written back to its own file. Returns warnings about anything the format cannot hold.")]
|
||||
(psd, png, jpg, tif, webp, exr, …) export. Without `path` only a PSD, PSB or .pcraft document is written back to its own file. `pcraftFullComposite` requests `composite/full.png`, which may be omitted; check the manifest's `full_composite` member. Returns warnings about anything the format cannot hold.")]
|
||||
async fn doc_save(&self, Parameters(p): Parameters<SaveParams>) -> Result<CallToolResult, McpError> {
|
||||
self.save_impl(p).await
|
||||
}
|
||||
@@ -939,7 +1006,11 @@ impl PhotocraftMcp {
|
||||
let was_error = response.is_err();
|
||||
let result = match response {
|
||||
Ok(value) => json!({"ok": true, "result": value}),
|
||||
Err(error) => json!({"ok": false, "error": error.to_string()}),
|
||||
Err(error) => {
|
||||
let mut obj = json!({"ok": false, "error": error.to_string()});
|
||||
attach_memory_error_details(&mut obj, &error);
|
||||
obj
|
||||
}
|
||||
};
|
||||
if let Err(error) = reply_budget.charge(&result) {
|
||||
failed += 1;
|
||||
@@ -1062,6 +1133,10 @@ 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 tiff_compression = match photocraft_engine::file_cmds::tiff_compression_param(&json!({"tiffCompression": p.tiff_compression}), "save") {
|
||||
Ok(c) => c,
|
||||
Err(e) => return Ok(fail(e.to_string())),
|
||||
};
|
||||
if let Some(q) = p.quality.filter(|q| !(1..=100).contains(q)) {
|
||||
return Ok(fail(format!("`quality` must be a whole number from 1 to 100, got {q}")));
|
||||
}
|
||||
@@ -1073,12 +1148,14 @@ impl PhotocraftMcp {
|
||||
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), 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.
|
||||
// does), the Targa bits, the DDS options and the TIFF compression, transparency and
|
||||
// pyramid, 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"),
|
||||
p.quality.is_some().then_some("quality"),
|
||||
p.tiff_layers.then_some("tiffLayers"),
|
||||
p.pcraft_full_composite.is_some().then_some("pcraftFullComposite"),
|
||||
dpx_bits.is_some().then_some("dpxBits"),
|
||||
p.index.is_some().then_some("index"),
|
||||
]
|
||||
@@ -1101,12 +1178,32 @@ impl PhotocraftMcp {
|
||||
if p.dds_mipmaps {
|
||||
params["ddsMipmaps"] = json!(true);
|
||||
}
|
||||
// The app's TIFF Options answer, as its Save As takes it.
|
||||
if let Some(c) = &p.tiff_compression {
|
||||
params["tiffCompression"] = json!(c);
|
||||
}
|
||||
if let Some(t) = p.tiff_transparency {
|
||||
params["tiffTransparency"] = json!(t);
|
||||
}
|
||||
if p.tiff_pyramid {
|
||||
params["tiffPyramid"] = 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() };
|
||||
let mut opts = photocraft_io::ExportOptions {
|
||||
tiff_layers: p.tiff_layers,
|
||||
tiff_transparency: p.tiff_transparency,
|
||||
tga_bits,
|
||||
pcraft_full_composite: p.pcraft_full_composite.unwrap_or(false),
|
||||
..Default::default()
|
||||
};
|
||||
opts.encode.dpx_bits = dpx_bits;
|
||||
if let Some(c) = tiff_compression {
|
||||
opts.encode.tiff_compression = c;
|
||||
}
|
||||
opts.encode.tiff_pyramid = p.tiff_pyramid;
|
||||
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;
|
||||
@@ -1255,6 +1352,66 @@ mod tests {
|
||||
assert_eq!(docs, 0, "the step after the budget ran out did not run");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn mcp_doc_open_schema_and_structured_error() {
|
||||
let schema = &PhotocraftMcp::tool_router().map["doc_open"].attr.input_schema;
|
||||
assert_eq!(schema["properties"]["approve_high_memory"]["type"], "boolean");
|
||||
let omitted: OpenParams = serde_json::from_value(json!({"path": "file.psd"})).unwrap();
|
||||
assert!(!omitted.approve_high_memory);
|
||||
let approved: OpenParams = serde_json::from_value(json!({"path": "file.psd", "approve_high_memory": true})).unwrap();
|
||||
assert!(approved.approve_high_memory);
|
||||
for invalid in [Value::Null, json!("true"), json!(1)] {
|
||||
assert!(serde_json::from_value::<OpenParams>(json!({"path": "file.psd", "approve_high_memory": invalid})).is_err());
|
||||
}
|
||||
|
||||
let err = AutomationError::Import(photocraft_io::IoError::PsdApprovalRequired {
|
||||
required: 6000,
|
||||
soft_limit: 3000,
|
||||
hard_limit: 9000,
|
||||
available_ram: Some(12000),
|
||||
});
|
||||
let res = to_result(Err(err)).unwrap();
|
||||
assert_eq!(res.is_error, Some(true));
|
||||
let text = &res.content.first().and_then(|c| c.as_text()).unwrap().text;
|
||||
let v: Value = serde_json::from_str(text).unwrap();
|
||||
assert_eq!(v["required"], 6000);
|
||||
assert_eq!(v["soft"], 3000);
|
||||
assert_eq!(v["hard"], 9000);
|
||||
assert_eq!(v["available"], 12000);
|
||||
}
|
||||
|
||||
/// #10897: the MCP `doc_place` tool takes the same strict, defaulted approval as `doc_open`
|
||||
/// and forwards it as the command's `approveHighMemory` option. A hard refusal keeps its
|
||||
/// typed `required`/`hardLimit` fields and claims nothing about a soft limit or capacity.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn mcp_doc_place_schema_approval_and_structured_hard_error() {
|
||||
let schema = &PhotocraftMcp::tool_router().map["doc_place"].attr.input_schema;
|
||||
assert_eq!(schema["properties"]["approve_high_memory"]["type"], "boolean");
|
||||
let omitted: PlaceParams = serde_json::from_value(json!({"path": "file.psd"})).unwrap();
|
||||
assert!(!omitted.approve_high_memory);
|
||||
assert!(place_options(&omitted).get("approveHighMemory").is_none(), "false is not forwarded");
|
||||
let approved: PlaceParams = serde_json::from_value(json!({"path": "file.psd", "approve_high_memory": true})).unwrap();
|
||||
assert!(approved.approve_high_memory);
|
||||
assert_eq!(place_options(&approved)["approveHighMemory"], true);
|
||||
let explicit_false: PlaceParams = serde_json::from_value(json!({"path": "file.psd", "approve_high_memory": false})).unwrap();
|
||||
assert!(!explicit_false.approve_high_memory);
|
||||
for invalid in [Value::Null, json!("true"), json!(1)] {
|
||||
assert!(serde_json::from_value::<PlaceParams>(json!({"path": "file.psd", "approve_high_memory": invalid})).is_err(), "{invalid}");
|
||||
}
|
||||
|
||||
let err = AutomationError::Engine(photocraft_engine::EngineError::PsdHardBudget { name: "huge.psd".into(), required: 9_000, limit: 8_000 });
|
||||
let res = to_result(Err(err)).unwrap();
|
||||
assert_eq!(res.is_error, Some(true));
|
||||
let text = &res.content.first().and_then(|c| c.as_text()).unwrap().text;
|
||||
let v: Value = serde_json::from_str(text).unwrap();
|
||||
assert_eq!(v["required"], 9_000);
|
||||
assert_eq!(v["hard"], 8_000);
|
||||
assert_eq!(v["hardLimit"], 8_000);
|
||||
for absent in ["soft", "softLimit", "available", "availableRam"] {
|
||||
assert!(v.get(absent).is_none(), "a hard refusal has no {absent}: {v}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The MCP server applies finished jobs before every tool call too (#503).
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn mcp_tools_see_a_finished_background_job() {
|
||||
|
||||
@@ -694,6 +694,8 @@ fn documents_filesystem_path_params(id: &str, params: &str) -> bool {
|
||||
| "layer.vectorMask.info"
|
||||
| "paint.symmetryFromPath"
|
||||
| "edit.defineCustomShape"
|
||||
// `"as":"path"` pastes the clipboard as a vector path (#10694).
|
||||
| "edit.paste"
|
||||
| "layer.combineShapes.unite"
|
||||
| "layer.combineShapes.subtractFrontShape"
|
||||
| "layer.combineShapes.intersectShapeAreas"
|
||||
|
||||
@@ -80,3 +80,28 @@ fn a_flat_export_is_a_copy_and_leaves_the_document_dirty() {
|
||||
assert!(!headless.session.active().unwrap().is_dirty());
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// The control channel's `doc.save` takes the TIFF Options (#8827): compression, Save
|
||||
/// Transparency and Save Image Pyramid; a bad value is an error and writes nothing.
|
||||
#[test]
|
||||
fn doc_save_takes_tiff_options() {
|
||||
let dir = tmp("tiff-options");
|
||||
let mut headless = Headless::trusted_local();
|
||||
headless.handle("doc.new", json!({"width": 200, "height": 100, "background": "transparent"})).unwrap();
|
||||
let path = dir.join("o.tif");
|
||||
let p = path.to_str().unwrap();
|
||||
headless.handle("doc.save", json!({"path": p, "tiffCompression": "zip", "tiffTransparency": false, "tiffPyramid": true})).unwrap();
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
let info = photocraft_codecs::tiff_info(&bytes).unwrap();
|
||||
assert_eq!(info.pages.len(), 3);
|
||||
assert!(info.pages.iter().all(|pg| pg.compression == 8));
|
||||
assert!(!photocraft_codecs::decode(&bytes).unwrap().layout().has_alpha());
|
||||
let bad = dir.join("bad.tif");
|
||||
for params in [json!({"tiffCompression": "jpeg"}), json!({"tiffPyramid": 1}), json!({"tiffTransparency": "no"})] {
|
||||
let mut params = params;
|
||||
params["path"] = json!(bad.to_str().unwrap());
|
||||
assert!(headless.handle("doc.save", params.clone()).is_err(), "{params}");
|
||||
}
|
||||
assert!(!bad.exists());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ mod layered_save_dirty;
|
||||
mod mcp;
|
||||
mod mcp_wire;
|
||||
mod missing_font_warnings;
|
||||
mod native_full_composite;
|
||||
mod native_save_name;
|
||||
mod openraster;
|
||||
mod raw_defaults;
|
||||
|
||||
@@ -360,6 +360,8 @@ async fn bridge_doc_save_rejects_headless_only_options_instead_of_saving_with_de
|
||||
json!({"path": "out.jpg", "quality": 50}),
|
||||
json!({"path": "out.png", "format": "png"}),
|
||||
json!({"path": "out.tif", "tiffLayers": true}),
|
||||
json!({"path": "out.pcraft", "pcraftFullComposite": true}),
|
||||
json!({"path": "out.pcraft", "pcraftFullComposite": false}),
|
||||
json!({"path": "out.pcraft", "index": 0}),
|
||||
] {
|
||||
let r = call(&client, "doc_save", params.clone()).await;
|
||||
@@ -464,6 +466,53 @@ async fn headless_doc_save_writes_dds_options() {
|
||||
cleanup(&dir);
|
||||
}
|
||||
|
||||
/// TIFF Options (#8827) reach the app's `app.save` through the bridge; a bad compression is
|
||||
/// refused before anything is sent.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn bridge_doc_save_forwards_tiff_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.tif", "tiffCompression": "lzw", "tiffTransparency": false, "tiffPyramid": true})).await);
|
||||
assert_eq!(saved, json!({"saved": {"path": "out.tif", "tiffCompression": "lzw", "tiffTransparency": false, "tiffPyramid": true}}));
|
||||
for bad in [json!({"path": "out.tif", "tiffCompression": "jpeg"}), json!({"path": "out.tif", "tiffPyramid": "yes"})] {
|
||||
let r = call(&client, "doc_save", bad.clone()).await;
|
||||
assert_eq!(r.is_error, Some(true), "{bad}: {}", text(&r));
|
||||
}
|
||||
client.cancel().await.unwrap();
|
||||
app.abort();
|
||||
}
|
||||
|
||||
/// Headless `doc_save`/`doc_export` write the TIFF Options asked for (#8827): compression,
|
||||
/// Save Transparency and Save Image Pyramid.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn headless_doc_save_writes_tiff_options() {
|
||||
let dir = tmp("tiff-options");
|
||||
let client = connect(headless_in(&dir)).await;
|
||||
call(&client, "doc_new", json!({"width": 200, "height": 100, "background": "transparent"})).await;
|
||||
// (params, compression tag, alpha, directories)
|
||||
for (tool, params, compression, alpha, dirs) in [
|
||||
("doc_export", json!({"path": "plain.tif"}), 1, true, 1),
|
||||
("doc_export", json!({"path": "lzw.tif", "tiffCompression": "lzw"}), 5, true, 1),
|
||||
("doc_save", json!({"path": "zip.tif", "tiffCompression": "zip", "tiffTransparency": false}), 8, false, 1),
|
||||
("doc_save", json!({"path": "pyr.tif", "tiffPyramid": true, "tiffTransparency": true}), 1, true, 3),
|
||||
] {
|
||||
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();
|
||||
let info = photocraft_codecs::tiff_info(&bytes).unwrap();
|
||||
assert!(info.pages.iter().all(|p| p.compression == compression), "{params}: {:?}", info.pages);
|
||||
assert_eq!(info.pages.len(), dirs, "{params}");
|
||||
assert_eq!(photocraft_codecs::decode(&bytes).unwrap().layout().has_alpha(), alpha, "{params}");
|
||||
}
|
||||
for bad in [json!({"path": "bad.tif", "tiffCompression": "jpeg"}), json!({"path": "bad.tif", "tiffTransparency": "off"})] {
|
||||
let r = call(&client, "doc_export", bad.clone()).await;
|
||||
assert_eq!(r.is_error, Some(true), "{bad}: {}", text(&r));
|
||||
}
|
||||
assert!(!dir.join("bad.tif").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};
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
//! Scripted `.pcraft` saves write the full-size composite when they ask for it
|
||||
//! (`pcraftFullComposite`), and leave it out by default, as the app's Save does.
|
||||
|
||||
use photocraft_automation::{Headless, files};
|
||||
use photocraft_format::zip::ZipReader;
|
||||
use serde_json::json;
|
||||
|
||||
fn full_composite(bytes: &[u8]) -> ((u32, u32), Vec<u8>) {
|
||||
let png = photocraft_format::read_full_composite(bytes).unwrap().expect("the manifest names the full-size composite");
|
||||
let image = photocraft_codecs::decode(&png).unwrap();
|
||||
(image.dimensions(), image.to_rgba8())
|
||||
}
|
||||
|
||||
fn has_full_composite(bytes: &[u8]) -> bool {
|
||||
photocraft_format::read_full_composite(bytes).unwrap().is_some() || ZipReader::new(bytes).unwrap().find("composite/full.png").is_some()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scripted_native_saves_write_the_full_size_composite_when_asked() {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"pc-native-full-composite-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut headless = Headless::trusted_local();
|
||||
headless.handle("doc.new", json!({"width": 40, "height": 30, "name": "Full"})).unwrap();
|
||||
headless.handle("engine.execute", json!({"command":"image.adjustments.invert", "params":{}})).unwrap();
|
||||
let doc = headless.session.active().unwrap().doc.clone();
|
||||
let flattened = photocraft_compose::render(&doc, doc.bounds()).to_rgba8().pixels;
|
||||
|
||||
let (bytes, _) = files::save_bytes(&doc, "Full.pcraft", None, &Default::default()).unwrap();
|
||||
assert!(!has_full_composite(&bytes), "off by default");
|
||||
let on = photocraft_io::ExportOptions { pcraft_full_composite: true, ..Default::default() };
|
||||
let (bytes, _) = files::save_bytes(&doc, "Full.pcraft", None, &on).unwrap();
|
||||
assert_eq!(full_composite(&bytes), ((40, 30), flattened.clone()));
|
||||
|
||||
let path = dir.join("Full.pcraft");
|
||||
headless.handle("doc.save", json!({"path": path.to_str().unwrap()})).unwrap();
|
||||
assert!(!has_full_composite(&std::fs::read(&path).unwrap()), "off by default");
|
||||
headless.handle("doc.save", json!({"path": path.to_str().unwrap(), "pcraftFullComposite": true})).unwrap();
|
||||
assert_eq!(full_composite(&std::fs::read(&path).unwrap()), ((40, 30), flattened));
|
||||
for bad in [json!("true"), json!(1)] {
|
||||
let err = headless.handle("doc.save", json!({"path": dir.join("Bad.pcraft").to_str().unwrap(), "pcraftFullComposite": bad})).unwrap_err();
|
||||
assert!(format!("{err:?}").contains("`pcraftFullComposite` must be true or false"), "{bad}: {err:?}");
|
||||
assert!(!dir.join("Bad.pcraft").exists(), "{bad}: nothing is written");
|
||||
}
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
//! A scripted `.pcraft` save refuses to write previews or a full-size composite rendered from
|
||||
//! scratch tiles that could not be read. Its own test binary: the scratch store is per process.
|
||||
#![cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
use photocraft_automation::{AutomationError, files};
|
||||
use photocraft_doc::{ColorMode, Document, Layer, PixelFormat, SampleType, Size};
|
||||
use photocraft_geom::TileCoord;
|
||||
use photocraft_raster::spill;
|
||||
use std::io::{Read, Seek, SeekFrom, Write};
|
||||
|
||||
#[test]
|
||||
fn native_save_refuses_previews_rendered_from_unreadable_scratch() {
|
||||
let dir = std::env::temp_dir().join(format!("photocraft-automation-scratch-save-{}", std::process::id()));
|
||||
spill::configure(Some(&dir), 1).unwrap();
|
||||
let mut doc = Document::new("Scratch", Size::new(512, 256), ColorMode::Rgb, SampleType::U8);
|
||||
let mut layer = Layer::raster("Pixels", PixelFormat::RGBA8);
|
||||
for i in 0..2 {
|
||||
let mut bytes = layer.surface_mut().unwrap().tile_mut(TileCoord::new(i, 0)).bytes_mut();
|
||||
for (j, byte) in bytes.iter_mut().enumerate() {
|
||||
*byte = (j as u8).wrapping_mul(29).wrapping_add((i * 7) as u8 + 1);
|
||||
}
|
||||
}
|
||||
doc.layers.push(layer);
|
||||
spill::settle();
|
||||
assert_eq!(spill::stats().resident_bytes, 0);
|
||||
let mut file = spill::scratch_handle_for_tests().unwrap();
|
||||
file.seek(SeekFrom::Start(0)).unwrap();
|
||||
let mut good = Vec::new();
|
||||
file.read_to_end(&mut good).unwrap();
|
||||
file.seek(SeekFrom::Start(0)).unwrap();
|
||||
file.write_all(&vec![0; good.len()]).unwrap();
|
||||
file.sync_all().unwrap();
|
||||
|
||||
let on = photocraft_io::ExportOptions { pcraft_full_composite: true, ..Default::default() };
|
||||
// Refused while rendering the previews, before the bundle is assembled.
|
||||
let err = files::save_bytes(&doc, "Scratch.pcraft", None, &on).unwrap_err();
|
||||
assert!(matches!(err, AutomationError::Io(_)), "{err:?}");
|
||||
let path = dir.join("Scratch.pcraft");
|
||||
let err = files::save(&doc, &path, None, &on, None).unwrap_err();
|
||||
assert!(matches!(err, AutomationError::Io(_)), "{err:?}");
|
||||
assert!(!path.exists());
|
||||
|
||||
file.seek(SeekFrom::Start(0)).unwrap();
|
||||
file.write_all(&good).unwrap();
|
||||
file.sync_all().unwrap();
|
||||
for (_, tile) in doc.layers[0].surface().unwrap().tiles() {
|
||||
tile.try_bytes().unwrap();
|
||||
}
|
||||
assert!(spill::check_integrity().is_ok());
|
||||
let (bytes, _) = files::save_bytes(&doc, "Scratch.pcraft", None, &on).unwrap();
|
||||
assert_eq!(photocraft_format::load_from_bytes(&bytes).unwrap().layers, doc.layers);
|
||||
drop((doc, file));
|
||||
spill::configure(None, 0).unwrap();
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
@@ -254,8 +254,10 @@ embedded table (and a PNG's transparent entry) so `photocraft-io` can open them
|
||||
ms in release). Image data cut off by the end of the file decodes with
|
||||
`DecodeWarning::Truncated`; a file far too small for its declared size (more than any
|
||||
compression could expand) is refused before allocating.
|
||||
* YCbCr, CIE Lab, signed integers and compressions that the `tiff` crate lacks (CCITT,
|
||||
JPEG-in-TIFF) return `Unsupported`.
|
||||
* YCbCr with three 8- or 16-bit samples decodes to RGB. YCbCr with other sample counts or
|
||||
bit depths returns `Unsupported`. JPEG-compressed YCbCr uses the full-range JFIF conversion.
|
||||
* 8/16-bit CIELab and ICCLab decode to the Lab channel layout.
|
||||
* Signed integer samples and unsupported compression methods return `Unsupported`.
|
||||
* **TIFF encode** writes classic TIFF, or BigTIFF when `EncodeOptions::tiff_bigtiff` is set or
|
||||
the file could pass 4 GiB. `Metadata::extra_samples` (up to 16) are written after the colour
|
||||
and alpha samples, with their `kind` in the ExtraSamples tag (a reserved `kind` above 2 is
|
||||
|
||||
@@ -33,9 +33,63 @@ fn map_exr(e: exr::error::Error) -> CodecError {
|
||||
}
|
||||
}
|
||||
|
||||
/// Refuses headers whose `preview` attributes declare more pixels than their value holds.
|
||||
///
|
||||
/// The `exr` crate sizes a preview's pixel buffer from its declared dimensions (up to 4 MiB at
|
||||
/// once, zero-filled) before it finds the attribute too short, and in the non-pedantic mode
|
||||
/// every reader here uses it then skips the attribute and goes on: thousands of tiny malformed
|
||||
/// previews would each cost that allocation. This walks the raw headers (name, type, size,
|
||||
/// value per attribute; an empty name ends a header, an empty header ends a multi-part list)
|
||||
/// and checks each preview against its own size, so the crate only reads consistent ones.
|
||||
/// Anything else odd is left to the crate to report.
|
||||
pub(crate) fn check_previews(b: &[u8]) -> Result<(), CodecError> {
|
||||
if b.get(..4) != Some(&[0x76, 0x2f, 0x31, 0x01][..]) {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(flags) = b.get(4..8).and_then(|v| <[u8; 4]>::try_from(v).ok()).map(u32::from_le_bytes) else { return Ok(()) };
|
||||
let multipart = flags & 0x1000 != 0;
|
||||
let cstr = |at: usize| -> Option<(&[u8], usize)> {
|
||||
let rest = b.get(at..)?;
|
||||
let n = rest.iter().position(|&c| c == 0)?;
|
||||
Some((rest.get(..n)?, at + n + 1))
|
||||
};
|
||||
let u32_at = |at: usize| b.get(at..at.checked_add(4)?).and_then(|v| <[u8; 4]>::try_from(v).ok()).map(u32::from_le_bytes);
|
||||
let mut at = 8;
|
||||
loop {
|
||||
// One header's attributes.
|
||||
loop {
|
||||
let Some((name, next)) = cstr(at) else { return Ok(()) };
|
||||
at = next;
|
||||
if name.is_empty() {
|
||||
break;
|
||||
}
|
||||
let Some((kind, next)) = cstr(at) else { return Ok(()) };
|
||||
let Some(size) = u32_at(next).and_then(|n| i32::try_from(n).ok()).and_then(|n| usize::try_from(n).ok()) else { return Ok(()) };
|
||||
let value = next + 4;
|
||||
let end = match value.checked_add(size) {
|
||||
Some(end) if end <= b.len() => end,
|
||||
_ => return Ok(()),
|
||||
};
|
||||
if kind == b"preview" {
|
||||
let declared =
|
||||
u32_at(value).zip(u32_at(value + 4)).and_then(|(w, h)| u64::from(w).checked_mul(u64::from(h))?.checked_mul(4)).filter(|_| size >= 8);
|
||||
if declared.is_none_or(|n| n > (size - 8) as u64) {
|
||||
return Err(err("a preview attribute declares more pixels than it holds"));
|
||||
}
|
||||
}
|
||||
at = end;
|
||||
}
|
||||
// A multi-part list ends with an empty header (a lone NUL).
|
||||
if !multipart || b.get(at) == Some(&0) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the headers without the `exr` crate's validation (it rejects deep parts outright),
|
||||
/// after checking every part's declared size against the limits, before any pixel allocation.
|
||||
pub(super) fn read_meta(bytes: &[u8], limits: &Limits) -> Result<MetaData, CodecError> {
|
||||
check_previews(bytes)?;
|
||||
let meta = MetaData::read_from_buffered(Cursor::new(bytes), false).map_err(err)?;
|
||||
// Flat decoding reads every part at once, so the parts' sizes are also capped together.
|
||||
let mut total: u64 = 0;
|
||||
@@ -130,6 +184,7 @@ impl<'s, C: ReadChannels<'s>> ReadChannels<'s> for OnlyPart<C> {
|
||||
/// Decode one part's flat layer via the `exr` crate (it handles every flat compression).
|
||||
/// Other parts are not decompressed, so an AOV file costs one part per request, not the file.
|
||||
pub(super) fn read_layer(bytes: &[u8], index: usize) -> Result<Layer<AnyChannels<FlatSamples>>, CodecError> {
|
||||
check_previews(bytes)?;
|
||||
let only = OnlyPart { inner: read().no_deep_data().largest_resolution_level().all_channels(), index, seen: std::cell::Cell::new(0) };
|
||||
only.first_valid_layer().all_attributes().from_buffered(Cursor::new(bytes)).map(|image| image.layer_data).map_err(map_exr)
|
||||
}
|
||||
@@ -334,3 +389,45 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
|
||||
image.write().to_buffered(&mut cursor).map_err(|e| CodecError::encode(F, e))?;
|
||||
Ok(cursor.into_inner())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod preview_tests {
|
||||
use super::*;
|
||||
use exr::meta::attribute::Preview;
|
||||
use exr::prelude::{ImageAttributes, IntegerBounds};
|
||||
|
||||
/// A 2x2 single-part file whose layer carries a 2x1 preview.
|
||||
fn with_preview() -> Vec<u8> {
|
||||
let mut attrs = LayerAttributes::named("rgb");
|
||||
attrs.preview = Some(Preview { size: exr::math::Vec2(2, 1), pixel_data: vec![7; 8] });
|
||||
let list = ["R", "G", "B"].into_iter().map(|n| AnyChannel::new(n, FlatSamples::F32(vec![0.5; 4]))).collect();
|
||||
let layer = Layer::new(
|
||||
(2, 2),
|
||||
attrs,
|
||||
Encoding { compression: Compression::Uncompressed, blocks: Blocks::ScanLines, line_order: LineOrder::Increasing },
|
||||
AnyChannels::sort(list),
|
||||
);
|
||||
let image = exr::image::Image::from_layers(ImageAttributes::new(IntegerBounds::new((0, 0), (2, 2))), vec![layer]);
|
||||
let mut out = Cursor::new(Vec::new());
|
||||
image.write().to_buffered(&mut out).unwrap();
|
||||
out.into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_preview_declaring_more_pixels_than_it_holds_is_refused_before_the_crate_reads_it() {
|
||||
let good = with_preview();
|
||||
check_previews(&good).unwrap();
|
||||
assert_eq!(decode(&good, &Limits::default()).unwrap().dimensions(), (2, 2));
|
||||
// Patch the preview to 1024x1024 (4 MiB) while its value still holds 8 bytes.
|
||||
let tag = b"preview\0preview\0";
|
||||
let at = good.windows(tag.len()).position(|w| w == tag).unwrap() + tag.len() + 4;
|
||||
let mut bad = good.clone();
|
||||
bad[at..at + 4].copy_from_slice(&1024u32.to_le_bytes());
|
||||
bad[at + 4..at + 8].copy_from_slice(&1024u32.to_le_bytes());
|
||||
for r in [check_previews(&bad), decode(&bad, &Limits::default()).map(|_| ()), read_layer(&bad, 0).map(|_| ())] {
|
||||
assert!(matches!(&r, Err(CodecError::Malformed { message, .. }) if message.contains("preview")), "{r:?}");
|
||||
}
|
||||
// A header cut off mid-attribute is left to the crate.
|
||||
check_previews(&good[..at]).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -148,6 +148,7 @@ fn layer_streams(header: &exr::meta::header::Header, name: &str) -> Vec<String>
|
||||
/// Lists every Cryptomatte layer of the file from the headers alone: what a Cryptomatte
|
||||
/// picker offers. The embedded manifest (capped, JSON) is parsed into (ID, name) pairs.
|
||||
pub(crate) fn layers(bytes: &[u8], limits: &Limits) -> Result<Vec<CryptomatteLayer>, CodecError> {
|
||||
super::exr::check_previews(bytes)?;
|
||||
let meta = MetaData::read_from_buffered(Cursor::new(bytes), false).map_err(|e| CodecError::malformed(F, e))?;
|
||||
// Manifests can be large; bound the embedded text before parsing anything.
|
||||
const MANIFEST_CAP: usize = 16 << 20;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! PNG via the `png` crate directly: 8/16-bit, Adam7 read (and write),
|
||||
//! iCCP, eXIf, tEXt/zTXt/iTXt (XMP in `XML:com.adobe.xmp`), pHYs.
|
||||
|
||||
use std::io::{Cursor, Write};
|
||||
use std::io::{self, Cursor, Write};
|
||||
|
||||
use crate::Format;
|
||||
use crate::error::CodecError;
|
||||
@@ -303,6 +303,21 @@ fn is_latin1_keyword(k: &str) -> bool {
|
||||
!k.is_empty() && k.len() <= 79 && k.chars().all(|c| (' '..='~').contains(&c) || ('\u{a1}'..='\u{ff}').contains(&c))
|
||||
}
|
||||
|
||||
/// Makes the PNG writer's output growth report an encode error on allocation failure.
|
||||
struct FallibleOutput<'a>(&'a mut Vec<u8>);
|
||||
|
||||
impl Write for FallibleOutput<'_> {
|
||||
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
|
||||
self.0.try_reserve(bytes.len()).map_err(|_| io::Error::other("not enough memory for PNG output"))?;
|
||||
self.0.extend_from_slice(bytes);
|
||||
Ok(bytes.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Vec<u8>, CodecError> {
|
||||
let img = src.converted(plan.layout, plan.sample);
|
||||
let (w, h) = img.dimensions();
|
||||
@@ -314,7 +329,8 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
{
|
||||
let mut writer = start(&mut out, (w, h), (img.layout(), img.sample_type()), img.icc.as_ref(), &img.meta, opts)?;
|
||||
let mut output = FallibleOutput(&mut out);
|
||||
let mut writer = start(&mut output, (w, h), (img.layout(), img.sample_type()), img.icc.as_ref(), &img.meta, opts)?;
|
||||
if opts.png_interlaced {
|
||||
let idat = adam7_idat(&data, w as usize, h as usize, bpp, opts.png_compression)?;
|
||||
write_idat(&mut writer, &idat, MAX_IDAT)?;
|
||||
@@ -498,7 +514,8 @@ impl<W: Write> PngRows<W> {
|
||||
let prev = (!self.prev.is_empty()).then_some(self.prev.as_slice());
|
||||
let parts = deflate_rows(rows, prev, stride, bpp, per_band, deflate_level(self.level), finish, encode_threads())
|
||||
.ok_or_else(|| CodecError::encode(F, "a band of rows could not be compressed"))?;
|
||||
let mut zlib = Vec::with_capacity(parts.iter().map(|p| p.z.len()).sum::<usize>() + 6);
|
||||
let mut zlib = Vec::new();
|
||||
zlib.try_reserve_exact(parts.iter().map(|p| p.z.len()).sum::<usize>() + 6).map_err(|_| CodecError::encode(F, "not enough memory for PNG data"))?;
|
||||
if !self.started {
|
||||
zlib.extend_from_slice(&zlib_header(self.level));
|
||||
self.started = true;
|
||||
@@ -530,7 +547,8 @@ impl<W: Write> PngRows<W> {
|
||||
fn adam7_idat(data: &[u8], w: usize, h: usize, bpp: usize, level: PngCompression) -> Result<Vec<u8>, CodecError> {
|
||||
// (x0, y0, dx, dy) per pass.
|
||||
const PASSES: [(usize, usize, usize, usize); 7] = [(0, 0, 8, 8), (4, 0, 8, 8), (0, 4, 4, 8), (2, 0, 4, 4), (0, 2, 2, 4), (1, 0, 2, 2), (0, 1, 1, 2)];
|
||||
let mut raw = Vec::with_capacity(data.len() + h * 7);
|
||||
let mut raw = Vec::new();
|
||||
raw.try_reserve_exact(data.len() + h * 7).map_err(|_| CodecError::encode(F, "not enough memory for PNG rows"))?;
|
||||
for (x0, y0, dx, dy) in PASSES {
|
||||
if x0 >= w || y0 >= h {
|
||||
continue;
|
||||
@@ -611,7 +629,8 @@ fn encode_threads() -> usize {
|
||||
fn banded_idat(data: &[u8], w: usize, h: usize, bpp: usize, level: PngCompression, threads: usize) -> Option<Vec<u8>> {
|
||||
let stride = w * bpp;
|
||||
let parts = deflate_rows(data.get(..h * stride)?, None, stride, bpp, band_rows(stride), deflate_level(level), true, threads)?;
|
||||
let mut out = Vec::with_capacity(parts.iter().map(|p| p.z.len()).sum::<usize>() + 6);
|
||||
let mut out = Vec::new();
|
||||
out.try_reserve_exact(parts.iter().map(|p| p.z.len()).sum::<usize>() + 6).ok()?;
|
||||
out.extend_from_slice(&zlib_header(level));
|
||||
let mut adler = 1u32;
|
||||
for p in &parts {
|
||||
@@ -648,8 +667,11 @@ fn deflate_rows(
|
||||
let h = rows.len() / stride.max(1);
|
||||
let bands: Vec<(usize, usize)> = (0..h).step_by(band_rows.max(1)).map(|y| (y, (y + band_rows).min(h))).collect();
|
||||
let band = |&(y0, y1): &(usize, usize)| -> Option<Deflated> {
|
||||
let mut filtered = Vec::with_capacity((y1 - y0) * (stride + 1));
|
||||
let mut scratch = vec![0u8; stride];
|
||||
let mut filtered = Vec::new();
|
||||
filtered.try_reserve_exact((y1 - y0) * (stride + 1)).ok()?;
|
||||
let mut scratch = Vec::new();
|
||||
scratch.try_reserve_exact(stride).ok()?;
|
||||
scratch.resize(stride, 0);
|
||||
for y in y0..y1 {
|
||||
let row = rows.get(y * stride..(y + 1) * stride)?;
|
||||
let above = if y > 0 { rows.get((y - 1) * stride..y * stride) } else { prev };
|
||||
@@ -696,7 +718,8 @@ fn deflate_rows(
|
||||
fn deflate_band(filtered: &[u8], lvl: flate2::Compression, last: bool) -> Option<Vec<u8>> {
|
||||
let mut c = flate2::Compress::new(lvl, false);
|
||||
// Stored blocks cost 5 bytes per 64 KiB, the flush an empty stored block: 1/8 is ample.
|
||||
let mut z = Vec::with_capacity(filtered.len() + filtered.len() / 8 + 1024);
|
||||
let mut z = Vec::new();
|
||||
z.try_reserve_exact(filtered.len() + filtered.len() / 8 + 1024).ok()?;
|
||||
let flush = if last { flate2::FlushCompress::Finish } else { flate2::FlushCompress::Sync };
|
||||
let status = c.compress_vec(filtered, &mut z, flush).ok()?;
|
||||
let done = if last { status == flate2::Status::StreamEnd } else { c.total_in() as usize == filtered.len() && z.len() < z.capacity() };
|
||||
|
||||
@@ -168,6 +168,7 @@ mod bands;
|
||||
mod decode;
|
||||
mod encode;
|
||||
mod tags;
|
||||
mod ycbcr;
|
||||
|
||||
pub use decode::TiffBands;
|
||||
pub(crate) use decode::{decode, decode_page, orientation};
|
||||
|
||||
@@ -2,6 +2,7 @@ use std::borrow::Cow;
|
||||
|
||||
use super::bands::{Tally, convert_ab, decode_bands, extra_sample_indices, refill_zeroed, split_extra_samples, zeroed};
|
||||
use super::tags::{F, TAG_IMAGE_SOURCE_DATA, TAG_PHOTOSHOP, TAG_XMP, TEXT_TAGS, photoshop_tags};
|
||||
use super::ycbcr::YCbCr;
|
||||
use super::{
|
||||
ChannelLayout, ChunkType, CodecError, DecodeWarning, Decoder, ExifDirs, File, Ifd, Image, Limits, Metadata, Read, SampleType, Seek, SeekFrom, Tag,
|
||||
camera_raw, err, ifd, io, map_err, tiff_exif,
|
||||
@@ -164,6 +165,18 @@ impl<'a> TiffBands<'a> {
|
||||
self.p.lay.out_row().checked_mul(rows).ok_or_else(|| CodecError::LimitExceeded("band size".into()))
|
||||
}
|
||||
|
||||
/// A conservative bound on the decoder's own scratch memory in a whole-image decode of this
|
||||
/// page (as `decode_page` does it): one strip or tile per worker that decodes a band at
|
||||
/// once. Zero when strips are decoded in place. Add the decoded image itself and any
|
||||
/// extra-sample planes.
|
||||
pub fn decode_scratch_bytes(&self) -> u64 {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let workers = rayon::current_num_threads() as u64;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let workers = 1;
|
||||
scratch_bound(self.p.lay.direct(), self.p.scratch_bytes as u64, self.p.band_rows, self.p.h, workers)
|
||||
}
|
||||
|
||||
/// Decodes the image a group of bands at a time, in order, handing each group to `sink`
|
||||
/// with the position of its top-left pixel in the image [`TiffBands::dimensions`] gives. A
|
||||
/// group is about 64 MiB (at least one strip or row of tiles per worker, at most `max_alloc`;
|
||||
@@ -185,6 +198,9 @@ impl<'a> TiffBands<'a> {
|
||||
// Missing chunks leave their rows as they are: they must start empty in every group.
|
||||
buf = refill_zeroed(buf, len)?;
|
||||
tally = tally + decode_bands(p, self.b, &mut buf, band, first, &mut [], 0)?;
|
||||
if let Some(c) = p.ycbcr {
|
||||
c.convert(&mut buf, p.lay.out_channels(), p.lay.out);
|
||||
}
|
||||
if let Some(signed) = p.lab {
|
||||
convert_ab(&mut buf, p.lay.out_channels(), p.lay.out, signed, false);
|
||||
}
|
||||
@@ -215,6 +231,16 @@ impl<'a> TiffBands<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`TiffBands::decode_scratch_bytes`]'s bound: none when chunks are decoded in place,
|
||||
/// otherwise one chunk per worker that decodes a band, and never more chunks than the image has.
|
||||
fn scratch_bound(direct: bool, chunk_bytes: u64, band_rows: usize, h: u32, workers: u64) -> u64 {
|
||||
if direct {
|
||||
return 0;
|
||||
}
|
||||
let bands = u64::from(h).div_ceil(band_rows.max(1) as u64).max(1);
|
||||
chunk_bytes.saturating_mul(workers.min(bands))
|
||||
}
|
||||
|
||||
/// The Orientation (1–8) of page `page` (`None`: the default page); 1 when absent, malformed
|
||||
/// or out of range. Classic TIFF and BigTIFF.
|
||||
pub(crate) fn orientation(bytes: &[u8], page: Option<usize>) -> u16 {
|
||||
@@ -520,6 +546,20 @@ fn header_patches(
|
||||
Ok(patches)
|
||||
}
|
||||
|
||||
/// Lab and YCbCr are converted after decoding, from 8 or 16-bit samples only: Lab needs at least
|
||||
/// its three colour samples, YCbCr exactly three.
|
||||
fn check_converted_model(photometric: u64, spp: u64, bits: u64, src: Src) -> Result<(), CodecError> {
|
||||
let (name, samples_ok) = match photometric {
|
||||
8 | 9 => ("Lab", spp >= 3),
|
||||
6 => ("YCbCr", spp == 3),
|
||||
_ => return Ok(()),
|
||||
};
|
||||
if samples_ok && matches!(src, Src::U8 | Src::U16) {
|
||||
return Ok(());
|
||||
}
|
||||
Err(CodecError::unsupported(F, format!("{name} image with {spp} samples of {bits} bits")))
|
||||
}
|
||||
|
||||
/// A directory checked and laid out for decoding, before any pixel is read.
|
||||
pub(super) struct Prepared {
|
||||
w: u32,
|
||||
@@ -531,6 +571,8 @@ pub(super) struct Prepared {
|
||||
tl: tiff::decoder::Limits,
|
||||
/// Lab samples: `Some(true)` for CIELab (a* and b* stored signed), `Some(false)` for ICCLab.
|
||||
lab: Option<bool>,
|
||||
/// YCbCr samples: the conversion to RGB (#10316).
|
||||
ycbcr: Option<YCbCr>,
|
||||
/// The colour samples of a pixel in the file's colour model.
|
||||
color: usize,
|
||||
/// The ExtraSamples tag (0 unspecified, 1 associated alpha, 2 unassociated alpha).
|
||||
@@ -539,6 +581,9 @@ pub(super) struct Prepared {
|
||||
extras_over_limit: usize,
|
||||
/// Extra samples left out because they did not fit the allocation budget beside the image.
|
||||
extras_dropped: u32,
|
||||
/// Bytes one strip or tile decodes to, before clipping to the image: a worker's scratch
|
||||
/// buffer. A strip is the full width; a tile keeps its declared size even at an edge.
|
||||
scratch_bytes: usize,
|
||||
}
|
||||
|
||||
impl Prepared {
|
||||
@@ -567,6 +612,9 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result<Image, CodecEr
|
||||
if tally.decoded == 0 && tally.missing > 0 {
|
||||
return Err(err("the image data is missing (cut off or past the end of the file)"));
|
||||
}
|
||||
if let Some(c) = p.ycbcr {
|
||||
c.convert(&mut data, p.lay.out_channels(), p.lay.out);
|
||||
}
|
||||
if let Some(signed) = p.lab {
|
||||
convert_ab(&mut data, p.lay.out_channels(), p.lay.out, signed, false);
|
||||
}
|
||||
@@ -642,15 +690,17 @@ fn prepare(f: &File<'_>, dir: &Ifd, limits: &Limits, whole: bool) -> Result<Prep
|
||||
// `tiff` crate as RGB, then labelled Lab with a* and b* in PhotoCraft's encoding
|
||||
// (`convert_ab`).
|
||||
let lab = matches!(photometric, 8 | 9);
|
||||
if lab && (spp < 3 || !matches!(src, Src::U8 | Src::U16)) {
|
||||
return Err(CodecError::unsupported(F, format!("Lab image with {spp} samples of {bits} bits")));
|
||||
}
|
||||
// YCbCr (6), as scanners and cameras write it (usually JPEG-compressed with subsampled
|
||||
// chroma): the `tiff` crate decodes and upsamples the samples, and they are turned into RGB
|
||||
// after decoding (#10316).
|
||||
let ycbcr = photometric == 6;
|
||||
check_converted_model(photometric, spp, bits, src)?;
|
||||
// TIFF 6.0 lets any extra sample be the alpha (#1349). For RGB and CMYK the `tiff` crate
|
||||
// returns the alpha only when it is the first extra sample: otherwise it is asked for every
|
||||
// sample (as multiband gray) and the colour and alpha samples are picked here. An alpha
|
||||
// position past the samples the file has is ignored.
|
||||
let color: usize = match photometric {
|
||||
2 | 8 | 9 => 3,
|
||||
2 | 6 | 8 | 9 => 3,
|
||||
5 => 4,
|
||||
_ => 1,
|
||||
};
|
||||
@@ -686,6 +736,7 @@ fn prepare(f: &File<'_>, dir: &Ifd, limits: &Limits, whole: bool) -> Result<Prep
|
||||
(if gray_alpha { ChannelLayout::GrayA } else { ChannelLayout::Gray }, usize::from(num_samples))
|
||||
}
|
||||
tiff::ColorType::RGB(_) => (ChannelLayout::Rgb, 3),
|
||||
tiff::ColorType::YCbCr(_) if ycbcr => (ChannelLayout::Rgb, 3),
|
||||
tiff::ColorType::RGBA(_) => (ChannelLayout::Rgba, 4),
|
||||
tiff::ColorType::CMYK(_) => (ChannelLayout::Cmyk, 4),
|
||||
tiff::ColorType::CMYKA(_) => (ChannelLayout::CmykA, 5),
|
||||
@@ -729,8 +780,9 @@ fn prepare(f: &File<'_>, dir: &Ifd, limits: &Limits, whole: bool) -> Result<Prep
|
||||
// strip or tile tables point at inside the file count (and never more than the file), so
|
||||
// padding the file while pointing the chunks past its end does not raise the bound: the
|
||||
// banded decoder has no whole-image `max_alloc` to fall back on.
|
||||
let compression = f.tag_uint(dir, ifd::TAG_COMPRESSION).unwrap_or(1);
|
||||
let stored = u64::from(w).saturating_mul(u64::from(h)).saturating_mul(spp).saturating_mul(bits).div_ceil(8);
|
||||
let ratio: u64 = match f.tag_uint(dir, ifd::TAG_COMPRESSION).unwrap_or(1) {
|
||||
let ratio: u64 = match compression {
|
||||
1 => 4,
|
||||
32773 => 130,
|
||||
4 => cw.clamp(4096, 65_535),
|
||||
@@ -776,6 +828,9 @@ fn prepare(f: &File<'_>, dir: &Ifd, limits: &Limits, whole: bool) -> Result<Prep
|
||||
.and_then(|s| s.checked_mul(src.bytes()))
|
||||
.ok_or_else(|| CodecError::LimitExceeded("tile size".into()))?;
|
||||
let scratch_cap = chunk_bytes.saturating_mul(2).max(64 << 20).min(limits.alloc_usize());
|
||||
// One chunk as the decoder actually reads it: a tile keeps its declared size at an edge,
|
||||
// which can be more than the clipped image bytes above.
|
||||
let scratch_bytes = cw.saturating_mul(ch).saturating_mul(chunk_spp).saturating_mul(src.bytes());
|
||||
let (extra_at, extras_over_limit) = extra_sample_indices(spp as usize, color, alpha_at, out_layout.has_alpha());
|
||||
let mut lay = Layout {
|
||||
w: wu,
|
||||
@@ -814,10 +869,12 @@ fn prepare(f: &File<'_>, dir: &Ifd, limits: &Limits, whole: bool) -> Result<Prep
|
||||
patches,
|
||||
tl,
|
||||
lab: lab.then_some(photometric == 8),
|
||||
ycbcr: ycbcr.then(|| YCbCr::of(f, dir, compression, if out == SampleType::U16 { 65535.0 } else { 255.0 })),
|
||||
color,
|
||||
extra_kinds: extra,
|
||||
extras_over_limit,
|
||||
extras_dropped,
|
||||
scratch_bytes,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -845,4 +902,15 @@ mod tests {
|
||||
let whole = decode(&bytes, &tight).unwrap();
|
||||
assert_eq!(whole.warnings, prepared_warnings(&p));
|
||||
}
|
||||
|
||||
/// The scratch bound a whole-image decode reserves: none for chunks decoded in place, one
|
||||
/// chunk per worker otherwise, never more chunks than the image has bands.
|
||||
#[test]
|
||||
fn whole_image_scratch_is_one_chunk_per_worker() {
|
||||
assert_eq!(scratch_bound(true, 1 << 20, 16, 1024, 8), 0, "strips decoded in place need no scratch");
|
||||
assert_eq!(scratch_bound(false, 1 << 20, 16, 1024, 8), 8 << 20, "one strip per worker");
|
||||
assert_eq!(scratch_bound(false, 1 << 20, 16, 32, 8), 2 << 20, "never more strips than the image has");
|
||||
assert_eq!(scratch_bound(false, 1 << 20, 0, 2, 8), 2 << 20, "a zero band height counts as one row");
|
||||
assert_eq!(scratch_bound(false, u64::MAX, 1, 1, 8), u64::MAX, "the bound saturates");
|
||||
}
|
||||
}
|
||||
@@ -260,8 +260,13 @@ struct TagSet<'a> {
|
||||
extra_samples: &'a [u16],
|
||||
photoshop_resources: Option<&'a [u8]>,
|
||||
photoshop_layers: Option<&'a [u8]>,
|
||||
/// An image pyramid level: written with `NewSubfileType` 1 (reduced resolution).
|
||||
reduced: bool,
|
||||
}
|
||||
|
||||
/// TIFF `NewSubfileType` (254).
|
||||
const TAG_NEW_SUBFILE_TYPE: u16 = 254;
|
||||
|
||||
/// Writes one image directory. (The `tiff` encoder only compresses through `write_data`, which
|
||||
/// takes every sample at once.)
|
||||
fn write_one<C, K>(enc: &mut TiffEncoder<&mut Cursor<Vec<u8>>, K>, w: u32, h: u32, data: &[C::Inner], tags: &TagSet<'_>) -> tiff::TiffResult<()>
|
||||
@@ -287,6 +292,9 @@ where
|
||||
im.y_resolution(Rational { n: (y * 1000.0).round() as u32, d: 1000 });
|
||||
}
|
||||
let d = im.encoder();
|
||||
if tags.reduced {
|
||||
d.write_tag(Tag::Unknown(TAG_NEW_SUBFILE_TYPE), 1u32)?;
|
||||
}
|
||||
if !tags.extra_samples.is_empty() {
|
||||
d.write_tag(Tag::ExtraSamples, tags.extra_samples)?;
|
||||
}
|
||||
@@ -374,6 +382,117 @@ fn write_image<K: TiffKind>(enc: &mut TiffEncoder<&mut Cursor<Vec<u8>>, K>, img:
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes the image pyramid after the full image: each level half the size of the one before
|
||||
/// (rounded up), colour and alpha only, until the longer side is at most
|
||||
/// [`crate::TIFF_PYRAMID_MIN`]. An image already that small gets no levels.
|
||||
fn write_pyramid<K: TiffKind>(enc: &mut TiffEncoder<&mut Cursor<Vec<u8>>, K>, img: &Image, tags: &TagSet<'_>) -> tiff::TiffResult<()> {
|
||||
let mut level: Option<Image> = None;
|
||||
loop {
|
||||
let prev = level.as_ref().unwrap_or(img);
|
||||
let (w, h) = prev.dimensions();
|
||||
if w.max(h) <= crate::TIFF_PYRAMID_MIN {
|
||||
return Ok(());
|
||||
}
|
||||
let next = half_size(prev).map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e.to_string()))?;
|
||||
write_image::<K>(enc, &next, tags)?;
|
||||
level = Some(next);
|
||||
}
|
||||
}
|
||||
|
||||
/// `img` at half its size, rounded up, by averaging each 2×2 block (an odd last row or column
|
||||
/// averages what it has). Colour is weighted by alpha, so transparent pixels don't darken
|
||||
/// the edges of what they surround. Extra samples are not carried over. Rows are worked in
|
||||
/// parallel on native targets.
|
||||
pub(super) fn half_size(img: &Image) -> Result<Image, CodecError> {
|
||||
let (w, h) = img.dimensions();
|
||||
let (nw, nh) = (w.div_ceil(2).max(1), h.div_ceil(2).max(1));
|
||||
let layout = img.layout();
|
||||
let sample = img.sample_type();
|
||||
let ch = layout.channels();
|
||||
let bps = sample.bytes();
|
||||
let src_row = (w as usize).checked_mul(ch).and_then(|n| n.checked_mul(bps)).ok_or_else(|| CodecError::encode(F, "image row size overflows"))?;
|
||||
let out_row = (nw as usize).checked_mul(ch).and_then(|n| n.checked_mul(bps)).ok_or_else(|| CodecError::encode(F, "pyramid row size overflows"))?;
|
||||
let len = out_row.checked_mul(nh as usize).ok_or_else(|| CodecError::encode(F, "pyramid level size overflows"))?;
|
||||
if img.data().len() < src_row.saturating_mul(h as usize) {
|
||||
return Err(CodecError::encode(F, "image buffer is shorter than its dimensions"));
|
||||
}
|
||||
let mut out = super::bands::zeroed(len)?;
|
||||
let level = Level { data: img.data(), w: w as usize, h: h as usize, ch, sample, src_row, alpha: layout.has_alpha() };
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use rayon::prelude::*;
|
||||
out.par_chunks_mut(out_row.max(1)).enumerate().for_each(|(y, row)| level.row(y, row));
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
out.chunks_mut(out_row.max(1)).enumerate().for_each(|(y, row)| level.row(y, row));
|
||||
let mut img2 = Image::from_raw(nw, nh, layout, sample, out)?;
|
||||
img2.icc = img.icc.clone();
|
||||
Ok(img2)
|
||||
}
|
||||
|
||||
/// The source of one [`half_size`] level.
|
||||
struct Level<'a> {
|
||||
data: &'a [u8],
|
||||
w: usize,
|
||||
h: usize,
|
||||
ch: usize,
|
||||
sample: SampleType,
|
||||
src_row: usize,
|
||||
/// The last channel is alpha.
|
||||
alpha: bool,
|
||||
}
|
||||
|
||||
impl Level<'_> {
|
||||
/// Source row `y` as numbers (empty past the bottom).
|
||||
fn samples(&self, y: usize) -> Vec<f64> {
|
||||
let Some(row) = (y < self.h).then(|| self.data.get(y * self.src_row..(y + 1) * self.src_row)).flatten() else { return Vec::new() };
|
||||
match self.sample {
|
||||
SampleType::U8 => row.iter().map(|&v| f64::from(v)).collect(),
|
||||
SampleType::U16 => row.as_chunks::<2>().0.iter().map(|b| f64::from(u16::from_ne_bytes(*b))).collect(),
|
||||
SampleType::F32 => row.as_chunks::<4>().0.iter().map(|b| f64::from(f32::from_ne_bytes(*b))).collect(),
|
||||
SampleType::F16 => row.as_chunks::<2>().0.iter().map(|b| f64::from(half::f16::from_bits(u16::from_ne_bytes(*b)).to_f32())).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes output row `y` into `out`.
|
||||
fn row(&self, y: usize, out: &mut [u8]) {
|
||||
let rows = [self.samples(y * 2), self.samples(y * 2 + 1)];
|
||||
let ch = self.ch;
|
||||
let bps = self.sample.bytes();
|
||||
let mut sums = vec![0f64; ch];
|
||||
for (x, px) in out.chunks_exact_mut(ch * bps).enumerate() {
|
||||
sums.iter_mut().for_each(|s| *s = 0.0);
|
||||
let (mut n, mut weight) = (0f64, 0f64);
|
||||
for row in rows.iter().filter(|r| !r.is_empty()) {
|
||||
for sx in [x * 2, x * 2 + 1].into_iter().filter(|&sx| sx < self.w) {
|
||||
let Some(p) = row.get(sx * ch..(sx + 1) * ch) else { continue };
|
||||
let a = if self.alpha { p.last().copied().unwrap_or(1.0) } else { 1.0 };
|
||||
for (c, (sum, v)) in sums.iter_mut().zip(p).enumerate() {
|
||||
*sum += if self.alpha && c + 1 == ch { *v } else { v * a };
|
||||
}
|
||||
n += 1.0;
|
||||
weight += a;
|
||||
}
|
||||
}
|
||||
for (c, (sum, dst)) in sums.iter().zip(px.chunks_exact_mut(bps)).enumerate() {
|
||||
let v = if self.alpha && c + 1 == ch {
|
||||
sum / n.max(1.0)
|
||||
} else if weight > 0.0 {
|
||||
sum / weight
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
match self.sample {
|
||||
SampleType::U8 => dst.copy_from_slice(&[v.round().clamp(0.0, 255.0) as u8]),
|
||||
SampleType::U16 => dst.copy_from_slice(&(v.round().clamp(0.0, 65535.0) as u16).to_ne_bytes()),
|
||||
SampleType::F32 => dst.copy_from_slice(&(v as f32).to_ne_bytes()),
|
||||
SampleType::F16 => dst.copy_from_slice(&half::f16::from_f32(v as f32).to_bits().to_ne_bytes()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Classic TIFF offsets are 32-bit: a file that may grow past this is written as BigTIFF.
|
||||
const CLASSIC_LIMIT: u64 = u32::MAX as u64;
|
||||
|
||||
@@ -476,23 +595,62 @@ fn encode_ready(img: &Image, opts: &EncodeOptions) -> Result<Vec<u8>, CodecError
|
||||
// Document content, not metadata: written whenever present.
|
||||
photoshop_resources: img.meta.photoshop_resources.as_deref().filter(|r| !r.is_empty()),
|
||||
photoshop_layers: img.meta.photoshop_layers.as_deref().filter(|l| !l.is_empty()),
|
||||
reduced: false,
|
||||
};
|
||||
// A pyramid level holds the colour and alpha only, with the profile so it shows the same.
|
||||
let alpha_kind: &[u16] = if img.layout().has_alpha() { &[2] } else { &[] };
|
||||
let level_tags = TagSet {
|
||||
icc: tags.icc,
|
||||
exif: None,
|
||||
xmp: None,
|
||||
dpi: None,
|
||||
text: Vec::new(),
|
||||
extra_samples: alpha_kind,
|
||||
photoshop_resources: None,
|
||||
photoshop_layers: None,
|
||||
reduced: true,
|
||||
};
|
||||
|
||||
// BigTIFF when asked, or when the file could pass the 4 GiB a classic TIFF can address. A
|
||||
// classic write that still runs out of 32-bit offsets (compression grew the data) is redone
|
||||
// as BigTIFF.
|
||||
let estimate = estimated_size(img, &tags);
|
||||
// The pyramid adds at most a third of the image (1/4 + 1/16 + …).
|
||||
let estimate = if opts.tiff_pyramid { estimate.saturating_add(img.data().len() as u64 / 3) } else { estimate };
|
||||
let write = |big: bool| -> tiff::TiffResult<Vec<u8>> {
|
||||
let mut cursor = Cursor::new(Vec::new());
|
||||
// An uncompressed file is allocated once at its estimated size: grown as written, the
|
||||
// buffer would double near the end and copy the file (#6607). Compressed output is
|
||||
// usually a fraction of the estimate, so it grows as written instead: reserving the
|
||||
// estimate would hold a second full-size buffer while encoding, which wasm32 never
|
||||
// returns and Windows counts as committed memory.
|
||||
let mut out = Vec::new();
|
||||
if matches!(opts.tiff_compression, TiffCompression::None) {
|
||||
// Best effort: without the room the buffer grows as before.
|
||||
let _ = out.try_reserve(usize::try_from(estimate).unwrap_or(usize::MAX));
|
||||
}
|
||||
let mut cursor = Cursor::new(out);
|
||||
if big {
|
||||
let mut enc = TiffEncoder::new_big(&mut cursor)?.with_compression(compression).with_predictor(predictor);
|
||||
write_image::<TiffKindBig>(&mut enc, img, &tags)?;
|
||||
if opts.tiff_pyramid {
|
||||
write_pyramid::<TiffKindBig>(&mut enc, img, &level_tags)?;
|
||||
}
|
||||
} else {
|
||||
let mut enc = TiffEncoder::new(&mut cursor)?.with_compression(compression).with_predictor(predictor);
|
||||
write_image::<TiffKindStandard>(&mut enc, img, &tags)?;
|
||||
if opts.tiff_pyramid {
|
||||
write_pyramid::<TiffKindStandard>(&mut enc, img, &level_tags)?;
|
||||
}
|
||||
}
|
||||
Ok(cursor.into_inner())
|
||||
// Growth (or an estimate that was too high) can leave spare capacity: give it back
|
||||
// instead of holding it while the file is saved.
|
||||
let mut out = cursor.into_inner();
|
||||
if out.capacity() - out.len() > out.len() / 8 {
|
||||
out.shrink_to_fit();
|
||||
}
|
||||
Ok(out)
|
||||
};
|
||||
let big = opts.tiff_bigtiff || estimated_size(img, &tags) > CLASSIC_LIMIT;
|
||||
let big = opts.tiff_bigtiff || estimate > CLASSIC_LIMIT;
|
||||
match write(big) {
|
||||
Ok(v) => Ok(v),
|
||||
Err(tiff::TiffError::IntSizeError) if !big => write(true).map_err(|e| CodecError::encode(F, e)),
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
//! YCbCr (PhotometricInterpretation 6) to RGB, after the `tiff` crate has decoded the samples.
|
||||
//!
|
||||
//! The crate decompresses JPEG data and upsamples subsampled chroma, but hands back the stored
|
||||
//! Y, Cb and Cr values. This module turns them into RGB in place: ReferenceBlackWhite scales the
|
||||
//! codes and YCbCrCoefficients gives the luma weights. JPEG-compressed data (compression 7) uses
|
||||
//! the JPEG (JFIF) conversion, full range with the BT.601 weights, and ignores both tags, as
|
||||
//! libtiff does when it asks the JPEG library for RGB.
|
||||
//!
|
||||
//! # Sources
|
||||
//!
|
||||
//! - TIFF Revision 6.0 (Final, June 3, 1992), Section 21 "YCbCr Images", pp. 89–92; the
|
||||
//! YCbCrCoefficients field and its inverse equations are on pp. 90–91:
|
||||
//! `R = Cr·(2 - 2·LumaRed) + Y`, `B = Cb·(2 - 2·LumaBlue) + Y`,
|
||||
//! `G = (Y - LumaBlue·B - LumaRed·R) / LumaGreen`.
|
||||
//! <https://www.itu.int/itudoc/itu-t/com16/tiff-fx/docs/tiff6.pdf>
|
||||
//! - libtiff, `libtiff/tif_color.c`: `TIFFYCbCrToRGBInit` (tables) and `TIFFYCbCrtoRGB` (per pixel).
|
||||
//! <https://gitlab.com/libtiff/libtiff/-/blob/master/libtiff/tif_color.c>
|
||||
//!
|
||||
//! # Green: a compatibility choice
|
||||
//!
|
||||
//! Red and blue follow the spec. Green follows libtiff, which rewrites the spec's equation as
|
||||
//! `G = Y - (LumaRed·(2 - 2·LumaRed) / LumaGreen)·Cr - (LumaBlue·(2 - 2·LumaBlue) / LumaGreen)·Cb`,
|
||||
//! adding Y unscaled. The two agree only when the weights sum to 1. Every standard set does
|
||||
//! (the spec's own examples, BT.601 and BT.709), but the spec only calls the weights "the
|
||||
//! proportions of red, green, and blue respectively in luminance" and does not require the sum.
|
||||
//! For other weights the spec's equation scales Y in green by `(1 - LumaRed - LumaBlue) / LumaGreen`,
|
||||
//! which tints neutral grays.
|
||||
//!
|
||||
//! PhotoCraft deliberately follows libtiff here (decided in PR #10319, for issue #10316), so such
|
||||
//! files open as they do in libtiff-based tools (ImageMagick, GIMP) and grays stay neutral. This is
|
||||
//! a contributor choice, not a requirement of the spec; the test
|
||||
//! `weights_that_do_not_sum_to_one_keep_grays_neutral` in `tests/it/tiff_ycbcr.rs` pins it.
|
||||
|
||||
use super::{File, Ifd, SampleType};
|
||||
|
||||
const TAG_YCBCR_COEFFICIENTS: u16 = 529;
|
||||
const TAG_REFERENCE_BLACK_WHITE: u16 = 532;
|
||||
|
||||
/// BT.601 luma weights: the TIFF default for YCbCrCoefficients.
|
||||
const BT601: [f64; 3] = [0.299, 0.587, 0.114];
|
||||
|
||||
/// How one file's YCbCr codes map to RGB.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(super) struct YCbCr {
|
||||
/// Luma weights of red, green and blue.
|
||||
luma: [f64; 3],
|
||||
/// Black and white codes of Y, Cb and Cr.
|
||||
ref_bw: [f64; 6],
|
||||
}
|
||||
|
||||
impl YCbCr {
|
||||
/// The conversion for directory `dir`, whose samples have `max` as their largest code
|
||||
/// (255 or 65535). Missing or unusable tags fall back to the TIFF defaults.
|
||||
pub(super) fn of(f: &File<'_>, dir: &Ifd, compression: u64, max: f64) -> Self {
|
||||
let defaults = Self::defaults(max);
|
||||
if compression == 7 {
|
||||
return defaults;
|
||||
}
|
||||
let luma = f
|
||||
.tag_f64s(dir, TAG_YCBCR_COEFFICIENTS, 3)
|
||||
.and_then(|v| <[f64; 3]>::try_from(v).ok())
|
||||
.filter(|l| l.iter().all(|c| c.is_finite() && *c > 0.0 && *c < 1.0))
|
||||
.unwrap_or(defaults.luma);
|
||||
let ref_bw = f
|
||||
.tag_f64s(dir, TAG_REFERENCE_BLACK_WHITE, 6)
|
||||
.and_then(|v| <[f64; 6]>::try_from(v).ok())
|
||||
.filter(|r| r.iter().all(|v| v.is_finite()) && r.as_chunks::<2>().0.iter().all(|[black, white]| (white - black).abs() > f64::EPSILON))
|
||||
.unwrap_or(defaults.ref_bw);
|
||||
YCbCr { luma, ref_bw }
|
||||
}
|
||||
|
||||
/// TIFF's defaults: BT.601 weights, full-range luma, chroma centred on the middle code.
|
||||
fn defaults(max: f64) -> Self {
|
||||
let mid = (max + 1.0) / 2.0;
|
||||
YCbCr { luma: BT601, ref_bw: [0.0, max, mid, max, mid, max] }
|
||||
}
|
||||
|
||||
/// Converts interleaved Y, Cb, Cr pixels of `channels` samples (the first three are the
|
||||
/// colour) to R, G, B in place. Other sample types are left as they are.
|
||||
pub(super) fn convert(self, data: &mut [u8], channels: usize, sample: SampleType) {
|
||||
match sample {
|
||||
SampleType::U8 => {
|
||||
let t = Tables::new(&self, 255);
|
||||
for_each_pixel(data, channels, |px| {
|
||||
if let [y, cb, cr, ..] = px {
|
||||
let [r, g, b] = t.rgb(usize::from(*y), usize::from(*cb), usize::from(*cr));
|
||||
(*y, *cb, *cr) = (r as u8, g as u8, b as u8);
|
||||
}
|
||||
});
|
||||
}
|
||||
SampleType::U16 => {
|
||||
let t = Tables::new(&self, 65535);
|
||||
for_each_pixel(data, channels * 2, |px| {
|
||||
if let [y0, y1, b0, b1, r0, r1, ..] = px {
|
||||
let get = |a: u8, b: u8| usize::from(u16::from_ne_bytes([a, b]));
|
||||
let [r, g, b] = t.rgb(get(*y0, *y1), get(*b0, *b1), get(*r0, *r1));
|
||||
[*y0, *y1] = (r as u16).to_ne_bytes();
|
||||
[*b0, *b1] = (g as u16).to_ne_bytes();
|
||||
[*r0, *r1] = (b as u16).to_ne_bytes();
|
||||
}
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `f` on every `stride`-byte pixel, in parallel on native targets.
|
||||
fn for_each_pixel(data: &mut [u8], stride: usize, f: impl Fn(&mut [u8]) + Sync + Send) {
|
||||
if stride == 0 {
|
||||
return;
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use rayon::prelude::*;
|
||||
// Bands of whole pixels, so each task has a useful amount of work.
|
||||
let band = stride.saturating_mul(1 << 14);
|
||||
data.par_chunks_mut(band).for_each(|b| b.chunks_exact_mut(stride).for_each(&f));
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
data.chunks_exact_mut(stride).for_each(f);
|
||||
}
|
||||
|
||||
/// Per-code contributions, so a pixel costs a few additions.
|
||||
struct Tables {
|
||||
max: f64,
|
||||
/// Scaled luma of each Y code.
|
||||
y: Vec<f64>,
|
||||
/// Red and green contributions of each Cr code.
|
||||
cr_r: Vec<f64>,
|
||||
cr_g: Vec<f64>,
|
||||
/// Green and blue contributions of each Cb code.
|
||||
cb_g: Vec<f64>,
|
||||
cb_b: Vec<f64>,
|
||||
}
|
||||
|
||||
impl Tables {
|
||||
fn new(c: &YCbCr, max: u32) -> Self {
|
||||
let m = f64::from(max);
|
||||
let [lr, lg, lb] = c.luma;
|
||||
let [y_black, y_white, cb_black, cb_white, cr_black, cr_white] = c.ref_bw;
|
||||
// TIFF 6.0: chroma codes span ±(max + 1) / 2 - 1 around the black code; with the
|
||||
// default ReferenceBlackWhite that is the code minus the middle code.
|
||||
let half = (m + 1.0) / 2.0 - 1.0;
|
||||
let codes = || (0..=max).map(f64::from);
|
||||
let y = codes().map(|v| (v - y_black) * m / (y_white - y_black)).collect();
|
||||
let cb: Vec<f64> = codes().map(|v| (v - cb_black) * half / (cb_white - cb_black)).collect();
|
||||
let cr: Vec<f64> = codes().map(|v| (v - cr_black) * half / (cr_white - cr_black)).collect();
|
||||
// R = Y + (2 - 2·Lr)·Cr and B = Y + (2 - 2·Lb)·Cb (TIFF 6.0 p. 91); green as libtiff:
|
||||
// G = Y - (Lr·(2 - 2·Lr) / Lg)·Cr - (Lb·(2 - 2·Lb) / Lg)·Cb (see the module notes).
|
||||
let (kr, kb) = (2.0 - 2.0 * lr, 2.0 - 2.0 * lb);
|
||||
Tables {
|
||||
max: m,
|
||||
y,
|
||||
cr_r: cr.iter().map(|v| kr * v).collect(),
|
||||
cr_g: cr.iter().map(|v| -lr * kr * v / lg).collect(),
|
||||
cb_g: cb.iter().map(|v| -lb * kb * v / lg).collect(),
|
||||
cb_b: cb.iter().map(|v| kb * v).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// R, G and B of one pixel, rounded and clamped to the code range.
|
||||
fn rgb(&self, y: usize, cb: usize, cr: usize) -> [u32; 3] {
|
||||
let at = |t: &[f64], i: usize| t.get(i).copied().unwrap_or(0.0);
|
||||
let yv = at(&self.y, y);
|
||||
// Green adds Y unscaled, as libtiff does (see "Green: a compatibility choice" above).
|
||||
let r = yv + at(&self.cr_r, cr);
|
||||
let g = yv + at(&self.cr_g, cr) + at(&self.cb_g, cb);
|
||||
let b = yv + at(&self.cb_b, cb);
|
||||
[r, g, b].map(|v| v.round().clamp(0.0, self.max) as u32)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn convert(c: &YCbCr, px: [u8; 3]) -> [u8; 3] {
|
||||
let mut d = px.to_vec();
|
||||
c.convert(&mut d, 3, SampleType::U8);
|
||||
[d[0], d[1], d[2]]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jfif_conversion_matches_known_colours() {
|
||||
let c = YCbCr::defaults(255.0);
|
||||
assert_eq!(convert(&c, [0, 128, 128]), [0, 0, 0]);
|
||||
assert_eq!(convert(&c, [255, 128, 128]), [255, 255, 255]);
|
||||
assert_eq!(convert(&c, [128, 128, 128]), [128, 128, 128]);
|
||||
// Pure red, green and blue as JFIF encodes them.
|
||||
assert_eq!(convert(&c, [76, 85, 255]), [254, 0, 0]);
|
||||
assert_eq!(convert(&c, [150, 44, 21]), [0, 255, 1]);
|
||||
assert_eq!(convert(&c, [29, 255, 107]), [0, 0, 254]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sixteen_bit_and_extra_samples() {
|
||||
let c = YCbCr::defaults(65535.0);
|
||||
let mut d: Vec<u8> = [65535u16, 32768, 32768, 1234].iter().flat_map(|v| v.to_ne_bytes()).collect();
|
||||
c.convert(&mut d, 4, SampleType::U16);
|
||||
let v: Vec<u16> = d.as_chunks::<2>().0.iter().map(|b| u16::from_ne_bytes(*b)).collect();
|
||||
assert_eq!(v, [65535, 65535, 65535, 1234]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn studio_range_reference_black_white() {
|
||||
// Y 16–235, chroma 128 ± 112 (ITU-R BT.601 studio range).
|
||||
let c = YCbCr { luma: BT601, ref_bw: [16.0, 235.0, 128.0, 240.0, 128.0, 240.0] };
|
||||
assert_eq!(convert(&c, [16, 128, 128]), [0, 0, 0]);
|
||||
assert_eq!(convert(&c, [235, 128, 128]), [255, 255, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn odd_buffers_do_not_panic() {
|
||||
let c = YCbCr::defaults(255.0);
|
||||
let mut d = vec![1u8, 2];
|
||||
c.convert(&mut d, 3, SampleType::U8);
|
||||
c.convert(&mut d, 0, SampleType::U8);
|
||||
c.convert(&mut d, 0, SampleType::U16);
|
||||
assert_eq!(d, [1, 2]);
|
||||
}
|
||||
}
|
||||
@@ -236,18 +236,33 @@ impl<'a> File<'a> {
|
||||
|
||||
/// The first value of a numeric tag as a float: RATIONAL, FLOAT, DOUBLE or an integer.
|
||||
pub fn tag_f64(&self, ifd: &Ifd, tag: u16) -> Option<f64> {
|
||||
self.float(ifd.get(tag)?, 0)
|
||||
}
|
||||
|
||||
/// The values of a numeric tag as floats, when it holds exactly `n` (`None` for any other
|
||||
/// count or an unreadable value).
|
||||
pub fn tag_f64s(&self, ifd: &Ifd, tag: u16, n: u64) -> Option<Vec<f64>> {
|
||||
let e = ifd.get(tag)?;
|
||||
if e.count == 0 {
|
||||
if e.count != n {
|
||||
return None;
|
||||
}
|
||||
(0..n).map(|i| self.float(e, i)).collect()
|
||||
}
|
||||
|
||||
/// Value `i` of a numeric entry as a float: RATIONAL, FLOAT, DOUBLE or an integer.
|
||||
fn float(&self, e: &Entry, i: u64) -> Option<f64> {
|
||||
if i >= e.count {
|
||||
return None;
|
||||
}
|
||||
let at = e.value_at.checked_add(i.checked_mul(type_size(e.ty)?)?)?;
|
||||
match e.ty {
|
||||
5 => {
|
||||
let (n, d) = (self.u32_at(e.value_at)?, self.u32_at(e.value_at.checked_add(4)?)?);
|
||||
let (n, d) = (self.u32_at(at)?, self.u32_at(at.checked_add(4)?)?);
|
||||
(d != 0).then(|| f64::from(n) / f64::from(d))
|
||||
}
|
||||
11 => Some(f64::from(f32::from_bits(self.u32_at(e.value_at)?))),
|
||||
12 => Some(f64::from_bits(self.u64_at(e.value_at)?)),
|
||||
_ => self.uint(e, 0).map(|v| v as f64),
|
||||
11 => Some(f64::from(f32::from_bits(self.u32_at(at)?))),
|
||||
12 => Some(f64::from_bits(self.u64_at(at)?)),
|
||||
_ => self.uint(e, i).map(|v| v as f64),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
//! Bounded, seekable single-part scanline OpenEXR decoding. Total pixel count does not
|
||||
//! size a decoded image buffer: only one compression block is returned at a time.
|
||||
use std::io::{Cursor, Read, Seek, SeekFrom};
|
||||
|
||||
use crate::{CodecError, Format};
|
||||
use exr::{
|
||||
block::{
|
||||
UncompressedBlock,
|
||||
chunk::{Chunk, CompressedBlock, CompressedScanLineBlock},
|
||||
lines::LineIndex,
|
||||
},
|
||||
meta::{BlockDescription, MetaData, attribute::SampleType},
|
||||
};
|
||||
|
||||
const META_BYTES: usize = 8 << 20;
|
||||
const BLOCK_BYTES: usize = 64 << 20;
|
||||
const MAX_CHUNKS: usize = 1 << 20;
|
||||
|
||||
fn invalid(e: impl std::fmt::Display) -> CodecError {
|
||||
CodecError::malformed(Format::OpenExr, e)
|
||||
}
|
||||
fn limit(s: &str) -> CodecError {
|
||||
CodecError::LimitExceeded(s.into())
|
||||
}
|
||||
fn unsupported(s: &str) -> CodecError {
|
||||
CodecError::unsupported(Format::OpenExr, s)
|
||||
}
|
||||
|
||||
fn read<const N: usize>(r: &mut impl Read) -> Result<[u8; N], CodecError> {
|
||||
let mut bytes = [0; N];
|
||||
r.read_exact(&mut bytes).map_err(invalid)?;
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn string(r: &mut impl Read, prefix: &mut Vec<u8>) -> Result<bool, CodecError> {
|
||||
for i in 0..256 {
|
||||
let b = read::<1>(r)?;
|
||||
prefix.extend(b);
|
||||
if b == [0] {
|
||||
return Ok(i != 0);
|
||||
}
|
||||
}
|
||||
Err(limit("EXR attribute name exceeds 255 bytes"))
|
||||
}
|
||||
|
||||
fn metadata(r: &mut impl Read, cancel: &dyn Fn() -> bool) -> Result<Vec<u8>, CodecError> {
|
||||
let start = read::<8>(r)?;
|
||||
if start.get(..4) != Some(&[0x76, 0x2f, 0x31, 0x01]) {
|
||||
return Err(invalid("EXR signature"));
|
||||
}
|
||||
let version = u32::from_le_bytes(start.get(4..).ok_or_else(|| invalid("version"))?.try_into().map_err(invalid)?);
|
||||
if version & (0x200 | 0x800 | 0x1000) != 0 {
|
||||
return Err(unsupported("streaming requires a single flat scanline part"));
|
||||
}
|
||||
let mut prefix = start.to_vec();
|
||||
let mut attributes = 0;
|
||||
loop {
|
||||
if cancel() {
|
||||
return Err(invalid("EXR import cancelled"));
|
||||
}
|
||||
if !string(r, &mut prefix)? {
|
||||
break;
|
||||
}
|
||||
attributes += 1;
|
||||
if attributes > 4096 {
|
||||
return Err(limit("EXR metadata exceeds 4096 attributes"));
|
||||
}
|
||||
string(r, &mut prefix)?;
|
||||
let length = read::<4>(r)?;
|
||||
prefix.extend(length);
|
||||
let n = usize::try_from(i32::from_le_bytes(length)).map_err(invalid)?;
|
||||
if n > META_BYTES || prefix.len().checked_add(n).is_none_or(|v| v > META_BYTES) {
|
||||
return Err(limit("EXR metadata exceeds 8 MiB"));
|
||||
}
|
||||
let begin = prefix.len();
|
||||
prefix.try_reserve_exact(n).map_err(|_| limit("EXR metadata allocation"))?;
|
||||
prefix.resize(begin + n, 0);
|
||||
for bytes in prefix.get_mut(begin..).ok_or_else(|| invalid("attribute size"))?.chunks_mut(64 << 10) {
|
||||
if cancel() {
|
||||
return Err(invalid("EXR import cancelled"));
|
||||
}
|
||||
r.read_exact(bytes).map_err(invalid)?;
|
||||
}
|
||||
}
|
||||
Ok(prefix)
|
||||
}
|
||||
|
||||
/// Header facts used by the document owner to admit tile and working memory.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Info {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub gray: bool,
|
||||
pub has_alpha: bool,
|
||||
pub all_half: bool,
|
||||
pub working_bytes: u64,
|
||||
pub blocks: usize,
|
||||
}
|
||||
|
||||
/// Interleaved little-endian F32, GrayA or RGBA, at source resolution.
|
||||
pub struct Band {
|
||||
pub x: u32,
|
||||
pub y: u32,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
pub struct Decoder<R> {
|
||||
source: R,
|
||||
meta: MetaData,
|
||||
info: Info,
|
||||
offsets: Vec<u64>,
|
||||
seen: Vec<bool>,
|
||||
channels: Vec<Option<usize>>,
|
||||
next: usize,
|
||||
file_len: u64,
|
||||
chunks_start: u64,
|
||||
raw_limit: usize,
|
||||
rows: usize,
|
||||
}
|
||||
|
||||
impl<R: Read + Seek> Decoder<R> {
|
||||
/// Read only bounded metadata and offsets; allocate no full-image or channel buffer.
|
||||
pub fn new(mut source: R, cancel: &dyn Fn() -> bool) -> Result<Self, CodecError> {
|
||||
let file_len = source.seek(SeekFrom::End(0)).map_err(invalid)?;
|
||||
source.seek(SeekFrom::Start(0)).map_err(invalid)?;
|
||||
let prefix = metadata(&mut source, cancel)?;
|
||||
let meta = std::panic::catch_unwind(|| MetaData::read_from_buffered(Cursor::new(&prefix), false))
|
||||
.map_err(|_| invalid("invalid EXR metadata"))?
|
||||
.map_err(invalid)?;
|
||||
let header = meta.headers.first().ok_or_else(|| invalid("missing header"))?;
|
||||
if meta.headers.len() != 1 || header.deep || !matches!(header.blocks, BlockDescription::ScanLines) {
|
||||
return Err(unsupported("streaming requires a single flat scanline part"));
|
||||
}
|
||||
let (w, h) = (header.layer_size.0, header.layer_size.1);
|
||||
if w == 0 || h == 0 || w > 1 << 18 || h > 1 << 18 {
|
||||
return Err(limit("EXR dimensions exceed the streaming limit of 262144"));
|
||||
}
|
||||
let list = &header.channels.list;
|
||||
if list.is_empty() || list.len() > 64 || list.iter().any(|c| c.sampling.0 != 1 || c.sampling.1 != 1) {
|
||||
return Err(unsupported("EXR channel count or subsampling"));
|
||||
}
|
||||
// The same channels the whole-file decoder shows, so both routes open the same image.
|
||||
let names: Vec<String> = list.iter().map(|c| c.name.to_string()).collect();
|
||||
let (layout, picked) = crate::image::exr_color_channels(names.iter().map(String::as_str)).ok_or_else(|| unsupported("no R/G/B or Y channels"))?;
|
||||
let gray = matches!(layout, crate::ChannelLayout::Gray | crate::ChannelLayout::GrayA);
|
||||
let mut selected: Vec<Option<usize>> = picked.into_iter().map(Some).collect();
|
||||
if !matches!(layout, crate::ChannelLayout::GrayA | crate::ChannelLayout::Rgba) {
|
||||
selected.push(None);
|
||||
}
|
||||
if selected.len() != if gray { 2 } else { 4 } {
|
||||
return Err(unsupported("EXR channel selection"));
|
||||
}
|
||||
// The document path currently tags Rec. 709 linear samples. Keep custom
|
||||
// primaries on the existing decoder until their profiles can be mapped.
|
||||
if !gray && header.shared_attributes.chromaticities.is_some() {
|
||||
return Err(unsupported("streaming custom EXR chromaticities requires a color profile"));
|
||||
}
|
||||
let all_half = selected.iter().flatten().all(|i| list.get(*i).is_some_and(|c| c.sample_type == SampleType::F16));
|
||||
let has_alpha = selected.last().copied().flatten().is_some();
|
||||
let channels = (0..list.len()).map(|i| selected.iter().position(|s| *s == Some(i))).collect();
|
||||
let rows = header.max_block_pixel_size().1;
|
||||
let expected = h.div_ceil(rows.max(1));
|
||||
if rows == 0 || header.chunk_count != expected || expected > MAX_CHUNKS {
|
||||
return Err(limit("EXR block count exceeds the streaming budget"));
|
||||
}
|
||||
let raw_limit = w
|
||||
.checked_mul(rows)
|
||||
.and_then(|n| n.checked_mul(header.channels.bytes_per_pixel))
|
||||
.filter(|n| *n <= BLOCK_BYTES)
|
||||
.ok_or_else(|| limit("EXR compression block exceeds 64 MiB"))?;
|
||||
let output = (w as u64).saturating_mul(rows as u64).saturating_mul(selected.len() as u64 * 4);
|
||||
let info = Info {
|
||||
width: w as u32,
|
||||
height: h as u32,
|
||||
gray,
|
||||
has_alpha,
|
||||
all_half,
|
||||
blocks: expected,
|
||||
working_bytes: (raw_limit as u64).saturating_mul(8).saturating_add(output.saturating_mul(2)).saturating_add(1 << 20),
|
||||
};
|
||||
let chunks_start = source
|
||||
.stream_position()
|
||||
.map_err(invalid)?
|
||||
.checked_add(expected as u64 * 8)
|
||||
.filter(|v| *v <= file_len)
|
||||
.ok_or_else(|| invalid("truncated offset table"))?;
|
||||
let mut offsets = Vec::new();
|
||||
offsets.try_reserve_exact(expected).map_err(|_| limit("EXR offset allocation"))?;
|
||||
for _ in 0..expected {
|
||||
if cancel() {
|
||||
return Err(invalid("EXR import cancelled"));
|
||||
}
|
||||
let offset = u64::from_le_bytes(read(&mut source)?);
|
||||
if offset < chunks_start || offset.checked_add(8).is_none_or(|v| v > file_len) {
|
||||
return Err(invalid("EXR chunk offset"));
|
||||
}
|
||||
offsets.push(offset);
|
||||
}
|
||||
Ok(Self { source, meta, info, offsets, seen: vec![false; expected], channels, next: 0, file_len, chunks_start, raw_limit, rows })
|
||||
}
|
||||
|
||||
pub fn info(&self) -> Info {
|
||||
self.info
|
||||
}
|
||||
|
||||
/// Decode one block. Check cancellation while reading and between channel rows.
|
||||
pub fn next_band(&mut self, cancel: &dyn Fn() -> bool) -> Result<Option<Band>, CodecError> {
|
||||
let Some(offset) = self.offsets.get(self.next).copied() else {
|
||||
return Ok(None);
|
||||
};
|
||||
if cancel() {
|
||||
return Err(invalid("EXR import cancelled"));
|
||||
}
|
||||
self.source.seek(SeekFrom::Start(offset)).map_err(invalid)?;
|
||||
let y = i32::from_le_bytes(read(&mut self.source)?);
|
||||
let n = usize::try_from(i32::from_le_bytes(read(&mut self.source)?)).map_err(invalid)?;
|
||||
if n > self.raw_limit.saturating_mul(2).saturating_add(64 << 10)
|
||||
|| offset < self.chunks_start
|
||||
|| offset.checked_add(8).and_then(|v| v.checked_add(n as u64)).is_none_or(|v| v > self.file_len)
|
||||
{
|
||||
return Err(invalid("EXR compressed block length"));
|
||||
}
|
||||
let header = self.meta.headers.first().ok_or_else(|| invalid("missing header"))?;
|
||||
let row = i64::from(y) - i64::from(header.own_attributes.layer_position.1);
|
||||
if row < 0 || row as u64 >= u64::from(self.info.height) || !(row as usize).is_multiple_of(self.rows) {
|
||||
return Err(invalid("EXR block coordinate"));
|
||||
}
|
||||
let seen = self.seen.get_mut(row as usize / self.rows).ok_or_else(|| invalid("EXR block coordinate"))?;
|
||||
if *seen {
|
||||
return Err(invalid("duplicate EXR block"));
|
||||
}
|
||||
*seen = true;
|
||||
let mut packed = Vec::new();
|
||||
packed.try_reserve_exact(n).map_err(|_| limit("EXR block allocation"))?;
|
||||
packed.resize(n, 0);
|
||||
for bytes in packed.chunks_mut(64 << 10) {
|
||||
if cancel() {
|
||||
return Err(invalid("EXR import cancelled"));
|
||||
}
|
||||
self.source.read_exact(bytes).map_err(invalid)?;
|
||||
}
|
||||
let chunk =
|
||||
Chunk { layer_index: 0, compressed_block: CompressedBlock::ScanLine(CompressedScanLineBlock { y_coordinate: y, compressed_pixels_le: packed }) };
|
||||
let block = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| UncompressedBlock::decompress_chunk(chunk, &self.meta, false)))
|
||||
.map_err(|_| invalid("invalid EXR compression block"))?
|
||||
.map_err(invalid)?;
|
||||
let (w, h) = (block.index.pixel_size.0, block.index.pixel_size.1);
|
||||
if w != self.info.width as usize
|
||||
|| block.index.pixel_position.0 != 0
|
||||
|| block.index.pixel_position.1 != row as usize
|
||||
|| h != self.rows.min(self.info.height as usize - row as usize)
|
||||
{
|
||||
return Err(invalid("EXR block shape"));
|
||||
}
|
||||
let expected = w.checked_mul(h).and_then(|n| n.checked_mul(header.channels.bytes_per_pixel)).ok_or_else(|| invalid("EXR block size"))?;
|
||||
if block.data.len() != expected {
|
||||
return Err(invalid("EXR block sample count"));
|
||||
}
|
||||
let nc = if self.info.gray { 2 } else { 4 };
|
||||
let count = w.checked_mul(h).and_then(|n| n.checked_mul(nc * 4)).ok_or_else(|| limit("EXR output block size"))?;
|
||||
let mut data = Vec::new();
|
||||
data.try_reserve_exact(count).map_err(|_| limit("EXR output allocation"))?;
|
||||
data.resize(count, 0);
|
||||
if !self.info.has_alpha {
|
||||
for pixel in data.chunks_exact_mut(nc * 4) {
|
||||
pixel.get_mut((nc - 1) * 4..).ok_or_else(|| invalid("alpha sample"))?.copy_from_slice(&1_f32.to_le_bytes());
|
||||
}
|
||||
}
|
||||
for (range, line) in LineIndex::lines_in_block(block.index, &header.channels) {
|
||||
if cancel() {
|
||||
return Err(invalid("EXR import cancelled"));
|
||||
}
|
||||
let Some(channel) = self.channels.get(line.channel).copied().flatten() else {
|
||||
continue;
|
||||
};
|
||||
let sample = header.channels.list.get(line.channel).ok_or_else(|| invalid("EXR channel"))?.sample_type;
|
||||
let input = block.data.get(range).ok_or_else(|| invalid("EXR row size"))?;
|
||||
let y = line.position.1.checked_sub(block.index.pixel_position.1).ok_or_else(|| invalid("EXR row coordinate"))?;
|
||||
let output = data.get_mut(y * w * nc * 4..(y + 1) * w * nc * 4).ok_or_else(|| invalid("EXR row coordinate"))?;
|
||||
for (bytes, pixel) in input.chunks_exact(sample.bytes_per_sample()).zip(output.chunks_exact_mut(nc * 4)) {
|
||||
let value = match sample {
|
||||
SampleType::F16 => half::f16::from_bits(u16::from_ne_bytes(bytes.try_into().map_err(invalid)?)).to_f32(),
|
||||
SampleType::F32 => f32::from_ne_bytes(bytes.try_into().map_err(invalid)?),
|
||||
SampleType::U32 => u32::from_ne_bytes(bytes.try_into().map_err(invalid)?) as f32,
|
||||
};
|
||||
pixel.get_mut(channel * 4..channel * 4 + 4).ok_or_else(|| invalid("EXR channel"))?.copy_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
}
|
||||
self.next += 1;
|
||||
Ok(Some(Band { x: 0, y: row as u32, width: w as u32, height: h as u32, data }))
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,9 @@
|
||||
//! metadata (EXIF, XMP, DPI, text) are preserved where the format allows.
|
||||
//! * [`caps`] declares per-format capabilities and [`fidelity_warnings`]
|
||||
//! tells the UI exactly what an export will lose.
|
||||
//! * The API is I/O free (`&[u8]` in, `Vec<u8>` out) and builds for
|
||||
//! `wasm32-unknown-unknown`. Decoders enforce [`Limits`] before allocating.
|
||||
//! * APIs use bytes or caller-provided readers, with no ambient filesystem access,
|
||||
//! and build for `wasm32-unknown-unknown`. Byte decoders enforce [`Limits`];
|
||||
//! [`exr_stream`] instead bounds metadata and one decoded compression block.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
@@ -17,6 +18,7 @@ pub mod cicp;
|
||||
mod codecs;
|
||||
mod error;
|
||||
pub mod exif_color;
|
||||
pub mod exr_stream;
|
||||
mod fidelity;
|
||||
mod format;
|
||||
mod image;
|
||||
@@ -43,7 +45,8 @@ pub use crate::image::{
|
||||
MAX_EXTRA_SAMPLES, Metadata, SampleType,
|
||||
};
|
||||
pub use crate::options::{
|
||||
DdsCompression, DdsOptions, DecodeOptions, DpxBits, EncodeOptions, ExrCompression, JpegFormat, Limits, PngCompression, PngStereo, TiffCompression,
|
||||
DdsCompression, DdsOptions, DecodeOptions, DpxBits, EncodeOptions, ExrCompression, JpegFormat, Limits, PngCompression, PngStereo, TIFF_PYRAMID_MIN,
|
||||
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};
|
||||
@@ -273,6 +276,7 @@ pub fn decode_exr_channels(bytes: &[u8], part: usize, names: &[&str], opts: &Dec
|
||||
/// [`decode_as_with`] instead flattens a deep file into a normal [`Image`] with a
|
||||
/// [`DecodeWarning::DeepFlattened`]. `Err(Unsupported)` when the file has no deep part.
|
||||
pub fn decode_deep_exr(bytes: &[u8], limits: &Limits) -> Result<DeepImage, CodecError> {
|
||||
codecs::exr::check_previews(bytes)?;
|
||||
let mut cur = std::io::Cursor::new(bytes);
|
||||
let meta = ::exr::meta::MetaData::read_from_buffered(&mut cur, false).map_err(|e| CodecError::malformed(Format::OpenExr, e))?;
|
||||
codecs::deep_exr::decode_deep(&meta, bytes, limits)
|
||||
|
||||
@@ -286,6 +286,10 @@ pub struct DdsOptions {
|
||||
pub mipmaps: bool,
|
||||
}
|
||||
|
||||
/// The longer side, in pixels, at which a TIFF image pyramid stops: the last level written is
|
||||
/// the first one no longer than this ([`EncodeOptions::tiff_pyramid`]).
|
||||
pub const TIFF_PYRAMID_MIN: u32 = 64;
|
||||
|
||||
/// Options for [`crate::encode`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EncodeOptions {
|
||||
@@ -310,6 +314,11 @@ pub struct EncodeOptions {
|
||||
/// Always write TIFF as BigTIFF (8-byte offsets). Without it a TIFF is written as BigTIFF
|
||||
/// only when it could pass the 4 GiB a classic TIFF can address.
|
||||
pub tiff_bigtiff: bool,
|
||||
/// TIFF: after the full image, write Photoshop's "Save Image Pyramid" levels: copies at half,
|
||||
/// a quarter, … of the size, each marked reduced-resolution (`NewSubfileType` 1), down to
|
||||
/// [`crate::TIFF_PYRAMID_MIN`] px on the longer side. Readers that don't know pyramids open
|
||||
/// the full image, which comes first.
|
||||
pub tiff_pyramid: bool,
|
||||
pub exr_compression: ExrCompression,
|
||||
/// DPX bits per sample. `None` follows the image: 8 for 8-bit, 16 otherwise.
|
||||
pub dpx_bits: Option<DpxBits>,
|
||||
@@ -334,6 +343,7 @@ impl Default for EncodeOptions {
|
||||
webp_quality: 80,
|
||||
tiff_compression: TiffCompression::None,
|
||||
tiff_bigtiff: false,
|
||||
tiff_pyramid: false,
|
||||
exr_compression: ExrCompression::Zip16,
|
||||
dpx_bits: None,
|
||||
dds: DdsOptions::default(),
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
use photocraft_codecs::{exr_stream::Decoder, *};
|
||||
use std::io::Cursor;
|
||||
|
||||
fn fixture(layout: ChannelLayout, sample: SampleType, compression: ExrCompression) -> Vec<u8> {
|
||||
let n = 31 * 35 * layout.channels();
|
||||
let values: Vec<f32> = (0..n)
|
||||
.map(|i| match i % 5 {
|
||||
0 => -0.25,
|
||||
1 => 3.5,
|
||||
2 => 0.0,
|
||||
3 => 0.0001,
|
||||
_ => 0.5,
|
||||
})
|
||||
.collect();
|
||||
let image = Image::from_f32(31, 35, layout, &values).unwrap().convert(layout, sample);
|
||||
encode(&image, Format::OpenExr, &EncodeOptions { exr_compression: compression, ..Default::default() }).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scanline_blocks_match_whole_decoder_at_each_layout_depth_and_compression() {
|
||||
for layout in [ChannelLayout::Gray, ChannelLayout::GrayA, ChannelLayout::Rgb, ChannelLayout::Rgba] {
|
||||
for sample in [SampleType::F16, SampleType::F32] {
|
||||
for compression in [ExrCompression::None, ExrCompression::Rle, ExrCompression::Zip1, ExrCompression::Zip16, ExrCompression::Piz] {
|
||||
let bytes = fixture(layout, sample, compression);
|
||||
let reference = decode(&bytes).unwrap();
|
||||
let target = if layout.is_gray() { ChannelLayout::GrayA } else { ChannelLayout::Rgba };
|
||||
let reference = reference.convert(target, SampleType::F32);
|
||||
let mut stream = Decoder::new(Cursor::new(&bytes), &|| false).unwrap();
|
||||
assert_eq!(stream.info().all_half, sample == SampleType::F16);
|
||||
let row = 31 * target.channels() * 4;
|
||||
let mut actual = vec![0; reference.data().len()];
|
||||
let mut blocks = 0;
|
||||
while let Some(band) = stream.next_band(&|| false).unwrap() {
|
||||
actual[band.y as usize * row..(band.y + band.height) as usize * row].copy_from_slice(&band.data);
|
||||
blocks += 1;
|
||||
}
|
||||
assert_eq!(blocks, stream.info().blocks);
|
||||
assert_eq!(actual, reference.data(), "{layout:?}/{sample:?}/{compression:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn change_window(bytes: &mut [u8], width: i32, height: i32) {
|
||||
let needle = b"dataWindow\0box2i\0\x10\0\0\0";
|
||||
let at = bytes.windows(needle.len()).position(|v| v == needle).unwrap() + needle.len();
|
||||
bytes[at + 8..at + 12].copy_from_slice(&(width - 1).to_le_bytes());
|
||||
bytes[at + 12..at + 16].copy_from_slice(&(height - 1).to_le_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enormous_header_is_not_a_whole_image_allocation() {
|
||||
let mut bytes = fixture(ChannelLayout::Rgb, SampleType::F16, ExrCompression::Zip16);
|
||||
change_window(&mut bytes, 65536, 32768);
|
||||
assert!(matches!(decode(&bytes), Err(CodecError::LimitExceeded(ref message)) if message.contains("2147483648 pixels")));
|
||||
// Streaming gets as far as the now-truncated offset table, without a pixel allocation.
|
||||
assert!(matches!(Decoder::new(Cursor::new(bytes), &|| false), Err(CodecError::Malformed { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancellation_truncation_duplicate_offsets_and_forged_lengths_fail() {
|
||||
let bytes = fixture(ChannelLayout::Rgb, SampleType::F16, ExrCompression::Zip16);
|
||||
assert!(Decoder::new(Cursor::new(&bytes), &|| true).is_err());
|
||||
let mut decoder = Decoder::new(Cursor::new(&bytes), &|| false).unwrap();
|
||||
assert!(decoder.next_band(&|| true).is_err());
|
||||
for end in [0, 8, bytes.len() / 2] {
|
||||
assert!(Decoder::new(Cursor::new(&bytes[..end]), &|| false).and_then(|mut r| r.next_band(&|| false)).is_err());
|
||||
}
|
||||
// Locate the offset table by finding the validated header end.
|
||||
let mut input = Cursor::new(&bytes);
|
||||
let _ = exr::meta::MetaData::read_from_buffered(&mut input, false).unwrap();
|
||||
let at = input.position() as usize;
|
||||
let offset = u64::from_le_bytes(bytes[at..at + 8].try_into().unwrap()) as usize;
|
||||
let mut corrupt = bytes.clone();
|
||||
corrupt[offset + 4..offset + 8].copy_from_slice(&i32::MAX.to_le_bytes());
|
||||
assert!(Decoder::new(Cursor::new(corrupt), &|| false).unwrap().next_band(&|| false).is_err());
|
||||
let mut duplicate = bytes.clone();
|
||||
duplicate[at + 8..at + 16].copy_from_slice(&bytes[at..at + 8]);
|
||||
let mut decoder = Decoder::new(Cursor::new(duplicate), &|| false).unwrap();
|
||||
assert!(decoder.next_band(&|| false).unwrap().is_some());
|
||||
assert!(decoder.next_band(&|| false).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_metadata_is_bounded_and_mutated_inputs_never_panic() {
|
||||
let bytes = fixture(ChannelLayout::Rgb, SampleType::F16, ExrCompression::Zip16);
|
||||
let mut large = bytes.clone();
|
||||
let needle = b"channels\0chlist\0";
|
||||
let length = bytes.windows(needle.len()).position(|v| v == needle).unwrap() + needle.len();
|
||||
large[length..length + 4].copy_from_slice(&i32::MAX.to_le_bytes());
|
||||
assert!(matches!(Decoder::new(Cursor::new(large), &|| false), Err(CodecError::LimitExceeded(_))));
|
||||
let mut many = bytes[..8].to_vec();
|
||||
for _ in 0..4097 {
|
||||
many.extend_from_slice(b"z\0string\0\0\0\0\0");
|
||||
}
|
||||
many.push(0);
|
||||
assert!(matches!(Decoder::new(Cursor::new(many), &|| false), Err(CodecError::LimitExceeded(_))));
|
||||
// Small deterministic mutations cover headers, offsets and compressed payloads.
|
||||
for i in 0..256 {
|
||||
let mut corrupt = bytes.clone();
|
||||
let at = i * (bytes.len() - 1) / 255;
|
||||
corrupt[at] ^= 0xff;
|
||||
assert!(
|
||||
std::panic::catch_unwind(|| {
|
||||
if let Ok(mut decoder) = Decoder::new(Cursor::new(corrupt), &|| false) {
|
||||
while let Ok(Some(_)) = decoder.next_band(&|| false) {}
|
||||
}
|
||||
})
|
||||
.is_ok(),
|
||||
"mutation at byte {at}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_samples_and_offset_data_window_keep_coordinates_and_values() {
|
||||
use exr::prelude::{AnyChannel, AnyChannels, Blocks, Compression, Encoding, FlatSamples, Layer, LayerAttributes, LineOrder, WritableImage};
|
||||
let channels = AnyChannels::sort(
|
||||
vec![
|
||||
AnyChannel::new("R", FlatSamples::F16(vec![f16::from_f32(-0.25); 31 * 35])),
|
||||
AnyChannel::new("G", FlatSamples::F32(vec![3.5; 31 * 35])),
|
||||
AnyChannel::new("B", FlatSamples::U32(vec![42; 31 * 35])),
|
||||
]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
);
|
||||
let attributes = LayerAttributes { layer_position: exr::math::Vec2(-15, 40), ..Default::default() };
|
||||
let layer =
|
||||
Layer::new((31, 35), attributes, Encoding { compression: Compression::ZIP16, blocks: Blocks::ScanLines, line_order: LineOrder::Decreasing }, channels);
|
||||
let mut bytes = Cursor::new(Vec::new());
|
||||
exr::image::Image::from_layer(layer).write().to_buffered(&mut bytes).unwrap();
|
||||
let mut decoder = Decoder::new(bytes, &|| false).unwrap();
|
||||
assert!(!decoder.info().all_half);
|
||||
let mut rows = 0;
|
||||
while let Some(band) = decoder.next_band(&|| false).unwrap() {
|
||||
assert_eq!(band.y, rows);
|
||||
rows += band.height;
|
||||
for p in band.data.as_chunks::<16>().0 {
|
||||
let v: Vec<f32> = p.as_chunks::<4>().0.iter().map(|s| f32::from_le_bytes(*s)).collect();
|
||||
assert_eq!(v, [-0.25, 3.5, 42.0, 1.0]);
|
||||
}
|
||||
}
|
||||
assert_eq!(rows, 35);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_variants_stay_on_the_guarded_legacy_path() {
|
||||
let bytes = fixture(ChannelLayout::Rgb, SampleType::F16, ExrCompression::Zip16);
|
||||
for flag in [0x200_u32, 0x800, 0x1000] {
|
||||
let mut changed = bytes.clone();
|
||||
changed[4..8].copy_from_slice(&(2 | flag).to_le_bytes());
|
||||
assert!(matches!(Decoder::new(Cursor::new(changed), &|| false), Err(CodecError::Unsupported { .. })));
|
||||
}
|
||||
let mut custom = bytes[..8].to_vec();
|
||||
custom.extend_from_slice(b"chromaticities\0chromaticities\0\x20\0\0\0");
|
||||
for v in [0.64_f32, 0.33, 0.3, 0.6, 0.15, 0.06, 0.3127, 0.329] {
|
||||
custom.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
custom.extend_from_slice(&bytes[8..]);
|
||||
assert!(matches!(Decoder::new(Cursor::new(custom), &|| false), Err(CodecError::Unsupported { .. })));
|
||||
}
|
||||
@@ -10,6 +10,7 @@ mod detect_caps;
|
||||
mod dpx;
|
||||
mod exr_cryptomatte;
|
||||
mod exr_multipart;
|
||||
mod exr_stream;
|
||||
mod fidelity;
|
||||
mod gif_frames;
|
||||
mod heif;
|
||||
@@ -30,7 +31,9 @@ mod tiff_exif;
|
||||
mod tiff_lab;
|
||||
mod tiff_pages;
|
||||
mod tiff_photoshop;
|
||||
mod tiff_pyramid;
|
||||
mod tiff_regressions;
|
||||
mod tiff_ycbcr;
|
||||
mod webp_frames;
|
||||
mod webp_lossy;
|
||||
mod xpm;
|
||||
@@ -213,6 +213,23 @@ fn tiff_all_compressions_lossless() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compressed_tiff_does_not_keep_the_uncompressed_reservation() {
|
||||
// The encoder reserves the uncompressed size up front for an uncompressed file (#6607). A
|
||||
// smooth image compresses to a fraction of it, and no returned file holds much spare room.
|
||||
let (w, h) = (512u32, 512u32);
|
||||
let px: Vec<u8> = (0..h).flat_map(|y| (0..w).flat_map(move |x| [(x / 2) as u8, (y / 2) as u8, 128])).collect();
|
||||
let img = Image::from_u8(w, h, ChannelLayout::Rgb, px).unwrap();
|
||||
for comp in [TiffCompression::None, TiffCompression::Lzw, TiffCompression::Deflate] {
|
||||
let v = encode(&img, Format::Tiff, &EncodeOptions { tiff_compression: comp, ..Default::default() }).unwrap();
|
||||
if !matches!(comp, TiffCompression::None) {
|
||||
assert!(v.len() < img.data().len() / 2, "{comp:?}: the image should compress well ({} bytes)", v.len());
|
||||
}
|
||||
assert!(v.capacity() - v.len() <= v.len() / 8, "{comp:?}: {} bytes kept for a {} byte file", v.capacity(), v.len());
|
||||
assert_eq!(decode(&v).unwrap().data(), img.data(), "{comp:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn png_all_compressions_lossless() {
|
||||
let img = test_image(ChannelLayout::Rgba, SampleType::U16);
|
||||
|
||||
@@ -105,3 +105,20 @@ fn decode_30mp() {
|
||||
let d = strips(W, H, 8, 3, 2, 64, &rgb);
|
||||
time("RGB 8 BigTIFF strips, none", &build(le, true, &[d], &[0]).bytes);
|
||||
}
|
||||
|
||||
/// Save Image Pyramid's cost on top of a plain encode (#8827): RGB/8 and RGBA/16.
|
||||
#[test]
|
||||
#[ignore = "benchmark: run in release with --ignored --nocapture"]
|
||||
fn encode_with_pyramid() {
|
||||
for (layout, sample, spp) in [(ChannelLayout::Rgb, SampleType::U8, 3), (ChannelLayout::Rgba, SampleType::U16, 4)] {
|
||||
let raw = pixels(spp);
|
||||
let data = if sample == SampleType::U16 { raw.iter().flat_map(|&v| (u16::from(v) * 257).to_ne_bytes()).collect() } else { raw };
|
||||
let img = Image::from_raw(W, H, layout, sample, data).unwrap();
|
||||
for pyramid in [false, true] {
|
||||
let opts = EncodeOptions { tiff_pyramid: pyramid, ..Default::default() };
|
||||
let t = Instant::now();
|
||||
let bytes = encode(&img, Format::Tiff, &opts).unwrap();
|
||||
println!("{layout:?} {sample:?} pyramid {pyramid}: {:.0} ms, {} MB", t.elapsed().as_secs_f64() * 1000.0, bytes.len() >> 20);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
//! TIFF "Save Image Pyramid" (#8827): reduced-resolution levels after the full image, each half
|
||||
//! the size of the one before, that every reader skips when it opens the file.
|
||||
|
||||
use photocraft_codecs::*;
|
||||
|
||||
fn pyramid() -> EncodeOptions {
|
||||
EncodeOptions { tiff_pyramid: true, ..Default::default() }
|
||||
}
|
||||
|
||||
/// An image of `layout`/`sample` where every sample is `v` (on the 0–1 scale).
|
||||
fn solid(w: u32, h: u32, layout: ChannelLayout, sample: SampleType, v: f32) -> Image {
|
||||
let n = w as usize * h as usize * layout.channels();
|
||||
let data: Vec<u8> = match sample {
|
||||
SampleType::U8 => vec![(v * 255.0).round() as u8; n],
|
||||
SampleType::U16 => (0..n).flat_map(|_| ((v * 65535.0).round() as u16).to_ne_bytes()).collect(),
|
||||
SampleType::F32 => (0..n).flat_map(|_| v.to_ne_bytes()).collect(),
|
||||
SampleType::F16 => (0..n).flat_map(|_| f16::from_f32(v).to_bits().to_ne_bytes()).collect(),
|
||||
};
|
||||
Image::from_raw(w, h, layout, sample, data).unwrap()
|
||||
}
|
||||
|
||||
fn sizes(bytes: &[u8]) -> Vec<(TiffPageKind, u32, u32)> {
|
||||
tiff_info(bytes).unwrap().pages.iter().map(|p| (p.kind, p.width, p.height)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn levels_halve_down_to_the_minimum() {
|
||||
use TiffPageKind::*;
|
||||
let img = solid(300, 200, ChannelLayout::Rgb, SampleType::U8, 0.5);
|
||||
let bytes = encode(&img, Format::Tiff, &pyramid()).unwrap();
|
||||
// 300 → 150 → 75 → 38: the first level no longer than 64 px is the last.
|
||||
assert_eq!(sizes(&bytes), [(Page, 300, 200), (ReducedResolution, 150, 100), (ReducedResolution, 75, 50), (ReducedResolution, 38, 25)]);
|
||||
// Opening the file opens the full image, and the levels are not extra pages.
|
||||
let back = decode(&bytes).unwrap();
|
||||
assert_eq!(back.dimensions(), (300, 200));
|
||||
assert_eq!(back.data(), img.data());
|
||||
assert!(!back.warnings.iter().any(|w| matches!(w, DecodeWarning::MorePages { .. })), "{:?}", back.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn off_by_default_and_none_for_small_images() {
|
||||
let img = solid(300, 200, ChannelLayout::Rgb, SampleType::U8, 0.5);
|
||||
assert_eq!(sizes(&encode(&img, Format::Tiff, &EncodeOptions::default()).unwrap()).len(), 1);
|
||||
let small = solid(64, 10, ChannelLayout::Rgb, SampleType::U8, 0.5);
|
||||
assert_eq!(sizes(&encode(&small, Format::Tiff, &pyramid()).unwrap()).len(), 1);
|
||||
// A 1-px-wide strip still halves its long side.
|
||||
let strip = solid(1, 200, ChannelLayout::Gray, SampleType::U8, 0.5);
|
||||
let s = sizes(&encode(&strip, Format::Tiff, &pyramid()).unwrap());
|
||||
assert_eq!(s.iter().map(|&(_, w, h)| (w, h)).collect::<Vec<_>>(), [(1, 200), (1, 100), (1, 50)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_depth_layout_and_compression() {
|
||||
let layouts =
|
||||
[ChannelLayout::Gray, ChannelLayout::GrayA, ChannelLayout::Rgb, ChannelLayout::Rgba, ChannelLayout::Cmyk, ChannelLayout::CmykA, ChannelLayout::Lab];
|
||||
for layout in layouts {
|
||||
for sample in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
for compression in [TiffCompression::None, TiffCompression::Lzw, TiffCompression::Deflate, TiffCompression::PackBits] {
|
||||
let img = solid(120, 70, layout, sample, 0.6);
|
||||
let opts = EncodeOptions { tiff_compression: compression, ..pyramid() };
|
||||
let bytes = encode(&img, Format::Tiff, &opts).unwrap_or_else(|e| panic!("{layout:?} {sample:?} {compression:?}: {e}"));
|
||||
let info = tiff_info(&bytes).unwrap();
|
||||
assert_eq!(info.pages.len(), 2, "{layout:?} {sample:?} {compression:?}");
|
||||
let level = decode_tiff_page(&bytes, 1, &DecodeOptions::default()).unwrap();
|
||||
assert_eq!(level.dimensions(), (60, 35));
|
||||
assert_eq!(level.layout(), decode(&bytes).unwrap().layout(), "{layout:?} {sample:?}");
|
||||
// A solid image stays the same colour at every level.
|
||||
let full = decode(&bytes).unwrap().convert(level.layout(), SampleType::F32);
|
||||
let level = level.convert(level.layout(), SampleType::F32);
|
||||
let (a, b) = (full.to_f32_samples().unwrap(), level.to_f32_samples().unwrap());
|
||||
for (c, v) in b.iter().take(level.layout().channels()).enumerate() {
|
||||
assert!((v - a[c]).abs() < 1e-3, "{layout:?} {sample:?} channel {c}: {v} vs {}", a[c]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A 128×128 image of 2×2 `tile`s (top left, top right, bottom left, bottom right; alpha last),
|
||||
/// and the first pixel of its first reduced level.
|
||||
fn first_level_pixel(layout: ChannelLayout, tile: [&[u8]; 4]) -> Vec<u8> {
|
||||
let mut data = Vec::new();
|
||||
for _ in 0..64 {
|
||||
for x in 0..128 {
|
||||
data.extend_from_slice(tile[x % 2]);
|
||||
}
|
||||
for x in 0..128 {
|
||||
data.extend_from_slice(tile[2 + x % 2]);
|
||||
}
|
||||
}
|
||||
let img = Image::from_raw(128, 128, layout, SampleType::U8, data).unwrap();
|
||||
let bytes = encode(&img, Format::Tiff, &pyramid()).unwrap();
|
||||
let level = decode_tiff_page(&bytes, 1, &DecodeOptions::default()).unwrap();
|
||||
assert_eq!(level.dimensions(), (64, 64), "{layout:?}");
|
||||
assert_eq!(level.layout(), layout);
|
||||
level.data()[..layout.channels()].to_vec()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colour_is_weighted_by_alpha() {
|
||||
// One opaque pixel and three transparent ones of another colour: the level keeps the opaque
|
||||
// colour at a quarter alpha. Unweighted, the transparent colour would bleed in (pink, not red).
|
||||
assert_eq!(first_level_pixel(ChannelLayout::Rgba, [&[255, 0, 0, 255], &[255, 255, 255, 0], &[255, 255, 255, 0], &[255, 255, 255, 0]]), [255, 0, 0, 64]);
|
||||
// Gray and CMYK reach the same path with other channel counts.
|
||||
assert_eq!(first_level_pixel(ChannelLayout::GrayA, [&[40, 255], &[255, 0], &[255, 0], &[255, 0]]), [40, 64]);
|
||||
assert_eq!(first_level_pixel(ChannelLayout::CmykA, [&[200, 10, 30, 90, 255], &[0; 5], &[0; 5], &[0; 5]]), [200, 10, 30, 90, 64]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colour_is_weighted_between_translucent_pixels() {
|
||||
// Two half-transparent pixels of different colours (red, blue) and two clear ones: the level
|
||||
// averages the two by their alpha (purple), where copying either pixel would give red or blue.
|
||||
let tile: [&[u8]; 4] = [&[255, 0, 0, 128], &[0, 0, 255, 128], &[0; 4], &[0; 4]];
|
||||
assert_eq!(first_level_pixel(ChannelLayout::Rgba, tile), [128, 0, 128, 64]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layered_and_tagged_data_stay_on_the_full_image() {
|
||||
let mut img = solid(200, 100, ChannelLayout::Rgb, SampleType::U8, 0.25);
|
||||
img.icc = Some(photocraft_cms_free_profile());
|
||||
img.meta.photoshop_layers = Some(vec![1, 2, 3, 4]);
|
||||
img.meta.extra_samples = vec![ExtraSample { kind: 0, data: vec![9; 200 * 100] }];
|
||||
let bytes = encode(&img, Format::Tiff, &pyramid()).unwrap();
|
||||
let info = tiff_info(&bytes).unwrap();
|
||||
assert_eq!(info.pages.len(), 3);
|
||||
// Only the full image carries the layers and the extra samples.
|
||||
assert_eq!(info.pages[0].samples_per_pixel, 4);
|
||||
assert!(info.pages[1..].iter().all(|p| p.samples_per_pixel == 3));
|
||||
assert_eq!(tiff_photoshop_tags(&bytes).1, Some(&[1u8, 2, 3, 4][..]));
|
||||
let level = decode_tiff_page(&bytes, 1, &DecodeOptions::default()).unwrap();
|
||||
assert_eq!(level.icc, img.icc, "levels keep the profile so they show the same colours");
|
||||
assert!(level.meta.extra_samples.is_empty());
|
||||
}
|
||||
|
||||
/// Any bytes a decoder keeps as the ICC profile: the codec stores it without parsing.
|
||||
fn photocraft_cms_free_profile() -> Vec<u8> {
|
||||
(0..=255u8).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn levels_average_exactly_at_every_depth() {
|
||||
// One 2×2 block of gray values 0, 0.25, 0.5, 1 averages to 0.4375; a 2×2 block with half
|
||||
// transparent pixels keeps only the opaque colour and averages the alpha.
|
||||
for sample in [SampleType::U16, SampleType::F32] {
|
||||
let vals = [0.0f32, 0.25, 0.5, 1.0];
|
||||
let mut img = solid(130, 2, ChannelLayout::Gray, sample, 0.0);
|
||||
let w = 130;
|
||||
for (i, v) in vals.iter().enumerate() {
|
||||
let (x, y) = (i % 2, i / 2);
|
||||
let at = (y * w + x) * sample.bytes();
|
||||
let bytes: Vec<u8> = match sample {
|
||||
SampleType::U16 => ((v * 65535.0).round() as u16).to_ne_bytes().to_vec(),
|
||||
_ => v.to_ne_bytes().to_vec(),
|
||||
};
|
||||
img.data_mut()[at..at + bytes.len()].copy_from_slice(&bytes);
|
||||
}
|
||||
let bytes = encode(&img, Format::Tiff, &pyramid()).unwrap();
|
||||
let level = decode_tiff_page(&bytes, 1, &DecodeOptions::default()).unwrap().convert(ChannelLayout::Gray, SampleType::F32);
|
||||
let v = level.to_f32_samples().unwrap()[0];
|
||||
assert!((v - 0.4375).abs() < 1e-4, "{sample:?}: {v}");
|
||||
}
|
||||
let mut data = Vec::new();
|
||||
for _ in 0..2 {
|
||||
for x in 0..130u32 {
|
||||
// RGBA16: opaque blue on even columns, transparent red on odd.
|
||||
let px: [u16; 4] = if x % 2 == 0 { [0, 0, 65535, 65535] } else { [65535, 0, 0, 0] };
|
||||
data.extend(px.iter().flat_map(|v| v.to_ne_bytes()));
|
||||
}
|
||||
}
|
||||
let img = Image::from_raw(130, 2, ChannelLayout::Rgba, SampleType::U16, data).unwrap();
|
||||
let bytes = encode(&img, Format::Tiff, &pyramid()).unwrap();
|
||||
let level = decode_tiff_page(&bytes, 1, &DecodeOptions::default()).unwrap();
|
||||
assert_eq!(level.to_u16_samples().unwrap()[..4], [0, 0, 65535, 32768]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bigtiff_files_carry_the_pyramid_too() {
|
||||
let img = solid(300, 200, ChannelLayout::Rgba, SampleType::U8, 0.5);
|
||||
let bytes = encode(&img, Format::Tiff, &EncodeOptions { tiff_bigtiff: true, ..pyramid() }).unwrap();
|
||||
let info = tiff_info(&bytes).unwrap();
|
||||
assert!(info.big_tiff);
|
||||
assert_eq!(info.pages.iter().map(|p| (p.kind, p.width)).collect::<Vec<_>>().len(), 4);
|
||||
assert_eq!(decode_tiff_page(&bytes, 3, &DecodeOptions::default()).unwrap().dimensions(), (38, 25));
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
//! YCbCr TIFFs (PhotometricInterpretation 6), as scanners write them: JPEG-compressed with 2×2
|
||||
//! chroma subsampling, and uncompressed with ReferenceBlackWhite and YCbCrCoefficients (#10316).
|
||||
//! Every file here is hand-built by `common::tiffgen`.
|
||||
|
||||
use crate::common::sample_icc;
|
||||
use crate::common::tiffgen::*;
|
||||
use photocraft_codecs::*;
|
||||
|
||||
const COMPRESSION: u16 = 259;
|
||||
const SUBSAMPLING: u16 = 530;
|
||||
const YCBCR_COEFFICIENTS: u16 = 529;
|
||||
const REFERENCE_BLACK_WHITE: u16 = 532;
|
||||
const ICC_PROFILE: u16 = 34675;
|
||||
|
||||
/// A directory whose Compression is `c`.
|
||||
fn compressed(mut d: Dir, c: u64) -> Dir {
|
||||
d.entries.retain(|e| e.0 != COMPRESSION);
|
||||
d.tag(COMPRESSION, SHORT, &[c])
|
||||
}
|
||||
|
||||
/// A little-endian RATIONAL value for `tiffgen`.
|
||||
fn rational(num: u32, den: u32) -> u64 {
|
||||
u64::from(num) | (u64::from(den) << 32)
|
||||
}
|
||||
|
||||
/// The image's pixels as RGB bytes.
|
||||
fn rgb(img: &Image) -> Vec<u8> {
|
||||
img.to_rgba8().as_chunks::<4>().0.iter().flat_map(|[r, g, b, _]| [*r, *g, *b]).collect()
|
||||
}
|
||||
|
||||
/// A smooth colour test card, `w`×`h` RGB.
|
||||
fn card(w: u32, h: u32) -> Vec<u8> {
|
||||
let mut v = Vec::new();
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
v.extend([(x * 255 / (w - 1)) as u8, (y * 255 / (h - 1)) as u8, (255 - x * 255 / (w - 1)) as u8]);
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// A 4:2:0 baseline JPEG stream of `rgb`.
|
||||
fn jpeg(rgb: &[u8], w: u32, h: u32) -> Vec<u8> {
|
||||
let img = Image::from_raw(w, h, ChannelLayout::Rgb, SampleType::U8, rgb.to_vec()).unwrap();
|
||||
encode(&img, Format::Jpeg, &EncodeOptions { jpeg_chroma_subsampling: true, ..Default::default() }).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jpeg_compressed_ycbcr_with_subsampled_chroma_reads_as_rgb() {
|
||||
let (w, h) = (32, 24);
|
||||
let stream = jpeg(&card(w, h), w, h);
|
||||
// The stream really is subsampled: the luma component samples 2×2 in the frame header.
|
||||
let sof = stream.windows(2).position(|m| m == [0xFF, 0xC0]).expect("baseline frame header");
|
||||
assert_eq!(stream[sof + 11], 0x22, "luma sampling factors");
|
||||
let dir = compressed(strips(w, h, 8, 3, 6, h, &stream), 7).tag(SUBSAMPLING, SHORT, &[2, 2]);
|
||||
let file = build(true, false, &[dir], &[0]).bytes;
|
||||
|
||||
let img = decode(&file).unwrap();
|
||||
assert_eq!((img.width(), img.height(), img.layout(), img.sample_type()), (w, h, ChannelLayout::Rgb, SampleType::U8));
|
||||
// The oracle: the same JPEG stream decoded to RGB by an independent decoder.
|
||||
let want = image::load_from_memory_with_format(&stream, image::ImageFormat::Jpeg).unwrap().to_rgb8().into_raw();
|
||||
let worst = rgb(&img).iter().zip(&want).map(|(g, o)| g.abs_diff(*o)).max().unwrap();
|
||||
assert!(worst <= 2, "largest difference from the oracle: {worst}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uncompressed_ycbcr_uses_the_default_conversion() {
|
||||
// Black, white, mid gray and JFIF's pure red, without subsampling.
|
||||
let px = [0, 128, 128, 255, 128, 128, 128, 128, 128, 76, 85, 255];
|
||||
let dir = strips(4, 1, 8, 3, 6, 1, &px).tag(SUBSAMPLING, SHORT, &[1, 1]);
|
||||
let img = decode(&build(true, false, &[dir], &[0]).bytes).unwrap();
|
||||
assert_eq!(img.layout(), ChannelLayout::Rgb);
|
||||
assert_eq!(rgb(&img), [0, 0, 0, 255, 255, 255, 128, 128, 128, 254, 0, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_black_white_sets_the_code_range() {
|
||||
// Studio range: Y 16–235, chroma 128 ± 112.
|
||||
let rbw = [rational(16, 1), rational(235, 1), rational(128, 1), rational(240, 1), rational(128, 1), rational(240, 1)];
|
||||
let dir = strips(2, 1, 8, 3, 6, 1, &[16, 128, 128, 235, 128, 128]).tag(SUBSAMPLING, SHORT, &[1, 1]).tag(REFERENCE_BLACK_WHITE, RATIONAL, &rbw);
|
||||
let img = decode(&build(true, false, &[dir], &[0]).bytes).unwrap();
|
||||
assert_eq!(rgb(&img), [0, 0, 0, 255, 255, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weights_that_do_not_sum_to_one_keep_grays_neutral() {
|
||||
// 0.25 each (sum 0.75): TIFF 6.0's full inverse would scale green's Y by (1 − Lr − Lb) / Lg
|
||||
// = 2 and give mid gray G 255 (clamped). Like libtiff, green keeps Y, so gray stays gray, and
|
||||
// the weights still set the chroma terms: R = Y + 1.5·Cr, B = Y + 1.5·Cb.
|
||||
let weights = [rational(1, 4), rational(1, 4), rational(1, 4)];
|
||||
let dir = strips(2, 1, 8, 3, 6, 1, &[128, 128, 128, 128, 64, 192]).tag(SUBSAMPLING, SHORT, &[1, 1]).tag(YCBCR_COEFFICIENTS, RATIONAL, &weights);
|
||||
let img = decode(&build(true, false, &[dir], &[0]).bytes).unwrap();
|
||||
// Green for the second pixel: 128 − (Lb·1.5/Lg)·Cb − (Lr·1.5/Lg)·Cr = 128 + 96 − 96.
|
||||
assert_eq!(rgb(&img), [128, 128, 128, 224, 128, 32]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ycbcr_coefficients_set_the_luma_weights() {
|
||||
// Weights 0.25, 0.5, 0.25 with Cb −64 and Cr +64: R = Y + 1.5·Cr, B = Y + 1.5·Cb, and G keeps Y
|
||||
// (BT.601 would give R 218 and B 15).
|
||||
let weights = [rational(1, 4), rational(1, 2), rational(1, 4)];
|
||||
let dir = strips(1, 1, 8, 3, 6, 1, &[128, 64, 192]).tag(SUBSAMPLING, SHORT, &[1, 1]).tag(YCBCR_COEFFICIENTS, RATIONAL, &weights);
|
||||
let img = decode(&build(true, false, &[dir], &[0]).bytes).unwrap();
|
||||
assert_eq!(rgb(&img), [224, 128, 32]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ycbcr_tags_with_the_wrong_count_use_the_defaults() {
|
||||
// Four weights and seven black/white codes: both tags are malformed, so BT.601 and full range
|
||||
// apply (the weights alone would give 224, 128, 32).
|
||||
let weights = [rational(1, 4), rational(1, 2), rational(1, 4), rational(1, 4)];
|
||||
let rbw = [rational(16, 1), rational(235, 1), rational(128, 1), rational(240, 1), rational(128, 1), rational(240, 1), rational(0, 1)];
|
||||
let dir = strips(1, 1, 8, 3, 6, 1, &[128, 64, 192]).tag(SUBSAMPLING, SHORT, &[1, 1]).tag(YCBCR_COEFFICIENTS, RATIONAL, &weights).tag(
|
||||
REFERENCE_BLACK_WHITE,
|
||||
RATIONAL,
|
||||
&rbw,
|
||||
);
|
||||
let img = decode(&build(true, false, &[dir], &[0]).bytes).unwrap();
|
||||
assert_eq!(rgb(&img), [218, 104, 15]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sixteen_bit_ycbcr_reads_as_rgb() {
|
||||
let px: Vec<u8> = [65535u16, 32768, 32768, 0, 32768, 32768].iter().flat_map(|v| v.to_le_bytes()).collect();
|
||||
let dir = strips(2, 1, 16, 3, 6, 1, &px).tag(SUBSAMPLING, SHORT, &[1, 1]);
|
||||
let img = decode(&build(true, false, &[dir], &[0]).bytes).unwrap();
|
||||
assert_eq!((img.layout(), img.sample_type()), (ChannelLayout::Rgb, SampleType::U16));
|
||||
let n = img.to_normalized();
|
||||
assert!(n[..3].iter().all(|&v| v == 1.0) && n[3..].iter().all(|&v| v == 0.0), "{n:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(target_pointer_width = "64")]
|
||||
fn sixteen_bit_ycbcr_bands_match_whole_decode_with_a_missing_last_strip() {
|
||||
// On 32-bit targets max_alloc also bounds the whole image, so a small allocation cap
|
||||
// cannot force these tiny strips into separate output groups there.
|
||||
let (w, h, rows) = (4u32, 5u32, 2u32);
|
||||
let pixels = [[0u16, 32768, 32768], [65535, 32768, 32768], [19595, 21710, 65535], [32768, 40960, 24576]];
|
||||
let samples: Vec<u16> = (0..w * h).flat_map(|i| pixels[i as usize % pixels.len()]).collect();
|
||||
let weights = [rational(1, 4), rational(1, 2), rational(1, 4)];
|
||||
let icc = sample_icc(512);
|
||||
for little in [true, false] {
|
||||
for missing in [false, true] {
|
||||
let dir = strips(w, h, 16, 3, 6, rows, &u16s(&samples, little)).tag(SUBSAMPLING, SHORT, &[1, 1]).tag(YCBCR_COEFFICIENTS, RATIONAL, &weights).bytes(
|
||||
ICC_PROFILE,
|
||||
UNDEFINED,
|
||||
&icc,
|
||||
);
|
||||
let mut file = build(little, false, &[dir], &[0]);
|
||||
if missing {
|
||||
let past = file.bytes.len() as u64 + 4096;
|
||||
patch_value(&mut file, little, false, 0, 273, 2, past);
|
||||
}
|
||||
let want = decode_with(&file.bytes, &DecodeOptions { keep_orientation: true, ..Default::default() }).unwrap();
|
||||
let limits = Limits { max_alloc: u64::from(w * rows * 3 * 2), ..Limits::banded() };
|
||||
let mut bands = TiffBands::open(&file.bytes, None, &limits).unwrap();
|
||||
assert_eq!((bands.dimensions(), bands.layout(), bands.sample_type()), ((w, h), ChannelLayout::Rgb, SampleType::U16));
|
||||
let mut got = Image::new(w, h, bands.layout(), bands.sample_type()).unwrap();
|
||||
let mut positions = Vec::new();
|
||||
bands
|
||||
.decode(|x, y, group: &Image| -> Result<(), CodecError> {
|
||||
positions.push((x, y, group.height()));
|
||||
assert_eq!((x, group.width(), group.layout(), group.sample_type()), (0, w, ChannelLayout::Rgb, SampleType::U16));
|
||||
let at = y as usize * w as usize * 3 * 2;
|
||||
got.data_mut()[at..at + group.data().len()].copy_from_slice(group.data());
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(positions, [(0, 0, 2), (0, 2, 2), (0, 4, 1)], "one output group per strip, including the short last strip");
|
||||
assert_eq!(got.data(), want.data(), "little endian: {little}, missing last strip: {missing}");
|
||||
assert_eq!(bands.icc, want.icc);
|
||||
assert_eq!(bands.meta, want.meta);
|
||||
assert_eq!(bands.warnings, want.warnings);
|
||||
assert_eq!(bands.warnings.iter().any(|w| matches!(w, DecodeWarning::Truncated { .. })), missing);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_ycbcr_files_fail_cleanly() {
|
||||
// Four samples (an extra sample next to YCbCr).
|
||||
let four = strips(1, 1, 8, 4, 6, 1, &[128, 128, 128, 255]).tag(SUBSAMPLING, SHORT, &[1, 1]);
|
||||
assert!(decode(&build(true, false, &[four], &[0]).bytes).is_err());
|
||||
// Subsampled chroma without JPEG compression (the default subsampling is 2×2).
|
||||
let sub = strips(2, 2, 8, 3, 6, 2, &[128; 12]);
|
||||
assert!(decode(&build(true, false, &[sub], &[0]).bytes).is_err());
|
||||
// A JPEG strip that is not JPEG data.
|
||||
let junk = compressed(strips(16, 16, 8, 3, 6, 16, &[0x5A; 64]), 7).tag(SUBSAMPLING, SHORT, &[2, 2]);
|
||||
assert!(decode(&build(true, false, &[junk], &[0]).bytes).is_err());
|
||||
// Coefficients and ReferenceBlackWhite that would divide by zero fall back to the defaults.
|
||||
let bad = strips(1, 1, 8, 3, 6, 1, &[255, 128, 128])
|
||||
.tag(SUBSAMPLING, SHORT, &[1, 1])
|
||||
.tag(YCBCR_COEFFICIENTS, RATIONAL, &[rational(0, 1), rational(0, 0), rational(5, 1)])
|
||||
.tag(REFERENCE_BLACK_WHITE, RATIONAL, &[rational(7, 1); 6]);
|
||||
let img = decode(&build(true, false, &[bad], &[0]).bytes).unwrap();
|
||||
assert_eq!(&img.to_rgba8()[..3], &[255, 255, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiled_jpeg_ycbcr_keeps_its_icc_profile() {
|
||||
// Two 16×16 tiles, each its own 4:2:0 JPEG stream, and an embedded profile, like a scanner's
|
||||
// tiled output.
|
||||
let (w, h, t) = (32u32, 16u32, 16u32);
|
||||
let px = card(w, h);
|
||||
let tile = |tx: u32| -> Vec<u8> {
|
||||
(0..t)
|
||||
.flat_map(|y| {
|
||||
let at = ((y * w + tx * t) * 3) as usize;
|
||||
px[at..at + (t * 3) as usize].to_vec()
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let streams = [jpeg(&tile(0), t, t), jpeg(&tile(1), t, t)];
|
||||
let icc = sample_icc(512);
|
||||
let dir = Dir { entries: base(w, h, 8, 3, 6), chunks: streams.to_vec(), tiled: true };
|
||||
let dir = compressed(dir, 7).tag(322, SHORT, &[u64::from(t)]).tag(323, SHORT, &[u64::from(t)]).tag(SUBSAMPLING, SHORT, &[2, 2]).bytes(
|
||||
ICC_PROFILE,
|
||||
UNDEFINED,
|
||||
&icc,
|
||||
);
|
||||
let img = decode(&build(true, false, &[dir], &[0]).bytes).unwrap();
|
||||
assert_eq!((img.width(), img.height(), img.layout()), (w, h, ChannelLayout::Rgb));
|
||||
assert_eq!(img.icc.as_deref(), Some(icc.as_slice()), "the embedded profile is kept");
|
||||
// The oracle: each tile's stream decoded by an independent decoder, placed side by side.
|
||||
let tiles: Vec<Vec<u8>> = streams.iter().map(|s| image::load_from_memory_with_format(s, image::ImageFormat::Jpeg).unwrap().to_rgb8().into_raw()).collect();
|
||||
let row = (t * 3) as usize;
|
||||
let want: Vec<u8> = (0..t as usize).flat_map(|y| [&tiles[0][y * row..(y + 1) * row], &tiles[1][y * row..(y + 1) * row]].concat()).collect();
|
||||
let worst = rgb(&img).iter().zip(&want).map(|(g, o)| g.abs_diff(*o)).max().unwrap();
|
||||
assert!(worst <= 2, "largest difference from the oracle: {worst}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn big_endian_sixteen_bit_ycbcr_reads_as_rgb() {
|
||||
// White, black, and red as full-range 16-bit YCbCr (Y 0.299·65535, Cb 32768 − 0.1687·65535, Cr max).
|
||||
let px = [65535u16, 32768, 32768, 0, 32768, 32768, 19595, 21710, 65535];
|
||||
let dir = strips(3, 1, 16, 3, 6, 1, &u16s(&px, false)).tag(SUBSAMPLING, SHORT, &[1, 1]);
|
||||
let file = build(false, false, &[dir], &[0]).bytes;
|
||||
assert_eq!(&file[..2], b"MM");
|
||||
let img = decode(&file).unwrap();
|
||||
assert_eq!((img.layout(), img.sample_type()), (ChannelLayout::Rgb, SampleType::U16));
|
||||
let n = img.to_normalized();
|
||||
let want = [1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0];
|
||||
assert!(n.iter().zip(want).all(|(g, w)| (g - w).abs() < 2.0 / 255.0), "{n:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn planar_ycbcr_reads_as_rgb_in_both_byte_orders() {
|
||||
// The pixels of the default-conversion test, one plane per component.
|
||||
let px = [0u8, 128, 128, 255, 128, 128, 128, 128, 128, 76, 85, 255];
|
||||
for little in [true, false] {
|
||||
let dir = Dir { entries: base(4, 1, 8, 3, 6), chunks: planar_strips(&px, 4, 1, 3, 1, 1), tiled: false }.tag(278, LONG, &[1]).tag(284, SHORT, &[2]).tag(
|
||||
SUBSAMPLING,
|
||||
SHORT,
|
||||
&[1, 1],
|
||||
);
|
||||
let img = decode(&build(little, false, &[dir], &[0]).bytes).unwrap();
|
||||
assert_eq!(rgb(&img), [0, 0, 0, 255, 255, 255, 128, 128, 128, 254, 0, 0], "little endian: {little}");
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ use std::borrow::Cow;
|
||||
use photocraft_doc::{Knockout, Layer, LayerContent};
|
||||
use photocraft_geom::Rect;
|
||||
|
||||
use crate::extents::recycle_mask;
|
||||
use crate::{Buffer, Ctx, composite_atop, composite_layer, effects, mask_vals, render_content};
|
||||
|
||||
/// Where a layer stack sits, for knockouts: at the document's top level, and/or inside an
|
||||
@@ -157,6 +158,7 @@ pub(crate) fn knockout(layer: &Layer, backdrop: &mut Buffer, target: &Buffer, cl
|
||||
if target.rect != backdrop.rect || shape.len() != backdrop.px.len() {
|
||||
// Never reached (targets are built over the backdrop's rect); compositing without the
|
||||
// knockout beats a wrong pixel or a panic.
|
||||
recycle_mask(Some(shape));
|
||||
draw(backdrop);
|
||||
return;
|
||||
}
|
||||
@@ -172,7 +174,10 @@ pub(crate) fn knockout(layer: &Layer, backdrop: &mut Buffer, target: &Buffer, cl
|
||||
}
|
||||
let knocked = (layer.opacity < 1.0).then(|| backdrop.clone());
|
||||
draw(backdrop);
|
||||
let Some(knocked) = knocked else { return };
|
||||
let Some(knocked) = knocked else {
|
||||
recycle_mask(Some(shape));
|
||||
return;
|
||||
};
|
||||
let rest = 1.0 - layer.opacity.clamp(0.0, 1.0);
|
||||
for (((p, b), k), s) in backdrop.px.iter_mut().zip(&before.px).zip(&knocked.px).zip(&shape) {
|
||||
if *s <= 0.0 {
|
||||
@@ -186,6 +191,7 @@ pub(crate) fn knockout(layer: &Layer, backdrop: &mut Buffer, target: &Buffer, cl
|
||||
let a = pm[3].clamp(0.0, 1.0);
|
||||
*p = if a > 0.0 { [(pm[0] / a).clamp(0.0, 1.0), (pm[1] / a).clamp(0.0, 1.0), (pm[2] / a).clamp(0.0, 1.0), a] } else { [0.0; 4] };
|
||||
}
|
||||
recycle_mask(Some(shape));
|
||||
}
|
||||
|
||||
/// Composite a clipping group's clipped layers atop `base` (the base's isolated content, Blend
|
||||
@@ -220,7 +226,12 @@ pub(crate) fn composite_clipped_individually(layer: &Layer, clipped: &[Layer], b
|
||||
let op = layer.opacity.clamp(0.0, 1.0);
|
||||
let clip: Vec<f32> = match render_content(layer, rect, cx) {
|
||||
Some(b) => b.px.iter().map(|p| p[3] * op).collect(),
|
||||
None => mask_vals(layer, rect, cx).unwrap_or_else(|| vec![1.0; n]).iter().map(|m| m * op).collect(),
|
||||
None => {
|
||||
let mv = mask_vals(layer, rect, cx);
|
||||
let clip = mv.as_ref().map_or_else(|| vec![op; n], |m| m.iter().map(|m| m * op).collect());
|
||||
recycle_mask(mv);
|
||||
clip
|
||||
}
|
||||
};
|
||||
if clip.len() != n {
|
||||
return;
|
||||
|
||||
@@ -395,7 +395,7 @@ fn shapeless_stroke_tile_skips_match_full_region_across_blending_settings() {
|
||||
.unwrap();
|
||||
let mut band_pixels = Vec::with_capacity(whole.px.len());
|
||||
for band in bands {
|
||||
band_pixels.extend(band.px);
|
||||
band_pixels.extend(band.into_px());
|
||||
}
|
||||
assert_eq!(whole.px, band_pixels, "banded: {depth:?}, {position:?}, {blend:?}, fill={fill}, blend_if={blend_if}");
|
||||
}
|
||||
@@ -639,7 +639,7 @@ fn transparency_shapes_off_ignores_masks_and_reaches_groups() {
|
||||
l.mask = Some(LayerMask { surface: photocraft_raster::Surface::with_default(PixelFormat::GRAY8, &[77.0 / 255.0]), ..LayerMask::reveal_all() });
|
||||
l.advanced.transparency_shapes = tsl;
|
||||
d.layers.push(l);
|
||||
render(&d, d.bounds()).px
|
||||
render(&d, d.bounds()).into_px()
|
||||
};
|
||||
let (off, on) = (render_with(false), render_with(true));
|
||||
assert!(off.iter().zip(&on).all(|(a, b)| close(*a, *b)), "{mode:?} ({depth:?}): a mask isn't the layer's transparency");
|
||||
@@ -691,13 +691,13 @@ fn transparency_shapes_off_keeps_the_base_alpha_under_a_clipped_knockout() {
|
||||
base.advanced.transparency_shapes = false;
|
||||
base.vector_mask = Some(photocraft_doc::vector::VectorMask { feather: 1.5, ..photocraft_doc::vector::VectorMask::new(square.clone()) });
|
||||
d.layers.push(base);
|
||||
let alone = render(&d, d.bounds()).px;
|
||||
let alone = render(&d, d.bounds()).into_px();
|
||||
let mut red = solid(&d, "red", full(&d), [1.0, 0.0, 0.0, 0.5]);
|
||||
red.clipped = true;
|
||||
let mut knock = knocker(&d, k);
|
||||
knock.clipped = true;
|
||||
d.layers.extend([red, knock]);
|
||||
let got = render(&d, d.bounds()).px;
|
||||
let got = render(&d, d.bounds()).into_px();
|
||||
let what = format!("{k:?} ({depth:?})");
|
||||
for (i, (g, a)) in got.iter().zip(&alone).enumerate() {
|
||||
if i % 12 < 6 {
|
||||
|
||||
@@ -34,6 +34,12 @@ fn cache() -> &'static Mutex<Cache> {
|
||||
C.get_or_init(|| Mutex::new(Cache { entries: HashMap::new(), sweep_at: MIN_SWEEP }))
|
||||
}
|
||||
|
||||
pub(crate) fn purge() {
|
||||
let mut cache = cache().lock().unwrap_or_else(|e| e.into_inner());
|
||||
cache.entries.clear();
|
||||
cache.sweep_at = MIN_SWEEP;
|
||||
}
|
||||
|
||||
/// `Surface::content_bounds`, cached per tile.
|
||||
pub fn content_bounds(s: &Surface) -> Rect {
|
||||
if s.tile_count() == 0 {
|
||||
@@ -54,8 +60,11 @@ pub fn content_bounds(s: &Surface) -> Rect {
|
||||
let b = match hit {
|
||||
Some(b) => b,
|
||||
None => {
|
||||
let generation = photocraft_raster::spill::read_error_generation();
|
||||
let b = t.content_bounds(&dp);
|
||||
c.entries.insert(key, Entry { tile: Arc::downgrade(t), default: dp.clone().into_boxed_slice(), bounds: b });
|
||||
if photocraft_raster::spill::check_since(generation).is_ok() && t.scratch_health().is_ok() {
|
||||
c.entries.insert(key, Entry { tile: Arc::downgrade(t), default: dp.clone().into_boxed_slice(), bounds: b });
|
||||
}
|
||||
b
|
||||
}
|
||||
};
|
||||
|
||||
@@ -27,6 +27,7 @@ use photocraft_doc::adjust::ToneSpace;
|
||||
use photocraft_doc::{Adjustment, Document, Knockout, Layer, LayerContent};
|
||||
use photocraft_geom::Rect;
|
||||
|
||||
use super::extents::recycle_mask;
|
||||
use super::{Ctx, RENDER_TILE, advanced, empty_in, mask_vals, pattern};
|
||||
|
||||
#[path = "cmyk_split.rs"]
|
||||
@@ -355,6 +356,7 @@ fn apply_mask(l: &Layer, buf: &mut CmykBuffer, cx: &Ctx) {
|
||||
for (p, k) in buf.px.iter_mut().zip(&m) {
|
||||
p[4] *= clean(*k).max(0.0);
|
||||
}
|
||||
recycle_mask(Some(m));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -425,6 +427,7 @@ fn mix_adjusted(l: &Layer, base: &mut CmykBuffer, adjusted: &CmykBuffer, cx: &Ct
|
||||
*v = if k >= 1.0 { b } else { *v + (b - *v) * k };
|
||||
}
|
||||
}
|
||||
recycle_mask(mv);
|
||||
}
|
||||
|
||||
/// Rounds to the document's levels after an adjustment layer, as the compositor does (on the
|
||||
@@ -534,6 +537,7 @@ fn composite_layer(l: &Layer, clipped: &[Layer], backdrop: &mut CmykBuffer, cx:
|
||||
[0.0; 5]
|
||||
};
|
||||
}
|
||||
recycle_mask(mv);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::content::{channel_weights, empty_in, recycle_buffer, render_content, restore_channels, scratch_buffer, surface_to_buffer};
|
||||
use super::content::{channel_weights, empty_in, render_content, restore_channels, surface_to_buffer};
|
||||
use super::ctx::Ctx;
|
||||
use super::extents::{effects_frame, mask_k, mask_vals, shapeless_stroke_bounds};
|
||||
use super::extents::{effects_frame, mask_k, mask_vals, recycle_mask, shapeless_stroke_bounds};
|
||||
use super::fx_cache::effect_maps;
|
||||
use super::{BlendMode, Buffer, Fill, Layer, LayerContent, Rect, Surface, advanced, blend, effects, psblend, shape_split};
|
||||
|
||||
@@ -339,6 +339,7 @@ pub(crate) fn composite_pixel_layer(layer: &Layer, surf: &Surface, backdrop: &mu
|
||||
}
|
||||
blend_span_fill(dst, &row, rect.x0, y, layer.blend, layer.opacity, layer.fill_opacity, gamma);
|
||||
}
|
||||
recycle_mask(mv);
|
||||
}
|
||||
|
||||
pub(crate) fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer, cx: &Ctx, scope: advanced::Scope) {
|
||||
@@ -389,6 +390,7 @@ pub(crate) fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop:
|
||||
[0.0; 4]
|
||||
};
|
||||
}
|
||||
recycle_mask(mv);
|
||||
}
|
||||
// A Frame's stroke over the frame's content, at the group's opacity.
|
||||
if let Some(stroke) = shape_split::frame_stroke(layer, cx.canvas, cx.depth) {
|
||||
@@ -425,9 +427,10 @@ pub(crate) fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop:
|
||||
if let LayerContent::Adjustment(adj) = &layer.content {
|
||||
let mv = mask_vals(layer, rect, cx);
|
||||
if mv.as_ref().is_some_and(|values| values.iter().all(|&value| opacity * value <= 0.0)) {
|
||||
recycle_mask(mv);
|
||||
return;
|
||||
}
|
||||
let mut adjusted = scratch_buffer(rect);
|
||||
let mut adjusted = Buffer::transparent(rect);
|
||||
adjusted.px.copy_from_slice(&backdrop.px);
|
||||
cx.apply_adjustment(adj, &mut adjusted);
|
||||
// Clipped layers onto an adjustment are uncommon; they composite atop the adjusted result.
|
||||
@@ -455,7 +458,7 @@ pub(crate) fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop:
|
||||
None => blend_back(&mut backdrop.px, &adjusted.px, std::iter::repeat(k), layer.blend, fill),
|
||||
}
|
||||
}
|
||||
recycle_buffer(adjusted);
|
||||
recycle_mask(mv);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -469,6 +472,7 @@ pub(crate) fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop:
|
||||
if let (Some(m), Some(b)) = (&hide, &before) {
|
||||
advanced::apply_hiding_mask(backdrop, b, m);
|
||||
}
|
||||
recycle_mask(hide);
|
||||
return;
|
||||
}
|
||||
if let Some((mut content, stroke, mask)) = shape_parts(layer, clipped, rect, cx) {
|
||||
@@ -478,6 +482,7 @@ pub(crate) fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop:
|
||||
p[3] *= mask_k(&mask, i);
|
||||
}
|
||||
blend_layer_into(backdrop, &content, layer, cx, 1.0);
|
||||
recycle_mask(mask);
|
||||
return;
|
||||
}
|
||||
if !matches!(layer.content, LayerContent::Group(_) | LayerContent::Fill(_) | LayerContent::Adjustment(_))
|
||||
@@ -512,7 +517,6 @@ pub(crate) fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop:
|
||||
let Some(mut content) = render_content(layer, rect, cx) else { return };
|
||||
advanced::composite_clipped(clipped, &mut content, cx);
|
||||
blend_layer_into(backdrop, &content, layer, cx, text_gamma(layer));
|
||||
recycle_buffer(content);
|
||||
}
|
||||
|
||||
/// A layer with effects (and its clipping group) onto `backdrop`.
|
||||
@@ -521,7 +525,7 @@ fn composite_layer_effects(layer: &Layer, clipped: &[Layer], backdrop: &mut Buff
|
||||
// Neighbourhoods are already captured by the full-region effect maps;
|
||||
// content and effect application only need the output rectangle.
|
||||
// A stroked shape's vector stroke stays above its clipped layers and interior effects.
|
||||
let (mut content, vstroke) = match split_parts(layer, rect, cx) {
|
||||
let (mut content, parts) = match split_parts(layer, rect, cx) {
|
||||
Some((fill, stroke, mask)) => (fill, Some((stroke, mask))),
|
||||
None => {
|
||||
let Some(content) = render_content(layer, rect, cx) else { return };
|
||||
@@ -529,9 +533,12 @@ fn composite_layer_effects(layer: &Layer, clipped: &[Layer], backdrop: &mut Buff
|
||||
}
|
||||
};
|
||||
advanced::composite_clipped(clipped, &mut content, cx);
|
||||
let vstroke = vstroke.as_ref().map(|(s, m)| effects::VectorStroke { stroke: s, mask: m.as_deref() });
|
||||
let vstroke = parts.as_ref().map(|(s, m)| effects::VectorStroke { stroke: s, mask: m.as_deref() });
|
||||
let maps = effect_maps(layer, rect, cx);
|
||||
effects::composite_with_effects_prepared(layer, &content, backdrop, &maps, effects_frame(layer, cx.canvas), cx.patterns, vstroke);
|
||||
if let Some((_, mask)) = parts {
|
||||
recycle_mask(mask);
|
||||
}
|
||||
}
|
||||
|
||||
/// The coverage-mixing gamma of a layer: the text blending gamma
|
||||
@@ -605,6 +612,7 @@ fn composite_atop_any(layer: &Layer, base: &mut Buffer, cx: &Ctx) {
|
||||
p[c] += (bl[c] - p[c]) * k;
|
||||
}
|
||||
}
|
||||
recycle_mask(mv);
|
||||
return;
|
||||
}
|
||||
if effects::has_effects(layer) {
|
||||
@@ -615,21 +623,28 @@ fn composite_atop_any(layer: &Layer, base: &mut Buffer, cx: &Ctx) {
|
||||
let hide = advanced::hiding_mask(layer, rect, cx);
|
||||
let src = advanced::effects_source(layer);
|
||||
let layer = src.as_ref();
|
||||
let (content, vstroke) = match split_parts(layer, rect, cx) {
|
||||
let (content, parts) = match split_parts(layer, rect, cx) {
|
||||
Some((fill, stroke, mask)) => (fill, Some((stroke, mask))),
|
||||
None => {
|
||||
let Some(content) = render_content(layer, rect, cx) else { return };
|
||||
let Some(content) = render_content(layer, rect, cx) else {
|
||||
recycle_mask(hide);
|
||||
return;
|
||||
};
|
||||
(content, None)
|
||||
}
|
||||
};
|
||||
let vstroke = vstroke.as_ref().map(|(s, m)| effects::VectorStroke { stroke: s, mask: m.as_deref() });
|
||||
let vstroke = parts.as_ref().map(|(s, m)| effects::VectorStroke { stroke: s, mask: m.as_deref() });
|
||||
let mut opaque = Buffer { rect, px: base.px.iter().map(|p| [p[0], p[1], p[2], 1.0]).collect() };
|
||||
let maps = effect_maps(layer, rect, cx);
|
||||
effects::composite_with_effects_prepared(layer, &content, &mut opaque, &maps, effects_frame(layer, cx.canvas), cx.patterns, vstroke);
|
||||
if let Some((_, mask)) = parts {
|
||||
recycle_mask(mask);
|
||||
}
|
||||
if let Some(m) = &hide {
|
||||
let plain = Buffer { rect, px: base.px.iter().map(|p| [p[0], p[1], p[2], 1.0]).collect() };
|
||||
advanced::apply_hiding_mask(&mut opaque, &plain, m);
|
||||
}
|
||||
recycle_mask(hide);
|
||||
for (p, o) in base.px.iter_mut().zip(&opaque.px) {
|
||||
if p[3] > 0.0 {
|
||||
*p = [o[0], o[1], o[2], p[3]];
|
||||
@@ -689,6 +704,7 @@ fn blend_layer_into(backdrop: &mut Buffer, src: &Buffer, layer: &Layer, cx: &Ctx
|
||||
let alpha = (s[3] / m).clamp(0.0, 1.0);
|
||||
*b = psblend::composite_fill(mode, *b, [s[0], s[1], s[2], m], opacity, fill * alpha, gamma);
|
||||
}
|
||||
recycle_mask(mv);
|
||||
}
|
||||
|
||||
/// [`blend_into_g`] for a layer's own `opacity` and `fill`: Fill is part of the blend for
|
||||
|
||||
@@ -1,63 +1,29 @@
|
||||
use super::composite::{adjustment_quantum, blend_into, composite_stack};
|
||||
use super::ctx::Ctx;
|
||||
use super::extents::{effect_shape, effect_source_bounds, mask_vals, shapeless_stroke_bounds, transparent_outside};
|
||||
use super::extents::{effect_shape, effect_source_bounds, mask_vals, recycle_mask, shapeless_stroke_bounds, transparent_outside};
|
||||
use super::fx_cache::effect_maps;
|
||||
use super::{BlendMode, Buffer, Document, Fill, Layer, LayerContent, Pattern, Rect, Surface, bounds, effects, gradient_fill, pattern, shape_split};
|
||||
|
||||
/// Tile size for parallel rendering (tile results are independent).
|
||||
pub const RENDER_TILE: i32 = 256;
|
||||
|
||||
// One tile of scratch per worker; nested rendering takes its own buffer.
|
||||
thread_local! {
|
||||
static SCRATCH: std::cell::RefCell<Vec<[f32; 4]>> = const { std::cell::RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
pub(crate) fn scratch_buffer(rect: Rect) -> Buffer {
|
||||
if rect.width() as usize * rect.height() as usize > (RENDER_TILE * RENDER_TILE) as usize {
|
||||
return Buffer::transparent(rect);
|
||||
}
|
||||
let mut px = SCRATCH.with(|slot| std::mem::take(&mut *slot.borrow_mut()));
|
||||
px.resize(rect.width() as usize * rect.height() as usize, [0.0; 4]);
|
||||
Buffer { rect, px }
|
||||
}
|
||||
|
||||
pub(crate) fn recycle_buffer(buf: Buffer) {
|
||||
if buf.px.capacity() <= (RENDER_TILE * RENDER_TILE) as usize {
|
||||
SCRATCH.with(|slot| {
|
||||
let mut slot = slot.borrow_mut();
|
||||
if slot.capacity() < buf.px.capacity() {
|
||||
*slot = buf.px;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[test]
|
||||
fn scratch_reuse_preserves_pixels_and_bounds_retention() {
|
||||
SCRATCH.with(|slot| slot.borrow_mut().clear());
|
||||
let rect = Rect::new(-2, -3, 18, 11);
|
||||
fn scratch_reuse_preserves_pixels() {
|
||||
// 256 × 256 pixels of [f32; 4] (1 MiB): over `MIN_POOLED_BYTES`, so the pool keeps it.
|
||||
let rect = Rect::new(-2, -3, 254, 253);
|
||||
for fmt in [photocraft_color::PixelFormat::RGBA8, photocraft_color::PixelFormat::RGBA16, photocraft_color::PixelFormat::RGBA32F] {
|
||||
let s = Surface::with_default(fmt, &[0.2, 0.4, 0.6, 0.8]);
|
||||
let buf = surface_to_scratch(&s, rect);
|
||||
let mut buf = surface_to_scratch(&s, rect);
|
||||
assert_eq!(buf, surface_to_buffer(&s, rect));
|
||||
let ptr = buf.px.as_ptr();
|
||||
recycle_buffer(buf);
|
||||
let reused = surface_to_scratch(&Surface::new(fmt), rect);
|
||||
assert_eq!(reused.px.as_ptr(), ptr);
|
||||
buf.px.fill([1.0; 4]);
|
||||
// A dropped buffer goes back to the pool; the next one must come out cleared.
|
||||
drop(buf);
|
||||
let reused = Buffer::transparent(rect);
|
||||
assert!(reused.px.iter().all(|p| p == &[0.0; 4]));
|
||||
let nested = scratch_buffer(rect);
|
||||
let nested = Buffer::transparent(rect);
|
||||
assert_ne!(nested.px.as_ptr(), reused.px.as_ptr());
|
||||
recycle_buffer(nested);
|
||||
recycle_buffer(reused);
|
||||
}
|
||||
let cached = SCRATCH.with(|slot| slot.borrow().as_ptr());
|
||||
let huge = scratch_buffer(Rect::new(0, 0, RENDER_TILE + 1, RENDER_TILE));
|
||||
recycle_buffer(huge);
|
||||
SCRATCH.with(|slot| {
|
||||
assert_eq!(slot.borrow().as_ptr(), cached);
|
||||
assert!(slot.borrow().capacity() <= (RENDER_TILE * RENDER_TILE) as usize);
|
||||
});
|
||||
}
|
||||
|
||||
/// Render a layer's own content (no blending into the backdrop yet) into an isolated buffer.
|
||||
@@ -93,6 +59,7 @@ pub(crate) fn render_content(layer: &Layer, rect: Rect, cx: &Ctx) -> Option<Buff
|
||||
for (p, k) in buf.px.iter_mut().zip(&m) {
|
||||
p[3] *= k;
|
||||
}
|
||||
recycle_mask(Some(m));
|
||||
}
|
||||
// A Frame's stroke lies over its masked content; the frame doesn't clip it.
|
||||
if let Some(stroke) = shape_split::frame_stroke(layer, cx.canvas, cx.depth) {
|
||||
@@ -125,13 +92,13 @@ pub fn stroke_frame(doc: &Document, layer: &Layer, st: &photocraft_doc::StrokeFx
|
||||
}
|
||||
|
||||
pub fn surface_to_buffer(s: &Surface, rect: Rect) -> Buffer {
|
||||
let mut px = vec![[0.0f32; 4]; rect.width() as usize * rect.height() as usize];
|
||||
s.read_rgba_into(rect, &mut px);
|
||||
Buffer { rect, px }
|
||||
let mut buf = Buffer::transparent(rect);
|
||||
s.read_rgba_into(rect, &mut buf.px);
|
||||
buf
|
||||
}
|
||||
|
||||
fn surface_to_scratch(s: &Surface, rect: Rect) -> Buffer {
|
||||
let mut buf = scratch_buffer(rect);
|
||||
let mut buf = Buffer::transparent(rect);
|
||||
s.read_rgba_into(rect, &mut buf.px);
|
||||
buf
|
||||
}
|
||||
|
||||
@@ -134,6 +134,7 @@ pub(crate) fn effect_shape(layer: &Layer, rect: Rect, cx: &Ctx) -> Vec<f32> {
|
||||
out[i] = v * mask_k(&mv, i);
|
||||
}
|
||||
}
|
||||
recycle_mask(mv);
|
||||
out
|
||||
}
|
||||
|
||||
@@ -283,13 +284,13 @@ pub(crate) fn mask_vals(layer: &Layer, rect: Rect, cx: &Ctx) -> Option<Vec<f32>>
|
||||
if masks::has_feather(layer)
|
||||
&& let Some(s) = masks::combined_mask(layer, cx.canvas)
|
||||
{
|
||||
let mut v = Vec::new();
|
||||
let mut v = photocraft_raster::scratch::take_cap(rect.width() as usize * rect.height() as usize);
|
||||
s.read_region_into(rect, &mut v);
|
||||
return Some(v);
|
||||
}
|
||||
let vector = layer.vector_mask.as_ref().map(|vm| cx.vector_masks.values(vm, rect));
|
||||
let Some(m) = layer.mask.as_ref() else { return vector };
|
||||
let mut v = Vec::new();
|
||||
let mut v = photocraft_raster::scratch::take_cap(rect.width() as usize * rect.height() as usize);
|
||||
m.values_into(rect, &mut v);
|
||||
if let Some(vm) = vector {
|
||||
for (a, b) in v.iter_mut().zip(vm) {
|
||||
@@ -299,6 +300,13 @@ pub(crate) fn mask_vals(layer: &Layer, rect: Rect, cx: &Ctx) -> Option<Vec<f32>>
|
||||
Some(v)
|
||||
}
|
||||
|
||||
/// Hand a [`mask_vals`] result back to the scratch pool once its tile is done.
|
||||
pub(crate) fn recycle_mask(m: Option<Vec<f32>>) {
|
||||
if let Some(v) = m {
|
||||
photocraft_raster::scratch::give(v);
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn mask_k(m: &Option<Vec<f32>>, i: usize) -> f32 {
|
||||
m.as_ref().map_or(1.0, |v| v[i])
|
||||
|
||||
@@ -122,6 +122,21 @@ pub fn purge_effect_cache() -> usize {
|
||||
freed
|
||||
}
|
||||
|
||||
/// Discard derived pixels and their source pins once after each new scratch read failure.
|
||||
/// This lets closing the affected document release its failed tiles instead of keeping them
|
||||
/// in a cache. Caches never admit results built from unreadable tiles, so healthy documents'
|
||||
/// caches are not purged again on every call while one document stays unreadable.
|
||||
pub fn discard_unreadable_pixel_caches() {
|
||||
static SEEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
let generation = photocraft_raster::spill::read_error_generation();
|
||||
if SEEN.swap(generation, std::sync::atomic::Ordering::AcqRel) != generation && photocraft_raster::spill::any_failed() {
|
||||
purge_effect_cache();
|
||||
super::bounds::purge();
|
||||
super::masks::purge();
|
||||
super::shape_split::purge();
|
||||
}
|
||||
}
|
||||
|
||||
fn fx_cache() -> &'static std::sync::Mutex<FxCache> {
|
||||
static C: std::sync::OnceLock<std::sync::Mutex<FxCache>> = std::sync::OnceLock::new();
|
||||
C.get_or_init(|| std::sync::Mutex::new(FxCache { map: Default::default(), order: Default::default(), bytes: 0 }))
|
||||
@@ -218,13 +233,22 @@ pub(crate) fn effect_maps(layer: &Layer, rect: Rect, cx: &Ctx) -> std::sync::Arc
|
||||
}
|
||||
|
||||
fn build_fx_slot_maps(layer: &Layer, region: Rect, key: u64, cx: &Ctx, slot: &FxSlot) -> std::sync::Arc<effects::FxMaps> {
|
||||
let generation = photocraft_raster::spill::read_error_generation();
|
||||
let shape: Vec<f32> = if region.is_empty() { Vec::new() } else { effect_shape(layer, region, cx) };
|
||||
let maps = effects::build_maps_prepared(layer, shape, region, &cx.light, &texture_ctx(layer, region, cx), cx.patterns);
|
||||
let mut pin = Vec::new();
|
||||
layer_identity(layer, &mut std::collections::hash_map::DefaultHasher::new(), &mut |t| pin.push(std::sync::Arc::downgrade(t)));
|
||||
let mut readable = true;
|
||||
layer_identity(layer, &mut std::collections::hash_map::DefaultHasher::new(), &mut |t| {
|
||||
readable &= t.scratch_health().is_ok();
|
||||
pin.push(std::sync::Arc::downgrade(t));
|
||||
});
|
||||
// The maps, plus what each reserved tile address keeps allocated.
|
||||
let bytes = maps.bytes().saturating_add(pin.len().saturating_mul(std::mem::size_of::<photocraft_raster::Tile>() + 2 * std::mem::size_of::<usize>()));
|
||||
let maps = std::sync::Arc::new(maps);
|
||||
if !readable || photocraft_raster::spill::check_since(generation).is_err() {
|
||||
// Built from a scratch placeholder: use it for this frame, never cache it.
|
||||
return maps;
|
||||
}
|
||||
if slot.set(FxEntry { maps: maps.clone(), _pin: pin, bytes }).is_ok() {
|
||||
// Counted exactly once, by the build that filled the slot, and only while the
|
||||
// slot is still cached (eviction may have dropped it meanwhile).
|
||||
|
||||
@@ -33,6 +33,7 @@ pub mod proxy;
|
||||
pub mod psblend;
|
||||
pub mod shape_split;
|
||||
mod uniform;
|
||||
pub mod viewport;
|
||||
|
||||
use photocraft_color::blend::BlendMode;
|
||||
use photocraft_doc::{Document, Fill, Layer, LayerContent, Pattern};
|
||||
@@ -68,7 +69,9 @@ pub use extents::{
|
||||
};
|
||||
#[cfg(test)]
|
||||
use extents::{mask_k, shapeless_stroke_bounds, trim_to_painted};
|
||||
pub use fx_cache::{DEFAULT_FX_CACHE_BUDGET, effect_cache_budget, effect_cache_bytes, purge_effect_cache, set_effect_cache_budget};
|
||||
pub use fx_cache::{
|
||||
DEFAULT_FX_CACHE_BUDGET, discard_unreadable_pixel_caches, effect_cache_budget, effect_cache_bytes, purge_effect_cache, set_effect_cache_budget,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use fx_cache::{effect_maps, effect_region, fx_region_bytes};
|
||||
pub use reduced::{PROXY_THUMBNAIL_PIXELS, render_reduced, render_reduced_damage, render_reduced_rect, thumbnail, thumbnail_buffer, thumbnail_size};
|
||||
@@ -84,18 +87,39 @@ pub use render::{
|
||||
};
|
||||
|
||||
/// Straight-alpha RGBA float buffer covering a rectangle.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
///
|
||||
/// The pixel storage comes from, and goes back to, the scratch pool.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct Buffer {
|
||||
pub rect: Rect,
|
||||
pub px: Vec<[f32; 4]>,
|
||||
}
|
||||
|
||||
impl Clone for Buffer {
|
||||
fn clone(&self) -> Self {
|
||||
let mut px = photocraft_raster::scratch::take(self.px.len(), [0.0; 4]);
|
||||
px.copy_from_slice(&self.px);
|
||||
Self { rect: self.rect, px }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Buffer {
|
||||
fn drop(&mut self) {
|
||||
photocraft_raster::scratch::give(std::mem::take(&mut self.px));
|
||||
}
|
||||
}
|
||||
|
||||
impl Buffer {
|
||||
pub fn transparent(rect: Rect) -> Self {
|
||||
Self { rect, px: vec![[0.0; 4]; rect.width() as usize * rect.height() as usize] }
|
||||
Self::filled(rect, [0.0; 4])
|
||||
}
|
||||
pub fn filled(rect: Rect, c: [f32; 4]) -> Self {
|
||||
Self { rect, px: vec![c; rect.width() as usize * rect.height() as usize] }
|
||||
Self { rect, px: photocraft_raster::scratch::take(rect.width() as usize * rect.height() as usize, c) }
|
||||
}
|
||||
/// The pixels, taken out of the buffer (it can't be destructured: it returns them to the
|
||||
/// pool on drop).
|
||||
pub fn into_px(mut self) -> Vec<[f32; 4]> {
|
||||
std::mem::take(&mut self.px)
|
||||
}
|
||||
#[inline]
|
||||
pub fn get(&self, x: i32, y: i32) -> [f32; 4] {
|
||||
|
||||
@@ -81,6 +81,12 @@ fn cache() -> &'static Mutex<Cache> {
|
||||
C.get_or_init(|| Mutex::new(Cache::default()))
|
||||
}
|
||||
|
||||
pub(crate) fn purge() {
|
||||
let mut c = cache().lock().unwrap_or_else(|e| e.into_inner());
|
||||
c.clear();
|
||||
c.in_flight.clear();
|
||||
}
|
||||
|
||||
/// Entries kept (least recently used dropped first).
|
||||
const CAPACITY: usize = 64;
|
||||
|
||||
@@ -116,7 +122,11 @@ fn lookup(c: &Mutex<Cache>, k: u64) -> CachedMask {
|
||||
fn finish_mask(c: &Mutex<Cache>, k: u64, slot: &Arc<OnceLock<Surface>>, generation: u64, build: impl FnOnce() -> (Surface, Option<Surface>)) -> Surface {
|
||||
slot.get_or_init(|| {
|
||||
// Never hold the global cache mutex while rasterizing or feathering.
|
||||
let read_generation = photocraft_raster::spill::read_error_generation();
|
||||
let (surface, pixel_input) = build();
|
||||
if photocraft_raster::spill::check_since(read_generation).is_err() || pixel_input.as_ref().is_some_and(|p| p.scratch_health().is_err()) {
|
||||
return surface;
|
||||
}
|
||||
let bytes = entry_bytes(&surface, pixel_input.as_ref());
|
||||
let mut c = c.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if c.in_flight.get(&k).and_then(Weak::upgrade).is_some_and(|active| Arc::ptr_eq(&active, slot)) {
|
||||
@@ -135,7 +145,7 @@ fn cached_mask(c: &Mutex<Cache>, k: u64, build: impl FnOnce() -> (Surface, Optio
|
||||
}
|
||||
|
||||
fn surface_bytes(surface: &Surface) -> usize {
|
||||
surface.tiles().fold(surface.default_bytes().len(), |bytes, (_, tile)| bytes.saturating_add(tile.bytes().len()))
|
||||
surface.tiles().fold(surface.default_bytes().len(), |bytes, (_, tile)| bytes.saturating_add(tile.len()))
|
||||
}
|
||||
|
||||
fn entry_bytes(surface: &Surface, pixel_input: Option<&Surface>) -> usize {
|
||||
|
||||
@@ -2,6 +2,7 @@ use super::content::RENDER_TILE;
|
||||
use super::ctx::Ctx;
|
||||
use super::render::{band_rows_for, render, render_bands, render_tiled_with};
|
||||
use super::{Buffer, Document, Rect, Rgba8Image, pattern, proxy, uniform};
|
||||
use photocraft_raster::Interrupt;
|
||||
|
||||
/// Documents above this many pixels get thumbnails from a proxy (see [`proxy`]) when that is
|
||||
/// faithful; smaller ones are reduced from the exact composite.
|
||||
@@ -101,23 +102,66 @@ pub(crate) fn render_reduced_with(
|
||||
uniform: bool,
|
||||
stats: &mut ReducedStats,
|
||||
) -> Buffer {
|
||||
match render_reduced_cells(doc, b, Cells::Fit(w, h), damage, band_rows, uniform, stats, Interrupt::NONE) {
|
||||
Ok(buf) => buf,
|
||||
// Only an interrupt fails a reduction, and this one has none.
|
||||
Err(_) => Buffer::transparent(Rect::EMPTY),
|
||||
}
|
||||
}
|
||||
|
||||
/// How a reduced render maps document pixels of its area to output pixels.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) enum Cells {
|
||||
/// `w`×`h` output pixels spread evenly over the area ([`render_reduced`]).
|
||||
Fit(u32, u32),
|
||||
/// Output pixel `t` covers the area's pixels `[t·k, (t+1)·k)`, clipped to the area: a
|
||||
/// globally aligned reduction by `k` when the area starts on a multiple of `k`
|
||||
/// ([`crate::viewport::render_page`]).
|
||||
Factor(u32),
|
||||
}
|
||||
|
||||
/// [`render_reduced_with`] on a cell grid, checking `interrupt` between bands.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn render_reduced_cells(
|
||||
doc: &Document,
|
||||
b: Rect,
|
||||
cells: Cells,
|
||||
damage: Option<Rect>,
|
||||
band_rows: i32,
|
||||
uniform: bool,
|
||||
stats: &mut ReducedStats,
|
||||
interrupt: Interrupt<'_>,
|
||||
) -> Result<Buffer, String> {
|
||||
let (fw, fh) = (b.width() as usize, b.height() as usize);
|
||||
let (w, h) = (w.clamp(1, b.width().max(1)) as usize, h.clamp(1, b.height().max(1)) as usize);
|
||||
let (w, h, k) = match cells {
|
||||
Cells::Fit(w, h) => (w.clamp(1, b.width().max(1)) as usize, h.clamp(1, b.height().max(1)) as usize, None),
|
||||
Cells::Factor(k) => {
|
||||
let k = k.max(1);
|
||||
(b.width().div_ceil(k).max(1) as usize, b.height().div_ceil(k).max(1) as usize, Some(k as usize))
|
||||
}
|
||||
};
|
||||
let full = Rect::from_xywh(0, 0, w as u32, h as u32);
|
||||
if fw == 0 || fh == 0 {
|
||||
return Buffer::transparent(if damage.is_some() { Rect::EMPTY } else { full });
|
||||
return Ok(Buffer::transparent(if damage.is_some() { Rect::EMPTY } else { full }));
|
||||
}
|
||||
// Output column / row of each document column / row: output pixel t covers [t·f/n, (t+1)·f/n).
|
||||
let span = |t: usize, f: usize, n: usize| (t * f / n, ((t + 1) * f / n).max(t * f / n + 1).min(f));
|
||||
// Output column / row of each document column / row: output pixel t covers [t·f/n, (t+1)·f/n)
|
||||
// (or [t·k, (t+1)·k) on a factor grid).
|
||||
let span = |t: usize, f: usize, n: usize| match k {
|
||||
Some(k) => ((t * k).min(f), ((t + 1) * k).min(f)),
|
||||
None => (t * f / n, ((t + 1) * f / n).max(t * f / n + 1).min(f)),
|
||||
};
|
||||
// The output pixel whose span holds document column / row `x` (spans are contiguous: n <= f).
|
||||
let cell = |x: usize, f: usize, n: usize| ((x + 1) * n).saturating_sub(1) / f;
|
||||
let cell = |x: usize, f: usize, n: usize| match k {
|
||||
Some(k) => x / k,
|
||||
None => ((x + 1) * n).saturating_sub(1) / f,
|
||||
};
|
||||
// Output pixels to produce and the document area they cover.
|
||||
let out = match damage {
|
||||
None => full,
|
||||
Some(d) => {
|
||||
let d = d.intersect(&b);
|
||||
if d.is_empty() {
|
||||
return Buffer::transparent(Rect::EMPTY);
|
||||
return Ok(Buffer::transparent(Rect::EMPTY));
|
||||
}
|
||||
let (x0, x1) = ((d.x0 - b.x0) as usize, (d.x1 - b.x0) as usize);
|
||||
let (y0, y1) = ((d.y0 - b.y0) as usize, (d.y1 - b.y0) as usize);
|
||||
@@ -131,7 +175,7 @@ pub(crate) fn render_reduced_with(
|
||||
if (w, h) == (fw, fh) {
|
||||
let mut px = render(doc, src);
|
||||
px.rect = out;
|
||||
return px;
|
||||
return Ok(px);
|
||||
}
|
||||
let map = |f: usize, n: usize| {
|
||||
let mut m = vec![0u32; f];
|
||||
@@ -200,6 +244,7 @@ pub(crate) fn render_reduced_with(
|
||||
let band_h = band_rows_for(src.width(), band_rows).max(RENDER_TILE) as usize;
|
||||
let mut y = grid(sy0, b.y0);
|
||||
while y < sy1 as i64 {
|
||||
interrupt.check().map_err(|e| e.to_string())?;
|
||||
let y1 = (y + band_h as i64).min(sy1 as i64);
|
||||
let tiles: Vec<(Rect, Rect)> = (y..y1)
|
||||
.step_by(t)
|
||||
@@ -255,7 +300,8 @@ pub(crate) fn render_reduced_with(
|
||||
y = y1;
|
||||
}
|
||||
} else {
|
||||
let ok: Result<(), ()> = render_bands(doc, src, band_rows, |band| {
|
||||
render_bands(doc, src, band_rows, |band| -> Result<(), String> {
|
||||
interrupt.check().map_err(|e| e.to_string())?;
|
||||
stats.composited += band.px.len() as u64;
|
||||
let (by0, by1) = ((band.rect.y0 - b.y0) as usize, (band.rect.y1 - b.y0) as usize);
|
||||
let sw = sx1 - sx0;
|
||||
@@ -265,8 +311,7 @@ pub(crate) fn render_reduced_with(
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
});
|
||||
debug_assert!(ok.is_ok());
|
||||
})?;
|
||||
}
|
||||
let col_w: Vec<usize> = (0..ow)
|
||||
.map(|x| {
|
||||
@@ -296,5 +341,6 @@ pub(crate) fn render_reduced_with(
|
||||
for (row, out) in acc.chunks_mut(ow).enumerate() {
|
||||
norm_row(row, out);
|
||||
}
|
||||
Buffer { rect: out, px: acc }
|
||||
interrupt.check().map_err(|e| e.to_string())?;
|
||||
Ok(Buffer { rect: out, px: acc })
|
||||
}
|
||||
@@ -229,14 +229,12 @@ pub fn composite_to_surface(doc: &Document, rect: Rect, fmt: photocraft_color::P
|
||||
let mut s = Surface::new(fmt);
|
||||
let n = fmt.channels();
|
||||
let empty = s.default_pixel();
|
||||
let mut data = Vec::new();
|
||||
let _ = render_bands(doc, rect, 0, |band| -> Result<(), ()> {
|
||||
let band = match background {
|
||||
Some(bg) => band.over_background(bg),
|
||||
None => band,
|
||||
};
|
||||
data.clear();
|
||||
data.resize(band.px.len() * n, 0.0);
|
||||
let mut data = photocraft_raster::scratch::take(band.px.len() * n, 0.0f32);
|
||||
photocraft_color::convert::with_cmyk_space(space.as_ref(), || {
|
||||
for (p, out) in band.px.iter().zip(data.chunks_exact_mut(n)) {
|
||||
if fmt.alpha && (p[3] <= 0.0 || p[3].is_nan()) && empty.len() == n {
|
||||
@@ -247,6 +245,7 @@ pub fn composite_to_surface(doc: &Document, rect: Rect, fmt: photocraft_color::P
|
||||
}
|
||||
});
|
||||
s.write_region(band.rect, &data);
|
||||
photocraft_raster::scratch::give(data);
|
||||
Ok(())
|
||||
});
|
||||
s.prune();
|
||||
|
||||
@@ -21,6 +21,10 @@ fn cache() -> &'static Mutex<(HashMap<u64, Entry>, u64)> {
|
||||
C.get_or_init(|| Mutex::new((HashMap::new(), 0)))
|
||||
}
|
||||
|
||||
pub(crate) fn purge() {
|
||||
cache().lock().unwrap_or_else(|e| e.into_inner()).0.clear();
|
||||
}
|
||||
|
||||
const CAPACITY: usize = 32;
|
||||
/// Most pixel bytes the split parts may hold (#4530): each entry is two canvas-sized surfaces,
|
||||
/// so 32 of them at 24 MP could hold several GB outside every memory budget.
|
||||
@@ -32,7 +36,7 @@ const BUDGET: usize = 8 << 20;
|
||||
|
||||
/// Pixel bytes of one entry's (fill, stroke) parts (the pinned shape pixels are the document's).
|
||||
fn entry_bytes(parts: &(Surface, Surface)) -> usize {
|
||||
[&parts.0, &parts.1].iter().map(|s| s.tiles().map(|(_, t)| t.bytes().len()).sum::<usize>()).sum()
|
||||
[&parts.0, &parts.1].iter().map(|s| s.tiles().map(|(_, t)| t.len()).sum::<usize>()).sum()
|
||||
}
|
||||
|
||||
/// Drop every cached split (Edit › Purge › All), returning the parts' pixel bytes. Callers may
|
||||
@@ -49,6 +53,7 @@ pub fn purge_cache() -> usize {
|
||||
/// (Photoshop's rendering, `sh.cache`) reach past our fill, the part the fill doesn't explain is
|
||||
/// stroke: the stroke follows Photoshop's coverage (psd-tools double-stroke-effects).
|
||||
pub fn split(sh: &ShapeLayer, canvas: Rect, depth: SampleType) -> Option<(Surface, Surface)> {
|
||||
let generation = photocraft_raster::spill::read_error_generation();
|
||||
sh.stroke.as_ref()?;
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
(canvas.x0, canvas.y0, canvas.x1, canvas.y1).hash(&mut h);
|
||||
@@ -78,6 +83,9 @@ pub fn split(sh: &ShapeLayer, canvas: Rect, depth: SampleType) -> Option<(Surfac
|
||||
if let Some(cache) = &sh.cache {
|
||||
fit_stroke(&parts.0, &mut parts.1, cache, canvas);
|
||||
}
|
||||
if photocraft_raster::spill::check_since(generation).is_err() || sh.cache.as_ref().is_some_and(|c| c.scratch_health().is_err()) {
|
||||
return Some(parts);
|
||||
}
|
||||
let mut c = cache().lock().unwrap_or_else(|e| e.into_inner());
|
||||
// Least recently used first, until the new entry fits both the count and the byte budget.
|
||||
let new_bytes = entry_bytes(&parts);
|
||||
|
||||
@@ -115,7 +115,7 @@ fn soft_shape_matches_an_equal_vector_mask() {
|
||||
fn soft_shape_keeps_its_stroke_crisp() {
|
||||
let stroke = ShapeStroke { width: 4.0, paint: Fill::Solid(Color::rgb(1.0, 0.0, 0.0)), ..ShapeStroke::default() };
|
||||
let d = shape_doc(SampleType::U8, 0.5, 10.0, Some(stroke));
|
||||
let px = render(&d, Rect::new(0, 100, 200, 101)).px;
|
||||
let px = render(&d, Rect::new(0, 100, 200, 101)).into_px();
|
||||
// Centred 4 px stroke over x = 48..52: opaque red.
|
||||
for x in [48, 49, 50, 51] {
|
||||
let p = px[x];
|
||||
|
||||
+10
-10
@@ -3001,7 +3001,7 @@ fn levels_steps_match_photoshop() {
|
||||
let px = xs.iter().map(|&x| f32::from(x) / 255.0).map(|v| [v, v, v, 1.0]).collect();
|
||||
let mut b = Buffer { rect: Rect::new(0, 0, xs.len() as i32, 1), px };
|
||||
adjust::apply_depth(adj, &mut b, adjust::Transfer::Srgb, Some(SampleType::U8));
|
||||
b.px
|
||||
b.into_px()
|
||||
};
|
||||
let rgb = levels(ch(0.0, 213.0, 1.22, 48.0, 255.0), [ch(0.0, 255.0, 0.47, 0.0, 194.0), id.clone(), ch(61.0, 255.0, 0.47, 0.0, 213.0)]);
|
||||
let xs = [2, 8, 9, 15, 16, 43, 45, 48, 55, 61, 72, 79, 84, 89, 90, 101, 106, 120, 150, 180, 200];
|
||||
@@ -3364,7 +3364,7 @@ fn render_bands_cover_the_rect_in_order() {
|
||||
render_bands(&d, r, 100, |b| -> Result<(), ()> {
|
||||
assert_eq!((b.rect.x0, b.rect.x1), (r.x0, r.x1));
|
||||
rows.push((b.rect.y0, b.rect.y1));
|
||||
px.extend(b.px);
|
||||
px.extend(b.into_px());
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
@@ -4469,13 +4469,13 @@ fn dissolve_adjustment_resolves_opacity_to_binary_coverage() {
|
||||
layer.blend = BlendMode::Dissolve;
|
||||
layer.opacity = 0.5;
|
||||
d.layers.push(layer);
|
||||
let pixels = render(&d, d.bounds()).px;
|
||||
let pixels = render(&d, d.bounds()).into_px();
|
||||
let black = pixels.iter().filter(|p| p[0] < 0.01).count();
|
||||
let white = pixels.iter().filter(|p| p[0] > 0.99).count();
|
||||
assert!(black > 0 && white > 0, "Dissolve must select both original and adjusted pixels");
|
||||
assert_eq!(black + white, pixels.len(), "no intermediate gray pixels");
|
||||
d.layers.last_mut().unwrap().blend = BlendMode::Normal;
|
||||
let continuous = render(&d, d.bounds()).px;
|
||||
let continuous = render(&d, d.bounds()).into_px();
|
||||
assert!(continuous.iter().all(|p| (p[0] - 0.5).abs() < E), "Normal remains continuously interpolated");
|
||||
}
|
||||
|
||||
@@ -4486,14 +4486,14 @@ fn clipped_dissolve_fill_matches_opaque_unclipped_coverage() {
|
||||
fill.blend = BlendMode::Dissolve;
|
||||
fill.opacity = 0.5;
|
||||
d.layers.push(fill);
|
||||
let control = render(&d, d.bounds()).px;
|
||||
let control = render(&d, d.bounds()).into_px();
|
||||
d.layers.last_mut().unwrap().clipped = true;
|
||||
let clipped = render(&d, d.bounds()).px;
|
||||
let clipped = render(&d, d.bounds()).into_px();
|
||||
assert_eq!(clipped, control, "clipping over a fully opaque base must preserve dissolve pixels");
|
||||
let black = clipped.iter().filter(|p| p[0] < 0.01).count();
|
||||
assert!(black > 0 && black < clipped.len());
|
||||
d.layers.last_mut().unwrap().blend = BlendMode::Normal;
|
||||
let continuous = render(&d, d.bounds()).px;
|
||||
let continuous = render(&d, d.bounds()).into_px();
|
||||
assert!(continuous.iter().all(|p| (p[0] - 0.5).abs() < E));
|
||||
}
|
||||
|
||||
@@ -4504,17 +4504,17 @@ fn dissolve_color_overlay_preserves_binary_layer_coverage() {
|
||||
layer.blend = BlendMode::Dissolve;
|
||||
layer.opacity = 0.5;
|
||||
d.layers.push(layer);
|
||||
let control = render(&d, d.bounds()).px;
|
||||
let control = render(&d, d.bounds()).into_px();
|
||||
d.layers
|
||||
.last_mut()
|
||||
.unwrap()
|
||||
.effects
|
||||
.items
|
||||
.push(photocraft_doc::Effect::ColorOverlay { common: photocraft_doc::FxCommon::new(BlendMode::Normal, 1.0), color: Color::BLACK });
|
||||
let styled = render(&d, d.bounds()).px;
|
||||
let styled = render(&d, d.bounds()).into_px();
|
||||
assert_eq!(styled, control, "an identity overlay should not turn dissolve pixels gray");
|
||||
d.layers.last_mut().unwrap().blend = BlendMode::Normal;
|
||||
let continuous = render(&d, d.bounds()).px;
|
||||
let continuous = render(&d, d.bounds()).into_px();
|
||||
assert!(continuous.iter().all(|p| (p[0] - 0.5).abs() < E));
|
||||
}
|
||||
|
||||
|
||||
@@ -85,7 +85,8 @@ impl<'a> UniformAreas<'a> {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
let bpp = s.default_bytes().len().max(1);
|
||||
let mut px = t.bytes().chunks_exact(bpp);
|
||||
let bytes = t.bytes();
|
||||
let mut px = bytes.chunks_exact(bpp);
|
||||
let v = px.next().filter(|p0| px.all(|p| p == *p0)).map(Box::from);
|
||||
self.tile_values.lock().unwrap_or_else(PoisonError::into_inner).insert(id, v.clone());
|
||||
v
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
//! Bounded, cancellable viewport pages. Reduction is globally aligned, so independently
|
||||
//! rendered pages have exactly the same pixel footprints at their shared edges.
|
||||
use crate::{Buffer, render, render_bands};
|
||||
use photocraft_doc::Document;
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_raster::{Interrupt, Surface};
|
||||
|
||||
/// A quick, explicitly provisional reduction. Only the requested source rows are read; the
|
||||
/// exact composite below replaces it after visible pages have first become available.
|
||||
pub fn preview_page(doc: &Document, source: Rect, factor: u32, interrupt: Interrupt<'_>) -> Result<Buffer, String> {
|
||||
if factor <= 1 || !crate::proxy::proxy_faithful(doc) {
|
||||
return render_page(doc, source, factor, interrupt);
|
||||
}
|
||||
let k = i32::try_from(factor).ok().filter(|k| *k > 0 && *k <= 1 << 20).ok_or("invalid viewport reduction")?;
|
||||
let source = source.intersect(&doc.bounds());
|
||||
if source.is_empty() {
|
||||
return Ok(Buffer::transparent(Rect::EMPTY));
|
||||
}
|
||||
let rect = Rect::new(
|
||||
source.x0 / k,
|
||||
source.y0 / k,
|
||||
source.x1.div_euclid(k) + i32::from(source.x1.rem_euclid(k) != 0),
|
||||
source.y1.div_euclid(k) + i32::from(source.y1.rem_euclid(k) != 0),
|
||||
);
|
||||
if rect.width() > 1024 || rect.height() > 1024 {
|
||||
return Err("viewport page is too large".into());
|
||||
}
|
||||
fn shrink(s: &Surface, r: Rect, k: i32, i: Interrupt<'_>) -> Result<Surface, String> {
|
||||
let mut out = Surface::with_default(s.format(), &s.default_pixel());
|
||||
let bpp = s.format().bytes_per_pixel();
|
||||
for y in r.y0..r.y1 {
|
||||
i.check().map_err(|e| e.to_string())?;
|
||||
let sy = y.saturating_mul(k).saturating_add(k / 2);
|
||||
let row = s.to_interleaved(Rect::new(r.x0.saturating_mul(k), sy, r.x1.saturating_mul(k), sy.saturating_add(1)));
|
||||
let mut reduced = Vec::with_capacity(r.width() as usize * bpp);
|
||||
for x in 0..r.width() as usize {
|
||||
let offset = (x * k as usize + k as usize / 2) * bpp;
|
||||
reduced.extend_from_slice(row.get(offset..offset + bpp).ok_or("invalid viewport sample")?);
|
||||
}
|
||||
out.write_interleaved(Rect::new(r.x0, y, r.x1, y + 1), &reduced);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
fn layer(l: &mut photocraft_doc::Layer, r: Rect, k: i32, i: Interrupt<'_>, depth: usize) -> Result<(), String> {
|
||||
if depth > 256 {
|
||||
return Err("viewport layer nesting limit exceeded".into());
|
||||
}
|
||||
if let Some(m) = &mut l.mask {
|
||||
m.surface = shrink(&m.surface, r, k, i)?;
|
||||
}
|
||||
if let Some(c) = &mut l.fill_cache {
|
||||
c.surface = shrink(&c.surface, r, k, i)?;
|
||||
}
|
||||
use photocraft_doc::LayerContent;
|
||||
match &mut l.content {
|
||||
LayerContent::Raster(s) => *s = shrink(s, r, k, i)?,
|
||||
LayerContent::Group(g) => {
|
||||
if let Some(a) = &mut g.artboard {
|
||||
a.rect = Rect::new(a.rect.x0.div_euclid(k), a.rect.y0.div_euclid(k), a.rect.x1.div_euclid(k), a.rect.y1.div_euclid(k));
|
||||
}
|
||||
for c in &mut g.children {
|
||||
layer(c, r, k, i, depth + 1)?;
|
||||
}
|
||||
}
|
||||
LayerContent::Text(t) => {
|
||||
if let Some(s) = &mut t.cache {
|
||||
*s = shrink(s, r, k, i)?;
|
||||
}
|
||||
}
|
||||
LayerContent::Shape(t) => {
|
||||
if let Some(s) = &mut t.cache {
|
||||
*s = shrink(s, r, k, i)?;
|
||||
}
|
||||
}
|
||||
LayerContent::Smart(t) => {
|
||||
if let Some(s) = &mut t.cache {
|
||||
*s = shrink(s, r, k, i)?;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
let mut p = doc.clone();
|
||||
p.size = photocraft_doc::Size::new(doc.size.width.div_ceil(factor), doc.size.height.div_ceil(factor));
|
||||
for l in &mut p.layers {
|
||||
layer(l, rect, k, interrupt, 0)?;
|
||||
}
|
||||
for c in &mut p.channels {
|
||||
c.surface = shrink(&c.surface, rect, k, interrupt)?;
|
||||
}
|
||||
interrupt.check().map_err(|e| e.to_string())?;
|
||||
Ok(render(&p, rect))
|
||||
}
|
||||
|
||||
pub fn render_page(doc: &Document, source: Rect, factor: u32, interrupt: Interrupt<'_>) -> Result<Buffer, String> {
|
||||
interrupt.check().map_err(|e| e.to_string())?;
|
||||
let source = source.intersect(&doc.bounds());
|
||||
if source.is_empty() {
|
||||
return Ok(Buffer::transparent(Rect::EMPTY));
|
||||
}
|
||||
let k = i32::try_from(factor).ok().filter(|k| *k > 0 && *k <= 1 << 20).ok_or("invalid viewport reduction")?;
|
||||
let out = Rect::new(
|
||||
source.x0.div_euclid(k),
|
||||
source.y0.div_euclid(k),
|
||||
source.x1.div_euclid(k) + i32::from(source.x1.rem_euclid(k) != 0),
|
||||
source.y1.div_euclid(k) + i32::from(source.y1.rem_euclid(k) != 0),
|
||||
);
|
||||
if out.width() > 1024 || out.height() > 1024 {
|
||||
return Err("viewport page is too large".into());
|
||||
}
|
||||
if k == 1 {
|
||||
return Ok(render(doc, source));
|
||||
}
|
||||
if source.x0.rem_euclid(k) != 0 || source.y0.rem_euclid(k) != 0 {
|
||||
return average_page(doc, source, k, out, interrupt);
|
||||
}
|
||||
// The reduced-render path the CPU canvas and navigator use: one-colour document tiles are
|
||||
// composited once instead of per pixel, and the rest in parallel tiles, then area-averaged
|
||||
// into cells aligned to multiples of `k` (the same cells as [`average_page`]).
|
||||
let stats = &mut crate::reduced::ReducedStats::default();
|
||||
let mut buf = crate::reduced::render_reduced_cells(doc, source, crate::reduced::Cells::Factor(factor), None, 128, true, stats, interrupt)?;
|
||||
if buf.rect.width() != out.width() || buf.rect.height() != out.height() {
|
||||
return Err("invalid viewport reduction".into());
|
||||
}
|
||||
buf.rect = out;
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
/// [`render_page`] by compositing every source pixel and averaging each `k`×`k` footprint (also
|
||||
/// the reference its reduced path is tested against).
|
||||
fn average_page(doc: &Document, source: Rect, k: i32, out: Rect, interrupt: Interrupt<'_>) -> Result<Buffer, String> {
|
||||
let factor = k.unsigned_abs();
|
||||
let mut result = Buffer::transparent(out);
|
||||
render_bands(doc, source, 128, |band| -> Result<(), String> {
|
||||
interrupt.check().map_err(|e| e.to_string())?;
|
||||
for (i, p) in band.px.iter().enumerate() {
|
||||
let x = band.rect.x0 + (i % band.rect.width() as usize) as i32;
|
||||
let y = band.rect.y0 + (i / band.rect.width() as usize) as i32;
|
||||
let offset = (y.div_euclid(k) - out.y0) as usize * out.width() as usize + (x.div_euclid(k) - out.x0) as usize;
|
||||
let q = result.px.get_mut(offset).ok_or("invalid viewport accumulator")?;
|
||||
for (a, b) in q.iter_mut().take(3).zip(p.iter()) {
|
||||
*a += *b * p.get(3).copied().unwrap_or(0.0);
|
||||
}
|
||||
if let Some(a) = q.get_mut(3) {
|
||||
*a += p.get(3).copied().unwrap_or(0.0);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
for (i, p) in result.px.iter_mut().enumerate() {
|
||||
let x = out.x0 + (i % out.width() as usize) as i32;
|
||||
let y = out.y0 + (i / out.width() as usize) as i32;
|
||||
let footprint = Rect::from_xywh(x.saturating_mul(k), y.saturating_mul(k), factor, factor).intersect(&source);
|
||||
let alpha = p.get(3).copied().unwrap_or(0.0);
|
||||
if alpha > 0.0 {
|
||||
for c in p.iter_mut().take(3) {
|
||||
*c /= alpha;
|
||||
}
|
||||
if let Some(a) = p.get_mut(3) {
|
||||
*a /= (u64::from(footprint.width()) * u64::from(footprint.height())).max(1) as f32;
|
||||
}
|
||||
}
|
||||
}
|
||||
interrupt.check().map_err(|e| e.to_string())?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use photocraft_color::{Color, ColorMode, SampleType};
|
||||
use photocraft_doc::Size;
|
||||
#[test]
|
||||
fn high_zoom_is_original_resolution_at_every_depth() {
|
||||
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
let r = Rect::new(65001, 22003, 65513, 22515);
|
||||
let mut d = Document::new("large", Size::new(86400, 43200), ColorMode::Rgb, depth);
|
||||
let mut s = photocraft_raster::Surface::new(d.pixel_format());
|
||||
s.fill_rect(r, &[1.0; 4]);
|
||||
d.layers.push(photocraft_doc::Layer::new("region", photocraft_doc::LayerContent::Raster(s)));
|
||||
let p = render_page(&d, r, 1, Interrupt::NONE).unwrap();
|
||||
assert_eq!(p.rect, r);
|
||||
assert_eq!(p.px, render(&d, r).px);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn independent_pages_match_whole_reduction_and_odd_edges() {
|
||||
let d = Document::with_background("d", Size::new(53, 31), ColorMode::Rgb, SampleType::U8, Color::WHITE);
|
||||
let whole = render_page(&d, d.bounds(), 4, Interrupt::NONE).unwrap();
|
||||
for r in [Rect::new(0, 0, 28, 31), Rect::new(28, 0, 53, 31)] {
|
||||
let p = render_page(&d, r, 4, Interrupt::NONE).unwrap();
|
||||
for y in p.rect.y0..p.rect.y1 {
|
||||
for x in p.rect.x0..p.rect.x1 {
|
||||
assert_eq!(
|
||||
p.px[((y - p.rect.y0) * p.rect.width() as i32 + x - p.rect.x0) as usize],
|
||||
whole.px[(y * whole.rect.width() as i32 + x) as usize]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A white document with a noisy, partly transparent layer and a multiplied solid over one-colour
|
||||
/// tiles, so the reduced path takes both its one-colour and per-pixel branches.
|
||||
fn busy(depth: SampleType) -> Document {
|
||||
let mut d = Document::with_background("busy", Size::new(1100, 700), ColorMode::Rgb, depth, Color::WHITE);
|
||||
let mut s = photocraft_raster::Surface::new(d.pixel_format());
|
||||
let mut seed = 0x2545_f491_u32;
|
||||
let mut next = || {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 17;
|
||||
seed ^= seed << 5;
|
||||
(seed % 1000) as f32 / 999.0
|
||||
};
|
||||
for y in (100..530).step_by(3) {
|
||||
for x in (190..905).step_by(3) {
|
||||
let px = [next(), next(), next(), next()];
|
||||
s.fill_rect(Rect::from_xywh(x, y, 3, 3), &px);
|
||||
}
|
||||
}
|
||||
d.layers.push(photocraft_doc::Layer::new("noise", photocraft_doc::LayerContent::Raster(s)));
|
||||
let mut s = photocraft_raster::Surface::new(d.pixel_format());
|
||||
s.fill_rect(Rect::new(400, 0, 1100, 650), &[0.2, 0.5, 0.9, 1.0]);
|
||||
let mut top = photocraft_doc::Layer::new("tint", photocraft_doc::LayerContent::Raster(s));
|
||||
top.opacity = 0.6;
|
||||
top.blend = photocraft_color::blend::BlendMode::Multiply;
|
||||
d.layers.push(top);
|
||||
d
|
||||
}
|
||||
#[test]
|
||||
fn reduced_pages_match_averaging_every_composited_pixel() {
|
||||
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
let d = busy(depth);
|
||||
for k in [2u32, 4, 16] {
|
||||
let ki = k as i32;
|
||||
for source in [d.bounds(), Rect::new(512, 256, 1024, 700), Rect::new(ki * 13, ki * 5, 1100, ki * 37)] {
|
||||
let source = source.intersect(&d.bounds());
|
||||
let out = Rect::new(source.x0 / ki, source.y0 / ki, (source.x1 + ki - 1) / ki, (source.y1 + ki - 1) / ki);
|
||||
let reference = average_page(&d, source, ki, out, Interrupt::NONE).unwrap();
|
||||
let got = render_page(&d, source, k, Interrupt::NONE).unwrap();
|
||||
assert_eq!(got.rect, reference.rect, "{depth:?} k={k} {source:?}");
|
||||
let worst = got.px.iter().zip(&reference.px).flat_map(|(a, b)| a.iter().zip(b).map(|(a, b)| (a - b).abs())).fold(0.0f32, f32::max);
|
||||
assert!(worst < 1e-4, "{depth:?} k={k} {source:?}: differs by {worst}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn rejects_invalid_sizes_and_honors_cancellation() {
|
||||
let d = Document::new("d", Size::new(86400, 43200), ColorMode::Rgb, SampleType::U8);
|
||||
assert!(render_page(&d, d.bounds(), 1, Interrupt::NONE).is_err());
|
||||
assert!(render_page(&d, d.bounds(), 0, Interrupt::NONE).is_err());
|
||||
assert!(render_page(&d, Rect::new(0, 0, 512, 512), 1, Interrupt::cancel_only(&|| true)).is_err());
|
||||
// Cancelled after the first band of the reduced path.
|
||||
let checks = std::sync::atomic::AtomicUsize::new(0);
|
||||
let later = || checks.fetch_add(1, std::sync::atomic::Ordering::Relaxed) >= 2;
|
||||
assert!(render_page(&d, Rect::new(0, 0, 4096, 4096), 8, Interrupt::cancel_only(&later)).is_err());
|
||||
assert!(checks.load(std::sync::atomic::Ordering::Relaxed) >= 3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
use photocraft_compose::Buffer;
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_raster::scratch;
|
||||
|
||||
/// Restores idle release even when an assertion fails.
|
||||
struct IdleReleaseOff;
|
||||
impl Drop for IdleReleaseOff {
|
||||
fn drop(&mut self) {
|
||||
scratch::release();
|
||||
scratch::set_idle_release(true);
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs in its own binary so other compositor tests cannot consume the pooled allocation.
|
||||
#[test]
|
||||
fn dropped_buffer_storage_is_reused() {
|
||||
scratch::set_idle_release(false);
|
||||
let _idle = IdleReleaseOff;
|
||||
scratch::release();
|
||||
let rect = Rect::from_xywh(0, 0, 256, 256);
|
||||
let buf = Buffer::transparent(rect);
|
||||
let ptr = buf.px.as_ptr();
|
||||
drop(buf);
|
||||
let reused = Buffer::transparent(rect);
|
||||
assert_eq!(reused.px.as_ptr(), ptr);
|
||||
assert!(reused.px.iter().all(|p| *p == [0.0; 4]));
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
#![cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
use photocraft_color::{ColorMode, PixelFormat, SampleType};
|
||||
use photocraft_doc::{Document, Effect, Layer};
|
||||
use photocraft_geom::{Rect, Size};
|
||||
use photocraft_raster::spill;
|
||||
use std::io::{Read, Seek, SeekFrom, Write};
|
||||
|
||||
#[test]
|
||||
fn failed_effect_build_is_not_reused_after_original_pixels_recover() {
|
||||
let dir = std::env::temp_dir().join(format!("photocraft-compose-scratch-retry-{}", std::process::id()));
|
||||
spill::configure(Some(&dir), 1).unwrap();
|
||||
let mut doc = Document::new("Shadow", Size::new(64, 64), ColorMode::Rgb, SampleType::U8);
|
||||
let mut background = Layer::raster("Background", PixelFormat::RGBA8);
|
||||
background.surface_mut().unwrap().fill_rect(Rect::from_xywh(0, 0, 64, 64), &[1.0; 4]);
|
||||
let mut foreground = Layer::raster("Shadow", PixelFormat::RGBA8);
|
||||
foreground.surface_mut().unwrap().fill_rect(Rect::new(16, 16, 48, 48), &[1.0, 0.0, 0.0, 1.0]);
|
||||
foreground.effects.items.push(Effect::default_drop_shadow());
|
||||
doc.layers.extend([background, foreground]);
|
||||
let expected = photocraft_compose::flatten(&doc).px.clone();
|
||||
// Keep the known-good bounds, but force a new effect-map build from the damaged source.
|
||||
photocraft_compose::purge_effect_cache();
|
||||
spill::settle();
|
||||
assert_eq!(spill::stats().resident_bytes, 0);
|
||||
let mut file = spill::scratch_handle_for_tests().unwrap();
|
||||
file.seek(SeekFrom::Start(0)).unwrap();
|
||||
let mut good = Vec::new();
|
||||
file.read_to_end(&mut good).unwrap();
|
||||
file.seek(SeekFrom::Start(0)).unwrap();
|
||||
file.write_all(&vec![0; good.len()]).unwrap();
|
||||
file.sync_all().unwrap();
|
||||
drop(photocraft_compose::flatten(&doc));
|
||||
assert!(spill::check_integrity().is_err());
|
||||
|
||||
file.seek(SeekFrom::Start(0)).unwrap();
|
||||
file.write_all(&good).unwrap();
|
||||
file.sync_all().unwrap();
|
||||
for layer in &doc.layers {
|
||||
for (_, tile) in layer.surface().unwrap().tiles() {
|
||||
tile.try_bytes().unwrap();
|
||||
}
|
||||
}
|
||||
assert!(spill::check_integrity().is_ok());
|
||||
assert_eq!(photocraft_compose::flatten(&doc).px, expected);
|
||||
photocraft_compose::purge_effect_cache();
|
||||
drop((doc, file));
|
||||
spill::configure(None, 0).unwrap();
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
@@ -1064,6 +1064,58 @@ impl Document {
|
||||
max
|
||||
}
|
||||
|
||||
/// Every pixel surface the document holds: layer pixels and caches, masks, smart filter
|
||||
/// masks, video frames (including the originals), channels, the quick mask, patterns and
|
||||
/// the selection. History snapshots and saves cover exactly these tiles.
|
||||
pub fn for_each_surface(&self, mut visit: impl FnMut(&Surface)) {
|
||||
for (_, _, layer) in self.walk() {
|
||||
if let Some(surface) = layer.surface() {
|
||||
visit(surface);
|
||||
}
|
||||
if let Some(mask) = &layer.mask {
|
||||
visit(&mask.surface);
|
||||
}
|
||||
if let Some(cache) = &layer.fill_cache {
|
||||
visit(&cache.surface);
|
||||
}
|
||||
if let LayerContent::Smart(smart) = &layer.content
|
||||
&& let Some(mask) = &smart.filter_mask
|
||||
{
|
||||
visit(&mask.surface);
|
||||
}
|
||||
if let Some(video) = &layer.video {
|
||||
for frame in video.frames.iter().chain(video.original_frames.iter().flatten()) {
|
||||
visit(frame);
|
||||
}
|
||||
}
|
||||
}
|
||||
for channel in self.channels.iter().chain(&self.quick_mask) {
|
||||
visit(&channel.surface);
|
||||
}
|
||||
for pattern in &self.patterns {
|
||||
visit(&pattern.surface);
|
||||
}
|
||||
if let Some(selection) = &self.selection {
|
||||
visit(selection);
|
||||
}
|
||||
}
|
||||
|
||||
/// `Err` if any of this document's tiles is unreadable on the scratch disk. Edits, saves
|
||||
/// and exports of this document check it; other documents are unaffected. Free when no
|
||||
/// tile in the process has failed.
|
||||
pub fn scratch_health(&self) -> Result<(), String> {
|
||||
if !photocraft_raster::spill::any_failed() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut health = Ok(());
|
||||
self.for_each_surface(|surface| {
|
||||
if health.is_ok() {
|
||||
health = surface.scratch_health();
|
||||
}
|
||||
});
|
||||
health
|
||||
}
|
||||
|
||||
/// Depth-first walk yielding `(path, depth, layer)` bottom-to-top.
|
||||
pub fn walk(&self) -> Vec<(LayerPath, usize, &Layer)> {
|
||||
fn rec<'a>(layers: &'a [Layer], prefix: &mut LayerPath, out: &mut Vec<(LayerPath, usize, &'a Layer)>) {
|
||||
|
||||
@@ -61,6 +61,7 @@ photocraft-ipp = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
photocraft-affinity = { workspace = true, features = ["synth"] }
|
||||
photocraft-io = { workspace = true, features = ["testgen"] }
|
||||
# Synthetic camera raw files for the open tests.
|
||||
photocraft-raw = { workspace = true, features = ["testgen"] }
|
||||
photocraft-testkit = { workspace = true }
|
||||
|
||||
@@ -25,7 +25,7 @@ fn main() -> Result<(), Box<dyn Error>> {
|
||||
let path = std::env::args().nth(1).ok_or("provide a local image path")?;
|
||||
let bytes = photocraft_format::read::read_file(std::path::Path::new(&path))?;
|
||||
let doc = photocraft_io::import(&path, &bytes)?.document;
|
||||
let px = photocraft_compose::flatten(&doc).px;
|
||||
let px = photocraft_compose::flatten(&doc).into_px();
|
||||
println!("{}", json!({"width":doc.size.width,"height":doc.size.height,"preview_factor":1}));
|
||||
for count in [8usize, 16, 32, 64, 128, 256] {
|
||||
for run in 0..4 {
|
||||
|
||||
@@ -85,7 +85,7 @@ pub(crate) fn copy(s: &mut Session, p: &Value) -> Result<Option<Value>> {
|
||||
if bounds.is_empty() {
|
||||
return Err(EngineError::Other("Could not copy: the selected area is empty".into()));
|
||||
}
|
||||
s.clipboard = Some(Clip { components: None, surface, bounds, layers: None, resolution: Some(doc.resolution_dpi), source: Some(doc.id) });
|
||||
s.clipboard = Some(Clip { svg: None, components: None, surface, bounds, layers: None, resolution: Some(doc.resolution_dpi), source: Some(doc.id) });
|
||||
Ok(Some(json!({"bounds": [bounds.x0, bounds.y0, bounds.width(), bounds.height()], "channel": k})))
|
||||
}
|
||||
|
||||
@@ -190,6 +190,7 @@ pub(crate) fn copy_colors(s: &mut Session, p: &Value, colors: crate::color_chann
|
||||
surface.try_write_region(r, &out)?;
|
||||
}
|
||||
s.clipboard = Some(Clip {
|
||||
svg: None,
|
||||
surface,
|
||||
bounds: area,
|
||||
layers: None,
|
||||
|
||||
+44
-12
@@ -103,6 +103,7 @@ pub mod layer_menu_cmds;
|
||||
pub mod layer_multi_cmds;
|
||||
pub mod layer_nav_cmds;
|
||||
mod layer_params;
|
||||
mod layer_references;
|
||||
pub mod layer_xmp_cmds;
|
||||
pub mod lens_cmds;
|
||||
pub mod magnetic_cmds;
|
||||
@@ -152,6 +153,7 @@ pub mod type_spell_cmds;
|
||||
pub mod type_styles_cmds;
|
||||
pub mod updates_cmds;
|
||||
pub(super) mod variables_cmds;
|
||||
mod vector_clipboard;
|
||||
pub mod vector_cmds;
|
||||
pub(super) mod video_cmds;
|
||||
pub mod vp_cmds;
|
||||
@@ -166,6 +168,7 @@ pub(crate) use enabled::{always, check_all_unlocked, check_pixels_unlocked, has_
|
||||
use enabled::{can_redo, can_undo, has_doc, has_layer, has_pixel_layer, has_pixel_or_channel, has_selection};
|
||||
pub use layer_params::blend_from_str;
|
||||
pub(crate) use layer_params::{NEW_LAYER_PATH, explicit_layer, layer_listing, layer_panel_option, layer_param, mask_at_depth, mask_new_layer, selection_mask};
|
||||
pub(crate) use layer_references::{recorded_drop, recorded_layer};
|
||||
use ops::{arrange, merge_group, set_mask, set_mask_and_deselect};
|
||||
pub(crate) use ops::{layer_copy, translate_layer};
|
||||
use params::{bad, set_clipped};
|
||||
@@ -239,6 +242,7 @@ fn registry() -> &'static Registry {
|
||||
// ---------- the table ----------
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
/// Register the core file, edit, layer and document commands.
|
||||
fn build() -> Vec<CommandSpec> {
|
||||
let mut v = vec![
|
||||
// File
|
||||
@@ -305,11 +309,30 @@ fn build() -> Vec<CommandSpec> {
|
||||
Ok(json!({ "document": i }))
|
||||
}
|
||||
),
|
||||
cmd!("file.close", "Close", ["File"], Some("Cmd+W"), r##"{"document":index?}"##, has_doc, |s, p| {
|
||||
let i = p.get("document").and_then(Value::as_u64).map(|v| v as usize).or(s.active_index()).ok_or(EngineError::NoDocument)?;
|
||||
s.close(i).ok_or(EngineError::NoDocument)?;
|
||||
Ok(Value::Null)
|
||||
}),
|
||||
cmd!(
|
||||
"file.close",
|
||||
"Close",
|
||||
["File"],
|
||||
Some("Cmd+W"),
|
||||
r##"{"document":index?,"save":"discard|ask"|false="discard"} (false means discard; ask refuses to close a changed document in headless playback)"##,
|
||||
has_doc,
|
||||
|s, p| {
|
||||
let i = p.get("document").and_then(Value::as_u64).map(|v| v as usize).or(s.active_index()).ok_or(EngineError::NoDocument)?;
|
||||
match p.get("save").filter(|v| !v.is_null()) {
|
||||
Some(v) if v == "ask" => {
|
||||
if s.documents().get(i).ok_or(EngineError::NoDocument)?.is_dirty() {
|
||||
return Err(bad("file.close", "the document has unsaved changes; save it or explicitly discard them"));
|
||||
}
|
||||
}
|
||||
// `false` (don't save) is the older spelling of discard.
|
||||
Some(Value::Bool(false)) => {}
|
||||
Some(v) if v != "discard" => return Err(bad("file.close", "`save` must be ask, discard or false")),
|
||||
_ => {}
|
||||
}
|
||||
s.close(i).ok_or(EngineError::NoDocument)?;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
),
|
||||
// Edit
|
||||
cmd!("edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, |s, _| Ok(json!(s.undo()))),
|
||||
cmd!("edit.redo", "Redo", ["Edit"], Some("Cmd+Shift+Z"), "{}", can_redo, |s, _| Ok(json!(s.redo()))),
|
||||
@@ -318,7 +341,7 @@ fn build() -> Vec<CommandSpec> {
|
||||
"Fill…",
|
||||
["Edit"],
|
||||
Some("Shift+F5"),
|
||||
r##"{"contents":"foreground|background|color|contentAware|pattern|history|black|gray|white"="color","color":"#rrggbb|[r,g,b,a]"=foreground (contents=color),"pattern":id|name (contents=pattern),"scale":%=100,"angle":deg,"state":row?,"snapshot":id? (contents=history: a History panel row as history.setSource, redo rows last, or a snapshot; default the History Brush source),"colorAdaptation":bool=true (contents=contentAware),"mode":"normal|multiply|…"="normal","opacity":0..100=100,"preserveTransparency":bool=false,"target":"pixels"|"mask"|"filterMask"|"quickMask"|{"channel":i}?}"##,
|
||||
r##"{"contents":"foreground|background|color|contentAware|pattern|history|black|gray|white"="color","color":"#rrggbb|[r,g,b,a]"=foreground (contents=color),"pattern":id|name (contents=pattern),"scale":%=100,"angle":deg,"state":row?,"snapshot":id? (contents=history: a History panel row as history.setSource, redo rows last, or a snapshot; default the History Brush source),"colorAdaptation":bool=true,"scaleAdaptation":bool=false,"mirror":bool=false,"rotationAdaptation":bool=false (contents=contentAware: enable quarter-turn source candidates within the sampling budget),"mode":"normal|multiply|…"="normal","opacity":0..100=100,"preserveTransparency":bool=false,"target":"pixels"|"mask"|"filterMask"|"quickMask"|{"channel":i}?}"##,
|
||||
has_pixel_or_channel,
|
||||
crate::fill_cmds::fill
|
||||
),
|
||||
@@ -586,7 +609,7 @@ fn build() -> Vec<CommandSpec> {
|
||||
"Select Layer",
|
||||
[],
|
||||
None,
|
||||
r##"{"layer":id|str | "name":str,"occurrence":index?,"mode":"replace|toggle|range|add|remove"="replace"} (name occurrence: zero-based document walk order; required for duplicate names; toggle = ⌘-click, range = ⇧-click, remove = deselect it, keeping at least one)"##,
|
||||
r##"{"layer":id|str|{"index":uint}|{"ordinal":"Trgt|Frwr|Bckw|Frnt|Back"} | "name":str,"occurrence":index?,"mode":"replace|toggle|range|add|remove"="replace"} (typed references use the recorded full layer stack; name occurrence: zero-based document walk order; required for duplicate names; toggle = ⌘-click, range = ⇧-click, remove = deselect it, keeping at least one)"##,
|
||||
has_doc,
|
||||
crate::layer_multi_cmds::select
|
||||
),
|
||||
@@ -910,7 +933,7 @@ fn build() -> Vec<CommandSpec> {
|
||||
"Reorder Layer",
|
||||
[],
|
||||
None,
|
||||
r##"{"layer":id?| "layers":[id,…]?, "target":id|name,"targetOccurrence":n? (which of several layers named `target`, in document order),"position":"above|below|into"="above","copy":bool=false} (layers: one undoable move, document order preserved; copy: place duplicates there and leave the layers, as ⌥-dragging a Layers panel row; a recorded action keeps the target by name)"##,
|
||||
r##"{"layer":id|{"index":uint}|{"ordinal":"Trgt|Frwr|Bckw|Frnt|Back"}?| "layers":[id,…]?, "target":id|name|{"index":uint},"targetOccurrence":n? (which of several layers named `target`, in document order),"position":"above|below|into"="above","copy":bool=false} (typed target: pre-move recorded stack boundary, omit position; layers: one undoable move, document order preserved; copy: place duplicates there and leave the layers, as ⌥-dragging a Layers panel row; recorded typed references retain their form)"##,
|
||||
has_layer,
|
||||
crate::layer_multi_cmds::move_to
|
||||
),
|
||||
@@ -1045,7 +1068,7 @@ fn build() -> Vec<CommandSpec> {
|
||||
label: "Activate Document",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r##"{"document":index} or {"offset":int} (the document that many places after the active one in opening order, before it when negative: a Photoshop action's switch to another document) → {document}"##,
|
||||
params: r##"{"document":index|{"index":uint}} or {"offset":int} (numeric document: zero-based tab index; typed index: one-based opening order; offset: places after the active document in opening order, before it when negative) → {document}"##,
|
||||
enabled: has_doc,
|
||||
run: |s, p| {
|
||||
// A null key counts as omitted.
|
||||
@@ -1053,13 +1076,22 @@ fn build() -> Vec<CommandSpec> {
|
||||
let idx = match (given("document"), given("offset")) {
|
||||
(Some(_), Some(_)) => return Err(bad("document.activate", "give `document` or `offset`, not both")),
|
||||
(None, None) => return Err(bad("document.activate", "needs `document` (a tab index) or `offset`")),
|
||||
// Refuse offsets past the opening-order ends rather than wrap, so an action that
|
||||
// copies from one document never silently pastes into the wrong one (#7103).
|
||||
// Recorded offsets wrap in opening order, independently of dragged tabs.
|
||||
(None, Some(o)) => {
|
||||
let o = o.as_i64().ok_or_else(|| bad("document.activate", "`offset` must be an integer"))?;
|
||||
s.document_at_offset(o)?
|
||||
}
|
||||
(Some(d), None) => {
|
||||
if let Some(selector) = d.as_object() {
|
||||
let index = selector
|
||||
.get("index")
|
||||
.filter(|_| selector.len() == 1)
|
||||
.and_then(Value::as_u64)
|
||||
.ok_or_else(|| bad("document.activate", "document reference needs one non-negative index"))?;
|
||||
return s
|
||||
.document_at_index(index)
|
||||
.and_then(|idx| if s.set_active(idx) { Ok(json!({"document": idx})) } else { Err(EngineError::NoDocument) });
|
||||
}
|
||||
let i = d.as_u64().ok_or_else(|| bad("document.activate", "`document` must be a tab index"))?;
|
||||
usize::try_from(i).map_err(|_| bad("document.activate", "`document` out of range"))?
|
||||
}
|
||||
@@ -1184,7 +1216,7 @@ fn build() -> Vec<CommandSpec> {
|
||||
label,
|
||||
menu: &["Layer", "New Adjustment Layer"],
|
||||
shortcut: None,
|
||||
params: Box::leak(format!("{params} + {{{NEW_LAYER_PATH}}}").into_boxed_str()),
|
||||
params: Box::leak(format!(r#"{params} + {{{NEW_LAYER_PATH},"opacity":0..1=1}}"#).into_boxed_str()),
|
||||
enabled: has_doc,
|
||||
run: |s, p| {
|
||||
let kind = p.get("__kind").and_then(Value::as_str).unwrap_or("invert").to_string();
|
||||
|
||||
@@ -137,9 +137,16 @@ pub(crate) fn recorded_params(s: &Session, id: &str, mut p: Value) -> Value {
|
||||
p
|
||||
}
|
||||
|
||||
/// Resolve the recorded visibility target identically in engine and shell playback.
|
||||
/// Resolve the recorded visibility target identically in engine and shell playback, and drop
|
||||
/// the `.atn` event a Filter Gallery step remembers for Save Actions: the command, its journal
|
||||
/// entry (Last Filter), a smart filter and the step's dialog never see it.
|
||||
pub fn playback_params(s: &Session, id: &str, p: &Value) -> Result<Value> {
|
||||
let mut p = p.clone();
|
||||
if id.starts_with("filter.gallery.")
|
||||
&& let Some(object) = p.as_object_mut()
|
||||
{
|
||||
object.remove(crate::atn_cmds::RECORDED_EVENT);
|
||||
}
|
||||
if id == "layer.setProps"
|
||||
&& let Some(target) = p.get("_actionLayer")
|
||||
{
|
||||
|
||||
@@ -114,6 +114,48 @@ fn recorded_eye_visibility_replays_on_another_document_and_duplicate_names() {
|
||||
assert!(s.active().unwrap().doc.layer(photocraft_doc::LayerId(target)).unwrap().visible);
|
||||
}
|
||||
|
||||
/// ⌘-clicking a layer's thumbnail that is not the selected layer records the layer by name (the
|
||||
/// Background as the Background), so the step saves in strict Save Actions and replays on the
|
||||
/// same-named layer of another document.
|
||||
#[test]
|
||||
fn recorded_thumbnail_click_on_another_layer_saves_and_replays_by_name() {
|
||||
use photocraft_geom::Rect;
|
||||
let fixture = |s: &mut Session| {
|
||||
s.execute("file.new", json!({"width": 4, "height": 1})).unwrap();
|
||||
let source = s.execute("layer.new.layer", json!({"name": "Source"})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.edit("Pixels", |doc, _| {
|
||||
doc.layer_mut(photocraft_doc::LayerId(source)).unwrap().surface_mut().unwrap().fill_rect(Rect::new(0, 0, 1, 1), &[1.0, 0.0, 0.0, 1.0]);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("layer.new.layer", json!({"name": "Active"})).unwrap();
|
||||
source
|
||||
};
|
||||
let mut s = Session::new();
|
||||
let source = fixture(&mut s);
|
||||
let background = s.active().unwrap().doc.background().unwrap().id.0;
|
||||
s.execute("actions.record", json!({"name": "Load"})).unwrap();
|
||||
s.execute("select.loadSelection", json!({"channel": "transparency", "layer": source, "operation": "new"})).unwrap();
|
||||
s.execute("select.loadSelection", json!({"channel": "transparency", "layer": background, "operation": "new"})).unwrap();
|
||||
s.execute("actions.stop", json!({})).unwrap();
|
||||
let action = s.actions.list.iter().find(|a| a.name == "Load").unwrap().clone();
|
||||
assert_eq!(
|
||||
action.steps,
|
||||
vec![
|
||||
("select.loadSelection".into(), json!({"channel": "transparency", "layer": {"name": "Source"}, "operation": "new"})),
|
||||
("select.loadSelection".into(), json!({"channel": "transparency", "layer": {"background": true}, "operation": "new"})),
|
||||
]
|
||||
);
|
||||
crate::atn_cmds::export("Set", &[&action], &[], false).unwrap();
|
||||
// Replayed in another document, the first step loads its Source layer.
|
||||
let mut other = Session::new();
|
||||
fixture(&mut other);
|
||||
let (id, params) = &action.steps[0];
|
||||
other.execute(id, params.clone()).unwrap();
|
||||
let doc = &other.active().unwrap().doc;
|
||||
assert_eq!(doc.selection.as_ref().unwrap().read_region(Rect::new(0, 0, 4, 1)), vec![1.0, 0.0, 0.0, 0.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_keeps_calls_and_function_key_and_rejects_bad_names() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -459,9 +459,9 @@ fn stats_pixels(doc: &Document, layer: Option<u64>, use_selection: bool) -> Resu
|
||||
let px: Vec<[f32; 4]> = match layer {
|
||||
Some(id) => {
|
||||
let l = doc.layer(photocraft_doc::LayerId(id)).ok_or(EngineError::NoLayer(photocraft_doc::LayerId(id)))?;
|
||||
photocraft_compose::render_layer(l, area).px
|
||||
photocraft_compose::render_layer(l, area).into_px()
|
||||
}
|
||||
None => photocraft_compose::flatten(doc).px,
|
||||
None => photocraft_compose::flatten(doc).into_px(),
|
||||
};
|
||||
let mask = doc.selection.as_ref().filter(|_| use_selection).map(|sel| photocraft_algo::selection::mask_from_surface(Some(sel), area));
|
||||
Ok((px, mask))
|
||||
@@ -521,7 +521,7 @@ fn hdr_toning(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.edit("HDR Toning", |doc, active| {
|
||||
let buf = photocraft_compose::flatten(doc).over_background([1.0, 1.0, 1.0]);
|
||||
let (w, hgt) = (buf.rect.width() as usize, buf.rect.height() as usize);
|
||||
let mut px = buf.px;
|
||||
let mut px = buf.into_px();
|
||||
tone::hdr_toning(&mut px, w, hgt, &h);
|
||||
let fmt = doc.pixel_format();
|
||||
let data: Vec<f32> = px.iter().flat_map(|q| photocraft_raster::from_rgba(&fmt, *q)).collect();
|
||||
|
||||
@@ -480,7 +480,7 @@ fn smart_session() -> Session {
|
||||
}
|
||||
|
||||
fn flatten(s: &Session) -> Vec<[f32; 4]> {
|
||||
photocraft_compose::flatten(doc(s)).px
|
||||
photocraft_compose::flatten(doc(s)).into_px()
|
||||
}
|
||||
|
||||
fn max_diff(a: &[[f32; 4]], b: &[[f32; 4]]) -> f32 {
|
||||
|
||||
@@ -3,11 +3,20 @@ use super::layer_params::{layer_param, mask_new_layer};
|
||||
use super::{Adjustment, ColorMode, EngineError, Layer, LayerContent, Map, Result, Session, Value, json, pixels};
|
||||
|
||||
/// Add an adjustment layer above the active one; it takes the mask `mask_new_layer` builds from `p` (a path, the selection, or the default white mask).
|
||||
/// Optional opacity is a 0..1 fraction, validated before the creation edit.
|
||||
pub(super) fn new_adjustment(s: &mut Session, adj: Adjustment, p: &Value, cmd: &str) -> Result<Value> {
|
||||
let opacity = match p.get("opacity") {
|
||||
None => 1.0,
|
||||
Some(value) => value
|
||||
.as_f64()
|
||||
.filter(|o| o.is_finite() && (0.0..=1.0).contains(o))
|
||||
.ok_or_else(|| EngineError::BadParams { cmd: cmd.into(), msg: "opacity must be a number in 0..1".into() })? as f32,
|
||||
};
|
||||
let label = format!("New {} Layer", adj.label());
|
||||
let name = adj.label().to_string();
|
||||
let id = s.edit(&label, |doc, active| {
|
||||
let mut l = Layer::new(doc.next_layer_name(&name), LayerContent::Adjustment(adj));
|
||||
l.opacity = opacity;
|
||||
mask_new_layer(doc, &mut l, p, cmd, true)?;
|
||||
let id = crate::layer_place::insert_new(doc, *active, l, crate::layer_place::Place::Auto);
|
||||
*active = Some(id);
|
||||
|
||||
Loaded 100 of 405 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user